Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9674e7f3c5 | ||
|
|
4e00f028ec | ||
|
|
584a075663 | ||
|
|
45ea66987f | ||
|
|
af18f6e014 | ||
|
|
34cdcfd00e | ||
|
|
679c0f8a5f |
@@ -1,11 +1,45 @@
|
||||
# Sanctuary
|
||||
|
||||
[Dernière release : beta.295](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.295) ·
|
||||
[Reçu de livraison](docs/release-beta295.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.296 — écrans partagés (livraison en préparation)
|
||||
|
||||
[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.
|
||||
|
||||
## É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
|
||||
|
||||
@@ -16,6 +50,14 @@ 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
|
||||
@@ -1274,9 +1316,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,
|
||||
@@ -3740,7 +3781,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
|
||||
@@ -3769,7 +3810,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).
|
||||
@@ -3819,11 +3860,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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ; leur reçu sera dans `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.
|
||||
@@ -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é.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.
|
||||
+3
-2
@@ -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 de la livraison courante est `build/Sanctuary-Server-beta.293.zip` ; les manifestes locaux sont
|
||||
Le kit de la livraison courante est `build/Sanctuary-Server-beta.296.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.
|
||||
@@ -91,7 +91,8 @@ 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.
|
||||
Depuis la préparation beta.292, le pack serveur possède Fabric API, Sanctuary,
|
||||
Lithium et Simple Voice Chat. Bobby et Entity Culling restent uniquement
|
||||
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,
|
||||
|
||||
+5
-2
@@ -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.295
|
||||
pack_version=beta.295
|
||||
mod_version=beta.296
|
||||
pack_version=beta.296
|
||||
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
|
||||
|
||||
@@ -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,79 @@
|
||||
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 "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);}
|
||||
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,91 @@
|
||||
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");
|
||||
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("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}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# Sanctuary Media — prototypes MEDIA-03/04
|
||||
|
||||
Module **0.3.0**, 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, contour dessiné sur toute la surface. Maj + clic droit près
|
||||
de l'ancre ou bouton Taille du menu natif ouvre le seul écran ajouté : largeur
|
||||
et hauteur, formats rapides facultatifs et aperçu du ratio.
|
||||
|
||||
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).
|
||||
@@ -0,0 +1,48 @@
|
||||
plugins { id 'java-library' }
|
||||
|
||||
group = rootProject.maven_group
|
||||
version = '0.3.0'
|
||||
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,40 @@
|
||||
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 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,p.isShiftKeyDown());
|
||||
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(),mc.player.isShiftKeyDown()?6: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,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.fill(x,y+sh-4,x+4,y+sh,0xffff3535);}
|
||||
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,p.isShiftKeyDown());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,41 @@
|
||||
package fr.koka.media.mixin;
|
||||
|
||||
import fr.koka.media.anchor.Anchors;
|
||||
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.renderer.rendertype.RenderType;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** The same pose, visibility and Vulkan draw path as the video, with constant block-space thickness. */
|
||||
@Mixin(targets="com.dreamdisplays.platform.client.render.ScreenRenderer",remap=false)
|
||||
abstract class AnchorBorderMixin {
|
||||
@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);}
|
||||
@SuppressWarnings("unchecked")
|
||||
@Inject(method="renderScreenTexture",at=@At("TAIL"),remap=false)
|
||||
private void media$border(DisplayScreen screen,PoseStack stack,boolean replay,@Coerce Object renderer,CallbackInfo ci){
|
||||
if(!screen.getCustomData().containsKey(Anchors.MARKER))return;
|
||||
var draw=(Function2<RenderType,Function2<PoseStack.Pose,VertexConsumer,Unit>,Unit>)renderer;
|
||||
stack.pushPose();
|
||||
DisplayPoseStack.INSTANCE.liftTowardViewer(stack,screen.getFacing(),replay?.025f:.015f);
|
||||
DisplayPoseStack.INSTANCE.applyScreenTransform(stack,screen.getFacing(),screen.getWidth(),screen.getHeight());
|
||||
float x=1f/(16*screen.getWidth()),y=1f/(16*screen.getHeight());
|
||||
draw.invoke(DisplayYuvRenderTypes.INSTANCE.solidColorType(),(pose,v)->{
|
||||
media$rect(pose,v,0,0,1,y,100,69,39);media$rect(pose,v,0,1-y,1,1,151,115,65);
|
||||
media$rect(pose,v,0,y,x,1-y,127,90,49);media$rect(pose,v,1-x,y,1,1-y,80,51,27);
|
||||
media$rect(pose,v,x,y,x*2,y*2,184,19,13);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
stack.popPose();
|
||||
}
|
||||
@Unique private static void media$rect(PoseStack.Pose p,VertexConsumer v,float x0,float y0,float x1,float y1,int r,int g,int b){
|
||||
v.addVertex(p,x0,y0,0).setUv(0,0).setColor(r,g,b,255);v.addVertex(p,x1,y0,0).setUv(0,0).setColor(r,g,b,255);
|
||||
v.addVertex(p,x1,y1,0).setUv(0,0).setColor(r,g,b,255);v.addVertex(p,x0,y1,0).setUv(0,0).setColor(r,g,b,255);
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"sanctuary_media.video_acoustics": "Acoustique des vidéos",
|
||||
"sanctuary_media.video_acoustics.help": "Appliquer Sound Physics aux vidéos. Désactivé : conserver le son spatial et le volume sans réverbération ni absorption des murs.",
|
||||
"sanctuary_media.anchor.size_title": "Dimensions de l’écran",
|
||||
"sanctuary_media.anchor.one_frame": "Une ancre · agrandissement gratuit",
|
||||
"sanctuary_media.anchor.width": "Largeur",
|
||||
"sanctuary_media.anchor.height": "Hauteur",
|
||||
"sanctuary_media.anchor.apply": "Appliquer",
|
||||
"sanctuary_media.anchor.player": "Lecteur",
|
||||
"sanctuary_media.anchor.close": "Fermer",
|
||||
"sanctuary_media.anchor.url": "Lien vidéo ou live",
|
||||
"sanctuary_media.anchor.dimensions": "Taille",
|
||||
"sanctuary_media.anchor.wall": "Un mur complet est nécessaire.",
|
||||
"sanctuary_media.anchor.obstacle": "L’espace devant le mur doit être libre.",
|
||||
"sanctuary_media.anchor.protected": "Droits de construction ou proximité requis.",
|
||||
"sanctuary_media.anchor.bounds": "Hors des limites du monde.",
|
||||
"sanctuary_media.anchor.unloaded": "Une partie de la surface n’est pas chargée.",
|
||||
"sanctuary_media.anchor.overlap": "Un écran occupe déjà cette surface.",
|
||||
"sanctuary_media.anchor.unavailable": "Le lecteur est momentanément indisponible.",
|
||||
"sanctuary_media.anchor.changed": "L’écran a changé ; vérifie les dimensions.",
|
||||
"sanctuary_media.anchor.size": "Taille hors du budget configuré du serveur.",
|
||||
"block.sanctuary_media.redstone_frame": "Cadre de redstone",
|
||||
"sanctuary_media.anchor.free_dimensions": "Dimensions libres, en blocs",
|
||||
"sanctuary_media.anchor.resize_help": "Ajuster l’écran à proximité de son ancre."
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"ambientocclusion": false,
|
||||
"textures": {
|
||||
"particle": "minecraft:block/oak_planks",
|
||||
"wood": "minecraft:block/oak_planks",
|
||||
"redstone": "minecraft:block/redstone_block"
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"from": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"to": [
|
||||
16,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"south": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"east": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"west": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"up": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"down": {
|
||||
"texture": "#wood"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
0,
|
||||
15,
|
||||
0
|
||||
],
|
||||
"to": [
|
||||
16,
|
||||
16,
|
||||
1
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"south": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"east": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"west": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"up": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"down": {
|
||||
"texture": "#wood"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"to": [
|
||||
1,
|
||||
15,
|
||||
1
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"south": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"east": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"west": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"up": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"down": {
|
||||
"texture": "#wood"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
15,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"to": [
|
||||
16,
|
||||
15,
|
||||
1
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"south": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"east": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"west": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"up": {
|
||||
"texture": "#wood"
|
||||
},
|
||||
"down": {
|
||||
"texture": "#wood"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
1,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"to": [
|
||||
3,
|
||||
3,
|
||||
2
|
||||
],
|
||||
"faces": {
|
||||
"north": {
|
||||
"texture": "#redstone"
|
||||
},
|
||||
"south": {
|
||||
"texture": "#redstone"
|
||||
},
|
||||
"east": {
|
||||
"texture": "#redstone"
|
||||
},
|
||||
"west": {
|
||||
"texture": "#redstone"
|
||||
},
|
||||
"up": {
|
||||
"texture": "#redstone"
|
||||
},
|
||||
"down": {
|
||||
"texture": "#redstone"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"parent": "minecraft:item/generated",
|
||||
"textures": {
|
||||
"layer0": "minecraft:item/item_frame",
|
||||
"layer1": "minecraft:item/redstone"
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "sanctuary_media:redstone_frame"
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "minecraft:survives_explosion"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"type": "minecraft:crafting_shaped",
|
||||
"category": "redstone",
|
||||
"pattern": [
|
||||
"SSS",
|
||||
"SRS",
|
||||
"SSS"
|
||||
],
|
||||
"key": {
|
||||
"S": "minecraft:stick",
|
||||
"R": "minecraft:redstone"
|
||||
},
|
||||
"result": {
|
||||
"id": "sanctuary_media:redstone_frame",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "sanctuary_media",
|
||||
"version": "0.3.0",
|
||||
"name": "Sanctuary Media",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"environment": "*",
|
||||
"entrypoints": {"main": ["fr.koka.media.MediaMod"], "client": ["fr.koka.media.MediaClient"]},
|
||||
"mixins": ["sanctuary-media.mixins.json"],
|
||||
"depends": {"fabricloader": ">=0.19.5", "minecraft": "26.3", "java": ">=25", "fabric-api": "*", "dreamdisplays": "1.10.4", "sanctuary": "*"},
|
||||
"suggests": {"sound_physics_remastered": "1.5.1+26.3"}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"required": true,
|
||||
"package": "fr.koka.media.mixin",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"mixins": ["AnchorDisplayGuardMixin", "AnchorActionGuardMixin"],
|
||||
"client": ["AnchorUseMixin", "AnchorMenuMixin", "AnchorModeMixin", "AnchorBorderMixin", "AnchorTextureBudgetMixin", "AnchorResizePlaybackMixin", "AudioSinkMixin", "MasterClockMixin", "PlayerClockMixin", "SessionStartMixin", "FrameGateMixin", "TimelineMixin", "VideoUrlMixin", "AudioUrlMixin", "SoundManagerAccess", "SoundEngineAccess", "LibraryAccess", "SoundLifecycleMixin", "VideoOptionsMixin"],
|
||||
"injectors": {"defaultRequire": 1}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package fr.koka.media;
|
||||
|
||||
import fr.koka.media.anchor.AnchorGeometry;
|
||||
import net.minecraft.core.*;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
final class AnchorGeometryTest {
|
||||
static void run(){
|
||||
assert com.dreamdisplays.api.display.data.DisplayCustomDataLimits.isValidKey(fr.koka.media.anchor.Anchors.MARKER);
|
||||
for(Direction facing:new Direction[]{Direction.NORTH,Direction.SOUTH,Direction.EAST,Direction.WEST}){
|
||||
var g=new AnchorGeometry(new BlockPos(12,70,24),facing,7,4);
|
||||
Vec3 normal=new Vec3(facing.getStepX(),0,facing.getStepZ());
|
||||
Vec3 eye=g.center().add(normal.scale(12));
|
||||
assert Math.abs(g.hit(eye,normal.scale(-1),16)-12)<.0001;
|
||||
assert g.hit(eye,normal.scale(-1),11)<0;
|
||||
assert g.hit(g.center().subtract(normal.scale(4)),normal,16)<0;
|
||||
assert g.hit(eye.add(0,3,0),normal.scale(-1),16)<0;
|
||||
assert g.opposite().equals(g.wall().relative(g.right(),6).above(3));
|
||||
var adjacent=new AnchorGeometry(g.anchor().relative(g.right(),7),facing,2,4);
|
||||
assert !g.overlaps(adjacent);
|
||||
assert g.overlaps(new AnchorGeometry(g.anchor().relative(g.right(),6),facing,2,4));
|
||||
assert !g.overlaps(new AnchorGeometry(g.anchor().above(4),facing,7,4));
|
||||
}
|
||||
assert new AnchorGeometry(BlockPos.ZERO,Direction.SOUTH,33,19).width()==33;
|
||||
assert fr.koka.media.anchor.AnchorLimits.allows(33,19);
|
||||
assert !fr.koka.media.anchor.AnchorLimits.allows(256,256);
|
||||
var encoded=java.nio.ByteBuffer.allocate(28).putInt(2).putLong(BlockPos.ZERO.asLong()).putInt(33).putInt(19).putInt(7).putInt(Direction.SOUTH.ordinal()).array();
|
||||
assert fr.koka.media.anchor.AnchorInfo.decode(java.util.UUID.randomUUID(),encoded).geometry().width()==33;
|
||||
var huge=fr.koka.media.anchor.TextureBudget.fit(184320,720);assert huge[0]<=4096&&huge[1]>=2;
|
||||
assert (long)huge[0]*huge[1]<=8_388_608;
|
||||
assert java.util.Arrays.equals(fr.koka.media.anchor.TextureBudget.fit(1920,1080),new int[]{1920,1080});
|
||||
for(int[] size:new int[][]{{0,1},{1,0},{60_000_001,1},{1,4097},{-1,1}}){
|
||||
try{new AnchorGeometry(BlockPos.ZERO,Direction.SOUTH,size[0],size[1]);throw new AssertionError("Invalid size accepted");}
|
||||
catch(IllegalArgumentException expected){}
|
||||
}
|
||||
System.out.println("Anchor geometry: four facings, front/blocked range, adjacent/overlapping bounds passed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package fr.koka.media;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
/** Deterministic clock, reconnect and manifest-contract checks; run with assertions enabled. */
|
||||
public final class MediaSyncTest {
|
||||
public static void main(String[] args) {
|
||||
var clock = new DisciplinedClock();
|
||||
clock.accept(1_000_000_000L, 1_020_000_000L, 10_010);
|
||||
assert clock.ready();
|
||||
assert Math.abs(clock.nowNanos(1_100_000_000L) - 10_100_000_000L) < 1_000;
|
||||
// A slow queued reply must not move the estimate to its delayed timestamp.
|
||||
clock.accept(1_100_000_000L, 1_500_000_000L, 10_100);
|
||||
assert Math.abs(clock.nowNanos(1_600_000_000L) - 10_600_000_000L) < 1_000;
|
||||
long last = clock.nowNanos(1_700_000_000L);
|
||||
assert clock.nowNanos(1_699_000_000L) >= last;
|
||||
clock.reset();
|
||||
assert !clock.ready();
|
||||
// A reconnect to a different epoch replaces the old estimate immediately.
|
||||
clock.accept(2_000_000_000L, 2_020_000_000L, 5_010);
|
||||
assert Math.abs(clock.nowNanos(2_100_000_000L) - 5_100_000_000L) < 1_000;
|
||||
clock.accept(3, 2, 0);
|
||||
assert clock.nowNanos(2_100_000_000L) >= 5_100_000_000L;
|
||||
|
||||
var timeline = new SyncControl.Timeline(2_000, 10_000, false, false, 0);
|
||||
assert timeline.at(12_000_000_000L) == 4_000_000_000L;
|
||||
assert new SyncControl.Timeline(2_000, 10_000, true, false, 0).at(12_000_000_000L) == 2_000_000_000L;
|
||||
assert new SyncControl.Timeline(9_000, 10_000, false, true, 10_000).at(12_000_000_000L) == 1_000_000_000L;
|
||||
assert SyncControl.rate(500_000_000) == 1.02;
|
||||
assert SyncControl.rate(-500_000_000) == 0.98;
|
||||
assert SyncControl.rate(1_000_000) == 1;
|
||||
|
||||
var base = URI.create("https://example.org/path/live.m3u8");
|
||||
var playlist = HlsPlaylist.parse(base, """
|
||||
#EXTM3U
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:42
|
||||
#EXT-X-KEY:METHOD=AES-128,URI="key.bin"
|
||||
#EXTINF:1.5,
|
||||
a.ts
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-10-26T10:00:01.500+0000
|
||||
#EXTINF:2,
|
||||
b.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:1,
|
||||
c.ts
|
||||
""");
|
||||
assert playlist.segments().get(0).epochMillis() == 1793008800000L;
|
||||
assert playlist.segments().get(2).epochMillis() == -1;
|
||||
var sliced = HlsPlaylist.parse(base, playlist.render(1, 2));
|
||||
assert sliced.sequence() == 43;
|
||||
assert sliced.segments().get(0).url().equals("https://example.org/path/b.ts");
|
||||
assert sliced.segments().get(0).key().contains("https://example.org/path/key.bin");
|
||||
assert sliced.segments().get(1).discontinuity();
|
||||
try {
|
||||
HlsPlaylist.parse(base, "#EXTM3U\n#EXT-X-BYTERANGE:10\n");
|
||||
throw new AssertionError("Byte-range playlist must fall back");
|
||||
} catch (IllegalArgumentException expected) {}
|
||||
// Restarting one live player must retire its epoch without touching another screen.
|
||||
var retired=new HlsProxy.Origin();retired.audioMillis=1;retired.videoMillis=2;
|
||||
var other=new HlsProxy.Origin();other.audioMillis=3;other.videoMillis=4;
|
||||
HlsProxy.ORIGINS.put("restarted",retired);HlsProxy.ORIGINS.put("other",other);
|
||||
HlsProxy.forget("restarted");
|
||||
assert !HlsProxy.ORIGINS.containsKey("restarted");
|
||||
assert HlsProxy.ORIGINS.get("other")==other;
|
||||
HlsProxy.forget("other");
|
||||
System.out.println("Media clock / reconnect / timeline / HLS contracts passed");
|
||||
AnchorGeometryTest.run();
|
||||
}
|
||||
}
|
||||
@@ -743,6 +743,8 @@ dependencies {
|
||||
}
|
||||
// One distributed Sanctuary JAR; keep the exact JEI fork and its own metadata.
|
||||
include project(':jei')
|
||||
// Preserve Media's own Fabric ID, registrations and save identifiers inside one Sanctuary JAR.
|
||||
include project(':sanctuary-media')
|
||||
minecraft "com.mojang:minecraft:${rootProject.minecraft_version}"
|
||||
implementation "net.fabricmc:fabric-loader:${rootProject.loader_version}"
|
||||
implementation "net.fabricmc.fabric-api:fabric-api:${rootProject.fabric_api_version}"
|
||||
|
||||
@@ -9,6 +9,11 @@ file = "mods/bobby.pw.toml"
|
||||
hash = "72ce751a7c14567629bac0b127f9d6635638f66cd0614b513eacc3179b227f97"
|
||||
metafile = true
|
||||
|
||||
[[files]]
|
||||
file = "mods/dream-displays.pw.toml"
|
||||
hash = "50468308379bb531dfb1fd521d2ea21662b684ec3bc058cf6ac6b1968547a0c4"
|
||||
metafile = true
|
||||
|
||||
[[files]]
|
||||
file = "mods/entityculling.pw.toml"
|
||||
hash = "37112e88c0e1685c09a5dbb6e4e7f37c75b11e8f495a25a6444ea7a022680886"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "Dream Displays"
|
||||
filename = "dreamdisplays-fabric-26.3-1.10.4.jar"
|
||||
side = "both"
|
||||
|
||||
[download]
|
||||
url = "https://cdn.modrinth.com/data/4IIKQyBu/versions/siUKU04U/dreamdisplays-fabric-26.3-1.10.4.jar"
|
||||
hash-format = "sha512"
|
||||
hash = "c034f3e890fed144832b27442459d254ad553cbaec592d2da50162711e0f71f2313ec52639c6b1a576890b909b935c98f3699da296801dde7db81ced6d9d9eca"
|
||||
|
||||
[update.modrinth]
|
||||
mod-id = "4IIKQyBu"
|
||||
version = "siUKU04U"
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.295"
|
||||
version = "beta.296"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
file = "index.toml"
|
||||
hash-format = "sha256"
|
||||
hash = "21e12b4b57d86ef2a312769da3319081ab7dbe86b0120d377de431e421444057"
|
||||
hash = "3e67f3eba127f46a81138c21c46cc7623d81d94047396c03f6d23024fb4189a0"
|
||||
|
||||
[versions]
|
||||
fabric = "0.19.5"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise actual anchor menus and authoritative refusals in an explicitly prepared media lab."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
import media_lab
|
||||
|
||||
|
||||
def run(name):
|
||||
lab = media_lab.session(name)
|
||||
manifest = json.loads((lab / "manifest.json").read_text())
|
||||
if not manifest.get("anchor_lab") or manifest.get("world") != "media-flat":
|
||||
raise SystemExit("Only --anchors laboratory sessions are supported")
|
||||
control = lab / "control.json"
|
||||
results = []
|
||||
|
||||
def last(kind):
|
||||
return json.loads((lab / (kind + ".jsonl")).read_text().splitlines()[-1])
|
||||
|
||||
def step(phase, **patch):
|
||||
value = json.loads(control.read_text())
|
||||
value.update(phase=phase, anchor_a="", anchor_b="", anchor_server_step="")
|
||||
value.update(patch)
|
||||
temporary = control.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(value))
|
||||
temporary.replace(control)
|
||||
time.sleep(3)
|
||||
|
||||
def check(title, condition):
|
||||
if not condition:
|
||||
raise AssertionError(title)
|
||||
results.append(title)
|
||||
print("PASS", title, flush=True)
|
||||
(lab / "anchor-checks.json").write_text(json.dumps({"passed": results}, indent=2) + "\n")
|
||||
|
||||
state = last("anchor-server")
|
||||
if not state.get("anchor"):
|
||||
step("anchor-setup", anchor_server_step="setup")
|
||||
step("anchor-place", anchor_a="place", aim_a=[-3.5, 81.5, .5])
|
||||
state = last("anchor-server")
|
||||
check("one frame consumed on placement", state.get("anchor") and state["frames_in_inventory"] == 3)
|
||||
inventory = state["frames_in_inventory"]
|
||||
original_id = state["display"]
|
||||
step("near-anchor", anchor_server_step="near", aim_a=[-3.5, 81.5, .5], anchor_a="close")
|
||||
step("open-anchor", anchor_a="click")
|
||||
step("custom-size", anchor_a="size:7:4")
|
||||
check("custom 7x4 and free enlargement", last("anchor-server")["width"] == 7 and last("anchor-server")["height"] == 4 and last("anchor-server")["frames_in_inventory"] == inventory)
|
||||
step("shrink", anchor_a="size:1:1")
|
||||
check("shrinking does not refund frames", last("anchor-server")["width"] == 1 and last("anchor-server")["frames_in_inventory"] == inventory)
|
||||
step("restore", anchor_a="size:7:4")
|
||||
check("resizing keeps the same display", last("anchor-server")["display"] == original_id)
|
||||
step("invalid-size", anchor_a="size:9999999:4")
|
||||
check("oversize refused", last("anchor-client-a").get("message") == "size" and last("anchor-server")["width"] == 7)
|
||||
step("missing-wall", anchor_a="size:10:4")
|
||||
check("incomplete wall refused", last("anchor-client-a").get("message") == "wall" and last("anchor-server")["width"] == 7)
|
||||
step("stale", anchor_a="stale")
|
||||
check("stale revision refused", last("anchor-client-a").get("message") == "changed")
|
||||
step("rules", anchor_server_step="rules")
|
||||
check("native delete, overlap, obstacle and unloaded chunks refused", all(last("anchor-server")["assertions"].get(k) for k in ["native_delete_refused", "marker_present", "unloaded_rejected", "obstacle_rejected", "overlap_rejected"]))
|
||||
step("adventure", anchor_server_step="no-build")
|
||||
step("protected", anchor_a="far-resize")
|
||||
check("building permission required", last("anchor-client-a").get("message") == "protected" and last("anchor-server")["width"] == 7)
|
||||
step("survival", anchor_server_step="build")
|
||||
step("far", anchor_server_step="far", aim_a=[0, 83, 1], anchor_a="close")
|
||||
step("far-reader", anchor_a="click")
|
||||
client = last("anchor-client-a")
|
||||
check("original Dream reader on remote overlay while holding frame", client.get("screen") == "DisplayMenu" and not client.get("editable") and client.get("distance", 0) > 12)
|
||||
step("far-resize", anchor_a="far-resize")
|
||||
check("forged remote resize refused", last("anchor-client-a").get("message") == "protected" and last("anchor-server")["width"] == 7)
|
||||
step("wall", anchor_server_step="wall", anchor_a="close")
|
||||
step("blocked-click", anchor_a="click")
|
||||
check("wall blocks normal interaction", last("anchor-client-a").get("screen") != "DisplayMenu" and not last("anchor-client-a").get("target"))
|
||||
step("forged-open", anchor_a="forged-open")
|
||||
check("wall blocks forged open packet", last("anchor-client-a").get("screen") != "DisplayMenu")
|
||||
step("clear-wall", anchor_server_step="clear-wall")
|
||||
step("remote-player", anchor_a="click", anchor_b="click")
|
||||
check("original Dream reader available to second spectator", last("anchor-client-b").get("screen") == "DisplayMenu")
|
||||
print("Interaction suite complete; retain world for restart/removal checks.", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run", required=True)
|
||||
run(parser.parse_args().run)
|
||||
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reproducible Dream Displays lab, two real Vulkan clients, loopback only."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.server
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE = ROOT / "build/media-prototype"
|
||||
DREAM_ID = "siUKU04U"
|
||||
DREAM_HASH = "c034f3e890fed144832b27442459d254ad553cbaec592d2da50162711e0f71f2313ec52639c6b1a576890b909b935c98f3699da296801dde7db81ced6d9d9eca"
|
||||
|
||||
|
||||
def java_home():
|
||||
return Path(os.environ.get("JAVA_HOME") or subprocess.check_output(["/usr/libexec/java_home", "-v", "25"], text=True).strip())
|
||||
|
||||
|
||||
def port():
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def session(name):
|
||||
if not re.fullmatch(r"[a-z0-9-]{1,40}", name):
|
||||
raise SystemExit("Use a short lowercase session name")
|
||||
return BASE / name
|
||||
|
||||
|
||||
def read_args(kind):
|
||||
p = ROOT / "build/duo" / f"{kind}.args"
|
||||
if not p.is_file():
|
||||
raise SystemExit("Run ./gradlew :sanctuary-test:exportDuoLaunch first")
|
||||
return [json.loads(line) for line in p.read_text().splitlines()]
|
||||
|
||||
|
||||
def download(url, expected, filename):
|
||||
p = BASE / "dependencies" / filename
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not p.exists():
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "SanctuaryMediaPrototype/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
p.write_bytes(response.read())
|
||||
if hashlib.sha512(p.read_bytes()).hexdigest() != expected:
|
||||
raise SystemExit(f"SHA-512 mismatch: {p}")
|
||||
return p
|
||||
|
||||
|
||||
def build_probe(dream, media=None):
|
||||
args = read_args("client")
|
||||
cp = args[args.index("-classpath") + 1] + os.pathsep + str(dream)
|
||||
if media:
|
||||
cp += os.pathsep + str(media)
|
||||
source = ROOT / "mods/media-probe/src/main"
|
||||
classes = BASE / "probe-classes"
|
||||
if classes.exists():
|
||||
shutil.rmtree(classes)
|
||||
classes.mkdir(parents=True, exist_ok=True)
|
||||
sources = list(source.glob("java/**/*.java"))
|
||||
if media:
|
||||
sources += list((source.parent / "anchor/java").glob("**/*.java"))
|
||||
subprocess.run([str(java_home() / "bin/javac"), "-proc:none", "--release", "25", "-cp", cp,
|
||||
"-d", str(classes), *map(str, sources)], check=True)
|
||||
jar = BASE / "media-probe.jar"
|
||||
with zipfile.ZipFile(jar, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
||||
for base in [classes, source / "resources"]:
|
||||
for p in sorted(base.rglob("*")):
|
||||
if p.is_file():
|
||||
z.write(p, p.relative_to(base))
|
||||
return jar
|
||||
|
||||
|
||||
def build_media(dream, test=False):
|
||||
args = read_args("client")
|
||||
cp = args[args.index("-classpath") + 1] + os.pathsep + str(dream)
|
||||
source = ROOT / "mods/sanctuary-media/src/main"
|
||||
classes = BASE / "media-classes"
|
||||
if classes.exists():
|
||||
shutil.rmtree(classes)
|
||||
classes.mkdir(parents=True)
|
||||
subprocess.run([str(java_home() / "bin/javac"), "-proc:none", "--release", "25", "-cp", cp,
|
||||
"-d", str(classes), *map(str, source.glob("java/**/*.java"))], check=True)
|
||||
if test:
|
||||
tests = source.parent / "test/java"
|
||||
test_classes = BASE / "media-test-classes"
|
||||
if test_classes.exists():
|
||||
shutil.rmtree(test_classes)
|
||||
test_classes.mkdir()
|
||||
subprocess.run([str(java_home() / "bin/javac"), "-proc:none", "--release", "25", "-cp", str(classes) + os.pathsep + cp,
|
||||
"-d", str(test_classes), *map(str, tests.glob("**/*.java"))], check=True)
|
||||
subprocess.run([str(java_home() / "bin/java"), "-ea", "-cp", str(test_classes) + os.pathsep + str(classes) + os.pathsep + cp,
|
||||
"fr.koka.media.MediaSyncTest"], check=True)
|
||||
jar = BASE / "sanctuary-media.jar"
|
||||
with zipfile.ZipFile(jar, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
||||
for base in [classes, source / "resources"]:
|
||||
for p in sorted(base.rglob("*")):
|
||||
if p.is_file():
|
||||
z.write(p, p.relative_to(base))
|
||||
return jar
|
||||
|
||||
|
||||
def packaged_media():
|
||||
"""Find the real Sanctuary artifact on Loom's launch path; avoid a duplicate addon."""
|
||||
args = read_args("client")
|
||||
for value in args[args.index("-classpath") + 1].split(os.pathsep):
|
||||
jar = Path(value)
|
||||
if not jar.is_file() or not jar.name.startswith("sanctuary-beta.") or jar.suffix != ".jar":
|
||||
continue
|
||||
with zipfile.ZipFile(jar) as archive:
|
||||
mod = json.loads(archive.read("fabric.mod.json"))
|
||||
if mod["id"] != "sanctuary":
|
||||
continue
|
||||
for nested in mod.get("jars", []):
|
||||
with zipfile.ZipFile(io.BytesIO(archive.read(nested["file"]))) as child:
|
||||
if "fabric.mod.json" in child.namelist() and json.loads(child.read("fabric.mod.json"))["id"] == "sanctuary_media":
|
||||
return {"version": mod["version"], "sha256": hashlib.sha256(jar.read_bytes()).hexdigest(), "path": str(jar)}
|
||||
return None
|
||||
|
||||
|
||||
def pattern(output):
|
||||
# Frame number encoded in 16 broad monochrome bars; robust to YUV and rescaling.
|
||||
filters = ["drawbox=x=0:y=0:w=iw:h=24:color=black:t=fill"]
|
||||
for bit in range(16):
|
||||
filters.append(f"drawbox=x={bit * 20}:y=0:w=20:h=24:color=white:t=fill:enable='mod(floor(n/{2 ** (15-bit)}),2)'")
|
||||
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=30",
|
||||
"-f", "lavfi", "-i", "sine=frequency=880:sample_rate=44100", "-t", "660", "-vf", ",".join(filters),
|
||||
"-af", "volume=0.15", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "20", "-g", "30",
|
||||
"-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "64k", "-movflags", "+faststart", str(output)]
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
def prepare(name, without_sodium=False, media_fix=False, anchors=False):
|
||||
lab = session(name)
|
||||
if lab.exists():
|
||||
raise SystemExit("Existing session preserved; choose a new --run name")
|
||||
version = json.load(urllib.request.urlopen(f"https://api.modrinth.com/v2/version/{DREAM_ID}"))
|
||||
item = next(f for f in version["files"] if f["primary"])
|
||||
if item["hashes"]["sha512"] != DREAM_HASH:
|
||||
raise SystemExit("The pinned Dream artifact changed")
|
||||
dream = download(item["url"], DREAM_HASH, item["filename"])
|
||||
media_addon = build_media(dream) if media_fix else None
|
||||
probe = build_probe(dream, media_addon)
|
||||
packaged = packaged_media()
|
||||
sodium_pin = tomllib.loads((ROOT / "packwiz/mods/sodium.pw.toml").read_text())
|
||||
sodium = download(sodium_pin["download"]["url"], sodium_pin["download"]["hash"], sodium_pin["filename"])
|
||||
game_port, media_port = port(), port()
|
||||
lab.mkdir(parents=True)
|
||||
manifest = {"layout": 1, "world": "media-flat", "backend": "vulkan", "dream": "1.10.4", "dream_sha512": DREAM_HASH,
|
||||
"port": game_port, "media_port": media_port, "sodium": None if without_sodium else sodium_pin["filename"], "media_fix": media_fix, "created": time.time()}
|
||||
if packaged:
|
||||
manifest["packaged_sanctuary"] = packaged
|
||||
(lab / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
(lab / "control.json").write_text(json.dumps({"phase": "boot", "sound_physics": False, "anchor_lab": anchors}))
|
||||
if anchors:
|
||||
manifest["anchor_lab"] = True
|
||||
(lab / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
for kind in ["server", "client-a", "client-b"]:
|
||||
folder = lab / kind
|
||||
(folder / "mods").mkdir(parents=True)
|
||||
(folder / "config").mkdir()
|
||||
os.link(dream, folder / "mods" / dream.name)
|
||||
shutil.copy2(probe, folder / "mods")
|
||||
if media_addon and not packaged:
|
||||
shutil.copy2(media_addon, folder / "mods")
|
||||
(folder / "config/sanctuary-community.json").write_text(json.dumps({"mode": "file", "serverId": str(uuid.uuid4())}))
|
||||
if kind == "server":
|
||||
(folder / "eula.txt").write_text("eula=true\n")
|
||||
(folder / "server.properties").write_text(f"server-ip=127.0.0.1\nserver-port={game_port}\nonline-mode=false\nwhite-list=false\nenforce-secure-profile=false\nlevel-name=media-flat\nlevel-type=sanctuary_test:flat\nlevel-seed=2602\ngamemode=creative\ndifficulty=peaceful\nmax-players=2\nview-distance=3\nsimulation-distance=3\nspawn-protection=0\npvp=false\npause-when-empty-seconds=0\n")
|
||||
else:
|
||||
if not without_sodium:
|
||||
os.link(sodium, folder / "mods" / sodium.name)
|
||||
(folder / "config/sanctuary-shaders.json").write_text('{"enabled":false}\n')
|
||||
(folder / "config/dreamdisplays").mkdir()
|
||||
(folder / "config/dreamdisplays/config.yml").write_text("audio-acoustics: off\naudio-normalization: false\n")
|
||||
(folder / "options.txt").write_text("renderDistance:3\nsimulationDistance:5\nmaxFps:30\nenableVsync:false\nguiScale:2\nlang:fr_fr\npauseOnLostFocus:false\n")
|
||||
binary = folder / "dreamdisplays/ffmpeg/macos-aarch64/ffmpeg"
|
||||
if sys.platform == "darwin" and os.uname().machine == "arm64" and shutil.which("ffmpeg"):
|
||||
binary.parent.mkdir(parents=True, exist_ok=True)
|
||||
binary.symlink_to(shutil.which("ffmpeg"))
|
||||
media = lab / "media"
|
||||
media.mkdir()
|
||||
fixture = BASE / "timecode-v1.mp4"
|
||||
if not fixture.exists():
|
||||
pattern(fixture)
|
||||
os.link(fixture, media / "timecode.mp4")
|
||||
print(lab, flush=True)
|
||||
|
||||
|
||||
def spawn(lab, kind, argv, cwd):
|
||||
pidfile = lab / f"{kind}.pid"
|
||||
if pidfile.exists():
|
||||
pid = int(pidfile.read_text())
|
||||
check = subprocess.run(["ps", "-p", str(pid), "-o", "command="], capture_output=True, text=True)
|
||||
if check.returncode == 0:
|
||||
raise SystemExit(f"Existing process preserved: {pidfile}")
|
||||
pidfile.rename(lab / f"{kind}.exited-{time.time_ns()}.pid")
|
||||
with (lab / f"{kind}.log").open("ab") as log:
|
||||
p = subprocess.Popen(argv, cwd=cwd, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
pidfile.write_text(str(p.pid))
|
||||
print(f"{kind}: {p.pid}", flush=True)
|
||||
return p
|
||||
|
||||
|
||||
def launch(name, kind):
|
||||
lab = session(name)
|
||||
m = json.loads((lab / "manifest.json").read_text())
|
||||
if m["layout"] != 1 or m["backend"] != "vulkan":
|
||||
raise SystemExit("Invalid lab manifest")
|
||||
if m.get("packaged_sanctuary"):
|
||||
artifact = m["packaged_sanctuary"]
|
||||
if hashlib.sha256(Path(artifact["path"]).read_bytes()).hexdigest() != artifact["sha256"]:
|
||||
raise SystemExit("Packaged Sanctuary changed after lab preparation; create a new session")
|
||||
args = read_args("server" if kind == "server" else "client")
|
||||
args = [a for a in args if not a.startswith(("-Xmx", "-Xms", "-Dsanctuary.duo="))]
|
||||
args = ["-Xms128m", "-Xmx768m" if kind == "server" else "-Xmx1536m", "-XX:ActiveProcessorCount=2",
|
||||
f"-Dsanctuary.mediaLab={lab}", f"-Dsanctuary.mediaClient={kind}", "-Ddreamdisplays.allowPrivateUrls=true", *args]
|
||||
if kind != "server":
|
||||
args[args.index("--username") + 1] = "MediaLabA" if kind.endswith("a") else "MediaLabB"
|
||||
args[args.index("--width") + 1] = "800"
|
||||
args[args.index("--height") + 1] = "450"
|
||||
args += ["--quickPlayMultiplayer", f"127.0.0.1:{m['port']}"]
|
||||
assert args[args.index("--graphicsBackend") + 1] == "vulkan"
|
||||
path = lab / f"{kind}-runtime.args"
|
||||
path.write_text("\n".join(json.dumps(a) for a in args) + "\n")
|
||||
return spawn(lab, kind, [str(java_home() / "bin/java"), "@" + str(path)], lab / kind)
|
||||
|
||||
|
||||
def serve(name):
|
||||
lab = session(name)
|
||||
m = json.loads((lab / "manifest.json").read_text())
|
||||
class RangeHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *a, **kw):
|
||||
super().__init__(*a, directory=str(lab / "media"), **kw)
|
||||
def log_message(self, fmt, *args):
|
||||
print(fmt % args, flush=True)
|
||||
def send_head(self):
|
||||
self.remaining = None
|
||||
requested = self.headers.get("Range")
|
||||
path = Path(self.translate_path(self.path))
|
||||
if requested and path.is_file():
|
||||
match = re.fullmatch(r"bytes=(\d+)-(\d*)", requested)
|
||||
if not match:
|
||||
self.send_error(416); return None
|
||||
size = path.stat().st_size
|
||||
start = int(match[1]); end = min(int(match[2]) if match[2] else size - 1, size - 1)
|
||||
if start > end or start >= size:
|
||||
self.send_error(416); return None
|
||||
f = path.open("rb"); f.seek(start)
|
||||
self.remaining = end - start + 1
|
||||
self.send_response(206)
|
||||
self.send_header("Content-Type", self.guess_type(str(path)))
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||
self.send_header("Content-Length", str(self.remaining)); self.end_headers()
|
||||
return f
|
||||
return super().send_head()
|
||||
def copyfile(self, source, output):
|
||||
if self.remaining is None:
|
||||
return super().copyfile(source, output)
|
||||
while self.remaining:
|
||||
data = source.read(min(64 * 1024, self.remaining))
|
||||
if not data: break
|
||||
output.write(data); self.remaining -= len(data)
|
||||
# The media endpoint is loopback-only and serves exclusively synthetic media.
|
||||
http.server.ThreadingHTTPServer(("127.0.0.1", m["media_port"]), RangeHandler).serve_forever()
|
||||
|
||||
|
||||
def live(name):
|
||||
lab = session(name)
|
||||
return spawn(lab, "live", ["ffmpeg", "-hide_banner", "-loglevel", "warning", "-re", "-stream_loop", "-1", "-i", str(lab / "media/timecode.mp4"),
|
||||
"-map", "0", "-c", "copy", "-f", "hls", "-hls_time", "1", "-hls_list_size", "12", "-hls_flags",
|
||||
"delete_segments+program_date_time+independent_segments", "-hls_segment_filename", str(lab / "media/live-%09d.ts"), str(lab / "media/live.m3u8")], lab)
|
||||
|
||||
|
||||
def control(name, phase, source, sound, look_away=False, video_acoustics=True):
|
||||
lab = session(name)
|
||||
m = json.loads((lab / "manifest.json").read_text())
|
||||
ctl = {"phase": phase, "sound_physics": sound, "look_away": look_away, "video_acoustics": video_acoustics, "changed_ns": time.time_ns(), "anchor_lab": m.get("anchor_lab", False)}
|
||||
if source:
|
||||
ctl["url"] = f"http://127.0.0.1:{m['media_port']}/" + ("timecode.mp4" if source == "vod" else "live.m3u8")
|
||||
tmp = lab / "control.tmp"
|
||||
tmp.write_text(json.dumps(ctl)); tmp.replace(lab / "control.json")
|
||||
|
||||
|
||||
def stop(name, target=None):
|
||||
lab = session(name)
|
||||
# Only processes demonstrably launched for this exact lab can be stopped.
|
||||
for kind in ["server", "client-a", "client-b", "live"]:
|
||||
if target and kind != target:
|
||||
continue
|
||||
pidfile = lab / f"{kind}.pid"
|
||||
if not pidfile.exists():
|
||||
continue
|
||||
pid = int(pidfile.read_text())
|
||||
result = subprocess.run(["ps", "-ww", "-p", str(pid), "-o", "command="], capture_output=True, text=True)
|
||||
marker = str(lab / "media/live.m3u8") if kind == "live" else "@" + str(lab / f"{kind}-runtime.args")
|
||||
if marker in result.stdout:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
print("Stopped", pidfile.stem, pid)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("command", choices=["prepare", "server", "client-a", "client-b", "serve", "live", "control", "stop", "test-media"])
|
||||
p.add_argument("--run", default="review-01")
|
||||
p.add_argument("--phase", default="vod-baseline")
|
||||
p.add_argument("--source", choices=["vod", "live"])
|
||||
p.add_argument("--sound-physics", action="store_true")
|
||||
p.add_argument("--look-away", action="store_true")
|
||||
p.add_argument("--without-sodium", action="store_true")
|
||||
p.add_argument("--target", choices=["server", "client-a", "client-b", "live"])
|
||||
p.add_argument("--media-fix", action="store_true")
|
||||
p.add_argument("--anchors", action="store_true")
|
||||
p.add_argument("--no-video-acoustics", action="store_true")
|
||||
a = p.parse_args()
|
||||
if a.command == "prepare": prepare(a.run, a.without_sodium, a.media_fix or a.anchors, a.anchors)
|
||||
elif a.command in ["server", "client-a", "client-b"]: launch(a.run, a.command)
|
||||
elif a.command == "serve": serve(a.run)
|
||||
elif a.command == "live": live(a.run)
|
||||
elif a.command == "control": control(a.run, a.phase, a.source, a.sound_physics, a.look_away, not a.no_video_acoustics)
|
||||
elif a.command == "stop": stop(a.run, a.target)
|
||||
elif a.command == "test-media": build_media(BASE / "dependencies/dreamdisplays-fabric-26.3-1.10.4.jar", test=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare synthetic video frame codes submitted to the GPU in two client JVMs."""
|
||||
import argparse
|
||||
import bisect
|
||||
import json
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
|
||||
|
||||
def percentile(values, fraction):
|
||||
return sorted(values)[round((len(values) - 1) * fraction)] if values else None
|
||||
|
||||
|
||||
def summary(values):
|
||||
return {"count": len(values), "median": statistics.median(values) if values else None,
|
||||
"p95": percentile(values, 0.95), "max": max(values) if values else None}
|
||||
|
||||
|
||||
def usable(row):
|
||||
return (row.get("connected") and row.get("backend", "").lower() == "vulkan"
|
||||
and row.get("visual_frame", -1) >= 0 and row.get("displays")
|
||||
and row["displays"][0].get("clock_running")
|
||||
and (row["displays"][0].get("audio_backend") != "openal" or row["displays"][0].get("audio_active"))
|
||||
and 0 <= row["wall_ns"] - row["visual_frame_wall_ns"] < 500_000_000)
|
||||
|
||||
|
||||
def compare(a, b):
|
||||
valid_b = [r for r in b if usable(r)]
|
||||
times = [r["wall_ns"] for r in valid_b]
|
||||
errors = []
|
||||
for r in a:
|
||||
if not usable(r) or not valid_b:
|
||||
continue
|
||||
i = bisect.bisect_left(times, r["wall_ns"])
|
||||
choices = valid_b[max(0, i - 1):i + 1]
|
||||
s = min(choices, key=lambda s: abs(s["wall_ns"] - r["wall_ns"]))
|
||||
if abs(s["wall_ns"] - r["wall_ns"]) > 150_000_000:
|
||||
continue
|
||||
# Remove the observation-time offset, using the actual GPU-submission times.
|
||||
# Resolution: one 30-fps media frame, plus scheduling/clock measurement error.
|
||||
# The 660-second synthetic source loops in the HLS generator.
|
||||
frame_delta = ((r["visual_frame"] - s["visual_frame"] + 9900) % 19800) - 9900
|
||||
delta = frame_delta / 30
|
||||
observed_later = (r["visual_frame_wall_ns"] - s["visual_frame_wall_ns"]) / 1e9
|
||||
errors.append(abs(delta - observed_later) * 1000)
|
||||
return summary(errors)
|
||||
|
||||
|
||||
def client_summary(rows):
|
||||
valid = [r for r in rows if usable(r)]
|
||||
av = []
|
||||
for r in valid:
|
||||
d = r["displays"][0]
|
||||
if not r.get("phase", "").startswith("live") and d.get("audio_origin_known") and d.get("audio_content_ns", -1) >= 0:
|
||||
image_now = r["visual_frame"] / 30 + (r["wall_ns"] - r["visual_frame_wall_ns"]) / 1e9
|
||||
av.append(abs(d["audio_content_ns"] / 1e9 - image_now) * 1000)
|
||||
return {"samples": len(rows), "usable_video_samples": len(valid),
|
||||
"largest_sample_gap_seconds": max(((b["wall_ns"] - a["wall_ns"]) / 1e9 for a, b in zip(rows, rows[1:])), default=0),
|
||||
"usable_fraction": len(valid) / len(rows) if rows else 0,
|
||||
"fps": summary([r["fps"] for r in valid]), "av_clock_error_ms": summary(av),
|
||||
"java_sound_active_samples": sum(bool(r.get("displays") and r["displays"][0].get("java_sound_running")) for r in rows),
|
||||
"openal_active_samples": sum(bool(r.get("displays") and r["displays"][0].get("audio_backend") == "openal" and r["displays"][0].get("audio_active")) for r in rows),
|
||||
"media_audio_first": rows[0].get("displays", [{}])[0].get("media_audio", {}) if rows and rows[0].get("displays") else {},
|
||||
"media_audio_last": rows[-1].get("displays", [{}])[0].get("media_audio", {}) if rows and rows[-1].get("displays") else {},
|
||||
"sound_physics_calls_first": rows[0].get("spr_calls", {}) if rows else {},
|
||||
"sound_physics_calls_last": rows[-1].get("spr_calls", {}) if rows else {},
|
||||
"probe_errors": sorted(set(r["probe_error"] for r in rows if "probe_error" in r))}
|
||||
|
||||
|
||||
def report(lab):
|
||||
clients = {}
|
||||
for name in ["client-a", "client-b"]:
|
||||
clients[name] = [json.loads(line) for line in (lab / (name + ".jsonl")).read_text().splitlines()]
|
||||
phases = sorted(set(r["phase"] for rows in clients.values() for r in rows))
|
||||
result = {"method": "16-bit frame code, 30 fps, successful GPU upload, nearest observations within 150 ms; no display-scanout or subjective listening measurement", "phases": {}}
|
||||
for phase in phases:
|
||||
selected = {name: [r for r in rows if r["phase"] == phase] for name, rows in clients.items()}
|
||||
combined = [r for rows in selected.values() for r in rows]
|
||||
start = min(r["wall_ns"] for r in combined)
|
||||
end = max(r["wall_ns"] for r in combined)
|
||||
# Keep startup results separate; never hide failed/stale sample coverage.
|
||||
stable = {name: [r for r in rows if r["wall_ns"] >= start + 15e9] for name, rows in selected.items()}
|
||||
result["phases"][phase] = {"duration_seconds": (end - start) / 1e9,
|
||||
"all_pairs_skew_ms": compare(selected["client-a"], selected["client-b"]),
|
||||
"after_15s_skew_ms": compare(stable["client-a"], stable["client-b"]),
|
||||
"clients": {name: client_summary(rows) for name, rows in selected.items()}}
|
||||
(lab / "report.json").write_text(json.dumps(result, indent=2) + "\n")
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("lab", type=Path)
|
||||
a = p.parse_args()
|
||||
r = report(a.lab)
|
||||
print(json.dumps({name: {"seconds": row["duration_seconds"], "skew_after_15s_ms": row["after_15s_skew_ms"]}
|
||||
for name, row in r["phases"].items()}, indent=2))
|
||||
+30
-2
@@ -95,6 +95,14 @@ def check():
|
||||
'Fabric API version drift')
|
||||
require(any(e['file'] == 'mods/fabric-api.pw.toml' and e.get('metafile') for e in entries),
|
||||
'Fabric API is missing from the index')
|
||||
dream = read_toml(SOURCE / 'mods/dream-displays.pw.toml')
|
||||
require(dream['side'] == 'both' and dream['filename'] ==
|
||||
'dreamdisplays-fabric-' + values['minecraft_version'] + '-' + values['dream_displays_version'] + '.jar',
|
||||
'Dream Displays platform or side drift')
|
||||
require(dream['download']['hash-format'] == 'sha512'
|
||||
and dream['download']['hash'] == values['dream_displays_sha512']
|
||||
and dream['update']['modrinth']['version'] == values['dream_displays_artifact'],
|
||||
'Dream Displays artifact drift')
|
||||
require((SOURCE / 'icon.png').read_bytes() == (ROOT / 'mods/sanctuary/src/main/resources/assets/sanctuary/icon.png').read_bytes(), 'Sanctuary mod and instance icons differ')
|
||||
print('Pack versions, icon and index hashes verified.')
|
||||
return values, entries
|
||||
@@ -126,6 +134,7 @@ def assemble(jar_url=None):
|
||||
require(not external & metadata['depends'].keys(),
|
||||
'External client mods must not be required by Sanctuary')
|
||||
embedded_jei = []
|
||||
embedded_media = []
|
||||
for nested_mod in metadata.get('jars', []):
|
||||
with zipfile.ZipFile(io.BytesIO(archive.read(nested_mod['file']))) as candidate:
|
||||
if 'fabric.mod.json' in candidate.namelist():
|
||||
@@ -134,9 +143,28 @@ def assemble(jar_url=None):
|
||||
'External client mod is still nested in Sanctuary: ' + info['id'])
|
||||
if info['id'] == 'jei':
|
||||
embedded_jei.append(archive.read(nested_mod['file']))
|
||||
if info['id'] == 'sanctuary_media':
|
||||
embedded_media.append(archive.read(nested_mod['file']))
|
||||
require(all('licenses/sound-physics-remastered/' + name in archive.namelist()
|
||||
for name in ('LICENSE', 'PROVENANCE.md')), 'Sound Physics notices missing')
|
||||
require(len(embedded_jei) == 1, 'Sanctuary must embed exactly one JEI fork')
|
||||
require(len(embedded_media) == 1, 'Sanctuary must embed exactly one Media module')
|
||||
with zipfile.ZipFile(io.BytesIO(embedded_media[0])) as media:
|
||||
info = json.loads(media.read('fabric.mod.json'))
|
||||
source_info = json.loads((ROOT / 'mods/sanctuary-media/src/main/resources/fabric.mod.json').read_text())
|
||||
require(info['version'] == source_info['version'] and info['depends']['dreamdisplays'] == values['dream_displays_version'],
|
||||
'Embedded Media version or Dream dependency drift')
|
||||
require('fr/koka/media/anchor/AnchorBlock.class' in media.namelist(), 'Media anchor missing')
|
||||
require(not any('mediaprobe' in p or p.endswith('MediaSyncTest.class') for p in media.namelist()), 'Media test code in release')
|
||||
require('LICENSE_sanctuary_media' in media.namelist(), 'Media license missing')
|
||||
dream = read_toml(SOURCE / 'mods/dream-displays.pw.toml')
|
||||
dream_jar = ROOT / 'build/external-mods' / dream['filename']
|
||||
require(dream_jar.is_file() and hashlib.sha512(dream_jar.read_bytes()).hexdigest() == values['dream_displays_sha512'],
|
||||
'Run Gradle Dream artifact verification before assembling')
|
||||
with zipfile.ZipFile(dream_jar) as artifact:
|
||||
info = json.loads(artifact.read('fabric.mod.json'))
|
||||
require(info['id'] == 'dreamdisplays' and info['version'] == values['dream_displays_version']
|
||||
and info['depends']['minecraft'] == '~' + values['minecraft_version'] + '-', 'Dream Displays metadata drift')
|
||||
with zipfile.ZipFile(io.BytesIO(embedded_jei[0])) as jei:
|
||||
info = json.loads(jei.read('fabric.mod.json'))
|
||||
require(info['id'] == 'jei' and info['version'] == values['jei_version'], 'JEI metadata drift')
|
||||
@@ -192,7 +220,7 @@ def assemble(jar_url=None):
|
||||
server_entries = []
|
||||
for entry in staged_entries:
|
||||
if entry['file'] not in ('mods/fabric-api.pw.toml', 'mods/sanctuary.pw.toml', 'mods/' + jar.name,
|
||||
'mods/lithium.pw.toml', 'mods/simple-voice-chat.pw.toml'):
|
||||
'mods/lithium.pw.toml', 'mods/simple-voice-chat.pw.toml', 'mods/dream-displays.pw.toml'):
|
||||
continue
|
||||
target = server / entry['file']
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -233,7 +261,7 @@ def verify_server(base):
|
||||
files = {entry['file'] for entry in entries}
|
||||
sanctuary = 'mods/sanctuary.pw.toml' if 'mods/sanctuary.pw.toml' in files else 'mods/sanctuary-' + values['mod_version'] + '.jar'
|
||||
require(files == {'mods/fabric-api.pw.toml', sanctuary, 'mods/lithium.pw.toml',
|
||||
'mods/simple-voice-chat.pw.toml', 'server-icon.png'},
|
||||
'mods/simple-voice-chat.pw.toml', 'mods/dream-displays.pw.toml', 'server-icon.png'},
|
||||
'Server pack must contain the shared mods and the Sanctuary server icon')
|
||||
icon = (base / 'server-icon.png').read_bytes()
|
||||
require(icon == (ROOT / 'mods/sanctuary/src/main/resources/assets/sanctuary/server-icon.png').read_bytes()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Check that sampling offsets cannot be reported as media drift or as a false pass."""
|
||||
import unittest
|
||||
from media_lab_report import compare, usable
|
||||
|
||||
|
||||
def sample(wall_ms, frame, frame_wall_ms=None):
|
||||
return {"wall_ns": wall_ms * 1_000_000, "visual_frame": frame,
|
||||
"visual_frame_wall_ns": (wall_ms if frame_wall_ms is None else frame_wall_ms) * 1_000_000,
|
||||
"connected": True, "backend": "Vulkan", "displays": [{"clock_running": True}]}
|
||||
|
||||
|
||||
class MeasurementTests(unittest.TestCase):
|
||||
def test_same_media_observed_at_different_times(self):
|
||||
self.assertAlmostEqual(compare([sample(1000, 30)], [sample(1100, 33)])["max"], 0)
|
||||
|
||||
def test_real_content_difference_is_not_hidden(self):
|
||||
self.assertAlmostEqual(compare([sample(1000, 30)], [sample(1000, 39)])["max"], 300)
|
||||
|
||||
def test_old_gpu_frame_is_not_a_sync_measurement(self):
|
||||
self.assertFalse(usable(sample(2000, 30, 1000)))
|
||||
self.assertEqual(compare([sample(2000, 30, 1000)], [sample(2000, 60)])["count"], 0)
|
||||
|
||||
def test_missing_second_client_cannot_pass(self):
|
||||
self.assertIsNone(compare([sample(1000, 30)], [])["p95"])
|
||||
|
||||
def test_future_audio_hold_is_not_playing_video(self):
|
||||
row = sample(1000, 30)
|
||||
row["displays"][0].update(audio_backend="openal", audio_active=False)
|
||||
self.assertFalse(usable(row))
|
||||
row["displays"][0]["audio_active"] = True
|
||||
self.assertTrue(usable(row))
|
||||
|
||||
def test_samples_too_far_apart_cannot_pass(self):
|
||||
self.assertEqual(compare([sample(1000, 30)], [sample(1500, 45)])["count"], 0)
|
||||
|
||||
def test_source_loop_does_not_create_a_false_ten_minute_jump(self):
|
||||
self.assertAlmostEqual(compare([sample(1000, 19799)], [sample(1000, 1)])["max"], 2000 / 30)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -15,3 +15,6 @@ project(':jei').projectDir = file('mods/jei')
|
||||
|
||||
include('sanctuary-test')
|
||||
project(':sanctuary-test').projectDir = file('mods/sanctuary-test')
|
||||
|
||||
include('sanctuary-media')
|
||||
project(':sanctuary-media').projectDir = file('mods/sanctuary-media')
|
||||
|
||||
Reference in New Issue
Block a user