diff --git a/core/src/main/java/pl/skidam/automodpack_core/client/ClientLaunch.java b/core/src/main/java/pl/skidam/automodpack_core/client/ClientLaunch.java index 1754c872b..df3ef55cd 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/client/ClientLaunch.java +++ b/core/src/main/java/pl/skidam/automodpack_core/client/ClientLaunch.java @@ -96,8 +96,10 @@ private StoredModpackConnection.Seeded seedSelected() { private void syncFromServer(ConnectionJsons.ConnectionInfo connectionInfo, Secrets.Secret secret) throws Exception { String followId = storage.selectedModpackId(); + LaunchProgress.stage("checking " + AddressHelpers.formatAddress(connectionInfo.origin) + " for modpack updates"); var manifestResult = ManifestFetcher.requestServerModpackContent(storage, connectionInfo, secret, false, followId); if (!manifestResult.successful()) { + LaunchProgress.stage("modpack server unreachable, starting with the installed pack"); // An unreachable server is a normal boot condition, not a failure: the installed pack keeps working and the // next launch with the server up catches up. One calm line says so; the cause stays at debug. LOGGER.info("Modpack server {} is unreachable; continuing with the local state without a sync", AddressHelpers.formatAddress(connectionInfo.origin)); diff --git a/core/src/main/java/pl/skidam/automodpack_core/client/DownloadManager.java b/core/src/main/java/pl/skidam/automodpack_core/client/DownloadManager.java index 8417c417f..64e1eb98c 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/client/DownloadManager.java +++ b/core/src/main/java/pl/skidam/automodpack_core/client/DownloadManager.java @@ -600,6 +600,14 @@ public String getStage() { return downloadedCount.get() + "/" + totalFilesAdded; } + public long getDownloadedBytes() { + return totalBytesDownloaded.get(); + } + + public long getTotalBytes() { + return totalBytesToDownload.get(); + } + public boolean isRunning() { return !downloadExecutor.isShutdown(); } diff --git a/core/src/main/java/pl/skidam/automodpack_core/client/LaunchProgress.java b/core/src/main/java/pl/skidam/automodpack_core/client/LaunchProgress.java new file mode 100644 index 000000000..5b81be066 --- /dev/null +++ b/core/src/main/java/pl/skidam/automodpack_core/client/LaunchProgress.java @@ -0,0 +1,139 @@ +package pl.skidam.automodpack_core.client; + +import static pl.skidam.automodpack_core.Constants.LOGGER; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import org.jetbrains.annotations.Nullable; + +import pl.skidam.automodpack_core.utils.ByteFormat; + +/** + * What the launch-time update is doing right now, for loaders that can draw on screen before the game window exists + * (Forge and NeoForge early loading screens). Core only records the stage; a loader adapter {@link #attach attaches} + * a {@link Display} around {@code Preload}, and a daemon poller pushes the current line to it. Without an attached + * display every call here is a cheap field write, so the update logic stays loader-agnostic. + */ +public final class LaunchProgress { + /** A loader's early-screen sink. Called from the poller thread only. */ + public interface Display { + /** {@code permille} is 0..1000 for a determinate step, or -1 while the step has no measurable progress. */ + void update(String label, int permille); + + /** Removes whatever the display added; called once when the launch phase ends. */ + void close(); + } + + private static final long POLL_INTERVAL_MS = 100; + /** How long a terminal message (restart notice) stays readable before the process exits. */ + private static final long HOLD_BEFORE_EXIT_MS = TimeUnit.SECONDS.toMillis(5); + + private static volatile @Nullable Display display; + private static volatile @Nullable Thread poller; + private static volatile String stage = ""; + private static volatile @Nullable DownloadManager downloads; + + private LaunchProgress() {} + + /** Starts pushing progress to {@code newDisplay}. A null display (loader has no early screen) keeps everything a no-op. */ + public static synchronized void attach(@Nullable Display newDisplay) { + if (newDisplay == null || display != null) return; + display = newDisplay; + stage("Starting"); + Thread thread = new Thread(LaunchProgress::pollLoop, "AutoModpack early progress"); + thread.setDaemon(true); + poller = thread; + thread.start(); + } + + /** Stops the poller and lets the display clean up. Safe to call when nothing is attached. */ + public static synchronized void detach() { + Display current = display; + Thread thread = poller; + display = null; + poller = null; + downloads = null; + stage = ""; + if (thread != null) { + thread.interrupt(); + try { + thread.join(POLL_INTERVAL_MS * 5); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + if (current != null) { + try { + current.close(); + } catch (RuntimeException e) { + LOGGER.debug("Early progress display failed to close", e); + } + } + } + + public static boolean isDisplayed() { + return display != null; + } + + /** Names the step now running; shown as an indeterminate line until a download attaches measurable progress. */ + public static void stage(String text) { + stage = Objects.requireNonNull(text, "text"); + } + + /** While set, the line shows this download's bytes, files, speed and ETA instead of the bare stage. */ + public static void downloading(@Nullable DownloadManager manager) { + downloads = manager; + } + + /** + * Shows a final message and keeps it on screen for a few seconds, for the moment right before the launch exits on + * its own (a required restart). Returns immediately when no display is attached. + */ + public static void holdFinalMessage(String text) { + if (display == null) return; + downloads = null; + stage(text); + pushNow(); + try { + Thread.sleep(HOLD_BEFORE_EXIT_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static void pollLoop() { + while (!Thread.currentThread().isInterrupted()) { + pushNow(); + try { + Thread.sleep(POLL_INTERVAL_MS); + } catch (InterruptedException e) { + return; + } + } + } + + private static void pushNow() { + Display current = display; + if (current == null) return; + try { + DownloadManager manager = downloads; + if (manager != null) current.update(downloadLine(manager), (int) Math.round(manager.getPrecisePercentage() * 10)); + else current.update("AutoModpack: " + stage, -1); + } catch (RuntimeException e) { + // The early screen is a convenience: a broken display must never take the update down with it. + LOGGER.debug("Early progress display update failed", e); + } + } + + static String downloadLine(DownloadManager manager) { + StringBuilder line = new StringBuilder("AutoModpack: downloading "); + line.append(ByteFormat.formatSize(manager.getDownloadedBytes())).append(" / ").append(ByteFormat.formatSize(manager.getTotalBytes())); + line.append(" (").append(manager.getStage()).append(" files)"); + long speed = manager.getDownloadSpeed(); + if (speed > 0) line.append(" - ").append(ByteFormat.formatSpeed(speed)); + long eta = manager.getETA(); + if (eta > 0) line.append(" - ").append(ByteFormat.formatETA(eta)).append(" left"); + return line.toString(); + } +} diff --git a/core/src/main/java/pl/skidam/automodpack_core/client/ModpackObjectAcquisition.java b/core/src/main/java/pl/skidam/automodpack_core/client/ModpackObjectAcquisition.java index 47f20eae2..3cfbda9c8 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/client/ModpackObjectAcquisition.java +++ b/core/src/main/java/pl/skidam/automodpack_core/client/ModpackObjectAcquisition.java @@ -110,6 +110,7 @@ int acquireTargetObjects(ModpackJsons.ModpackContentFields target, FileCache cac requireTransferSession(); long start = System.currentTimeMillis(); long totalBytes = ModpackUtils.remainingUncachedBytes(missing, storage); + if (!playerFacing) LaunchProgress.stage("finding download sources for " + missing.size() + " files"); if (playerFacing) WaitingMusic.start(transport, storage, waitingMusicSha1); FetchManager fetchManager = sourceCatalogue.sourceFetch(missing); try { @@ -151,6 +152,7 @@ private boolean downloadModpack(Set {}, failureCallback); } - downloadManager.joinAll(); + try { + downloadManager.joinAll(); + } finally { + if (!playerFacing) LaunchProgress.downloading(null); + } LOGGER.info("Finished downloading files in {}ms", System.currentTimeMillis() - startFetching); if (downloadManager.isCancelled()) { LOGGER.info("Download canceled"); diff --git a/core/src/main/java/pl/skidam/automodpack_core/client/ModpackUpdater.java b/core/src/main/java/pl/skidam/automodpack_core/client/ModpackUpdater.java index 5a7348efe..a99278920 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/client/ModpackUpdater.java +++ b/core/src/main/java/pl/skidam/automodpack_core/client/ModpackUpdater.java @@ -396,6 +396,7 @@ private void launchApply(boolean applyFirstInstall, boolean offline) throws Exce return; } long start = System.currentTimeMillis(); + LaunchProgress.stage("preparing modpack update"); sourceCatalogue.startSourceFetch(); UpdateSession launch = beginUpdateAttempt(); launch.prepare(false, false); @@ -414,7 +415,9 @@ private void launchApply(boolean applyFirstInstall, boolean offline) throws Exce return; } launch.approve(); + LaunchProgress.stage("applying modpack update"); ApplyResult applyResult = commitFlow(launch); + LaunchProgress.stage("modpack update applied"); LOGGER.info("Launch apply completed; restart demand: {} Took: {}ms", RestartPolicy.atPreload(applyResult.restartReasons()), System.currentTimeMillis() - start); finishLaunchApply(applyResult); } diff --git a/core/src/main/java/pl/skidam/automodpack_core/client/ReLauncher.java b/core/src/main/java/pl/skidam/automodpack_core/client/ReLauncher.java index 8b70c0da6..6e78b5cfc 100644 --- a/core/src/main/java/pl/skidam/automodpack_core/client/ReLauncher.java +++ b/core/src/main/java/pl/skidam/automodpack_core/client/ReLauncher.java @@ -57,6 +57,9 @@ private void handleClientRestart(Runnable[] callbacks, boolean isHeadless) { if (isHeadless) { LOGGER.info("Please restart the game to apply updates!"); + // Headless means no Swing restart window; the early loading screen (when a loader attached one) is the only + // place the player can read why the game is about to close. + LaunchProgress.holdFinalMessage("modpack updated - the game will close, launch it again to play"); } else { semaphore = ScreenManager.preloadRestart(updateMessage); } diff --git a/loader/forge/earlyservices/src/main/java/pl/skidam/automodpack_loader_core_forge/AutoModpackTransformationService.java b/loader/forge/earlyservices/src/main/java/pl/skidam/automodpack_loader_core_forge/AutoModpackTransformationService.java index 3ae728c32..5fbc24923 100644 --- a/loader/forge/earlyservices/src/main/java/pl/skidam/automodpack_loader_core_forge/AutoModpackTransformationService.java +++ b/loader/forge/earlyservices/src/main/java/pl/skidam/automodpack_loader_core_forge/AutoModpackTransformationService.java @@ -10,11 +10,13 @@ import cpw.mods.modlauncher.api.ITransformer; import pl.skidam.automodpack_core.Preload; +import pl.skidam.automodpack_core.client.LaunchProgress; import pl.skidam.automodpack_core.loader.EarlyLaunchEnvironment; import pl.skidam.automodpack_core.loader.GenerationProbes; import pl.skidam.automodpack_core.loader.TargetId; import pl.skidam.automodpack_loader_core_forge.loader.LoaderManager; import pl.skidam.automodpack_loader_core_forge.mods.ModpackLoader; +import pl.skidam.automodpack_loader_core_modlauncher.EarlyProgressDisplay; import pl.skidam.automodpack_loader_core_modlauncher.ModLauncherEarlyServiceBridge; import pl.skidam.automodpack_loader_core_modlauncher.ModuleClassLoaderAccess; @@ -66,7 +68,13 @@ public void onLoad(IEnvironment env, Set otherServices) { TargetId.id("forge", EarlyLaunchEnvironment.MC_VERSION); - new Preload(new LoaderManager(), new ModpackLoader()); + // Forge 1.20.1+ has an early loading window; the launch update can take minutes, so show what it is doing. + LaunchProgress.attach(EarlyProgressDisplay.create(AutoModpackTransformationService.class.getClassLoader())); + try { + new Preload(new LoaderManager(), new ModpackLoader()); + } finally { + LaunchProgress.detach(); + } EarlyServiceLayer.bootstrap(); ModLauncherEarlyServiceBridge.forEachTransformationService("onLoad", service -> service.onLoad(env, otherServices)); } diff --git a/loader/modlauncher/earlyservices/src/main/java/pl/skidam/automodpack_loader_core_modlauncher/EarlyProgressDisplay.java b/loader/modlauncher/earlyservices/src/main/java/pl/skidam/automodpack_loader_core_modlauncher/EarlyProgressDisplay.java new file mode 100644 index 000000000..a11101106 --- /dev/null +++ b/loader/modlauncher/earlyservices/src/main/java/pl/skidam/automodpack_loader_core_modlauncher/EarlyProgressDisplay.java @@ -0,0 +1,73 @@ +package pl.skidam.automodpack_loader_core_modlauncher; + +import static pl.skidam.automodpack_core.Constants.LOGGER; + +import java.lang.reflect.Method; + +import pl.skidam.automodpack_core.client.LaunchProgress; + +/** + * Draws {@link LaunchProgress} on the loader's early loading window through its {@code StartupNotificationManager}. The + * API is looked up reflectively because this class is compiled once for every ModLauncher-era loader: legacy Forge + * before 1.20.1 has no early window at all, and NeoForge publishes the same surface under its own package. Where the + * lookup fails, {@link #create} returns null and {@link LaunchProgress#attach} stays a no-op. + */ +public final class EarlyProgressDisplay implements LaunchProgress.Display { + private static final String[] NOTIFICATION_MANAGERS = {"net.minecraftforge.fml.loading.progress.StartupNotificationManager", + "net.neoforged.fml.loading.progress.StartupNotificationManager"}; + private static final int STEPS = 1000; + + private final Object meter; + private final Method setAbsolute; + private final Method label; + private final Method complete; + private final Method popBar; + + private EarlyProgressDisplay(Object meter, Method setAbsolute, Method label, Method complete, Method popBar) { + this.meter = meter; + this.setAbsolute = setAbsolute; + this.label = label; + this.complete = complete; + this.popBar = popBar; + } + + /** A display bound to a fresh bar on the early window, or null when this loader has no usable early window API. */ + public static EarlyProgressDisplay create(ClassLoader loader) { + for (String name : NOTIFICATION_MANAGERS) { + try { + Class manager = Class.forName(name, false, loader); + Method addBar = manager.getMethod("prependProgressBar", String.class, int.class); + Object meter = addBar.invoke(null, "AutoModpack", STEPS); + Class meterType = meter.getClass(); + return new EarlyProgressDisplay(meter, meterType.getMethod("setAbsolute", int.class), meterType.getMethod("label", String.class), meterType.getMethod("complete"), + manager.getMethod("popBar", meterType)); + } catch (ClassNotFoundException e) { + // Not this loader's package; try the next one. + } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + LOGGER.debug("Early loading window progress is unavailable via {}", name, e); + return null; + } + } + return null; + } + + @Override + public void update(String text, int permille) { + try { + label.invoke(meter, text); + setAbsolute.invoke(meter, permille < 0 ? 0 : Math.min(permille, STEPS)); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Early progress bar rejected an update", e); + } + } + + @Override + public void close() { + try { + complete.invoke(meter); + popBar.invoke(null, meter); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Early progress bar could not be removed", e); + } + } +} diff --git a/loader/neoforge/earlyservices/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java b/loader/neoforge/earlyservices/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java index 6525f1c48..4c9f35089 100644 --- a/loader/neoforge/earlyservices/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java +++ b/loader/neoforge/earlyservices/src/main/java/pl/skidam/automodpack_loader_core_neoforge/EarlyModLocator.java @@ -20,6 +20,7 @@ import pl.skidam.automodpack_core.Constants; import pl.skidam.automodpack_core.Preload; +import pl.skidam.automodpack_core.client.LaunchProgress; import pl.skidam.automodpack_core.loader.ConnectorFallback; import pl.skidam.automodpack_core.loader.EarlyLaunchEnvironment; import pl.skidam.automodpack_core.loader.GenerationProbes; @@ -68,9 +69,24 @@ public void findCandidates(ILaunchContext context, IDiscoveryPipeline pipeline) // Run our own update/reconcile step first: it decides what this launch loads, and the // early-service hosting below hosts only jars from that decision. Preload failures must crash // the launch; swallowing them would boot without the pack. - ProgressMeter progress = StartupNotificationManager.prependProgressBar("[Automodpack] Preload", 0); - new Preload(new LoaderManager(), new ModpackLoader()); - progress.complete(); + ProgressMeter progress = StartupNotificationManager.prependProgressBar("[Automodpack] Preload", 1000); + LaunchProgress.attach(new LaunchProgress.Display() { + @Override + public void update(String label, int permille) { + progress.label(label); + progress.setAbsolute(Math.max(0, permille)); + } + + @Override + public void close() { + progress.complete(); + } + }); + try { + new Preload(new LoaderManager(), new ModpackLoader()); + } finally { + LaunchProgress.detach(); + } try { // Early-service hosting serves the client's active projection; a dedicated server has none. diff --git a/loader/neoforge/fml4/src/main/java/pl/skidam/automodpack_loader_core_neoforge_4/EarlyServiceBootstrapper.java b/loader/neoforge/fml4/src/main/java/pl/skidam/automodpack_loader_core_neoforge_4/EarlyServiceBootstrapper.java index dd167fad0..326945cd4 100644 --- a/loader/neoforge/fml4/src/main/java/pl/skidam/automodpack_loader_core_neoforge_4/EarlyServiceBootstrapper.java +++ b/loader/neoforge/fml4/src/main/java/pl/skidam/automodpack_loader_core_neoforge_4/EarlyServiceBootstrapper.java @@ -3,16 +3,16 @@ import java.nio.file.Path; import java.util.List; -import net.neoforged.fml.loading.progress.ProgressMeter; -import net.neoforged.fml.loading.progress.StartupNotificationManager; import net.neoforged.neoforgespi.earlywindow.GraphicsBootstrapper; import pl.skidam.automodpack_core.Constants; import pl.skidam.automodpack_core.Preload; +import pl.skidam.automodpack_core.client.LaunchProgress; import pl.skidam.automodpack_core.loader.EarlyLaunchEnvironment; import pl.skidam.automodpack_core.loader.GenerationProbes; import pl.skidam.automodpack_core.loader.LoaderManagerService; import pl.skidam.automodpack_core.loader.TargetId; +import pl.skidam.automodpack_loader_core_modlauncher.EarlyProgressDisplay; import pl.skidam.automodpack_loader_core_modlauncher.EarlyServiceBridgePlugin; import pl.skidam.automodpack_loader_core_neoforge_4.loader.LoaderManager; import pl.skidam.automodpack_loader_core_neoforge_4.mods.ModpackLoader; @@ -54,9 +54,12 @@ public void bootstrap(String[] arguments) { // Run our own update/reconcile step first: it decides what this launch loads, and the // early-service hosting below hosts only jars from that decision. Preload failures must crash // the launch; swallowing them would boot without the pack. - ProgressMeter progress = StartupNotificationManager.prependProgressBar("[Automodpack] Preload", 0); - new Preload(new LoaderManager(), new ModpackLoader()); - progress.complete(); + LaunchProgress.attach(EarlyProgressDisplay.create(EarlyServiceBootstrapper.class.getClassLoader())); + try { + new Preload(new LoaderManager(), new ModpackLoader()); + } finally { + LaunchProgress.detach(); + } try { // Early-service hosting serves the client's active projection; a dedicated server has none.