Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -151,6 +152,7 @@ private boolean downloadModpack(Set<ModpackJsons.ModpackContentFields.ModpackCon

downloadManager = new DownloadManager(totalBytes, storage.dataLocation().layout(), platformCache);
if (playerFacing) ScreenManager.download(downloadManager, modpackName.get(), onPlayerCancel);
else LaunchProgress.downloading(downloadManager);
downloadManager.attachTransport(transport);
for (var serverItem : files) {
Path downloadFile = storage.activePath(serverItem.file);
Expand All @@ -163,7 +165,11 @@ private boolean downloadModpack(Set<ModpackJsons.ModpackContentFields.ModpackCon
downloadManager.download(downloadFile, serverItem.sha1, serverItem.murmur, serverItem.type, sources, serverItem.size, () -> {}, 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -66,7 +68,13 @@ public void onLoad(IEnvironment env, Set<String> 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));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down