nosingleplayer 1.1.9 : menu titre résilient (repli vanilla) + launcher : plus de console noire Windows

Écran noir au menu titre sur les PC Windows (2 postes) : dans
TitleScreen.extractRenderState, les widgets sont dessinés juste APRÈS
extractPanorama. Toute exception dans la redirection du panorama
interrompt donc le rendu du menu entier (écran noir, sans bouton).

Mod nosingleplayer 1.1.9 :
- try/catch autour du fond custom + repli automatique sur le panorama
  vanilla (avant : écran noir définitif si la texture est refusée par
  le GPU ou absente du jar) ;
- try/catch autour de la construction du menu, et les widgets vanilla ne
  sont retirés qu'APRÈS la création des widgets SurviePote (avant : une
  exception laissait un menu vidé) ;
- ping serveur déplacé sur un thread dédié : il était lancé depuis
  extractWidgetRenderState, donc sur le thread de rendu (un DNS lent
  suffisait à figer le menu) ;
- log GPU au démarrage du menu (nom/vendor/pilote/backend) ;
- option de diagnostic -Dsurviepote.customTitle=false (menu vanilla).

Launcher : java.exe est lancé avec detached:true sans windowsHide ->
sur Windows, une console noire et vide s'ouvre par-dessus le jeu.
Ajout de windowsHide:true.

