#requires -Version 7 <# .SYNOPSIS Audit pack.lock.json for missing required mod dependencies by reading each jar's mods.toml (and any jar-in-jar bundles) directly. Loader-truth, not Modrinth-truth. .DESCRIPTION Each NeoForge/Forge mod declares its real dependencies in META-INF/neoforge.mods.toml (or META-INF/mods.toml) inside the jar. This is what the loader actually checks at startup; the Modrinth dependency field is metadata authors fill in (or don't). The script also recurses into META-INF/jarjar/ bundles. Big mods like Create ship their deps (Registrate, Flywheel, Ponder) embedded as jar-in-jar -- the loader auto-loads them, so their modIds count as "present" without explicit lockfile entries. Slug filter: only entries under "mods/" are treated as mod jars. Shaderpacks and other managed-but-non-mod files are skipped. Language-provider jars (e.g. Kotlin for Forge) ship without a standard mods.toml; their modIds are added via a tiny override map. Jars are cached in /tmp/bns-check-deps-cache/.jar so subsequent runs are instant. .PARAMETER LockPath .PARAMETER CacheDir .PARAMETER WarnOnly Print missing deps but exit 0. Deploy-Brass.ps1 calls without it. #> [CmdletBinding()] param( [string]$LockPath = $(Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) '..\pack\pack.lock.json'), [string]$CacheDir = '/tmp/bns-check-deps-cache', [switch]$WarnOnly ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $LockPath = (Resolve-Path $LockPath).Path $lock = Get-Content $LockPath -Raw | ConvertFrom-Json -Depth 20 if (-not $lock.mods) { throw "pack.lock.json has no 'mods' array." } if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir | Out-Null } # Vanilla / loader IDs are always satisfied -- they're never modpack mods. $vanillaIds = @('minecraft','neoforge','forge','fabric','fabricloader','quilt','java','javafml','lowcodefml') # Language-provider / library jars that ship without a standard mods.toml. # Map their lockfile slug to the modId they register at runtime. Add entries # here as new ones are encountered. $slugToImplicitModId = @{ 'kotlin-for-forge' = 'kotlinforforge' } Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem function Get-JarPath { param($mod) $cached = Join-Path $CacheDir ("$($mod.sha1).jar") if (Test-Path $cached) { return $cached } Write-Host " downloading $($mod.slug) ..." -ForegroundColor DarkGray try { Invoke-WebRequest -Uri $mod.url -OutFile $cached -TimeoutSec 60 } catch { throw "Failed to download $($mod.url): $($_.Exception.Message)" } return $cached } # Read a specific entry's bytes from an already-open ZipArchive into memory. function Read-ZipEntry { param([System.IO.Compression.ZipArchive]$Zip, [string]$EntryName) $entry = $Zip.GetEntry($EntryName) if (-not $entry) { return $null } $ms = New-Object System.IO.MemoryStream $s = $entry.Open() try { $s.CopyTo($ms) } finally { $s.Dispose() } return $ms.ToArray() } function Read-ZipEntryText { param([System.IO.Compression.ZipArchive]$Zip, [string]$EntryName) $entry = $Zip.GetEntry($EntryName) if (-not $entry) { return $null } $reader = New-Object System.IO.StreamReader($entry.Open()) try { return $reader.ReadToEnd() } finally { $reader.Dispose() } } # Tiny TOML reader -- only the bits we need (sections + key="value" lines). function Parse-ModsToml { param([string]$Text) $modIds = @() $deps = @() $section = $null $current = $null function Flush-Record { param($refModIds, $refDeps, $sec, $cur) if (-not $cur) { return } if ($sec -eq 'mods' -and $cur.modId) { $refModIds.Value += $cur.modId } elseif ($sec -like 'dep:*' -and $cur.modId) { $refDeps.Value += [pscustomobject]@{ parent = $sec.Substring(4) modId = $cur.modId type = $cur.type mandatory = $cur.mandatory versionRange = $cur.versionRange } } } $modIdsRef = [ref]$modIds $depsRef = [ref]$deps foreach ($raw in ($Text -split "`n")) { $line = $raw.Trim() if (-not $line -or $line.StartsWith('#')) { continue } if ($line.StartsWith('[[')) { Flush-Record $modIdsRef $depsRef $section $current $current = $null if ($line -match '^\[\[mods\]\]') { $section = 'mods'; $current = @{} } elseif ($line -match '^\[\[dependencies\.([^\]]+)\]\]') { $section = "dep:$($Matches[1])"; $current = @{} } else { $section = $null } continue } if (-not $current) { continue } if ($line -match '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$') { $k = $Matches[1]; $v = $Matches[2].Trim() if ($v.StartsWith('"') -and $v.EndsWith('"')) { $v = $v.Substring(1, $v.Length - 2) } $current[$k] = $v } } Flush-Record $modIdsRef $depsRef $section $current return [pscustomobject]@{ ModIds = $modIdsRef.Value; Deps = $depsRef.Value } } function Is-Required { param($dep) if ($dep.type) { return ($dep.type -eq 'required' -or $dep.type -eq 'mandatory') } if ($dep.mandatory) { return ($dep.mandatory -eq 'true') } return $true # NeoForge default } # Recursively walk a jar (and its META-INF/jarjar/ embedded jars) collecting # every modId declared by mods.toml inside. Used to satisfy deps that ship # bundled (e.g. ponder/registrate/flywheel inside Create). function Collect-Jar-Identity { param([string]$JarPath) $modIds = @() $deps = @() $zip = [System.IO.Compression.ZipFile]::OpenRead($JarPath) try { # 1) This jar's own mods.toml (if any). $toml = Read-ZipEntryText -Zip $zip -EntryName 'META-INF/neoforge.mods.toml' if (-not $toml) { $toml = Read-ZipEntryText -Zip $zip -EntryName 'META-INF/mods.toml' } if ($toml) { $parsed = Parse-ModsToml -Text $toml $modIds += $parsed.ModIds $deps += $parsed.Deps } # 2) jar-in-jar bundles -- recursively extract + collect modIds (we only # need their modIds; their deps are the parent's problem to satisfy). $jarjarEntries = $zip.Entries | Where-Object { $_.FullName -like 'META-INF/jarjar/*.jar' } foreach ($e in $jarjarEntries) { $tmpInner = [System.IO.Path]::GetTempFileName() + '.jar' $s = $e.Open() try { $fs = [System.IO.File]::Open($tmpInner, [System.IO.FileMode]::Create) try { $s.CopyTo($fs) } finally { $fs.Dispose() } } finally { $s.Dispose() } $inner = Collect-Jar-Identity -JarPath $tmpInner $modIds += $inner.ModIds # bundled deps count as present # Don't propagate bundled deps -- they're satisfied within this jar Remove-Item $tmpInner -Force -ErrorAction SilentlyContinue } } finally { $zip.Dispose() } return [pscustomobject]@{ ModIds = ($modIds | Select-Object -Unique); Deps = $deps } } Write-Host "" Write-Host "Auditing $($lock.mods.Count) lockfile entries (parsing each jar's mods.toml + jar-in-jar bundles)..." Write-Host "" $presentModIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) foreach ($v in $vanillaIds) { [void]$presentModIds.Add($v) } # parent modId -> list of dep records (so the missing report blames a modId, not a slug) $jarDepsByParentModId = @{} $skipped = @() # informational $count = 0 foreach ($m in $lock.mods) { $count++ # Only consider entries that live under mods/ -- shaderpacks, configs etc. # are managed by the launcher but aren't mods. if (-not ($m.path -like 'mods/*')) { $skipped += "$($m.slug): not under mods/ ($($m.path))" continue } # Language-provider override (kotlin-for-forge etc.) if ($slugToImplicitModId.ContainsKey($m.slug)) { [void]$presentModIds.Add($slugToImplicitModId[$m.slug]) } try { $jar = Get-JarPath -mod $m $ident = Collect-Jar-Identity -JarPath $jar foreach ($id in $ident.ModIds) { [void]$presentModIds.Add($id) } foreach ($id in $ident.ModIds) { $jarDepsByParentModId[$id] = $ident.Deps } if (-not $ident.ModIds -and -not $slugToImplicitModId.ContainsKey($m.slug)) { $skipped += "$($m.slug): no modId discovered (no mods.toml; consider adding to override map)" } } catch { $skipped += "$($m.slug): $($_.Exception.Message)" } if ($count % 10 -eq 0) { Write-Host " ...processed $count/$($lock.mods.Count)" -ForegroundColor DarkGray } } # Pass 2: check each parent's required deps against the present set. $missing = @{} foreach ($parentId in $jarDepsByParentModId.Keys) { foreach ($dep in $jarDepsByParentModId[$parentId]) { if (-not (Is-Required $dep)) { continue } if (-not $presentModIds.Contains($dep.modId)) { if (-not $missing.ContainsKey($dep.modId)) { $missing[$dep.modId] = @{ parents = @() } } $missing[$dep.modId].parents += $parentId } } } Write-Host "" Write-Host ("=" * 60) if ($missing.Count -eq 0) { Write-Host "All required deps satisfied. ($($presentModIds.Count) mod IDs in pack, incl. bundled jar-in-jar)" -ForegroundColor Green } else { Write-Host "MISSING REQUIRED DEPS: $($missing.Count) unique" -ForegroundColor Red Write-Host "" foreach ($depId in ($missing.Keys | Sort-Object)) { $parents = ($missing[$depId].parents | Sort-Object -Unique) -join ', ' Write-Host " $depId" -ForegroundColor Red Write-Host " needed by: $parents" -ForegroundColor DarkGray } } if ($skipped.Count -gt 0) { Write-Host "" Write-Host "Informational (skipped / unanalysable):" -ForegroundColor DarkGray foreach ($s in $skipped) { Write-Host " - $s" -ForegroundColor DarkGray } } if ($missing.Count -gt 0 -and -not $WarnOnly) { throw "Missing required dependencies. Add them to pack/pack.lock.json (or pass -WarnOnly to bypass)." }