Initial sanctuary sources

This commit is contained in:
cnstiout
2026-07-04 20:27:08 +02:00
commit c796a20321
1582 changed files with 253264 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Gradle
.gradle/
build/
out/
classes/
# Eclipse
*.launch
# IntelliJ IDEA
.idea/
*.iml
*.ipr
*.iws
# VS Code
.settings/
.vscode/
bin/
.classpath
.project
# macOS
*.DS_Store
# Fabric
run/
# Java
.jdk/
hs_err_*.log
replay_*.log
*.hprof
*.jfr
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

+67
View File
@@ -0,0 +1,67 @@
plugins {
id "net.fabricmc.fabric-loom" version "${loom_version}"
id "maven-publish"
}
version = project.mod_version
group = project.maven_group
repositories {
}
loom {
splitEnvironmentSourceSets()
mods {
"helloworld" {
sourceSet sourceSets.main
sourceSet sourceSets.client
}
}
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
}
processResources {
def version = project.version
inputs.property "version", version
filesMatching("fabric.mod.json") {
expand "version": version
}
}
tasks.withType(JavaCompile).configureEach {
it.options.release = 25
}
java {
withSourcesJar()
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}
jar {
def projectName = project.name
inputs.property "projectName", projectName
from("../../license.txt") {
rename { "${it}_${projectName}" }
}
}
publishing {
publications {
create("mavenJava", MavenPublication) {
from components.java
}
}
repositories {
}
}
+21
View File
@@ -0,0 +1,21 @@
# Done to increase the memory available to Gradle.
org.gradle.jvmargs=-Xmx1G
org.gradle.parallel=true
# IntelliJ IDEA is not yet fully compatible with configuration cache:
# https://github.com/FabricMC/fabric-loom/issues/1349
org.gradle.configuration-cache=false
# Fabric properties
# Check current versions on https://fabricmc.net/develop
minecraft_version=26.1.2
loader_version=0.19.2
loom_version=1.16-SNAPSHOT
# Mod properties
mod_version=1.0.0
maven_group=fr.koka99cab
archives_base_name=helloworld
# Dependencies
fabric_api_version=0.148.0+26.1.2
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+253
View File
@@ -0,0 +1,253 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
if [ -z "$JAVA_HOME" ] && [ -x "$APP_HOME/.jdk/current/bin/java" ] ; then
JAVA_HOME=$APP_HOME/.jdk/current
export JAVA_HOME
fi
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+12
View File
@@ -0,0 +1,12 @@
pluginManagement {
repositories {
maven {
name = "Fabric"
url = "https://maven.fabricmc.net/"
}
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "helloworld"
@@ -0,0 +1,13 @@
package fr.koka99cab.helloworld.client;
import fr.koka99cab.helloworld.HelloWorldMod;
import fr.koka99cab.helloworld.client.rpc.SanctuaryDiscordPresence;
import net.fabricmc.api.ClientModInitializer;
public class HelloWorldClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
HelloWorldMod.LOGGER.info("helloworld client initialise.");
SanctuaryDiscordPresence.initialize();
}
}
@@ -0,0 +1,27 @@
package fr.koka99cab.helloworld.client.loading;
import fr.koka99cab.helloworld.HelloWorldMod;
import net.minecraft.resources.Identifier;
public final class LoadingScreenVisuals {
public static final Identifier LOADING_SHEET =
Identifier.fromNamespaceAndPath(HelloWorldMod.MOD_ID, "textures/gui/loading_sheet.png");
public static final String LOADING_SHEET_RESOURCE = "/assets/helloworld/textures/gui/loading_sheet.png";
public static final int BACKGROUND_ARGB = 0xFF050505;
public static final int FRAME_WIDTH = 750;
public static final int FRAME_HEIGHT = 750;
public static final int FRAME_COUNT = 11;
public static final int SHEET_WIDTH = FRAME_WIDTH * FRAME_COUNT;
private static int currentFrameIndex;
private LoadingScreenVisuals() {
}
public static int currentFrameU() {
return currentFrameIndex * FRAME_WIDTH;
}
public static void advanceFrame() {
currentFrameIndex = (currentFrameIndex + 1) % FRAME_COUNT;
}
}
@@ -0,0 +1,30 @@
package fr.koka99cab.helloworld.client.loading;
import com.mojang.blaze3d.platform.NativeImage;
import java.io.IOException;
import java.io.InputStream;
import net.minecraft.client.renderer.texture.MipmapStrategy;
import net.minecraft.client.renderer.texture.ReloadableTexture;
import net.minecraft.client.renderer.texture.TextureContents;
import net.minecraft.client.resources.metadata.texture.TextureMetadataSection;
import net.minecraft.server.packs.resources.ResourceManager;
public final class LoadingSheetTexture extends ReloadableTexture {
private static final TextureMetadataSection METADATA =
new TextureMetadataSection(true, true, MipmapStrategy.MEAN, 0.0F);
public LoadingSheetTexture() {
super(LoadingScreenVisuals.LOADING_SHEET);
}
@Override
public TextureContents loadContents(ResourceManager resourceManager) throws IOException {
try (InputStream inputStream = LoadingSheetTexture.class.getResourceAsStream(LoadingScreenVisuals.LOADING_SHEET_RESOURCE)) {
if (inputStream == null) {
throw new IOException("Missing loading sheet resource: " + LoadingScreenVisuals.LOADING_SHEET_RESOURCE);
}
return new TextureContents(NativeImage.read(inputStream), METADATA);
}
}
}
@@ -0,0 +1,124 @@
package fr.koka99cab.helloworld.client.mixin;
import com.mojang.blaze3d.pipeline.RenderPipeline;
import fr.koka99cab.helloworld.client.loading.LoadingSheetTexture;
import fr.koka99cab.helloworld.client.loading.LoadingScreenVisuals;
import java.util.function.IntSupplier;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.LoadingOverlay;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.TextureManager;
import net.minecraft.resources.Identifier;
import net.minecraft.util.ARGB;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Mutable;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(LoadingOverlay.class)
public abstract class LoadingOverlayMixin {
@Shadow
@Final
@Mutable
private static IntSupplier BRAND_BACKGROUND;
@Inject(method = "<clinit>", at = @At("TAIL"))
private static void helloworld$setLoadingBackground(CallbackInfo ci) {
BRAND_BACKGROUND = () -> LoadingScreenVisuals.BACKGROUND_ARGB;
}
@Inject(method = "registerTextures", at = @At("TAIL"))
private static void helloworld$registerLoadingSheet(TextureManager textureManager, CallbackInfo ci) {
textureManager.registerAndLoad(LoadingScreenVisuals.LOADING_SHEET, new LoadingSheetTexture());
}
@Inject(method = "tick", at = @At("HEAD"))
private void helloworld$advanceLoadingAnimation(CallbackInfo ci) {
LoadingScreenVisuals.advanceFrame();
}
@Redirect(
method = "extractRenderState",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;blit(Lcom/mojang/blaze3d/pipeline/RenderPipeline;Lnet/minecraft/resources/Identifier;IIFFIIIIIII)V",
ordinal = 0
)
)
private void helloworld$drawLoadingChicken(
GuiGraphicsExtractor graphics,
RenderPipeline pipeline,
Identifier texture,
int x,
int y,
float u,
float v,
int width,
int height,
int regionWidth,
int regionHeight,
int textureWidth,
int textureHeight,
int color
) {
int drawSize = width;
int drawX = x + width - drawSize / 2;
int drawY = y - Math.max(0, (drawSize - height) / 2);
graphics.blit(
RenderPipelines.GUI_TEXTURED,
LoadingScreenVisuals.LOADING_SHEET,
drawX,
drawY,
LoadingScreenVisuals.currentFrameU(),
0.0F,
drawSize,
drawSize,
LoadingScreenVisuals.FRAME_WIDTH,
LoadingScreenVisuals.FRAME_HEIGHT,
LoadingScreenVisuals.SHEET_WIDTH,
LoadingScreenVisuals.FRAME_HEIGHT,
color
);
}
@Redirect(
method = "extractRenderState",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;blit(Lcom/mojang/blaze3d/pipeline/RenderPipeline;Lnet/minecraft/resources/Identifier;IIFFIIIIIII)V",
ordinal = 1
)
)
private void helloworld$skipSecondVanillaLogoHalf(
GuiGraphicsExtractor graphics,
RenderPipeline pipeline,
Identifier texture,
int x,
int y,
float u,
float v,
int width,
int height,
int regionWidth,
int regionHeight,
int textureWidth,
int textureHeight,
int color
) {
}
@Redirect(
method = "extractProgressBar",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/util/ARGB;color(IIII)I"
)
)
private int helloworld$whiteProgressBar(int alpha, int red, int green, int blue) {
return ARGB.color(alpha, 255, 255, 255);
}
}
@@ -0,0 +1,17 @@
package fr.koka99cab.helloworld.client.mixin;
import fr.koka99cab.helloworld.client.title.CustomTitleIconClient;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(Minecraft.class)
public abstract class MinecraftClientMixin {
@Inject(method = "run", at = @At("HEAD"))
private void helloworld$applyWindowTitleAndIcon(CallbackInfo callbackInfo) {
Minecraft minecraft = (Minecraft) (Object) this;
CustomTitleIconClient.apply(minecraft.getWindow());
}
}
@@ -0,0 +1,155 @@
package fr.koka99cab.helloworld.client.mixin;
import fr.koka99cab.helloworld.motd.AnimationPayload;
import fr.koka99cab.helloworld.motd.GifFrameData;
import fr.koka99cab.helloworld.motd.GifIconLoader;
import fr.koka99cab.helloworld.motd.MotdPlusPlusMod;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.FaviconTexture;
import net.minecraft.client.multiplayer.ServerData;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.Style;
import net.minecraft.util.Util;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.gen.Invoker;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import java.util.List;
import java.util.Objects;
@Mixin(targets = "net.minecraft.client.gui.screens.multiplayer.ServerSelectionList$OnlineServerEntry")
public abstract class OnlineServerEntryMixin {
@Shadow @Final private ServerData serverData;
@Shadow @Final private FaviconTexture icon;
@Unique
private String motdpp$lastPayloadInsertion;
@Unique
private AnimationPayload motdpp$payload;
@Unique
private List<GifFrameData> motdpp$iconFrames = List.of();
@Unique
private long motdpp$receivedAtMillis;
@Unique
private byte[] motdpp$lastRenderedIconBytes;
@Invoker("uploadServerIcon")
abstract boolean motdpp$uploadServerIcon(byte[] bytes);
@Inject(method = "extractContent", at = @At("HEAD"))
private void motdpp$animateLocally(GuiGraphicsExtractor graphics, int mouseX, int mouseY, boolean hovered, float delta, CallbackInfo ci) {
this.motdpp$tryDecodePayload();
if (this.motdpp$payload == null) {
return;
}
long now = Util.getMillis();
long elapsedTicks = this.motdpp$payload.tickOffset + Math.max(0L, (now - this.motdpp$receivedAtMillis) / 50L);
this.serverData.motd = Component.literal(this.motdpp$frameAt(elapsedTicks))
.withStyle(Style.EMPTY.withInsertion(this.motdpp$lastPayloadInsertion));
byte[] iconBytes = this.motdpp$iconBytesAt(elapsedTicks);
if (iconBytes != null && !java.util.Arrays.equals(this.motdpp$lastRenderedIconBytes, iconBytes)) {
if (this.motdpp$uploadServerIcon(iconBytes)) {
this.motdpp$lastRenderedIconBytes = iconBytes.clone();
}
}
}
@Unique
private void motdpp$tryDecodePayload() {
String insertion = this.serverData.motd == null ? null : this.serverData.motd.getStyle().getInsertion();
if (Objects.equals(this.motdpp$lastPayloadInsertion, insertion)) {
return;
}
this.motdpp$lastPayloadInsertion = insertion;
this.motdpp$payload = AnimationPayload.decode(insertion, MotdPlusPlusMod.LOGGER);
this.motdpp$receivedAtMillis = Util.getMillis();
this.motdpp$lastRenderedIconBytes = null;
this.motdpp$iconFrames = this.motdpp$payload == null
? List.of()
: GifIconLoader.load(this.motdpp$payload.gifBytes(), MotdPlusPlusMod.LOGGER);
}
@Unique
private String motdpp$frameAt(long elapsedTicks) {
List<String> frames = this.motdpp$payload.motdFrames == null || this.motdpp$payload.motdFrames.isEmpty()
? List.of("sanctuary")
: this.motdpp$payload.motdFrames;
String text = this.motdpp$payload.scrollText == null ? "" : this.motdpp$payload.scrollText;
if ("frames".equals(this.motdpp$payload.normalizedMode()) || (text.isBlank() && !frames.isEmpty())) {
int frameIndex = (int) ((elapsedTicks / this.motdpp$payload.normalizedMotdSpeedTicks()) % frames.size());
String frame = frames.get(frameIndex);
return frame.isBlank() ? "sanctuary" : frame;
}
String padding = this.motdpp$payload.scrollPadding == null ? " " : this.motdpp$payload.scrollPadding;
int width = Math.max(1, this.motdpp$payload.scrollWindowWidth);
if (text.isBlank()) {
text = String.join(" | ", frames);
}
int[] codePoints = (text + padding).codePoints().toArray();
if (codePoints.length == 0) {
return frames.getFirst();
}
int offset = (int) ((elapsedTicks / this.motdpp$payload.normalizedMotdSpeedTicks()) % codePoints.length);
StringBuilder builder = new StringBuilder();
for (int index = 0; index < width; index++) {
builder.appendCodePoint(codePoints[(offset + index) % codePoints.length]);
}
String frame = builder.toString();
return frame.isBlank() ? frames.getFirst() : frame;
}
@Unique
private byte[] motdpp$iconBytesAt(long elapsedTicks) {
if (this.motdpp$iconFrames.isEmpty()) {
return null;
}
if (this.motdpp$iconFrames.size() == 1) {
return this.motdpp$iconFrames.getFirst().pngBytes();
}
long cycleDuration = this.motdpp$iconFrames.stream()
.mapToLong(GifFrameData::durationTicks)
.sum();
if (cycleDuration <= 0L) {
return this.motdpp$iconFrames.getFirst().pngBytes();
}
long tickInCycle = elapsedTicks % cycleDuration;
long cursor = 0L;
for (GifFrameData frame : this.motdpp$iconFrames) {
cursor += frame.durationTicks();
if (tickInCycle < cursor) {
return frame.pngBytes();
}
}
return this.motdpp$iconFrames.getLast().pngBytes();
}
}
@@ -0,0 +1,89 @@
package fr.koka99cab.helloworld.client.mixin;
import java.util.function.Supplier;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.layouts.FrameLayout;
import net.minecraft.client.gui.layouts.GridLayout;
import net.minecraft.client.gui.screens.PauseScreen;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.ShareToLanScreen;
import net.minecraft.client.gui.screens.options.OptionsScreen;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.network.chat.CommonComponents;
import net.minecraft.network.chat.Component;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(PauseScreen.class)
public abstract class PauseScreenMixin extends Screen {
private static final int BUTTON_WIDTH = 204;
@Shadow
private Button disconnectButton;
protected PauseScreenMixin(Component title) {
super(title);
}
@Inject(method = "createPauseMenu", at = @At("HEAD"), cancellable = true)
private void helloworld$createCompactPauseMenu(CallbackInfo ci) {
GridLayout layout = new GridLayout();
layout.defaultCellSetting().padding(4, 4, 4, 0);
GridLayout.RowHelper rowHelper = layout.createRowHelper(1);
rowHelper.addChild(
Button.builder(Component.translatable("menu.returnToGame"), button -> this.helloworld$returnToGame())
.width(BUTTON_WIDTH)
.build(),
1,
layout.newCellSettings().paddingTop(50)
);
rowHelper.addChild(this.helloworld$screenButton(
Component.translatable("menu.options"),
() -> new OptionsScreen((Screen) (Object) this, this.minecraft.options, true)
));
if (this.minecraft.hasSingleplayerServer() && !this.minecraft.getSingleplayerServer().isPublished()) {
rowHelper.addChild(this.helloworld$screenButton(
Component.translatable("menu.shareToLan"),
() -> new ShareToLanScreen((Screen) (Object) this)
));
}
Button quitButton = Button.builder(
CommonComponents.disconnectButtonLabel(this.minecraft.isLocalServer()),
this::helloworld$handleDisconnect
).width(BUTTON_WIDTH).build();
this.disconnectButton = quitButton;
rowHelper.addChild(quitButton);
layout.arrangeElements();
FrameLayout.alignInRectangle(layout, 0, 0, this.width, this.height, 0.5F, 0.25F);
layout.visitChildren(element -> element.visitWidgets(this::addRenderableWidget));
ci.cancel();
}
private Button helloworld$screenButton(Component label, Supplier<Screen> screenSupplier) {
return Button.builder(label, button -> this.minecraft.setScreen(screenSupplier.get()))
.width(BUTTON_WIDTH)
.build();
}
private void helloworld$handleDisconnect(Button button) {
button.active = false;
this.minecraft.getReportingContext().draftReportHandled(
this.minecraft,
(Screen) (Object) this,
() -> this.minecraft.disconnectFromWorld(ClientLevel.DEFAULT_QUIT_MESSAGE),
true
);
}
private void helloworld$returnToGame() {
this.minecraft.setScreen(null);
this.minecraft.mouseHandler.grabMouse();
}
}
@@ -0,0 +1,152 @@
package fr.koka99cab.helloworld.client.mixin;
import com.mojang.realmsclient.gui.screens.RealmsNotificationsScreen;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.client.gui.components.AbstractWidget;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.SpriteIconButton;
import net.minecraft.client.gui.components.events.GuiEventListener;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.TitleScreen;
import net.minecraft.network.chat.Component;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.ModifyArg;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(TitleScreen.class)
public abstract class TitleScreenMixin extends Screen {
private static final int SECONDARY_ROW_MIN_X_OFFSET = 130;
private static final int SECONDARY_ROW_MAX_X_OFFSET = 130;
private static final int OPTIONS_SECTION_GAP = 36;
private static final String LICENSE_TEXT = "sanctuary © KOKA99CAB - LGPL-3.0-or-later";
private static final int FOOTER_TEXT_COLOR = 0xFFFFFFFF;
@Shadow
private RealmsNotificationsScreen realmsNotificationsScreen;
protected TitleScreenMixin(Component title) {
super(title);
}
@Inject(method = "init", at = @At("TAIL"))
private void helloworld$compactTitleMenu(CallbackInfo ci) {
this.realmsNotificationsScreen = null;
if (this.minecraft.isDemo()) {
return;
}
Button realmsButton = null;
AbstractWidget singleplayerButton = null;
AbstractWidget multiplayerButton = null;
AbstractWidget optionsButton = null;
AbstractWidget quitButton = null;
String singleplayerText = Component.translatable("menu.singleplayer").getString();
String multiplayerText = Component.translatable("menu.multiplayer").getString();
String realmsText = Component.translatable("menu.online").getString();
String optionsText = Component.translatable("menu.options").getString();
String quitText = Component.translatable("menu.quit").getString();
for (GuiEventListener child : this.children()) {
if (!(child instanceof AbstractWidget widget)) {
continue;
}
String widgetText = widget.getMessage().getString();
if (realmsButton == null && child instanceof Button button && widgetText.equals(realmsText)) {
realmsButton = button;
}
if (singleplayerButton == null && widgetText.equals(singleplayerText)) {
singleplayerButton = widget;
}
if (multiplayerButton == null && widgetText.equals(multiplayerText)) {
multiplayerButton = widget;
}
if (optionsButton == null && widgetText.equals(optionsText)) {
optionsButton = widget;
}
if (quitButton == null && widgetText.equals(quitText)) {
quitButton = widget;
}
}
if (realmsButton != null) {
this.removeWidget(realmsButton);
}
if (singleplayerButton == null || multiplayerButton == null || optionsButton == null || quitButton == null) {
return;
}
int centerX = this.width / 2;
int secondaryRowY = optionsButton.getY();
List<AbstractWidget> widgetsToRemove = new ArrayList<>();
for (GuiEventListener child : this.children()) {
if (!(child instanceof AbstractWidget widget)) {
continue;
}
if (widget.getY() != secondaryRowY) {
continue;
}
int widgetX = widget.getX();
if (widgetX < centerX - SECONDARY_ROW_MIN_X_OFFSET || widgetX > centerX + SECONDARY_ROW_MAX_X_OFFSET) {
continue;
}
if (widget instanceof SpriteIconButton) {
widgetsToRemove.add(widget);
}
}
for (AbstractWidget widget : widgetsToRemove) {
this.removeWidget(widget);
}
this.helloworld$restoreMenuSpacing(singleplayerButton, multiplayerButton, optionsButton, quitButton);
}
@ModifyArg(
method = "extractRenderState",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;text(Lnet/minecraft/client/gui/Font;Ljava/lang/String;III)V"
),
index = 1
)
private String helloworld$replaceVersionText(String original) {
return "";
}
@Inject(method = "extractRenderState", at = @At("TAIL"))
private void helloworld$renderLicenseNotice(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta, CallbackInfo ci) {
graphics.text(this.font, LICENSE_TEXT, 2, Math.max(2, this.height - 10), FOOTER_TEXT_COLOR);
}
private void helloworld$restoreMenuSpacing(
AbstractWidget singleplayerButton,
AbstractWidget multiplayerButton,
AbstractWidget optionsButton,
AbstractWidget quitButton
) {
int centerX = this.width / 2;
int baseY = this.height / 4 + 48;
int optionsRowY = baseY + 24 + OPTIONS_SECTION_GAP;
singleplayerButton.setPosition(centerX - 100, baseY);
multiplayerButton.setPosition(centerX - 100, baseY + 24);
optionsButton.setPosition(centerX - 100, optionsRowY);
quitButton.setPosition(centerX + 2, optionsRowY);
}
}
@@ -0,0 +1,21 @@
package fr.koka99cab.helloworld.client.mixin;
import fr.koka99cab.helloworld.client.title.CustomTitleIconClient;
import org.lwjgl.glfw.GLFW;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(com.mojang.blaze3d.platform.Window.class)
public abstract class WindowMixin {
@Shadow
public abstract long handle();
@Inject(method = "setTitle", at = @At("HEAD"), cancellable = true)
private void helloworld$forceCustomTitle(String title, CallbackInfo callbackInfo) {
GLFW.glfwSetWindowTitle(this.handle(), CustomTitleIconClient.windowTitle());
callbackInfo.cancel();
}
}
@@ -0,0 +1,43 @@
package fr.koka99cab.helloworld.client.rpc;
import fr.koka99cab.helloworld.HelloWorldMod;
import fr.koka99cab.helloworld.client.rpc.config.SanctuaryRpcConfig;
import fr.koka99cab.helloworld.client.rpc.discord.DiscordActivity;
import fr.koka99cab.helloworld.client.rpc.discord.DiscordIpcClient;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
public final class SanctuaryDiscordPresence {
private static DiscordIpcClient discordIpcClient;
private SanctuaryDiscordPresence() {
}
public static void initialize() {
SanctuaryRpcConfig config = SanctuaryRpcConfig.load();
if (!config.enabled()) {
HelloWorldMod.LOGGER.info("Discord RPC Sanctuary desactive dans la configuration.");
return;
}
if (!config.hasClientId()) {
HelloWorldMod.LOGGER.warn("Sanctuary RPC attend un clientId Discord dans config/sanctuary-rpc.properties.");
return;
}
discordIpcClient = new DiscordIpcClient(
config.clientId(),
DiscordActivity.fromConfig(config),
HelloWorldMod.LOGGER
);
discordIpcClient.start();
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> stop());
}
private static void stop() {
if (discordIpcClient != null) {
discordIpcClient.stop();
discordIpcClient = null;
}
}
}
@@ -0,0 +1,115 @@
package fr.koka99cab.helloworld.client.rpc.config;
import fr.koka99cab.helloworld.HelloWorldMod;
import net.fabricmc.loader.api.FabricLoader;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
public record SanctuaryRpcConfig(
boolean enabled,
String clientId,
String details,
String state,
String largeImage,
String largeText,
String smallImage,
String smallText,
boolean showElapsedTime
) {
private static final String CONFIG_FILE = "sanctuary-rpc.properties";
private static final String DEFAULT_CLIENT_ID = "1522671140735025182";
private static final String OLD_CLIENT_ID_PLACEHOLDER = "put-your-discord-application-id-here";
public static SanctuaryRpcConfig load() {
Path path = FabricLoader.getInstance().getConfigDir().resolve(CONFIG_FILE);
Properties properties = defaultProperties();
boolean shouldSave = false;
try {
if (Files.notExists(path)) {
writeDefaults(path, properties);
} else {
try (Reader reader = Files.newBufferedReader(path)) {
properties.load(reader);
}
shouldSave = migrate(properties);
}
} catch (IOException exception) {
HelloWorldMod.LOGGER.warn("Impossible de lire la configuration Sanctuary RPC, valeurs par defaut utilisees.", exception);
}
if (shouldSave) {
try {
writeDefaults(path, properties);
} catch (IOException exception) {
HelloWorldMod.LOGGER.warn("Impossible de mettre a jour la configuration Sanctuary RPC.", exception);
}
}
return new SanctuaryRpcConfig(
Boolean.parseBoolean(properties.getProperty("enabled")),
properties.getProperty("clientId", "").trim(),
properties.getProperty("details", "").trim(),
properties.getProperty("state", "").trim(),
properties.getProperty("largeImage", "").trim(),
properties.getProperty("largeText", "").trim(),
properties.getProperty("smallImage", "").trim(),
properties.getProperty("smallText", "").trim(),
Boolean.parseBoolean(properties.getProperty("showElapsedTime"))
);
}
public boolean hasClientId() {
return !clientId.isBlank();
}
private static Properties defaultProperties() {
Properties properties = new Properties();
properties.setProperty("enabled", "true");
properties.setProperty("clientId", DEFAULT_CLIENT_ID);
properties.setProperty("details", "Joue a Sanctuary");
properties.setProperty("state", "Minecraft 26.1.2 - Fabric");
properties.setProperty("largeImage", "");
properties.setProperty("largeText", "Sanctuary");
properties.setProperty("smallImage", "");
properties.setProperty("smallText", "");
properties.setProperty("showElapsedTime", "true");
return properties;
}
private static boolean migrate(Properties properties) {
boolean changed = false;
String clientId = properties.getProperty("clientId", "").trim();
if (clientId.isBlank() || OLD_CLIENT_ID_PLACEHOLDER.equals(clientId)) {
properties.setProperty("clientId", DEFAULT_CLIENT_ID);
changed = true;
}
for (String key : defaultProperties().stringPropertyNames()) {
if (!properties.containsKey(key)) {
properties.setProperty(key, defaultProperties().getProperty(key));
changed = true;
}
}
return changed;
}
private static void writeDefaults(Path path, Properties properties) throws IOException {
Files.createDirectories(path.getParent());
try (Writer writer = Files.newBufferedWriter(path)) {
writer.write("# Sanctuary RPC\n");
writer.write("# clientId doit etre l'identifiant d'une application Discord Developer Portal.\n");
writer.write("# Pour afficher une image, ajoute un asset Rich Presence nomme comme largeImage.\n");
properties.store(writer, null);
}
}
}
@@ -0,0 +1,60 @@
package fr.koka99cab.helloworld.client.rpc.discord;
import com.google.gson.JsonObject;
import fr.koka99cab.helloworld.client.rpc.config.SanctuaryRpcConfig;
import java.time.Instant;
public record DiscordActivity(
String details,
String state,
String largeImage,
String largeText,
String smallImage,
String smallText,
boolean showElapsedTime,
long startedAt
) {
public static DiscordActivity fromConfig(SanctuaryRpcConfig config) {
return new DiscordActivity(
config.details(),
config.state(),
config.largeImage(),
config.largeText(),
config.smallImage(),
config.smallText(),
config.showElapsedTime(),
Instant.now().getEpochSecond()
);
}
public JsonObject toJson() {
JsonObject activity = new JsonObject();
putIfNotBlank(activity, "details", details);
putIfNotBlank(activity, "state", state);
if (showElapsedTime) {
JsonObject timestamps = new JsonObject();
timestamps.addProperty("start", startedAt);
activity.add("timestamps", timestamps);
}
JsonObject assets = new JsonObject();
putIfNotBlank(assets, "large_image", largeImage);
putIfNotBlank(assets, "large_text", largeText);
putIfNotBlank(assets, "small_image", smallImage);
putIfNotBlank(assets, "small_text", smallText);
if (assets.size() > 0) {
activity.add("assets", assets);
}
return activity;
}
private static void putIfNotBlank(JsonObject json, String key, String value) {
if (value != null && !value.isBlank()) {
json.addProperty(key, value);
}
}
}
@@ -0,0 +1,243 @@
package fr.koka99cab.helloworld.client.rpc.discord;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.slf4j.Logger;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
public final class DiscordIpcClient {
private static final long RECONNECT_DELAY_MILLIS = 15_000L;
private static final long ACTIVITY_REFRESH_MILLIS = 30_000L;
private final String clientId;
private final DiscordActivity activity;
private final Logger logger;
private final AtomicBoolean running = new AtomicBoolean();
private volatile DiscordIpcConnection connection;
private volatile String lastActivityNonce;
private Thread worker;
private boolean missingDiscordLogged;
private boolean activityAcceptedLogged;
public DiscordIpcClient(String clientId, DiscordActivity activity, Logger logger) {
this.clientId = clientId;
this.activity = activity;
this.logger = logger;
}
public void start() {
if (!running.compareAndSet(false, true)) {
return;
}
worker = new Thread(this::run, "Sanctuary Discord RPC");
worker.setDaemon(true);
worker.start();
}
public void stop() {
if (!running.compareAndSet(true, false)) {
return;
}
DiscordIpcConnection activeConnection = connection;
if (activeConnection != null) {
try {
clearActivity(activeConnection);
} catch (IOException exception) {
logger.debug("Impossible d'effacer la presence Discord.", exception);
}
try {
activeConnection.close();
} catch (IOException exception) {
logger.debug("Impossible de fermer la connexion Discord IPC.", exception);
}
}
Thread activeWorker = worker;
if (activeWorker != null) {
activeWorker.interrupt();
}
}
private void run() {
while (running.get()) {
try (DiscordIpcConnection activeConnection = DiscordIpcConnection.open()) {
connection = activeConnection;
handshake(activeConnection);
setActivity(activeConnection);
missingDiscordLogged = false;
logger.info("Presence Discord Sanctuary active via {}.", activeConnection.endpoint());
AtomicBoolean refreshRunning = new AtomicBoolean(true);
Thread refreshWorker = startRefreshWorker(activeConnection, refreshRunning);
try {
readLoop(activeConnection);
} finally {
refreshRunning.set(false);
refreshWorker.interrupt();
}
} catch (IOException exception) {
logConnectionFailure(exception);
} finally {
connection = null;
}
sleepBeforeReconnect();
}
}
private void handshake(DiscordIpcConnection activeConnection) throws IOException {
JsonObject payload = new JsonObject();
payload.addProperty("v", 1);
payload.addProperty("client_id", clientId);
activeConnection.writeFrame(DiscordIpcConnection.OPCODE_HANDSHAKE, payload);
}
private void setActivity(DiscordIpcConnection activeConnection) throws IOException {
JsonObject args = new JsonObject();
args.addProperty("pid", ProcessHandle.current().pid());
args.add("activity", activity.toJson());
JsonObject payload = new JsonObject();
payload.addProperty("cmd", "SET_ACTIVITY");
payload.add("args", args);
String nonce = UUID.randomUUID().toString();
payload.addProperty("nonce", nonce);
lastActivityNonce = nonce;
activeConnection.writeFrame(DiscordIpcConnection.OPCODE_FRAME, payload);
}
private void clearActivity(DiscordIpcConnection activeConnection) throws IOException {
JsonObject args = new JsonObject();
args.addProperty("pid", ProcessHandle.current().pid());
args.add("activity", null);
JsonObject payload = new JsonObject();
payload.addProperty("cmd", "SET_ACTIVITY");
payload.add("args", args);
payload.addProperty("nonce", UUID.randomUUID().toString());
activeConnection.writeFrame(DiscordIpcConnection.OPCODE_FRAME, payload);
}
private void readLoop(DiscordIpcConnection activeConnection) throws IOException {
while (running.get()) {
DiscordIpcConnection.IpcFrame frame = activeConnection.readFrame();
if (frame == null || frame.opcode() == DiscordIpcConnection.OPCODE_CLOSE) {
return;
}
if (frame.opcode() == DiscordIpcConnection.OPCODE_PING) {
activeConnection.writeFrame(DiscordIpcConnection.OPCODE_PONG, frame.payload());
continue;
}
if (frame.opcode() == DiscordIpcConnection.OPCODE_FRAME) {
handlePayload(frame.payload());
}
}
}
private Thread startRefreshWorker(DiscordIpcConnection activeConnection, AtomicBoolean refreshRunning) {
Thread refreshWorker = new Thread(() -> {
while (running.get() && refreshRunning.get()) {
try {
Thread.sleep(ACTIVITY_REFRESH_MILLIS);
if (running.get() && refreshRunning.get()) {
setActivity(activeConnection);
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
} catch (IOException exception) {
logger.debug("Impossible de rafraichir la presence Discord.", exception);
refreshRunning.set(false);
}
}
}, "Sanctuary Discord RPC refresh");
refreshWorker.setDaemon(true);
refreshWorker.start();
return refreshWorker;
}
private void handlePayload(String payload) {
try {
JsonObject json = JsonParser.parseString(payload).getAsJsonObject();
String command = stringMember(json, "cmd");
String event = stringMember(json, "evt");
String nonce = stringMember(json, "nonce");
if ("ERROR".equals(command)) {
JsonObject data = objectMember(json, "data");
String message = data == null ? payload : stringMember(data, "message");
logger.warn("Discord RPC a renvoye une erreur: {}", message == null ? payload : message);
} else if ("DISPATCH".equals(command) && "READY".equals(event)) {
logger.debug("Discord RPC pret.");
} else if ("SET_ACTIVITY".equals(command) && nonce != null && nonce.equals(lastActivityNonce)) {
logActivityAccepted(json);
}
} catch (RuntimeException exception) {
logger.debug("Payload Discord RPC ignore.", exception);
}
}
private void logActivityAccepted(JsonObject payload) {
if (activityAcceptedLogged) {
logger.debug("Discord RPC confirme le rafraichissement de l'activite.");
return;
}
JsonObject data = objectMember(payload, "data");
String name = data == null ? null : stringMember(data, "name");
String details = data == null ? null : stringMember(data, "details");
String state = data == null ? null : stringMember(data, "state");
logger.info(
"Discord RPC confirme l'activite: name={}, details={}, state={}.",
name == null ? "?" : name,
details == null ? "?" : details,
state == null ? "?" : state
);
activityAcceptedLogged = true;
}
private void logConnectionFailure(IOException exception) {
if (!running.get()) {
return;
}
if (!missingDiscordLogged) {
logger.warn(exception.getMessage());
missingDiscordLogged = true;
} else {
logger.debug("Reconnexion Discord RPC en attente.", exception);
}
}
private void sleepBeforeReconnect() {
if (!running.get()) {
return;
}
try {
Thread.sleep(RECONNECT_DELAY_MILLIS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
}
private static JsonObject objectMember(JsonObject json, String memberName) {
JsonElement element = json.get(memberName);
return element != null && element.isJsonObject() ? element.getAsJsonObject() : null;
}
private static String stringMember(JsonObject json, String memberName) {
JsonElement element = json.get(memberName);
return element != null && element.isJsonPrimitive() ? element.getAsString() : null;
}
}
@@ -0,0 +1,208 @@
package fr.koka99cab.helloworld.client.rpc.discord;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.io.Closeable;
import java.io.IOException;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.ByteChannel;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
final class DiscordIpcConnection implements Closeable {
static final int OPCODE_HANDSHAKE = 0;
static final int OPCODE_FRAME = 1;
static final int OPCODE_CLOSE = 2;
static final int OPCODE_PING = 3;
static final int OPCODE_PONG = 4;
private static final Gson GSON = new Gson();
private static final int MAX_FRAME_SIZE = 1024 * 1024;
private final ByteChannel channel;
private final String endpoint;
private DiscordIpcConnection(ByteChannel channel, String endpoint) {
this.channel = channel;
this.endpoint = endpoint;
}
static DiscordIpcConnection open() throws IOException {
if (isWindows()) {
return openWindowsPipe();
}
return openUnixSocket();
}
synchronized void writeFrame(int opcode, JsonObject payload) throws IOException {
writeFrame(opcode, GSON.toJson(payload));
}
synchronized void writeFrame(int opcode, String payload) throws IOException {
byte[] payloadBytes = payload.getBytes(StandardCharsets.UTF_8);
ByteBuffer buffer = ByteBuffer.allocate(8 + payloadBytes.length).order(ByteOrder.LITTLE_ENDIAN);
buffer.putInt(opcode);
buffer.putInt(payloadBytes.length);
buffer.put(payloadBytes);
buffer.flip();
writeFully(buffer);
}
IpcFrame readFrame() throws IOException {
ByteBuffer header = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);
if (!readFully(header)) {
return null;
}
header.flip();
int opcode = header.getInt();
int length = header.getInt();
if (length < 0 || length > MAX_FRAME_SIZE) {
throw new IOException("Discord IPC frame invalide: " + length + " octets.");
}
ByteBuffer payload = ByteBuffer.allocate(length);
if (!readFully(payload)) {
return null;
}
payload.flip();
return new IpcFrame(opcode, StandardCharsets.UTF_8.decode(payload).toString());
}
String endpoint() {
return endpoint;
}
@Override
public void close() throws IOException {
channel.close();
}
private void writeFully(ByteBuffer buffer) throws IOException {
while (buffer.hasRemaining()) {
int written = channel.write(buffer);
if (written == 0) {
Thread.onSpinWait();
}
}
}
private boolean readFully(ByteBuffer buffer) throws IOException {
while (buffer.hasRemaining()) {
int read = channel.read(buffer);
if (read < 0) {
return false;
}
if (read == 0) {
Thread.onSpinWait();
}
}
return true;
}
private static DiscordIpcConnection openUnixSocket() throws IOException {
IOException lastException = null;
for (Path candidate : unixCandidates()) {
if (!Files.exists(candidate)) {
continue;
}
SocketChannel socket = null;
try {
socket = SocketChannel.open(StandardProtocolFamily.UNIX);
socket.connect(UnixDomainSocketAddress.of(candidate));
return new DiscordIpcConnection(socket, candidate.toString());
} catch (IOException exception) {
lastException = exception;
if (socket != null) {
socket.close();
}
}
}
throw missingDiscordException(lastException);
}
private static DiscordIpcConnection openWindowsPipe() throws IOException {
IOException lastException = null;
for (int index = 0; index < 10; index++) {
String endpoint = "\\\\.\\pipe\\discord-ipc-" + index;
try {
FileChannel channel = FileChannel.open(
Path.of(endpoint),
StandardOpenOption.READ,
StandardOpenOption.WRITE
);
return new DiscordIpcConnection(channel, endpoint);
} catch (IOException exception) {
lastException = exception;
}
}
throw missingDiscordException(lastException);
}
private static List<Path> unixCandidates() {
List<Path> candidates = new ArrayList<>();
for (Path runtimeDirectory : unixRuntimeDirectories()) {
for (int index = 0; index < 10; index++) {
candidates.add(runtimeDirectory.resolve("discord-ipc-" + index));
candidates.add(runtimeDirectory.resolve("snap.discord/discord-ipc-" + index));
candidates.add(runtimeDirectory.resolve("app/com.discordapp.Discord/discord-ipc-" + index));
}
}
return candidates;
}
private static Set<Path> unixRuntimeDirectories() {
Set<Path> directories = new LinkedHashSet<>();
addEnvironmentPath(directories, "XDG_RUNTIME_DIR");
addEnvironmentPath(directories, "TMPDIR");
addEnvironmentPath(directories, "TMP");
addEnvironmentPath(directories, "TEMP");
directories.add(Path.of("/tmp"));
return directories;
}
private static void addEnvironmentPath(Set<Path> directories, String name) {
String value = System.getenv(name);
if (value != null && !value.isBlank()) {
directories.add(Path.of(value));
}
}
private static boolean isWindows() {
return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win");
}
private static IOException missingDiscordException(IOException cause) {
IOException exception = new IOException("Aucune connexion Discord IPC trouvee. Lance Discord puis redemarre Minecraft.");
if (cause != null) {
exception.initCause(cause);
}
return exception;
}
record IpcFrame(int opcode, String payload) {
}
}
@@ -0,0 +1,162 @@
package fr.koka99cab.helloworld.client.title;
import com.mojang.blaze3d.platform.Window;
import fr.koka99cab.helloworld.HelloWorldMod;
import java.awt.EventQueue;
import java.awt.Taskbar;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import javax.imageio.ImageIO;
import org.lwjgl.glfw.GLFW;
import org.lwjgl.glfw.GLFWImage;
import org.lwjgl.system.MemoryStack;
import org.lwjgl.system.MemoryUtil;
public final class CustomTitleIconClient {
private static final String WINDOW_TITLE = "sanctuary";
private static final String APPLICATION_ICON_RESOURCE = "/assets/helloworld/icon.png";
private static final boolean IS_MAC_OS = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac");
private static final String[] WINDOW_ICON_RESOURCES = {
APPLICATION_ICON_RESOURCE
};
private CustomTitleIconClient() {
}
public static String windowTitle() {
return WINDOW_TITLE;
}
public static void apply(Window window) {
applyApplicationIcon();
window.setTitle(WINDOW_TITLE);
if (IS_MAC_OS) {
HelloWorldMod.LOGGER.info("Skipping GLFW window icon on macOS because Cocoa windows do not support it.");
return;
}
List<ByteBuffer> pixelBuffers = new ArrayList<>(WINDOW_ICON_RESOURCES.length);
try (MemoryStack stack = MemoryStack.stackPush()) {
GLFWImage.Buffer iconBuffer = GLFWImage.malloc(WINDOW_ICON_RESOURCES.length, stack);
for (int i = 0; i < WINDOW_ICON_RESOURCES.length; i++) {
IconData iconData = loadIcon(WINDOW_ICON_RESOURCES[i]);
pixelBuffers.add(iconData.pixels());
iconBuffer.position(i);
iconBuffer.width(iconData.width());
iconBuffer.height(iconData.height());
iconBuffer.pixels(iconData.pixels());
}
iconBuffer.position(0);
GLFW.glfwSetWindowIcon(window.handle(), iconBuffer);
HelloWorldMod.LOGGER.info("Applied sanctuary window title and icon.");
} catch (IOException exception) {
HelloWorldMod.LOGGER.error("Failed to load the sanctuary window icon.", exception);
} finally {
pixelBuffers.forEach(MemoryUtil::memFree);
}
}
private static void applyApplicationIcon() {
try {
BufferedImage iconImage = loadBufferedImage(APPLICATION_ICON_RESOURCE);
applyTaskbarIcon(iconImage);
if (canUseAppleDockApi()) {
EventQueue.invokeLater(() -> applyAppleDockIcon(iconImage));
}
} catch (IOException exception) {
HelloWorldMod.LOGGER.warn("Unable to load the sanctuary application icon.", exception);
}
}
private static boolean canUseAppleDockApi() {
if (!IS_MAC_OS) {
return false;
}
return ModuleLayer.boot()
.findModule("java.desktop")
.map(module -> module.isExported("com.apple.eawt", CustomTitleIconClient.class.getModule()))
.orElse(false);
}
private static void applyTaskbarIcon(BufferedImage iconImage) {
try {
if (!Taskbar.isTaskbarSupported()) {
return;
}
Taskbar taskbar = Taskbar.getTaskbar();
if (!taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) {
return;
}
taskbar.setIconImage(iconImage);
HelloWorldMod.LOGGER.info("Applied sanctuary taskbar or Dock icon.");
} catch (RuntimeException exception) {
HelloWorldMod.LOGGER.warn("Unable to apply the taskbar or Dock icon.", exception);
}
}
private static void applyAppleDockIcon(BufferedImage iconImage) {
try {
Class<?> applicationClass = Class.forName("com.apple.eawt.Application");
Object application = applicationClass.getMethod("getApplication").invoke(null);
applicationClass.getMethod("setDockIconImage", java.awt.Image.class).invoke(application, iconImage);
HelloWorldMod.LOGGER.info("Applied sanctuary Dock icon through com.apple.eawt.Application.");
} catch (ReflectiveOperationException | RuntimeException exception) {
HelloWorldMod.LOGGER.warn("Unable to apply the Dock icon through com.apple.eawt.Application.", exception);
}
}
private static IconData loadIcon(String resourcePath) throws IOException {
return toIconData(loadBufferedImage(resourcePath));
}
private static BufferedImage loadBufferedImage(String resourcePath) throws IOException {
try (InputStream inputStream = CustomTitleIconClient.class.getResourceAsStream(resourcePath)) {
if (inputStream == null) {
throw new IOException("Missing icon resource: " + resourcePath);
}
BufferedImage image = ImageIO.read(inputStream);
if (image == null) {
throw new IOException("Unable to decode image: " + resourcePath);
}
return image;
}
}
private static IconData toIconData(BufferedImage image) {
int width = image.getWidth();
int height = image.getHeight();
int[] pixels = new int[width * height];
ByteBuffer pixelBuffer = MemoryUtil.memAlloc(width * height * 4);
image.getRGB(0, 0, width, height, pixels, 0, width);
for (int pixel : pixels) {
pixelBuffer.put((byte) (pixel >> 16 & 0xFF));
pixelBuffer.put((byte) (pixel >> 8 & 0xFF));
pixelBuffer.put((byte) (pixel & 0xFF));
pixelBuffer.put((byte) (pixel >> 24 & 0xFF));
}
pixelBuffer.flip();
return new IconData(width, height, pixelBuffer);
}
private record IconData(int width, int height, ByteBuffer pixels) {
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

@@ -0,0 +1,19 @@
{
"required": true,
"package": "fr.koka99cab.helloworld.client.mixin",
"compatibilityLevel": "JAVA_25",
"client": [
"LoadingOverlayMixin",
"MinecraftClientMixin",
"OnlineServerEntryMixin",
"PauseScreenMixin",
"TitleScreenMixin",
"WindowMixin"
],
"injectors": {
"defaultRequire": 1
},
"overwrites": {
"requireAnnotations": true
}
}
@@ -0,0 +1,17 @@
package fr.koka99cab.helloworld;
import fr.koka99cab.helloworld.motd.MotdPlusPlusMod;
import net.fabricmc.api.ModInitializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class HelloWorldMod implements ModInitializer {
public static final String MOD_ID = "helloworld";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
@Override
public void onInitialize() {
MotdPlusPlusMod.initialize();
LOGGER.info("helloworld charge pour Minecraft 26.1.2.");
}
}
@@ -0,0 +1,18 @@
package fr.koka99cab.helloworld.mixin;
import fr.koka99cab.helloworld.motd.MotdPlusPlusMod;
import net.minecraft.network.protocol.status.ServerStatus;
import net.minecraft.server.MinecraftServer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(MinecraftServer.class)
public abstract class MinecraftServerStatusMixin {
@Inject(method = "getStatus", at = @At("RETURN"), cancellable = true)
private void motdpp$replaceStatusSnapshot(CallbackInfoReturnable<ServerStatus> cir) {
MinecraftServer server = (MinecraftServer) (Object) this;
cir.setReturnValue(MotdPlusPlusMod.CONTROLLER.dynamicStatus(server, cir.getReturnValue()));
}
}
@@ -0,0 +1,114 @@
package fr.koka99cab.helloworld.motd;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import fr.koka99cab.helloworld.motd.config.MotdppConfig;
import org.slf4j.Logger;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
public final class AnimationPayload {
private static final Gson GSON = new GsonBuilder()
.disableHtmlEscaping()
.create();
private static final String PREFIX = "helloworld_motd:";
private static final int MAX_INSERTION_CHARS = 20_000;
public String motdMode;
public String scrollText;
public String scrollPadding;
public int scrollWindowWidth;
public int motdSpeedTicks;
public List<String> motdFrames;
public String gifBase64;
public long tickOffset;
public static String encode(MotdppConfig config, byte[] gifBytes, long tickOffset, Logger logger) {
AnimationPayload payload = new AnimationPayload();
payload.motdMode = config.normalizedMode();
payload.scrollText = config.normalizedScrollText();
payload.scrollPadding = config.normalizedScrollPadding();
payload.scrollWindowWidth = config.normalizedScrollWindowWidth();
payload.motdSpeedTicks = config.normalizedMotdSpeedTicks();
payload.motdFrames = config.normalizedFrames();
payload.tickOffset = tickOffset;
payload.gifBase64 = gifBytes == null ? null : Base64.getEncoder().encodeToString(gifBytes);
String encoded = encodePayload(payload);
if (encoded.length() <= MAX_INSERTION_CHARS) {
return encoded;
}
if (payload.gifBase64 != null) {
payload.gifBase64 = null;
encoded = encodePayload(payload);
if (encoded.length() <= MAX_INSERTION_CHARS) {
logger.warn("motd++: payload GIF trop gros pour le ping status, animation d'icone client desactivee");
return encoded;
}
}
logger.warn("motd++: payload MOTD trop gros pour le ping status, animation client desactivee");
return null;
}
public static AnimationPayload decode(String insertion, Logger logger) {
if (insertion == null || !insertion.startsWith(PREFIX)) {
return null;
}
try {
byte[] compressed = Base64.getDecoder().decode(insertion.substring(PREFIX.length()));
try (GZIPInputStream gzipInputStream = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
String json = new String(gzipInputStream.readAllBytes(), StandardCharsets.UTF_8);
AnimationPayload payload = GSON.fromJson(json, AnimationPayload.class);
return payload == null ? null : payload;
}
} catch (IOException | IllegalArgumentException exception) {
logger.warn("motd++: impossible de decoder le payload client", exception);
return null;
}
}
public byte[] gifBytes() {
if (this.gifBase64 == null || this.gifBase64.isBlank()) {
return null;
}
try {
return Base64.getDecoder().decode(this.gifBase64);
} catch (IllegalArgumentException exception) {
return null;
}
}
public String normalizedMode() {
return Objects.equals(this.motdMode, "frames") ? "frames" : "scroll";
}
public int normalizedMotdSpeedTicks() {
return Math.max(1, this.motdSpeedTicks);
}
private static String encodePayload(AnimationPayload payload) {
try (ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteStream)) {
gzipOutputStream.write(GSON.toJson(payload).getBytes(StandardCharsets.UTF_8));
gzipOutputStream.finish();
return PREFIX + Base64.getEncoder().encodeToString(byteStream.toByteArray());
} catch (IOException exception) {
throw new IllegalStateException("Impossible d'encoder le payload motd++", exception);
}
}
}
@@ -0,0 +1,4 @@
package fr.koka99cab.helloworld.motd;
public record GifFrameData(byte[] pngBytes, int durationTicks) {
}
@@ -0,0 +1,162 @@
package fr.koka99cab.helloworld.motd;
import org.slf4j.Logger;
import org.w3c.dom.NodeList;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.metadata.IIOMetadata;
import javax.imageio.metadata.IIOMetadataNode;
import javax.imageio.stream.ImageInputStream;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public final class GifIconLoader {
private static final int ICON_SIZE = 64;
private GifIconLoader() {
}
public static byte[] readGifBytes(Path gifPath, Logger logger) {
if (!Files.isRegularFile(gifPath)) {
return null;
}
try {
return Files.readAllBytes(gifPath);
} catch (IOException exception) {
logger.error("motd++: echec de lecture du GIF {}", gifPath, exception);
return null;
}
}
public static List<GifFrameData> load(Path gifPath, Logger logger) {
if (!Files.isRegularFile(gifPath)) {
return List.of();
}
try (InputStream inputStream = Files.newInputStream(gifPath)) {
return load(inputStream, gifPath.getFileName().toString(), logger);
} catch (IOException exception) {
logger.error("motd++: echec du chargement de {}", gifPath, exception);
return List.of();
}
}
public static List<GifFrameData> load(byte[] gifBytes, Logger logger) {
if (gifBytes == null || gifBytes.length == 0) {
return List.of();
}
try (InputStream inputStream = new ByteArrayInputStream(gifBytes)) {
return load(inputStream, "payload GIF", logger);
} catch (IOException exception) {
logger.error("motd++: echec du chargement du GIF embarque", exception);
return List.of();
}
}
private static List<GifFrameData> load(InputStream inputStream, String sourceName, Logger logger) throws IOException {
try (ImageInputStream imageInputStream = ImageIO.createImageInputStream(inputStream)) {
if (imageInputStream == null) {
logger.warn("motd++: impossible de lire {}", sourceName);
return List.of();
}
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("gif");
if (!readers.hasNext()) {
logger.warn("motd++: aucun reader GIF disponible pour {}", sourceName);
return List.of();
}
ImageReader reader = readers.next();
try {
reader.setInput(imageInputStream, false, false);
int frameCount = reader.getNumImages(true);
List<GifFrameData> frames = new ArrayList<>(frameCount);
for (int frameIndex = 0; frameIndex < frameCount; frameIndex++) {
BufferedImage rawFrame = reader.read(frameIndex);
BufferedImage normalizedFrame = scaleToIcon(rawFrame);
int durationTicks = readFrameDurationTicks(reader.getImageMetadata(frameIndex));
frames.add(new GifFrameData(writePng(normalizedFrame), durationTicks));
}
logger.info("motd++: {} frame(s) GIF chargee(s) depuis {}", frames.size(), sourceName);
return frames;
} finally {
reader.dispose();
}
}
}
private static BufferedImage scaleToIcon(BufferedImage source) {
if (source.getWidth() == ICON_SIZE && source.getHeight() == ICON_SIZE && source.getType() == BufferedImage.TYPE_INT_ARGB) {
return source;
}
BufferedImage target = new BufferedImage(ICON_SIZE, ICON_SIZE, BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics = target.createGraphics();
try {
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
graphics.drawImage(source, 0, 0, ICON_SIZE, ICON_SIZE, null);
} finally {
graphics.dispose();
}
return target;
}
private static int readFrameDurationTicks(IIOMetadata metadata) {
if (metadata == null) {
return 1;
}
String nativeFormat = metadata.getNativeMetadataFormatName();
if (nativeFormat == null) {
return 1;
}
IIOMetadataNode root = (IIOMetadataNode) metadata.getAsTree(nativeFormat);
NodeList graphicControls = root.getElementsByTagName("GraphicControlExtension");
if (graphicControls.getLength() == 0) {
return 1;
}
IIOMetadataNode graphicControl = (IIOMetadataNode) graphicControls.item(0);
String delayTime = graphicControl.getAttribute("delayTime");
try {
int hundredths = Integer.parseInt(delayTime);
return Math.max(1, Math.round(hundredths / 5.0f));
} catch (NumberFormatException ignored) {
return 1;
}
}
private static byte[] writePng(BufferedImage image) throws IOException {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
if (!ImageIO.write(image, "PNG", outputStream)) {
throw new IOException("Aucun writer PNG disponible");
}
return outputStream.toByteArray();
}
}
}
@@ -0,0 +1,40 @@
package fr.koka99cab.helloworld.motd;
import fr.koka99cab.helloworld.HelloWorldMod;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class MotdPlusPlusMod {
public static final Logger LOGGER = LoggerFactory.getLogger(HelloWorldMod.MOD_ID + "/motdpp");
public static final MotdStatusController CONTROLLER = new MotdStatusController();
private static boolean initialized;
private MotdPlusPlusMod() {
}
public static void initialize() {
if (initialized) {
return;
}
initialized = true;
ServerLifecycleEvents.SERVER_STARTED.register(CONTROLLER::start);
ServerLifecycleEvents.SERVER_STOPPING.register(CONTROLLER::stop);
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) ->
dispatcher.register(
Commands.literal("motdpp")
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
.then(Commands.literal("reload")
.executes(context -> {
CONTROLLER.reload(context.getSource().getServer());
context.getSource().sendSuccess(() -> Component.literal("sanctuary MOTD++ config rechargee."), true);
return 1;
}))
)
);
}
}
@@ -0,0 +1,217 @@
package fr.koka99cab.helloworld.motd;
import fr.koka99cab.helloworld.motd.config.MotdppConfig;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
import net.minecraft.network.chat.Style;
import net.minecraft.network.protocol.status.ServerStatus;
import net.minecraft.server.MinecraftServer;
import java.nio.file.Path;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class MotdStatusController {
private static final long NANOS_PER_TICK = 50_000_000L;
private final Map<MinecraftServer, ServerAnimationState> states = new IdentityHashMap<>();
public void start(MinecraftServer server) {
ServerStatus currentStatus = server.getStatus();
ServerStatus.Favicon fallbackIcon = currentStatus == null ? null : currentStatus.favicon().orElse(null);
this.states.put(server, ServerAnimationState.load(server, fallbackIcon));
}
public void stop(MinecraftServer server) {
this.states.remove(server);
}
public void reload(MinecraftServer server) {
ServerAnimationState currentState = this.states.get(server);
ServerStatus currentStatus = server.getStatus();
ServerStatus.Favicon fallbackIcon = currentState != null
? currentState.fallbackIcon()
: currentStatus == null ? null : currentStatus.favicon().orElse(null);
this.states.put(server, ServerAnimationState.load(server, fallbackIcon));
}
public ServerStatus dynamicStatus(MinecraftServer server, ServerStatus baseStatus) {
ServerAnimationState state = this.states.get(server);
if (state == null || baseStatus == null) {
return baseStatus;
}
return state.createStatus(baseStatus, System.nanoTime());
}
private record ServerAnimationState(
MotdppConfig config,
MotdAnimator motdAnimator,
IconAnimator iconAnimator,
ServerStatus.Favicon fallbackIcon,
byte[] rawGifBytes,
long startedAtNanos
) {
private static final String GIF_FILE_NAME = "server-icon.gif";
private static ServerAnimationState load(MinecraftServer server, ServerStatus.Favicon fallbackIcon) {
Path configPath = FabricLoader.getInstance().getConfigDir().resolve("motdpp.json");
MotdppConfig config = MotdppConfig.load(configPath, MotdPlusPlusMod.LOGGER);
Path gifPath = server.getFile(GIF_FILE_NAME);
List<IconAnimator.IconFrame> iconFrames = GifIconLoader.load(gifPath, MotdPlusPlusMod.LOGGER).stream()
.map(frame -> new IconAnimator.IconFrame(new ServerStatus.Favicon(frame.pngBytes()), frame.durationTicks()))
.toList();
return new ServerAnimationState(
config,
MotdAnimator.create(config),
IconAnimator.create(iconFrames),
fallbackIcon,
GifIconLoader.readGifBytes(gifPath, MotdPlusPlusMod.LOGGER),
System.nanoTime()
);
}
private ServerStatus createStatus(ServerStatus baseStatus, long nowNanos) {
long elapsedTicks = Math.max(0L, (nowNanos - this.startedAtNanos) / NANOS_PER_TICK);
String motd = this.motdAnimator.frameAt(elapsedTicks);
ServerStatus.Favicon favicon = this.iconAnimator.faviconAt(elapsedTicks, this.fallbackIcon);
String insertion = AnimationPayload.encode(this.config, this.rawGifBytes, elapsedTicks, MotdPlusPlusMod.LOGGER);
MutableComponent description = Component.literal(motd);
if (insertion != null) {
description = description.withStyle(Style.EMPTY.withInsertion(insertion));
}
return new ServerStatus(
description,
baseStatus.players(),
baseStatus.version(),
Optional.ofNullable(favicon),
baseStatus.enforcesSecureChat()
);
}
}
private static final class MotdAnimator {
private final String mode;
private final int speedTicks;
private final List<String> frameSequence;
private final int[] scrollCodePoints;
private final int scrollWindowWidth;
private MotdAnimator(String mode, int speedTicks, List<String> frameSequence, int[] scrollCodePoints, int scrollWindowWidth) {
this.mode = mode;
this.speedTicks = speedTicks;
this.frameSequence = frameSequence;
this.scrollCodePoints = scrollCodePoints;
this.scrollWindowWidth = scrollWindowWidth;
}
private static MotdAnimator create(MotdppConfig config) {
List<String> frames = config.normalizedFrames();
String scrollText = config.normalizedScrollText();
if ("frames".equals(config.normalizedMode()) || (scrollText.isBlank() && !frames.isEmpty())) {
return new MotdAnimator(
"frames",
config.normalizedMotdSpeedTicks(),
frames,
new int[0],
0
);
}
if (scrollText.isBlank()) {
scrollText = String.join(" | ", frames);
}
String scrollSource = scrollText + config.normalizedScrollPadding();
return new MotdAnimator(
"scroll",
config.normalizedMotdSpeedTicks(),
frames,
scrollSource.codePoints().toArray(),
config.normalizedScrollWindowWidth()
);
}
private String frameAt(long elapsedTicks) {
if ("frames".equals(this.mode)) {
long frameStep = elapsedTicks / this.speedTicks;
int frameIndex = (int) (frameStep % this.frameSequence.size());
String frame = this.frameSequence.get(frameIndex);
return frame.isBlank() ? "motd++" : frame;
}
if (this.scrollCodePoints.length == 0) {
return this.frameSequence.isEmpty() ? "motd++" : this.frameSequence.getFirst();
}
long scrollStep = elapsedTicks / this.speedTicks;
int offset = (int) (scrollStep % this.scrollCodePoints.length);
String frame = buildScrollFrame(offset);
return frame.isBlank() && !this.frameSequence.isEmpty() ? this.frameSequence.getFirst() : frame;
}
private String buildScrollFrame(int offset) {
if (this.scrollCodePoints.length == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int index = 0; index < this.scrollWindowWidth; index++) {
int codePoint = this.scrollCodePoints[(offset + index) % this.scrollCodePoints.length];
builder.appendCodePoint(codePoint);
}
return builder.toString();
}
}
static final class IconAnimator {
private final List<IconFrame> frames;
private final long cycleDurationTicks;
private IconAnimator(List<IconFrame> frames) {
this.frames = frames;
this.cycleDurationTicks = frames.stream()
.mapToLong(IconFrame::durationTicks)
.sum();
}
private static IconAnimator create(List<IconFrame> frames) {
return new IconAnimator(frames);
}
private ServerStatus.Favicon faviconAt(long elapsedTicks, ServerStatus.Favicon fallbackIcon) {
if (this.frames.isEmpty()) {
return fallbackIcon;
}
if (this.frames.size() == 1 || this.cycleDurationTicks <= 0L) {
return this.frames.getFirst().favicon();
}
long tickInCycle = elapsedTicks % this.cycleDurationTicks;
long cursor = 0L;
for (IconFrame frame : this.frames) {
cursor += frame.durationTicks();
if (tickInCycle < cursor) {
return frame.favicon();
}
}
return this.frames.getLast().favicon();
}
record IconFrame(ServerStatus.Favicon favicon, int durationTicks) {
}
}
}
@@ -0,0 +1,129 @@
package fr.koka99cab.helloworld.motd.config;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import org.slf4j.Logger;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
public final class MotdppConfig {
private static final Gson GSON = new GsonBuilder()
.setPrettyPrinting()
.disableHtmlEscaping()
.create();
public String motdMode = "scroll";
public String scrollText = "Bienvenue sur sanctuary | pose server-icon.gif a la racine du serveur";
public String scrollPadding = " ";
public int scrollWindowWidth = 48;
public int motdSpeedTicks = 1;
public boolean clientAutoRefresh = false;
public int clientRefreshTicks = 10;
public List<String> motdFrames = new ArrayList<>(List.of(
"sanctuary est pret",
"Ajoute server-icon.gif",
"Edite config/motdpp.json"
));
public static MotdppConfig load(Path path, Logger logger) {
MotdppConfig defaults = new MotdppConfig();
try {
Files.createDirectories(path.getParent());
if (Files.notExists(path)) {
save(path, defaults);
logger.info("motd++: config creee dans {}", path);
return defaults;
}
try (Reader reader = Files.newBufferedReader(path)) {
MotdppConfig loaded = GSON.fromJson(reader, MotdppConfig.class);
if (loaded == null) {
save(path, defaults);
return defaults;
}
return sanitize(loaded);
}
} catch (IOException | JsonParseException exception) {
logger.error("motd++: impossible de charger la config {}, valeurs par defaut utilisees", path, exception);
return defaults;
}
}
public String normalizedMode() {
String mode = Objects.requireNonNullElse(this.motdMode, "scroll");
return switch (mode.toLowerCase(Locale.ROOT)) {
case "frames" -> "frames";
default -> "scroll";
};
}
public String normalizedScrollText() {
return Objects.requireNonNullElse(this.scrollText, "");
}
public String normalizedScrollPadding() {
return Objects.requireNonNullElse(this.scrollPadding, " ");
}
public int normalizedScrollWindowWidth() {
return Math.max(1, this.scrollWindowWidth);
}
public int normalizedMotdSpeedTicks() {
return Math.max(1, this.motdSpeedTicks);
}
public boolean normalizedClientAutoRefresh() {
return this.clientAutoRefresh;
}
public int normalizedClientRefreshTicks() {
return Math.max(1, this.clientRefreshTicks);
}
public List<String> normalizedFrames() {
List<String> frames = this.motdFrames == null ? List.of() : this.motdFrames.stream()
.filter(Objects::nonNull)
.map(String::strip)
.filter(frame -> !frame.isEmpty())
.toList();
if (!frames.isEmpty()) {
return frames;
}
return List.of("sanctuary");
}
private static MotdppConfig sanitize(MotdppConfig config) {
MotdppConfig sanitized = new MotdppConfig();
sanitized.motdMode = config.normalizedMode();
sanitized.scrollText = config.normalizedScrollText();
sanitized.scrollPadding = config.normalizedScrollPadding();
sanitized.scrollWindowWidth = config.normalizedScrollWindowWidth();
sanitized.motdSpeedTicks = config.normalizedMotdSpeedTicks();
sanitized.clientAutoRefresh = config.normalizedClientAutoRefresh();
sanitized.clientRefreshTicks = config.normalizedClientRefreshTicks();
sanitized.motdFrames = new ArrayList<>(config.normalizedFrames());
return sanitized;
}
private static void save(Path path, MotdppConfig config) throws IOException {
try (Writer writer = Files.newBufferedWriter(path)) {
GSON.toJson(config, writer);
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,4 @@
{
"modmenu.nameTranslation.helloworld": "helloworld",
"modmenu.descriptionTranslation.helloworld": "Custom loading screen, menu, title icon and MOTD++."
}
@@ -0,0 +1,4 @@
{
"modmenu.nameTranslation.helloworld": "helloworld",
"modmenu.descriptionTranslation.helloworld": "Ecran de chargement, menu, icone de titre et MOTD++ personnalises."
}
@@ -0,0 +1,34 @@
{
"schemaVersion": 1,
"id": "helloworld",
"version": "${version}",
"name": "helloworld",
"description": "Adds a custom loading screen, main menu, title icon, and enhanced MOTD for the sanctuary instance.",
"icon": "assets/helloworld/icon.png",
"authors": [
"KOKA99CAB"
],
"license": "LGPL-3.0-or-later",
"environment": "*",
"entrypoints": {
"main": [
"fr.koka99cab.helloworld.HelloWorldMod"
],
"client": [
"fr.koka99cab.helloworld.client.HelloWorldClient"
]
},
"mixins": [
"helloworld.mixins.json",
{
"config": "helloworld.client.mixins.json",
"environment": "client"
}
],
"depends": {
"fabricloader": ">=0.19.2",
"minecraft": "~26.1.2",
"java": ">=25",
"fabric-api": "*"
}
}
@@ -0,0 +1,11 @@
{
"required": true,
"package": "fr.koka99cab.helloworld.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"MinecraftServerStatusMixin"
],
"injectors": {
"defaultRequire": 1
}
}