atlas : distribution.json regénéré (MD5/URL du nouveau jar).
This commit is contained in:
Anaïs Saraiva
2026-10-19 23:31:49 +02:00
parent 1ff5f52ce8
commit 4c7da4cab5
9 changed files with 170 additions and 39 deletions
+4 -4
View File
@@ -487,13 +487,13 @@
} }
}, },
{ {
"id": "fr.surviepote:nosingleplayer:1.1.8@jar", "id": "fr.surviepote:nosingleplayer:1.1.9@jar",
"name": "NoSingleplayer (SurviePote)", "name": "NoSingleplayer (SurviePote)",
"type": "FabricMod", "type": "FabricMod",
"artifact": { "artifact": {
"size": 3637392, "size": 3639116,
"url": "https://git.kisakay.com/k/mc/raw/branch/main/atlas/root/servers/SurviePote-26.3/fabricmods/required/nosingleplayer-1.1.8.jar", "url": "https://git.kisakay.com/k/mc/raw/branch/main/atlas/root/servers/SurviePote-26.3/fabricmods/required/nosingleplayer-1.1.9.jar",
"MD5": "dc69cd7ebc5d10ba4797c4e212e47e0c" "MD5": "d9666a2698e9bcb3d69e03097dc081b4"
} }
}, },
{ {
+4
View File
@@ -185,6 +185,10 @@ class ProcessBuilder {
const child = child_process.spawn(ConfigManager.getJavaExecutable(this.server.rawServer.id), args, { const child = child_process.spawn(ConfigManager.getJavaExecutable(this.server.rawServer.id), args, {
cwd: this.gameDir, cwd: this.gameDir,
detached: ConfigManager.getLaunchDetached(), detached: ConfigManager.getLaunchDetached(),
// Windows : sans ça, java.exe ouvre sa propre console (detached => nouvelle
// console) : une fenêtre noire, vide (stdout est pipe vers le launcher),
// qui traîne par-dessus la fenêtre du jeu pendant toute la session.
windowsHide: true,
env: gameEnv env: gameEnv
}) })
+11 -1
View File
@@ -13,4 +13,14 @@ au serveur unique :
pas de refmap nécessaire. pas de refmap nécessaire.
Build : `cd mods/nosingleplayer && ./gradlew build` (dans nix-shell racine pour Java 25). Build : `cd mods/nosingleplayer && ./gradlew build` (dans nix-shell racine pour Java 25).
Jar : `build/libs/nosingleplayer-1.1.4.jar` → copié dans `atlas/.../fabricmods/required/`. Jar : `build/libs/nosingleplayer-1.1.9.jar` → copié dans `atlas/.../fabricmods/required/`.
## Diagnostic
- `-Dsurviepote.customTitle=false` (options JVM du launcher) : repasser au menu **vanilla**
(panorama vanilla au lieu du fond custom). Sert à isoler un souci de rendu du fond custom.
- Le mod log au démarrage du menu : `[SurviePote] GPU: <nom> | <vendor> | pilote <driver> | backend <OpenGL|Vulkan>`
et `[SurviePote] Fond chargé : <w>x<h>` (ou l'erreur de chargement de la texture).
- Si le fond custom échoue (texture refusée par le GPU, jar corrompu…), le mod se repli
automatiquement sur le panorama vanilla au lieu de laisser un écran noir.
+1 -1
View File
@@ -5,7 +5,7 @@ loader_version=0.20.0
loom_version=1.19.1 loom_version=1.19.1
# Mod Properties # Mod Properties
mod_version=1.1.8 mod_version=1.1.9
maven_group=fr.surviepote maven_group=fr.surviepote
archives_base_name=nosingleplayer archives_base_name=nosingleplayer
@@ -26,6 +26,14 @@ public class ServerStatusWidget extends AbstractWidget {
private static final long REFRESH_MS = 15000L; private static final long REFRESH_MS = 15000L;
private static final long TIMEOUT_MS = 8000L; private static final long TIMEOUT_MS = 8000L;
/** Thread dédié : le ping ne doit jamais bloquer le thread de rendu. */
private static final java.util.concurrent.ExecutorService PING_EXECUTOR =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "SurviePote-ServerStatus");
t.setDaemon(true);
return t;
});
private static final int BG_COLOR = 0xB0000000; private static final int BG_COLOR = 0xB0000000;
private static final int ONLINE_COLOR = 0xFF4CD964; private static final int ONLINE_COLOR = 0xFF4CD964;
private static final int OFFLINE_COLOR = 0xFFE04B4B; private static final int OFFLINE_COLOR = 0xFFE04B4B;
@@ -106,9 +114,15 @@ public class ServerStatusWidget extends AbstractWidget {
} }
private void refresh() { private void refresh() {
// ATTENTION : pingServer() fait une résolution DNS + ouvre une connexion.
// Appelé depuis extractWidgetRenderState (thread de rendu), ça BLOQUE le
// rendu du menu (écran figé/noir) le temps du timeout DNS. On le lance
// donc sur un thread dédié ; l'état lu par le rendu est "volatile".
try { try {
this.pingStartedAt = System.currentTimeMillis(); this.pingStartedAt = System.currentTimeMillis();
this.pending = true; this.pending = true;
PING_EXECUTOR.execute(() -> {
try {
this.pinger.pingServer(this.data, () -> { this.pinger.pingServer(this.data, () -> {
}, () -> { }, () -> {
}, EventLoopGroupHolder.remote(Minecraft.getInstance().options.useNativeTransport())); }, EventLoopGroupHolder.remote(Minecraft.getInstance().options.useNativeTransport()));
@@ -118,6 +132,13 @@ public class ServerStatusWidget extends AbstractWidget {
this.resolved = true; this.resolved = true;
this.online = false; this.online = false;
} }
});
} catch (Exception e) {
NoSingleplayer.LOGGER.warn("[SurviePote] Ping serveur impossible : {}", e.toString());
this.pending = false;
this.resolved = true;
this.online = false;
}
} }
@Override @Override
@@ -1,14 +1,24 @@
package fr.surviepote.nosingleplayer.mixin; package fr.surviepote.nosingleplayer.mixin;
import java.util.List; import java.util.List;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Renderable; import net.minecraft.client.gui.components.Renderable;
import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.Screen;
import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor; import org.spongepowered.asm.mixin.gen.Accessor;
import org.spongepowered.asm.mixin.gen.Invoker;
@Mixin(Screen.class) @Mixin(Screen.class)
public interface ScreenAccessor { public interface ScreenAccessor {
@Accessor("renderables") @Accessor("renderables")
List<Renderable> surviepote$getRenderables(); List<Renderable> surviepote$getRenderables();
/**
* Panorama vanilla (déclaré dans Screen, appelé par TitleScreen).
* Utilisé en repli quand le fond custom de SurviePote n'a pas pu être chargé :
* sans ça, la redirection de l'appel laisse l'écran entièrement noir.
*/
@Invoker("extractPanorama")
void surviepote$extractVanillaPanorama(GuiGraphicsExtractor extractor, float partialTick);
} }
@@ -45,7 +45,18 @@ public abstract class TitleScreenMixin {
@Inject(method = "init", at = @At("TAIL")) @Inject(method = "init", at = @At("TAIL"))
private void surviepote$customizeMenu(CallbackInfo ci) { private void surviepote$customizeMenu(CallbackInfo ci) {
TitleScreen self = (TitleScreen) (Object) this; // Garde-fou : si la personnalisation échoue, on NE DOIT PAS avoir retiré les
// boutons vanilla (menu vide + fond noir = "écran cassé"). Les retraits ne
// sont appliqués qu'après la création des widgets, en fin de méthode.
try {
surviepote$buildMenu((TitleScreen) (Object) this);
} catch (Throwable t) {
NoSingleplayer.LOGGER.error("[SurviePote] Personnalisation du menu échouée, menu vanilla conservé.", t);
}
}
@Unique
private void surviepote$buildMenu(TitleScreen self) {
Minecraft minecraft = Minecraft.getInstance(); Minecraft minecraft = Minecraft.getInstance();
String solo = Component.translatable("menu.singleplayer").getString(); String solo = Component.translatable("menu.singleplayer").getString();
@@ -97,9 +108,9 @@ public abstract class TitleScreenMixin {
} }
} }
} }
for (GuiEventListener widget : toRemove) { // ATTENTION : les retraits des widgets vanilla ne sont appliqués qu'APRÈS la
surviepote$removeWidget(self, widget); // création des widgets SurviePote (fin de méthode). Si une exception survient
} // entre-temps, le menu vanilla est conservé au lieu d'être vidé.
Button join = Button.builder(Component.literal("Rejoindre SurviePote"), pressed -> { Button join = Button.builder(Component.literal("Rejoindre SurviePote"), pressed -> {
ServerData data = new ServerData("SurviePote", SERVER_HOST, ServerData.Type.OTHER); ServerData data = new ServerData("SurviePote", SERVER_HOST, ServerData.Type.OTHER);
@@ -158,6 +169,13 @@ public abstract class TitleScreenMixin {
w.visible = true; w.visible = true;
w.active = true; w.active = true;
} }
// Tout est prêt : on retire enfin les widgets vanilla (Solo, Multi, Realms,
// réseau social, icônes bas) remplacés ci-dessus.
for (GuiEventListener widget : toRemove) {
surviepote$removeWidget(self, widget);
}
if (!foundMulti) { if (!foundMulti) {
NoSingleplayer.LOGGER.warn("[SurviePote] Bouton Multi introuvable, Join placé par défaut."); NoSingleplayer.LOGGER.warn("[SurviePote] Bouton Multi introuvable, Join placé par défaut.");
} }
@@ -1,5 +1,6 @@
package fr.surviepote.nosingleplayer.mixin; package fr.surviepote.nosingleplayer.mixin;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.realmsclient.gui.screens.RealmsNotificationsScreen; import com.mojang.realmsclient.gui.screens.RealmsNotificationsScreen;
import fr.surviepote.nosingleplayer.ModTextures; import fr.surviepote.nosingleplayer.ModTextures;
import fr.surviepote.nosingleplayer.NoSingleplayer; import fr.surviepote.nosingleplayer.NoSingleplayer;
@@ -29,6 +30,15 @@ public abstract class TitleScreenRenderMixin {
private static final String SURVIEPOTE_BACKGROUND_RESOURCE = private static final String SURVIEPOTE_BACKGROUND_RESOURCE =
"/assets/nosingleplayer/textures/gui/background.png"; "/assets/nosingleplayer/textures/gui/background.png";
/**
* Option de diagnostic : lancer le jeu avec
* {@code -Dsurviepote.customTitle=false} pour repasser au menu vanilla
* (utilisable dans les options JVM du launcher). Sert à isoler un éventuel
* souci de rendu du fond custom sur un poste donné.
*/
@Unique
private static final String DISABLE_CUSTOM_TITLE_PROPERTY = "surviepote.customTitle";
@Unique @Unique
private static DynamicTexture surviepote$background; private static DynamicTexture surviepote$background;
@@ -38,6 +48,13 @@ public abstract class TitleScreenRenderMixin {
@Unique @Unique
private static int surviepote$backgroundHeight; private static int surviepote$backgroundHeight;
// Le fond custom a déjà échoué : on ne réessaie plus (le menu reste noir sinon).
@Unique
private static boolean surviepote$backgroundFailed;
@Unique
private static boolean surviepote$gpuLogged;
// Pas de panorama dynamique : fond statique custom à la place. // Pas de panorama dynamique : fond statique custom à la place.
// Note : extractPanorama est déclarée dans Screen (héritée), on redirige donc // Note : extractPanorama est déclarée dans Screen (héritée), on redirige donc
// l'APPEL dans TitleScreen plutôt que la méthode elle-même. // l'APPEL dans TitleScreen plutôt que la méthode elle-même.
@@ -46,11 +63,27 @@ public abstract class TitleScreenRenderMixin {
at = @At(value = "INVOKE", at = @At(value = "INVOKE",
target = "Lnet/minecraft/client/gui/screens/TitleScreen;extractPanorama(Lnet/minecraft/client/gui/GuiGraphicsExtractor;F)V")) target = "Lnet/minecraft/client/gui/screens/TitleScreen;extractPanorama(Lnet/minecraft/client/gui/GuiGraphicsExtractor;F)V"))
private void surviepote$customBackground(TitleScreen self, GuiGraphicsExtractor extractor, float partialTick) { private void surviepote$customBackground(TitleScreen self, GuiGraphicsExtractor extractor, float partialTick) {
// Les widgets du menu sont dessinés par super.extractRenderState(), juste APRÈS
// cet appel. Toute exception levée ici interromp le rendu du menu entier =>
// écran 100 % noir (sans boutons). D'où les garde-fous ci-dessous.
surviepote$logGpuOnce();
// Menu vanilla si le fond custom a déjà échoué, ou si l'utilisateur a forcé
// -Dsurviepote.customTitle=false (option de diagnostic).
final boolean customEnabled = !"false".equalsIgnoreCase(
System.getProperty(DISABLE_CUSTOM_TITLE_PROPERTY, "true"));
if (surviepote$backgroundFailed || !customEnabled) {
surviepote$vanillaPanorama(self, extractor, partialTick);
return;
}
try {
int w = Minecraft.getInstance().getWindow().getGuiScaledWidth(); int w = Minecraft.getInstance().getWindow().getGuiScaledWidth();
int h = Minecraft.getInstance().getWindow().getGuiScaledHeight(); int h = Minecraft.getInstance().getWindow().getGuiScaledHeight();
surviepote$loadBackground(); surviepote$loadBackground();
if (surviepote$background == null) { if (surviepote$background == null) {
surviepote$vanillaPanorama(self, extractor, partialTick);
return; return;
} }
@@ -79,6 +112,41 @@ public abstract class TitleScreenRenderMixin {
0, 0, (float) u, (float) v, 0, 0, (float) u, (float) v,
w, h, srcW, srcH, w, h, srcW, srcH,
surviepote$backgroundWidth, surviepote$backgroundHeight, -1); surviepote$backgroundWidth, surviepote$backgroundHeight, -1);
} catch (Throwable t) {
// Repli : on bascule sur le panorama vanilla pour ne pas laisser un écran noir.
surviepote$backgroundFailed = true;
NoSingleplayer.LOGGER.error("[SurviePote] Échec du fond custom, repli sur le panorama vanilla.", t);
surviepote$vanillaPanorama(self, extractor, partialTick);
}
}
/**
* Redessine le panorama vanilla (appelé dans Screen, pas dans TitleScreen :
* la redirection porte sur l'appel, donc pas de récursion).
*/
@Unique
private void surviepote$vanillaPanorama(TitleScreen self, GuiGraphicsExtractor extractor, float partialTick) {
try {
((ScreenAccessor) self).surviepote$extractVanillaPanorama(extractor, partialTick);
} catch (Throwable t) {
NoSingleplayer.LOGGER.error("[SurviePote] Échec du panorama vanilla.", t);
}
}
/** Log une fois les infos GPU : permet de comparer un poste qui plante d'un poste qui marche. */
@Unique
private static void surviepote$logGpuOnce() {
if (surviepote$gpuLogged) {
return;
}
surviepote$gpuLogged = true;
try {
var info = RenderSystem.getDevice().getDeviceInfo();
NoSingleplayer.LOGGER.info("[SurviePote] GPU: {} | {} | pilote {} | backend {}",
info.name(), info.vendorName(), info.driverInfo(), info.backendName());
} catch (Throwable t) {
NoSingleplayer.LOGGER.warn("[SurviePote] Infos GPU indisponibles : {}", t.toString());
}
} }
/** /**