The install pipeline downloads and verifies every game file before the
JVM is spawned. It's 8-way parallel, idempotent (SHA1 verification),
and emits per-bucket events when the events feature is on.
Installer::install(&self, &Version, event_bus)
├── Phase 0: resolve_extra_mods
│ ├── [optional] Modpack pipeline (feature = "modrinth" | "curseforge")
│ │ └─► Vec<Mods> merged first (user mods win on filename dedup)
│ └── [optional] User-mod resolver (feature = "modrinth" | "curseforge")
│ └─► lighty_modsloader::resolver::resolve — BFS, dedup by ModKey
│
├── Phase 1: Verification
│ └── 8-way tokio::join! — every bucket walks its slice concurrently
│ Each bucket returns (Vec<(url, path)>, bytes) for missing/outdated files
│
├── Phase 2: Decision
│ └── If total_downloads == 0 → emit IsInstalled, re-extract natives, return
│
└── Phase 3: Parallel download
└── 8-way tokio::try_join! — libraries, natives, client, assets,
mods, resourcepacks, shaderpacks, datapacks
The orchestrator lives in
crates/launch/src/installer/installer.rs. Installer::install is
blanket-implemented for any T: VersionInfo<LoaderType = Loader> + WithMods.
| Bucket | Layout | Source | Notes |
|---|---|---|---|
| Libraries | {game_dir}/libraries/<maven-path> |
loader metadata | 100-300 files; SHA1 verified |
| Natives | {game_dir}/natives/ + extracted to temp dir per launch |
loader metadata | LWJGL .dll / .so / .dylib; extracted every launch (clean state) |
| Client | {game_dir}/versions/{version}/{version}.jar |
loader metadata | ~20-30 MB |
| Assets | {game_dir}/assets/objects/<aa>/<hash> |
Mojang asset index | 3 000 – 10 000 files |
| Mods | {runtime_dir}/mods/ |
LightyUpdater + Modrinth/CurseForge resolver | Legacy fallback: unqualified path = filename mapped to mods/<filename> |
| Resource packs | {runtime_dir}/resourcepacks/ |
mods slice (qualified path) | Strict prefix match |
| Shader packs | {runtime_dir}/shaderpacks/ |
mods slice (qualified path) | Strict prefix match |
| Datapacks | {runtime_dir}/datapacks/ |
mods slice (qualified path) | Strict prefix match |
All four mod-like buckets share a single helper at
crates/launch/src/installer/ressources/asset_partition.rs:
pub(super) async fn collect<V: VersionInfo>(
version: &V,
mods: &[Mods],
subdir: &str,
legacy_fallback: bool,
) -> (Vec<(String, PathBuf)>, u64);
pub(super) async fn download(
tasks: Vec<(String, PathBuf)>,
label: &str,
#[cfg(feature = "events")] event_bus: Option<&EventBus>,
) -> InstallerResult<()>;Each bucket file (mods.rs, resourcepacks.rs, …) is a thin wrapper
pinning its own subdir prefix. Only mods enables legacy_fallback
— the routing migration is documented in ASSETS_ROUTING.md at the
workspace root.
Before any download, the verifier reads the file at the destination path and compares the SHA1 against the metadata value. Three outcomes:
- Missing → enqueue download.
- SHA1 mismatch → enqueue redownload.
- Match → skip.
When every bucket comes back with zero tasks, the installer emits
LaunchEvent::IsInstalled and short-circuits. Natives are always
re-extracted into a fresh temp dir per launch (the JVM unpacks them
from a clean directory each time to avoid LWJGL conflicts).
Lives in crates/launch/src/installer/ressources/modpack.rs. Runs
before the user-mod resolver, so its files end up in Version.mods
in time for the standard download pipeline. Activated by the
modrinth and / or curseforge Cargo feature (no separate modpack
feature — enabling a provider activates its modpack format parser).
Pipeline:
- Resolve archive URL —
ModrinthUrl,ModrinthPinned { project, version }(API call), orCurseForgePinned { project_id, file_id }(API call, needsset_api_key). - Cache lookup —
<cache_dir>/modpacks/<url_sha1>.archive+.installedmarker. Marker match → skip everything (idempotent). - Download archive (if cache miss).
- Extract into
<cache_dir>/modpacks/work-<sha1>/. - Parse manifest —
modrinth.index.jsonvsmanifest.json. - Reconcile loader / MC version with the
VersionBuilder(builder wins; mismatches log atrace_warn). - Convert files →
Vec<Mods>:- Modrinth: every
files[]entry withenv.client == "required". - CurseForge: resolve each
(projectID, fileID)via the API. Ifdownload_urlis null (third-party distribution disabled), fail fast withQueryError::ModDistributionForbidden.
- Modrinth: every
- Extract
overrides/(andclient-overrides/on Modrinth) intoversion.runtime_dir(). Existing user files are never overwritten — they're kept and the override is skipped with a warning. - Write the
.installedmarker so subsequent runs no-op. - Clean up the
work-<sha1>/directory.
Force re-install: delete
{cache_dir}/modpacks/<sha1>.installed (or wipe modpacks/).
Modpack events (with events feature) live under ModloaderEvent —
see events.md.
Single shared downloader in lighty-core with bounded concurrency,
3 retries per file, and chunked writes. When the events feature is
on, each chunk emits LaunchEvent::InstallProgress { bytes } — clients
sum that against total_bytes from InstallStarted to drive a
progress bar.
calculate_download_size only walks metadata for libraries, client
JAR, assets and natives. The mod-like total is a single pre-summed
mod_like_bytes: u64 returned by the four bucket collectors —
avoiding an O(N·M) re-scan of the Mods slice:
let mod_like = mod_bytes
+ resourcepack_bytes
+ shaderpack_bytes
+ datapack_bytes;Installer::install only handles the universal pipeline. Loaders that
need a post-step do it from execute_launch after install():
- Forge (1.13+) — download
install_profilelibraries, then run Forge install processors via aJava -jarexec. - Forge legacy (1.7.10 – 1.12.2) — no processors; extract the bundled universal JAR to its Maven path so the classpath resolves.
- NeoForge — same shape as modern Forge.
The Cargo forge feature covers both modern and legacy Forge.
Per-loader processor mechanics are documented in
crates/loaders/docs/loaders/forge.md
and neoforge.md.
{game_dir}/
├── libraries/
├── natives/
├── assets/{indexes,objects}/
├── versions/<version>/
├── mods/ (runtime_dir)
├── resourcepacks/ (runtime_dir)
├── shaderpacks/ (runtime_dir)
└── datapacks/ (runtime_dir)
runtime_dir() defaults to game_dirs() but the runner may rewrite it
via set_runtime_dir() when the caller overrides KEY_GAME_DIRECTORY
on the ArgumentsBuilder (relative override resolves under
game_dirs, absolute override wins outright).
# use lighty_core::AppState;
# use lighty_launch::errors::InstallerResult;
# use lighty_loaders::types::Loader;
# use lighty_loaders::types::version_metadata::VersionMetaData;
# use lighty_version::VersionBuilder;
use lighty_launch::installer::Installer;
# async fn run() -> InstallerResult<()> {
# AppState::init("MyLauncher").ok();
let mut instance = VersionBuilder::new("inst", Loader::Fabric, "0.16.9", "1.21.1");
let metadata = instance.get_metadata().await?;
if let VersionMetaData::Version(v) = metadata.as_ref() {
instance.install(v, #[cfg(feature = "events")] None).await?;
}
# Ok(()) }pub enum InstallerError {
DownloadFailed(String),
VerificationFailed(String),
ExtractionFailed(String),
InvalidMetadata,
NoPid,
IOError(std::io::Error),
// …
}- Launch — where
install()sits in the pipeline - Events —
LaunchEvent::Install*+ModloaderEvent::* - Arguments —
${classpath}build,KEY_GAME_DIRECTORYoverride - Loaders:
crates/loaders/docs/loaders/ - Mods / modpacks:
crates/modsloader/docs/