Compare commits

...
Author SHA1 Message Date
koka 89db24d7c2 docs: record complete beta.297 deployment
Build Sanctuary / build (push) Canceled after 0s
2026-10-27 05:05:31 +01:00
koka bad2bdb0d1 docs: prepare beta.297 release notes
Build Sanctuary / build (push) Canceled after 0s
2026-10-27 04:56:22 +01:00
koka 098a5aa1bd fix: correct media frame interaction and side geometry
Build Sanctuary / build (push) Canceled after 0s
2026-10-26 18:02:18 +01:00
koka 8b1ae25256 docs: record completed beta.296 media delivery
Build Sanctuary / build (push) Canceled after 0s
2026-10-26 16:58:18 +01:00
koka 9674e7f3c5 Integrate shared media screens and prepare Sanctuary beta.296
Build Sanctuary / build (push) Canceled after 0s
2026-10-26 16:42:47 +01:00
koka 4e00f028ec Merge remote-tracking branch 'origin/main' into codex/media-beta296 2026-10-26 16:28:28 +01:00
koka 584a075663 feat: add free resizable media anchors with native Dream controls 2026-10-26 10:25:57 +01:00
koka 45ea66987f feat: synchronize experimental media and add per-client video acoustics 2026-10-26 08:30:11 +01:00
koka af18f6e014 test: prototype shared Dream playback with two Vulkan clients 2026-10-26 07:23:03 +01:00
koka 34cdcfd00e docs: synchronize README through beta.295
Build Sanctuary / build (push) Canceled after 0s
2026-10-26 06:42:57 +01:00
koka 679c0f8a5f docs: study shared media displays and placement contract 2026-10-26 06:37:12 +01:00
koka c0b6b14e89 docs: record completed beta.295 delivery
Build Sanctuary / build (push) Canceled after 0s
2026-10-26 06:30:17 +01:00
koka 2734c2383a Add the default Sanctuary server and pack favicon (beta.295)
Build Sanctuary / build (push) Canceled after 0s
2026-10-26 06:24:03 +01:00
koka 74dc1265a8 docs: record completed beta.293 delivery
Build Sanctuary / build (push) Canceled after 0s
2026-10-26 04:31:27 +01:00
koka 678d77fb91 Integrate Happy Creepers and prepare the complete beta.293 update
Build Sanctuary / build (push) Canceled after 0s
2026-10-26 04:23:06 +01:00
koka 97c601cd7f Add Sanctuary packwiz branding and optional pack mods (beta.292) 2026-10-26 03:59:53 +01:00
koka a93e261120 feat: import flying ghost whale and native egg (beta.290) 2026-10-26 02:45:21 +01:00
koka 72f1cf50ae docs: record completed beta.289 delivery
Build Sanctuary / build (push) Canceled after 0s
2026-10-26 01:35:04 +01:00
169 changed files with 10068 additions and 52 deletions
+16
View File
@@ -1,3 +1,19 @@
# beta.293 — Happy Creepers, logo packwiz et mods facultatifs
- Intégration de la branche `codex/happy-creepers` avec les baleines beta.290.
- Logo Sanctuary pendant packwiz et outils prêts dans le ZIP Prism.
- Simple Voice Chat, Bobby facultatif, Lithium et Entity Culling épinglés pour 26.3.
- Même canal et même instance Prism, profils client/serveur et sauvegardes conservés.
- [Contrat et reçu de livraison](docs/release-beta293.md).
# beta.290 — Baleine fantôme volante
- Import de l’IA, du modèle animé, de la texture et des effets de la baleine Sanctuary 26.2.
- Deux baleines chargées pour le serveur entier, toutes dimensions et sources d’apparition comprises.
- Œuf créatif personnalisé depuis la texture native `egg.png` de Minecraft 26.3, silhouette et ombres conservées.
- Refus d’un troisième œuf avant consommation, libellés FR/EN et calendrier propre à chaque serveur.
- [Contrat et vérifications](docs/flying-whale-beta290.md).
# beta.278 — Duel Battle pour un claim consenti
- Proposition depuis le clic droit d’un claim déjà revendiqué ; propriétaire et challenger valident le même enjeu.
+114 -9
View File
@@ -1,16 +1,119 @@
# Sanctuary
[Dernière release : beta.297](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.297) ·
[Reçu de livraison](docs/release-beta297.md) ·
[Installation et mises à jour](docs/packwiz.md).
Les **quatre expansions validées sont disponibles dans Sanctuary normal** :
Nord glacial, Sud des canyons, Ouest océanique et Est volcanique.
Créer un monde **Sanctuary** en Petit, Moyen ou Grand, puis activer les ancres
correspondantes. Le module Sanctuary Test n’est pas requis.
[Intégration et vérification du pack beta.224](docs/four-islands-base224.md).
## beta.297 — correctifs des cadres disponibles
[Menu de taille à la pose, lecteur au Maj + clic droit et cadre en volume](docs/media-frame-fixes-beta297.md).
Retrait du repère rouge et du contour de sélection du bloc invisible.
Le redimensionnement reste accessible par Taille / Size dans le lecteur.
[Livraison vérifiée](docs/release-beta297.md) : serveur Beta, site et même
instance Prism à jour, sauvegardes et réglages conservés.
## beta.296 — écrans partagés disponibles pour essai
[Cadres de redstone, taille libre et menu Dream Displays](docs/media-beta296.md).
Un seul cadre pour poser une ancre, agrandissement gratuit, vidéo commune
et acoustique personnelle. Le module Media est inclus dans Sanctuary ; Dream
Displays rejoint packwiz côté client et serveur.
[Livraison complète vérifiée](docs/release-beta296.md) : serveur Beta, site et
même instance Prism synchronisés ; 18 tests serveur et parcours à deux clients
Vulkan réussis. Sauvegardes et réglages conservés.
## Étude — écrans et séances partagées
[Cadrage des cadres de redstone et du streaming collectif](docs/media-displays-study.md) :
ancre avec menu de taille, mur complet, image à 16 pixels par bloc
et lecteur commun accessible à 16 blocs. Comparaison Dream Displays, extension,
fork et moteurs alternatifs, avec limites de synchronisation live et d'acoustique.
Étude sur branche dédiée, sans implémentation ni modification du pack.
[Prototype à deux vrais clients Vulkan](docs/media-playback-prototype.md) :
lecture VOD et live HLS contrôlé, traces d'images GPU et audio, avec correction
de la collision de couleur Hello World. Le live dérive après reconnexion et
le son vidéo ne traverse pas Sound Physics. Outils de développement isolés,
aucun cadre Sanctuary ni ajout au pack.
[Correction expérimentale de synchronisation et d'audio](docs/media-sync-audio-prototype.md) :
horloge commune, reprise VOD au bon point de décodage, live HLS daté et sortie
audio Minecraft. Réglage personnel « Acoustique des vidéos » pour retirer Sound
Physics du flux. Extension isolée sur `codex/media-sync-audio`, avant les cadres.
[Ancre et cadre ajusté à tout l’écran](docs/media-anchor-prototype.md) : un seul
cadre consommé, dimensions libres sans surcoût, **menu natif Dream Displays**
conservé pour les flux. Extension expérimentale sur `codex/media-anchor`.
## beta.295 — Serveur Sanctuary dans la liste multijoueur
[Serveur préenregistré et logo du pack](docs/default-server-beta295.md) :
ajout initial de **Sanctuary Beta** sans écraser les favoris personnels,
avec icône 64 × 64 également distribuée au serveur. Intègre la simplification
de `/sanctuary property`. Build, 18 tests serveur et parcours Vulkan FR/EN
réussis. [Livraison complète effectuée](docs/release-beta295.md) : serveur,
téléchargement Web et instance Prism historique en beta.295.
## beta.294 — Une seule commande de propriété (intégrée à beta.295)
[La commande de propriété garde uniquement son nom anglais](docs/property-command-beta294.md) :
`/sanctuary property`, avec `claim`, `protect` et `duel`. Les alias `propriete`
et `propriété` sont retirés. Build, **18 tests serveur ciblés** et assemblage
du pack réussis lors de la préparation locale. Cette version n’a pas été
publiée séparément ; son contenu est livré avec beta.295.
## beta.293 — mise à jour complète
[Livraison complète publiée](docs/release-beta293.md) : Happy Creepers de la
branche dédiée, baleines fantômes, logo packwiz et quatre mods facultatifs.
Serveur Beta, téléchargement Web et même instance Prism synchronisés en
**beta.293**, sauvegardes et réglages conservés. Build et 79 tests serveur
réussis ; assertions Vulkan réussies, blocage de fermeture de l'outil de test
consigné. Réseau vocal contrôlé, essai audible à deux joueurs encore ouvert.
## beta.292 — voix et mods facultatifs (préparation locale)
[Simple Voice Chat, Bobby, Lithium et Entity Culling rejoignent le pack](docs/optional-mods-beta292.md).
Les quatre restent décochables côté client ; Lithium et la voix rejoignent
aussi le profil serveur. Logo packwiz conservé, 46 tests serveur ciblés et
parcours Vulkan avec et sans Sodium réussis. Préparation locale, sans publication ni
modification de l'instance personnelle. Dozei Stream et Only Fun restent en tickets.
## beta.291 — logo Sanctuary dans packwiz (préparation locale)
[Le bandeau fourni s'affiche pendant les téléchargements packwiz](docs/packwiz-logo-beta291.md),
dans l'instance Prism préparée. L'installateur embarque le logo et conserve
le moteur packwiz officiel. Les [tickets des prochaines intégrations](docs/integrations-mods-next.md)
couvrent Simple Voice Chat, Bobby facultatif, Lithium, Entity Culling,
Dozei Stream et Only Fun ; ces mods ne sont pas ajoutés dans cette version.
Fenêtre FR/EN, contrôles d'archive et deux synchronisations isolées réussis.
Construction ciblée avec 13 tests serveur réussie ; un test hérité du quota
de baleines reste en échec, détaillé dans le contrat. Aucune publication.
## beta.290 — baleine fantôme volante (branche locale)
[Import direct de la baleine 26.2](docs/flying-whale-beta290.md), avec son IA,
son rendu nuageux animé et son œuf personnalisé à partir de `egg.png` de
Minecraft. Deux baleines chargées au maximum pour le serveur, œuf refusé
sans consommation si les places sont prises. Construction, 19 tests serveur
et parcours Vulkan avec Sodium réussis. Préparée localement, sans publication
ni mise à jour d’installation.
## beta.289 — mise à jour complète
[Livraison des zombies et de la lumière portée](docs/release-beta289.md), avec
JEI intégré au même JAR pour le client et le serveur. Le téléchargement du
site conserve l’instance Prism prête à suivre le canal stable.
**Publiée** : serveur Beta, téléchargement Web et instance Prism personnelle
synchronisés en beta.289, avec sauvegardes et réglages conservés.
[Procédure de développement et de publication](docs/updates.md).
Construction client/serveur, **31 tests serveur** et parcours des zombies et
familiers sous **Vulkan avec Sodium** réussis pour cette livraison.
@@ -1225,9 +1328,8 @@ explicites, résultat commun, sans récompense économique dans ce prototype.
Le laboratoire utilise un nouveau monde ; aucune mise à jour de Prism ni du
canal public n'est effectuée par ce chantier.
[Dernière release : beta.166](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.166) ·
[Historique des publications beta.144–166](docs/publications-beta144-166.md).
Les releases sont distinctes du canal packwiz, qui reste inchangé lors de ce rattrapage.
Ce rattrapage historique des releases n’avait pas modifié le canal packwiz.
La [direction de travail du 25 septembre](docs/ecosysteme-communaute-economie.md)
pose les liens entre tableau, Gazette, statistiques en BDD, État, navets,
@@ -3691,7 +3793,7 @@ Depuis beta.090, la cible est **Minecraft 26.3 finale**, publiée le 15 septembr
| Fabric API | 0.160.5+26.3 |
| Fabric Loom | 1.17.20 |
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
| Sanctuary / pack | beta.166 |
| Sanctuary / pack | beta.295 |
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
uniquement la bibliothèque standard et repère aussi une installation Python
@@ -3720,7 +3822,7 @@ décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-beta.166.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-beta.295.jar` : mod à installer avec
Fabric API sur la version Minecraft indiquée.
- `build/packwiz/` : pack de développement complet, avec le mod construit et
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
@@ -3770,11 +3872,14 @@ sauvegarde 26.2 : aucun outil de migration de ses chunks n'est livré ici.
It's Alive !, Only Fun et Master Key restent des modules autonomes prévus par la
vision. Leurs sources 26.2 ne sont pas copiées dans ce socle. Fabric API est
requise ; Demeure appartient au code interne de Sanctuary. Depuis beta.257,
le port source de JEI pour 26.3 et Sound Physics sont des mods client séparés
et facultatifs, comme Sodium. Leurs sources ou procédures de reconstruction
et leurs attributions figurent dans `mods/demeure/`, `mods/jei/` et
`mods/sound-physics-remastered/`.
requise ; Demeure appartient au code interne de Sanctuary. Depuis beta.280,
le port source de JEI pour 26.3 est embarqué dans le JAR Sanctuary pour le
client et le serveur. Sound Physics et Sodium restent facultatifs côté client.
Depuis beta.293, Simple Voice Chat, Bobby, Lithium et Entity Culling sont
aussi facultatifs côté client ; la voix et Lithium font également partie du
profil serveur. Voir [les dépendances du pack](docs/optional-mods-beta292.md).
Les sources ou procédures de reconstruction et les attributions figurent
dans `mods/demeure/`, `mods/jei/` et `mods/sound-physics-remastered/`.
## Travailler par tickets
+55
View File
@@ -1,5 +1,28 @@
# Provenance et crédits
## Mods facultatifs beta.292
Les binaires officiels de [Simple Voice Chat 2.6.24+26.3](https://modrinth.com/plugin/simple-voice-chat/version/OLnMVWXy)
(Max Henkel, All Rights Reserved), [Bobby 5.2.16+mc26.3](https://modrinth.com/mod/bobby/version/J7YnfCss)
(johni0702, LGPL-3.0-or-later), [Lithium 0.26.2+mc26.3](https://modrinth.com/mod/lithium/version/xS0Q8LSi)
(CaffeineMC et contributeurs, LGPL-3.0-only) et
[Entity Culling 1.11.3 pour 26.3](https://modrinth.com/mod/entityculling/version/X1g3VhTx)
(tr7zw et contributeurs, tr7zw Protective License) sont téléchargés séparément
par packwiz, sans modification ni imbrication dans Sanctuary. Leurs notices,
licences et bibliothèques imbriquées restent attachées aux JAR originaux.
Leurs métadonnées et SHA-512 sont dans `packwiz/mods/` ;
[choix de versions et vérifications](docs/optional-mods-beta292.md).
## Installateur packwiz Sanctuary beta.291
Le bandeau `launcher/packwiz/sanctuary.png` est le fichier `minecraft.png`
fourni par le créateur le 26 octobre 2026, conservé sans modification.
`InstallWindow.java` adapte la fenêtre de packwiz-installer v0.5.14 de
comp500, sous MIT ; licence et source sont joints au JAR généré. Toutes les
autres classes et les notices des dépendances de l'installateur officiel
restent identiques. Sources, hashes et périmètre dans
[le contrat du logo packwiz](docs/packwiz-logo-beta291.md).
## Tenues des zombies beta.287
Les neuf PNG `zombie.png`, `zombie2.png` à `zombie9.png` sont fournis par le
@@ -323,3 +346,35 @@ t-shirt. SHA-256 :
Les sons et le modèle sont natifs à Minecraft. Aucun bitmap ou son de Left 4
Dead ni texture générative n'est inclus. Les droits sur les ressources
Minecraft dérivées restent à Mojang/Microsoft.
## Baleine fantôme volante beta.290
Le modèle, son animation, le rendu, l’IA et le spawner proviennent du module
`itsalive` de Sanctuary 26.2, commit
`94644a28f05b8010f06ec1181fe6c74a50ad80d6`, sous la licence du projet
GPL-3.0-or-later. Cette source est elle-même le port de la baleine du JAR
historique Real Ghost ; aucune archive 26.2 n’est redistribuée.
`textures/entity/flying_whale/flying_whale.png` est repris octet pour octet.
SHA-256 : `0828ba1b9443f31cdb0ce9c22393a8702a72b9e59c2a0c02f3dd6dedadd33fb8`.
L’icône `textures/item/flying_whale_spawn_egg.png` est une recoloration
déterministe directe de `assets/minecraft/textures/item/egg.png` de Minecraft
26.3, produite par `scripts/generate_whale_egg.py` ; silhouette, alpha et
ombrage natifs conservés. Source SHA-256 :
`3bfccf5fcfe66933d591e944f6b08456c8083f5c3898b924da52bfebc41e6322`.
Résultat SHA-256 :
`d476a3fedba3af3e463d6461b81cf6ca8702fe10ceb3934f426390d59da63f7e`.
Les sons et particules sont natifs à Minecraft, référencés sans copie ; les
droits sur les ressources Minecraft dérivées restent à Mojang/Microsoft.
## Happy creepers
`assets/sanctuary/textures/entity/creeper/happy.png` est repris sans modification
du module `itsalive` de Sanctuary 26.2, commit
`26f2c2a0061619df1f146f9081d6740c560a7497`, chemin
`itsalive/src/main/resources/assets/itsalive/textures/entity/creeper/happy.png`.
SHA-256 : `40e22bf0610cfc7ae8ca910b1e6bdce0c759ccbf8714df7566ebe5538cac24f0`.
Il s'agit du visage souriant historique sur le modèle natif du creeper. Les
droits sur les ressources Minecraft dérivées restent à Mojang/Microsoft.
Aucune autre variante de texture ni ressource Mobopia n'est importée.
+22 -1
View File
@@ -14,6 +14,8 @@ allprojects {
tasks.named('build') { dependsOn(':sanctuary:build', ':jei:build', ':sanctuary-test:build') }
tasks.named('check') { dependsOn(':sanctuary:check', ':jei:check', ':sanctuary-test:check', 'verifyPack') }
tasks.named('check') { dependsOn(':sanctuary-media:check') }
tasks.named('build') { dependsOn(':sanctuary-media:build') }
tasks.register('verifyServerUpdater', Exec) {
group = 'verification'
@@ -27,6 +29,25 @@ tasks.register('verifyWebDistribution', Exec) {
}
tasks.named('check') { dependsOn('verifyWebDistribution') }
tasks.register('verifyOptionalMods', Exec) {
group = 'verification'
commandLine('python3', 'scripts/verify_optional_mods.py')
}
tasks.named('check') { dependsOn('verifyOptionalMods') }
tasks.register('assemblePackwizInstaller', Exec) {
group = 'distribution'
description = 'Build and verify the Sanctuary-branded packwiz window.'
commandLine('python3', 'scripts/packwiz_installer.py')
}
tasks.register('verifyPackwizInstaller', Exec) {
group = 'verification'
dependsOn('assemblePackwizInstaller')
commandLine('python3', 'scripts/test_packwiz_installer.py')
}
tasks.named('check') { dependsOn('verifyPackwizInstaller') }
tasks.register('verifyPack', Exec) {
group = 'verification'
description = 'Verify pinned pack dependencies, versions and index hashes.'
@@ -36,7 +57,7 @@ tasks.register('verifyPack', Exec) {
tasks.register('assemblePack', Exec) {
group = 'distribution'
description = 'Assemble a local packwiz pack including the built Sanctuary mod.'
dependsOn(':sanctuary:build', ':jei:build', 'verifyPack', 'assembleResourcePack', 'assembleResourceTemplate')
dependsOn(':sanctuary:build', ':jei:build', 'verifyPack', 'verifyOptionalMods', 'assembleResourcePack', 'assembleResourceTemplate', 'assemblePackwizInstaller')
commandLine('python3', 'scripts/pack.py', 'assemble')
}
+6
View File
@@ -1958,6 +1958,12 @@ et le client graphique avec connexion réelle passent. Voir
## Réserve de thèmes futurs
Le cadrage du 26 octobre 2026 détaille les
[tickets d'intégration du pack et des modules personnels](integrations-mods-next.md) :
Simple Voice Chat, Bobby facultatif, Lithium, Entity Culling, Dozei Stream
et Only Fun. Les quatre intégrations du pack sont préparées localement dans
[beta.292](optional-mods-beta292.md) ; les deux modules personnels restent à cadrer.
Ces thèmes servent à retrouver la vision, pas à demander leur implémentation immédiate. On en extrait un ticket seulement lorsqu'il devient utile au prochain incrément jouable.
| Thème | Contenus à découper plus tard |
+6 -2
View File
@@ -24,8 +24,12 @@ standard. Le MRpack beta.280 reste accessible par son ancienne adresse pour
compatibilité des liens, sans devenir un deuxième bouton du site. Le parcours
manuel de configuration et son lien dans le pied de page sont retirés.
L'archive légère contient uniquement `instance.cfg`, `mmc-pack.json`, l'icône et
`minecraft/packwiz-installer-bootstrap.jar`. Elle démarre avec zéro mod local.
L'archive contient uniquement `instance.cfg`, `mmc-pack.json`, l'icône,
`minecraft/packwiz-installer-bootstrap.jar` et, à partir de la préparation
beta.291, `minecraft/packwiz-installer.jar` avec le
[logo Sanctuary embarqué](packwiz-logo-beta291.md). Elle démarre avec zéro mod
local. L'installateur est vérifié par SHA-256 et conservé par
`--bootstrap-no-update` ; la mise à jour automatique des mods reste active.
L'instance est nommée **Sanctuary Beta**, avec `ManagedPack=false`,
`OverrideCommands=true`, la sélection automatique de Java et le canal stable :
+53
View File
@@ -0,0 +1,53 @@
# PACK-03 — Serveur Sanctuary préenregistré et icône
Ticket du **26 octobre 2026**, branche `codex/default-server`, version `beta.295`.
Le premier démarrage du client ajoute **Sanctuary Beta**, adresse publique
`beta.sanctuary-minecraft.net`, dans la liste multijoueur vanilla. Le mécanisme
est livré dans le JAR : il fonctionne après import du ZIP Prism du site,
installation MRpack et mise à jour d'une instance packwiz existante.
`servers.dat` reste un fichier personnel, jamais géré par packwiz. L'ajout
conserve l'ordre, les noms, réglages et champs inconnus des autres favoris.
Un favori Sanctuary existant est conservé sans doublon, y compris avec le nom
DNS en majuscules, un point final ou les ports 25565/25567. Une entrée cachée
issue de connexion directe devient visible sans être renommée. Avant la
première écriture, une copie exacte est conservée dans
`servers.dat.before-sanctuary-beta`. Un fichier illisible reste intact.
Le marqueur local `.sanctuary/default-server-beta` indique que l'ajout initial
est terminé : le joueur peut ensuite renommer, déplacer ou supprimer le favori.
Il n'est pas recréé à chaque lancement. Aucun téléchargement ni connexion de
jeu automatique n'est ajouté.
Le logo 160 × 160 du pack est redimensionné en PNG 64 × 64, sans nouvelle
illustration. Cette même image sert d'icône initiale du favori et de
`server-icon.png` dans le profil serveur packwiz. Le serveur annonce ainsi
l'icône aux clients lors du ping de la liste multijoueur. Le profil client ne
possède jamais `servers.dat` ; le profil serveur possède explicitement cette
icône. Les mondes et réglages de jeu ne changent pas.
La livraison commune intègre le ticket terminé
[CLAIM-03](property-command-beta294.md) : seule la commande anglaise
`/sanctuary property` et ses sous-commandes sont conservées. Les deux fichiers
de production, les deux fichiers de GameTests et le contrat sont repris
exactement depuis sa préparation locale beta.294, après ses 18 tests serveur
réussis, sans modifier son checkout de travail.
## Vérifications
- `defaultServerListSmoke` : 15 contrôles, dont lecture par `ServerData`
vanilla, icône 64 × 64, favoris existants, sauvegarde exacte, suppression,
absence de doublon et fichier corrompu suivi d'une réparation.
- Parcours client **Vulkan FR/EN**, GUI 3 en 1280 × 800 : réussi en 38 s,
hook réel de démarrage, liste vanilla, nom et icône, ping public 0/20.
Deux captures inspectées ; aucun monde ouvert. Journal et captures sous
`build/release295/` et `mods/sanctuary/build/run/clientGameTest/screenshots/`.
- `./gradlew check build assemblePack assembleServerPack` : réussi en
**3 min 22 s**, avec les contrôles purs et **18 tests serveur ciblés**
`property270,claims272,claims277,claims278`, `sanctuaryAtlasOnly=true`.
Les tests vérifient à nouveau la commande unifiée sur la base commune.
Journal : `build/release295/check-build.log`.
- **Publication effectuée** : Gitea, canal client/serveur, CT 350, téléchargement
Web et instance Prism historique en beta.295. [Reçu complet](release-beta295.md),
sauvegardes et limite de l’instance importée encore ouverte.
+105
View File
@@ -0,0 +1,105 @@
# WHALE-01 — baleine fantôme volante
Branche `codex/flying-ghost-whale`, préparation locale **beta.290** le
26 octobre 2026. Demande : import direct de la baleine de Sanctuary 26.2,
avec son IA et son œuf, deux baleines présentes au maximum sur le serveur.
## Import
Sources historiques : `26.2/itsalive`, commit
`94644a28f05b8010f06ec1181fe6c74a50ad80d6`. Le modèle, ses animations,
la texture nuageuse 512 × 256, les particules et les sons Minecraft sont
repris. Les API Fabric/Minecraft sont adaptées à **Minecraft 26.3**, sans
nouvelle dépendance. La texture de la baleine est copiée octet pour octet.
- `sanctuary:flying_whale` : baleine géante, paisible, volante, intangible,
invulnérable, sans butin. Dimensions historiques 36 × 11, rendu ×2,7.
- IA conservée : compagnon vivant non spectateur le plus proche à 520 blocs,
cibles successives entre 68 et 140 blocs du joueur, altitude libre, virages
progressifs, souffle et lente errance sans joueur.
- Spawner historique : contrôle toutes les 400 ticks, une apparition réussie
par tiers de journée (8 000 ticks), dans l’Overworld à 92–138 blocs d’un
joueur. Il attend si le chunk candidat n’est pas déjà chargé. Aucun chunk
n’est généré ou forcé par le spawner. Le calendrier appartient au serveur
courant et repart au démarrage, comme le calendrier historique.
- Dissipation au plus tard après 14 400 ticks (12 minutes à 20 TPS), possible
dès 12 000 ticks après plus de 900 ticks sans joueur proche. Seules les
baleines nommées sont sauvegardées, comme en 26.2 ; elles restent soumises
à cette durée de vie après chargement.
Le quota historique de deux baleines chargées est partagé entre **toutes les
dimensions du serveur**, et étendu aux œufs, invocations, distributeurs et
rechargements. Une troisième entité chargée est retirée. L’utilisation
manuelle d’un œuf est refusée avant consommation si les deux places sont
prises, avec un message FR/EN. Retirer une baleine libère immédiatement sa
place. Les baleines nommées dans des chunks déchargés ne réservent pas de
place ; elles repassent par le quota au rechargement.
## Œuf Minecraft personnalisé
`sanctuary:flying_whale_spawn_egg` figure dans l’onglet Œufs d’apparition.
Son icône est dérivée directement de **`assets/minecraft/textures/item/egg.png`
de Minecraft 26.3**, sans reprendre le dessin historique de l’œuf.
`scripts/generate_whale_egg.py` recolore les pixels en bleu nuage pâle avec
huit pixels lavande. Dimensions 16 × 16, les 90 pixels de coquille,
transparence, contour asymétrique et emplacements des ombres restent natifs.
Le script refuse une version ou une empreinte source différente et propose
`--check` pour comparer les pixels livrés sans réécriture.
```sh
python3.14 scripts/generate_whale_egg.py --check /chemin/minecraft-client.jar
/give @s sanctuary:flying_whale_spawn_egg
/summon sanctuary:flying_whale ~ ~10 ~
```
Libellés de l’entité, de l’œuf et du refus de quota en FR/EN. Aucun familier
miniature, capture de baleine ou conversion d’ID 26.2 n’est ajouté. Pas de
changement de générateur, de sauvegarde existante ou de publication distante.
## Vérification
- `./gradlew check build assemblePack
-PsanctuaryFocusedTests=whale290,recipes,progression
-PsanctuaryAtlasOnly=true` : **réussi en 3 min 34 s**, avec **19 tests
serveur requis réussis**, tous les contrôles purs de `check`, le JAR
`sanctuary-beta.290.jar` et les profils packwiz client/serveur assemblés.
Ce périmètre serveur cible la baleine, les recettes et la progression ;
il ne rejoue pas les suites de génération historiques.
- Archive JAR relue : version beta.290, classes et deux textures présentes,
un seul JEI embarqué, aucune classe de test ; même JAR unique dans chacun
des deux profils packwiz. Reçu SHA-256 sous `build/whale290/artifacts.json`.
- Suite native ciblée `:sanctuary:runGameTest
-PsanctuaryFocusedTests=whale290 -PsanctuaryAtlasOnly=true` : **7 tests
requis réussis**, dont les six scénarios baleine et le contrôle
d’infrastructure. Usage réel de l’œuf en survie, non-consommation à quota
plein, quota Overworld/Nether, refus de la troisième invocation, libération
d’une place, apparition naturelle à rayon/altitude historiques, déplacement
natif, dissipation, collision sans modification du terrain et sauvegarde
nommée vérifiés. Les tests attendent que les chunks de développement soient
effectivement chargés avant de vérifier leur population.
- Parcours graphique **Vulkan / MoltenVK 1.4.2 / Apple M1**, avec
**Sodium 0.9.2+mc26.3**, réussi en **49 s** : deux baleines géantes
translucides, nageoires animées, texture historique, véritable déplacement
autour du joueur, œuf en main et hotbar, libellés FR/EN. Les captures
`0000_whale290-native-egg-and-two-whales.png` et
`0001_whale290-ghosts.png` sont inspectées visuellement.
- `generate_whale_egg.py --check` : pixels livrés identiques au résultat
déterministe, 90 pixels opaques et masque alpha strictement natif.
PNG de la baleine identique octet pour octet à la référence 26.2 ;
JSON des libellés FR/EN et modèles d’objet valides.
Commande du parcours graphique :
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryWhale290ClientTests=true -PsanctuarySodiumClientTests=true \
-PsanctuaryFocusedTests=whale290 -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true
```
Journaux et captures ignorés sous `build/whale290/`. Validation en monde
plat de développement neuf, graine `290` pour le client ; aucun monde
personnel ni serveur Beta n’est ouvert ou modifié. Le calendrier complet
sur une journée réelle et un parcours multijoueur distant ne sont pas
revendiqués ; les trois fenêtres sont exercées par les tests de calendrier.
+99
View File
@@ -0,0 +1,99 @@
# Happy creepers — réintégration
Branche `codex/happy-creepers`, worktree isolé du chantier baleines.
Version locale préparée : **beta.291**, sur le socle beta.289.
Cette fiche conserve le reçu de la branche dédiée. Son commit `0ffd371a` est
repris dans la [livraison intégrée beta.293](release-beta293.md), avec les
baleines, le logo packwiz et les quatre mods facultatifs. Consulter ce reçu
pour l'état de publication et les vérifications de la combinaison finale.
## Contrat
Le happy creeper est un état du creeper Minecraft existant. **Maj + clic droit
avec une fleur** équipe son unique emplacement cosmétique de tête ; **Maj +
clic droit à main vide** rend cette même fleur. Le geste, les contrôles de
distance/visibilité, la consommation en survie et la conservation des composants
de l'objet sont ceux du système de chapeaux existant. Un emplacement occupé
doit être vidé avant d'équiper une autre fleur.
Toute fleur du tag Minecraft `minecraft:flowers` rend le creeper heureux :
visage souriant historique, fleur exacte visible sur la tête animée, aucun
ciblage hostile ni explosion. L'équipement annule immédiatement une mèche
déjà amorcée, y compris au briquet ; retirer la fleur ne reprend pas cette
ancienne mèche. Tant que la fleur est portée, briquet et boule de feu ne
l'allument pas et ne sont pas consommés ou usés. Le creeper continue d'utiliser
ses déplacements natifs et peut toujours subir des dégâts.
Retirer la fleur ou la remplacer par un objet sans rapport rétablit le visage,
le ciblage et l'explosion ordinaires. Un pot de fleurs, des feuilles ou des
graines ne déclenchent pas cet état. La charge électrique est indépendante :
un creeper chargé peut être heureux, et la foudre peut charger un happy
creeper. Retirer sa fleur conserve sa charge.
## Sauvegarde et périmètre
L'état heureux se déduit uniquement de l'objet dans `sanctuary:mob_head`, dont
le schéma 1 est réutilisé sans modification. Cette pile est déjà sauvegardée
et synchronisée par les chapeaux de mobs. Aucun second drapeau, nouvelle
entité, changement de format, migration de monde ou tirage de variante n'est
ajouté. Un creeper portant déjà une fleur devient donc heureux avec cette
version. L'identité, la santé, la charge et les autres données natives restent
sur le même creeper. Le port d'un chapeau conserve sa protection habituelle
contre la disparition à distance.
Les nouvelles apparences de creepers restent pour un prochain ticket. Le
suivi du joueur, les apparitions naturelles de happy creepers et les anciennes
entités `happy_creeper:*`/`mobopia:*` ne font pas partie de cet incrément.
Aucune nouvelle interface n'est ajoutée ; les messages d'équipement et de
retrait FR/EN existants sont réutilisés.
## Vérification
- Serveur `happycreeper,cosmetics,headblocks` : **28 tests requis réussis**,
dont sept tests happy creeper et le test d'infrastructure Fabric. Annulation
immédiate d'une vraie mèche à vingt ticks, refus répété du ciblage/allumage,
représailles, tous les objets du tag fleurs, récupération de la pile nommée,
allumage natif sans fleur et rechargement du même creeper chargé vérifiés.
- Parcours client **Vulkan avec Sodium** : **réussi**. Vrais appuis sur Maj et
clics souris pour équiper/récupérer une fleur en survie ; synchronisation de
la pile et du visage ; AI active pendant 85 ticks près d'un vrai joueur ;
sourire, fleur sur la tête animée et aura électrique native vérifiés.
Monde plat de développement neuf, graine `290`, Minecraft 26.3 sur Apple M1.
Capture : `build/happy-creepers-screenshots/four-states-vulkan.png` ; de gauche
à droite : chargé, chargé heureux, heureux, ordinaire.
- `check build assemblePack` : **réussi en 3 min 55 s**, contrôles purs et
**28 tests serveur** inclus. Export MRpack réussi, versions et dépendances
exactes contrôlées ; un seul JAR Sanctuary embarqué, identique au JAR
construit ; texture historique identique octet par octet et classes de test
absentes. Logs : `build/happy-creepers-final-check-build.log`,
`build/happy-creepers-server.log`, `build/happy-creepers-vulkan.log`.
Reçu : `build/happy-creepers-validation.json`.
Commandes reproductibles :
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=happycreeper,cosmetics,headblocks \
-PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryHappyCreeperClientTests=true -PsanctuaryQuickTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuarySodiumClientTests=true \
-PsanctuaryClientHeapGb=4 -PsanctuaryClientNoVsync=true
```
Le premier essai de `check build` associait par erreur le preset unifié et le
profil rapide ; il a échoué au choix du générateur avant les tests. Le profil
plat `sanctuaryAtlasOnly=true` évite ce conflit de configuration existant.
Aucun monde personnel, serveur Beta, canal packwiz ou Prism n'est modifié.
## Artefacts locaux
- [Pack beta.291](../build/Sanctuary-beta.291.mrpack), 16 724 142 octets.
SHA-256 : `3c2d630fc85e88b08bac94cc2244e975d3552fcac4d508ad366adea730062468`.
- JAR : `mods/sanctuary/build/libs/sanctuary-beta.291.jar`.
SHA-256 : `e8e315db1e4831400244d17429723099fbfe3a352387d8a9cf0ecb01d8170e9e`.
Cette branche part de beta.289 et ne contient pas le travail parallèle des
baleines préparé en beta.290. L'intégration des deux branches précède toute
éventuelle publication commune. Aucun tag ni artefact distant n'est créé.
+124
View File
@@ -0,0 +1,124 @@
# Prochaine passe — mods du pack et modules personnels
Cadrage du **26 octobre 2026**, à la demande de Koka. Initialement préparés
pour après beta.291, les quatre tickets d'intégration sont désormais repris
dans [la préparation locale beta.292](optional-mods-beta292.md), à sa demande.
Dozei Stream et Only Fun restent des contrats futurs. Chaque implémentation
prendra sa propre branche `codex/<sujet>` et son prochain compteur libre.
Les versions du cadrage ci-dessous sont des candidats repérés pour **Minecraft 26.3,
Fabric 0.19.5 et Java 25**, pas des intégrations validées. Tous les essais
graphiques cibleront Vulkan, avec Sodium puis sans si le mod le permet.
L'API Modrinth a été interrogée avec les filtres exacts `game_versions=["26.3"]`
et `loaders=["fabric"]`. Les JAR candidats ont été téléchargés dans le dossier
ignoré `build/optional-mods291/`, vérifiés contre leur SHA-512 publié et leurs
`fabric.mod.json` relus. Revalider versions, dépendances, licences et hashes au
début de chaque ticket ; aucun binaire téléchargé n'entre dans Git.
| Ticket | Branche prévue | Candidat observé | Résultat à obtenir |
| --- | --- | --- | --- |
| PACK-VOICE-01 | `codex/simple-voice-chat` | [2.6.26+26.3, Fabric, bêta](https://modrinth.com/plugin/simple-voice-chat/version/cZm965sW) | Conversation de proximité client/serveur, compatible avec l'acoustique Sanctuary |
| PACK-BOBBY-01 | `codex/bobby-optional` | [5.2.16+mc26.3](https://modrinth.com/mod/bobby/version/J7YnfCss) | Voir les chunks déjà reçus au-delà de la distance envoyée par le serveur, installation facultative |
| PACK-LITHIUM-01 | `codex/lithium-integration` | [0.26.2+mc26.3, Fabric](https://modrinth.com/mod/lithium/version/xS0Q8LSi) | Réduire le coût de simulation en conservant les règles Sanctuary |
| PACK-CULL-01 | `codex/entity-culling` | [1.11.3 pour 26.3, Fabric](https://modrinth.com/mod/entityculling/version/X1g3VhTx) | Éviter le rendu des entités et blocs entités masqués, sans disparitions incorrectes |
| MODULE-DOZEI-01 | `codex/dozei-stream-contract` | Pas encore réalisé, aucun artefact fourni | Définir le premier résultat vérifiable de Dozei Stream |
| MODULE-ONLYFUN-01 | `codex/only-fun-contract` | Mod personnel futur | Définir une première interaction potache autonome |
## PACK-VOICE-01 — Simple Voice Chat
Le JAR 2.6.26 déclare `minecraft=26.3.x`, Java ≥25, Loader ≥0.19.3 ;
Fabric API doit être ≥0.160.5+26.3, ce qui correspond au socle actuel.
**Conflit constaté : il déclare `breaks.sound_physics_remastered=<1.5.2`,
alors que Sanctuary distribue 1.5.1+26.3.** Le candidat 2.6.25 a le même refus.
Le JAR [2.6.24+26.3](https://modrinth.com/plugin/simple-voice-chat/version/OLnMVWXy)
ne déclare pas ce conflit ; cela ne prouve pas sa compatibilité en partie.
Décider et tester soit une mise à jour délibérée de Sound Physics et de son
adaptateur natif Sanctuary, soit une version vocale antérieure compatible.
Ne pas contourner les dépendances de Fabric. Conserver un artefact autonome
provenant de l'auteur et ses conditions de distribution.
Acceptation : connexion de deux clients de développement, voix de proximité,
muet, push-to-talk, déconnexion/reconnexion, coexistence Sound Physics et
préférences conservées. Vérifier les profils packwiz client/serveur, le refus
des versions incompatibles et les interfaces FR/EN ajoutées par Sanctuary.
L'activation réseau de la Beta sera une étape de livraison distincte : le
[protocole amont](https://dev.modrepo.de/minecraft/voicechat/wiki/server_setup)
utilise UDP et un port vocal propre, par défaut 24454. Tester depuis l'extérieur
avec `/voicechat test`, puis un échange audible ; l'ouverture du port Minecraft
TCP ne suffit pas. Aucun port ni serveur personnel n'est modifié par ce ticket
documentaire.
## PACK-BOBBY-01 — cache client facultatif
Le candidat déclare un environnement client, Minecraft `~26.3`, Loader
≥0.18.4 et refuse Sodium <0.6.0 ; Sodium 0.9.2 du pack satisfait ce seul
contrat déclaré. Cloth Config et Mod Menu sont suggérés, pas requis dans le
JAR inspecté. Le [principe amont](https://github.com/Johni0702/bobby) est de
mettre en cache les chunks envoyés dans `.bobby`, puis d'afficher leur dernière
copie connue à distance. Ce cache ne simule pas les chunks et ne remplace pas
leur état serveur actuel.
Acceptation : choix packwiz **facultatif**, installation/désinstallation par
le sélecteur et préférence conservée au second lancement ; parcours puis
retour sur un serveur de développement avec distance d'envoi courte ; chunks
lointains visibles sous Vulkan, actualisation lors de leur réception réelle,
claims/Atlas et blocs Sanctuary cohérents au retour. Aucun monde personnel
utilisé comme `bobby-fallback`. Définir une valeur initiale de distance et une
politique de cache mesurées avant livraison ; pas de génération supplémentaire
imposée au serveur.
## PACK-LITHIUM-01 — simulation
Le [candidat](https://github.com/CaffeineMC/lithium) peut fonctionner côté client
et serveur. Ses dépendances déclarées sont Minecraft `~26.3`, Loader ≥0.19.5
et MixinExtras ≥0.5.5. Vérifier la version réellement fournie par Loader ; ne
pas ajouter une bibliothèque par supposition. Définir séparément les profils
serveur et client, sans faire dépendre l'autorité serveur du choix d'un joueur.
Acceptation : démarrage et rechargement d'un monde de test, recettes et
inventaires, IA des infectés/familiers/baleines, interactions multiblocs et
budget de ticks comparés avant/après sur le même scénario. Les gains doivent
être mesurés ; conserver les identifiants, sauvegardes et règles serveur.
## PACK-CULL-01 — masquage visuel
Le [candidat](https://github.com/tr7zw/EntityCulling) cible Minecraft `~26.3`
et requiert Fabric API ; TRansition et TRender sont déjà imbriqués dans le
JAR inspecté. Vérifier leurs hooks de rendu sur Vulkan avant toute inclusion,
ainsi que les conditions de distribution du binaire original.
Acceptation : comparaison activé/désactivé sur une scène de développement,
avec mur opaque, fenêtres, verre, sortie d'occlusion et changement rapide de
caméra. Vérifier familiers portés, cosmétiques, baleines translucides, coffres
et multiblocs ; aucune disparition visible incorrecte. Mesurer le gain et le
coût CPU. Aucun essai OpenGL ne vaut validation de ce ticket.
## Modules personnels — contrats à écrire
**MODULE-DOZEI-01 :** conserver le nom donné « Dozei Stream » ; son rôle,
ses sources, sa cible client/serveur, ses dépendances et son premier scénario
restent à préciser avec le créateur. Ne déduire aucune fonctionnalité du nom.
Acceptation du cadrage : une action observable et un périmètre testable avant
de créer un faux artefact ou une entrée packwiz sans téléchargement.
**MODULE-ONLYFUN-01 :** reprendre la [vision Only Fun](vision.md#only-fun--interactions-sociales-et-absurdes),
avec l'intention confirmée d'ajouter de l'humour « pipi caca ». C'est un mod
personnel autonome, séparé du socle nécessaire pour jouer à Sanctuary.
Choisir une première interaction, définir ses entrées, effets, données
persistantes et branchements facultatifs au calendrier/aux événements.
Acceptation du cadrage : petit scénario jouable défini, comportement en solo
et serveur, installation indépendante et libellés FR/EN prévus. Le contenu
futur de la vision ne devient pas une fonctionnalité livrée par ce document.
## Ordre proposé et fin d'un ticket
Commencer par Bobby facultatif, puis les optimisations mesurées ; traiter la
voix avec sa décision Sound Physics et son parcours réseau. Les deux modules
personnels commencent par leur contrat, sans dépendance implicite à ces mods.
Cet ordre reste une proposition pour la prochaine séance.
Chaque intégration livrée passe `./gradlew check build assemblePack`, les
essais propres au comportement ajouté, deux synchronisations packwiz isolées,
la conservation des choix et le contrôle du profil serveur. Décrire les
résultats réels et les limites restantes avant toute publication demandée.
+217
View File
@@ -0,0 +1,217 @@
# MEDIA-04 — Un cadre comme ancre, agrandissement gratuit
Branche `codex/media-anchor`, 26 octobre 2026. Suite du
[prototype de synchronisation](media-sync-audio-prototype.md).
Décision de Koka : **un seul cadre est consommé pour poser l'ancre ; aucun
cadre supplémentaire pour agrandir, aucun remboursement quand on réduit**.
Cette décision remplace le coût par bloc envisagé dans MEDIA-01.
L'extension expérimentale `sanctuary_media` passe en **0.3.0-prototype** ;
elle reste séparée du pack beta.293. Aucun compteur Sanctuary, manifeste,
serveur personnel ou installation Prism modifié.
## Contrat de données avant le premier essai
Nouveaux identifiants : bloc, objet et block entity
`sanctuary_media:redstone_frame`. Recette : huit bâtons autour d'une redstone.
Le bloc représente uniquement l'ancre, placée devant un mur, au coin inférieur
gauche vu de face. Une seule block entity et une seule surface Dream par écran.
La block entity utilise **AnchorVersion=1**, avec Width, Height, Revision,
Owner et Display (UUID Dream). Dream conserve la source et sa timeline dans
son propre stockage. Le chargement/déchargement d'un chunk ne détruit pas
l'écran et ne donne aucun objet. Casser l'ancre ou perdre un support chargé
supprime le display une fois ; le loot normal rend un cadre en survie.
La taille n'est pas copiée dans l'objet récupéré.
Le marqueur Dream final `sanctuary_media:anchor_v2` encode 28 octets : version
2, position longue, largeur, hauteur, révision, orientation. Il permet le rendu
et le raycast même sans block entity dans l'index local. Les premiers essais
non livrés utilisaient un marqueur V1 de 24 octets ; au chargement de leur
ancre, `ensureDisplay` reconstruit le V2 depuis le BlockState, vérifie son
enregistrement, puis retire le V1, en conservant UUID, source et géométrie.
Le format de la block entity ne change pas. Cette migration concerne seulement
les mondes expérimentaux créés par ce ticket ; aucun monde personnel n'est ouvert.
Le schéma apparaît seulement dans un **nouveau monde de laboratoire** pour ces
essais. Le redémarrage de ce même laboratoire vérifie la relecture d'AnchorVersion=1 sans
changement de format. Aucun monde personnel ni conversion d'un format existant.
Une future évolution incompatible devra définir sa migration avant utilisation.
## Interaction
- Poser sur une face verticale : ancre 1 × 1, puis menu largeur/hauteur.
- Dimensions entières libres, formats rapides facultatifs 4 × 3, 8 × 4, 16 × 9
et aperçu du ratio. Aucun catalogue de tailles imposées.
- Confirmer modifie le rectangle ; fermer laisse la taille précédente.
- Clic droit sur toute la surface jusqu'à **16 blocs**, depuis les yeux, pour
le lecteur commun. Les murs bloquent le rayon.
- Maj + clic droit ou bouton Taille à portée de construction de l'ancre :
menu de dimensions si les droits de construction l'autorisent.
- Source, lecture, pause et seek sont communs. Volume et muet sont personnels.
Ouvrir le menu ne met pas la séance en pause.
**Il s'agit d'une extension, pas d'un portage ni d'une réécriture du lecteur.**
À la demande de Koka, le petit lecteur Sanctuary provisoire a été retiré :
le clic normal ouvre le véritable `DisplayMenu` de Dream Displays 1.10.4,
avec son champ de recherche/liens, ses cartes, aperçu et commandes existantes.
Seul le menu des dimensions est ajouté, avec un bouton Taille dans l'en-tête
natif. Les commandes restent communes et les modes/access restent fixés sur
Synchronisé/Tout le monde. La suppression se fait via l'ancre ; la double
acoustique Dream est désactivée au profit du pont Minecraft/Sound Physics.
Le cadre visuel dessine les quatre bords dans **le même repère et le même
pipeline Vulkan que l'image**, avec une épaisseur constante d'un seizième de
bloc. Son contour extérieur correspond exactement au rectangle demandé, y
compris avant de charger une source. L'ancien contour du seul bloc 1 × 1 est
invisible. Un petit repère de redstone marque le coin d'ancrage.
Budgets serveur dans `config/sanctuary-media-anchors.json` : par défaut largeur
256, hauteur 256, surface 16 384 blocs ; zéro retire le plafond correspondant.
Les limites du monde et les chunks réellement chargés restent des contraintes.
Le plafond fixe 32 × 18 du premier essai a été retiré après la précision de Koka.
La résolution des textures garde le ratio, avec 4096 pixels par axe et au plus
8 388 608 pixels, pour que la taille physique ne déclenche pas une allocation
vidéo immense. Les cas extrêmes sont vérifiés dans le contrat Java.
Le serveur recalcule cible, visibilité, portée et droits, vérifie chaque cellule
du mur et de l'espace avant, les limites du monde et les autres ancres chargées.
Un rectangle partiellement déchargé est refusé sans charger les chunks.
Révision et UUID protègent contre une ancienne fenêtre. Les paquets natifs
Dream concernant un écran ancré passent par le contrat de l'ancre ; ses API
de géométrie et suppression refusent les appels hors transaction autorisée.
Les écrans Dream ordinaires conservent leur comportement.
Le prototype réutilise la synchro et le réglage personnel d'acoustique de
MEDIA-03. Le filtre 16 pixels par bloc, les sources publiques et les disques
blancs restent des lots distincts.
Un redimensionnement pendant la lecture recrée les textures et recharge le
décodeur aux nouvelles proportions, puis redemande la timeline commune. Cela
évite d'étirer l'ancienne texture ; une brève remise en tampon est attendue.
L'image garde son ratio, avec des bandes noires si celui-ci diffère du cadre.
L'UUID et la source restent identiques.
## Vérification du prototype
Laboratoire `anchor-03`, créé pour ce ticket, deux vrais clients Minecraft
26.3 sous **Vulkan**, serveur dédié local, Dream Displays 1.10.4, Sound Physics
Remastered 1.5.1+26.3 et Sodium 0.9.2+mc26.3, Java 25. Même méthode et même
source synthétique à 30 images/s que MEDIA-03, Apple M1 8 Gio. Le backend libav
embarqué reste indisponible sur ce poste : le chemin natif Dream utilise
FFmpeg externe 8.1.2. Aucun essai OpenGL.
La suite `scripts/anchor_lab_checks.py` a passé ses **14 contrôles** :
consommation unique, agrandissement gratuit, réduction sans remboursement,
même UUID, budget, mur complet, révision périmée, accès natifs protégés,
obstacle/chevauchement/chunk absent, permissions de construction, lecteur
Dream à distance avec un cadre en main, redimensionnement lointain refusé,
mur bloquant le clic et le paquet forgé, second spectateur. Les interactions
passent par les widgets et les paquets du jeu ; le monde n'est pas modifié
hors des gestes explicites du scénario de laboratoire.
Essais complémentaires :
- Recette et loot chargés par le serveur ; calculs des quatre orientations,
raycast et bornes de texture vérifiés par le contrat Java.
- Véritable menu `DisplayMenu` en FR et EN, deux échelles d'interface ; lien
saisi dans sa recherche puis carte sélectionnée, source propagée aux deux
clients, pause/reprise depuis le second spectateur.
- Passage de 7 × 4 à **33 × 7** pendant le live, cadre autour de tout le
rectangle, vidéo centrée sans étirement ; inventaire toujours à trois
après une seule pose depuis une pile de quatre.
- Index local d'ancres retiré : rendu, lecteur et commandes retrouvent la
surface via les métadonnées Dream répliquées.
- Redémarrage du laboratoire : mêmes dimensions 33 × 7, révision, UUID
`111fe5e2-a78e-4541-8d7c-cab51901d326` et source ; un seul display.
- Reconnexion d'un client au même serveur puis réouverture du lecteur.
Le délai anti-spam utilise maintenant une horloge monotone, avec nettoyage
à la déconnexion, au lieu de l'âge du nouveau joueur.
- Pause du live dépassant la fenêtre de segments, puis reprise : chaque
session froide épingle un manifeste récent et son origine PCM. L'essai a
découvert puis corrigé la réutilisation d'une ancienne origine, qui figeait
l'image et vidait l'audio après reprise.
- Option personnelle d'acoustique désactivée sur A pendant le live : ses
appels SPR cessent, ceux de B continuent, audio OpenAL actif sur les deux.
- Hors champ sur A : plus d'uploads GPU, curseur audio toujours en mouvement.
- Retrait d'un support du mur : disparition de l'ancre et du display, un seul
cadre lâché, aucun doublon cinq secondes plus tard.
Les phases anciennes `live-anchor-rejoin-play` et les premiers essais de
redimensionnement sont des diagnostics **avant correction**, pas des tests
réussis. Les séries finales et leurs couvertures sont conservées dans le
[relevé de résultats](media-anchor-results.json). Les traces détaillées et
captures restent dans `build/media-prototype/anchor-03/`, hors Git.
La mesure compare les numéros d'images réellement envoyées au GPU, à la
résolution d'une image à 30 i/s, et les curseurs OpenAL. Elle ne mesure ni le
scanout des moniteurs ni l'écoute des haut-parleurs. Les valeurs après les
quinze premières secondes ne garantissent pas une latence réseau ; les
chargements et pointes restent comptés dans les traces.
| Phase contrôlée | Durée | Paires après 15 s | Médiane | P95 | Maximum |
| --- | ---: | ---: | ---: | ---: | ---: |
| VOD via menu Dream, 7 × 4 | 200 s | 738 | 22 ms | 118 ms | 200 ms |
| Live, reprise après pause froide | 68,5 s | 208 | 15 ms | 249 ms | 579 ms |
| Live final stabilisé, 33 × 7 | 74,6 s | 237 | 14 ms | 33 ms | 242 ms |
Couverture utilisable de la dernière phase : 99,3 % sur A et 100 % sur B,
médiane 30 FPS. Le tout dernier redémarrage des deux JVM n'a que cinq paires
après 15 s (P95 526 ms, couverture 27–28 %) : c'est un échantillon de démarrage
insuffisant pour conclure sur le régime stable. Le relevé le conserve, ainsi
que les pointes de la reprise froide ; la synchro n'est pas instantanée.
Après les tests, les clients, le générateur HLS et le serveur HTTP ont été
arrêtés. Le serveur de laboratoire est resté bloqué plus d'une minute dans
`ServerChunkCache.runDistanceManagerUpdates` pendant `stopServer` ; dump des
threads conservé dans `build/media-fix/anchor-server-stop-threads.log`, puis
arrêt forcé du seul processus identifié. Le test de persistance avait réussi
lors du redémarrage précédent ; cet arrêt forcé n'est pas une validation de
sauvegarde propre. Aucune installation personnelle n'est concernée.
Vérification finale du code :
- `./gradlew check build --max-workers=1 -Dorg.gradle.jvmargs=-Xmx1G
-PsanctuaryFocusedTests=property270,claims272,claims277,claims278
-PsanctuaryAtlasOnly=true --console=plain` : **réussi en 2 min 51 s**, 145
tâches, **18 GameTests requis réussis**. Java 25 via `JAVA_HOME`.
- `python3.14 scripts/media_lab.py test-media` : contrats horloge, timeline,
HLS, isolation d'origine après reprise, géométrie et textures réussis ;
construction du JAR expérimental séparé réussie.
- Sept tests du rapport de mesure et compilation syntaxique Python réussis.
- Aucun changement de distribution, donc pas d'`assemblePack`, publication,
changement de version Beta ou synchronisation Prism.
## Reproduire
```sh
./gradlew :sanctuary-test:exportDuoLaunch
python3.14 scripts/media_lab.py prepare --run anchor-test --anchors
python3.14 scripts/media_lab.py serve --run anchor-test
# Dans d'autres terminaux :
python3.14 scripts/media_lab.py server --run anchor-test
python3.14 scripts/media_lab.py client-a --run anchor-test
python3.14 scripts/media_lab.py client-b --run anchor-test
python3.14 scripts/anchor_lab_checks.py --run anchor-test
python3.14 scripts/media_lab.py test-media
```
La suite interactive attend que les deux joueurs aient rejoint le laboratoire.
Pour vérifier les flux, lancer `live --run anchor-test`, puis saisir l'URL
loopback de `manifest.json` dans le menu Dream ; les médias restent des
fixtures synthétiques locales. Arrêter avec `stop --run anchor-test`, puis
fermer le serveur HTTP du terminal `serve`.
## Limites encore ouvertes
Les dimensions libres désignent des **blocs entiers**, sur un mur complet ;
les budgets configurables du serveur protègent le coût des validations. Les
tailles extrêmes n'ont pas été essayées dans le monde ; seules leurs bornes
de calcul et d'allocation ont été vérifiées. Les quatre orientations ont un
contrat de géométrie, le parcours graphique est orienté sud.
Twitch/YouTube publics, playlists HLS maître, LL-HLS, discontinuités, DRM,
résolution ANI-CLI, filtre 16 pixels/bloc et disques blancs ne sont pas
validés dans ce ticket. Le lecteur et ses réglages restent ceux de Dream.
L'extension expérimentale n'est pas encore distribuée dans Sanctuary Beta.
+489
View File
@@ -0,0 +1,489 @@
{
"ticket": "MEDIA-04",
"date": "2026-10-26",
"branch": "codex/media-anchor",
"version": "0.3.0-prototype",
"environment": {
"minecraft": "26.3",
"java": 25,
"dream_displays": "1.10.4",
"sound_physics_remastered": "1.5.1+26.3",
"backend": "Vulkan",
"machine": "Apple M1, 8 GiB",
"lab": "anchor-03",
"seed": 2602,
"media": "synthetic H264/AAC 320x180, 30fps; dated 1s HLS segments"
},
"method": "16-bit frame code, 30 fps, successful GPU upload, nearest observations within 150 ms; no display-scanout or subjective listening measurement",
"interaction_checks": {
"passed": [
"one frame consumed on placement",
"custom 7x4 and free enlargement",
"shrinking does not refund frames",
"resizing keeps the same display",
"oversize refused",
"incomplete wall refused",
"stale revision refused",
"native delete, overlap, obstacle and unloaded chunks refused",
"building permission required",
"original Dream reader on remote overlay while holding frame",
"forged remote resize refused",
"wall blocks normal interaction",
"wall blocks forged open packet",
"original Dream reader available to second spectator"
]
},
"lifecycle": {
"culling_audio_continues": true,
"support_loss_one_drop": true,
"no_duplicate_drop_after_5s": true,
"final_server": {
"assertions": {
"reloaded": true
},
"server_step": "remove-support",
"anchor": false,
"frames_in_inventory": 3,
"frame_drops": 1,
"displays": 0,
"wall_ns": 1793006462588000000,
"mono_ns": 2229766026712875,
"phase": "anchor-support-loss"
}
},
"phases": {
"final-native-video": {
"duration_seconds": 200.023,
"after_15s_skew_ms": {
"count": 738,
"median": 22.000000000000007,
"p95": 118.00000000000001,
"max": 200.33333333333334
},
"all_pairs_skew_ms": {
"count": 799,
"median": 26.00000000000001,
"p95": 151.66666666666669,
"max": 200.99999999999997
},
"clients": {
"client-a": {
"samples": 800,
"usable_video_samples": 800,
"usable_fraction": 1.0,
"largest_sample_gap_seconds": 0.398,
"fps": {
"count": 800,
"median": 30.0,
"p95": 30,
"max": 30
},
"media_audio_first": {
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 24,
"error_ns": 2222222
},
"media_audio_last": {
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 814,
"error_ns": 7551020
},
"java_sound_active_samples": 0,
"openal_active_samples": 800,
"probe_errors": []
},
"client-b": {
"samples": 801,
"usable_video_samples": 800,
"usable_fraction": 0.9987515605493134,
"largest_sample_gap_seconds": 0.322,
"fps": {
"count": 800,
"median": 30.0,
"p95": 30,
"max": 30
},
"media_audio_first": {
"resets": 0,
"al_error": 0,
"backend": "openal",
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": 181406,
"effects_calls": 24
},
"media_audio_last": {
"resets": 0,
"al_error": 0,
"backend": "openal",
"rate": 1.004580499,
"playing": true,
"video_acoustics": true,
"error_ns": 9160998,
"effects_calls": 814
},
"java_sound_active_samples": 0,
"openal_active_samples": 800,
"probe_errors": []
}
}
},
"live-anchor-rejoin": {
"duration_seconds": 23.226,
"after_15s_skew_ms": {
"count": 32,
"median": 12.333333333333334,
"p95": 25.666666666666664,
"max": 29.666666666666668
},
"all_pairs_skew_ms": {
"count": 91,
"median": 38.666666666666664,
"p95": 76.00000000000001,
"max": 105.66666666666666
},
"clients": {
"client-a": {
"samples": 94,
"usable_video_samples": 94,
"usable_fraction": 1.0,
"largest_sample_gap_seconds": 0.534,
"fps": {
"count": 94,
"median": 30.0,
"p95": 30,
"max": 30
},
"media_audio_first": {
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": -6167800,
"effects_calls": 466,
"resets": 0,
"al_error": 0,
"backend": "openal"
},
"media_audio_last": {
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": -6009070,
"effects_calls": 556,
"resets": 0,
"al_error": 0,
"backend": "openal"
},
"java_sound_active_samples": 0,
"openal_active_samples": 94,
"probe_errors": []
},
"client-b": {
"samples": 93,
"usable_video_samples": 93,
"usable_fraction": 1.0,
"largest_sample_gap_seconds": 0.41,
"fps": {
"count": 93,
"median": 29,
"p95": 30,
"max": 30
},
"media_audio_first": {
"al_error": 0,
"backend": "openal",
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": 3854875,
"effects_calls": 169,
"resets": 0
},
"media_audio_last": {
"al_error": 0,
"backend": "openal",
"rate": 1.004319728,
"playing": true,
"video_acoustics": true,
"error_ns": 8639456,
"effects_calls": 260,
"resets": 0
},
"java_sound_active_samples": 0,
"openal_active_samples": 93,
"probe_errors": []
}
}
},
"live-fresh-pin-resume": {
"duration_seconds": 68.542,
"after_15s_skew_ms": {
"count": 208,
"median": 15.0,
"p95": 249.33333333333334,
"max": 578.6666666666666
},
"all_pairs_skew_ms": {
"count": 247,
"median": 14.333333333333337,
"p95": 133.66666666666666,
"max": 578.6666666666666
},
"clients": {
"client-a": {
"samples": 274,
"usable_video_samples": 251,
"usable_fraction": 0.916058394160584,
"largest_sample_gap_seconds": 0.526,
"fps": {
"count": 251,
"median": 30,
"p95": 30,
"max": 30
},
"media_audio_first": {},
"media_audio_last": {
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 267,
"error_ns": 6757370,
"video_acoustics": true
},
"java_sound_active_samples": 0,
"openal_active_samples": 251,
"probe_errors": []
},
"client-b": {
"samples": 274,
"usable_video_samples": 251,
"usable_fraction": 0.916058394160584,
"largest_sample_gap_seconds": 1.047,
"fps": {
"count": 251,
"median": 30,
"p95": 30,
"max": 30
},
"media_audio_first": {},
"media_audio_last": {
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 266,
"error_ns": -8934240,
"video_acoustics": true,
"playing": true,
"rate": 0.99553288
},
"java_sound_active_samples": 0,
"openal_active_samples": 251,
"probe_errors": []
}
}
},
"live-anchor-final-rejoin": {
"duration_seconds": 16.718,
"after_15s_skew_ms": {
"count": 5,
"median": 375.0,
"p95": 526.3333333333334,
"max": 526.3333333333334
},
"all_pairs_skew_ms": {
"count": 15,
"median": 184.33333333333331,
"p95": 375.3333333333333,
"max": 526.3333333333334
},
"clients": {
"client-a": {
"samples": 62,
"usable_video_samples": 17,
"usable_fraction": 0.27419354838709675,
"largest_sample_gap_seconds": 1.865,
"fps": {
"count": 17,
"median": 29,
"p95": 29,
"max": 30
},
"media_audio_first": {},
"media_audio_last": {
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 36,
"error_ns": 59138322,
"video_acoustics": true,
"playing": true,
"rate": 1.02
},
"java_sound_active_samples": 0,
"openal_active_samples": 17,
"probe_errors": []
},
"client-b": {
"samples": 60,
"usable_video_samples": 17,
"usable_fraction": 0.2833333333333333,
"largest_sample_gap_seconds": 2.436,
"fps": {
"count": 17,
"median": 27,
"p95": 30,
"max": 30
},
"media_audio_first": {},
"media_audio_last": {
"effects_calls": 35,
"resets": 0,
"al_error": 0,
"backend": "openal",
"rate": 1.02,
"playing": true,
"video_acoustics": true,
"error_ns": 176575964
},
"java_sound_active_samples": 0,
"openal_active_samples": 18,
"probe_errors": []
}
}
},
"live-anchor-stable-final": {
"duration_seconds": 74.563,
"after_15s_skew_ms": {
"count": 237,
"median": 14.333333333333334,
"p95": 33.0,
"max": 242.33333333333334
},
"all_pairs_skew_ms": {
"count": 297,
"median": 16.0,
"p95": 951.3333333333334,
"max": 1175.6666666666667
},
"clients": {
"client-a": {
"samples": 299,
"usable_video_samples": 297,
"usable_fraction": 0.9933110367892977,
"largest_sample_gap_seconds": 0.756,
"fps": {
"count": 297,
"median": 30,
"p95": 30,
"max": 30
},
"media_audio_first": {
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 456,
"error_ns": 476190,
"video_acoustics": true,
"playing": true,
"rate": 1.0
},
"media_audio_last": {
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 459,
"error_ns": -2857143,
"video_acoustics": false,
"playing": true,
"rate": 1.0
},
"java_sound_active_samples": 0,
"openal_active_samples": 298,
"probe_errors": []
},
"client-b": {
"samples": 299,
"usable_video_samples": 299,
"usable_fraction": 1.0,
"largest_sample_gap_seconds": 0.431,
"fps": {
"count": 299,
"median": 30,
"p95": 30,
"max": 30
},
"media_audio_first": {
"effects_calls": 456,
"resets": 0,
"al_error": 0,
"backend": "openal",
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": 2766440
},
"media_audio_last": {
"effects_calls": 750,
"resets": 0,
"al_error": 0,
"backend": "openal",
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": -1337868
},
"java_sound_active_samples": 0,
"openal_active_samples": 299,
"probe_errors": []
}
}
}
},
"interpretation": {
"vod": "Native Dream search/card/playback at 7x4; measurement through anchor",
"live-anchor-rejoin": "Single client rejoin at 33x7; native menu and shared pause checked; predates cold-pause fix",
"live-fresh-pin-resume": "First live restart after cold-pause fix; includes warmup and scheduling spikes during tools/build; no frozen playback",
"live-anchor-final-rejoin": "Latest code, both JVMs restarted: short startup sample, only five stable pairs, insufficient for steady-state conclusion",
"live-anchor-stable-final": "Latest code after warmup, 33x7; A video acoustics off and B on; occasional upload/scheduling spikes retained"
},
"limits": [
"No monitor scanout or loudspeaker capture",
"Only controlled local VOD/HLS; no public Twitch/YouTube validation",
"Final short reconnect phase has low usable coverage, not an instantaneous reconnect claim"
],
"checks": {
"java_contracts": "passed",
"python_report_tests": 7,
"root_check_build": {
"status": "passed",
"seconds": 171,
"actionable_tasks": 145,
"executed": 120,
"game_tests": 18,
"focused_tests": "property270,claims272,claims277,claims278",
"atlas_only": true,
"command": "JAVA_HOME=$(/usr/libexec/java_home -v 25) ./gradlew check build --max-workers=1 -Dorg.gradle.jvmargs=-Xmx1G -PsanctuaryFocusedTests=property270,claims272,claims277,claims278 -PsanctuaryAtlasOnly=true --console=plain"
}
},
"publication": "none; independent experimental addon, Beta pack unchanged",
"shutdown": {
"server_pid": 44868,
"forced_after_seconds": 63,
"reason": "Server thread blocked in ServerChunkCache.runDistanceManagerUpdates during MinecraftServer.stopServer; shutdown hook waiting",
"thread_dump": "build/media-fix/anchor-server-stop-threads.log",
"clients_live_and_http_stopped": true
}
}
+76
View File
@@ -0,0 +1,76 @@
# MEDIA-05 — livrer les écrans dans Sanctuary beta.296
Demande du 26 octobre 2026 : mettre Sanctuary à jour pour essayer les cadres.
Branche `codex/media-beta296`, intégration de `codex/media-anchor` et du socle
publié beta.295, favori serveur et commandes de propriété conservés.
## Distribution
Le JAR Sanctuary contient maintenant le module Fabric `sanctuary_media`
**0.3.0**, construit par Gradle depuis `mods/sanctuary-media/`. Ses identifiants
et entrées restent ceux du prototype ; aucun deuxième JAR Media à installer.
L'assemblage vérifie la présence d'un seul module, ses dépendances, sa licence
et l'absence de code de sonde. Le module compile contre les classes Sanctuary
pour éviter un cycle avec le JAR qui l'embarque.
Dream Displays **1.10.4**, artefact Modrinth **siUKU04U** pour Minecraft **26.3**,
est une dépendance packwiz commune aux clients et au serveur, vérifiée par
SHA-512. Les profils serveur possèdent désormais cinq mods externes/principaux :
Fabric API, Sanctuary (JEI et Media inclus), Dream Displays, Lithium et Simple
Voice Chat. Sound Physics reste facultatif côté client. Pas de mise à jour de
Minecraft, Fabric, JEI ni des autres dépendances.
Le décodeur reste sur les clients. Sur ce Mac Apple Silicon, le chemin validé
utilise FFmpeg 8.1.2 ; le backend libav embarqué ne se charge pas. Dream peut
télécharger ses outils au premier lancement. L'installation locale réutilise
le FFmpeg système vérifié pour éviter une autre variante du décodeur.
## Contrat des données
L'ajout n'effectue aucune conversion, génération ou modification de blocs
existants. Il enregistre `sanctuary_media:redstone_frame` et ses nouvelles
données **AnchorVersion=1**, seulement quand un joueur pose une ancre. Dream
garde sa propre source et sa timeline ; la surface reçoit le marqueur V2
documenté dans [MEDIA-04](media-anchor-prototype.md).
Sauvegarder l'instance Beta entière, configuration et environnement privé
avant changement du canal. Préserver mondes, joueurs, permissions, admission,
communauté et réglages. Aucun monde personnel n'est ouvert pour la validation
locale. Après utilisation des nouveaux écrans, un retour à beta.295 demande
la restauration de la sauvegarde correspondante ; ne pas lancer l'ancien
binaire sur un monde contenant les nouveaux blocs.
## Essayer en jeu
1. Lancer l'instance Sanctuary Beta habituelle ; packwiz télécharge la mise à
jour. Rejoindre le favori Sanctuary Beta.
2. Fabriquer un **cadre de redstone** : huit bâtons et une redstone au centre.
3. Poser le cadre sur un mur plein : il devient le coin inférieur gauche.
Saisir largeur et hauteur, puis Appliquer. Un seul cadre est consommé et
tout redimensionnement est gratuit.
4. Clic droit sur la surface, jusqu'à 16 blocs : le véritable menu Dream
Displays s'ouvre. Coller un lien, lancer sa recherche puis choisir la carte.
Les commandes de lecture sont communes ; volume et muet sont personnels.
5. Près de l'ancre, utiliser Maj + clic droit ou le bouton Taille pour ajuster
les dimensions. Le mur complet doit rester derrière l'écran.
Le contour correspond exactement au rectangle ; la vidéo conserve son ratio.
Budgets dans `config/sanctuary-media-anchors.json` (256 × 256, surface 16 384
par défaut ; zéro enlève le plafond concerné). Aucun format prédéfini imposé.
Pour garder le son sans réverbération/occlusion : **Options → Sons → Sound
Physics Remastered → Acoustique des vidéos : Non**. Le choix est personnel.
L'audio continue quand l'écran sort du champ de vision.
## Vérifications et limites
Le prototype a été exercé avec deux clients Vulkan : vidéo synthétique, HLS
daté, reconnexion, pause froide, cadre 33 × 7, menu natif FR/EN, droits et
inventaire. [Mesures détaillées et pointes](media-anchor-results.json).
Cette livraison ajoute les contrôles Gradle, des archives et des installations
réelles ; voir le [reçu de livraison](release-beta296.md).
Twitch/YouTube publics ne sont pas une validation obtenue par les fixtures
locales. DRM, HLS maître/LL-HLS, ANI-CLI, pixellisation 16 pixels/bloc et disques
blancs restent hors de cette livraison. La reprise d'un live peut remettre
quelques secondes en tampon pour retrouver le retard commun.
+334
View File
@@ -0,0 +1,334 @@
# MEDIA-01 — Écrans et séances partagées
Étude du **26 octobre 2026**, branche `codex/media-displays-study`, issue du
commit Sanctuary `74dc1265` (**beta.293**). Les modifications beta.294 en cours
dans le checkout `codex/property-command` sont laissées dans leur checkout.
**Statut : cadrage documentaire, aucun mod implémenté ni installé.** Les choix
ci-dessous proviennent de la discussion avec Koka ; les propositions techniques
et les critères d'essai ne constituent pas des résultats mesurés.
Suite de ce cadrage initial : le [prototype MEDIA-02](media-playback-prototype.md)
consigne les essais réels à deux clients Vulkan. Il ajoute uniquement une sonde
de développement et une scène Dream Displays, sans les cadres Sanctuary.
## Choix confirmés
| Sujet | Décision du créateur |
| --- | --- |
| Pose | Une ancre, puis un menu largeur/hauteur |
| Support | Surface murale complète, comme un tableau |
| Fabrication | Recette du tableau avec redstone au centre, à la place de la laine |
| Coût | **Décision révisée : une seule ancre, agrandissement gratuit** — voir [MEDIA-04](media-anchor-prototype.md) |
| Image | Option de pixellisation à **16 × 16 pixels par bloc** |
| Séance | Priorité à la synchronisation du groupe, quitte à retarder le direct |
| Commandes de lecture | Tous les spectateurs peuvent changer la source et mettre en pause |
| Audio initial | Son émis par l'écran ; disques blancs et jukebox ensuite |
| Interaction | Lecteur accessible à 16 blocs en visant ; redimensionnement à proximité |
| Référence animés | **ANI-CLI**, projet GitHub ; aucune dépendance existante identifiée dans Sanctuary |
Volume et muet personnels accompagnent les commandes communes. Les systèmes
utilisés par tous les joueurs restent à inventorier ; Windows, macOS Intel,
macOS Apple Silicon et Linux ne sont pas considérés comme validés par défaut.
Le disque blanc est une intention de la [vision](vision.md), pas une mécanique
trouvée dans le code actuel. L'acoustique existante est **Sound Physics Remastered**,
mod facultatif [documenté ici](../mods/sound-physics-remastered/PROVENANCE.md).
## Options de réalisation
| Option | Ce qu'elle apporte | Travail ou limite déterminante | Avis |
| --- | --- | --- | --- |
| Dream Displays intact | Sources en ligne et lecture multijoueur déjà prévues | Contrat Sanctuary de pose/coût/filtre à adapter ; synchronisation live insuffisante pour ce contrat | Référence et candidat d'essai |
| Extension Sanctuary de Dream Displays | Réutiliser la lecture, ajouter objets, pose et règles de jeu | API expérimentale ; vérifier les points d'extension du filtre, du son et de l'horloge | **Premier chemin à éprouver** |
| Fork ciblé de Dream Displays | Modifier précisément synchronisation, sortie audio et rendu | Entretenir le fork et les mises à jour du résolveur et des composants natifs | Repli si l'extension ne suffit pas |
| Mod original avec moteur natif | Maîtrise du modèle écran/séance et du son Minecraft | Décodage, composants natifs, reprise réseau et maintenance des sources à prendre en charge | Plus coûteux ; réserver aux blocages démontrés |
| Navigateur embarqué, type MCEF | Pages et lecteurs web dans le jeu | Port 26.3/Vulkan non établi, son spatial et horloges peu contrôlables, contraintes des embeds | Mauvais point de départ pour ce besoin |
Ces avis sont une évaluation d'architecture, pas un benchmark. Une extension
qui ne pourrait pas changer la synchronisation ne satisferait pas le besoin
complet, même si la pose fonctionnait. Un fork éventuel doit rester une
dépendance clairement identifiée ; ne pas copier un moteur dans Sanctuary sans
sa provenance et ses notices.
Proposition d'organisation : un module dédié sous `mods/` pour les cadres,
surfaces et séances, un adaptateur pour les claims Sanctuary, et une interface
de moteur média. Le nom et les identifiants restent à fixer. Cette séparation
permet de changer de moteur sans réécrire la fabrication ou les règles de pose ;
elle ne suppose pas que l'API Dream fournisse déjà tous les points nécessaires.
### Dream Displays : compatibilité vérifiée et limites observées
Le candidat exact est [Dream Displays 1.10.4 Fabric 26.3](https://modrinth.com/mod/dreamdisplays/version/siUKU04U),
publié le 25 octobre 2026. Son JAR a été téléchargé **en mémoire seulement**,
son SHA-512 comparé à l'API Modrinth et son `fabric.mod.json` lu :
- fichier `dreamdisplays-fabric-26.3-1.10.4.jar`, **26 809 153 octets** ;
- Loader `>=0.19`, Minecraft `~26.3-`, Java `>=25`, Fabric API requise ;
- points d'entrée client et serveur Fabric ; licence déclarée `LGPL-3.0` ;
- SHA-512 :
`c034f3e890fed144832b27442459d254ad553cbaec592d2da50162711e0f71f2313ec52639c6b1a576890b909b935c98f3699da296801dde7db81ced6d9d9eca`.
Cela satisfait les versions déclarées du socle **26.3 / Loader 0.19.5 /
Fabric API 0.160.5+26.3 / Java 25**. Cela ne valide ni son démarrage avec le
pack, ni son affichage, ni ses performances sous Vulkan et Sodium.
Les sources consultées sont figées au
[commit `0d13cb8`](https://github.com/arnodoelinger/dreamdisplays/tree/0d13cb865ac4d4d6783f556b6e8130291056ccef).
Constats distincts des annonces du projet :
1. Le [serveur expose création et redimensionnement](https://github.com/arnodoelinger/dreamdisplays/blob/0d13cb865ac4d4d6783f556b6e8130291056ccef/api/src/main/kotlin/com/dreamdisplays/api/server/ServerDisplays.kt)
par deux coins et orientation. L'appelant doit vérifier le mur. Ces API sont
marquées `@Unstable` : épingler la version et tester les refus côté serveur.
2. Le [rendu détecte Vulkan](https://github.com/arnodoelinger/dreamdisplays/blob/0d13cb865ac4d4d6783f556b6e8130291056ccef/platform/client/common/src/main/kotlin/com/dreamdisplays/platform/client/render/RenderBackendCompat.kt)
et prévoit l'envoi des textures par l'encodeur de commandes Minecraft.
Un essai Vulkan réel reste nécessaire, y compris pour le filtre pixel.
3. Le [suivi de timeline](https://github.com/arnodoelinger/dreamdisplays/blob/0d13cb865ac4d4d6783f556b6e8130291056ccef/platform/client/common/src/main/kotlin/com/dreamdisplays/platform/client/displays/TimelineFollower.kt)
abandonne la correction de position lorsque `screen.isLive` est vrai.
Pour les VOD, les tolérances initiales sont de **1 500 ms de retard** et
**3 500 ms d'avance**, avec élargissement possible du retard toléré.
Ce code ne démontre donc pas la synchronisation fine demandée.
4. La [sortie audio effective](https://github.com/arnodoelinger/dreamdisplays/blob/0d13cb865ac4d4d6783f556b6e8130291056ccef/media/player/src/main/kotlin/com/dreamdisplays/media/player/pipeline/AudioSink.kt)
utilise Java Sound (`SourceDataLine`). Le projet possède sa propre acoustique,
mais cette sortie ne traverse pas automatiquement celle de Minecraft.
L'interface publique `AudioSink` ne prouve pas qu'un remplacement soit câblable.
5. Le [code des écrans](https://github.com/arnodoelinger/dreamdisplays/blob/0d13cb865ac4d4d6783f556b6e8130291056ccef/platform/client/common/src/main/kotlin/com/dreamdisplays/platform/client/displays/DisplayScreen.kt)
prévoit déjà un état masqué sans upload vidéo, en maintenant le décodeur.
Il faudra distinguer ce gain GPU d'un véritable arrêt du décodage vidéo.
### Autres moteurs
[WATERMeDIA v3](https://github.com/WaterMediaTeam/watermedia-v3) décrit des
moteurs Vulkan et OpenAL séparés. C'est une piste technique réelle, mais le
[commit consulté `2c3dc07`](https://github.com/WaterMediaTeam/watermedia-v3/tree/2c3dc07148a3a16d99d4b9b530e76e0e2c066828)
ne déclare pas 26.3 dans ses propriétés, et l'API Modrinth ne renvoie aucune
version Fabric 26.3 pour `watermedia` ou `waterframes` lors de cette étude.
Son [texte de licence](https://github.com/WaterMediaTeam/watermedia-v3/blob/2c3dc07148a3a16d99d4b9b530e76e0e2c066828/LICENSE.md)
est **Defensive Public Source (Non-Commercial) v1.0.1** : ne pas le traiter
comme une bibliothèque librement interchangeable avec le socle GPL.
Un décodeur FFmpeg ou libVLC peut produire des images CPU et du PCM, puis
Sanctuary peut les envoyer à ses moteurs graphique et audio. C'est réalisable
en architecture ; cela laisse à éprouver les copies mémoire, les composants
natifs par système et la tenue des horloges. Aucun gain matériel ni absence
de copie ne doit être promis sans mesure. Pour
[MCEF](https://github.com/CinemaMod/mcef), aucune version Fabric 26.3 n'est
retournée par la même requête Modrinth ; cela ne prouve pas l'absence d'un port
sur un autre canal, mais ne permet aucune intégration immédiate vérifiée.
## Pose et interactions proposées à partir des décisions
Fabriquer un **cadre de redstone** avec huit bâtons et une redstone centrale.
Poser le cadre sur un mur crée une ancre 1 × 1 et ouvre le menu. L'ancre reste
le coin **inférieur gauche vu de face** ; le choix du mur fixe l'orientation.
Le menu donne largeur, hauteur, aperçu dans le monde et coût restant.
Annuler conserve le cadre 1 × 1 déjà posé ; confirmer applique l'agrandissement.
Les dimensions sont entières, sans fusion automatique avec les écrans voisins.
Des boutons 4 × 3, 8 × 4 et 16 × 9 peuvent accompagner la saisie libre. Une
limite initiale **proposée**, à mesurer, serait 32 × 18 blocs : la taille dans
le monde ne doit pas imposer le décodage d'une vidéo 4K.
L'image conserve son ratio par défaut, avec bandes si nécessaire.
Le coût a été révisé par Koka après les essais du moteur : **un cadre pour
l'ancre, aucun coût pour agrandir**. Réduire ne rend aucun objet ; casser
l'ancre rend un seul cadre en survie. Le [prototype MEDIA-04](media-anchor-prototype.md)
conserve le menu Dream d'origine pour les flux et dessine le cadre sur toute
la surface, avec dimensions libres et budgets serveur configurables.
Avant confirmation, vérifier tout le rectangle : support mural, espace devant,
absence de chevauchement, limites du monde, inventaire et droits de construction
sur chaque chunk traversé. La propriété des commandes de lecture reste distincte
du droit de déplacer ou casser une construction. Perdre un support doit déposer
l'écran une seule fois ; un chunk déchargé ne constitue pas un support détruit.
Le changement de dimensions ne charge pas de chunks de force.
Le rectangle entier est interactif. Le rayon part **des yeux du joueur**, dans
la direction du viseur ; il rencontre le premier obstacle ou écran admissible.
Le serveur recalcule portée, visibilité, dimension et cible pour chaque action :
un paquet client ne suffit pas à autoriser l'interaction.
- **Clic droit jusqu'à 16 blocs** : ouvrir le lecteur ; changer la vidéo,
lecture/pause et déplacement dans une VOD agissent sur la séance commune.
- **Volume, muet, qualité et participation** : réglages personnels.
- **Maj + clic droit à portée habituelle de construction** : dimensions et
entretien pour les joueurs ayant les droits de construction. À distance,
présenter le lecteur sans accorder la modification de géométrie.
- Une action explicite du lecteur modifie la séance ; ouvrir le menu seul
ne met pas tous les joueurs en pause. Afficher qui a déclenché une action
et borner sa fréquence pour éviter les répétitions accidentelles.
Une seule ancre logique et un seul rectangle sont préférables à une entité
et un lecteur par bloc. L'index spatial doit couvrir toute la surface : un
grand écran ne doit pas disparaître parce que son ancre est hors du champ.
Prévoir les libellés FR/EN, par exemple « Cadre de redstone / Redstone frame »,
« Largeur / Width », « Hauteur / Height » et « Séance partagée / Shared session ».
Les identifiants de nouveaux objets seront fixés avant leur premier enregistrement.
## Synchronisation et charge
Le serveur garde l'identité du média, l'identifiant de séance, la révision,
l'état lecture/pause, une position de référence et une horloge. Les clients
récupèrent le média directement auprès de sa source, le décodent et l'affichent.
Les images et les échantillons audio ne transitent pas par les paquets Minecraft.
Cela épargne le trafic vidéo au serveur ; chaque spectateur utilise néanmoins
sa propre bande passante. « Sans télécharger » signifie sans télécharger
préalablement tout le fichier : les données doivent toujours arriver chez lui.
Pour une VOD, estimer le décalage d'horloge avec plusieurs échanges réseau,
programmer le démarrage après une prélecture et calculer la position attendue
depuis une horloge monotone, indépendante du temps du monde et des TPS.
Corriger progressivement les petits écarts si le lecteur le permet ; réserver
les sauts aux écarts importants. Les événements lecture/pause/source sont
immédiatement annoncés avec leur instant d'effet ; les états périodiques sont
espacés. Les révisions empêchent un ancien paquet de rétablir une ancienne vidéo.
Un entrant rejoint la position commune. Un client lent rebufférise puis rejoint
la séance ; proposition : il ne met pas automatiquement tous les autres en pause.
Si l'API amont ne permet pas cette horloge et ces corrections, un fork ou un
autre moteur devient nécessaire. Diminuer ses seuils de seek ne suffit pas à
prouver une synchronisation plus stable.
Pour le direct, lancer la même URL simultanément ne garantit pas le même contenu
au même instant. Il faut une référence temporelle commune dans le média, puis
viser cette référence avec un retard partagé. Les dates de segments HLS
[`EXT-X-PROGRAM-DATE-TIME`](https://datatracker.ietf.org/doc/html/rfc8216#section-4.3.2.6)
peuvent aider lorsqu'elles existent ; les discontinuités et variantes doivent
être traitées. Sans timeline comparable ou fenêtre de reprise suffisante,
signaler une synchronisation approximative ou refuser le mode strict.
L'[embed officiel Twitch](https://dev.twitch.tv/docs/embed/video-and-clips/)
ne fournit pas de seek live et réserve `getCurrentTime` aux VOD : un navigateur
ne résout donc pas à lui seul la contrainte. Publicités individualisées,
buffering, restrictions d'accès ou variantes différentes peuvent séparer les
spectateurs. Une source commune contrôlée ou un relais média **distinct**
pourrait améliorer la maîtrise, mais ajouterait exploitation et bande passante ;
ce serait un choix séparé, jamais une charge vidéo implicite du serveur Minecraft.
Objectifs de prototype proposés, **non mesurés** : écart entre clients ≤250 ms
pour une VOD stable et ≤500 ms pour un live offrant une référence commune,
au 95e percentile sur dix minutes. Mesurer séparément retard sur le direct,
écart entre spectateurs et désynchronisation audio/vidéo. Un tampon de plusieurs
secondes est envisageable ; sa valeur dépend de la source et des connexions.
## Pixellisation et culling
Un écran `W × H` blocs a une grille de `16W × 16H` pixels :
8 × 4 → 128 × 64 ; 16 × 9 → 256 × 144. Réduire l'image avec préfiltrage,
puis agrandir sans lissage pour conserver des pixels nets. Un simple
échantillonnage nearest d'une vidéo haute résolution peut scintiller ; comparer
le résultat sur du mouvement. Le filtre reste une option par écran ; un réglage
personnel de qualité peut limiter la charge sans changer la séance.
Ce filtre réduit le coût d'affichage et de transfert si la réduction est faite
avant l'upload. Il ne réduit pas automatiquement le débit ou le coût du décodage
d'une source 1080p. Sélectionner aussi une variante adaptée et borner cadence,
mémoire et nombre de séances actives. Partager un décodeur entre plusieurs
surfaces **de la même séance**, pas simplement entre deux URLs identiques
qui pourraient être lues à des positions différentes.
| État local | Vidéo | Audio |
| --- | --- | --- |
| Surface visible | Décodage et upload dans les budgets fixés | Son spatial |
| Hors champ ou derrière le joueur, son audible | Aucun dessin ni upload ; supprimer le décodage vidéo si le moteur le permet | Continue |
| Masqué par un mur | Même politique de visibilité, sans confondre masquage et mute | Propagation et absorption acoustiques |
| Hors portée visuelle et auditive, sans aperçu ouvert | Libérer le lecteur après une courte temporisation | Arrêt local |
| Retour dans la zone | Reprendre la position de séance actuelle | Reprendre synchronisé |
L'occlusion visuelle ne prouve pas l'inaudibilité. L'abonnement à l'audio doit
rester indépendant de la visibilité et du suivi des entités Minecraft.
Éviter les changements incessants à la limite de portée. Le déchargement d'un
lecteur local ne met pas la séance serveur en pause et ne force aucun chunk.
## Audio, disques et sources
Le premier écran émet son propre son à un point de sa surface, avec atténuation
spatiale, volume personnel et catégorie dédiée. Pour que Sound Physics
l'affecte, il faut fournir le PCM au moteur audio Minecraft ou appeler une
intégration explicite sur une source compatible. Son
[code `processSound`](https://github.com/henkelmax/sound-physics-remastered/blob/06368ea194f056097201f8395ea418c831d562da/common/src/main/java/com/sonicether/soundphysics/SoundPhysics.java)
travaille sur des sources OpenAL : Java Sound seul ne suffit pas. Même une
source OpenAL extérieure doit partager le contexte et les hooks appropriés.
Éviter d'appliquer simultanément l'acoustique Dream et celle de Sound Physics.
Le son spatial de base doit fonctionner lorsque ce dernier est absent ou éteint.
Le disque blanc pourra porter une référence stable validée, jamais un fichier
audio complet ni une URL signée expirante sauvegardée comme identité. Proposition :
renommer avec une référence YouTube reconnue permet son inscription ; un petit
menu de collage évite les limites pratiques d'une longue URL dans un nom.
Le jukebox créera ensuite une séance audio avec le même modèle de serveur et
les mêmes contrôles personnels. Enceintes liées et mixeur spatial restent un
lot ultérieur, pour ne pas multiplier les décodages ni faire doubler le son
de l'écran et du jukebox.
| Source | Faisabilité et périmètre proposé |
| --- | --- |
| Fichier vidéo/audio HTTP(S), flux HLS compatible | Première preuve du lecteur, du son et de l'horloge ; vérifier les codecs exacts |
| YouTube VOD puis live | Connecteur de résolution ; revalider les liens expirants et les erreurs de lecture |
| Twitch | Connecteur live spécifique et preuve de timeline commune ; aucune promesse de synchro stricte par simple URL |
| Page contenant un lecteur HTML5 | Une page n'est pas une URL média ; connecteur nécessaire si elle cache son manifeste ou dépend de scripts/authentification |
| ANI-CLI | Référence de recherche/résolution de sources, à traiter séparément du lecteur Minecraft |
| Spotify | Ne pas annoncer la lecture de morceaux complets comme une simple URL audio |
[ANI-CLI](https://github.com/pystardust/ani-cli/tree/0301d67d5768d332b049821a30082ad43d9a215a)
est un outil shell qui résout des liens et lance un lecteur externe. Une
intégration éventuelle doit séparer catalogue, sélection d'épisode, résolution,
pistes audio/sous-titres et lecture. Le lien peut nécessiter des métadonnées
de requête et expirer ; il ne devient pas une source universelle parce qu'il
fonctionne dans mpv. Ne pas imposer Bash et ANI-CLI à tous les clients avant
d'avoir défini et éprouvé ce connecteur sur leurs systèmes.
La [politique Spotify](https://developer.spotify.com/policy) réserve le streaming
aux abonnés Premium et exclut notamment les jeux et certains usages simultanés.
Ce scénario n'a donc pas de voie officielle simple identifiée. Une référence
Spotify pourrait servir à identifier un titre et proposer une autre source
explicitement choisie ; cela ne constitue pas une prise en charge audio Spotify.
Les URLs média doivent être validées côté serveur et client : HTTP(S), limites
de taille et de redirections, pas de fichier local ni d'accès réseau privé
déclenché par un autre joueur. Les liens autorisés et la participation restent
configurables ; aucun cookie personnel partagé ni URL à jeton dans les journaux.
## Lots vérifiables proposés
1. **MEDIA-02 — Faisabilité du moteur.** Monde neuf de développement, serveur et
deux clients 26.3 ; vidéo de test horodatée et flux live contrôlé, Vulkan
avec Sodium puis sans. Vérifier image, son, dérive, retour dans la zone,
mémoire et arrêt propre. Produire une décision extension/fork/moteur original.
2. **MEDIA-03 — Cadre et surface.** Recette, menu FR/EN, aperçu, coût par bloc,
restitution, mur et claims, raycast 16 blocs, clics sur toute la surface,
concurrence et rechargement. Source contrôlée seulement, puis filtre 16 pixels.
3. **MEDIA-04 — Séances et sources publiques.** Contrôles communs à tous les
spectateurs, arrivée tardive, buffering et déconnexion ; YouTube puis Twitch,
avec métriques et limites annoncées pour chaque source.
4. **MEDIA-05 — Disque blanc.** Inscription, lecture audio par jukebox, éjection,
sauvegarde et reconnexion ; réutilisation du lecteur et de l'horloge.
5. **MEDIA-06 — Sources complémentaires.** ANI-CLI/catalogue et sous-titres,
puis éventuellement enceintes et mixeur selon les besoins constatés.
Pour chaque lot de code : branche dédiée, prochain compteur beta libre,
`./gradlew check build`, tests ciblés du comportement ; `assemblePack` lorsque
la distribution change. Vérifier les composants natifs par OS/architecture,
leurs licences, leur intégrité et les téléchargements du premier lancement.
Épingler les artefacts packwiz avant une livraison demandée. Aucun décodeur
ni navigateur ne doit être chargé par erreur côté serveur dédié.
Toute donnée persistante nouvelle doit avoir son contrat de version avant
son premier usage dans un monde existant.
## Vérifications de cette étude et limites
- État Git initial lu ; travail isolé depuis beta.293 et changements beta.294
conservés dans leur checkout.
- Requêtes Modrinth filtrées exactement par `game_versions=["26.3"]` et
`loaders=["fabric"]` : huit versions Dream, zéro pour chacun des projets
`watermedia`, `waterframes`, `mcef` et `webdisplays` au moment de la lecture.
- Intégrité et métadonnées du JAR Dream 1.10.4 vérifiées sans l'installer.
- Sources amont et contrats d'API lus aux références précisées dans ce document.
- Aucune compilation, mesure de performances, lecture audible, synchronisation
réelle à deux clients ou validation graphique n'a été réalisée pour ce ticket
documentaire. Aucun essai OpenGL ne fait partie du plan de validation.
- Les plafonds de taille et de lecteurs, le comportement d'un client lent,
les restitutions et l'inventaire des systèmes clients restent des propositions
à préciser dans les lots de réalisation.
Le choix d'implémentation reste conditionné par MEDIA-02. Aucun artefact pack,
serveur Beta, canal stable, installation Prism ou monde personnel n'est modifié.
+88
View File
@@ -0,0 +1,88 @@
# MEDIA-06 — interaction et épaisseur des cadres
Retours du 26 octobre 2026 après essai de beta.296. Branche
`codex/media-frame-fixes`, **beta.297**, module Media **0.3.1**.
[Livraison complète du 27 octobre 2026](release-beta297.md).
## Comportement demandé
Le menu largeur/hauteur s'ouvre automatiquement seulement à la pose. Ensuite,
clic droit et **Maj + clic droit** ouvrent le lecteur Dream. Le bouton
**Taille / Size** de ce lecteur reste le point d'entrée pour redimensionner,
avec les mêmes vérifications serveur de proximité et de construction.
Appliquer une taille garde le menu ouvert pour l'ajuster ; aucune modification
du coût, de la recette ou des tailles autorisées.
Les trois chemins sont alignés : raycast client, paquet d'ouverture serveur
et interaction directe avec le bloc. La pose initiale conserve sa demande
explicite du menu de taille.
## Rendu
Le pixel rouge était un repère d'ancrage ajouté par Sanctuary. Il est retiré
du cadre du monde et de l'aperçu de taille.
Le cadre précédent utilisait le repère vidéo Dream. Celui-ci applique une
échelle Z nulle : c'est un dessin plat, sans faces latérales. Le nouveau
maillage est un anneau fermé en unités de blocs : quatre bandes avant,
quatre arrière, quatre côtés extérieurs et quatre faces de feuillure.
Épaisseur et largeur du bois : **1/16 de bloc**, indépendamment de la taille.
Le contour extérieur est exactement celui de l'écran, au contact du mur.
La vidéo conserve son plan et ses proportions, en retrait du bois.
Les sommets sont calculés depuis la géométrie de l'ancre et ramenés dans le
repère du renderer Dream sans passer par sa transformation vidéo aplatie.
Ils gardent les mêmes positions dans les passes normale et de reprise :
aucune deuxième bordure décalée. Le pipeline Vulkan Dream et sa profondeur
restent utilisés, sans nouveau shader.
La vidéo et les sous-titres sont masqués depuis l'arrière du plan, et depuis
les côtés lorsque l'observateur est derrière la face avant du bois et hors
de l'ouverture. Cela évite un liseré vidéo en vue rasante ; une caméra située
dans l'ouverture conserve l'image. Le cadre en volume reste visible.
Le son reste actif, et les uploads vidéo
sont suspendus comme pour un écran hors champ. Les écrans Dream ordinaires
gardent leur rendu.
L'ancre invisible ne dessine plus son contour de sélection vanilla 1 × 1 à
l'intérieur d'un grand écran. Sa forme d'occlusion est explicitement vide ;
sa forme de ciblage reste disponible pour casser le bloc. Les validations
du mur et le retrait de l'écran côté serveur sont conservés.
## Données et livraison
Aucun identifiant, paquet ou format de sauvegarde ne change. Les écrans posés
en beta.296 retrouvent le nouveau rendu à partir de leurs données existantes,
sans remplacement de blocs ni conversion de monde. Aucun monde personnel
ou serveur personnel n'est utilisé pour la validation.
Le correctif est publié en beta.297 et installé sur la Beta, le site et la
même instance Prism. Les essais de développement ont précédé cette livraison.
## Validation
- `check build assemblePack assembleServerPack` : **réussi en 4 min 17 s**,
158 tâches, **18 GameTests requis** (`property270,claims272,claims277,claims278`).
- Contrat Java du maillage : 16 faces non dégénérées, bornes exactes et
épaisseur constante sur quatre tailles et les quatre orientations ; vidéo
visible dans l'ouverture et masquée derrière le bois depuis les côtés.
- **17 contrôles interactifs** réussis : nouvelle pose, réouverture normale,
Maj réellement maintenue, bouton Taille et protections/inventaire existants.
- Après correction du dernier liseré vidéo : contrats Media et assemblages
ciblés réussis en **21 s**, puis nouvel essai avec le JAR final embarqué.
- Deux clients **Vulkan**, Sodium et Sound Physics, vidéo synthétique sur
écran **9 × 5**. Captures inspectées de face, de droite, de gauche, du haut
et de l'arrière : côtés présents, aucun repère rouge ni fente colorée.
- Depuis droite/gauche/haut/arrière : uploads de l'image arrêtés et curseur
OpenAL toujours en mouvement ; retour devant : uploads repris.
- Sept tests du rapport et compilation syntaxique Python réussis.
Le premier laboratoire `frame-fixes-01` valide l'interaction ; ses premières
vues rasantes ont révélé le liseré restant. Seules les captures de
`build/media-prototype/frame-fixes-02/` valident son retrait final. Le maillage
est testé numériquement sur quatre orientations ; le parcours graphique est
orienté sud. Aucune nouvelle validation de live public ou d'écoute subjective.
[Relevé structuré](media-frame-fixes-results.json). Le
[reçu de livraison](release-beta297.md) distingue ces essais du déploiement
et de ses vérifications opérationnelles.
+77
View File
@@ -0,0 +1,77 @@
{
"ticket": "MEDIA-06",
"version": "beta.297",
"media_version": "0.3.1",
"state": "published and installed on Beta server, Web distribution and existing Prism instance",
"delivery": {
"date": "2026-10-27",
"receipt": "release-beta297.md",
"source_tag": "bad2bdb0d12295a736aee64e08b9f6f5a8ae81d3",
"packwiz_commit": "e40b0d372af9fa43a301b842661c131f4e69f7c7",
"fresh_check_build_assemble": "passed, 2m57s, 158 tasks, 18 required GameTests; identical JAR to final Vulkan validation"
},
"branch": "codex/media-frame-fixes",
"artifact_sha256": "f9bfffb0198abb8a4eae8a6d76a1ad3f1190edda7ce33f719bd47f0f4da27d9d",
"interaction_suite": {
"passed": [
"one frame consumed on placement",
"first placement opens dimensions",
"reopening near the anchor opens native player",
"Shift-right-click also opens native player",
"custom 7x4 and free enlargement",
"shrinking does not refund frames",
"resizing keeps the same display",
"oversize refused",
"incomplete wall refused",
"stale revision refused",
"native delete, overlap, obstacle and unloaded chunks refused",
"building permission required",
"original Dream reader on remote overlay while holding frame",
"forged remote resize refused",
"wall blocks normal interaction",
"wall blocks forged open packet",
"original Dream reader available to second spectator"
]
},
"final_graphics_checks": {
"placement_opens_dimensions": true,
"ordinary_click_player": true,
"shift_click_player": true,
"right_video_hidden_audio_continues": true,
"left_video_hidden_audio_continues": true,
"top_video_hidden_audio_continues": true,
"back_video_hidden_audio_continues": true,
"front_picture_resumes": true
},
"environment": {
"minecraft": "26.3",
"backend": "Vulkan",
"clients": 2,
"dream": "1.10.4",
"sound_physics": "1.5.1+26.3",
"screen_blocks": [
9,
5
],
"camera_views": [
"front",
"right grazing",
"left grazing",
"top grazing",
"back",
"front after hidden"
]
},
"validation": {
"full_check_build_assemble": "passed, 4m17s, 158 tasks, 18 required GameTests",
"final_client_visibility_contracts_and_assembly": "passed, 21s, 27 tasks",
"image_inspection": "five final views inspected; no red marker, no colored video slit through sides",
"python_report_tests": 7
},
"diagnostic": "frame-fixes-01 grazing images precede the final video visibility fix; final images are frame-fixes-02",
"limits": [
"No new public streaming or audio listening validation",
"Graphical wall faces south; all four orientations checked numerically",
"Graphical tests used development instances; deployment to the existing personal installation is documented in the delivery receipt, no personal world opened"
]
}
+232
View File
@@ -0,0 +1,232 @@
# MEDIA-02 — Prototype de lecture à deux clients Vulkan
Suite : [MEDIA-03 — correction de synchronisation et pont audio](media-sync-audio-prototype.md).
Les mesures ci-dessous décrivent Dream Displays intact, avant cette extension.
Branche `codex/media-playback-prototype`, **26 octobre 2026**, base Sanctuary
**beta.293**, à la suite de [MEDIA-01](media-displays-study.md).
Le prototype utilise de vrais clients Minecraft réseau et un serveur dédié
local. Aucun cadre Sanctuary n'est développé. Le module de mesure
[`mods/media-probe`](../mods/media-probe/README.md) est compilé séparément et
n'entre dans aucun pack : aucune livraison du mod Sanctuary, aucun compteur
beta ni manifeste packwiz modifié.
## Décision issue de l'essai
**Dream Displays constitue une base de rendu et de lecture utilisable sous
Vulkan, mais son comportement intact ne satisfait pas la synchronisation fine
du groupe et ne fait pas passer le son vidéo dans Sound Physics.** La prochaine
preuve doit porter sur l'horloge commune du live et une sortie PCM compatible
avec le moteur audio Minecraft, avant les cadres.
Les premières tentatives avaient un défaut du laboratoire : les deux clients
choisissaient la même couleur dans Hello World. L'un restait dans le formulaire,
pendant que l'autre commençait à lire. Koka l'a observé et signalé. Le lancement
retenu utilise des couleurs distinctes et attend **les deux joueurs admis,
dans le monde, sans menu bloquant**, avant de transmettre la nouvelle source.
Le départ décalé de cette première tentative n'est pas une mesure de la dérive
du lecteur. Les résultats ci-dessous viennent de la relance `review-05` corrigée.
## Environnement et méthode
- Minecraft **26.3**, Fabric Loader **0.19.5**, API **0.160.5+26.3**, Java **25**.
- Dream Displays **1.10.4**, JAR exact `siUKU04U`, SHA-512 vérifié par le lanceur.
- Sound Physics Remastered **1.5.1+26.3** ; activé et désactivé uniquement dans
les configurations des clients de laboratoire.
- Série principale : Sodium **0.9.2+mc26.3**, backend **Vulkan**, MoltenVK **1.4.2**,
Apple M1 et **8 Gio de RAM système**. Serveur et deux clients sur le même Mac.
- Tas plafonnés à 768 Mio serveur et 1 536 Mio par client. Vue de trois chunks,
deux joueurs créatifs dans un monde plat neuf, graine **2602**, écran Dream
de béton noir de 9 × 5 blocs. Shaders Sanctuary éteints.
- Source synthétique H.264/AAC de **320 × 180 à 30 images/s**, durée **660 s**,
motif animé et numéro d'image encodé en 16 bandes noires/blanches. Le live HLS
répète cette source, avec segments d'une seconde, fenêtre de douze segments
et `EXT-X-PROGRAM-DATE-TIME`. Source desservie sur loopback avec HTTP Range.
- FFmpeg système **8.1.2**, sélectionné par le cache amont isolé. La bibliothèque
libav embarquée ne s'ouvre pas sur cette machine ; Dream utilise son chemin
natif de tuyauterie avec processus FFmpeg. Ce repli fait partie du résultat.
Des logs montrent des sorties de décodage redimensionnées à 1296 × 720 :
la petite source ne signifie donc pas que toutes les copies internes sont petites.
Le code temporel est vérifié après encodage aux secondes 1, 10 et 25 : images
30, 300 et 750 retrouvées. La sonde lit ensuite ce code et note les **uploads
GPU réussis** dans chaque client, avec leur heure. Le rapport aligne les
observations distantes d'au plus 150 ms, corrige leur décalage d'observation
et sépare les quinze premières secondes de chaque phase. Le bouclage de la
source est pris en compte. Résolution temporelle du média : environ 33 ms.
Ce sont des mesures d'envoi d'images au GPU, **pas du balayage physique de
l'écran**. Les captures Minecraft vérifient que les surfaces sont réellement
visibles. Le rapport expose aussi les échantillons manquants et les erreurs
de sonde ; une absence de données n'est jamais un succès.
## Résultats de la série avec Sodium
Écarts absolus entre les deux clients, après les quinze premières secondes :
| Phase | Durée observée | Paires | Médiane | 95e percentile | Maximum |
| --- | ---: | ---: | ---: | ---: | ---: |
| VOD, départ avec les deux clients prêts | 142 s | 504 | 181 ms | **337 ms** | 368 ms |
| Même VOD, Sound Physics activé | 126 s | 443 | 242 ms | **332 ms** | 335 ms |
| Live, départ commun | 175 s | 640 | 25 ms | **57 ms** | 75 ms |
| Live, Sound Physics activé | 90 s | 299 | 49 ms | **128 ms** | 134 ms |
| Live, après arrêt puis retour du client B | 139 s | 374 | 549 ms | **580 ms** | 592 ms |
| Retour des caméras vers l'écran | 35 s | 80 | 611 ms | **636 ms** | 639 ms |
| Fin du live après le retour | 91 s | 304 | 721 ms | **741 ms** | 745 ms |
Les durées incluent les transitions et, pour la reconnexion, l'absence du client ;
le nombre de paires indique ce qui a effectivement été comparable. Le client B
est arrêté, attendu vingt secondes, puis relancé dans le live en cours.
Il ne s'agit pas d'un simple joueur simulé sur le serveur.
Les deux clients tiennent une médiane de **30 FPS** dans les phases de lecture
stable de cette scène. Aucun échec de sonde n'est enregistré dans la série.
Cela ne constitue pas une validation de plusieurs écrans, de contenu 1080p/4K
ou de machines différentes.
Les cibles exploratoires de MEDIA-01 étaient 250 ms pour une VOD et 500 ms
pour un live. La VOD dépasse sa cible et le live la dépasse après reconnexion.
Le bon résultat du départ live reflète ici une source et un réseau locaux
communs ; il ne prouve pas une correction serveur de la position live.
Les sources amont examinées dans MEDIA-01 ignorent justement cette correction
pour les médias live. Les résultats ne démontrent pas une causalité de Sound
Physics sur la dérive : l'ordre des phases et le temps écoulé diffèrent.
## Audio et visibilité
L'activité réelle de la ligne **Java Sound** est observée dans les deux clients.
Sound Physics initialise ses effets EFX et la sonde voit notamment ses appels
pour les sons témoins `minecraft:block.note_block.pling`, joués toutes les cinq
secondes. La ligne vidéo continue à avancer pendant l'activation de Sound Physics
et le rechargement du moteur audio Minecraft.
**Coexistence observée ; intégration acoustique vidéo absente.** Le son vidéo
sort par `SourceDataLine`, tandis que Sound Physics traite les sources OpenAL
Minecraft. Aucun nouvel appel Sound Physics correspondant à la source vidéo
n'apparaît. Le traitement spatial propre de Dream est désactivé pour cet essai,
afin de ne pas lui attribuer les effets de Sound Physics. Une passerelle PCM
ou une autre sortie audio est donc requise. Aucune écoute subjective de
réverbération, mesure de sortie physique ou capture du microphone n'est revendiquée.
Pendant la phase hors champ de 35 secondes, les caméras regardent à l'opposé :
| Client | Intervalle comparable | Nouveaux uploads GPU | Trames PCM jouées |
| --- | ---: | ---: | ---: |
| A | 34,25 s | **0** | **1 510 454** |
| B | 34,00 s | **0** | **1 499 165** |
L'audio continue donc quand l'image n'est plus envoyée au GPU. Au retour,
les uploads reprennent. Le décodeur reste actif : ce résultat valide le masquage
des uploads, pas une économie complète de décodage. Le coût de plusieurs sources
et l'occlusion par un mur restent à tester séparément.
## Reproduire dans un nouveau laboratoire
Java 25, Python **3.11 ou plus récent**, FFmpeg avec encodeur H.264 et un poste
graphique Vulkan sont requis. Garder de l'espace disque pour les dépendances,
le swap du système et les mondes de test ; la saturation observée lors d'une
première tentative a été exclue de la série retenue. Seuls des doublons de
médias et binaires générés par ce laboratoire ont été supprimés ou dédupliqués.
```sh
JAVA_HOME=$(/usr/libexec/java_home -v 25) ./gradlew \
:sanctuary-test:exportDuoLaunch --max-workers=1 \
-Dorg.gradle.jvmargs=-Xmx1G
python3.14 scripts/media_lab.py prepare --run essai-neuf
python3.14 scripts/media_lab.py server --run essai-neuf
# Attendre MEDIA_PROBE_SERVER_READY, puis lancer les deux clients :
python3.14 scripts/media_lab.py client-a --run essai-neuf
python3.14 scripts/media_lab.py client-b --run essai-neuf
# Dans un terminal distinct ; cette commande reste active :
python3.14 scripts/media_lab.py serve --run essai-neuf
```
Dans un autre terminal, conserver chaque phase de lecture deux à trois minutes
avant la transition suivante. Les commandes ci-dessous décrivent les transitions,
pas un scénario à exécuter sans attente :
```sh
python3.14 scripts/media_lab.py control --run essai-neuf \
--phase vod --source vod
# L'URL n'est appliquée qu'après admission effective des deux clients.
python3.14 scripts/media_lab.py control --run essai-neuf \
--phase vod-spr --sound-physics
python3.14 scripts/media_lab.py live --run essai-neuf
python3.14 scripts/media_lab.py control --run essai-neuf \
--phase live --source live --sound-physics
# Tester aussi une arrivée tardive : arrêter seulement B, attendre, puis relancer.
python3.14 scripts/media_lab.py stop --run essai-neuf --target client-b
sleep 20
python3.14 scripts/media_lab.py client-b --run essai-neuf
python3.14 scripts/media_lab.py control --run essai-neuf \
--phase live-hidden --sound-physics --look-away
python3.14 scripts/media_lab.py control --run essai-neuf \
--phase live-visible --sound-physics
python3.14 scripts/media_lab_report.py build/media-prototype/essai-neuf
python3.14 scripts/media_lab.py stop --run essai-neuf
# Arrêter aussi le terminal « serve » avec Ctrl-C.
```
Un nom de session existant est refusé par `prepare`. Les sources, paramètres,
PID, scènes et configurations résident exclusivement sous `build/media-prototype/`.
Le mode hors ligne et l'autorisation d'URL privées concernent uniquement ces
processus sur `127.0.0.1`. Ne pas transposer ces réglages au pack ou à la Beta.
Pour le parcours sans Sodium, ajouter `--without-sodium` à `prepare`.
## Preuves locales et vérifications
- Série principale : `build/media-prototype/review-05/report.json`, journaux
`server.log`, `client-a.log`, `client-b.log`, mesures `*.jsonl` et manifeste.
- Captures de contrôle :
[client A](../build/media-prototype/review-05/client-a/screenshots/media-live-final-proof.png),
[client B](../build/media-prototype/review-05/client-b/screenshots/media-live-final-proof.png).
- Six tests du calcul de mesure : observation décalée sans dérive, décalage réel
de 300 ms, image ancienne, deuxième client absent, observations trop éloignées
et passage au bouclage de la source. `python3 -m unittest discover -s scripts
-p test_media_lab_report.py` réussi.
- Les sources de la sonde sont compilées par `javac --release 25` dans le
lanceur. L'intégrité des artefacts Dream et Sodium est vérifiée avant usage ;
le profil Loom conserve la vérification SPR du dépôt.
Le parcours **sans Sodium** utilise `build/media-prototype/without-sodium-01/`,
deux vrais clients Vulkan et Sound Physics activé. Sur **162 s** de VOD,
588 paires après quinze secondes : médiane **172 ms**, 95e percentile **242 ms**,
maximum **265 ms**, médiane de 30 FPS. Le rendu est aussi vérifié par les captures
et les compteurs d'upload. Ce parcours court ne démontre pas que Sodium explique
la différence avec la première série, et ne valide pas le live sans Sodium.
`check build` réussit en **3 min 4 s**, 145 tâches, avec les **18 GameTests serveur
requis réussis** et les autres contrôles du dépôt. Commande exacte :
```sh
JAVA_HOME=$(/usr/libexec/java_home -v 25) ./gradlew check build \
--max-workers=1 -Dorg.gradle.jvmargs=-Xmx1G \
-PsanctuaryFocusedTests=property270,claims272,claims277,claims278 \
-PsanctuaryAtlasOnly=true --console=plain
```
Journal : `build/media-prototype/check-build-retry.log`. La première invocation
échouait dans la préparation du cache Loom, avant les tests, faute d'espace.
Seul le cache régénérable de ce worktree et un téléchargement du laboratoire
ont été retirés avant la relance. Les mesures des deux séries retenues ne
contiennent pas d'erreur de disque plein. Aucun `assemblePack` n'est nécessaire
pour cette sonde séparée ; la distribution est inchangée.
Les processus Minecraft et FFmpeg des deux séries, ainsi que leurs serveurs
HTTP, sont arrêtés après les mesures. Les fichiers de preuve restent présents.
Aucune installation personnelle, aucun monde de joueur,
serveur Beta, canal packwiz ou instance Prism n'a été modifié.
## Limites et suite
Les phases sont exploratoires et durent quelques minutes, pas dix minutes
chacune. Les clients partagent le même Mac et le même réseau loopback ; les
résultats ne prouvent pas une tenue à distance, sous pertes réseau ou sur Windows
et Linux. YouTube et Twitch n'ont pas été utilisés comme sources pendant ces
mesures : leur résolution, leurs variantes et leurs publicités restent ouverts.
Avant les cadres, réaliser une preuve ciblée de suivi live par référence de
média commune, y compris après reconnexion, et une preuve de sortie PCM vers
Minecraft/OpenAL avec Sound Physics. Éviter de diminuer seulement les seuils
de seek VOD : ce prototype ne montre pas que cela corrigerait le live.
+175
View File
@@ -0,0 +1,175 @@
# MEDIA-03 — Synchronisation et acoustique des vidéos
Suite : [MEDIA-04 — ancre unique, cadre complet et menu Dream d'origine](media-anchor-prototype.md).
Branche `codex/media-sync-audio`, **26 octobre 2026**, base Sanctuary **beta.293**.
Suite de [MEDIA-02](media-playback-prototype.md). Le module séparé
[`sanctuary-media`](../mods/sanctuary-media/README.md), **0.2.0-prototype**, corrige
le lecteur expérimental ; aucun cadre n'est développé, aucun pack ni monde
personnel modifié. Ce n'est pas une nouvelle livraison des binaires Sanctuary :
les compteurs beta et le canal stable restent inchangés.
## Comportement
Chaque client estime l'heure du serveur avec des échanges ping/réponse et remet
son estimation à zéro après déconnexion. Le serveur garde la timeline, sans
décoder, télécharger ou retransmettre la vidéo. L'estimation retient le plus
petit aller-retour récent ; son évolution est bornée et monotone.
La VOD utilise cette timeline dès le démarrage des décodeurs audio **et vidéo**.
Avancer seulement le curseur audio était insuffisant : lors d'une première
tentative de reconnexion, FFmpeg redécodait la vidéo depuis zéro. Le correctif
`SessionStartMixin` donne maintenant l'offset serveur à la session de décodage.
Les commandes FFmpeg de la reprise vérifiée contiennent `-ss 96.593000` pour
les deux pistes. Les petites corrections suivent le nombre d'échantillons
effectivement consommés par OpenAL, avec une vitesse limitée à ±2 %. Un retard
de plus de 200 ms supprime le PCM en retard. Le rattrapage ne dépend pas du motif
de la vidéo de test. L'avance empirique de 2,5 secondes de Dream est retirée.
Pour le live **HLS média avec `EXT-X-PROGRAM-DATE-TIME`**, un service loopback
propre à chaque client fixe les trois premiers segments et leurs origines
audio/vidéo. Il réécrit les manifestes, pas les médias : les segments restent
téléchargés directement par le client. Les deux lecteurs suivent la même heure
avec **huit secondes de retard commun**. L'origine vidéo et l'origine audio
peuvent différer ; le pont compense cet écart. Les nouvelles images restent
masquées tant que leur origine est dans le futur par rapport à ce retard.
Le PCM Dream passe dans une source **OpenAL mono spatialisée**, dans le contexte
audio de Minecraft, au centre de l'écran. Le curseur de volume **Jukebox / blocs
musicaux** reste applicable. Dream conserve son atténuation de distance ; son
DSP acoustique est désactivé pour éviter l'empilement. Sound Physics évalue la
source fixe quatre fois par seconde. L'évaluation utilise la catégorie interne
BLOCKS, car SPR ignore RECORDS quand `update_moving_sounds=false` ; le volume
reste celui des disques. Le pont ne possède ni ne ferme le contexte Minecraft.
## Réglage demandé : exclure les vidéos de Sound Physics
Dans **Options → Sons → Sound Physics Remastered → Acoustique des vidéos** :
- **Oui**, par défaut : réverbération, occlusion et absorption Sound Physics.
- **Non** : détache le filtre direct et les quatre envois de réverbération,
remet l'absorption de l'air à zéro ; son positionnel, volume et synchro conservés.
Le choix est propre au client, persiste dans `config/sanctuary-media.json` sous
`videoAcoustics` et prend effet pendant la lecture, sans recharger tout le son.
Libellés et infobulle FR/EN. L'autre joueur et les sons Minecraft conservent leur
propre acoustique. Le module de test actionne le vrai bouton dans l'interface.
## Méthode et preuves
Même environnement contrôlé que MEDIA-02 : Minecraft **26.3**, Java **25**,
Dream **1.10.4** exact, SPR **1.5.1+26.3**, Sodium **0.9.2+mc26.3**, deux JVM
clientes et serveur dédié local, **Vulkan / MoltenVK 1.4.2 / Apple M1 8 Gio**.
Monde plat neuf, graine **2602**, joueurs admis avec couleurs Hello World
distinctes avant transmission de la source. VOD synthétique H.264/AAC 30 i/s,
660 secondes ; live généré à partir de cette source, segments d'une seconde
avec date UTC. Chemin natif Dream avec processus FFmpeg système **8.1.2** ;
le backend libav embarqué ne se charge pas sur ce poste.
La sonde lit le numéro d'image sur les uploads GPU réussis et compare les
clients avec correction de l'instant d'observation. Résolution d'une image
à 30 i/s, pas une mesure de scanout des moniteurs. Les observations distantes
de plus de 150 ms, les images figées depuis 500 ms, les clients déconnectés
et les sorties OpenAL en attente sont exclues du calcul de dérive ; leur
couverture reste comptée. Aucune mesure d'écoute subjective ni de sortie
haut-parleur : la preuve audio porte sur les curseurs PCM et filtres appliqués.
Traces et captures conservées sous `build/media-prototype/sync-audio-04/`.
Les séries `sync-audio-01/02/03` sont des diagnostics intermédiaires, pas la
validation finale. Dans `04`, la phase `vod-rejoin` reproduit le défaut de
décodage avant `SessionStartMixin` ; seules les phases `vod-final-*` valident
sa correction. Les phases finales chargent la même extension sur les deux
clients. Le JAR et les dépendances restent ignorés par Git.
Résultats finaux de synchronisation, après les quinze premières secondes de
chaque phase ; la durée comprend le chargement et la reconnexion :
| Phase | Durée | Paires | Médiane | P95 | Maximum |
| --- | ---: | ---: | ---: | ---: | ---: |
| VOD, départ | 83,6 s | 260 | 70 ms | **140 ms** | 278 ms |
| VOD, reconnexion à environ 1 min 40 | 49,6 s | 88 | 19 ms | **55 ms** | 87 ms |
| Live, départ | 85,6 s | 266 | 9 ms | **38 ms** | 322 ms |
| Live, reconnexion | 55,5 s | 97 | 11 ms | **96 ms** | 156 ms |
Médiane de **30 FPS** dans les deux clients pour ces phases. Les couvertures
de vidéo utilisable au départ sont 84,5/83,3 % en VOD et 90,1/91,2 % en live.
La reconnexion froide inclut le redémarrage complet d'une JVM et de Minecraft :
ce n'est pas une reprise réseau instantanée. Les durées sans image et les pics
restent dans les traces ; les P95 ne constituent pas une garantie de latence.
Dream intact donnait environ 337 ms en VOD et 580 à 741 ms après reprise live.
Preuves acoustiques : avec le mur de pierre, le filtre de la vidéo passe à
`cutoff=0.049787067`, `gain=0.7408182` contre `1/1` sans mur, avec envois de
réverbération non nuls. Quand A désactive son option, ses appels vidéo se
stabilisent à 312 tandis que ceux de B continuent ; les notes Minecraft de A
progressent encore de 23 à 26. Le fichier de préférence est relu après redémarrage
avec la valeur désactivée. Les captures montrent les boutons FR Oui et Non.
Hors champ, après deux secondes de transition, **zéro upload vidéo pendant
environ 69 secondes**, mais **3 042 629 / 3 064 928 échantillons PCM** joués.
Les erreurs OpenAL rapportées par la sortie sont nulles dans ces phases.
Le rechargement global de Sound Physics a révélé une réutilisation du même
pointeur natif OpenAL après destruction : le pont distingue désormais les
générations du contexte, puis recrée ses sources et abandonne le PCM devenu
périmé. Les phases `live-global-off/on` reproduisent l'ancien blocage et sont
des diagnostics, comme `live-context-*` qui a permis de trouver un second cas :
une tâche annulée par l'ancien exécuteur laissait le verrou de pompe occupé.
Le nouveau contexte libère aussi ce verrou. La série `live-reload-*` vérifie
quatre remplacements successifs, désactivation/réactivation deux fois.
Les deux clients terminent avec `resets=4`, PCM et uploads en progression,
30 FPS, `al_error=0`. Cette dernière modification ne touche que le cycle de vie
du moteur audio ; elle est testée dans `live-reload-*`, après les mesures VOD
et live du tableau. [Résumé JSON conservé](media-sync-audio-results.json).
Construction du prototype et contrôles dédiés :
```sh
./gradlew :sanctuary-test:exportDuoLaunch
python3.14 scripts/media_lab.py prepare --run mon-essai --media-fix
python3.14 scripts/media_lab.py test-media
python3.14 -m unittest discover -s scripts -p 'test_media_lab_report.py'
```
Le contrat Java vérifie l'estimation de l'heure face aux réponses retardées,
la monotonie, une reconnexion avec changement d'époque, pause/boucle, le bornage
de vitesse et les manifestes HLS relatifs/datés/discontinus. Les **sept tests
du rapport** empêchent les fausses réussites avec image périmée, client manquant,
observations trop éloignées ou sortie audio en attente. Ils ne remplacent pas
les vrais clients. Les formats HLS refusés reprennent le chemin amont.
La suite du dépôt se lance séparément, le module expérimental étant construit
par `test-media` plutôt qu'inclus dans les sous-projets Gradle :
```sh
JAVA_HOME=$(/usr/libexec/java_home -v 25) ./gradlew check build \
--max-workers=1 -Dorg.gradle.jvmargs=-Xmx1G \
-PsanctuaryFocusedTests=property270,claims272,claims277,claims278 \
-PsanctuaryAtlasOnly=true --console=plain
```
`check build` réussit en **2 min 55 s**, 145 tâches, dont **18 GameTests serveur
réussis**. Compilation du prototype et contrat Java réussis, sept tests du
rapport réussis. Journal : `build/media-fix/check-build.log`. Pas d'`assemblePack`, puisque la
distribution n'est pas modifiée. Les JVM de jeu, FFmpeg et le service HTTP du
laboratoire sont arrêtés après les mesures. Deux serveurs des premiers diagnostics
se sont bloqués à l'arrêt et ont été forcés après capture de leurs threads ;
la dernière série s'est fermée. Aucun processus de jeu personnel n'est arrêté.
## Limites
Cette preuve locale ne valide pas Twitch/YouTube publics, des liens résolus par
ANI-CLI, Spotify, des sessions authentifiées, DRM, publicités personnalisées,
playlists maîtres, LL-HLS, byte ranges ou changements de timeline. Une playlist
sans date exploitable reprend le chemin Dream d'origine et ne bénéficie pas
de la synchronisation live précise. Les horloges UTC du fournisseur et du
serveur doivent être cohérentes ; la fenêtre HLS doit contenir le retard choisi.
Le réglage de huit secondes est fixé pour le prototype, pas encore une option
de séance. La correction ±2 % change temporairement la hauteur audio. Mono
positionnel, huit sources simultanées au maximum ; Windows et Linux restent
à tester. Les mixins visent les versions exactes ; pas de compatibilité déduite.
Les tests ciblent Vulkan avec Sodium. Aucun nouveau test OpenGL, aucune
promesse de publication ou de prise en charge des autres profils. Les cadres,
le dimensionnement, le filtre 16 pixels par bloc et les disques blancs restent
les étapes suivantes après validation de cette base.
+815
View File
@@ -0,0 +1,815 @@
{
"lab": "sync-audio-04",
"backend": "Vulkan",
"date": "2026-10-26",
"minecraft": "26.3",
"dream": "1.10.4",
"sound_physics": "1.5.1+26.3",
"sodium": "0.9.2+mc26.3",
"measurement": "16-bit frame code, 30 fps, successful GPU upload, nearest observations within 150 ms; no display-scanout or subjective listening measurement",
"final_addon_sha256": "cbc0ebca949acb87b5c4afe13e61910685354285cb93033d06342ea577a22daf",
"phases": {
"vod-final-start": {
"duration_seconds": 83.603,
"all_pairs_skew_ms": {
"count": 263,
"median": 70.33333333333333,
"p95": 140.0,
"max": 278.0
},
"after_15s_skew_ms": {
"count": 260,
"median": 70.33333333333333,
"p95": 140.0,
"max": 278.0
},
"clients": {
"client-a": {
"samples": 329,
"usable_video_samples": 278,
"usable_fraction": 0.8449848024316109,
"fps": {
"count": 278,
"median": 30.0,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 278,
"median": 8.114512499997062,
"p95": 21.84807333333305,
"max": 37.15192766666675
},
"openal_active_samples": 278,
"media_audio_first": {},
"media_audio_last": {
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": 7845805,
"effects_calls": 296,
"resets": 0,
"al_error": 0,
"backend": "openal"
},
"probe_errors": []
},
"client-b": {
"samples": 317,
"usable_video_samples": 264,
"usable_fraction": 0.832807570977918,
"fps": {
"count": 264,
"median": 30.0,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 264,
"median": 31.379819166666323,
"p95": 47.10430866666826,
"max": 194.07936433333361
},
"openal_active_samples": 264,
"media_audio_first": {},
"media_audio_last": {
"effects_calls": 281,
"resets": 0,
"al_error": 0,
"backend": "openal",
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": -3900227
},
"probe_errors": []
}
}
},
"vod-final-rejoin": {
"duration_seconds": 49.611,
"all_pairs_skew_ms": {
"count": 88,
"median": 18.99999999999999,
"p95": 54.66666666666666,
"max": 87.33333333333333
},
"after_15s_skew_ms": {
"count": 88,
"median": 18.99999999999999,
"p95": 54.66666666666666,
"max": 87.33333333333333
},
"clients": {
"client-a": {
"samples": 199,
"usable_video_samples": 199,
"usable_fraction": 1.0,
"fps": {
"count": 199,
"median": 30,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 199,
"median": 6.954647999990016,
"p95": 18.23582666665402,
"max": 44.9251706666729
},
"openal_active_samples": 199,
"media_audio_first": {
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": -1541950,
"effects_calls": 297,
"resets": 0,
"al_error": 0,
"backend": "openal"
},
"media_audio_last": {
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": 204082,
"effects_calls": 492,
"resets": 0,
"al_error": 0,
"backend": "openal"
},
"probe_errors": []
},
"client-b": {
"samples": 116,
"usable_video_samples": 88,
"usable_fraction": 0.7586206896551724,
"fps": {
"count": 88,
"median": 30.0,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 88,
"median": 10.12811800000435,
"p95": 23.564625999995314,
"max": 37.9297059999999
},
"openal_active_samples": 88,
"media_audio_first": {},
"media_audio_last": {
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 91,
"error_ns": 3242630,
"video_acoustics": true,
"playing": true,
"rate": 1.0
},
"probe_errors": []
}
}
},
"live-final": {
"duration_seconds": 85.586,
"all_pairs_skew_ms": {
"count": 296,
"median": 9.66666666666666,
"p95": 37.666666666666664,
"max": 650.6666666666667
},
"after_15s_skew_ms": {
"count": 266,
"median": 8.999999999999995,
"p95": 37.666666666666664,
"max": 322.0
},
"clients": {
"client-a": {
"samples": 342,
"usable_video_samples": 308,
"usable_fraction": 0.9005847953216374,
"fps": {
"count": 308,
"median": 30.0,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 311,
"media_audio_first": {
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": 6553288,
"effects_calls": 493,
"resets": 0,
"al_error": 0,
"backend": "openal"
},
"media_audio_last": {
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": 5895692,
"effects_calls": 309,
"resets": 0,
"al_error": 0,
"backend": "openal"
},
"probe_errors": []
},
"client-b": {
"samples": 331,
"usable_video_samples": 302,
"usable_fraction": 0.9123867069486404,
"fps": {
"count": 302,
"median": 30.0,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 304,
"media_audio_first": {
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 92,
"error_ns": 952381,
"video_acoustics": true,
"playing": true,
"rate": 1.0
},
"media_audio_last": {
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 301,
"error_ns": -90703,
"video_acoustics": true,
"playing": true,
"rate": 1.0
},
"probe_errors": []
}
}
},
"live-final-rejoin": {
"duration_seconds": 55.501,
"all_pairs_skew_ms": {
"count": 97,
"median": 11.333333333333334,
"p95": 95.66666666666666,
"max": 155.66666666666666
},
"after_15s_skew_ms": {
"count": 97,
"median": 11.333333333333334,
"p95": 95.66666666666666,
"max": 155.66666666666666
},
"clients": {
"client-a": {
"samples": 223,
"usable_video_samples": 223,
"usable_fraction": 1.0,
"fps": {
"count": 223,
"median": 30,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 223,
"media_audio_first": {
"rate": 1.0,
"playing": true,
"video_acoustics": true,
"error_ns": 4693878,
"effects_calls": 310,
"resets": 0,
"al_error": 0,
"backend": "openal"
},
"media_audio_last": {
"rate": 1.02,
"playing": true,
"video_acoustics": true,
"error_ns": 63560091,
"effects_calls": 528,
"resets": 0,
"al_error": 0,
"backend": "openal"
},
"probe_errors": []
},
"client-b": {
"samples": 142,
"usable_video_samples": 97,
"usable_fraction": 0.6830985915492958,
"fps": {
"count": 97,
"median": 30,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 102,
"media_audio_first": {},
"media_audio_last": {
"backend": "openal",
"rate": 1.0045351475,
"playing": true,
"video_acoustics": true,
"error_ns": 9070295,
"effects_calls": 117,
"resets": 0,
"al_error": 0
},
"probe_errors": []
}
}
},
"live-reload-ready": {
"duration_seconds": 16.169,
"all_pairs_skew_ms": {
"count": 20,
"median": 21.33333333333333,
"p95": 174.66666666666666,
"max": 284.3333333333333
},
"after_15s_skew_ms": {
"count": 5,
"median": 174.0,
"p95": 284.3333333333333,
"max": 284.3333333333333
},
"clients": {
"client-a": {
"samples": 60,
"usable_video_samples": 20,
"usable_fraction": 0.3333333333333333,
"fps": {
"count": 20,
"median": 29.0,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 20,
"media_audio_first": {},
"media_audio_last": {
"error_ns": 7165533,
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 38
},
"probe_errors": []
},
"client-b": {
"samples": 60,
"usable_video_samples": 19,
"usable_fraction": 0.31666666666666665,
"fps": {
"count": 19,
"median": 30,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 19,
"media_audio_first": {},
"media_audio_last": {
"error_ns": 152630385,
"video_acoustics": true,
"playing": true,
"rate": 1.02,
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 35
},
"probe_errors": []
}
}
},
"live-reload-off-1": {
"duration_seconds": 11.816,
"all_pairs_skew_ms": {
"count": 47,
"median": 20.33333333333333,
"p95": 54.33333333333333,
"max": 108.00000000000001
},
"after_15s_skew_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"clients": {
"client-a": {
"samples": 48,
"usable_video_samples": 48,
"usable_fraction": 1.0,
"fps": {
"count": 48,
"median": 30.0,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 48,
"media_audio_first": {
"error_ns": 12471655,
"video_acoustics": true,
"playing": true,
"rate": 1.0062358275,
"backend": "openal",
"al_error": 0,
"resets": 0,
"effects_calls": 39
},
"media_audio_last": {
"error_ns": 6167800,
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 1,
"effects_calls": 39
},
"probe_errors": []
},
"client-b": {
"samples": 48,
"usable_video_samples": 46,
"usable_fraction": 0.9583333333333334,
"fps": {
"count": 46,
"median": 30.0,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 46,
"media_audio_first": {
"error_ns": 408163,
"video_acoustics": true,
"playing": false,
"rate": 1.0,
"backend": "openal",
"al_error": 40964,
"resets": 0,
"effects_calls": 36
},
"media_audio_last": {
"error_ns": 8866213,
"video_acoustics": true,
"playing": true,
"rate": 1.0044331065,
"backend": "openal",
"al_error": 0,
"resets": 1,
"effects_calls": 36
},
"probe_errors": []
}
}
},
"live-reload-on-1": {
"duration_seconds": 11.776,
"all_pairs_skew_ms": {
"count": 47,
"median": 32.0,
"p95": 41.66666666666666,
"max": 47.666666666666664
},
"after_15s_skew_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"clients": {
"client-a": {
"samples": 48,
"usable_video_samples": 48,
"usable_fraction": 1.0,
"fps": {
"count": 48,
"median": 30.0,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 48,
"media_audio_first": {
"error_ns": 54126984,
"video_acoustics": true,
"playing": true,
"rate": 1.02,
"backend": "openal",
"al_error": 40964,
"resets": 1,
"effects_calls": 40
},
"media_audio_last": {
"error_ns": -634921,
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 2,
"effects_calls": 87
},
"probe_errors": []
},
"client-b": {
"samples": 48,
"usable_video_samples": 47,
"usable_fraction": 0.9791666666666666,
"fps": {
"count": 47,
"median": 30,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 47,
"media_audio_first": {
"error_ns": 92607710,
"video_acoustics": true,
"playing": true,
"rate": 1.02,
"backend": "openal",
"al_error": 40964,
"resets": 1,
"effects_calls": 37
},
"media_audio_last": {
"error_ns": -566893,
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 2,
"effects_calls": 84
},
"probe_errors": []
}
}
},
"live-reload-off-2": {
"duration_seconds": 11.868,
"all_pairs_skew_ms": {
"count": 44,
"median": 12.50000000000001,
"p95": 25.999999999999996,
"max": 59.333333333333336
},
"after_15s_skew_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"clients": {
"client-a": {
"samples": 48,
"usable_video_samples": 45,
"usable_fraction": 0.9375,
"fps": {
"count": 45,
"median": 30,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 47,
"media_audio_first": {
"error_ns": 2630385,
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 3,
"effects_calls": 88
},
"media_audio_last": {
"error_ns": -136054,
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 3,
"effects_calls": 88
},
"probe_errors": []
},
"client-b": {
"samples": 48,
"usable_video_samples": 47,
"usable_fraction": 0.9791666666666666,
"fps": {
"count": 47,
"median": 30,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 47,
"media_audio_first": {
"error_ns": 340136,
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 2,
"effects_calls": 84
},
"media_audio_last": {
"error_ns": 7324263,
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 3,
"effects_calls": 84
},
"probe_errors": []
}
}
},
"live-reload-on-2": {
"duration_seconds": 38.686,
"all_pairs_skew_ms": {
"count": 154,
"median": 16.0,
"p95": 30.333333333333336,
"max": 92.33333333333333
},
"after_15s_skew_ms": {
"count": 95,
"median": 18.333333333333325,
"p95": 29.333333333333336,
"max": 92.33333333333333
},
"clients": {
"client-a": {
"samples": 155,
"usable_video_samples": 154,
"usable_fraction": 0.9935483870967742,
"fps": {
"count": 154,
"median": 30.0,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 154,
"media_audio_first": {
"error_ns": 52811791,
"video_acoustics": true,
"playing": true,
"rate": 1.02,
"backend": "openal",
"al_error": 40964,
"resets": 3,
"effects_calls": 89
},
"media_audio_last": {
"error_ns": 7074830,
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 4,
"effects_calls": 242
},
"probe_errors": []
},
"client-b": {
"samples": 156,
"usable_video_samples": 155,
"usable_fraction": 0.9935897435897436,
"fps": {
"count": 155,
"median": 30,
"p95": 30,
"max": 30
},
"av_clock_error_ms": {
"count": 0,
"median": null,
"p95": null,
"max": null
},
"openal_active_samples": 155,
"media_audio_first": {
"error_ns": 63900227,
"video_acoustics": true,
"playing": true,
"rate": 1.02,
"backend": "openal",
"al_error": 40964,
"resets": 4,
"effects_calls": 85
},
"media_audio_last": {
"error_ns": -634921,
"video_acoustics": true,
"playing": true,
"rate": 1.0,
"backend": "openal",
"al_error": 0,
"resets": 4,
"effects_calls": 238
},
"probe_errors": []
}
}
}
},
"artifact_scope": "Final SHA-256 is for live-reload-* after the audio lifecycle-only correction; prior named phases measure the VOD/live sync implementation."
}
+19
View File
@@ -0,0 +1,19 @@
# Sanctuary beta.293
- Happy Creepers repris de la branche dédiée `codex/happy-creepers` : Maj +
clic droit avec une fleur pour la porter, visage souriant et comportement
passif ; retrait à main vide avec le même geste. Charge électrique conservée.
- Baleines fantômes volantes et œuf natif de beta.290 inclus.
- Logo Sanctuary affiché pendant les téléchargements et mises à jour packwiz,
avec l'installateur intégré au ZIP Prism prêt à importer.
- Simple Voice Chat 2.6.24+26.3, Bobby 5.2.16+mc26.3, Lithium 0.26.2+mc26.3 et
Entity Culling 1.11.3 pour 26.3 disponibles comme choix facultatifs du client.
Lithium et la voix font également partie du profil serveur.
- Sound Physics 1.5.1, Sodium 0.9.2, JEI embarqué et Minecraft 26.3 / Fabric
0.19.5 conservés. La version vocale retenue respecte ces dépendances.
- Sauvegardes et réglages restent attachés à la même instance ; aucun changement
de génération ou de format de monde. Dozei Stream et Only Fun restent futurs.
Le ZIP Prism suit le canal stable à chaque lancement. Les anciens utilisateurs
gardent leur instance ; la première mise à jour de l'habillage doit aussi
installer le nouvel outil packwiz et conserver `--bootstrap-no-update`.
+17
View File
@@ -0,0 +1,17 @@
# Sanctuary beta.295
Le serveur **Sanctuary Beta** est enregistré automatiquement au premier
lancement du client, avec le logo du modpack. Il utilise l'adresse publique
`beta.sanctuary-minecraft.net`. Les autres favoris restent conservés ; vous
pouvez ensuite renommer, déplacer ou supprimer cette entrée.
Le serveur annonce aussi le logo Sanctuary dans la liste multijoueur.
Le même parcours s'applique au ZIP Prism du site, au MRpack et aux instances
existantes qui suivent le canal packwiz.
La commande de propriété est unifiée : **`/sanctuary property`**, avec
`claim`, `protect` et `duel`. Les alias `propriete` et `propriété` sont retirés.
Les règles, prix et protections des claims restent identiques.
Les sauvegardes et paramètres de génération sont conservés ; aucun nouveau
monde n'est nécessaire.
+20
View File
@@ -0,0 +1,20 @@
# Sanctuary beta.296
Les écrans partagés arrivent dans Sanctuary, avec le menu original de Dream
Displays 1.10.4. Huit bâtons autour d'une redstone fabriquent une ancre ; la
largeur et la hauteur se règlent librement, sans cadre supplémentaire.
Le contour suit toute la surface et l'image conserve ses proportions.
Le serveur garde la séance commune, les clients lisent la vidéo. Clic droit
sur l'écran à 16 blocs pour le lecteur ; Maj + clic droit près de l'ancre ou
bouton Taille pour redimensionner. Sound Physics peut être désactivé pour les
vidéos uniquement, dans ses options de son.
Le favori Sanctuary Beta, les dernières commandes de propriété, les mondes et
les réglages sont conservés. Minecraft 26.3 / Fabric 0.19.5 inchangés.
Le module Media est inclus dans Sanctuary ; Dream Displays est installé par
packwiz sur les deux côtés.
Fonction expérimentale à tester : vidéos et live HLS daté contrôlés vérifiés
sous Vulkan, sources publiques Twitch/YouTube encore à essayer. Le filtre
16 pixels/bloc et les disques blancs ne sont pas inclus.
+18
View File
@@ -0,0 +1,18 @@
# Sanctuary beta.297
Le menu de dimensions s'ouvre automatiquement à la première pose du cadre.
Ensuite, clic droit et Maj + clic droit ouvrent directement le lecteur Dream ;
le bouton Taille / Size permet toujours de redimensionner près de l'ancre.
Le cadre possède maintenant une vraie épaisseur de 1/16 de bloc, avec des
faces latérales qui suivent exactement la surface de l'écran. Le repère rouge,
le contour de sélection du bloc invisible et les liserés vidéo visibles de
côté sont retirés. Le son continue lorsque l'image est masquée.
Les écrans déjà posés en beta.296 conservent leur source, leurs dimensions et
leurs données. Aucun bloc à remplacer et aucun changement de format de monde.
Media passe en 0.3.1 ; Minecraft 26.3, Fabric, Dream Displays 1.10.4 et les
autres dépendances restent inchangés.
Correctif vérifié sous Vulkan avec deux clients, 17 contrôles d'interaction
et des vues de face, des côtés, du haut et de l'arrière.
+164
View File
@@ -0,0 +1,164 @@
# PACK-292 — voix, cache de chunks et optimisations
Préparation locale du **26 octobre 2026**, branche
`codex/optional-mods-integration`, depuis le logo packwiz beta.291.
Le même logo et le même canal stable sont conservés.
## Mods intégrés
| Mod | Binaire retenu pour Minecraft 26.3 / Fabric | Client | Serveur |
| --- | --- | --- | --- |
| [Simple Voice Chat](https://modrinth.com/plugin/simple-voice-chat/version/OLnMVWXy) | 2.6.24+26.3, bêta | Facultatif, coché initialement | Inclus, activé initialement |
| [Bobby](https://modrinth.com/mod/bobby/version/J7YnfCss) | 5.2.16+mc26.3 | Facultatif, coché initialement | Exclu |
| [Lithium](https://modrinth.com/mod/lithium/version/xS0Q8LSi) | 0.26.2+mc26.3 | Facultatif, coché initialement | Inclus, activé initialement |
| [Entity Culling](https://modrinth.com/mod/entityculling/version/X1g3VhTx) | 1.11.3 pour mc26.3 | Facultatif, coché initialement | Exclu |
Chaque téléchargement utilise le binaire original, sa version Modrinth
immuable et son SHA-512 dans `packwiz/mods/*.pw.toml`. Les JAR restent séparés,
sans dépendance obligatoire ni copie imbriquée dans le JAR Sanctuary. Les
descriptions du sélecteur packwiz sont en FR/EN. Les interfaces amont conservent
leurs propres traductions, vérifiées pour le menu vocal français et anglais.
`scripts/data/optional-mods.json` fixe les identifiants, versions, côtés,
dépendances et conflits attendus. `verify_optional_mods.py`, exécuté par
`check` et `assemblePack`, vérifie les métadonnées Fabric des JAR, leur hash et
les bibliothèques imbriquées. Les quatre fichiers réellement utilisés par
Gradle sont aussi comparés aux binaires vérifiés pendant la validation.
Minecraft reste **26.3**, Loader **0.19.5**, Java **25**, Fabric API
**0.160.5+26.3** ; MixinExtras **0.5.5** est fourni par Loader et satisfait Lithium.
## Choix de version vocale
Les versions vocales 2.6.25 et 2.6.26 refusent explicitement Sound Physics
Remastered <1.5.2. Le pack conserve son **1.5.1+26.3** et son interface native,
donc la voix est épinglée en **2.6.24+26.3**, sans contournement des dépendances.
Le plug-in Sound Physics est chargé par le serveur vocal et la connexion locale
est authentifiée pendant le parcours client. Les mises à jour de ces deux mods
doivent être traitées ensemble à la prochaine évolution de version.
Le menu vocal s'ouvre avec sa touche amont **V**, configurable ; la touche de
push-to-talk se choisit dans les réglages vocaux. Aucun raccourci ni préférence
personnelle n'est remplacé par le pack. La voix reste facultative pour le joueur.
Le profil serveur contient désormais **Fabric API, Sanctuary, Lithium et Simple
Voice Chat**. Son choix est indépendant des options d'un client. Le kit Linux
reste sans interface ; son installateur officiel est conservé. La vérification
Web compare aussi les nouveaux JAR serveur au canal avant toute publication.
L'utilisation sur la Beta dédiée exigera le
[port UDP vocal](https://dev.modrepo.de/minecraft/voicechat/wiki/server_setup),
par défaut 24454, et un échange audible entre deux joueurs. Cette préparation
ne modifie ni la CT Beta, ni ses règles réseau, ni l'instance personnelle. Le
microphone est désactivé dans le test de développement ; aucune captation de la
voix réelle de l'utilisateur n'est nécessaire à la validation réseau locale.
## Portée de Bobby et d'Entity Culling
[Bobby](https://github.com/Johni0702/bobby) conserve les chunks déjà envoyés
par le serveur dans son cache client `.bobby`, puis peut afficher leur dernière
copie connue au-delà de la distance d'envoi. Il ne génère pas de chunks et ne
simule pas les zones lointaines. Le serveur reste autoritaire lorsqu'il renvoie
l'état courant. Le pack ne distribue aucun monde `bobby-fallback`, cache, ni
configuration personnelle ; la limite de distance et la conservation du cache
restent celles du mod et des réglages du joueur.
[Entity Culling](https://github.com/tr7zw/EntityCulling) conserve son moteur
amont et ses bibliothèques TRender/TRansition imbriquées. L'occlusion et la
réapparition d'une entité sont exercées sur Vulkan. Aucun gain chiffré de FPS
ou de ticks n'est revendiqué ; les validations vérifient ici le fonctionnement
et la coexistence, sans benchmark représentatif de tous les mondes Sanctuary.
## Vérifications
- `./gradlew check build assemblePack assembleServerPack
-PsanctuaryFocusedTests=recipes,progression,smoker285,hunter286
-PsanctuaryAtlasOnly=true -PsanctuaryOptional292ServerTests=true` :
**réussi en 2 min 52 s**, **46 tests serveur requis réussis**, Lithium et voix
effectivement chargés. Recettes, progression, lasso du Smoker et bond du Hunter
sont exercés avec ces dépendances. Les contrôles de ressources, de packwiz et
de l'habillage passent aussi. Le test de quota de baleines déjà en échec dans
[beta.291](packwiz-logo-beta291.md) reste hors de ce périmètre, sans correction
ni validation nouvelle de ce cas.
- Parcours graphique **Vulkan / MoltenVK 1.4.2 / Apple M1 avec Sodium 0.9.2**,
réussi en **38 s** : quatre mods présents, Sound Physics initialisé, entité
masquée derrière le mur puis visible après retrait ; Bobby actif, stockage
persistant et **35 chunks de cache** visibles après un déplacement de huit
chunks avec une distance d'envoi de quatre. Authentification et connexion UDP
vocales réelles, menus vocaux FR/EN ouverts en gardant le microphone muet.
Les captures du mur, de l'entité réapparue et du menu FR sont inspectées.
- Le même parcours **Vulkan sans Sodium** réussit en **43 s**, avec les mêmes
contrôles, 35 chunks de cache et menus FR/EN. Un premier passage avait atteint
tous les contrôles avant de se bloquer dans la synchronisation des threads du
lanceur Fabric pendant la fermeture du monde ; il a été interrompu après
capture des piles. La nouvelle exécution se ferme normalement. Cette limite
du premier essai reste dans le journal, sans la présenter comme un succès.
- Deux synchronisations client depuis le ZIP Prism réussies : **huit JAR**,
un seul Sanctuary, mêmes hashes au second passage, logo de l'installateur
conservé et trois fichiers témoins personnels inchangés. Puis deux passages
supplémentaires avec un choix Bobby désactivé déjà persisté, simulé dans le
cache de test : Bobby n'est pas réinstallé et le choix reste faux.
- Deux synchronisations serveur réussies : exactement les quatre JAR du
profil serveur ; aucun Bobby, Entity Culling, Sodium ou Sound Physics client.
- Les **11 tests** du vérificateur Web passent, y compris la comparaison des
dépendances partagées avec le relevé serveur et le refus d'un mod client dans
son profil. Le MRpack exporté possède les sept dépendances distantes et leurs
bons côtés et options ; les overrides sont limités à Sanctuary et son icône.
- Contrôle complémentaire des baleines avec les quatre mods, Sodium et Vulkan :
les assertions de rendu translucide, modèle animé, œuf FR/EN, deux entités
chargées et déplacement réel atteignent `WHALE290_CLIENT_PASS`. La capture
`0001_whale290-ghosts.png` est inspectée. Ce parcours se bloque ensuite à la
fermeture dans la synchronisation du lanceur de tests Fabric et est arrêté
après capture des piles ; il ne constitue pas une exécution complète réussie.
- Après ajout des notices, le JAR et les archives sont réassemblés et les
synchronisations sont répétées sur les binaires finaux. Les **2 658 classes
de jeu** restent identiques à beta.291. Le vérificateur Web accepte le MRpack
et le ZIP Prism finaux contre les métadonnées préparées et les hashes du
serveur de test synchronisé, avec état de service simulé pour cette vérification
hors ligne ; aucune CT de production n'est contactée.
Les essais utilisent uniquement des mondes et dossiers de développement sous
`build/`. Les copies et hashes finaux sont consignés dans
`build/optional-mods292/`, avec les journaux, captures et reçus natifs.
Le passage vocal audible à deux joueurs, l'accès UDP depuis Internet et les
rendus propres aux familiers et multiblocs avec culling restent des
vérifications complémentaires à effectuer avant d'en revendiquer la couverture.
Commande du parcours client principal (ajouter `-PsanctuarySodiumClientTests=true`
pour le passage avec Sodium) :
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true \
-PsanctuaryOptional292ClientTests=true \
-PsanctuaryClientGraphicsBackend=vulkan -PsanctuaryClientHeapGb=8 \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true \
-PsanctuaryFocusedTests=recipes,progression
```
L'option supplémentaire `-PsanctuaryOptional292WhaleOnly=true` sélectionne
le diagnostic des baleines avec le même ensemble de mods ; son blocage de
fermeture observé ci-dessus reste ouvert.
## Livraison et suite
Le compteur du pack et du mod est aligné sur **beta.292**. Les archives locales
conservent l'habillage packwiz, les options et le canal de la Beta. Un ZIP Prism
préparé ne fait pas avancer le canal public : avant publication, ce canal sert
encore la version publiée. Aucune installation de jeu, sauvegarde personnelle,
publication Web, release Gitea ou configuration réseau n'est modifiée ici.
Dozei Stream et Only Fun restent des modules personnels futurs, avec leurs
[tickets de cadrage](integrations-mods-next.md). Aucun artefact fictif n'est
ajouté au pack pour ces projets.
Artefacts locaux vérifiés, reçus dans `artifacts.json` :
| Fichier dans `build/` | Octets | SHA-256 |
| --- | ---: | --- |
| `Sanctuary-beta.292.mrpack` | 16 753 207 | `28980cec018fc81f700cb8ecb3981199537e04ff737bf58b220ce442b7e5dc56` |
| `Sanctuary-Prism-beta.292.zip` | 3 928 525 | `95c57f5e70627a2847d1cc2033c8d9f50f44ef4a302ca892bc727e0c37ece0a9` |
| `Sanctuary-Server-beta.292.zip` | 4 036 530 | `0ac362bbfbfd2c03efdaf6b209d9319e648ad546bee074521038fffc86bb640a` |
JAR Sanctuary : `0163da08897de96559ab51f482363bd00c03cfb3309784cdb6e112acf25a4862`.
Le ZIP Prism d'amorçage a les mêmes octets qu'en beta.291 : il contient l'outil,
le logo et l'adresse stable, et les versions de Minecraft/Loader n'ont pas changé.
Le MRpack et les métadonnées du canal préparé contiennent les nouveaux mods.
+109
View File
@@ -0,0 +1,109 @@
# PACK-LOGO — logo Sanctuary pendant packwiz
Branche `codex/packwiz-sanctuary-logo`, préparation locale **beta.291**, depuis
le socle beta.290. Demande du 26 octobre 2026 : afficher le bandeau Sanctuary
fourni dans la fenêtre de téléchargement et de mise à jour, avant Minecraft.
## Résultat
Le PNG `minecraft.png` fourni par le créateur est conservé octet pour octet
dans `launcher/packwiz/sanctuary.png` (1024 × 256, RGBA). Son SHA-256 est
`efee73a6c812c89b2900d41f6446c0f906ccf85136b6c226daf42870a41a5157`.
La fenêtre l'affiche sur fond noir à 640 × 160, proportions conservées,
au-dessus de la progression réelle. Ses boutons, le texte initial et le
compte à rebours suivent la langue du système : français, anglais sinon.
Les messages et dialogues du moteur amont conservent leurs traductions amont.
`scripts/packwiz_installer.py` télécharge le JAR officiel
[packwiz-installer v0.5.14](https://github.com/packwiz/packwiz-installer/releases/tag/v0.5.14),
vérifie son SHA-256, puis remplace uniquement `InstallWindow.class` par
l'adaptation Java de sa fenêtre Swing. Toutes les autres classes et notices
restent identiques : téléchargement, vérification des hashes, sélection des
mods facultatifs, erreurs et annulation continuent de dépendre de packwiz.
La licence MIT de comp500 et le source de l'adaptation sont joints au JAR.
Le logo est embarqué : aucun téléchargement d'image n'est requis pour l'afficher.
Le build utilise Java 25 ; les classes de la fenêtre ciblent Java 8 comme
l'amont. Les SHA-256 de l'amont, du logo et du résultat sont fixés dans
`launcher/packwiz/installer.json`. Une compilation différente échoue avant
d'écrire le JAR de distribution. Les dépendances et JAR générés restent dans
`build/packwiz-branding/`, ignoré par Git.
## Archive Prism et mises à jour
La commande habituelle `python3.14 scripts/pack.py prism <url-du-canal>`
embarque le bootstrap officiel vérifié et le nouvel installateur vérifié,
avec la même configuration Minecraft 26.3 / Fabric 0.19.5 et le même canal
stable. `--bootstrap-no-update` empêche le bootstrap de remplacer la fenêtre
Sanctuary par le binaire officiel. Cette option concerne **l'installateur** ;
la synchronisation des mods packwiz reste active à chaque lancement.
Le nom beta.291 du ZIP local n'avance pas ce canal : avant publication, un
lancement visant l'adresse stable récupère la version déjà publiée, beta.289
au moment de cette préparation. Les essais beta.291 utilisent un serveur HTTP
de développement isolé, jamais le monde ni le serveur personnel.
Les évolutions de l'installateur doivent donc être préparées et testées
explicitement, avec mise à jour du verrou et du vérificateur Web. Une simple
avance du canal de mods ne met pas à jour l'outil d'une instance existante.
Lors d'une future synchronisation locale demandée, sauvegarder l'instance,
copier le JAR vérifié et ajouter l'option à sa commande avant lancement en
conservant les autres réglages ; conserver cette même instance, sans réimport.
Le kit serveur conserve son installateur officiel et son fonctionnement sans
interface. Le logo n'est pas ajouté à l'index des fichiers du monde ou du jeu.
`verify_prism` contrôle désormais les deux JAR par leurs hashes et la présence
de l'option qui conserve l'habillage. Les sauvegardes et fichiers personnels
restent refusés dans le ZIP. Le MRpack n'embarque pas les commandes Prism et
ne suffit pas à installer cet habillage.
## Vérifications
- Logo fourni identique octet pour octet ; JAR résultant contrôlé par son
SHA-256 `9e2912fdd05e5c23721acd58cd5c61bbf13061e453627e8f4df9a4c53f7aa875`.
- Les deux tests de distribution de `test_packwiz_installer.py` passent :
toutes les entrées de l'amont sont relues, seule la classe de fenêtre change,
et le véritable JAR vérifié est retrouvé dans l'export Prism.
- Les dix tests de `test_publish_web_pack.py` passent : outils modifiés refusés,
option de conservation obligatoire, contrôles de publication préservés.
- Parcours Swing réel en FR et EN réussi avec le `GUIHandler` officiel :
progression déterminée/indéterminée, options, Annuler, Continuer et compte à
rebours. Les captures `preview-fr.png` et `preview-en.png` sont inspectées.
Le test ne lance ni Minecraft ni un microphone.
- Deux exécutions de `check build assemblePack` avec le profil
`whale290,recipes,progression` échouent sur le même test serveur hérité :
`eggQuotaNaturalSpawnAndFlightUseActualServerEntities`, quota après utilisation
de l'œuf entre Overworld et Nether (18 réussites sur 19). Le profil isolé
`whale290` échoue également sur ce cas (6 réussites sur 7). Les sources du
comportement et de son test ne sont pas modifiées dans ce chantier. Cet échec
reste ouvert ; aucune validation complète des baleines n'est revendiquée.
- `./gradlew check build assemblePack -PsanctuaryFocusedTests=recipes,progression
-PsanctuaryAtlasOnly=true` réussit en **2 min 42 s**, avec **13 tests serveur
requis réussis**, les contrôles purs de `check`, le JAR et les profils client
et serveur assemblés. Ce périmètre ne valide pas le test de baleines en échec.
- Comparaison des JAR beta.290/beta.291 : **2 658 classes de jeu identiques**,
aucune classe ajoutée. Le compteur et les notices évoluent, sans changement
du code de jeu.
- Deux lancements du véritable bootstrap et de l'installateur adapté réussis
depuis le ZIP extrait dans `native instance with spaces/`, avec les arguments
de sa commande Prism et un canal HTTP local beta.291. Un seul JAR Sanctuary,
fichiers gérés identiques au second passage, installateur avec logo conservé,
trois fichiers témoins personnels inchangés. Aucun monde ouvert. Le mode
sans interface vérifie la synchronisation ; les tests Swing ci-dessus
vérifient la fenêtre. Reçu `native-validation.json`.
Archive locale `build/Sanctuary-Prism-beta.291.zip` : **3 928 525 octets**,
SHA-256 `95c57f5e70627a2847d1cc2033c8d9f50f44ef4a302ca892bc727e0c37ece0a9`.
JAR Sanctuary beta.291 :
`bd130418a05f820dffd86b00595c5b7ce96293ec82867926f5493a95736aee73`.
Les interfaces Linux/Windows, le lancement Minecraft depuis Prism et une
mise à jour de l'instance personnelle ne sont pas revendiqués par ces essais
macOS. Le test de quota des baleines reste à résoudre avant une validation
complète de cette base de jeu.
Les reçus et captures restent sous `build/packwiz-branding/`.
Les mods évoqués pendant ce chantier sont seulement
[préparés en tickets pour la passe suivante](integrations-mods-next.md).
Aucune publication du canal, du site, du serveur Beta ou de l'instance
personnelle n'est demandée ou effectuée par cette préparation locale.
+33
View File
@@ -8,6 +8,34 @@ La [procédure de reprise et de publication complète](updates.md) relie aussi
le serveur dédié, le téléchargement du site et l'instance locale. Les reçus
des anciennes versions ci-dessous restent historiques.
## beta.293 — livraison complète publiée
[Reçu de livraison](release-beta293.md) : Happy Creepers de la branche dédiée,
baleines, logo et quatre mods facultatifs réunis. Release immuable, canal
client/serveur, CT 350, téléchargement Web et même instance Prism personnelle
mis à jour. Import neuf et instance existante vérifiés sur deux passages,
sauvegardes et réglages conservés. Voix UDP 25565 annoncée sur le port public
25567 via la règle Beta existante ; ping applicatif réussi depuis le LAN.
L'audio à deux joueurs et l'accès depuis un réseau extérieur restent à essayer.
## beta.292 — quatre mods facultatifs (préparation locale)
[Voix, cache de chunks et optimisations](optional-mods-beta292.md) : Simple
Voice Chat 2.6.24, Bobby 5.2.16, Lithium 0.26.2 et Entity Culling 1.11.3, tous
épinglés pour Minecraft 26.3 / Fabric. Les quatre sont facultatifs côté client ;
Lithium et la voix sont aussi présents dans le profil serveur. Le logo de
beta.291 et les préférences de sélection sont conservés. Préparation locale,
sans avance du canal public ni modification d'installation personnelle.
## beta.291 — logo dans l'installateur (préparation locale)
Le [bandeau Sanctuary fourni](packwiz-logo-beta291.md) est embarqué dans la
fenêtre packwiz du ZIP Prism préparé. Le bootstrap reste officiel ; la fenêtre
est adaptée et l'installateur épinglé avec `--bootstrap-no-update`. Les mods
continuent de suivre le canal stable. Les instances déjà installées devront
recevoir ce JAR et cette option lors d'une synchronisation explicitement demandée,
en conservant la même instance. Cette préparation ne publie pas le canal.
## beta.280 — JEI embarqué et profil serveur
JEI rejoint le JAR Sanctuary ; `mods/sanctuary.pw.toml` reste le seul artefact
@@ -2166,6 +2194,11 @@ déjà branchée sur le canal stable n'a pas besoin d'être réimportée.
## Données locales et mondes
Depuis beta.295, le premier lancement du client ajoute le serveur Sanctuary
dans les favoris sans remplacer leur liste. Un marqueur local respecte ensuite
les suppressions volontaires ; le profil serveur distribue le logo 64 × 64.
Voir [le contrat](default-server-beta295.md).
Le pack livré possède uniquement les fichiers déclarés dans son index. Il ne
déclare ni `saves/`, ni `options.txt`, `servers.dat`, `screenshots/`, ni les packs
graphiques personnels. L'installation de packwiz et une mise à jour des mods
+6
View File
@@ -1,5 +1,11 @@
# Prochains tickets — carte immersive, progression et New Game+
**Ajout du 26 octobre 2026 :** [prochaine passe d'intégrations du pack](integrations-mods-next.md)
pour Simple Voice Chat, Bobby facultatif, Lithium et Entity Culling, puis les
contrats des futurs modules personnels Dozei Stream et Only Fun. Tickets
initialement documentaires ; les quatre mods du pack sont désormais préparés
dans [beta.292](optional-mods-beta292.md). Le cadrage historique ci-dessous est conservé.
Cadrage du 13 septembre 2026, après l'essai de beta.006.
Branche documentaire : `codex/roadmap-progression-ngplus`.
**Statut : MAP-03 implémenté en beta.007, PROG-02A en beta.008, PROG-02B en beta.009, PROG-02C en beta.010 ; CYCLE-01, PROG-03, FACTION-01 et NGP-01 sont réunis dans beta.011.**
+60
View File
@@ -0,0 +1,60 @@
# CLAIM-03 — Une seule commande de propriété
Ticket local du **26 octobre 2026**, branche `codex/property-command`,
préparation locale `beta.294`.
## Demande et résultat attendu
Les trois variantes de propriété encombrent les commandes proposées au joueur.
Conserver uniquement le nom anglais `/sanctuary property`, cohérent avec les
autres commandes. Retirer les alias `propriete` et `propriété`, y compris pour
les duels, afin qu'aucun enregistrement secondaire ne les réintroduise.
Commandes conservées :
- `/sanctuary property` : ouvrir la carte de propriété ;
- `/sanctuary property claim` : revendiquer le chunk courant ;
- `/sanctuary property protect` : acheter sa protection ;
- `/sanctuary property duel <id>` : consulter le duel proposé.
Ce ticket remplace uniquement les alias de commande introduits en beta.270.
Les règles de revendication, prix, protections, interfaces FR/EN et sauvegardes
restent identiques. Aucun contrat de migration n'est nécessaire.
## Critères d'acceptation
- La complétion de `/sanctuary prop` propose uniquement `property`.
- Les deux alias retirés sont absents de l'arbre serveur et sont refusés,
seuls comme avec `claim`, `protect` ou `duel`.
- La branche anglaise et ses trois sous-commandes restent accessibles sans
permission opérateur. Revendication et protection fonctionnent via le
répartiteur réel des commandes.
- Les tests de propriétés et de duels passent ; le mod et le pack portent
ensemble `beta.294`.
## Vérifications
`check build assemblePack` réussit en **4 min 4 s**, sur Minecraft 26.3,
Fabric 0.19.5 et Java 25. Les **18 GameTests serveur ciblés** passent :
propriétés historiques, claims par chunks, priorité Demeure et duels Battle.
Le contrôle de commande vérifie la complétion unique, les anciens alias refusés
et les sous-commandes conservées. Le scénario natif de minage revendique et
protège désormais son terrain via les vraies commandes `property claim` et
`property protect`, en contrôlant le propriétaire et les huit saphirs débités.
Les **63 contrôles du registre de claims**, les **26 contrôles du journal de
duels** et les autres vérifications de `check` passent également.
```sh
./gradlew check build assemblePack \
-PsanctuaryFocusedTests=property270,claims272,claims277,claims278 \
-PsanctuaryAtlasOnly=true
```
Journal ignoré : `build/property294-check-build-pack.log`.
JAR local : `mods/sanctuary/build/libs/sanctuary-beta.294.jar`.
Les packs client et serveur sont assemblés et vérifiés dans `build/packwiz`
et `build/packwiz-server`. La validation porte sur l'arbre serveur et les
commandes exécutées ; aucun essai graphique n'est revendiqué pour ce ticket.
Préparation locale uniquement ; aucune publication ni synchronisation d'une
installation de jeu ou du serveur Beta demandée pour ce ticket.
+43 -3
View File
@@ -1,7 +1,8 @@
# RELEASE-289 — zombies, lumière portée et mise à jour complète
Branche `codex/release-beta289`. Publication demandée le 26 octobre 2026.
État : artefacts construits et vérifiés, publication demandée en cours.
État : **publiée et installée le 26 octobre 2026** sur la Beta dédiée,
le site et l’instance Prism existante.
## Contenu
@@ -16,8 +17,8 @@ Branche `codex/release-beta289`. Publication demandée le 26 octobre 2026.
`fb20160e`. Aucun retour aux anciens réglages de carte de cette branche :
le dézoom et les relevés de beta.279 restent conservés.
Le binaire local beta.288 reste immuable. Le prochain binaire est beta.289
car il contient aussi JEI, comme la Beta dédiée actuelle. Minecraft 26.3,
Le binaire local beta.288 reste immuable. La livraison beta.289
contient aussi JEI, comme la Beta dédiée précédente. Minecraft 26.3,
Fabric Loader 0.19.5, Fabric API 0.160.5+26.3, JEI 30.32.0-sanctuary.4,
Sodium 0.9.2+mc26.3 et Sound Physics 1.5.1+26.3 restent épinglés.
@@ -47,6 +48,45 @@ Les hashes et les journaux se trouvent dans `build/release289/`.
Les preuves propres à beta.288 restent dans `docs/dynamic-hand-light-beta288.md`.
Elles ne sont pas présentées comme une validation du nouveau conditionnement.
## Publication et installation effectives
- Sources et tag `beta.289` : `6821f6ee12095583663f1aafb02c92fbb105683e`,
poussés depuis `codex/release-beta289`.
- [Release immuable](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.289)
et trois archives publiques relues avec leurs SHA-256 exacts, HTTP 200.
- Canal packwiz client et serveur :
`56e3462a39d6b911c27ec926780ba5eb509e599c`, version `beta.289`.
- CT 350 : même service `sanctuary-beta.service`, actif, JAR beta.289 et JEI
embarqué confirmés dans le démarrage ; RCON répond, 0/20 joueurs au contrôle.
Les 239 fichiers protégés et l’environnement privé sont identiques entre
l’arrêt propre et la mise à jour, avant redémarrage.
- CT 320 : bascule atomique de la distribution vers
`storage/app/distribution/releases/beta.289/prism`. Le code live n’a pas été
remplacé. Footer et attribut de survol du bouton public annoncent beta.289.
Le ZIP anonyme répond HTTP 200, nom `Sanctuary-Beta.zip`, et le lien MRpack
historique répond aussi avec le hash attendu.
- Même communauté des deux côtés, métriques fraîches et serveur en marche.
L’API d’admission répond 422 à un code volontairement invalide, sans appel
de consommation d’un code.
- Même instance personnelle **Sanctuary Beta**, dossier historique
`Sanctuary-0.1.0-alpha.1` : passage de beta.144 à beta.289, un seul JAR
Sanctuary, deux synchronisations identiques. Les 999 fichiers personnels
contrôlés et les composants Minecraft/Fabric sont conservés. Aucun monde
personnel n’a été ouvert ; l’installation isolée a aussi passé deux cycles.
Sauvegarde serveur complète avant avancement du canal :
`/root/sanctuary-beta-backups/before-beta289-20261026T002155Z`,
archive de 167 964 197 octets, SHA-256
`724533e56bf4ab5319bbd519d123c6731fc882f684cfc0998d876c34ed09805d`.
Sauvegarde locale complète de l’instance avant synchronisation :
`sanctuary-backups/before-beta289-20261026T011713/instance.tar.gz`,
230 400 000 octets. La publication Web beta.280 reste disponible pour reprise.
Le ping Minecraft via `beta.sanctuary-minecraft.net` et son SRV 25567 répond
en 26.3, 0/20, depuis le Mac sur le LAN. **L’accès depuis un réseau extérieur
n’a pas été testé.** Les vérifications graphiques de cette livraison ciblent
Vulkan uniquement. Reçus détaillés ignorés sous `build/release289/`.
## Artefacts vérifiés
- `Sanctuary-beta.289.mrpack` : 16,715,428 octets ; SHA-256 `7a8935225a97119ba278f8a7ccd51aae7efef588244be782661545c67270b8fc`.
+136
View File
@@ -0,0 +1,136 @@
# beta.293 — livraison intégrée
Livraison demandée le **26 octobre 2026** pour essayer le parcours complet :
site → ZIP Prism → packwiz avec logo → jeu et serveur Beta.
Branche `codex/release-beta293`.
## Contenu et provenance
La base conserve les baleines de `a93e2611`, l'habillage packwiz et les quatre
mods facultatifs de `97c601cd`. Les Happy Creepers sont repris du commit
`0ffd371a` de `codex/happy-creepers`, sans réimplémentation : les règles et la
texture restent celles de cette branche dédiée. Les conflits d'intégration
portaient sur les versions, les listes de tests et les documents communs.
La livraison utilise le prochain compteur libre **beta.293** ; les préparations
locales beta.291 et beta.292 ne sont pas publiées séparément.
[Notes de version](notes-beta293.md), [Happy Creepers](happy-creepers.md),
[logo packwiz](packwiz-logo-beta291.md), [mods facultatifs](optional-mods-beta292.md).
Le test du quota interdimensionnel des baleines attend désormais la fin du
chargement des entités du chunk Nether via `waitForEntities`. Ses deux fixtures
restent immobiles : l'une au centre du chunk forcé, l'autre en hauteur pour que
sa boîte de 36 blocs ne croise pas le nettoyage des tests voisins. Le test
d'origine pouvait perdre une fixture avant de vérifier le quota. Le code de
jeu reste identique ; **79 tests serveur** passent sur la base réunie, avec
Lithium et la voix chargés. Le déplacement de la baleine naturelle reste testé
avec son IA active.
## Cibles de livraison
- Artefacts immuables et canal client/serveur Gitea ;
- CT **350** et service `sanctuary-beta.service`, après arrêt et sauvegarde ;
- téléchargement direct de Sanctuary Web, CT **320**, sans modification du code live ;
- même instance Prism **Sanctuary Beta**, avec sauvegarde, installateur habillé
vérifié et conservation des données personnelles.
La règle NAT Beta existante `25567 → 192.168.1.122:25565` couvre TCP et UDP.
La voix utilise **UDP 25565 dans la CT**, annoncé comme
`beta.sanctuary-minecraft.net:25567`. Aucun changement de Bbox, UPnP, Alpha,
RCON ou règles des autres serveurs n'est nécessaire.
## Validation de la livraison
`check build assemblePack assembleServerPack` : **réussi en 2 min 46 s**,
avec les contrôles purs et **79 tests serveur requis**. Profil natif :
`whale290,happycreeper,cosmetics,headblocks,recipes,progression,smoker285,hunter286`,
`sanctuaryAtlasOnly=true`, `sanctuaryOptional292ServerTests=true`.
Journal : `build/release293/check-build-final.log`.
Parcours Happy Creepers **Vulkan avec Sodium et les quatre mods chargés** :
toutes les assertions en jeu réussissent sur deux essais, capture des quatre
états inspectée (`build/release293/happy-four-states-vulkan.png`). Équipement et
retrait au clavier/souris, fleur exacte synchronisée, visage restauré, IA
passive près du joueur et charge indépendante vérifiés. La fermeture du monde
reste bloquée dans la synchronisation `Phaser` de Fabric Client GameTest ; ce
contrôle graphique n'est donc pas un succès de tâche Gradle. Le premier dump
montre le thread de test dans `TestSingleplayerContextImpl.close`, le serveur
dans `ThreadingImpl.enterPhase` et le rendu dans `IntegratedServer.halt`.
Ce blocage de l'infrastructure de test est consigné séparément des assertions
réussies et du build complet réussi. Les parcours des quatre mods avec et sans
Sodium ont leur reçu dans [beta.292](optional-mods-beta292.md).
Les archives ont été contrôlées avant publication : sept dépendances externes
et un seul JAR Sanctuary dans le MRpack ; huit mods du client et quatre du
serveur ; JEI embarqué, aucun test ni dépendance facultative imposée dans le
JAR Sanctuary. Texture happy identique à la branche dédiée, classes creeper
et baleine présentes. ZIP Prism sans mods ni sauvegardes personnelles.
| Artefact | Octets | SHA-256 |
| --- | ---: | --- |
| `sanctuary-beta.293.jar` | 18171689 | `4a0a3941f651ba3566ec41940c2f4c820d917121312e3aead23b39f99633ebe3` |
| `Sanctuary-beta.293.mrpack` | 16761300 | `80e1d9a54165f279738a6486c8c3cd7e5e90ad7ad4ca81d32a4d4a04b15f0e90` |
| `Sanctuary-Prism-beta.293.zip` | 3928525 | `95c57f5e70627a2847d1cc2033c8d9f50f44ef4a302ca892bc727e0c37ece0a9` |
| `Sanctuary-Server-beta.293.zip` | 4036536 | `5f3135f7f5b5448023f9873c5242e3c2ab9444c197e1b551d356c8b1120bf01d` |
## État opérationnel
**Livraison complète effectuée le 26 octobre 2026.**
| Cible | État vérifié |
| --- | --- |
| Source et tag `beta.293` | `678d77fb91ef5e3ffee96cac5c88ebafa0a77976` |
| Canal client/serveur | `687d07e57ec008e1865a0081f317c4f3e3159e14` |
| Release Gitea | [beta.293](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.293), quatre artefacts publics vérifiés |
| CT 350 | Service actif, Sanctuary beta.293, Fabric API, Lithium et Voice Chat chargés ; démarrage sans erreur |
| CT 320 | Publication atomique `releases/beta.293/prism`, ancienne beta.289 conservée |
| Prism personnel | Même instance **Sanctuary Beta**, huit mods et un seul JAR Sanctuary beta.293 ; deux synchronisations identiques |
Le serveur était vide (0/20) avant arrêt. Sauvegarde complète privée :
`/root/sanctuary-beta-backups/before-beta293-20261026T032339Z/instance.tar.gz`,
SHA-256 `b9bd9fa7b10d7e54b3595d52061529a3da8a5c9193f1854966a60fda975e9f11`.
Les **240 fichiers protégés sont identiques après synchronisation, avant
redémarrage**, de même que l'environnement privé. Après redémarrage, Minecraft
réécrit le commentaire horodaté de `server.properties` ; toutes les clés et
valeurs restent identiques. Seul le nouveau fichier vocal a été configuré :
`port=25565`, `bind_address=` vide,
`voice_host=beta.sanctuary-minecraft.net:25567`, `force_voice_chat=false`.
Aucun changement de monde, de génération ou de format n'est effectué par
l'outil de mise à jour ; le serveur reprend son monde existant.
La voix écoute en UDP 25565. Un ping applicatif vocal valide répond via le
nom public et le port 25567 depuis le Mac (68 ms). Le statut Minecraft via
DNS/SRV répond également : 26.3, 0/20. Ces contrôles partent du LAN via
l'adresse publique : **l'accès depuis un autre réseau et l'audio audible à
deux joueurs restent à essayer**. L'authentification vocale dans les mondes de
test est réussie ; elle ne remplace pas cet essai vocal distant.
L'API d'admission authentifiée refuse le code volontairement invalide
(`HTTP 200`, `valid=false`, `reason=invalid`), sans appel à `redeem`.
Les métriques Web sont fraîches, en marche, avec le même UUID communautaire
`cc6f4f5a-e58d-409a-879f-81daecf59461` et la même base de jeu.
Le [ZIP direct du site](https://sanctuary-minecraft.net/downloads/sanctuary-prism.zip)
répond HTTP 200 sous le nom **Sanctuary-Beta.zip**, avec le hash du tableau.
Le lien MRpack historique répond aussi HTTP 200 avec son hash exact.
Le bouton, son survol et le footer affichent beta.293. Le code Web live est
resté sur `eadc30e8c7b6dda50bc810584af057235e7fdba8` ; les **73 entrées Git
préexistantes sont identiques**, sans pull, reset ni reconstruction de cache.
Une importation isolée du ZIP a récupéré les huit mods depuis le vrai canal
HTTPS, puis une deuxième synchronisation a conservé exactement leurs hashes.
Sauvegarde Prism complète sous le dossier historique de l'instance :
`sanctuary-backups/before-beta293-20261026T030401Z/instance.tar.gz`, SHA-256
`9bfc9b963551885d3e124c865aabe0dc9750ecb6cfed89b7d936a3e14abe51bb`.
Les **999 fichiers personnels contrôlés sont inchangés**, et aucun monde
personnel n'a été ouvert. Seules les deux commandes `PreLaunchCommand` de
`instance.cfg` et le JAR de l'installateur ont reçu l'habillage ; les autres
lignes de configuration sont identiques. L'outil reste épinglé par
`--bootstrap-no-update`, et le pack continue de suivre le canal stable.
Sodium et Sound Physics ont gardé leurs sélections ; les quatre nouveaux
choix sont activés par défaut et restent facultatifs côté client.
Installateur SHA-256 :
`9e2912fdd05e5c23721acd58cd5c61bbf13061e453627e8f4df9a4c53f7aa875`.
Les journaux et reçus détaillés sont sous `build/release293/`, hors de Git.
Le worktree original `codex/happy-creepers` est resté propre au commit
`0ffd371adac0c4f950237448625673b83a94bee5`.
+80
View File
@@ -0,0 +1,80 @@
# beta.295 — livraison du serveur préenregistré
Livraison complète du **26 octobre 2026**, branche `codex/default-server`.
[Contrat PACK-03](default-server-beta295.md), [notes de version](notes-beta295.md).
Le ticket CLAIM-03 terminé est intégré depuis ses fichiers validés, sans
modifier son checkout. beta.294 reste une préparation locale non publiée.
## Versions réellement livrées
| Cible | État vérifié |
| --- | --- |
| Source et tag `beta.295` | `2734c2383a8ce8562506a9d6bd1da036084143a5` |
| Canal client/serveur | `9a3dd1bea975efc94cbdd5e9f7ef7288458063b7` |
| Release | [beta.295](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.295), quatre artefacts publics vérifiés |
| CT 350 | Service actif, quatre mods dont Sanctuary beta.295, démarrage sans erreur |
| Icône annoncée | Logo PNG 64 × 64, hash du ping public identique à la ressource du JAR |
| CT 320 | `releases/beta.295/prism`, beta.293 conservée, téléchargement direct et footer actualisés |
| Prism historique | Même instance Sanctuary Beta, huit mods et un seul JAR Sanctuary beta.295 |
Le ZIP du site reste une amorce du canal stable. Ses octets sont identiques
à ceux de beta.293 car la plateforme et les outils sont inchangés ; le premier
lancement récupère désormais le JAR beta.295 qui ajoute le serveur.
## Vérifications
- `check build assemblePack assembleServerPack` réussi en **3 min 22 s** :
contrôles purs et **18 tests serveur** `property270,claims272,claims277,claims278`.
- **15 contrôles des favoris** : conservation, sauvegarde, absence de doublon,
suppression volontaire, lecture vanilla de l’icône et corruption réparable.
- **Vulkan FR/EN**, GUI 3 en 1280 × 800 : réussi en 38 s, captures inspectées.
Hook réel de démarrage et liste multijoueur vanilla ; aucun monde ouvert.
- Deux synchronisations packwiz identiques dans une importation isolée, puis
deux dans l’instance historique. Serveur ajouté dans celle-ci avec le même
code d’initialisation, deux appels identiques, sans lancer le jeu.
- Ping public via DNS/SRV : Minecraft 26.3, 0/20, icône vérifiée depuis le LAN.
Aucun essai depuis un réseau extérieur ajouté.
- Admission : HTTP 200, code volontairement invalide refusé, aucun redeem.
Métriques Web fraîches et en marche ; même base et UUID communautaire.
## Artefacts
| Artefact | Octets | SHA-256 |
| --- | ---: | --- |
| `sanctuary-beta.295.jar` | 18179585 | `45a4af94c56bdd25f1eeeaa5e642588e0f9b1ab3f77e934f396fee1a73898831` |
| `Sanctuary-beta.295.mrpack` | 16768587 | `ab1b71de5a70b0f097a2b89a5ed7d4ca2ca3b3453b19d404b5ebf85ff205cfd7` |
| `Sanctuary-Prism-beta.295.zip` | 3928525 | `95c57f5e70627a2847d1cc2033c8d9f50f44ef4a302ca892bc727e0c37ece0a9` |
| `Sanctuary-Server-beta.295.zip` | 4036670 | `40841bf0f0c8a16d56ef04441f0f71729348b14e6eba98ebf0865aacea843b67` |
Icône serveur SHA-256 :
`2b8966e8f2a0cfe064a1dfac4205874216373f8bb4c59e8eb712d5e7d204fce7`.
## Conservation et limites
Serveur vide avant arrêt. Sauvegarde complète privée :
`/root/sanctuary-beta-backups/before-beta295-20261026T052413Z/instance.tar.gz`,
SHA-256 `b46c036a439944f79d5f89a188064e7507bc5804ffaf8e3a6f42458319340f66`.
Les **248 fichiers protégés** et l’environnement privé sont identiques après
synchronisation, avant redémarrage. Ensuite toutes les clés/valeurs de
`server.properties` restent identiques ; Minecraft réécrit son commentaire
horodaté. Voix, réseau et monde existant gardent leurs réglages. Aucune
migration de monde ou de génération.
Prism : sauvegarde complète sous le dossier de l’instance historique,
`sanctuary-backups/before-beta295-20261026T052117Z/instance.tar.gz`,
SHA-256 `a128a1463cf598707674c84834c9b456c36d781fbf558daa4ce195713ceca8ca`.
Les **999 fichiers personnels préexistants** et `instance.cfg` sont identiques.
Seuls les fichiers gérés sont actualisés, et les nouveaux `servers.dat` et
`.sanctuary/default-server-beta` initialisent le favori. Aucun monde personnel ouvert.
Une autre instance importée, dossier `Sanctuary-Beta`, était en cours de jeu.
Elle n’a pas été modifiée pendant cette session et suivra le canal beta.295
au prochain lancement. Aucune nouvelle instance personnelle créée ici.
Web : HEAD `eadc30e8c7b6dda50bc810584af057235e7fdba8` et **73 changements préexistants** identiques.
Aucun remplacement du code live ni reconstruction de cache. ZIP et MRpack
publics HTTP 200 avec hashes exacts, ZIP reçu sous le nom `Sanctuary-Beta.zip`.
Reçus, journaux, hashes et scripts opérationnels : `build/release295/`, hors Git.
Les limites vocales antérieures restent celles de [beta.293](release-beta293.md) ;
cette livraison ne revendique pas de nouvel échange audible à deux joueurs.
+108
View File
@@ -0,0 +1,108 @@
# beta.296 — écrans partagés installés pour essai
Livraison complète du **26 octobre 2026**, branche `codex/media-beta296`.
[Contrat et gestes en jeu](media-beta296.md), [notes](notes-beta296.md).
Le socle publié beta.295 et ses ajouts sont conservés.
## Cibles vérifiées
| Cible | État |
| --- | --- |
| Source et tag `beta.296` | `9674e7f3c5b755990ab546c3bfb202850ecb299e` |
| Canal client/serveur | `550a079699ae3a731b0cb39255f4c3af4efa77eb` |
| Release | [beta.296](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.296), quatre artefacts publics vérifiés |
| CT 350 | Service actif, Sanctuary beta.296 avec Media 0.3.0 embarqué et Dream Displays 1.10.4 chargé |
| CT 320 | Distribution `releases/beta.296/prism`, ancienne beta.295 conservée |
| Prism historique | Même instance Sanctuary Beta, neuf JAR, deux synchronisations identiques |
Un seul JAR Sanctuary est installé. Le module Media conserve son ID Fabric
`sanctuary_media`, ses recettes, blocs et données. Dream Displays est installé
séparément par packwiz des deux côtés. Minecraft 26.3, Fabric 0.19.5, JEI et les
autres dépendances restent aux versions précédentes.
## Vérifications
- Tâches `check` et `build` réussies : contrôles purs, **18 GameTests requis**
`property270,claims272,claims277,claims278`, contrats horloge/HLS/reprise et
géométrie Media. La première commande globale a ensuite refusé l'assemblage
sur la vérification de la borne Minecraft déclarée par Dream (`~26.3-`).
Le vérificateur épingle maintenant cette valeur exacte de l'artefact déjà
validé pour 26.3. Assemblages client/serveur réexécutés et réussis en **5 s**.
- Module Media construit par Gradle ; un seul exemplaire dans Sanctuary,
licence incluse, aucune sonde ni classe de test dans l'artefact livré.
- **14 contrôles interactifs** de l'ancre réussis avec le JAR beta.296 :
coût unique, taille libre, menus, portées, droits, murs et paquets refusés.
- Deux vrais clients **Vulkan**, Sodium et Sound Physics, utilisant le même
JAR livré et son module embarqué. Recherche et sélection via le menu natif,
puis vidéo et live HLS synthétiques ; sortie OpenAL active, sans erreur AL.
- Deux synchronisations du vrai installateur dans une instance isolée, puis
deux dans l'instance personnelle existante : neuf mods identiques au second
passage. Pas de monde personnel ouvert pour ces contrôles.
- Onze tests du vérificateur Web réussis ; ZIP/MRpack et profils comparés
aux artefacts et hashes effectivement installés sur la CT.
- Ping public DNS/SRV depuis le LAN, Minecraft 26.3, 0/20 lors du contrôle,
logo serveur vérifié. Admission : faux code refusé en HTTP 200, aucun redeem.
Métriques Web fraîches et serveur en marche, même UUID communautaire.
Laboratoire `build/media-prototype/release296/`, Apple M1 8 Gio, Java 25,
FFmpeg 8.1.2 externe. Comparaison des numéros d'images soumis au GPU après les
quinze premières secondes, méthode de MEDIA-04 :
| Source | Durée | Paires | Médiane | P95 | Maximum |
| --- | ---: | ---: | ---: | ---: | ---: |
| Vidéo, menu natif | 28,8 s | 55 | 1,7 ms | 25,7 ms | 31,7 ms |
| Live HLS daté | 64,0 s | 196 | 10,7 ms | 28,0 ms | 70 ms |
Les couvertures utilisables, chargement inclus, sont 90,5/91,4 % pour la vidéo,
88,3/88,7 % pour le live. Aucun scanout de moniteur ni enregistrement audible.
Les essais de reconnexion et de pause froide restent détaillés dans
[MEDIA-04](media-anchor-prototype.md) ; cette série vérifie leur code dans
l'artefact embarqué, sans revendiquer un nouveau parcours de reconnexion.
## Artefacts
| Artefact | Octets | SHA-256 |
| --- | ---: | --- |
| `sanctuary-beta.296.jar` | 18295484 | `20896c6271edc1eef27a7ac0ba5c4844b70fe589b866e2fac436e233fadf8ab1` |
| `Sanctuary-beta.296.mrpack` | 16884738 | `952dcfcd23a9d2fda9b4c193398aee233d74bceefd94b1ce411d47e01250b267` |
| `Sanctuary-Prism-beta.296.zip` | 3928525 | `95c57f5e70627a2847d1cc2033c8d9f50f44ef4a302ca892bc727e0c37ece0a9` |
| `Sanctuary-Server-beta.296.zip` | 4036720 | `f31bcf2c83929028a29a51eae53b37eae2bb7fa8253dc124acad1e2ac2846af0` |
Le ZIP Prism reste identique à beta.295 : mêmes outils et plateforme, canal
stable maintenant en beta.296. Le bouton du site donne HTTP 200, nom reçu
`Sanctuary-Beta.zip` ; ZIP, MRpack et version dans le HTML public vérifiés.
## Conservation
Serveur vide avant arrêt propre. Sauvegarde complète privée :
`/root/sanctuary-beta-backups/before-beta296-20261026T154620Z/instance.tar.gz`,
SHA-256 `c2996fd33451b57e51be7d30fb6cebc6fb0a48e5fcc346d9dd94c6344e6f3d6d`.
Les **152 fichiers protégés** (hors mods/outils/caches/logs/métadonnées gérées)
sont identiques après synchronisation, avant redémarrage ; le monde contient
122 fichiers et est inclus dans cette sauvegarde. Environnement privé et
toutes les clés/valeurs de `server.properties` restent identiques après démarrage.
Aucune régénération ni migration de monde.
Prism : sauvegarde sous l'instance existante,
`sanctuary-backups/before-beta296-20261026T153934Z/instance.tar.gz`, SHA-256
`d2fe94fae8a0ed28668d3a011a82a9b1fe61208b07748c971e14939d9283db7b`.
Les **933 fichiers personnels contrôlés** et `instance.cfg` sont identiques.
Le chemin cache FFmpeg de Dream a été ajouté comme lien vers le binaire système
8.1.2 vérifié ; aucune option personnelle remplacée, aucun monde ouvert.
L'autre instance importée suit le même canal à son prochain lancement ; elle
n'a pas été réimportée ni modifiée manuellement.
Web : HEAD `eadc30e8c7b6dda50bc810584af057235e7fdba8` et **73 changements
préexistants** conservés, empreintes Git avant/après identiques. Seule la
distribution atomique est avancée ; pas de remplacement de code ou de cache.
Alpha, réseau/NAT, comptes et permissions ne sont pas modifiés.
## Limites
Fonction expérimentale disponible pour essai. Twitch/YouTube publics restent
à valider ; les résultats portent sur des fixtures locales. Le filtre
16 pixels/bloc, ANI-CLI et les disques blancs ne sont pas inclus. Les écrans
requièrent un mur complet et des dimensions entières dans le budget serveur.
Après pose de nouveaux blocs, un retour à beta.295 exige la sauvegarde associée.
Reçus, journaux et hashes : `build/release296/`, hors Git.
+107
View File
@@ -0,0 +1,107 @@
# beta.297 — correctifs des cadres installés
Livraison complète du **27 octobre 2026**, branche `codex/media-frame-fixes`.
[Comportement et validation graphique](media-frame-fixes-beta297.md),
[notes de version](notes-beta297.md). Le socle beta.296 est conservé.
## Résultat en jeu
Le menu de dimensions apparaît automatiquement à la première pose. Ensuite,
clic droit et **Maj + clic droit** ouvrent le lecteur Dream ; son bouton
**Taille / Size** permet de redimensionner. Le repère rouge est retiré.
Le cadre possède des faces latérales et suit les dimensions de l'écran ;
la vidéo ne laisse plus de liseré coloré à travers le bois en vue rasante.
Le son continue lorsque l'image est masquée. Les écrans existants conservent
leurs dimensions, source et identité, sans conversion de données.
## Cibles vérifiées
| Cible | État |
| --- | --- |
| Source et tag `beta.297` | `bad2bdb0d12295a736aee64e08b9f6f5a8ae81d3` |
| Canal client/serveur | `e40b0d372af9fa43a301b842661c131f4e69f7c7` |
| Release | [beta.297](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.297), quatre artefacts publics vérifiés |
| CT 350 | Service actif, Sanctuary beta.297, Media 0.3.1 embarqué et Dream Displays 1.10.4 chargé |
| CT 320 | Distribution `releases/beta.297/prism`, ancienne beta.296 conservée |
| Prism historique | Même instance Sanctuary Beta, neuf JAR, deux synchronisations identiques |
Minecraft 26.3, Fabric 0.19.5, Java 25 et les dépendances restent inchangés.
Un seul JAR Sanctuary inclut JEI et Media ; Dream Displays reste un JAR
distinct obligatoire côté client et serveur. Le serveur comporte cinq JAR.
Le tag immuable désigne les sources de construction ; le présent reçu est
ajouté ensuite sans déplacer ce tag.
## Vérifications
- Nouvelle exécution `check build assemblePack assembleServerPack` réussie
en **2 min 57 s**, 158 tâches, **18 GameTests requis**, profil
`property270,claims272,claims277,claims278`, atlas ciblé. Contrats Media
inclus. Le JAR reconstruit a exactement le hash du JAR final testé sous Vulkan.
- Avant livraison : **17 contrôles interactifs** et deux clients **Vulkan**,
Sodium et Sound Physics, sur un écran 9 × 5. Captures finales de face,
droite, gauche, haut et arrière inspectées : aucun repère rouge ni liseré
vidéo, volume du cadre visible. Image suspendue hors ouverture, curseur
OpenAL actif, uploads repris en revenant devant. Les preuves et limites
sont dans [MEDIA-06](media-frame-fixes-beta297.md).
- Archives, plateformes, profils client/serveur et dépendances comparés à
leurs hashes. Deux synchronisations du véritable installateur dans une
instance isolée, puis deux dans l'instance personnelle : second passage
identique, aucun doublon Sanctuary, sélections facultatives conservées.
- Redémarrage CT 350 terminé (`Done`), versions Sanctuary, Media et Dream
confirmées dans le journal. Ping DNS/SRV depuis le LAN : Minecraft 26.3,
0/20 lors du contrôle, logo serveur inchangé.
- ZIP et MRpack publics : HTTP 200 et SHA-256 attendus. Le bouton du site
donne `Sanctuary-Beta.zip` ; le HTML annonce beta.297 et garde le lien direct.
- Admission : faux code refusé en HTTP 200 (`invalid`), aucun redeem.
Métriques Web fraîches, serveur en marche, même UUID communautaire
`cc6f4f5a-e58d-409a-879f-81daecf59461` côté jeu et site.
Les essais graphiques précèdent cette opération de publication et utilisent
le même binaire. Aucune nouvelle séance graphique ni mesure de synchronisation
vidéo/live n'est revendiquée pour le déploiement du 27 octobre.
## Artefacts
| Artefact | Octets | SHA-256 |
| --- | ---: | --- |
| `sanctuary-beta.297.jar` | 18301448 | `f9bfffb0198abb8a4eae8a6d76a1ad3f1190edda7ce33f719bd47f0f4da27d9d` |
| `Sanctuary-beta.297.mrpack` | 16890964 | `89a031a85015615cfcf68c492bf1a73d32e0b10261c36f68045c985dc78d6886` |
| `Sanctuary-Prism-beta.297.zip` | 3928525 | `95c57f5e70627a2847d1cc2033c8d9f50f44ef4a302ca892bc727e0c37ece0a9` |
| `Sanctuary-Server-beta.297.zip` | 4036720 | `f31bcf2c83929028a29a51eae53b37eae2bb7fa8253dc124acad1e2ac2846af0` |
Les ZIP d'amorçage sont identiques à beta.296 : mêmes outils et plateforme,
canal stable maintenant en beta.297. Le MRpack contient le nouveau JAR.
## Conservation
Serveur vide avant arrêt propre. Sauvegarde complète privée :
`/root/sanctuary-beta-backups/before-beta297-20261027T035832Z/instance.tar.gz`,
SHA-256 `6b7fc4b5a6569e8cd3a9bab7e138a67b4344fde84a5dfdd453db9fec273ccdc5`.
Les **176 fichiers protégés** sont identiques après synchronisation et avant
redémarrage. Environnement privé et toutes les clés/valeurs de
`server.properties` sont identiques après démarrage. Aucune régénération ni
migration de monde.
Prism : sauvegarde sous la même instance,
`sanctuary-backups/before-beta297-20261027T035256Z/instance.tar.gz`, SHA-256
`0c95dfd13aa45eb26ea62b16465c87598838a19b2835b7b641d197167c92af09`.
Les **933 fichiers personnels contrôlés**, `instance.cfg` et l'installateur
habillé Sanctuary sont inchangés. Le lien FFmpeg existant est conservé.
Aucun monde personnel ouvert. L'autre instance importée suit le canal au
prochain lancement ; elle n'a pas été réimportée ni modifiée manuellement.
Web : HEAD `eadc30e8c7b6dda50bc810584af057235e7fdba8` et **73 changements
préexistants** conservés, empreintes Git avant/après identiques. Seule la
distribution atomique est avancée. Alpha, réseau/NAT, comptes et permissions
ne sont pas modifiés ; le checkout principal du chantier voisin est préservé.
## Limites
Twitch/YouTube publics et l'écoute subjective n'ont pas de nouvelle validation.
Les essais graphiques utilisent une vidéo synthétique, un mur orienté sud ;
les quatre orientations sont vérifiées numériquement. ANI-CLI, le filtre
16 pixels/bloc et les disques blancs ne sont pas inclus. Les dimensions
restent entières, dans le budget serveur, sur un mur complet.
Reçus opérationnels : `build/release297/`, hors Git. Captures graphiques
finales : `build/media-prototype/frame-fixes-02/`.
+10 -2
View File
@@ -8,7 +8,7 @@ Plateforme épinglée : Minecraft **26.3**, Fabric Loader **0.19.5**, Fabric API
## Préparer le kit
Dans les sources : `./gradlew check build assemblePack assembleServerPack`.
Le kit est `build/Sanctuary-Server-beta.289.zip` ; les manifestes locaux sont
Le kit de la livraison courante est `build/Sanctuary-Server-beta.297.zip` ; les manifestes locaux sont
dans `build/packwiz-server/`. Le ZIP est un amorçage : il télécharge les mods
depuis le canal au premier démarrage, il n'est pas une copie hors ligne du pack.
Il n'est utilisable sur le canal public qu'après publication de cette version.
@@ -56,6 +56,10 @@ avant de rendre le verrou de mise à jour.
L'API d'accueil et la communauté sont deux services existants distincts.
Le pack ne contient ni secret ni configuration de production.
Depuis beta.295, le profil serveur gère aussi `server-icon.png`, le logo du
pack en 64 × 64. La synchronisation le pose à la racine ; Minecraft l'annonce
aux clients au démarrage suivant. Le favori client est ajouté séparément au
premier lancement, sans indexer le fichier personnel `servers.dat`.
Pour l'accès/candidature, fournir dans l'environnement du service Minecraft :
@@ -86,7 +90,11 @@ Le kit n'effectue aucune migration SQL ni modification de sauvegarde.
Arrêter le service, sauvegarder, lancer `./server.sh update`, vérifier les logs,
puis redémarrer le même dossier serveur. `packwiz.json` doit rester présent.
Le pack ne possède que les mods Fabric API et Sanctuary. Il ne gère ni monde,
Depuis la préparation beta.292, le pack serveur possède Fabric API, Sanctuary,
Lithium, Simple Voice Chat et, depuis beta.296, Dream Displays 1.10.4.
Le module Sanctuary Media est embarqué dans le même JAR Sanctuary. Bobby et Entity Culling restent uniquement
clients. [Versions, validation et port UDP vocal](optional-mods-beta292.md).
Il ne gère ni monde,
`server.properties`, `eula.txt`, `config/`, listes de joueurs, permissions,
captures ou secrets. Les réglages des opérateurs restent en place.
+26 -2
View File
@@ -43,6 +43,8 @@ changement de Minecraft/Fabric ou de format de sauvegarde n'est implicite.
```sh
./gradlew check build assemblePack assembleServerPack
# Remplacer beta.xxx par la version choisie ; exporter depuis le dossier assemblé.
(cd build/packwiz && packwiz modrinth export -o ../Sanctuary-beta.xxx.mrpack)
python3.14 scripts/pack.py prism https://git.botsu.net/koka/sanctuary-beta/raw/branch/packwiz/pack.toml
```
@@ -63,8 +65,17 @@ Les sorties attendues pour la version choisie sont :
Vérifier les versions, SHA-256, index et contenus avant publication. Ne pas
versionner les binaires, dépendances, mondes ni secrets. Le ZIP Prism contient
seulement sa configuration, son icône et le bootstrap ; le premier lancement
télécharge les mods. Ne pas lui ajouter des réglages ou sauvegardes personnels.
seulement sa configuration, son icône, le bootstrap et, depuis beta.291,
l'installateur habillé avec le logo Sanctuary ; le premier lancement télécharge
les mods. [Contrat et maintien de l'outil](packwiz-logo-beta291.md). L'option
`--bootstrap-no-update` conserve cet installateur vérifié ; elle ne désactive
pas les mises à jour du pack. Ne pas ajouter de réglages ou sauvegardes personnels.
Depuis beta.295, le JAR client initialise une seule fois le favori Sanctuary,
pour les nouvelles installations comme pour les instances déjà reliées au
canal. `servers.dat` reste personnel et hors index. Le profil serveur distribue
en revanche `server-icon.png` (logo 64 × 64) ; vérifier son hash et l'icône
annoncée par le ping après redémarrage. [Contrat](default-server-beta295.md).
## Publier les artefacts et le canal
@@ -95,6 +106,14 @@ passage. Conserver le reçu et les hashes dans `build/`, hors de Git.
## Mettre à jour le serveur Beta dédié
Depuis la préparation beta.292, le profil serveur comprend aussi Lithium et
Simple Voice Chat ; Bobby et Entity Culling restent clients. Le vérificateur
Web compare les hashes de ces dépendances partagées avec ceux de la CT.
Avant la mise en service vocale demandée, vérifier séparément le port UDP
annoncé et un échange audible à deux joueurs ; consulter
[le contrat beta.292](optional-mods-beta292.md). Ajouter le JAR vocal au pack
ne configure pas les redirections ou le pare-feu du serveur.
La cible est la **CT 350 sur le nœud `gpu`**, dossier
`/srv/minecraft/sanctuary-beta`, service `sanctuary-beta.service`, utilisateur
`minecraft`. La CT 300 Alpha et les autres serveurs sont hors cible.
@@ -210,6 +229,11 @@ sauvegarder les fichiers gérés et les métadonnées de l'instance hors de `mod
relever les hashes des données personnelles, puis exécuter sa commande packwiz
avant lancement avec ses vrais `INST_JAVA`, `INST_MC_DIR`, `INST_DIR`.
Pour une synchronisation sans lancer Minecraft, ajouter `-g` à l'installateur.
Lors de la première livraison de l'habillage beta.291, sauvegarder aussi la
commande existante et le JAR de l'installateur. Copier l'installateur vérifié du
ZIP dans cette même instance et ajouter `--bootstrap-no-update` à sa commande
avant lancement ; le canal de mods seul ne remplace pas cet outil. Les prochains
changements de l'installateur suivent la même mise à jour explicite et vérifiée.
Ne jamais lancer `instance.cfg` comme du shell ni évaluer une chaîne de commande
non vérifiée. Les chemins avec espaces doivent rester des arguments séparés.
+5 -2
View File
@@ -9,11 +9,14 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.289
pack_version=beta.289
mod_version=beta.297
pack_version=beta.297
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.4
sound_physics_version=1.5.1+26.3
sound_physics_artifact=9loJgQlO
sound_physics_sha512=c6bbe6e2018a19fa98e9590f17feeac17ccea4eb2327f23915a4c112f4a7ed99220f99b9d2a9ee3c9a9919a847a8965d9d82aaee67371d4d8d675b3fcf4b46a5
dream_displays_version=1.10.4
dream_displays_artifact=siUKU04U
dream_displays_sha512=c034f3e890fed144832b27442459d254ad553cbaec592d2da50162711e0f71f2313ec52639c6b1a576890b909b935c98f3699da296801dde7db81ced6d9d9eca
+134
View File
@@ -0,0 +1,134 @@
/*
* Adapted from packwiz-installer v0.5.14 InstallWindow.kt.
* Copyright (c) 2021 comp500. MIT license, see LICENSE.
* Sanctuary branding: KOKA99CAB, 2026.
*/
package link.infra.packwiz.installer.ui.gui;
import link.infra.packwiz.installer.ui.data.InstallProgress;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Image;
import java.util.Locale;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JProgressBar;
import javax.swing.SwingUtilities;
import javax.swing.border.EmptyBorder;
/** Presentation only: GUIHandler retains all packwiz installation decisions. */
public final class InstallWindow extends JFrame {
private final boolean french = "fr".equals(Locale.getDefault().getLanguage());
private final JLabel progressLabel;
private final JProgressBar progressBar;
private final JButton options;
private final JButton cancel;
private final JButton ok;
private final JPanel buttons;
public InstallWindow(GUIHandler handler) {
setDefaultCloseOperation(EXIT_ON_CLOSE);
setResizable(false);
ImageIcon logo = new ImageIcon(InstallWindow.class.getResource("/sanctuary/packwiz/logo.png"));
JPanel header = new JPanel(new BorderLayout());
header.setBackground(Color.BLACK);
header.setBorder(new EmptyBorder(14, 24, 4, 24));
JLabel wordmark = new JLabel(new ImageIcon(
logo.getImage().getScaledInstance(640, 160, Image.SCALE_AREA_AVERAGING)));
wordmark.getAccessibleContext().setAccessibleName("Sanctuary");
header.add(wordmark, BorderLayout.CENTER);
add(header, BorderLayout.NORTH);
JPanel progress = new JPanel(new BorderLayout(0, 10));
progress.setBorder(new EmptyBorder(20, 24, 12, 24));
progressBar = new JProgressBar();
progressBar.setIndeterminate(true);
progressBar.getAccessibleContext().setAccessibleName(text("Progression", "Progress"));
progressLabel = new JLabel(text("Chargement du pack…", "Loading the pack…"));
// Keep long filenames from widening the window beyond the screen.
progressLabel.setPreferredSize(new Dimension(640, 24));
progress.add(progressBar, BorderLayout.NORTH);
progress.add(progressLabel, BorderLayout.SOUTH);
add(progress, BorderLayout.CENTER);
buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
buttons.setBorder(new EmptyBorder(0, 16, 18, 16));
options = new JButton(text("Mods optionnels…", "Optional mods…"));
options.addActionListener(event -> {
options.setText(text("Chargement…", "Loading…"));
options.setEnabled(false);
handler.setOptionsButtonPressed(true);
});
cancel = new JButton(text("Annuler", "Cancel"));
cancel.addActionListener(event -> {
cancel.setEnabled(false);
handler.setCancelButtonPressed(true);
});
ok = new JButton(text("Continuer", "Continue"));
ok.addActionListener(event -> handler.setOkButtonPressed(true));
buttons.add(options);
buttons.add(cancel);
add(buttons, BorderLayout.SOUTH);
pack();
setLocationRelativeTo(null);
}
private String text(String fr, String en) {
return french ? fr : en;
}
public final void displayProgress(InstallProgress progress) {
progressBar.setIndeterminate(!progress.getHasProgress());
progressBar.setStringPainted(progress.getHasProgress());
if (progress.getHasProgress()) {
progressBar.setMaximum(progress.getProgressTotal());
progressBar.setValue(progress.getProgress());
progressBar.setString(progress.getProgress() + " / " + progress.getProgressTotal());
} else {
progressBar.setValue(0);
}
progressLabel.setText(progress.getMessage());
progressLabel.setToolTipText(progress.getMessage());
}
public final void disableOptionsButton(boolean hasOptions) {
options.setText(hasOptions ? text("Mods optionnels…", "Optional mods…")
: text("Aucun mod optionnel", "No optional mods"));
options.setEnabled(false);
}
public final void showOk(boolean hideCancel) {
if (hideCancel) {
buttons.remove(cancel);
}
if (ok.getParent() != buttons) {
buttons.add(ok);
}
buttons.revalidate();
buttons.repaint();
}
public final void hideOk() {
buttons.remove(ok);
if (cancel.getParent() != buttons) {
buttons.add(cancel);
}
buttons.revalidate();
buttons.repaint();
}
public final void timeoutOk(long remaining) {
// Upstream GUIHandler invokes this callback from its countdown timer.
if (!SwingUtilities.isEventDispatchThread()) {
SwingUtilities.invokeLater(() -> timeoutOk(remaining));
return;
}
ok.setText(text("Continuer", "Continue") + " (" + remaining + ")");
}
}
+91
View File
@@ -0,0 +1,91 @@
/* Swing integration check for the adapted window and the upstream GUIHandler. */
import java.awt.Component;
import java.awt.Container;
import java.awt.Graphics2D;
import java.awt.Window;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.Locale;
import javax.imageio.ImageIO;
import javax.swing.JButton;
import javax.swing.JProgressBar;
import javax.swing.SwingUtilities;
import link.infra.packwiz.installer.ui.data.InstallProgress;
import link.infra.packwiz.installer.ui.gui.GUIHandler;
import link.infra.packwiz.installer.ui.gui.InstallWindow;
public final class InstallWindowCheck {
private static <T extends Component> T find(Container root, Class<T> type, String text) {
for (Component child : root.getComponents()) {
if (type.isInstance(child) && (text == null ||
(child instanceof JButton && ((JButton) child).getText().equals(text)))) {
return type.cast(child);
}
if (child instanceof Container) {
T nested = find((Container) child, type, text);
if (nested != null) return nested;
}
}
return null;
}
private static void require(boolean value, String message) {
if (!value) throw new AssertionError(message);
}
public static void main(String[] args) throws Exception {
boolean fr = "fr".equals(Locale.getDefault().getLanguage());
GUIHandler handler = new GUIHandler();
try {
handler.setTitle("Sanctuary · packwiz");
handler.show();
SwingUtilities.invokeAndWait(() -> {});
InstallWindow window = null;
for (Window candidate : Window.getWindows()) {
if (candidate instanceof InstallWindow) window = (InstallWindow) candidate;
}
final InstallWindow frame = window;
require(frame != null && frame.isVisible(), "Upstream handler did not show branded window");
handler.submitProgress(new InstallProgress(fr ? "Téléchargement des mods…" : "Downloading mods…", 3, 10));
SwingUtilities.invokeAndWait(() -> {
JProgressBar bar = find(frame, JProgressBar.class, null);
require(bar != null && !bar.isIndeterminate() && bar.getValue() == 3
&& bar.getMaximum() == 10, "Upstream progress was not preserved");
frame.showOk(false);
});
// Exercise the timer callback from outside the Swing thread.
frame.timeoutOk(5);
SwingUtilities.invokeAndWait(() -> {
JButton next = find(frame, JButton.class, fr ? "Continuer (5)" : "Continue (5)");
require(next != null, "Countdown or locale broken");
next.doClick();
require(handler.getOkButtonPressed(), "Continue no longer reaches upstream handler");
frame.hideOk();
// Capture actual Swing layout, including the bundled logo, in both locales.
BufferedImage image = new BufferedImage(frame.getWidth(), frame.getHeight(), BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
frame.paint(graphics);
graphics.dispose();
try { ImageIO.write(image, "png", new File(args[0])); }
catch (Exception error) { throw new RuntimeException(error); }
frame.showOk(true);
require(find(frame, JButton.class, fr ? "Annuler" : "Cancel") == null,
"Cancel should be hidden during the optional-mod countdown");
frame.hideOk();
JButton options = find(frame, JButton.class, fr ? "Mods optionnels…" : "Optional mods…");
options.doClick();
require(handler.getOptionsButtonPressed() && !options.isEnabled(), "Options callback broken");
frame.disableOptionsButton(false);
require(find(frame, JButton.class, fr ? "Aucun mod optionnel" : "No optional mods") != null,
"Empty options label broken");
find(frame, JButton.class, fr ? "Annuler" : "Cancel").doClick();
require(handler.getCancelButtonPressed(), "Cancellation callback broken");
frame.displayProgress(new InstallProgress("Loading…"));
require(find(frame, JProgressBar.class, null).isIndeterminate(), "Indeterminate progress broken");
});
System.out.println("PASS: " + Locale.getDefault() + " logo/progress/options/continue/countdown/cancel");
} finally {
handler.dispose();
}
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 comp500
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+35
View File
@@ -0,0 +1,35 @@
# Fenêtre packwiz Sanctuary
`sanctuary.png` est le bandeau fourni par le créateur, conservé sans modification.
`InstallWindow.java` adapte la seule fenêtre de progression de packwiz-installer
v0.5.14, sous MIT. Le moteur officiel et ses autres interfaces restent intacts.
Le contrat de distribution est dans [le ticket beta.291](../../docs/packwiz-logo-beta291.md).
Construire avec le JDK 25 utilisé par le dépôt :
```sh
python3.14 scripts/packwiz_installer.py
python3.14 scripts/test_packwiz_installer.py
```
Le premier appel télécharge l'amont vérifié dans `build/packwiz-branding/`.
Le JAR Sanctuary généré doit correspondre à `installer.json` ; ce verrou et
`INSTALLER_SHA256` de `scripts/publish_web_pack.py` évoluent ensemble après revue
et vérification de la nouvelle fenêtre. Ne pas remplacer le moteur ni désactiver
le contrôle de hash pour faire passer une compilation différente.
Le parcours Swing réel FR/EN se vérifie sur un poste graphique, sans ouvrir
Minecraft :
```sh
javac -encoding UTF-8 -classpath build/packwiz-branding/packwiz-installer-sanctuary.jar \
-d build/packwiz-branding/check launcher/packwiz/InstallWindowCheck.java
java -Duser.language=fr -classpath build/packwiz-branding/check:build/packwiz-branding/packwiz-installer-sanctuary.jar \
InstallWindowCheck build/packwiz-branding/preview-fr.png
java -Duser.language=en -classpath build/packwiz-branding/check:build/packwiz-branding/packwiz-installer-sanctuary.jar \
InstallWindowCheck build/packwiz-branding/preview-en.png
```
Le séparateur de classpath est `;` sous Windows. Le parcours contrôle les
callbacks du vrai `GUIHandler`, la progression et le compte à rebours ; il
capture la fenêtre et la ferme. Les captures ne font pas partie du JAR livré.
+7
View File
@@ -0,0 +1,7 @@
{
"upstream_version": "v0.5.14",
"upstream_url": "https://github.com/packwiz/packwiz-installer/releases/download/v0.5.14/packwiz-installer.jar",
"upstream_sha256": "c9f646908d340d84773948a9a7d98bc1dae250d35e1016dc6e2b8459760b5598",
"logo_sha256": "efee73a6c812c89b2900d41f6446c0f906ccf85136b6c226daf42870a41a5157",
"installer_sha256": "9e2912fdd05e5c23721acd58cd5c61bbf13061e453627e8f4df9a4c53f7aa875"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+38
View File
@@ -0,0 +1,38 @@
# Sonde média de développement
Ce module n'entre dans aucun pack Sanctuary. `scripts/media_lab.py` compile
ses sources avec Java 25 contre le classpath Loom exporté et crée son JAR dans
`build/media-prototype/`. Il utilise les versions exactes Dream Displays 1.10.4
et Sound Physics Remastered 1.5.1+26.3 ; il échoue hors de son laboratoire.
La scène est créée dans un monde plat neuf `media-flat`. Le lecteur est un
écran Dream de béton noir construit par l'API amont ; aucun cadre Sanctuary
n'est ajouté. L'API serveur crée la surface, le prototype choisit le mode
`SYNCED`, puis attend les deux joueurs effectivement admis avant de changer
la source. Les identités de test choisissent des couleurs différentes dans
Hello World. Les menus de pause sont fermés pour laisser avancer l'essai.
La sonde n'altère ni les seuils de synchronisation Dream ni les pixels lus :
un mixin lit le code temporel synthétique de 16 bits et note uniquement les
uploads GPU réussis. Un autre compte les appels à `SoundPhysics.processSound`.
Les positions internes et l'activité de la sortie Java Sound sont observées
par réflexion ; les API internes sont volontairement épinglées à ces versions.
L'acoustique propre de Dream est éteinte pour ne pas la confondre avec SPR.
Les captures sont produites par l'API Minecraft dans les seuls clients du
laboratoire. Aucune capture du bureau ni du microphone. Les mesures concernent
l'envoi au GPU, pas l'instant physique de balayage d'un écran ou une écoute
subjective. Le rapport expose les échantillons manquants et sépare les quinze
premières secondes de chaque phase.
Le serveur Minecraft et le serveur HTTP sont limités à `127.0.0.1`. Le flag
amont `dreamdisplays.allowPrivateUrls=true` est réservé aux processus du labo
pour lire le média synthétique local ; il ne rejoint aucune configuration du
pack. Le lanceur ne commande que ses propres PID identifiés par leur argfile.
Les données, composants natifs et médias restent sous `build/`, ignorés par Git.
Source Sanctuary : GPL-3.0-or-later. Dream est téléchargé intact depuis
[son artefact Modrinth](https://modrinth.com/mod/dreamdisplays/version/siUKU04U),
licence LGPL-3.0 déclarée, avec vérification SHA-512. Les sources amont étudiées
et les limites de compatibilité figurent dans
[MEDIA-01](../../docs/media-displays-study.md).
@@ -0,0 +1,84 @@
package fr.koka.mediaprobe;
import fr.koka.media.anchor.*;
import com.google.gson.JsonObject;
import java.util.*;
import net.minecraft.core.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
import net.minecraft.world.entity.item.ItemEntity;
/** Only packaged by --media-fix; all writes are in the already guarded fresh laboratory. */
public final class AnchorProbe {
public static final BlockPos POS=new BlockPos(-4,81,1);
private static String last="";
private static final JsonObject assertions=new JsonObject();
public static void tick(MinecraftServer s){
var ctl=Probe.control();var level=s.overworld();String step=ctl.has("anchor_server_step")?ctl.get("anchor_server_step").getAsString():"";
var p=s.getPlayerList().getPlayers().stream().filter(x->x.getName().getString().endsWith("A")).findFirst().orElse(null);
if(!step.isEmpty()&&!step.equals(last)&&p!=null){
switch(step){
case "setup"->{
p.setGameMode(GameType.SURVIVAL);p.getInventory().clearContent();p.getInventory().setItem(0,new ItemStack(Anchors.ITEM,4));
move(p,-3.5,81,4);
for(var b:s.getPlayerList().getPlayers())if(b!=p)move(b,0,81,8);
}
case "far"->move(p,0,81,14);
case "wide-view"->move(p,12.5,81,30);
case "large-wall"->{
for(var q:BlockPos.betweenClosed(-6,80,0,36,80,34)){
if(!level.getBlockState(q).isAir()&&!level.getBlockState(q).is(Blocks.STONE))throw new IllegalStateException("Existing lab floor changed");
level.setBlockAndUpdate(q,Blocks.STONE.defaultBlockState());
}
for(var q:BlockPos.betweenClosed(-4,81,0,30,88,0)){
if(!level.getBlockState(q).isAir()&&!level.getBlockState(q).is(Blocks.CONCRETE.black()))throw new IllegalStateException("Existing lab wall changed");
level.setBlockAndUpdate(q,Blocks.CONCRETE.black().defaultBlockState());
}
}
case "unindex"->{var a=(AnchorBlockEntity)level.getBlockEntity(POS);Anchors.unindex(a);assertions.addProperty("index_removed",Anchors.loaded(level).isEmpty());}
case "near"->move(p,-3.5,81,4);
case "camera"->{
var v=ctl.getAsJsonArray("camera_a");p.setNoGravity(true);
move(p,v.get(0).getAsDouble(),v.get(1).getAsDouble(),v.get(2).getAsDouble());
}
case "wall"->{for(var q:BlockPos.betweenClosed(-6,81,6,6,86,6))level.setBlockAndUpdate(q,Blocks.STONE.defaultBlockState());}
case "clear-wall"->{for(var q:BlockPos.betweenClosed(-6,81,6,6,86,6))level.setBlockAndUpdate(q,Blocks.AIR.defaultBlockState());}
case "remove-support"->level.setBlockAndUpdate(new BlockPos(2,84,0),Blocks.AIR.defaultBlockState());
case "rules"->{
var a=(AnchorBlockEntity)level.getBlockEntity(POS);
if(a==null)return;
var d=com.dreamdisplays.api.server.DreamDisplaysServer.display(a.display);
assertions.addProperty("native_delete_refused",d.delete()==com.dreamdisplays.api.CommandResult.REFUSED);
assertions.addProperty("marker_present",d.getCustomData().get(Anchors.MARKER)!=null);
assertions.addProperty("unloaded_rejected",Anchors.validate(level,p,new AnchorGeometry(new BlockPos(40000,81,1),Direction.SOUTH,2,2),null).equals("unloaded"));
assertions.addProperty("obstacle_rejected",testObstacle(p,a));
assertions.addProperty("overlap_rejected",Anchors.validate(level,p,new AnchorGeometry(new BlockPos(2,81,1),Direction.SOUTH,2,2),null).equals("overlap"));
}
case "no-build"->p.setGameMode(GameType.ADVENTURE);
case "build"->p.setGameMode(GameType.SURVIVAL);
case "verify-reload"->assertions.addProperty("reloaded",level.getBlockEntity(POS) instanceof AnchorBlockEntity a&&a.width==(ctl.has("expect_width")?ctl.get("expect_width").getAsInt():7)&&a.height==(ctl.has("expect_height")?ctl.get("expect_height").getAsInt():4)&&a.display!=null&&com.dreamdisplays.api.server.DreamDisplaysServer.display(a.display)!=null);
default->throw new IllegalArgumentException(step);
}
last=step;
}
var row=new JsonObject();row.add("assertions",assertions.deepCopy());row.addProperty("server_step",last);
var a=level.isLoaded(POS)&&level.getBlockEntity(POS) instanceof AnchorBlockEntity v?v:null;
if(a!=null){row.addProperty("width",a.width);row.addProperty("height",a.height);row.addProperty("revision",a.revision);row.addProperty("display",String.valueOf(a.display));var d=com.dreamdisplays.api.server.DreamDisplaysServer.display(a.display);if(d!=null){row.addProperty("url",d.getUrl());row.addProperty("mode",d.getMode().name());}}
row.addProperty("anchor",a!=null);
if(p!=null){int n=0;for(int i=0;i<p.getInventory().getContainerSize();i++){var st=p.getInventory().getItem(i);if(st.is(Anchors.ITEM))n+=st.getCount();}row.addProperty("frames_in_inventory",n);}
if(p!=null)row.addProperty("crouching",p.isShiftKeyDown());
int drops=level.getEntitiesOfClass(ItemEntity.class,new AABB(-6,80,-1,6,87,5)).stream().filter(e->e.getItem().is(Anchors.ITEM)).mapToInt(e->e.getItem().getCount()).sum();row.addProperty("frame_drops",drops);
row.addProperty("displays",com.dreamdisplays.api.server.DreamDisplaysServer.isAvailable()?com.dreamdisplays.api.server.DreamDisplaysServer.getDisplays().getSize():-1);
Probe.write(Probe.LAB.resolve("anchor-server.jsonl"),row);
}
private static boolean testObstacle(ServerPlayer p,AnchorBlockEntity a){
var cell=a.geometry().cell(a.width-1,a.height-1).relative(Direction.SOUTH);var old=p.level().getBlockState(cell);
p.level().setBlockAndUpdate(cell,Blocks.GLASS.defaultBlockState());
boolean result=Anchors.validate(p.level(),p,a.geometry(),a).equals("obstacle");p.level().setBlockAndUpdate(cell,old);return result;
}
private static void move(ServerPlayer p,double x,double y,double z){p.teleportTo(p.level(),x,y,z,Set.of(),180,0,false);}
}
@@ -0,0 +1,94 @@
package fr.koka.mediaprobe;
import fr.koka.media.Access;
import fr.koka.media.anchor.*;
import com.google.gson.JsonObject;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.input.MouseButtonInfo;
import net.minecraft.core.*;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.phys.*;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
public final class AnchorProbeClient {
private static String done="",captured="",phase="";
private static int phaseTicks;
public static void tick(Minecraft mc){
if(mc.player==null||mc.level==null)return;
var ctl=Probe.control();String who=System.getProperty("sanctuary.mediaClient");
if(ctl.has("hide_gui")&&mc.gui.hud.isHidden()!=ctl.get("hide_gui").getAsBoolean())mc.gui.hud.toggle();
boolean a=who.endsWith("a");
Vec3 aim=a?new Vec3(-3.5,81.5,.5):new Vec3(0,83,1);
if(ctl.has(a?"aim_a":"aim_b")){var xs=ctl.getAsJsonArray(a?"aim_a":"aim_b");aim=new Vec3(xs.get(0).getAsDouble(),xs.get(1).getAsDouble(),xs.get(2).getAsDouble());}
Vec3 d=aim.subtract(mc.player.getEyePosition());mc.player.setYRot((float)Math.toDegrees(Math.atan2(-d.x,d.z)));mc.player.setXRot((float)-Math.toDegrees(Math.atan2(d.y,Math.hypot(d.x,d.z))));
if(!phase.equals(Probe.phase)){phase=Probe.phase;phaseTicks=0;}else phaseTicks++;
String step=ctl.has(a?"anchor_a":"anchor_b")?ctl.get(a?"anchor_a":"anchor_b").getAsString():"";
String key=Probe.phase+"/"+step;
if(!step.isEmpty()&&!done.equals(key)&&phaseTicks>=2&&perform(mc,step,ctl)){done=key;phaseTicks=0;}
if(!captured.equals(Probe.phase)&&phaseTicks>=4){
net.minecraft.client.Screenshot.grab(mc.gameDirectory,"anchor-"+Probe.phase+".png",mc.gameRenderer.mainRenderTarget(),1,m->{});captured=Probe.phase;
}
var row=new JsonObject();row.addProperty("step",done);row.addProperty("screen",mc.gui.screen()==null?"":mc.gui.screen().getClass().getSimpleName());
var target=Anchors.target(mc.level,mc.player);row.addProperty("target",target!=null);if(target!=null)row.addProperty("distance",target.distance());
if(mc.gui.screen() instanceof AnchorScreen screen){var v=screen.snapshot();row.addProperty("settings",v.settings());row.addProperty("editable",v.editable());row.addProperty("message",v.message());row.addProperty("width",v.width());row.addProperty("height",v.height());}
else if(mc.gui.screen()!=null&&mc.gui.screen().getClass().getSimpleName().equals("DisplayMenu")){
row.addProperty("settings",false);row.addProperty("editable",target!=null&&mc.player.mayBuild()&&mc.player.getEyePosition().distanceTo(Vec3.atCenterOf(target.info().geometry().anchor()))<=mc.player.blockInteractionRange()+.5);
}
Probe.write(Probe.LAB.resolve("anchor-"+who+".jsonl"),row);
}
private static boolean perform(Minecraft mc,String step,JsonObject ctl){
if(step.equals("place")){
mc.gameMode.useItemOn(mc.player,InteractionHand.MAIN_HAND,new BlockHitResult(new Vec3(-3.5,81.5,1),Direction.SOUTH,new BlockPos(-4,81,0),false));return true;
}
if(step.equals("close")){mc.gui.setScreen(null);return true;}
if(step.equals("shift-hold")){mc.gui.setScreen(null);mc.options.keyShift.setDown(true);return true;}
if(step.equals("shift-release")){mc.options.keyShift.setDown(false);return true;}
if(step.equals("unindex")){for(var a:Anchors.loaded(mc.level))Anchors.unindex(a);return true;}
if(step.equals("click")){mc.gui.setScreen(null);Access.privateCall(mc,"startUseItem");return true;}
if(step.equals("forged-open")){
mc.gui.setScreen(null);var a=Anchors.loaded(mc.level).stream().findFirst().orElseThrow();
ClientPlayNetworking.send(new AnchorPackets.Action(a.getBlockPos(),a.display,0,0,0,a.revision,"",0));return true;
}
Screen current=mc.gui.screen();if(current==null)return false;
if(step.startsWith("size:")){
if(!(current instanceof AnchorScreen)){click(current,"dimensions");return false;}
AnchorScreen screen=(AnchorScreen)current;
String[] n=step.split(":");box(screen,"width").setValue(n[1]);box(screen,"height").setValue(n[2]);click(screen,"apply");return true;
}
if(step.equals("reader")){if(current instanceof AnchorScreen)click(current,"player");return true;}
if(step.equals("url")){
if(current instanceof AnchorScreen){click(current,"player");return false;}
Object panel=Probe.field(current,"suggestions");EditBox search=(EditBox)Probe.field(panel,"searchBox");
search.setValue(ctl.get("url").getAsString());search.setFocused(true);Probe.call(panel,"keyPressed",new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0));return true;
}
if(step.equals("pick")){
Object panel=Probe.field(current,"suggestions");
try{
var method=panel.getClass().getDeclaredMethod("cardAt",double.class,double.class);method.setAccessible(true);
AbstractWidget widget=(AbstractWidget)panel;
for(int y=widget.getY()+30;y<widget.getY()+widget.getHeight();y+=4)for(int x=widget.getX()+10;x<widget.getX()+widget.getWidth()-20;x+=4)
if(((Number)method.invoke(panel,(double)x,(double)y)).intValue()==0){Probe.call(panel,"mouseClicked",new MouseButtonEvent(x+.5,y+.5,new MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT,0)),false);return true;}
}catch(ReflectiveOperationException e){throw new IllegalStateException(e);}
return false;
}
if(step.equals("pause")||step.equals("play")){
if(current instanceof AnchorScreen){click(current,"player");return false;}
Object ds=Probe.field(current,"displayScreen");boolean paused=Boolean.TRUE.equals(Probe.call(ds,"isPaused"));
if(paused!=step.equals("pause"))current.keyPressed(new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_SPACE,0,0));return true;
}
if(step.equals("stale")){
if(!(current instanceof AnchorScreen))return false;AnchorScreen screen=(AnchorScreen)current;
var v=screen.snapshot();ClientPlayNetworking.send(new AnchorPackets.Action(v.pos(),v.display(),1,6,3,v.revision()-1,"",0));return true;
}
if(step.equals("far-resize")){
var v=Anchors.infos(mc.level).getFirst();ClientPlayNetworking.send(new AnchorPackets.Action(v.geometry().anchor(),v.display(),1,6,3,v.revision(),"",0));return true;
}
throw new IllegalArgumentException(step);
}
private static EditBox box(AnchorScreen s,String key){return (EditBox)s.children().stream().filter(w->w instanceof EditBox b&&b.getMessage().getString().equals(Anchors.label(key).getString())).findFirst().orElseThrow();}
private static void click(Screen s,String key){var button=(Button)s.children().stream().filter(w->w instanceof Button b&&b.getMessage().getString().equals(Anchors.label(key).getString())).findFirst().orElseThrow();button.onPress(new KeyEvent(257,0,0));}
}
@@ -0,0 +1,66 @@
package fr.koka.mediaprobe;
import com.google.gson.*;
import java.lang.reflect.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/** Read-only metrics and explicit file controls, confined to a disposable laboratory. */
public final class Probe {
public static volatile java.util.Map<String,Object> VIDEO_ENV=java.util.Map.of();
public static final Gson JSON = new Gson();
public static final Map<String, AtomicLong> SOUNDS = new ConcurrentHashMap<>();
public static volatile long frame = -1, frameWallNs, uploads;
public static volatile String phase = "boot";
public static final Path LAB = Path.of(System.getProperty("sanctuary.mediaLab", ".")).toAbsolutePath().normalize();
public static void guard(Path gameDir) {
if (!LAB.toString().contains("/build/media-prototype/") || !Files.isRegularFile(LAB.resolve("manifest.json"))
|| !gameDir.toAbsolutePath().normalize().getParent().equals(LAB))
throw new IllegalStateException("Media probe requires its isolated build/media-prototype session");
}
public static Object field(Object o, String name) {
if (o == null) return null;
for (Class<?> c = o.getClass(); c != null; c = c.getSuperclass()) try {
var f = c.getDeclaredField(name); f.setAccessible(true); return f.get(o);
} catch (NoSuchFieldException e) { /* try parent */ }
catch (ReflectiveOperationException e) { throw new IllegalStateException(e); }
return null;
}
public static Object call(Object o, String name, Object... args) {
try {
Class<?> type = o instanceof Class<?> c ? c : o.getClass();
for (var m : type.getMethods()) if (m.getName().equals(name) && m.getParameterCount() == args.length) {
m.setAccessible(true);
try { return m.invoke(o instanceof Class<?> ? null : o, args); }
catch (IllegalArgumentException e) { /* another overload */ }
}
throw new NoSuchMethodException(type.getName() + "." + name);
} catch (ReflectiveOperationException e) { throw new IllegalStateException(e); }
}
public static Object staticCall(String type, String name, Object... args) {
try { return call(Class.forName(type), name, args); }
catch (ClassNotFoundException e) { throw new IllegalStateException(e); }
}
public static Object instance(String type) {
try { return Class.forName(type).getField("INSTANCE").get(null); }
catch (ReflectiveOperationException e) { throw new IllegalStateException(e); }
}
@SuppressWarnings({"unchecked", "rawtypes"})
public static Object constant(String type, String name) {
try { return Enum.valueOf((Class) Class.forName(type), name); }
catch (ClassNotFoundException e) { throw new IllegalStateException(e); }
}
public static JsonObject control() {
try { return JsonParser.parseString(Files.readString(LAB.resolve("control.json"))).getAsJsonObject(); }
catch (java.io.IOException e) { return new JsonObject(); }
}
public static synchronized void write(Path path, JsonObject row) {
row.addProperty("wall_ns", System.currentTimeMillis() * 1_000_000L);
row.addProperty("mono_ns", System.nanoTime()); row.addProperty("phase", phase);
try { Files.writeString(path, JSON.toJson(row) + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND); }
catch (java.io.IOException e) { throw new IllegalStateException(e); }
}
public static void value(JsonObject row, String key, Object value) { row.add(key, JSON.toJsonTree(value)); }
}
@@ -0,0 +1,143 @@
package fr.koka.mediaprobe;
import com.google.gson.*;
import java.nio.file.*;
import java.util.*;
import javax.sound.sampled.SourceDataLine;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
import com.mojang.blaze3d.systems.RenderSystem;
public final class ProbeClient implements ClientModInitializer {
private int ticks;
private Boolean soundEnabled;
private String capturedPhase = "";
private String optionPhase="";
private int optionTicks;
private final Path metrics = Probe.LAB.resolve(System.getProperty("sanctuary.mediaClient") + ".jsonl");
@Override public void onInitializeClient() {
Probe.guard(Minecraft.getInstance().gameDirectory.toPath());
ScreenEvents.AFTER_INIT.register((mc, screen, w, h) -> {
if (!(screen instanceof fr.koka.sanctuary.client.HelloWorldScreen)) return;
mc.execute(() -> {
if (mc.gui.screen() != screen) return;
var enter = new KeyEvent(257, 0, 0);
for (var suffix : List.of("$ColorButton", "$StarterSlot")) {
var choices = screen.children().stream().filter(child -> child instanceof Button b && b.active && child.getClass().getName().endsWith(suffix)).toList();
if (!choices.isEmpty()) ((Button) choices.get(System.getProperty("sanctuary.mediaClient").endsWith("a") ? Math.min(1, choices.size()-1) : 0)).onPress(enter);
}
for (var child : screen.children()) if (child instanceof EditBox box
&& box.getMessage().getString().equals(Component.translatable("sanctuary.starter.name").getString())) box.setValue("MediaLab");
for (var child : screen.children()) if (child instanceof Button b && b.active
&& b.getMessage().getString().equals(fr.koka.sanctuary.progression.ProgressionService.label("enter").getString())) { b.onPress(enter); break; }
});
});
ClientTickEvents.END_CLIENT_TICK.register(this::sample);
}
private void sample(Minecraft mc) {
if (++ticks % 5 != 0) return;
var row = new JsonObject();
try {
String backend = RenderSystem.getDevice().getDeviceInfo().backendName();
if (!backend.equalsIgnoreCase("vulkan")) throw new IllegalStateException("Expected Vulkan, got " + backend);
row.addProperty("backend", backend); row.addProperty("connected", mc.player != null);
row.addProperty("fps", mc.getFps());
if (mc.player != null) row.addProperty("player_position", mc.player.position().toString());
row.addProperty("screen", mc.gui.screen() == null ? null : mc.gui.screen().getClass().getSimpleName());
var ctl = Probe.control(); if (ctl.has("phase")) Probe.phase = ctl.get("phase").getAsString();
boolean anchorLab=ctl.has("anchor_lab")&&ctl.get("anchor_lab").getAsBoolean();
if(anchorLab)Probe.staticCall("fr.koka.mediaprobe.AnchorProbeClient","tick",mc);
if(net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("sanctuary_media")&&ctl.has("video_acoustics")){
boolean desired=ctl.get("video_acoustics").getAsBoolean();
String who=System.getProperty("sanctuary.mediaClient");
if(ctl.has("video_acoustics_clients")&&ctl.getAsJsonObject("video_acoustics_clients").has(who))
desired=ctl.getAsJsonObject("video_acoustics_clients").get(who).getAsBoolean();
boolean actual=Boolean.TRUE.equals(Probe.staticCall("fr.koka.media.MediaSettings","enabled"));
if(actual!=desired&&mc.player!=null){
if(!(mc.gui.screen() instanceof fr.koka.sanctuary.client.sound.SoundPhysicsOptionsScreen)){
mc.gui.setScreen(new fr.koka.sanctuary.client.sound.SoundPhysicsOptionsScreen(null));optionTicks=0;
}else if(++optionTicks>=2){
var widget=findOption(mc.gui.screen());
if(widget==null)throw new IllegalStateException("Video acoustics option not found");
Probe.call(widget,"onPress",new KeyEvent(257,0,0));
Object list=Probe.field(mc.gui.screen(),"list");
if(list!=null)Probe.call(list,"setScrollAmount",0.0);
optionPhase=Probe.phase;optionTicks=-3;
}
}else if(optionTicks<0){
if(++optionTicks==-2)net.minecraft.client.Screenshot.grab(mc.gameDirectory,"option-"+optionPhase+".png",mc.gameRenderer.mainRenderTarget(),1,message->{});
if(optionTicks==0)mc.gui.setScreen(null);
}
row.addProperty("video_acoustics",actual);
}
if (mc.player != null && mc.gui.screen() instanceof net.minecraft.client.gui.screens.PauseScreen) mc.gui.setScreen(null);
if (mc.player != null && mc.gui.screen() == null) {
if(!anchorLab){mc.player.setYRot(ctl.has("look_away") && ctl.get("look_away").getAsBoolean() ? 0 : 180);mc.player.setXRot(-5);}
if (!capturedPhase.equals(Probe.phase) && Probe.uploads > 0) {
String name = Probe.phase.replaceAll("[^a-z0-9-]", "_");
net.minecraft.client.Screenshot.grab(mc.gameDirectory, "media-" + name + ".png", mc.gameRenderer.mainRenderTarget(), 1, message -> {});
capturedPhase = Probe.phase;
}
}
boolean enabled = ctl.has("sound_physics") && ctl.get("sound_physics").getAsBoolean();
if (!Objects.equals(soundEnabled, enabled)) {
var config = com.sonicether.soundphysics.SoundPhysicsMod.CONFIG;
config.enabled.set(enabled).saveSync(); config.reloadClient(); mc.getSoundManager().reload(); soundEnabled = enabled;
}
row.addProperty("sound_physics", enabled);
row.add("spr_calls", Probe.JSON.toJsonTree(Probe.SOUNDS));
row.add("video_environment",Probe.JSON.toJsonTree(Probe.VIDEO_ENV));
row.addProperty("visual_frame", Probe.frame); row.addProperty("visual_frame_wall_ns", Probe.frameWallNs);
row.addProperty("uploads", Probe.uploads);
var list = new JsonArray();
if (Boolean.TRUE.equals(Probe.staticCall("com.dreamdisplays.api.DreamDisplays", "isAvailable"))) {
Object registry = Probe.instance("com.dreamdisplays.platform.client.displays.DisplayRegistry");
Object raw = Probe.call(registry, "getScreens");
Collection<?> screens = raw instanceof Map<?,?> map ? map.values() : (Collection<?>) raw;
for (Object screen : screens) {
var entry = new JsonObject();
Probe.value(entry, "started", Probe.call(screen, "isVideoStarted"));
Object player = Probe.field(Probe.field(screen, "media"), "player");
if (screen != null) {
var acoustics = screen.getClass().getMethod("setAcousticsEnabled", boolean.class); acoustics.invoke(screen, false);
Probe.value(entry, "live", Probe.call(screen, "isLive"));
}
if (player != null) {
Probe.value(entry, "position_ns", Probe.call(player, "getCurrentTime"));
Probe.value(entry, "clock_running", Probe.call(player, "isClockRunning"));
Object audio = Probe.field(Probe.field(player, "sessionManager"), "audio");
if (audio != null) {
Object clock = Probe.call(audio, "sampleClock");
Probe.value(entry, "audio_content_ns", Probe.field(clock, "nanos"));
Probe.value(entry, "audio_origin_known", Probe.field(clock, "originKnown"));
}
Object line = Probe.field(Probe.field(audio, "current"), "line");
if (line instanceof SourceDataLine output) {
boolean openal=line.getClass().getName().equals("fr.koka.media.OpenAlLine");
entry.addProperty("audio_backend",openal?"openal":"java_sound");
entry.addProperty("audio_active",output.isRunning());
entry.addProperty("java_sound_running",!openal&&output.isRunning());
if(openal)Probe.value(entry,"media_audio",Probe.staticCall("fr.koka.media.OpenAlLine","status",String.valueOf(Probe.field(audio,"debugLabel"))));
entry.addProperty("audio_frames", output.getLongFramePosition());
entry.addProperty("audio_buffer_bytes", output.getBufferSize() - output.available());
}
}
list.add(entry);
}
}
row.add("displays", list);
} catch (Exception e) { row.addProperty("probe_error", e.toString()); }
Probe.write(metrics, row);
}
private static net.minecraft.client.gui.components.AbstractWidget findOption(Object node){
if(node instanceof net.minecraft.client.gui.components.AbstractWidget w&&w.getMessage().getString().startsWith(Component.translatable("sanctuary_media.video_acoustics").getString()))return w;
if(node instanceof net.minecraft.client.gui.components.events.ContainerEventHandler parent)
for(var child:parent.children()){var found=findOption(child);if(found!=null)return found;}
return null;
}
}
@@ -0,0 +1,94 @@
package fr.koka.mediaprobe;
import com.google.gson.JsonObject;
import java.nio.file.*;
import java.util.*;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.lifecycle.v1.*;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.LevelResource;
public final class ProbeServer implements ModInitializer {
private Object display;
private long tick;
private String url = "";
private boolean wall;
@Override public void onInitialize() {
ServerLifecycleEvents.SERVER_STARTED.register(s -> {
Probe.guard(s.getServerDirectory());
var world = s.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
if (!world.getFileName().toString().equals("media-flat")) throw new IllegalStateException("Wrong lab world");
var level = s.overworld();
var marker = world.resolve("media-scene-v1.txt");
try {
if (Files.exists(marker)) {
if (!Files.readString(marker).equals("ready\n")) throw new IllegalStateException("Incomplete scene preserved; choose a fresh run");
} else {
for (var p : BlockPos.betweenClosed(-12, 80, -5, 12, 85, 15))
if (!level.getBlockState(p).isAir()) throw new IllegalStateException("Existing scene preserved; choose a fresh run");
Files.writeString(marker, "preparing\n", StandardOpenOption.CREATE_NEW);
for (var p : BlockPos.betweenClosed(-12, 80, -5, 12, 80, 15)) level.setBlockAndUpdate(p, Blocks.STONE.defaultBlockState());
for (var p : BlockPos.betweenClosed(-4, 81, 0, 4, 85, 0)) level.setBlockAndUpdate(p, Blocks.CONCRETE.black().defaultBlockState());
Files.writeString(marker, "ready\n");
}
} catch (java.io.IOException e) { throw new IllegalStateException("Cannot prepare media scene", e); }
System.out.println("MEDIA_PROBE_SERVER_READY");
});
ServerPlayConnectionEvents.JOIN.register((h, sender, s) -> s.execute(() ->
h.player.teleportTo(s.overworld(), h.player.getName().getString().endsWith("B") ? 1 : -1,
81, 9, Set.of(), 180, -5, false)));
ServerTickEvents.END_SERVER_TICK.register(this::tick);
}
private void tick(MinecraftServer s) {
if (++tick % 10 != 0) return;
try {
var ctl = Probe.control();
if (ctl.has("phase")) Probe.phase = ctl.get("phase").getAsString();
if(ctl.has("anchor_lab")&&ctl.get("anchor_lab").getAsBoolean()){
Probe.staticCall("fr.koka.mediaprobe.AnchorProbe","tick",s);return;
}
boolean occluded=ctl.has("occlusion")&&ctl.get("occlusion").getAsBoolean();
if(wall!=occluded){
for(var pos:BlockPos.betweenClosed(-6,81,5,6,86,5))s.overworld().setBlockAndUpdate(pos,(occluded?Blocks.STONE:Blocks.AIR).defaultBlockState());
wall=occluded;
}
if (display == null && s.getPlayerList().getPlayerCount() > 0
&& Boolean.TRUE.equals(Probe.staticCall("com.dreamdisplays.api.server.DreamDisplaysServer", "isAvailable"))) {
Object displays = Probe.staticCall("com.dreamdisplays.api.server.DreamDisplaysServer", "getDisplays");
display = Probe.call(displays, "create", s.getPlayerList().getPlayers().getFirst().getUUID(),
"minecraft:overworld", -4, 81, 0, 4, 85, 0,
Probe.constant("com.dreamdisplays.api.display.model.property.DisplayFacing", "SOUTH"));
if (display == null) throw new IllegalStateException("Cannot create laboratory display");
Object data = Probe.field(display, "data");
Probe.call(data, "setMode", Probe.constant("com.dreamdisplays.api.playback.model.PlaybackMode", "SYNCED"));
System.out.println("MEDIA_PROBE_DISPLAY_READY " + Probe.call(display, "getId"));
}
if (display != null && ctl.has("url") && !url.equals(ctl.get("url").getAsString()) && ready("client-a") && ready("client-b")) {
String next = ctl.get("url").getAsString();
if (!next.startsWith("http://127.0.0.1:")) throw new IllegalStateException("Only the controlled lab source is accepted");
Object result = Probe.call(display, "setUrl", next, "");
System.out.println("MEDIA_PROBE_SOURCE " + result + " phase=" + Probe.phase);
if (!result.toString().equals("OK")) throw new IllegalStateException("Source refused: " + result);
url = next;
}
if (tick % 100 == 0 && s.getPlayerList().getPlayerCount() > 0)
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),
"playsound minecraft:block.note_block.pling master @a 0 83 1 0.1 1");
var row = new JsonObject(); row.addProperty("players", s.getPlayerList().getPlayerCount());
row.addProperty("tick", tick); Probe.write(Probe.LAB.resolve("server.jsonl"), row);
} catch (Exception e) {
System.err.println("MEDIA_PROBE_SERVER_ERROR " + e); e.printStackTrace();
}
}
private boolean ready(String name) {
try {
var lines = Files.readAllLines(Probe.LAB.resolve(name + ".jsonl"));
var row = com.google.gson.JsonParser.parseString(lines.getLast()).getAsJsonObject();
return row.get("connected").getAsBoolean() && (!row.has("screen") || row.get("screen").isJsonNull())
&& System.currentTimeMillis() * 1_000_000L - row.get("wall_ns").getAsLong() < 2_000_000_000L;
} catch (Exception ignored) { return false; }
}
}
@@ -0,0 +1,28 @@
package fr.koka.mediaprobe.mixin;
import fr.koka.mediaprobe.Probe;
import net.minecraft.resources.Identifier;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.phys.Vec3;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Pseudo
@Mixin(targets = "com.sonicether.soundphysics.SoundPhysics", remap = false)
abstract class SoundPhysicsMixin {
@Unique private static final ThreadLocal<Boolean> mediaProbe$video=ThreadLocal.withInitial(()->false);
@Inject(method = "processSound(IDDDLnet/minecraft/sounds/SoundSource;Lnet/minecraft/resources/Identifier;Z)Lnet/minecraft/world/phys/Vec3;", at = @At("HEAD"), remap = false)
private static void observe(int source, double x, double y, double z, SoundSource category,
Identifier sound, boolean auxOnly, CallbackInfoReturnable<Vec3> cir) {
Probe.SOUNDS.computeIfAbsent(String.valueOf(sound), k -> new java.util.concurrent.atomic.AtomicLong()).incrementAndGet();
mediaProbe$video.set(String.valueOf(sound).equals("sanctuary_media:display"));
}
@Inject(method = "processSound(IDDDLnet/minecraft/sounds/SoundSource;Lnet/minecraft/resources/Identifier;Z)Lnet/minecraft/world/phys/Vec3;", at = @At("RETURN"), remap = false)
private static void finish(int source,double x,double y,double z,SoundSource category,Identifier sound,boolean auxOnly,CallbackInfoReturnable<Vec3> cir){mediaProbe$video.set(false);}
@Inject(method="setEnvironment",at=@At("HEAD"),remap=false)
private static void environment(int source,float g0,float g1,float g2,float g3,float c0,float c1,float c2,float c3,float cutoff,float gain,org.spongepowered.asm.mixin.injection.callback.CallbackInfo ci){
if(mediaProbe$video.get())Probe.VIDEO_ENV=java.util.Map.of("cutoff",cutoff,"gain",gain,"reverb",g0+g1+g2+g3,"wall_ns",System.currentTimeMillis()*1_000_000L);
}
}
@@ -0,0 +1,49 @@
package fr.koka.mediaprobe.mixin;
import fr.koka.mediaprobe.Probe;
import java.nio.ByteBuffer;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Reads the synthetic test pattern only, at successful GPU submission; never changes video data. */
@Pseudo
@Mixin(targets = "com.dreamdisplays.media.player.pipeline.FrameSurface", remap = false)
abstract class VideoFrameMixin {
@Unique private final ThreadLocal<int[]> mediaProbeSample = ThreadLocal.withInitial(() -> new int[]{0, 0, -1});
@Inject(method = "updateFrame", at = @At("HEAD"), remap = false)
private void begin(@Coerce Object target, int w, int h, int ew, int eh, CallbackInfoReturnable<Boolean> cir) {
mediaProbeSample.set(new int[]{w, h, -1});
}
@Inject(method = "updateFramePlanar", at = @At("HEAD"), remap = false)
private void beginPlanar(@Coerce Object y, @Coerce Object u, @Coerce Object v, int w, int h, int ew, int eh, CallbackInfoReturnable<Boolean> cir) {
mediaProbeSample.set(new int[]{w, h, -1});
}
@Inject(method = "takeDueFrame", at = @At("RETURN"), remap = false)
private void selected(CallbackInfoReturnable<ByteBuffer> cir) {
ByteBuffer b = cir.getReturnValue(); int[] sample = mediaProbeSample.get();
if (b == null || sample[0] < 16) return;
String format = String.valueOf(Probe.field(this, "pixelFormat"));
int stride = format.contains("I420") ? 1 : format.contains("RGBA") ? 4 : 3;
// The synthetic 320x180 fixture is fitted inside arbitrary display ratios.
// Sample within its top timecode band, accounting for Dream's letterboxing.
double fit=Math.min(sample[0]/320.0,sample[1]/180.0);
int padX=(int)Math.round((sample[0]-320*fit)/2),padY=(int)Math.round((sample[1]-180*fit)/2);
int y=padY+(int)(8*fit);
int code = 0;
for (int bit = 0; bit < 16; bit++) {
int x = padX+(int)((bit + 0.5) * 320*fit / 16);
int index=(y*sample[0]+x)*stride;
if (index >= b.limit()) return;
code = (code << 1) | ((b.get(index) & 255) > 125 ? 1 : 0);
}
sample[2] = code;
}
@Inject(method = {"updateFrame", "updateFramePlanar"}, at = @At("RETURN"), remap = false)
private void uploaded(CallbackInfoReturnable<Boolean> cir) {
if (Boolean.TRUE.equals(cir.getReturnValue()) && mediaProbeSample.get()[2] >= 0) {
Probe.frame = mediaProbeSample.get()[2]; Probe.frameWallNs = System.currentTimeMillis() * 1_000_000L; Probe.uploads++;
}
}
}
@@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"id": "sanctuary_media_probe",
"version": "0.1.0-prototype",
"name": "Sanctuary Media Probe (development only)",
"license": "GPL-3.0-or-later",
"environment": "*",
"entrypoints": {
"main": ["fr.koka.mediaprobe.ProbeServer"],
"client": ["fr.koka.mediaprobe.ProbeClient"]
},
"mixins": ["media-probe.mixins.json"],
"depends": {
"fabricloader": ">=0.19.5",
"minecraft": "26.3",
"java": ">=25",
"fabric-api": "*",
"sanctuary_test": "*",
"dreamdisplays": "1.10.4",
"sound_physics_remastered": "1.5.1+26.3"
}
}
@@ -0,0 +1,7 @@
{
"required": true,
"package": "fr.koka.mediaprobe.mixin",
"compatibilityLevel": "JAVA_25",
"client": ["VideoFrameMixin", "SoundPhysicsMixin"],
"injectors": {"defaultRequire": 1}
}
+60
View File
@@ -0,0 +1,60 @@
# Sanctuary Media — prototypes MEDIA-03/04
Module **0.3.1**, embarqué dans Sanctuary depuis **beta.296**,
pour Dream Displays **1.10.4** sur Minecraft **26.3 / Java 25 / Fabric**.
Les clients décodent les médias ; le serveur fournit l'heure et garde l'état
partagé ainsi que la validation des constructions.
Compatibilité acoustique vérifiée avec Sound Physics Remastered **1.5.1+26.3**.
L'extension conserve le moteur et le **menu original Dream Displays** pour la
recherche, le collage d'un lien, l'aperçu, la lecture, les pistes et les réglages.
Elle ajoute le cadre de redstone comme ancre : un objet à la pose, aucun coût
d'agrandissement, cadre en volume sur toute la surface. Le menu largeur/hauteur
s'ouvre à la pose, puis via le bouton Taille du lecteur. Clic droit et Maj +
clic droit ouvrent le lecteur. [Correctif et validation beta.297](../../docs/media-frame-fixes-beta297.md).
Les dimensions sont libres en blocs entiers. Les budgets du serveur sont dans
`config/sanctuary-media-anchors.json` (`maxWidth`, `maxHeight`, `maxArea`) ; une
valeur zéro retire ce plafond. Les bornes du monde, le mur complet, l'espace
libre, les droits et les chunks chargés restent vérifiés. Les textures sont
bornées à 4096 pixels par axe et huit mégapixels, quelle que soit la taille
dans le monde. [Contrat et essais de l'ancre](../../docs/media-anchor-prototype.md).
- Horloge serveur estimée par ping, remise à zéro à la déconnexion.
- VOD ancrée à la timeline serveur et au curseur PCM réellement joué.
- Live HLS daté : origine audio/vidéo fixée par manifeste local, retard commun
de huit secondes, segments téléchargés directement par les clients.
- PCM stéréo converti en source OpenAL mono spatialisée sur l'écran, dans le
contexte audio Minecraft. Correction de vitesse limitée à ±2 %, saut de PCM
lors d'un retard supérieur à 200 ms.
- **Options → Sons → Sound Physics Remastered → Acoustique des vidéos** : choix
personnel, activé par défaut, sauvegardé dans `config/sanctuary-media.json`.
Désactivé : retrait des filtres, réverbérations et absorption Sound Physics
de la vidéo ; volume et position restent actifs. Aucun effet sur les autres sons.
Construction isolée, après export du classpath de développement :
```sh
./gradlew :sanctuary-test:exportDuoLaunch
python3.14 scripts/media_lab.py prepare --run media-test --media-fix
python3.14 scripts/media_lab.py test-media
```
Avec les ancres : `prepare --run anchor-test --anchors`, puis lancer serveur,
`serve` et les deux clients. `python3.14 scripts/anchor_lab_checks.py --run
anchor-test` pilote les vrais menus et vérifie les refus côté serveur.
Le JAR distribué est construit par Gradle puis inclus dans le JAR Sanctuary.
Le JAR isolé du laboratoire reste dans `build/media-prototype/sanctuary-media.jar`.
Le lanceur détecte la version embarquée et évite de copier un second module
dans les dossiers du laboratoire.
Le module de mesure `media-probe` n'est pas une dépendance de l'extension.
`--no-video-acoustics` sur la commande `control` pilote le vrai bouton de
l'interface via la sonde, sans changer Sound Physics globalement.
Le pont s'appuie sur des méthodes internes exactement épinglées de Dream et de
Sanctuary ; ce n'est pas une API publique stable. Les playlists HLS maître,
LL-HLS, sans date, avec byte ranges ou changements de timeline ne constituent
pas une synchronisation live validée. Pas de validation Twitch/YouTube publique,
de résolution ANI-CLI, La livraison du canal Beta est suivie dans [MEDIA-05](../../docs/media-beta296.md).
Voir [le contrat et les mesures](../../docs/media-sync-audio-prototype.md).
+48
View File
@@ -0,0 +1,48 @@
plugins { id 'java-library' }
group = rootProject.maven_group
version = '0.3.1'
base { archivesName = 'sanctuary-media' }
evaluationDependsOn(':sanctuary')
// Compile against classes, not Sanctuary's JAR, which embeds this module.
def sanctuary = project(':sanctuary')
sourceSets.main.compileClasspath += sanctuary.sourceSets.main.output + sanctuary.sourceSets.main.compileClasspath
sourceSets.test.compileClasspath += sourceSets.main.compileClasspath
sourceSets.test.runtimeClasspath += sourceSets.main.compileClasspath
configurations { dreamArtifact { transitive = false } }
dependencies {
compileOnly("maven.modrinth:4IIKQyBu:${rootProject.dream_displays_artifact}") { transitive = false }
dreamArtifact("maven.modrinth:4IIKQyBu:${rootProject.dream_displays_artifact}")
}
tasks.register('verifyDreamArtifact') {
inputs.files(configurations.dreamArtifact)
inputs.property('sha512', rootProject.dream_displays_sha512)
doLast {
def artifact = configurations.dreamArtifact.singleFile
def actual = java.security.MessageDigest.getInstance('SHA-512').digest(artifact.bytes).encodeHex().toString()
if (actual != rootProject.dream_displays_sha512) throw new GradleException('Dream Displays artifact hash mismatch')
copy {
from artifact
into rootProject.layout.buildDirectory.dir('external-mods')
rename { "dreamdisplays-fabric-${rootProject.minecraft_version}-${rootProject.dream_displays_version}.jar" }
}
}
}
java { toolchain.languageVersion = JavaLanguageVersion.of(25); withSourcesJar() }
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8'; options.release = 25 }
tasks.named('compileJava') { dependsOn('verifyDreamArtifact') }
tasks.named('jar') {
from(rootProject.file('LICENSE')) { rename { 'LICENSE_sanctuary_media' } }
preserveFileTimestamps = false
reproducibleFileOrder = true
}
tasks.named('test') { failOnNoDiscoveredTests = false }
tasks.register('mediaContracts', JavaExec) {
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.media.MediaSyncTest'
enableAssertions = true
}
tasks.named('check') { dependsOn('mediaContracts') }
@@ -0,0 +1,36 @@
package fr.koka.media;
import java.lang.reflect.*;
/** Version-pinned access to Dream internals; failures remain explicit. */
public final class Access {
private Access() {}
public static Object field(Object value, String name) {
if (value == null) return null;
for (Class<?> c = value.getClass(); c != null; c = c.getSuperclass()) try {
var f = c.getDeclaredField(name); f.setAccessible(true); return f.get(value);
} catch (NoSuchFieldException e) { /* parent */ }
catch (ReflectiveOperationException e) { throw new IllegalStateException(e); }
return null;
}
public static Object call(Object value, String name, Object... args) {
try {
Class<?> type = value instanceof Class<?> c ? c : value.getClass();
for (var m : type.getMethods()) if (m.getName().equals(name) && m.getParameterCount() == args.length) {
m.setAccessible(true);
try { return m.invoke(value instanceof Class<?> ? null : value, args); }
catch (IllegalArgumentException ignored) { }
}
throw new NoSuchMethodException(type.getName() + "." + name);
} catch (ReflectiveOperationException e) { throw new IllegalStateException(e); }
}
public static Object singleton(String name) {
try { return Class.forName(name).getField("INSTANCE").get(null); }
catch (ReflectiveOperationException e) { throw new IllegalStateException(e); }
}
public static Object privateCall(Object value,String name){
try{var method=value.getClass().getDeclaredMethod(name);method.setAccessible(true);return method.invoke(value);}
catch(ReflectiveOperationException e){throw new IllegalStateException(e);}
}
public static String label(Object value) { return String.valueOf(field(value, "debugLabel")); }
}
@@ -0,0 +1,44 @@
package fr.koka.media;
import fr.koka.media.mixin.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import net.minecraft.client.Minecraft;
/** All AL work runs on Minecraft's sound executor, borrowing its context without owning it. */
public final class AudioService {
public static final ConcurrentHashMap<String, OpenAlLine> LINES = new ConcurrentHashMap<>();
public static final java.util.Set<OpenAlLine> ALL = ConcurrentHashMap.newKeySet();
private static final AtomicBoolean pending=new AtomicBoolean();
private static ScheduledExecutorService timer;
public static final java.util.concurrent.atomic.AtomicLong GENERATION=new java.util.concurrent.atomic.AtomicLong();
public static void contextRecreated(){
GENERATION.incrementAndGet();
// The old sound executor can discard an already queued job during reload.
// Its finally block then never runs; allow a job on the replacement executor.
pending.set(false);
}
public static synchronized void start(){
if(timer!=null)return;
timer=Executors.newSingleThreadScheduledExecutor(r->{var t=new Thread(r,"Sanctuary media audio pump");t.setDaemon(true);return t;});
timer.scheduleAtFixedRate(AudioService::schedule,0,5,TimeUnit.MILLISECONDS);
}
private static void schedule(){
if(ALL.isEmpty()||!pending.compareAndSet(false,true))return;
try{
var mc=Minecraft.getInstance();
var engine=(SoundEngineAccess)((SoundManagerAccess)mc.getSoundManager()).media$engine();
if(!engine.media$loaded()){pending.set(false);return;}
engine.media$executor().schedule(()->{
try{
if(!engine.media$loaded())return;
long context=((LibraryAccess)engine.media$library()).media$context();
for(var line:ALL)line.pump(context,GENERATION.get());
}
catch(Throwable e){org.slf4j.LoggerFactory.getLogger("sanctuary-media").error("OpenAL media pump failed",e);}
finally{pending.set(false);}
});
}catch(Throwable e){pending.set(false);}
}
public static void close(){for(var line:ALL)line.close();}
}
@@ -0,0 +1,29 @@
package fr.koka.media;
import java.util.ArrayDeque;
/** Estimates server epoch from the lowest-RTT recent sample, then advances monotonically. */
public final class DisciplinedClock {
private record Sample(long received, long rtt, double offset) {}
private final ArrayDeque<Sample> samples = new ArrayDeque<>();
private double offsetMillis;
private boolean ready;
private long lastNanos;
public synchronized void reset() { samples.clear(); ready = false; lastNanos = 0; }
public synchronized void accept(long sent, long received, long serverMillis) {
long rtt = received - sent;
if (rtt < 0 || rtt > 5_000_000_000L) return;
samples.addLast(new Sample(received, rtt, serverMillis + rtt / 2e6 - received / 1e6));
while (samples.size() > 12) samples.removeFirst();
Sample best = samples.stream().min(java.util.Comparator.comparingLong(Sample::rtt)).orElseThrow();
if(!ready)lastNanos=0;
offsetMillis = !ready ? best.offset : offsetMillis + Math.clamp(best.offset - offsetMillis, -2, 2);
ready = true;
}
public synchronized long nowNanos(long mono) {
long value = (long)((ready ? mono / 1e6 + offsetMillis : System.currentTimeMillis()) * 1e6);
lastNanos = Math.max(lastNanos, value); return lastNanos;
}
public long nowNanos() { return nowNanos(System.nanoTime()); }
public synchronized boolean ready() { return ready; }
}
@@ -0,0 +1,65 @@
package fr.koka.media;
import java.net.URI;
import java.time.OffsetDateTime;
import java.util.*;
/** Bounded media-playlist metadata; no content-specific or synthetic-video timing. */
public record HlsPlaylist(long sequence, int targetSeconds, boolean ended, List<Segment> segments) {
public record Segment(String url, long epochMillis, long durationMillis, boolean discontinuity,
String key, String init) {}
public static HlsPlaylist parse(URI base, String body) {
if (body.length() > 524288 || !body.stripLeading().startsWith("#EXTM3U")) throw new IllegalArgumentException("Invalid HLS playlist");
long sequence = 0, epoch = -1, duration = 0; int target = 2;
boolean end = false, discontinuity = false;
String key = null, init = null;
var segments = new ArrayList<Segment>();
for (String raw : body.split("\\R")) {
String line = raw.trim();
if (line.startsWith("#EXT-X-MEDIA-SEQUENCE:")) sequence = Long.parseLong(line.substring(line.indexOf(':')+1));
else if (line.startsWith("#EXT-X-TARGETDURATION:")) target = Integer.parseInt(line.substring(line.indexOf(':')+1));
else if (line.startsWith("#EXT-X-PROGRAM-DATE-TIME:")) {
String date=line.substring(line.indexOf(':')+1).replaceFirst("([+-][0-9]{2})([0-9]{2})$", "$1:$2");
epoch = OffsetDateTime.parse(date).toInstant().toEpochMilli();
}
else if (line.startsWith("#EXTINF:")) duration = Math.round(Double.parseDouble(line.substring(8).split(",")[0])*1000);
else if (line.equals("#EXT-X-DISCONTINUITY")) { discontinuity = true; epoch = -1; }
else if (line.startsWith("#EXT-X-BYTERANGE") || line.startsWith("#EXT-X-PART")) throw new IllegalArgumentException("Unsupported HLS byte range / LL-HLS");
else if (line.startsWith("#EXT-X-KEY:")) key = absoluteAttribute(base, line);
else if (line.startsWith("#EXT-X-MAP:")) init = absoluteAttribute(base, line);
else if (line.equals("#EXT-X-ENDLIST")) end = true;
else if (!line.isEmpty() && !line.startsWith("#")) {
if (duration <= 0 || segments.size() >= 512) throw new IllegalArgumentException("Expected a bounded media playlist");
segments.add(new Segment(base.resolve(line).toString(), epoch, duration, discontinuity, key, init));
if (epoch >= 0) epoch += duration;
duration = 0; discontinuity = false;
}
}
// A date later in a continuous playlist also timestamps preceding segments.
for (int i = segments.size()-2; i >= 0; i--) {
var s = segments.get(i); var next = segments.get(i+1);
if (s.epochMillis < 0 && next.epochMillis >= 0 && !next.discontinuity)
segments.set(i, new Segment(s.url,next.epochMillis-s.durationMillis,s.durationMillis,s.discontinuity,s.key,s.init));
}
return new HlsPlaylist(sequence, Math.max(1,target),end,List.copyOf(segments));
}
private static String absoluteAttribute(URI base, String line) {
var m = java.util.regex.Pattern.compile("URI=\"([^\"]+)\"").matcher(line);
return m.find() ? line.substring(0,m.start(1))+base.resolve(m.group(1))+line.substring(m.end(1)) : line;
}
public String render(int first, int count) {
int last = Math.min(segments.size(),first+count);
var text = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:6\n#EXT-X-TARGETDURATION:"+targetSeconds+"\n#EXT-X-MEDIA-SEQUENCE:"+(sequence+first)+"\n");
String previousKey = null, previousInit = null;
for (int i=first;i<last;i++) {
var s = segments.get(i);
if (s.discontinuity) text.append("#EXT-X-DISCONTINUITY\n");
if (s.key != null && !s.key.equals(previousKey)) {text.append(s.key).append('\n');previousKey=s.key;}
if (s.init != null && !s.init.equals(previousInit)) {text.append(s.init).append('\n');previousInit=s.init;}
if (s.epochMillis >= 0) text.append("#EXT-X-PROGRAM-DATE-TIME:").append(java.time.Instant.ofEpochMilli(s.epochMillis)).append('\n');
text.append("#EXTINF:").append(s.durationMillis/1000.0).append(",\n").append(s.url).append('\n');
}
if (ended) text.append("#EXT-X-ENDLIST\n");
return text.toString();
}
}
@@ -0,0 +1,98 @@
package fr.koka.media;
import com.sun.net.httpserver.HttpServer;
import java.net.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;
/** Client-local manifest pinning. Media segments remain direct CDN requests, never Minecraft packets. */
public final class HlsProxy {
public static final class Origin {
public volatile long videoMillis = -1, audioMillis = -1;
}
private record Key(String label, String url, boolean video) {}
private static final Map<Key, Entry> ENTRIES = new ConcurrentHashMap<>();
public static final Map<String, Origin> ORIGINS = new ConcurrentHashMap<>();
private static HttpServer server;
private static ExecutorService executor;
private static final class Entry {
final String label, url, token, initial;
final boolean video;
volatile long firstRequest;
Entry(String label,String url,boolean video,HlsPlaylist playlist,int first) {
this.label=label;this.url=url;this.video=video;token=UUID.randomUUID().toString();
initial=playlist.render(first,3);
var origin=ORIGINS.computeIfAbsent(label,k->new Origin());
if(video)origin.videoMillis=playlist.segments().get(first).epochMillis();
else origin.audioMillis=playlist.segments().get(first).epochMillis();
}
}
private static String safe(String url) {
return (String)Access.call(Access.singleton("com.dreamdisplays.media.runtime.security.MediaHostGuard"),"resolveSafeUrl",url,5);
}
private static String read(String url) throws IOException {
var connection = URI.create(safe(url)).toURL().openConnection();
connection.setConnectTimeout(4000); connection.setReadTimeout(4000);
try(var input=connection.getInputStream()) {
byte[] bytes=input.readNBytes(524289);
if(bytes.length>524288)throw new IOException("HLS manifest exceeds limit");
return new String(bytes,StandardCharsets.UTF_8);
}
}
public static String resolve(Object guard,String url,String label,boolean video) {
String checked=(String)Access.call(guard,"resolveSafeUrl",url,5);
if(!checked.contains(".m3u8"))return checked;
Key key=new Key(label,checked,video);
try {
Entry entry=ENTRIES.get(key);
if(entry==null) synchronized(ENTRIES) {
entry=ENTRIES.get(key);
if(entry==null) {
var playlist=HlsPlaylist.parse(URI.create(checked),read(checked));
if(playlist.ended()||playlist.segments().size()<3)return checked;
int first=playlist.segments().size()-3;
if(playlist.segments().get(first).epochMillis()<0)return checked;
for(var segment:playlist.segments())safe(segment.url());
entry=new Entry(label,checked,video,playlist,first);
start();ENTRIES.put(key,entry);
}
}
String suffix=checked.contains(".ttvnw.net/")?".ttvnw.net":"";
return "http://127.0.0.1:"+server.getAddress().getPort()+"/"+entry.token+suffix+".m3u8";
}catch(Exception e) {
org.slf4j.LoggerFactory.getLogger("sanctuary-media").warn("Precise live synchronization unavailable: {}",e.getClass().getSimpleName());
return checked;
}
}
private static synchronized void start() throws IOException {
if(server!=null)return;
server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);
executor=Executors.newFixedThreadPool(2,r->{var t=new Thread(r,"Sanctuary HLS metadata");t.setDaemon(true);return t;});
server.setExecutor(executor);
server.createContext("/",exchange->{
try {
String path=exchange.getRequestURI().getPath();
Entry entry=ENTRIES.values().stream().filter(e->path.equals("/"+e.token+(e.url.contains(".ttvnw.net/")?".ttvnw.net":"")+".m3u8")).findFirst().orElse(null);
if(entry==null||!Set.of("GET","HEAD").contains(exchange.getRequestMethod())) {exchange.sendResponseHeaders(404,-1);return;}
String body;
if(entry.firstRequest==0)entry.firstRequest=System.nanoTime();
if(System.nanoTime()-entry.firstRequest<2_000_000_000L)body=entry.initial;
else {
var playlist=HlsPlaylist.parse(URI.create(entry.url),read(entry.url));
body=playlist.render(0,playlist.segments().size());
}
byte[] bytes=body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type","application/vnd.apple.mpegurl");
exchange.getResponseHeaders().set("Cache-Control","no-store");
exchange.sendResponseHeaders(200,exchange.getRequestMethod().equals("HEAD")?-1:bytes.length);
if(!exchange.getRequestMethod().equals("HEAD"))exchange.getResponseBody().write(bytes);
}catch(Exception e){try{exchange.sendResponseHeaders(502,-1);}catch(IOException ignored){}}
finally{exchange.close();}
});
server.start();
}
public static void forget(String label) {ENTRIES.keySet().removeIf(k->k.label.equals(label));ORIGINS.remove(label);}
public static synchronized void close() {ENTRIES.clear();ORIGINS.clear();if(server!=null){server.stop(0);server=null;}if(executor!=null){executor.shutdownNow();executor=null;}}
}
@@ -0,0 +1,49 @@
package fr.koka.media;
import java.util.*;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.*;
public final class MediaClient implements ClientModInitializer {
private long nextPing,serial;
private final Map<Long,Long> outstanding=new HashMap<>();
private int tick;
public void onInitializeClient(){
MediaSettings.load();
fr.koka.media.anchor.AnchorClient.register();
ClientPlayNetworking.registerGlobalReceiver(MediaMod.Reply.TYPE,(p,c)->{
Long sent=outstanding.remove(p.serial());
if(sent!=null&&sent==p.clientNanos())SyncControl.CLOCK.accept(sent,System.nanoTime(),p.serverMillis());
});
ClientPlayConnectionEvents.DISCONNECT.register((handler,mc)->{SyncControl.reset();AudioService.close();HlsProxy.close();outstanding.clear();});
ClientTickEvents.END_CLIENT_TICK.register(mc->{
if(mc.player==null)return;
long now=System.nanoTime();
if(now>=nextPing&&ClientPlayNetworking.canSend(MediaMod.Ping.TYPE)){
outstanding.entrySet().removeIf(e->now-e.getValue()>5_000_000_000L);
outstanding.put(++serial,now);ClientPlayNetworking.send(new MediaMod.Ping(serial,now));
nextPing=now+(SyncControl.CLOCK.ready()?1_000_000_000L:200_000_000L);
}
if(++tick%5!=0)return;
try{
Object registry=Access.singleton("com.dreamdisplays.platform.client.displays.DisplayRegistry");
Object raw=Access.call(registry,"getScreens");
var screens=raw instanceof Map<?,?> m?m.values():(Collection<?>)raw;
var alive=new HashSet<UUID>();
for(Object screen:screens){
UUID id=(UUID)Access.call(screen,"getUuid");alive.add(id);
SyncControl.LIVE.put(id,Boolean.TRUE.equals(Access.call(screen,"isLive")));
Object plane=Access.privateCall(screen,"sourcePlane");
float x=((Number)Access.call(plane,"getCenterX")).floatValue();
float y=((Number)Access.call(plane,"getCenterY")).floatValue();
float z=((Number)Access.call(plane,"getCenterZ")).floatValue();
SyncControl.POSITIONS.put(id,new float[]{x,y,z});
Access.call(screen,"setAcousticsEnabled",false); // No stacked Dream DSP + Sound Physics.
}
SyncControl.POSITIONS.keySet().retainAll(alive);
SyncControl.LIVE.keySet().retainAll(alive);
}catch(Exception e){org.slf4j.LoggerFactory.getLogger("sanctuary-media").warn("Cannot update media emitter",e);}
});
}
}
@@ -0,0 +1,40 @@
package fr.koka.media;
import java.util.concurrent.ConcurrentHashMap;
import java.util.UUID;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.networking.v1.*;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Server supplies time only; no decoder or client audio classes are loaded here. */
public final class MediaMod implements ModInitializer {
public record Ping(long serial, long clientNanos) implements CustomPacketPayload {
public static final Type<Ping> TYPE = new Type<>(Identifier.fromNamespaceAndPath("sanctuary_media", "clock_ping"));
public static final StreamCodec<FriendlyByteBuf, Ping> CODEC = CustomPacketPayload.codec(
(p,b) -> {b.writeLong(p.serial);b.writeLong(p.clientNanos);}, b -> new Ping(b.readLong(),b.readLong()));
public Type<Ping> type() { return TYPE; }
}
public record Reply(long serial, long clientNanos, long serverMillis) implements CustomPacketPayload {
public static final Type<Reply> TYPE = new Type<>(Identifier.fromNamespaceAndPath("sanctuary_media", "clock_reply"));
public static final StreamCodec<FriendlyByteBuf, Reply> CODEC = CustomPacketPayload.codec(
(p,b) -> {b.writeLong(p.serial);b.writeLong(p.clientNanos);b.writeLong(p.serverMillis);},
b -> new Reply(b.readLong(),b.readLong(),b.readLong()));
public Type<Reply> type() { return TYPE; }
}
@Override public void onInitialize() {
fr.koka.media.anchor.Anchors.register();
PayloadTypeRegistry.serverboundPlay().register(Ping.TYPE, Ping.CODEC);
PayloadTypeRegistry.clientboundPlay().register(Reply.TYPE, Reply.CODEC);
var last = new ConcurrentHashMap<UUID, Long>();
ServerPlayNetworking.registerGlobalReceiver(Ping.TYPE, (p,c) -> {
long now = System.nanoTime();
Long before = last.put(c.player().getUUID(), now);
if (before != null && now - before < 100_000_000L) return;
c.responseSender().sendPacket(new Reply(p.serial(), p.clientNanos(), System.currentTimeMillis()));
});
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> last.remove(handler.player.getUUID()));
}
}
@@ -0,0 +1,23 @@
package fr.koka.media;
import java.nio.file.*;
import com.google.gson.*;
import net.fabricmc.loader.api.FabricLoader;
public final class MediaSettings {
public static volatile boolean videoAcoustics=true;
public static boolean enabled(){return videoAcoustics;}
private static Path file(){return FabricLoader.getInstance().getConfigDir().resolve("sanctuary-media.json");}
public static void load(){
try{if(Files.exists(file()))videoAcoustics=JsonParser.parseString(Files.readString(file())).getAsJsonObject().get("videoAcoustics").getAsBoolean();}
catch(Exception e){org.slf4j.LoggerFactory.getLogger("sanctuary-media").warn("Cannot read video sound preference; preserving default",e);}
}
public static void set(boolean enabled){
videoAcoustics=enabled;
try{
Path temporary=file().resolveSibling("sanctuary-media.json.tmp");
Files.writeString(temporary,"{\"videoAcoustics\":"+enabled+"}\n");
Files.move(temporary,file(),StandardCopyOption.REPLACE_EXISTING,StandardCopyOption.ATOMIC_MOVE);
}catch(java.io.IOException e){throw new IllegalStateException("Cannot save media sound preference",e);}
}
}
@@ -0,0 +1,212 @@
package fr.koka.media;
import java.util.*;
import javax.sound.sampled.*;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.Identifier;
import net.minecraft.sounds.SoundSource;
import org.lwjgl.openal.*;
import org.lwjgl.system.MemoryStack;
/** Stereo PCM input, mono positional AL output, with the actual source sample cursor as master clock. */
public final class OpenAlLine implements SourceDataLine {
private static final int RATE=44100;
private static final Identifier SOUND=Identifier.fromNamespaceAndPath("sanctuary_media","display");
private static final class Packet {
final byte[] mono;final int frames;int buffer;
Packet(byte[] mono,int frames){this.mono=mono;this.frames=frames;}
}
private final Object sink;
public final String label;
private final ArrayDeque<Packet> queue=new ArrayDeque<>();
private AudioFormat format;
private volatile boolean opened,requested,active,closing;
private long context,generation,completed,submitted;
private volatile long played;
private int source,capacity=RATE*4*350/1000;
private boolean aligned;
private boolean flushing;
private long lastEnvironment;
private boolean hadEffects;
private net.minecraft.world.phys.Vec3 propagatedPosition;
public volatile double playbackRate=1;
public volatile long errorNanos;
public volatile long effectsCalls;
public volatile long resets;
public volatile int alError;
private boolean syncEnabled(){return SyncControl.TIMELINES.containsKey(SyncControl.id(label));}
public OpenAlLine(Object sink){this.sink=sink;this.label=Access.label(sink);}
private long contentStart(){
Object current=Access.field(sink,"current");Object value=Access.field(current,"contentStartNanos");
return value instanceof Number n?n.longValue():0;
}
public long videoBias(){
var origin=HlsProxy.ORIGINS.get(label);
return origin!=null&&origin.audioMillis>=0&&origin.videoMillis>=0?(origin.audioMillis-origin.videoMillis)*1_000_000L:0;
}
private long desiredFrames(){
if(!syncEnabled()||!SyncControl.CLOCK.ready())return Long.MIN_VALUE;
var live=HlsProxy.ORIGINS.get(label);
long target,base;
if(live!=null){
if(live.audioMillis<0)return Long.MIN_VALUE;
target=SyncControl.CLOCK.nowNanos()-SyncControl.LIVE_DELAY_NS;base=live.audioMillis*1_000_000L;
}else{
if(Boolean.TRUE.equals(SyncControl.LIVE.get(SyncControl.id(label))))return Long.MIN_VALUE;
target=SyncControl.target(label);base=contentStart();if(target<0)return Long.MIN_VALUE;
}
return Math.round((target-base)*RATE/1e9);
}
@Override public synchronized int write(byte[] data,int off,int length){
if(!opened||closing)return 0;
if(off<0||length<0||off+length>data.length||length%4!=0)throw new IllegalArgumentException("Unaligned PCM");
int consumed=0;
while(consumed<length&&!closing){
long desired=desiredFrames();
if(!aligned&&queue.isEmpty()&&desired>submitted){
int skip=(int)Math.min((length-consumed)/4,desired-submitted);
submitted+=skip;completed+=skip;played=completed;consumed+=skip*4;
if(consumed==length)break;
}
while((submitted-played)*4>=capacity&&!closing)try{wait(10);}catch(InterruptedException e){Thread.currentThread().interrupt();return consumed;}
if(closing)break;
int frames=Math.min((length-consumed)/4,RATE/20);
byte[] mono=new byte[frames*2];
for(int i=0;i<frames;i++){
int p=off+consumed+i*4;
int left=(short)((data[p]&255)|(data[p+1]<<8));
int right=(short)((data[p+2]&255)|(data[p+3]<<8));
short mixed=(short)((left+right)/2);mono[i*2]=(byte)mixed;mono[i*2+1]=(byte)(mixed>>8);
}
queue.addLast(new Packet(mono,frames));submitted+=frames;consumed+=frames*4;
}
return consumed;
}
public synchronized void pump(long newContext,long newGeneration){
if(context!=newContext||generation!=newGeneration){
if(context!=0){completed=submitted;played=completed;queue.clear();aligned=false;resets++;}
context=newContext;generation=newGeneration;source=0;hadEffects=false;propagatedPosition=null;
}
if(closing){destroy();AudioService.LINES.remove(label,this);AudioService.ALL.remove(this);return;}
if(!opened||context==0)return;
if(source==0){
source=AL10.alGenSources();
AL10.alSourcei(source,AL10.AL_SOURCE_RELATIVE,AL10.AL_FALSE);
AL10.alSourcef(source,AL10.AL_ROLLOFF_FACTOR,0); // Dream already applies viewer distance gain.
AL10.alSourcef(source,AL10.AL_REFERENCE_DISTANCE,1);
AL10.alSourcef(source,AL10.AL_MAX_DISTANCE,64);
}
if(flushing){
AL10.alSourceStop(source);AL10.alSourcei(source,AL10.AL_BUFFER,0);
for(var packet:queue)if(packet.buffer!=0)AL10.alDeleteBuffers(packet.buffer);
queue.clear();completed=submitted;played=completed;flushing=false;aligned=false;
}
int processed=AL10.alGetSourcei(source,AL10.AL_BUFFERS_PROCESSED);
while(processed-->0&&!queue.isEmpty()){
int buffer=AL10.alSourceUnqueueBuffers(source);
Packet head=queue.removeFirst();completed+=head.frames;AL10.alDeleteBuffers(buffer);
}
played=completed+AL10.alGetSourcei(source,AL11.AL_SAMPLE_OFFSET);
long desired=desiredFrames();
// A join/seek can finish decoding after the common start. Trim decoded PCM
// immediately rather than taking tens of seconds to recover at +2% pitch.
if(desired!=Long.MIN_VALUE&&desired-played>RATE/5){
AL10.alSourceStop(source);AL10.alSourcei(source,AL10.AL_BUFFER,0);
for(var packet:queue)if(packet.buffer!=0){AL10.alDeleteBuffers(packet.buffer);packet.buffer=0;}
while(!queue.isEmpty()&&completed+queue.getFirst().frames<=desired)
completed+=queue.removeFirst().frames;
if(!queue.isEmpty()&&desired>completed){
Packet head=queue.removeFirst();int skip=(int)(desired-completed);
queue.addFirst(new Packet(Arrays.copyOfRange(head.mono,skip*2,head.mono.length),head.frames-skip));
completed=desired;
}
played=completed;aligned=false;
}
for(Packet packet:queue)if(packet.buffer==0){
packet.buffer=AL10.alGenBuffers();
try(var stack=MemoryStack.stackPush()){
var bytes=stack.malloc(packet.mono.length);bytes.put(packet.mono).flip();
AL10.alBufferData(packet.buffer,AL10.AL_FORMAT_MONO16,bytes,RATE);
}
AL10.alSourceQueueBuffers(source,packet.buffer);
}
float[] pos=SyncControl.POSITIONS.getOrDefault(SyncControl.id(label),new float[]{0,0,0});
AL10.alSource3f(source,AL10.AL_POSITION,pos[0],pos[1],pos[2]);
AL10.alSourcef(source,AL10.AL_GAIN,Minecraft.getInstance().options.getFinalSoundSourceVolume(SoundSource.RECORDS));
boolean effects=MediaSettings.videoAcoustics&&FabricLoader.getInstance().isModLoaded("sound_physics_remastered")
&&com.sonicether.soundphysics.SoundPhysicsMod.CONFIG.enabled.get();
long now=System.nanoTime();
if(effects&&(now-lastEnvironment>250_000_000L||!hadEffects)){
// This stationary emitter owns its reevaluation cadence. SPR skips RECORDS
// when its moving-sound option is off; retain the Records volume slider,
// but evaluate the screen as a block sound so acoustics actually apply.
propagatedPosition=com.sonicether.soundphysics.SoundPhysics.processSound(source,pos[0],pos[1],pos[2],SoundSource.BLOCKS,SOUND);
effectsCalls++;lastEnvironment=now;
}else if(!effects&&hadEffects){
AL10.alSourcei(source,EXTEfx.AL_DIRECT_FILTER,EXTEfx.AL_FILTER_NULL);
for(int send=0;send<4;send++)AL11.alSource3i(source,EXTEfx.AL_AUXILIARY_SEND_FILTER,EXTEfx.AL_EFFECTSLOT_NULL,send,EXTEfx.AL_FILTER_NULL);
AL10.alSourcef(source,EXTEfx.AL_AIR_ABSORPTION_FACTOR,0);
propagatedPosition=null;
}
if(effects&&propagatedPosition!=null)AL10.alSource3f(source,AL10.AL_POSITION,(float)propagatedPosition.x,(float)propagatedPosition.y,(float)propagatedPosition.z);
hadEffects=effects;
errorNanos=desired==Long.MIN_VALUE?0:Math.round((desired-played)*1e9/RATE);
playbackRate=desired==Long.MIN_VALUE?1:SyncControl.rate(errorNanos);
AL10.alSourcef(source,AL10.AL_PITCH,(float)playbackRate);
// A future-dated media origin waits for the same server time on every client.
boolean due=desired==Long.MIN_VALUE||(aligned&&desired>=played-RATE/5)||desired>=played;
if(requested&&due&&!queue.isEmpty()){
if(AL10.alGetSourcei(source,AL10.AL_SOURCE_STATE)!=AL10.AL_PLAYING)AL10.alSourcePlay(source);
aligned=true;active=true;
}else{if(AL10.alGetSourcei(source,AL10.AL_SOURCE_STATE)==AL10.AL_PLAYING)AL10.alSourcePause(source);active=false;}
alError=AL10.alGetError();
notifyAll();
}
private void destroy(){
if(source!=0){
AL10.alSourceStop(source);AL10.alSourcei(source,AL10.AL_BUFFER,0);
for(var packet:queue)if(packet.buffer!=0)AL10.alDeleteBuffers(packet.buffer);
AL10.alDeleteSources(source);source=0;
}
queue.clear();active=false;notifyAll();
}
@Override public synchronized void open(AudioFormat format,int bufferSize)throws LineUnavailableException{
if(format.getSampleRate()!=RATE||format.getChannels()!=2||format.getSampleSizeInBits()!=16||format.isBigEndian())
throw new LineUnavailableException("Expected 44.1 kHz S16LE stereo PCM");
if(AudioService.ALL.size()>=8)throw new LineUnavailableException("Media line budget exceeded");
this.format=format;opened=true;AudioService.LINES.put(label,this);AudioService.ALL.add(this);AudioService.start();
}
@Override public void open(AudioFormat f)throws LineUnavailableException{open(f,capacity);}
@Override public void open()throws LineUnavailableException{throw new LineUnavailableException("PCM format required");}
@Override public synchronized void start(){requested=true;}
@Override public synchronized void stop(){requested=false;}
@Override public synchronized void close(){closing=true;opened=false;requested=false;notifyAll();}
@Override public synchronized void flush(){
// Teardown owns this line; dropping queued PCM is reflected in its logical media cursor.
if(!closing)flushing=true;
}
@Override public synchronized void drain(){while(opened&&submitted>played)try{wait(10);}catch(InterruptedException e){Thread.currentThread().interrupt();return;}}
@Override public boolean isRunning(){return active;}
@Override public boolean isActive(){return active;}
@Override public boolean isOpen(){return opened;}
@Override public int getBufferSize(){return capacity;}
@Override public synchronized int available(){return (int)Math.max(0,capacity-Math.max(0,submitted-played)*4);}
@Override public AudioFormat getFormat(){return format;}
@Override public int getFramePosition(){return (int)played;}
@Override public long getLongFramePosition(){return played;}
@Override public long getMicrosecondPosition(){return played*1_000_000L/RATE;}
@Override public float getLevel(){return AudioSystem.NOT_SPECIFIED;}
@Override public Line.Info getLineInfo(){return new DataLine.Info(SourceDataLine.class,format);}
@Override public Control[] getControls(){return new Control[0];}
@Override public boolean isControlSupported(Control.Type type){return false;}
@Override public Control getControl(Control.Type type){throw new IllegalArgumentException("PCM volume is managed by the display");}
@Override public void addLineListener(LineListener l){}
@Override public void removeLineListener(LineListener l){}
public static Map<String,Object> status(String label){
var line=AudioService.LINES.get(label);
if(line==null)return Map.of();
return Map.of("backend","openal","playing",line.active,"rate",line.playbackRate,"error_ns",line.errorNanos,
"effects_calls",line.effectsCalls,"video_acoustics",MediaSettings.videoAcoustics,"al_error",line.alError,"resets",line.resets);
}
}
@@ -0,0 +1,32 @@
package fr.koka.media;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public final class SyncControl {
public static final DisciplinedClock CLOCK = new DisciplinedClock();
public static final long LIVE_DELAY_NS = 8_000_000_000L;
public record Timeline(long positionMs, long serverMs, boolean paused, boolean loop, long durationMs) {
public long at(long epochNanos) {
long time = positionMs + (paused ? 0 : epochNanos / 1_000_000 - serverMs);
return (loop && durationMs > 0 ? Math.floorMod(time, durationMs) : Math.max(0, time)) * 1_000_000;
}
}
public static final Map<UUID, Timeline> TIMELINES = new ConcurrentHashMap<>();
public static final Map<UUID, float[]> POSITIONS = new ConcurrentHashMap<>();
public static final Map<UUID, Boolean> LIVE = new ConcurrentHashMap<>();
public static UUID id(String label) { return UUID.fromString(label.substring(0, label.indexOf('/'))); }
public static long target(String label) {
var t = TIMELINES.get(id(label)); return t == null ? -1 : t.at(CLOCK.nowNanos());
}
public static double rate(long errorNanos) {
if (Math.abs(errorNanos) < 8_000_000L) return 1;
return Math.clamp(1 + errorNanos / 2_000_000_000.0, 0.98, 1.02);
}
public static void timeline(Object screen, long position, long serverMs, boolean paused, boolean loop) {
UUID id = (UUID) Access.call(screen, "getUuid");
long duration = ((Number) Access.call(screen, "getMediaPlayerDurationNanos")).longValue() / 1_000_000;
TIMELINES.put(id, new Timeline(position, serverMs, paused, loop, duration));
}
public static void reset() { CLOCK.reset(); TIMELINES.clear(); POSITIONS.clear(); LIVE.clear(); }
}
@@ -0,0 +1,41 @@
package fr.koka.media.anchor;
import net.minecraft.core.*;
import net.minecraft.world.*;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.level.block.state.*;
import net.minecraft.world.level.block.state.properties.*;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.*;
public final class AnchorBlock extends BaseEntityBlock {
public static final EnumProperty<Direction> FACING=BlockStateProperties.HORIZONTAL_FACING;
public AnchorBlock(Properties p){super(p);registerDefaultState(stateDefinition.any().setValue(FACING,Direction.SOUTH));}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block,BlockState> b){b.add(FACING);}
@Override public BlockEntity newBlockEntity(BlockPos p,BlockState s){return new AnchorBlockEntity(p,s);}
@Override protected RenderShape getRenderShape(BlockState s){return RenderShape.INVISIBLE;}
@Override protected VoxelShape getShape(BlockState s,BlockGetter l,BlockPos p,CollisionContext c){
return switch(s.getValue(FACING)){
case SOUTH->Block.box(0,0,0,16,16,1);
case NORTH->Block.box(0,0,15,16,16,16);
case EAST->Block.box(0,0,0,1,16,16);
case WEST->Block.box(15,0,0,16,16,16);
default->Shapes.empty();
};
}
@Override protected VoxelShape getCollisionShape(BlockState s,BlockGetter l,BlockPos p,CollisionContext c){return Shapes.empty();}
@Override protected VoxelShape getOcclusionShape(BlockState s){return Shapes.empty();}
@Override protected InteractionResult useWithoutItem(BlockState s,Level l,BlockPos pos,Player p,BlockHitResult hit){
if(p instanceof net.minecraft.server.level.ServerPlayer sp&&l.getBlockEntity(pos) instanceof AnchorBlockEntity a)
Anchors.open(sp,a,false);
return InteractionResult.SUCCESS;
}
@Override protected InteractionResult useItemOn(ItemStack stack,BlockState s,Level l,BlockPos pos,Player p,InteractionHand hand,BlockHitResult hit){return useWithoutItem(s,l,pos,p,hit);}
@Override public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level l,BlockState s,BlockEntityType<T> t){
return createTickerHelper(t,Anchors.TYPE,(world,pos,state,a)->a.tick());
}
}
@@ -0,0 +1,50 @@
package fr.koka.media.anchor;
import java.util.UUID;
import net.minecraft.core.*;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.*;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.*;
/** V1 is new data, used only in new experimental worlds. Dream owns its media/session data. */
public final class AnchorBlockEntity extends BlockEntity {
public UUID display,owner;
public int width=1,height=1,revision;
private boolean indexed;
private int ticks;
public AnchorBlockEntity(BlockPos p,BlockState s){super(Anchors.TYPE,p,s);}
public AnchorGeometry geometry(){return new AnchorGeometry(worldPosition,getBlockState().getValue(AnchorBlock.FACING),width,height);}
public void tick(){
if(level==null)return;
if(!indexed){Anchors.index(this);indexed=true;}
if(level instanceof ServerLevel sl&&++ticks%40==0&&owner!=null){
String support=Anchors.validate(sl,null,geometry(),this);
if(support.equals("wall")||support.equals("obstacle"))sl.destroyBlock(worldPosition,true);
else if(support.isEmpty())Anchors.ensureDisplay(this);
}
}
public void sync(){setChanged();if(level!=null)level.sendBlockUpdated(worldPosition,getBlockState(),getBlockState(),Block.UPDATE_CLIENTS);}
@Override public void setRemoved(){Anchors.unindex(this);indexed=false;super.setRemoved();}
@Override public void preRemoveSideEffects(BlockPos pos,BlockState s){
if(level instanceof ServerLevel)Anchors.deleteDisplay(this);
super.preRemoveSideEffects(pos,s);
}
@Override protected void saveAdditional(ValueOutput out){
super.saveAdditional(out);out.putInt("AnchorVersion",1);out.putInt("Width",width);out.putInt("Height",height);out.putInt("Revision",revision);
if(display!=null)out.putString("Display",display.toString());if(owner!=null)out.putString("Owner",owner.toString());
}
@Override protected void loadAdditional(ValueInput in){
super.loadAdditional(in);
width=Math.clamp(in.getIntOr("Width",1),1,AnchorGeometry.MAX_WIDTH);
height=Math.clamp(in.getIntOr("Height",1),1,AnchorGeometry.MAX_HEIGHT);revision=in.getIntOr("Revision",0);
display=uuid(in.getStringOr("Display",""));owner=uuid(in.getStringOr("Owner",""));
}
private static UUID uuid(String s){try{return UUID.fromString(s);}catch(IllegalArgumentException e){return null;}}
@Override public Packet<ClientGamePacketListener> getUpdatePacket(){return ClientboundBlockEntityDataPacket.create(this);}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider lookup){return saveCustomOnly(lookup);}
}
@@ -0,0 +1,41 @@
package fr.koka.media.anchor;
import fr.koka.media.Access;
import java.util.*;
import net.fabricmc.fabric.api.client.networking.v1.*;
import net.minecraft.client.Minecraft;
import net.minecraft.world.InteractionHand;
public final class AnchorClient {
public static void register(){
Anchors.CLIENT_INFO=level->{
if(Minecraft.getInstance().level!=level)return List.of();
var result=new ArrayList<AnchorInfo>();
Object raw=Access.call(Access.singleton("com.dreamdisplays.platform.client.displays.DisplayRegistry"),"getScreens");
for(Object s:raw instanceof Map<?,?> m?m.values():(Collection<?>)raw){
var data=(Map<?,?>)Access.call(s,"getCustomData");var i=AnchorInfo.decode((UUID)Access.call(s,"getUuid"),(byte[])data.get(Anchors.MARKER));if(i!=null)result.add(i);
}
return result;
};
ClientPlayNetworking.registerGlobalReceiver(AnchorPackets.View.TYPE,(v,c)->{
if(c.client().player!=null){if(v.settings())c.client().gui.setScreen(new AnchorScreen(v));else openReader(v.display());}
});
ClientPlayConnectionEvents.DISCONNECT.register((h,mc)->Anchors.clearClient());
}
public static boolean use(){
var mc=Minecraft.getInstance();
if(mc.player==null||mc.level==null||mc.gui.screen()!=null||mc.player.isSpectator()
||!ClientPlayNetworking.canSend(AnchorPackets.Action.TYPE))return false;
var hit=Anchors.target(mc.level,mc.player);
if(hit==null)return false;
var a=hit.info();
ClientPlayNetworking.send(new AnchorPackets.Action(a.geometry().anchor(),a.display(),0,0,0,a.revision(),"",0));
return true;
}
public static void openReader(UUID id){
Object raw=Access.call(Access.singleton("com.dreamdisplays.platform.client.displays.DisplayRegistry"),"getScreens");
for(Object s:raw instanceof Map<?,?> m?m.values():(Collection<?>)raw)if(id.equals(Access.call(s,"getUuid"))){
com.dreamdisplays.platform.client.ui.DisplayMenu.Companion.open((com.dreamdisplays.platform.client.displays.DisplayScreen)s);return;
}
}
}
@@ -0,0 +1,60 @@
package fr.koka.media.anchor;
import java.util.*;
import net.minecraft.world.phys.Vec3;
/** A closed wooden ring, in block units, flush to the wall and exactly inside the screen bounds. */
public final class AnchorFrameMesh {
public static final double THICKNESS=1.0/16;
public record Vertex(double u,double v,double depth){}
public record Face(Vertex a,Vertex b,Vertex c,Vertex d,int color){
public List<Vertex> vertices(){return List.of(a,b,c,d);}
}
public final int width,height;
public final List<Face> faces;
public AnchorFrameMesh(int width,int height){
if(width<1||height<1)throw new IllegalArgumentException("Invalid frame size");
this.width=width;this.height=height;
var result=new ArrayList<Face>();double t=THICKNESS,w=width,h=height;
for(double z:new double[]{t,0}){
boolean back=z==0;
rect(result,0,0,w,t,z,0x77512f,back);
rect(result,0,h-t,w,h,z,0xa47a48,back);
rect(result,0,t,t,h-t,z,0x90653a,back);
rect(result,w-t,t,w,h-t,z,0x694426,back);
}
// Outer sides, with outward winding.
side(result,0,0,w,0,0x684628);
side(result,w,0,w,h,0x795330);
side(result,w,h,0,h,0x9a7141);
side(result,0,h,0,0,0x876038);
// Inner reveal faces point into the opening around the recessed video.
side(result,w-t,t,t,t,0x51351e);
side(result,w-t,h-t,w-t,t,0x51351e);
side(result,t,h-t,w-t,h-t,0x684628);
side(result,t,t,t,h-t,0x684628);
faces=List.copyOf(result);
}
private static void rect(List<Face> out,double x0,double y0,double x1,double y1,double z,int color,boolean back){
var a=new Vertex(x0,y0,z);var b=new Vertex(x1,y0,z);var c=new Vertex(x1,y1,z);var d=new Vertex(x0,y1,z);
out.add(back?new Face(d,c,b,a,color):new Face(a,b,c,d,color));
}
private static void side(List<Face> out,double x0,double y0,double x1,double y1,int color){
out.add(new Face(new Vertex(x0,y0,0),new Vertex(x1,y1,0),new Vertex(x1,y1,THICKNESS),new Vertex(x0,y0,THICKNESS),color));
}
public static Vec3 world(AnchorGeometry g,Vertex v){
var n=g.facing();var right=g.right();
return Vec3.atCenterOf(g.wall()).add(n.getStepX()*(.5+v.depth),v.v-.5,n.getStepZ()*(.5+v.depth))
.add(right.getStepX()*(v.u-.5),0,right.getStepZ()*(v.u-.5));
}
/** Behind the raised rim, an observer outside the opening cannot see any part of the panel. */
public static boolean videoVisible(AnchorGeometry g,Vec3 eye,double videoClearance){
var n=g.facing();var wall=Vec3.atCenterOf(g.wall());
double outward=(eye.x-wall.x)*n.getStepX()+(eye.z-wall.z)*n.getStepZ()-.5;
if(outward<videoClearance)return false;
if(outward>=THICKNESS)return true;
var right=g.right();double u=(eye.x-wall.x)*right.getStepX()+(eye.z-wall.z)*right.getStepZ()+.5;
double v=eye.y-wall.y+.5;
return u>=THICKNESS&&u<=g.width()-THICKNESS&&v>=THICKNESS&&v<=g.height()-THICKNESS;
}
}
@@ -0,0 +1,41 @@
package fr.koka.media.anchor;
import net.minecraft.core.*;
import net.minecraft.world.phys.Vec3;
/** Bottom-left as seen from the front. All coordinates refer to the backing wall. */
public record AnchorGeometry(BlockPos anchor, Direction facing, int width, int height) {
// Coordinate safety only; the server's configurable budget is checked separately.
public static final int MAX_WIDTH=60_000_000, MAX_HEIGHT=4096;
public AnchorGeometry {
if(facing.getAxis().isVertical()||width<1||height<1||width>MAX_WIDTH||height>MAX_HEIGHT)
throw new IllegalArgumentException("Invalid display rectangle");
anchor=anchor.immutable();
}
public Direction right(){return facing.getCounterClockWise();}
public BlockPos wall(){return anchor.relative(facing.getOpposite());}
public BlockPos cell(int x,int y){return wall().relative(right(),x).above(y);}
public BlockPos opposite(){return cell(width-1,height-1);}
public Vec3 center(){
return Vec3.atCenterOf(wall()).add(right().getStepX()*(width-1)/2.0,(height-1)/2.0,right().getStepZ()*(width-1)/2.0)
.add(facing.getStepX()*.51,0,facing.getStepZ()*.51);
}
/** Front-facing eye ray. A negative result means no hit. */
public double hit(Vec3 eye,Vec3 direction,double reach){
Vec3 normal=new Vec3(facing.getStepX(),0,facing.getStepZ());
double denominator=direction.dot(normal);
if(denominator>=-1e-7)return -1;
double t=center().subtract(eye).dot(normal)/denominator;
if(t<0||t>reach)return -1;
Vec3 local=eye.add(direction.scale(t)).subtract(center());
double horizontal=local.x*right().getStepX()+local.z*right().getStepZ();
return Math.abs(horizontal)<=width/2.0&&Math.abs(local.y)<=height/2.0?t:-1;
}
public boolean overlaps(AnchorGeometry other){
if(facing!=other.facing)return false;
Vec3 delta=other.center().subtract(center());
if(Math.abs(delta.x*facing.getStepX()+delta.z*facing.getStepZ())>.02)return false;
double horizontal=Math.abs(delta.x*right().getStepX()+delta.z*right().getStepZ());
return horizontal<(width+other.width)/2.0-.001&&Math.abs(delta.y)<(height+other.height)/2.0-.001;
}
}
@@ -0,0 +1,19 @@
package fr.koka.media.anchor;
import java.nio.ByteBuffer;
import java.util.UUID;
import net.minecraft.core.*;
/** Replicated by Dream, independent of the anchor chunk's client tracking. */
public record AnchorInfo(UUID display,AnchorGeometry geometry,int revision) {
public static AnchorInfo decode(UUID id,byte[] bytes){
if(bytes==null||bytes.length!=28)return null;
try{
var b=ByteBuffer.wrap(bytes);if(b.getInt()!=2)return null;
var pos=BlockPos.of(b.getLong());int w=b.getInt(),h=b.getInt(),rev=b.getInt(),face=b.getInt();
if(face<0||face>=Direction.values().length)return null;
return new AnchorInfo(id,new AnchorGeometry(pos,Direction.values()[face],w,h),rev);
}catch(IllegalArgumentException e){return null;}
}
public static AnchorInfo of(AnchorBlockEntity a){return new AnchorInfo(a.display,a.geometry(),a.revision);}
}
@@ -0,0 +1,20 @@
package fr.koka.media.anchor;
import com.google.gson.*;
import java.nio.file.*;
import net.fabricmc.loader.api.FabricLoader;
/** Server budgets; zero removes that configured ceiling, subject to world/support bounds. */
public final class AnchorLimits {
private static int width=256,height=256;
private static long area=16_384;
public static void load(){
Path file=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-media-anchors.json");
try{
if(!Files.exists(file))Files.writeString(file,"{\"maxWidth\":256,\"maxHeight\":256,\"maxArea\":16384}\n");
var v=JsonParser.parseString(Files.readString(file)).getAsJsonObject();
width=Math.max(0,v.get("maxWidth").getAsInt());height=Math.max(0,v.get("maxHeight").getAsInt());area=Math.max(0,v.get("maxArea").getAsLong());
}catch(Exception e){org.slf4j.LoggerFactory.getLogger("sanctuary-media").error("Cannot read anchor budgets; using defaults",e);width=height=256;area=16384;}
}
public static boolean allows(int w,int h){return w>0&&h>0&&(width==0||w<=width)&&(height==0||h<=height)&&(area==0||(long)w*h<=area);}
}
@@ -0,0 +1,38 @@
package fr.koka.media.anchor;
import java.util.UUID;
import net.fabricmc.fabric.api.networking.v1.*;
import net.minecraft.core.BlockPos;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.server.level.ServerPlayer;
public final class AnchorPackets {
public record Action(BlockPos pos,UUID display,int action,int width,int height,int revision,String url,long positionMs) implements CustomPacketPayload {
public static final Type<Action> TYPE=new Type<>(Anchors.id("anchor_action"));
public static final StreamCodec<FriendlyByteBuf,Action> CODEC=CustomPacketPayload.codec((p,b)->{
b.writeBlockPos(p.pos);b.writeUUID(p.display);b.writeVarInt(p.action);b.writeVarInt(p.width);b.writeVarInt(p.height);b.writeVarInt(p.revision);b.writeUtf(p.url,2048);b.writeLong(p.positionMs);
},b->new Action(b.readBlockPos(),b.readUUID(),b.readVarInt(),b.readVarInt(),b.readVarInt(),b.readVarInt(),b.readUtf(2048),b.readLong()));
public Type<Action> type(){return TYPE;}
}
public record View(BlockPos pos,UUID display,int width,int height,int revision,boolean settings,boolean editable,String url,String message) implements CustomPacketPayload {
public static final Type<View> TYPE=new Type<>(Anchors.id("anchor_view"));
public static final StreamCodec<FriendlyByteBuf,View> CODEC=CustomPacketPayload.codec((p,b)->{
b.writeBlockPos(p.pos);b.writeUUID(p.display);b.writeVarInt(p.width);b.writeVarInt(p.height);b.writeVarInt(p.revision);b.writeBoolean(p.settings);b.writeBoolean(p.editable);b.writeUtf(p.url,2048);b.writeUtf(p.message,64);
},b->new View(b.readBlockPos(),b.readUUID(),b.readVarInt(),b.readVarInt(),b.readVarInt(),b.readBoolean(),b.readBoolean(),b.readUtf(2048),b.readUtf(64)));
public Type<View> type(){return TYPE;}
}
public static void register(){
PayloadTypeRegistry.serverboundPlay().register(Action.TYPE,Action.CODEC);
PayloadTypeRegistry.clientboundPlay().register(View.TYPE,View.CODEC);
ServerPlayNetworking.registerGlobalReceiver(Action.TYPE,(m,c)->Anchors.action(c.player(),m));
}
public static void view(ServerPlayer p,AnchorBlockEntity a,boolean settings,String error){
view(p,AnchorInfo.of(a),settings,error,Anchors.near(p,a)&&Anchors.canBuild(p,a.getBlockPos()));
}
public static void view(ServerPlayer p,AnchorInfo a,boolean settings,String error,boolean editable){
var d=com.dreamdisplays.api.server.DreamDisplaysServer.display(a.display());var g=a.geometry();
ServerPlayNetworking.send(p,new View(g.anchor(),a.display(),g.width(),g.height(),a.revision(),settings,editable,d==null?"":d.getUrl(),error));
}
}
@@ -0,0 +1,39 @@
package fr.koka.media.anchor;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.Screen;
/** Only the anchor geometry. Video, streams and playback keep Dream's original menu. */
public final class AnchorScreen extends Screen {
private final AnchorPackets.View view;
private EditBox widthBox,heightBox;
private int left,top,panelWidth;
public AnchorScreen(AnchorPackets.View v){super(Anchors.label("size_title"));view=v;}
public AnchorPackets.View snapshot(){return view;}
@Override public boolean isPauseScreen(){return false;}
private Button button(String key,int x,int y,int w,Runnable action){return addRenderableWidget(Button.builder(Anchors.label(key),b->action.run()).bounds(x,y,w,20).build());}
private EditBox input(String key,int x,int y,int w,int value){var box=addRenderableWidget(new EditBox(font,x,y,w,20,Anchors.label(key)));box.setMaxLength(8);box.setValue(""+value);return box;}
@Override protected void init(){
panelWidth=Math.min(310,width-16);left=(width-panelWidth)/2;top=Math.max(4,(height-218)/2);int inner=panelWidth-20;
widthBox=input("width",left+10,top+45,90,view.width());heightBox=input("height",left+112,top+45,90,view.height());
int x=left+10;
for(int[] size:new int[][]{{4,3},{8,4},{16,9}}){
addRenderableWidget(Button.builder(net.minecraft.network.chat.Component.literal(size[0]+" × "+size[1]),b->{widthBox.setValue(""+size[0]);heightBox.setValue(""+size[1]);}).bounds(x,top+73,Math.max(50,(inner-8)/3),20).build());x+=(inner+4)/3;
}
button("apply",left+10,top+156,(inner-6)/2,()->ClientPlayNetworking.send(new AnchorPackets.Action(view.pos(),view.display(),1,number(widthBox),number(heightBox),view.revision(),"",0)));
button("player",left+16+(inner-6)/2,top+156,(inner-6)/2,()->AnchorClient.openReader(view.display()));
button("close",left+10,top+190,inner,this::onClose);
}
private static int number(EditBox box){try{return Integer.parseInt(box.getValue());}catch(NumberFormatException e){return 0;}}
@Override public void extractRenderState(GuiGraphicsExtractor g,int mx,int my,float delta){
g.fill(left-4,top-4,left+panelWidth+4,top+215,0xd91b2027);super.extractRenderState(g,mx,my,delta);
g.centeredText(font,title,width/2,top+3,0xffffffff);g.centeredText(font,Anchors.label("one_frame"),width/2,top+18,0xffc3d4e4);
g.text(font,Anchors.label("width"),left+10,top+33,0xffffffff);g.text(font,Anchors.label("height"),left+112,top+33,0xffffffff);
int w=number(widthBox),h=number(heightBox);boolean valid=w>0&&h>0;
if(valid){double scale=Math.min((panelWidth-60.0)/w,42.0/h);int sw=Math.max(1,(int)(w*scale)),sh=Math.max(1,(int)(h*scale));int x=left+(panelWidth-sw)/2,y=top+99+(42-sh)/2;g.fill(x,y,x+sw,y+sh,0xff223a4b);g.outline(x,y,sw,sh,0xff87c8df);}
g.centeredText(font,Anchors.label("free_dimensions"),width/2,top+144,valid?0xffa5b6c7:0xffff7b7b);
if(!view.message().isEmpty())g.centeredText(font,Anchors.label(view.message()),width/2,top+180,0xffff8c8c);
}
}
@@ -0,0 +1,201 @@
package fr.koka.media.anchor;
import fr.koka.media.Access;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.function.Supplier;
import com.dreamdisplays.api.*;
import com.dreamdisplays.api.server.*;
import com.dreamdisplays.api.display.model.property.DisplayFacing;
import com.dreamdisplays.api.playback.model.*;
import com.dreamdisplays.platform.server.utils.net.VanillaDisplayActions;
import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.minecraft.core.*;
import net.minecraft.core.registries.*;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.*;
import net.minecraft.server.level.*;
import net.minecraft.sounds.*;
import net.minecraft.world.*;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.*;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.material.PushReaction;
import net.minecraft.world.phys.*;
public final class Anchors {
public static final String MARKER="sanctuary_media:anchor_v2";
public static final String LEGACY_MARKER="sanctuary_media:anchor_v1";
public static java.util.function.Function<Level,List<AnchorInfo>> CLIENT_INFO=l->List.of();
public static Identifier id(String path){return Identifier.fromNamespaceAndPath("sanctuary_media",path);}
public static Component label(String key,Object...args){return Component.translatable("sanctuary_media.anchor."+key,args);}
public static final AnchorBlock BLOCK=Registry.register(BuiltInRegistries.BLOCK,id("redstone_frame"),new AnchorBlock(
BlockBehaviour.Properties.ofFullCopy(Blocks.OAK_PLANKS).strength(.5f).noOcclusion().pushReaction(PushReaction.IMMOVEABLE)
.setId(ResourceKey.create(Registries.BLOCK,id("redstone_frame")))));
public static final Item ITEM=Registry.register(BuiltInRegistries.ITEM,id("redstone_frame"),new BlockItem(BLOCK,
new Item.Properties().setId(ResourceKey.create(Registries.ITEM,id("redstone_frame"))).useBlockDescriptionPrefix()){
@Override public InteractionResult useOn(UseOnContext c){return Anchors.place(c);}
});
public static final BlockEntityType<AnchorBlockEntity> TYPE=Registry.register(BuiltInRegistries.BLOCK_ENTITY_TYPE,id("redstone_frame"),
FabricBlockEntityTypeBuilder.create(AnchorBlockEntity::new,BLOCK).build());
private static final Map<Level,Map<BlockPos,AnchorBlockEntity>> INDEX=new java.util.concurrent.ConcurrentHashMap<>();
private static final Map<UUID,Long> LAST=new HashMap<>();
public static final ThreadLocal<Boolean> AUTHORIZED=ThreadLocal.withInitial(()->false);
public static <T>T authorized(Supplier<T> action){boolean before=AUTHORIZED.get();AUTHORIZED.set(true);try{return action.get();}finally{AUTHORIZED.set(before);}}
public static void register(){
AnchorLimits.load();
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.FUNCTIONAL_BLOCKS).register(out->out.accept(ITEM));
AnchorPackets.register();
ServerLifecycleEvents.SERVER_STOPPED.register(s->{INDEX.keySet().removeIf(l->l instanceof ServerLevel sl&&sl.getServer()==s);LAST.clear();});
net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents.DISCONNECT.register((h,s)->LAST.remove(h.player.getUUID()));
}
public static void index(AnchorBlockEntity a){INDEX.computeIfAbsent(a.getLevel(),l->new java.util.concurrent.ConcurrentHashMap<>()).put(a.getBlockPos(),a);}
public static void unindex(AnchorBlockEntity a){var m=INDEX.get(a.getLevel());if(m!=null){m.remove(a.getBlockPos(),a);if(m.isEmpty())INDEX.remove(a.getLevel());}}
public static void clearClient(){INDEX.keySet().removeIf(Level::isClientSide);}
public static List<AnchorBlockEntity> loaded(Level level){var m=INDEX.get(level);return m==null?List.of():List.copyOf(m.values());}
public static boolean managed(UUID id){
for(var entry:INDEX.entrySet())if(entry.getKey() instanceof ServerLevel)
for(var a:entry.getValue().values())if(id.equals(a.display))return true;
return DreamDisplaysServer.isAvailable()&&DreamDisplaysServer.display(id)!=null
&&(DreamDisplaysServer.display(id).getCustomData().get(MARKER)!=null||DreamDisplaysServer.display(id).getCustomData().get(LEGACY_MARKER)!=null);
}
private static InteractionResult place(UseOnContext c){
if(c.getClickedFace().getAxis().isVertical())return InteractionResult.FAIL;
if(c.getLevel().isClientSide())return InteractionResult.SUCCESS;
if(!(c.getPlayer() instanceof ServerPlayer p)||!p.mayBuild()||p.isSpectator()||!p.isAlive())return InteractionResult.FAIL;
var level=p.level();var pos=c.getClickedPos().relative(c.getClickedFace());
if(p.getEyePosition().distanceTo(Vec3.atCenterOf(c.getClickedPos()))>p.blockInteractionRange()+1)return InteractionResult.FAIL;
if(!level.getBlockState(pos).isAir())return fail(p,"obstacle");
var geometry=new AnchorGeometry(pos,c.getClickedFace(),1,1);
String error=validate(level,p,geometry,null);if(!error.isEmpty())return fail(p,error);
if(!DreamDisplaysServer.isAvailable())return fail(p,"unavailable");
if(!level.setBlock(pos,BLOCK.defaultBlockState().setValue(AnchorBlock.FACING,c.getClickedFace()),Block.UPDATE_ALL))return fail(p,"unavailable");
var a=(AnchorBlockEntity)level.getBlockEntity(pos);a.owner=p.getUUID();index(a);
if(!ensureDisplay(a)){level.removeBlock(pos,false);return fail(p,"unavailable");}
if(!p.getAbilities().instabuild)c.getItemInHand().shrink(1);
level.playSound(null,pos,SoundEvents.WOOD_PLACE,SoundSource.BLOCKS,1,1);
a.sync();open(p,a,true);return InteractionResult.SUCCESS_SERVER;
}
private static InteractionResult fail(ServerPlayer p,String error){p.sendSystemMessage(label(error),true);return InteractionResult.FAIL;}
public static boolean canBuild(ServerPlayer p,BlockPos pos){return p.mayBuild()&&!p.isSpectator()&&p.isAlive()
&&fr.koka.sanctuary.property.PropertyService.mayModifyBlock(p.level(),p,pos)
&&p.level().mayInteract(p,pos);}
/** Never requests chunks. A missing chunk is unavailable, never a destroyed support. */
public static String validate(ServerLevel level,ServerPlayer p,AnchorGeometry g,AnchorBlockEntity self){
if(p!=null&&!AnchorLimits.allows(g.width(),g.height()))return "size";
for(int y=0;y<g.height();y++)for(int x=0;x<g.width();x++){
BlockPos wall=g.cell(x,y),front=wall.relative(g.facing());
if(!level.isInWorldBounds(wall)||!level.isInWorldBounds(front)||!level.getWorldBorder().isWithinBounds(wall)||!level.getWorldBorder().isWithinBounds(front))return "bounds";
if(!level.isLoaded(wall)||!level.isLoaded(front))return "unloaded";
if(p!=null&&(!canBuild(p,wall)||!canBuild(p,front)))return "protected";
if(!level.getBlockState(wall).isFaceSturdy(level,wall,g.facing()))return "wall";
if(!(front.equals(g.anchor())&&self!=null)&&!level.getBlockState(front).isAir())return "obstacle";
}
for(var other:infos(level))if((self==null||!other.display().equals(self.display))&&other.geometry().overlaps(g))return "overlap";
return "";
}
public static boolean ensureDisplay(AnchorBlockEntity a){
if(!(a.getLevel() instanceof ServerLevel sl)||a.owner==null||!DreamDisplaysServer.isAvailable())return false;
var existing=a.display==null?null:DreamDisplaysServer.display(a.display);
if(existing!=null)return existing.getOwnerId().equals(a.owner)&&metadata(a);
var g=a.geometry();BlockPos first=g.wall(),last=g.opposite();
var d=DreamDisplaysServer.getDisplays().create(a.owner,sl.dimension().identifier().toString(),first.getX(),first.getY(),first.getZ(),last.getX(),last.getY(),last.getZ(),DisplayFacing.valueOf(g.facing().name()));
if(d==null)return false;
Access.call(Access.field(d,"data"),"setMode",PlaybackMode.SYNCED);
d.setAccess(DisplayAccess.EVERYONE);a.display=d.getId();
if(!metadata(a)){authorized(()->d.delete());a.display=null;return false;}
a.sync();return true;
}
private static boolean metadata(AnchorBlockEntity a){
var d=DreamDisplaysServer.display(a.display);if(d==null)return false;
byte[] data=ByteBuffer.allocate(28).putInt(2).putLong(a.getBlockPos().asLong()).putInt(a.width).putInt(a.height).putInt(a.revision).putInt(a.geometry().facing().ordinal()).array();
boolean saved=d.getCustomData().set(MARKER,data)==CommandResult.OK;
if(saved&&d.getCustomData().get(LEGACY_MARKER)!=null)d.getCustomData().remove(LEGACY_MARKER);
return saved;
}
public static void deleteDisplay(AnchorBlockEntity a){
if(a.display!=null&&DreamDisplaysServer.isAvailable())authorized(()->{var d=DreamDisplaysServer.display(a.display);if(d!=null)d.delete();return true;});
a.display=null;
}
public record Hit(AnchorInfo info,double distance){}
public static List<AnchorInfo> infos(Level level){
Map<UUID,AnchorInfo> all=new LinkedHashMap<>();
for(var a:loaded(level))if(a.display!=null)all.put(a.display,AnchorInfo.of(a));
if(level instanceof ServerLevel&&DreamDisplaysServer.isAvailable()){
for(var d:DreamDisplaysServer.getDisplays())if(d.getWorld().equals(level.dimension().identifier().toString())){
var i=AnchorInfo.decode(d.getId(),d.getCustomData().get(MARKER));if(i!=null)all.putIfAbsent(i.display(),i);
}
}else for(var i:CLIENT_INFO.apply(level))all.putIfAbsent(i.display(),i);
return List.copyOf(all.values());
}
public static Hit target(Level level,Player p){
Vec3 eye=p.getEyePosition(),ray=p.getLookAngle();Hit best=null;
for(var a:infos(level)){
double t=a.geometry().hit(eye,ray,16);if(t<0||best!=null&&t>=best.distance)continue;
Vec3 end=eye.add(ray.scale(Math.max(0,t-.025)));
var obstruction=level.clip(new ClipContext(eye,end,ClipContext.Block.COLLIDER,ClipContext.Fluid.NONE,p));
if(obstruction.getType()!=HitResult.Type.MISS)continue;
best=new Hit(a,t);
}
return best;
}
public static boolean near(ServerPlayer p,AnchorBlockEntity a){return p.getEyePosition().distanceTo(Vec3.atCenterOf(a.getBlockPos()))<=p.blockInteractionRange()+.5;}
public static boolean mayControl(ServerPlayer p,UUID id){if(!p.isAlive()||p.isSpectator())return false;Hit h=target(p.level(),p);return h!=null&&h.info().display().equals(id);}
public static void open(ServerPlayer p,AnchorBlockEntity a,boolean settings){
if(a.display==null||!ensureDisplay(a))return;
AnchorPackets.view(p,a,settings&&near(p,a)&&canBuild(p,a.getBlockPos()),"");
}
public static void action(ServerPlayer p,AnchorPackets.Action m){
long now=System.nanoTime();Long previous=LAST.get(p.getUUID());
if(previous!=null&&now-previous<200_000_000L)return;LAST.put(p.getUUID(),now);
if(!p.isAlive()||p.isSpectator())return;
Hit hit=target(p.level(),p);if(hit==null||!hit.info.display().equals(m.display())||!hit.info.geometry().anchor().equals(m.pos()))return;
if(!p.level().isLoaded(m.pos())){
if(m.action()==0)AnchorPackets.view(p,hit.info(),false,"",false);
return;
}
if(!(p.level().getBlockEntity(m.pos()) instanceof AnchorBlockEntity a)||!Objects.equals(a.display,m.display()))return;
if(m.action()==0){open(p,a,false);return;}
boolean edit=near(p,a)&&canBuild(p,a.getBlockPos());String error="";
if(m.action()==6){AnchorPackets.view(p,a,edit,edit?"":"protected");return;}
if(m.action()==1){
if(!edit)error="protected";
else if(m.revision()!=a.revision)error="changed";
else try{
var g=new AnchorGeometry(a.getBlockPos(),a.geometry().facing(),m.width(),m.height());
error=validate(p.level(),p,g,a);
if(error.isEmpty()){
BlockPos b=g.wall(),e=g.opposite();var d=DreamDisplaysServer.display(a.display);
CommandResult result=authorized(()->d.moveTo(b.getX(),b.getY(),b.getZ(),e.getX(),e.getY(),e.getZ(),DisplayFacing.valueOf(g.facing().name())));
if(result!=CommandResult.OK)error="unavailable";
else {
int oldWidth=a.width,oldHeight=a.height,oldRevision=a.revision;
a.width=g.width();a.height=g.height();a.revision++;
if(metadata(a))a.sync();
else{
a.width=oldWidth;a.height=oldHeight;a.revision=oldRevision;
var old=a.geometry();var first=old.wall();var last=old.opposite();
authorized(()->d.moveTo(first.getX(),first.getY(),first.getZ(),last.getX(),last.getY(),last.getZ(),DisplayFacing.valueOf(old.facing().name())));
error="unavailable";
}
}
}
}catch(IllegalArgumentException invalid){error="size";}
AnchorPackets.view(p,a,true,error);return;
}
var actions=VanillaDisplayActions.INSTANCE;
if(m.action()==2){
if(m.url().isBlank()){AnchorPackets.view(p,a,false,"url");return;}
authorized(()->{actions.setVideo(p,p.level().getServer(),a.display,m.url(),"");return true;});
}else if(m.action()==3||m.action()==4||m.action()==5){
authorized(()->{actions.playbackCommand(p,a.display,m.action()==3?PlaybackAction.PLAY:m.action()==4?PlaybackAction.PAUSE:PlaybackAction.SEEK,Math.clamp(m.positionMs(),0,86_400_000L));return true;});
}else return;
AnchorPackets.view(p,a,false,"");
}
}
@@ -0,0 +1,8 @@
package fr.koka.media.anchor;
public final class TextureBudget {
public static int[] fit(int w,int h){
double scale=Math.min(1,Math.min(4096.0/Math.max(2,w),4096.0/Math.max(2,h)));
scale=Math.min(scale,Math.sqrt(8_388_608.0/Math.max(4,(long)w*h)));
return new int[]{Math.max(2,((int)(w*scale))&~1),Math.max(2,((int)(h*scale))&~1)};
}
}
@@ -0,0 +1,28 @@
package fr.koka.media.mixin;
import fr.koka.media.anchor.Anchors;
import java.util.UUID;
import com.dreamdisplays.api.playback.model.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Native Dream packets cannot bypass anchor reach, wall/claim or session validation. */
@Mixin(targets="com.dreamdisplays.platform.server.utils.net.VanillaDisplayActions",remap=false)
abstract class AnchorActionGuardMixin {
@Unique private static boolean denied(UUID id){return !Anchors.AUTHORIZED.get()&&Anchors.managed(id);}
@Inject(method="setVideo",at=@At("HEAD"),cancellable=true,remap=false)
private void media$url(ServerPlayer p,MinecraftServer s,UUID id,String url,String lang,CallbackInfo ci){if(denied(id)&&!Anchors.mayControl(p,id))ci.cancel();}
@Inject(method="delete",at=@At("HEAD"),cancellable=true,remap=false)
private void media$delete(ServerPlayer p,MinecraftServer s,UUID id,CallbackInfo ci){if(denied(id))ci.cancel();}
@Inject(method="playbackCommand",at=@At("HEAD"),cancellable=true,remap=false)
private void media$command(ServerPlayer p,UUID id,PlaybackAction a,long time,CallbackInfo ci){if(denied(id)&&!Anchors.mayControl(p,id))ci.cancel();}
@Inject(method="setMode",at=@At("HEAD"),cancellable=true,remap=false)
private void media$mode(ServerPlayer p,MinecraftServer s,UUID id,PlaybackMode mode,long time,CallbackInfo ci){if(denied(id))ci.cancel();}
@Inject(method="setAccess",at=@At("HEAD"),cancellable=true,remap=false)
private void media$access(ServerPlayer p,MinecraftServer s,UUID id,DisplayAccess access,CallbackInfo ci){if(denied(id))ci.cancel();}
@Inject(method="watchPartyStart",at=@At("HEAD"),cancellable=true,remap=false)
private void media$watch(ServerPlayer p,UUID id,String url,String lang,CallbackInfo ci){if(denied(id))ci.cancel();}
@Inject(method="watchPartyControl",at=@At("HEAD"),cancellable=true,remap=false)
private void media$watchControl(ServerPlayer p,UUID id,WatchPartyAction action,long time,CallbackInfo ci){if(denied(id))ci.cancel();}
}
@@ -0,0 +1,56 @@
package fr.koka.media.mixin;
import fr.koka.media.anchor.*;
import java.util.*;
import com.dreamdisplays.platform.client.displays.DisplayScreen;
import com.dreamdisplays.platform.client.render.*;
import com.dreamdisplays.libs.kotlin.Unit;
import com.dreamdisplays.libs.kotlin.jvm.functions.Function2;
import com.mojang.blaze3d.vertex.*;
import net.minecraft.client.Camera;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.world.phys.Vec3;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Block-space ring using Dream's Vulkan draw path; its video transform deliberately flattens Z. */
@Mixin(targets="com.dreamdisplays.platform.client.render.ScreenRenderer",remap=false)
abstract class AnchorBorderMixin {
@Unique private Vec3 media$eye=Vec3.ZERO;
@Unique private final Map<DisplayScreen,AnchorFrameMesh> media$meshes=new WeakHashMap<>();
@Inject(method="render(Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/Camera;ZLcom/dreamdisplays/libs/kotlin/jvm/functions/Function2;)V",at=@At("HEAD"),remap=false)
private void media$camera(PoseStack stack,Camera camera,boolean replay,@Coerce Object renderer,CallbackInfo ci){media$eye=camera.position();}
@Redirect(method="render(Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/Camera;ZLcom/dreamdisplays/libs/kotlin/jvm/functions/Function2;)V",
at=@At(value="INVOKE",target="Lcom/dreamdisplays/platform/client/displays/DisplayScreen;getHasTexture()Z"),remap=false)
private boolean media$blankScreen(DisplayScreen s){return s.getHasTexture()||s.getCustomData().containsKey(Anchors.MARKER);}
@Inject(method="renderScreenTexture",at=@At("HEAD"),cancellable=true,remap=false)
private void media$back(DisplayScreen screen,PoseStack stack,boolean replay,@Coerce Object renderer,CallbackInfo ci){
var info=AnchorInfo.decode(screen.getUuid(),screen.getCustomData().get(Anchors.MARKER));
if(info!=null&&!AnchorFrameMesh.videoVisible(info.geometry(),media$eye,DisplayPoseStack.INSTANCE.surfaceClearance()+(replay?.01:0))){
if(!replay)screen.fitTextureWhileHidden();
media$draw(screen,stack,renderer,info.geometry());ci.cancel();
}
}
@Inject(method="renderScreenTexture",at=@At("TAIL"),remap=false)
private void media$front(DisplayScreen screen,PoseStack stack,boolean replay,@Coerce Object renderer,CallbackInfo ci){
var info=AnchorInfo.decode(screen.getUuid(),screen.getCustomData().get(Anchors.MARKER));
if(info!=null)media$draw(screen,stack,renderer,info.geometry());
}
@Unique @SuppressWarnings("unchecked")
private void media$draw(DisplayScreen screen,PoseStack stack,Object renderer,AnchorGeometry geometry){
var mesh=media$meshes.get(screen);
if(mesh==null||mesh.width!=geometry.width()||mesh.height!=geometry.height()){
mesh=new AnchorFrameMesh(geometry.width(),geometry.height());media$meshes.put(screen,mesh);
}
var frame=mesh;var origin=Vec3.atLowerCornerOf(screen.getPos());
var draw=(Function2<RenderType,Function2<PoseStack.Pose,VertexConsumer,Unit>,Unit>)renderer;
draw.invoke(DisplayYuvRenderTypes.INSTANCE.solidColorType(),(pose,v)->{
for(var face:frame.faces)for(var vertex:face.vertices()){
var p=AnchorFrameMesh.world(geometry,vertex).subtract(origin);int c=face.color();
v.addVertex(pose,(float)p.x,(float)p.y,(float)p.z).setUv(0,0).setColor((c>>16)&255,(c>>8)&255,c&255,255);
}
return Unit.INSTANCE;
});
}
}
@@ -0,0 +1,15 @@
package fr.koka.media.mixin;
import fr.koka.media.anchor.Anchors;
import com.dreamdisplays.api.CommandResult;
import com.dreamdisplays.api.server.ServerDisplay;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** The block and its validated transactions own an anchored display's lifetime and layout. */
@Mixin(targets="com.dreamdisplays.platform.server.api.ServerDisplayBase",remap=false)
abstract class AnchorDisplayGuardMixin {
@Inject(method={"delete","moveTo","setAccess","setUrl"},at=@At("HEAD"),cancellable=true,remap=false)
private void media$guard(CallbackInfoReturnable<CommandResult> cir){
if(!Anchors.AUTHORIZED.get()&&Anchors.managed(((ServerDisplay)(Object)this).getId()))cir.setReturnValue(CommandResult.REFUSED);
}
}
@@ -0,0 +1,44 @@
package fr.koka.media.mixin;
import fr.koka.media.Access;
import fr.koka.media.anchor.*;
import com.dreamdisplays.platform.client.displays.DisplayScreen;
import com.dreamdisplays.libs.kotlin.jvm.functions.Function0;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraft.world.phys.Vec3;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Keep Dream's own menu and its scaling/layout; add only the anchor-size entry. */
@Mixin(targets="com.dreamdisplays.platform.client.ui.DisplayMenu",remap=false)
abstract class AnchorMenuMixin extends Screen {
@Shadow @Final private DisplayScreen displayScreen;
@Unique private Button media$dimensions;
protected AnchorMenuMixin(Component title){super(title);}
@Inject(method="init",at=@At("RETURN"),remap=false)
private void media$init(CallbackInfo ci){
if(!displayScreen.getCustomData().containsKey(Anchors.MARKER))return;
media$dimensions=addRenderableWidget(Button.builder(Anchors.label("dimensions"),b->{
var info=AnchorInfo.decode(displayScreen.getUuid(),displayScreen.getCustomData().get(Anchors.MARKER));
if(info!=null)ClientPlayNetworking.send(new AnchorPackets.Action(info.geometry().anchor(),info.display(),6,0,0,info.revision(),"",0));
}).bounds(width-114,5,104,20).build());
media$dimensions.setTooltip(Tooltip.create(Anchors.label("resize_help")));
Object audio=Access.field(this,"audio3d");if(audio!=null)Access.call(audio,"setEnabledWhen",(Function0<Boolean>)()->false);
for(var child:children())if(child.getClass().getName().endsWith(".IconButton")){
Object icon=Access.field(child,"icon");Object id=Access.call(icon,"invoke");
if(String.valueOf(id).contains("delete"))Access.call(child,"setEnabledWhen",(Function0<Boolean>)()->false);
}
}
@Inject(method="drawScreen",at=@At("HEAD"),remap=false)
private void media$layout(@Coerce Object graphics,int mx,int my,float delta,CallbackInfo ci){
if(media$dimensions==null)return;
media$dimensions.setX(width-114);media$dimensions.setY(5);
var info=AnchorInfo.decode(displayScreen.getUuid(),displayScreen.getCustomData().get(Anchors.MARKER));
media$dimensions.active=info!=null&&minecraft.player!=null&&minecraft.player.mayBuild()
&&minecraft.player.getEyePosition().distanceTo(Vec3.atCenterOf(info.geometry().anchor()))<=minecraft.player.blockInteractionRange()+.5;
}
}
@@ -0,0 +1,13 @@
package fr.koka.media.mixin;
import fr.koka.media.Access;
import fr.koka.media.anchor.Anchors;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(targets="com.dreamdisplays.platform.client.displays.DisplayScreen",remap=false)
abstract class AnchorModeMixin {
@Inject(method={"getCanSetModeHere","getCanToggleLockHere"},at=@At("HEAD"),cancellable=true,remap=false)
private void media$shared(CallbackInfoReturnable<Boolean> cir){
if(((java.util.Map<?,?>)Access.call(this,"getCustomData")).containsKey(Anchors.MARKER))cir.setReturnValue(false);
}
}
@@ -0,0 +1,22 @@
package fr.koka.media.mixin;
import fr.koka.media.anchor.Anchors;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.*;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.client.renderer.state.level.BlockOutlineRenderState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** The invisible 1x1 anchor must not leave its vanilla selection rectangle inside the large screen. */
@Mixin(LevelRenderer.class)
abstract class AnchorOutlineMixin {
@Inject(method="submitHitOutline",at=@At("HEAD"),cancellable=true)
private void media$outline(PoseStack pose,SubmitNodeCollector collector,RenderType type,BlockOutlineRenderState state,
int color,float width,boolean translucent,CallbackInfo ci){
var level=Minecraft.getInstance().level;
if(level!=null&&level.getBlockState(state.pos()).is(Anchors.BLOCK))ci.cancel();
}
}
@@ -0,0 +1,27 @@
package fr.koka.media.mixin;
import fr.koka.media.Access;
import fr.koka.media.anchor.Anchors;
import com.dreamdisplays.platform.client.displays.DisplayScreen;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** A world-only resize otherwise stretches the old texture. Refit at the shared media time. */
@Mixin(targets="com.dreamdisplays.platform.client.displays.DisplayScreen",remap=false)
abstract class AnchorResizePlaybackMixin {
@Unique private int media$oldWidth,media$oldHeight;
@Unique private String media$oldUrl;
@Inject(method="updateData(Lcom/dreamdisplays/core/protocol/common/packets/DisplayInfo;)V",at=@At("HEAD"),remap=false)
private void media$before(@Coerce Object packet,CallbackInfo ci){
var s=(DisplayScreen)(Object)this;media$oldWidth=s.getWidth();media$oldHeight=s.getHeight();media$oldUrl=s.getVideoUrl();
}
@Inject(method="updateData(Lcom/dreamdisplays/core/protocol/common/packets/DisplayInfo;)V",at=@At("RETURN"),remap=false)
private void media$refit(@Coerce Object packet,CallbackInfo ci){
var s=(DisplayScreen)(Object)this;
if(s.getCustomData().containsKey(Anchors.MARKER)&&(media$oldWidth!=s.getWidth()||media$oldHeight!=s.getHeight())
&&media$oldUrl!=null&&!media$oldUrl.isBlank()&&media$oldUrl.equals(s.getVideoUrl())){
s.reloadTexture();s.loadVideo(s.getVideoUrl(),s.getLang());Access.privateCall(s,"sendRequestSyncPacket");
}
}
}
@@ -0,0 +1,14 @@
package fr.koka.media.mixin;
import fr.koka.media.anchor.TextureBudget;
import com.dreamdisplays.libs.kotlin.Pair;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(targets="com.dreamdisplays.platform.client.render.DisplayTextureResource",remap=false)
abstract class AnchorTextureBudgetMixin {
@Inject(method="textureDimensions",at=@At("RETURN"),cancellable=true,remap=false)
private void media$budget(int bw,int bh,int quality,CallbackInfoReturnable<Pair<Integer,Integer>> cir){
var p=cir.getReturnValue();var fit=TextureBudget.fit(p.getFirst(),p.getSecond());
if(fit[0]!=p.getFirst()||fit[1]!=p.getSecond())cir.setReturnValue(new Pair<>(fit[0],fit[1]));
}
}
@@ -0,0 +1,11 @@
package fr.koka.media.mixin;
import fr.koka.media.anchor.AnchorClient;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(value=Minecraft.class,priority=1100)
abstract class AnchorUseMixin {
@Inject(method="startUseItem",at=@At("HEAD"),cancellable=true)
private void media$use(CallbackInfo ci){if(AnchorClient.use())ci.cancel();}
}
@@ -0,0 +1,15 @@
package fr.koka.media.mixin;
import fr.koka.media.OpenAlLine;
import javax.sound.sampled.*;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(targets="com.dreamdisplays.media.player.pipeline.AudioSink",remap=false)
abstract class AudioSinkMixin {
@Inject(method="openLine",at=@At("HEAD"),cancellable=true,remap=false)
private void media$line(DataLine.Info info,AudioFormat format,CallbackInfoReturnable<SourceDataLine> cir)throws LineUnavailableException{
var line=new OpenAlLine(this);line.open(format);cir.setReturnValue(line);
}
@Redirect(method={"run","runSwitch","runBridge"},at=@At(value="INVOKE",target="Ljavax/sound/sampled/AudioSystem;isLineSupported(Ljavax/sound/sampled/Line$Info;)Z"),remap=false)
private boolean media$supported(Line.Info info){return info instanceof DataLine.Info;}
}
@@ -0,0 +1,11 @@
package fr.koka.media.mixin;
import fr.koka.media.*;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
@Mixin(targets="com.dreamdisplays.media.player.managers.PlaybackSessionManager",remap=false)
abstract class AudioUrlMixin {
@Redirect(method="buildAudioProcess",at=@At(value="INVOKE",target="Lcom/dreamdisplays/media/runtime/security/MediaHostGuard;resolveSafeUrl$default(Lcom/dreamdisplays/media/runtime/security/MediaHostGuard;Ljava/lang/String;IILjava/lang/Object;)Ljava/lang/String;"),remap=false)
private String media$manifest(@Coerce Object guard,String url,int max,int mask,@Coerce Object marker){
return HlsProxy.resolve(guard,url,Access.label(this),false);
}
}
@@ -0,0 +1,23 @@
package fr.koka.media.mixin;
import fr.koka.media.*;
import java.nio.ByteBuffer;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Keep the new surface blank while its future-dated live origin is waiting to start. */
@Mixin(targets="com.dreamdisplays.media.player.pipeline.FrameSurface",remap=false)
abstract class FrameGateMixin {
@Inject(method="takeDueFrame",at=@At("HEAD"),cancellable=true,remap=false)
private void media$waitForCommonStart(CallbackInfoReturnable<ByteBuffer> cir){
String label=Access.label(this);
var origin=HlsProxy.ORIGINS.get(label);
if(origin!=null&&origin.videoMillis>=0&&(!SyncControl.CLOCK.ready()
||SyncControl.CLOCK.nowNanos()-SyncControl.LIVE_DELAY_NS<origin.videoMillis*1_000_000L)){
cir.setReturnValue(null);return;
}
var line=AudioService.LINES.get(label);
if(line!=null&&!line.isRunning())cir.setReturnValue(null);
}
}
@@ -0,0 +1,6 @@
package fr.koka.media.mixin;
import com.mojang.blaze3d.audio.Library;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(Library.class)
public interface LibraryAccess {@Accessor("context") long media$context();}
@@ -0,0 +1,17 @@
package fr.koka.media.mixin;
import fr.koka.media.*;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(targets="com.dreamdisplays.media.player.pipeline.AudioMasterClock",remap=false)
abstract class MasterClockMixin {
@Inject(method="nanos",at=@At("HEAD"),cancellable=true,remap=false)
private void media$clock(@Coerce Object sample,long wall,boolean suspended,@Coerce Object exact,CallbackInfoReturnable<Long> cir){
String label=Access.label(this);var line=AudioService.LINES.get(label);
Object value=Access.field(sample,"nanos");
if(line!=null&&value instanceof Number n&&n.longValue()>=0){
boolean origin=Boolean.TRUE.equals(Access.field(sample,"originKnown"));
cir.setReturnValue(Math.max(0,n.longValue()+(origin?0:line.videoBias())));
}
}
}
@@ -0,0 +1,20 @@
package fr.koka.media.mixin;
import fr.koka.media.*;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(targets="com.dreamdisplays.media.player.MediaPlayer",remap=false)
abstract class PlayerClockMixin {
@Inject(method="getCurrentTime",at=@At("RETURN"),cancellable=true,remap=false)
private void media$actualClock(CallbackInfoReturnable<Long> cir){
Object manager=Access.field(this,"sessionManager");Object audio=Access.field(manager,"audio");
Object session=Access.field(audio,"current");Object line=Access.field(session,"line");
if(line instanceof OpenAlLine){
Object sample=Access.call(audio,"sampleClock");
if(((Number)Access.field(sample,"nanos")).longValue()>=0)
cir.setReturnValue(((Number)Access.call(manager,"currentPacingNanos")).longValue());
}
}
@Inject(method="stop",at=@At("RETURN"),remap=false)
private void media$forget( org.spongepowered.asm.mixin.injection.callback.CallbackInfo ci){HlsProxy.forget(Access.label(this));}
}
@@ -0,0 +1,24 @@
package fr.koka.media.mixin;
import fr.koka.media.*;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Seek the decoder itself on a late join; advancing PCM alone leaves old video to decode. */
@Mixin(targets="com.dreamdisplays.media.player.managers.PlaybackSessionManager",remap=false)
abstract class SessionStartMixin {
/** A cold live restart must pin a fresh manifest and reset its PCM epoch together. */
@Inject(method="start",at=@At("HEAD"),remap=false)
private void media$freshLive(@Coerce Object streams,long offset,int quality,
@Coerce Object acceleration,boolean live,@Coerce Object firstFrame,CallbackInfo ci){
if(live)HlsProxy.forget(Access.label(this));
}
@ModifyVariable(method="start",at=@At("HEAD"),argsOnly=true,ordinal=0,remap=false)
private long media$startAtTimeline(long offset,@Coerce Object streams,long original,int quality,
@Coerce Object acceleration,boolean live,@Coerce Object firstFrame){
if(live||!SyncControl.CLOCK.ready())return offset;
long target=SyncControl.target(Access.label(this));
return target<0?offset:target;
}
}
@@ -0,0 +1,11 @@
package fr.koka.media.mixin;
import net.minecraft.client.sounds.*;
import com.mojang.blaze3d.audio.Library;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(SoundEngine.class)
public interface SoundEngineAccess {
@Accessor("executor") SoundEngineExecutor media$executor();
@Accessor("library") Library media$library();
@Accessor("loaded") boolean media$loaded();
}
@@ -0,0 +1,15 @@
package fr.koka.media.mixin;
import fr.koka.media.AudioService;
import net.minecraft.client.sounds.SoundEngine;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(SoundEngine.class)
abstract class SoundLifecycleMixin {
// A recreated OpenAL context may reuse the same native pointer. Its lifetime
// needs a generation, otherwise old source/buffer IDs appear valid forever.
@Inject(method="loadLibrary",at=@At("RETURN"))
private void media$newContext(CallbackInfo ci){AudioService.contextRecreated();}
}
@@ -0,0 +1,6 @@
package fr.koka.media.mixin;
import net.minecraft.client.sounds.*;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(SoundManager.class)
public interface SoundManagerAccess {@Accessor("soundEngine") SoundEngine media$engine();}
@@ -0,0 +1,26 @@
package fr.koka.media.mixin;
import fr.koka.media.*;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.*;
@Mixin(targets="com.dreamdisplays.platform.client.displays.TimelineFollower",remap=false)
abstract class TimelineMixin {
@Shadow private long learnedLeadMs;
@Inject(method="<init>",at=@At("RETURN"),remap=false)
private void media$noGuessedLead(CallbackInfo ci){learnedLeadMs=0;}
@Inject(method="learnFromLanding",at=@At("HEAD"),cancellable=true,remap=false)
private void media$noGuessedCorrection(long drift,CallbackInfo ci){learnedLeadMs=0;ci.cancel();}
@Inject(method="apply",at=@At("HEAD"),remap=false)
private void media$anchor(long position,long serverMs,boolean paused,boolean loop,CallbackInfo ci){
SyncControl.timeline(Access.field(this,"screen"),position,serverMs,paused,loop);
}
@Inject(method="projectTargetMs",at=@At("RETURN"),cancellable=true,remap=false)
private void media$project(@Coerce Object packet,CallbackInfoReturnable<Long> cir){
long stamp=((Number)Access.field(packet,"serverTimeMs")).longValue();
if(stamp>0&&SyncControl.CLOCK.ready()){
long position=((Number)Access.field(packet,"targetMs")).longValue();
boolean paused=Boolean.TRUE.equals(Access.field(packet,"paused"));
cir.setReturnValue(Math.max(0,position+(paused?0:SyncControl.CLOCK.nowNanos()/1_000_000-stamp)));
}
}
}
@@ -0,0 +1,21 @@
package fr.koka.media.mixin;
import fr.koka.media.MediaSettings;
import net.minecraft.client.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.options.OptionsSubScreen;
import net.minecraft.network.chat.Component;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Pseudo
@Mixin(targets="fr.koka.sanctuary.client.sound.SoundPhysicsOptionsScreen",remap=false)
abstract class VideoOptionsMixin extends OptionsSubScreen {
@Shadow @Final private String category;
protected VideoOptionsMixin(Screen parent,Options options,Component title){super(parent,options,title);}
@Inject(method="addBoolean",at=@At("TAIL"),remap=false)
private void media$option(@Coerce Object entry,CallbackInfo ci){
if("main".equals(category))list.addBig(OptionInstance.createBoolean("sanctuary_media.video_acoustics",
OptionInstance.cachedConstantTooltip(Component.translatable("sanctuary_media.video_acoustics.help")),
MediaSettings.videoAcoustics,MediaSettings::set));
}
}
@@ -0,0 +1,11 @@
package fr.koka.media.mixin;
import fr.koka.media.*;
import org.spongepowered.asm.mixin.*;
import org.spongepowered.asm.mixin.injection.*;
@Mixin(targets="com.dreamdisplays.media.player.managers.PlaybackSessionManager$VideoChannel",remap=false)
abstract class VideoUrlMixin {
@Redirect(method="launch",at=@At(value="INVOKE",target="Lcom/dreamdisplays/media/runtime/security/MediaHostGuard;resolveSafeUrl$default(Lcom/dreamdisplays/media/runtime/security/MediaHostGuard;Ljava/lang/String;IILjava/lang/Object;)Ljava/lang/String;"),remap=false)
private String media$manifest(@Coerce Object guard,String url,int max,int mask,@Coerce Object marker){
return HlsProxy.resolve(guard,url,Access.label(Access.field(this,"this$0")),true);
}
}
@@ -0,0 +1,20 @@
{
"variants": {
"facing=south": {
"model": "sanctuary_media:block/redstone_frame",
"y": 0
},
"facing=west": {
"model": "sanctuary_media:block/redstone_frame",
"y": 90
},
"facing=north": {
"model": "sanctuary_media:block/redstone_frame",
"y": 180
},
"facing=east": {
"model": "sanctuary_media:block/redstone_frame",
"y": 270
}
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "sanctuary_media:item/redstone_frame"
}
}
@@ -0,0 +1,25 @@
{
"sanctuary_media.video_acoustics": "Video acoustics",
"sanctuary_media.video_acoustics.help": "Apply Sound Physics to videos. When disabled, retain positional sound and volume without reverberation or wall absorption.",
"sanctuary_media.anchor.size_title": "Screen dimensions",
"sanctuary_media.anchor.one_frame": "One anchor · free resizing",
"sanctuary_media.anchor.width": "Width",
"sanctuary_media.anchor.height": "Height",
"sanctuary_media.anchor.apply": "Apply",
"sanctuary_media.anchor.player": "Player",
"sanctuary_media.anchor.close": "Close",
"sanctuary_media.anchor.url": "Video or live URL",
"sanctuary_media.anchor.dimensions": "Size",
"sanctuary_media.anchor.wall": "A complete backing wall is required.",
"sanctuary_media.anchor.obstacle": "The space in front of the wall must be clear.",
"sanctuary_media.anchor.protected": "Building rights and proximity are required.",
"sanctuary_media.anchor.bounds": "Outside the world bounds.",
"sanctuary_media.anchor.unloaded": "Part of the surface is not loaded.",
"sanctuary_media.anchor.overlap": "Another screen occupies this surface.",
"sanctuary_media.anchor.unavailable": "The player is temporarily unavailable.",
"sanctuary_media.anchor.changed": "The screen changed; check the dimensions.",
"sanctuary_media.anchor.size": "Size exceeds the configured server budget.",
"block.sanctuary_media.redstone_frame": "Redstone frame",
"sanctuary_media.anchor.free_dimensions": "Custom dimensions, in blocks",
"sanctuary_media.anchor.resize_help": "Resize while close to the anchor."
}

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