Files
packwiz/core/versionutil.go
2026-02-18 23:53:42 +01:00

370 lines
12 KiB
Go

package core
import (
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"strings"
"github.com/unascribed/FlexVer/go/flexver"
)
type MavenMetadata struct {
XMLName xml.Name `xml:"metadata"`
GroupID string `xml:"groupId"`
ArtifactID string `xml:"artifactId"`
Versioning struct {
Release string `xml:"release"`
Latest string `xml:"latest"`
Versions struct {
Version []string `xml:"version"`
} `xml:"versions"`
LastUpdated string `xml:"lastUpdated"`
} `xml:"versioning"`
}
type ModLoaderVersions struct {
// All Versions of this modloader
Versions []string
// The Latest/preferred version for this modloader
Latest string
}
type ModLoaderComponent struct {
// An identifier for the modloader
Name string
// A user-friendly name
FriendlyName string
// Retrieves the list of all modloader versions. Modloader versions are always filtered to those compatible
// with a specific minecraft version.
VersionListGetter func(q VersionListQuery) (*ModLoaderVersions, error)
}
var modLoadersList = []ModLoaderComponent{
{
// There's no need to specify yarn version - yarn isn't used outside a dev environment, and intermediary corresponds to game version anyway
Name: "fabric",
FriendlyName: "Fabric loader",
VersionListGetter: func(q VersionListQuery) (*ModLoaderVersions, error) {
// Fabric loaders isn't locked to a mc version per se
return fetchVersionsFromMaven(q, "https://maven.fabricmc.net/net/fabricmc/fabric-loader/maven-metadata.xml")
},
},
{
Name: "forge",
FriendlyName: "Forge",
VersionListGetter: fetchForForge,
},
{
Name: "liteloader",
FriendlyName: "LiteLoader",
VersionListGetter: func(q VersionListQuery) (*ModLoaderVersions, error) {
return fetchLiteloaderStyle(q, "https://repo.mumfrey.com/content/repositories/snapshots/com/mumfrey/liteloader/maven-metadata.xml")
},
},
{
Name: "quilt",
FriendlyName: "Quilt loader",
VersionListGetter: func(q VersionListQuery) (*ModLoaderVersions, error) {
return fetchVersionsFromMaven(q, "https://maven.quiltmc.org/repository/release/org/quiltmc/quilt-loader/maven-metadata.xml")
},
},
{
Name: "neoforge",
FriendlyName: "NeoForge",
VersionListGetter: fetchForNeoForge,
},
}
// A map containing information about all supported modloaders.
// Can be indexed by the [ModLoaderComponent]'s name, which serves as an identifier.
var ModLoaders = createModloaderMap(modLoadersList)
func createModloaderMap(input []ModLoaderComponent) map[string]ModLoaderComponent {
var mlMap = make(map[string]ModLoaderComponent)
for _, loader := range input {
mlMap[loader.Name] = loader
}
return mlMap
}
type QueryType int
const (
// The Latest field will contain the last released loader version
Latest QueryType = iota
// The Latest field will contain the loader version recommended for use
Recommended
)
type VersionListQuery struct {
// Which loader to query versions for
Loader ModLoaderComponent
// Which minecraft version the returned loader versions should be compatible with
McVersion string
// Determines how the latest version is determined
QueryType QueryType
}
func MakeQuery(loader ModLoaderComponent, mcVersion string) VersionListQuery {
return VersionListQuery{
Loader: loader,
McVersion: mcVersion,
QueryType: Latest,
}
}
func (in VersionListQuery) WithQueryType(queryType QueryType) VersionListQuery {
return VersionListQuery{
Loader: in.Loader,
McVersion: in.McVersion,
QueryType: queryType,
}
}
// Queries the versions of a modloader
func DoQuery(q VersionListQuery) (*ModLoaderVersions, error) {
return q.Loader.VersionListGetter(q)
}
// Retrieve a list of versions from maven, with no filtering or processing of the maven data
func fetchVersionsFromMaven(q VersionListQuery, url string) (*ModLoaderVersions, error) {
identity_function := func(version string) *string { return &version }
return fetchMavenWithFilterMap(q, url, identity_function)
}
func fetchForgeStyle(q VersionListQuery, url string) (*ModLoaderVersions, error) {
// Forge style:
// each version is formatted like `mcVersion-forgeVersion`
// eg: `1.18.1-39.0.18`
return fetchMavenWithFilterMap(q, url, func(version string) *string {
before, after, f := strings.Cut(version, "-")
if !f {
// The version didn't have a dash? Lets just reject it entirely
return nil
}
if before != q.McVersion {
// The part before the dash should match the mc version we're looking for
return nil
}
// The part after the dash is the actual version, and the part we care about
return &after
})
}
func fetchNeoForgeStyle(q VersionListQuery, url string) (*ModLoaderVersions, error) {
// NeoForge style, for mc versions above 26.1:
// If minecraft versions are in the form of year.major.minor-(pre-release),
// then neoforge versions are in the form of year.major.minor.x(nf-pre-release)+(pre-release)
// Eg, for minecraft 26.1-snapshot-6, neoforge has versions 26.1.0.0-alpha.9+snapshot-6 and 26.1.0.0-alpha.10+snapshot-6
var mcSplit = strings.SplitN(q.McVersion, ".", 3)
if len(mcSplit) < 2 {
// This does not appear to be a minecraft version that's formatted in a way that neoforge's scheme supports
return nil, fmt.Errorf("packwiz cannot detect compatible %s versions for this Minecraft version (%s)", q.Loader.FriendlyName, q.McVersion)
}
var year = mcSplit[0]
var major = mcSplit[1]
var minor = "0"
var prerelease = ""
if len(mcSplit) == 3 {
minor, prerelease, _ = strings.Cut(mcSplit[2], "-")
} else {
major, prerelease, _ = strings.Cut(mcSplit[1], "-")
}
var requiredPrefix = year + "." + major + "." + minor
var requiredSuffix = prerelease
return fetchMavenWithFilterMap(q, url, func(version string) *string {
if strings.HasPrefix(version, requiredPrefix) && strings.HasSuffix(version, requiredSuffix) {
return &version
}
return nil
})
}
func fetchOldNeoForgeStyle(q VersionListQuery, url string) (*ModLoaderVersions, error) {
// NeoForge style, for mc versions below 1.21.11:
// If minecraft versions are in the form of 1.a.b, then neoforge versions are in the form of a.b.x
// Eg, for minecraft 1.20.6, neoforge version 20.6.2 and 20.6.83-beta would both be valid versions
// for minecraft 1.20.2, neoforge version 20.2.23-beta
// for minecraft 1.21, neoforge version 21.0.143 would be valid
var mcSplit = strings.Split(q.McVersion, ".")
if len(mcSplit) < 2 {
// This does not appear to be a minecraft version that's formatted in a way that neoforge's scheme supports
return nil, fmt.Errorf("packwiz cannot detect compatible %s versions for this Minecraft version (%s)", q.Loader.FriendlyName, q.McVersion)
}
var mcMajor = mcSplit[1]
var mcMinor = "0"
if len(mcSplit) > 2 {
mcMinor = mcSplit[2]
}
// Note that the period at the end is significant, we don't want to match `21.10.43` as being for 1.21.1 (instead of 1.21.10)
var requiredPrefix = mcMajor + "." + mcMinor + "."
return fetchMavenWithFilterMap(q, url, func(version string) *string {
if !strings.HasPrefix(version, requiredPrefix) {
// Reject NeoForge versions that don't have the right prefix for this mc version
return nil
}
return &version
})
}
func fetchLiteloaderStyle(q VersionListQuery, url string) (*ModLoaderVersions, error) {
// Liteloader style:
// each version is formatted like `mcVersion-SNAPSHOT`
// eg: `1.12.2-SNAPSHOT`
// (yes, it appears like liteloader only has a single version per mc version)
return fetchMavenWithFilterMap(q, url, func(version string) *string {
before, _, f := strings.Cut(version, "-")
// Check if the part before the dash matches the mc version we're looking for
if f && before == q.McVersion {
return &version
} else {
return nil
}
})
}
// Retrieves all versions through maven metadata, and then processes the using the provided `filterMap` function.
// When `filterMap` returns a string, the version will be renamed to the provided string. If `nil` is returned, the
// version is marked as invalid and will not be considered in the result.
func fetchMavenWithFilterMap(q VersionListQuery, url string, filterMap func(version string) *string) (*ModLoaderVersions, error) {
res, err := GetWithUA(url, "application/xml")
if err != nil {
return nil, err
}
dec := xml.NewDecoder(res.Body)
out := MavenMetadata{}
err = dec.Decode(&out)
if err != nil {
return nil, err
}
// Pass all of the versions listed in the maven through our filterMap
versions := make([]string, 0, len(out.Versioning.Versions.Version))
for _, v := range out.Versioning.Versions.Version {
mappedV := filterMap(v)
if mappedV != nil {
versions = append(versions, *mappedV)
}
}
if len(versions) == 0 {
return nil, errors.New("no " + q.Loader.FriendlyName + " versions available for " + q.McVersion)
}
// Determine the latest release
var latestRelease = ""
release := filterMap(out.Versioning.Release)
latest := filterMap(out.Versioning.Latest)
if release != nil {
latestRelease = *release
} else if latest != nil {
latestRelease = *latest
} else {
// Maven was useless, just rely on flexver sorting
flexver.VersionSlice(versions).Sort()
latestRelease = versions[len(versions)-1]
}
return &ModLoaderVersions{versions, latestRelease}, nil
}
func fetchForNeoForge(q VersionListQuery) (*ModLoaderVersions, error) {
// NeoForge reused Forge's versioning scheme for 1.20.1, but moved to their own versioning scheme for 1.20.2 and above
if q.McVersion == "1.20.1" {
return fetchForgeStyle(q, "https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml")
} else {
// Mojang changed versioning schemes between 1.21.11 and 26.1
// The old versioning scheme was 1.major.minor, which changed to year.major(.patch)
// With snapshot releases for 26.1 being eg 26.1-snapshot.1
// NeoForge's versioning scheme changed with that. Luckily all versions using the old versioning
// scheme start with "1.". Well, some things don't (alpha versions, snapshot versions, etc) but NeoForge
// doesn't support any of those either way.
if strings.HasPrefix(q.McVersion, "1.") {
return fetchOldNeoForgeStyle(q, "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml")
} else {
return fetchNeoForgeStyle(q, "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml")
}
}
}
func fetchForForge(q VersionListQuery) (*ModLoaderVersions, error) {
result, err := fetchForgeStyle(q, "https://files.minecraftforge.net/maven/net/minecraftforge/forge/maven-metadata.xml")
if err != nil {
return nil, err
}
// Forge is the only loader which defines a special recommended version
if q.QueryType == Recommended {
recommended := getForgeRecommended(q)
if recommended != "" {
result.Latest = recommended
}
}
return result, nil
}
func ComponentToFriendlyName(component string) string {
if component == "minecraft" {
return "Minecraft"
}
loader, ok := ModLoaders[component]
if ok {
return loader.FriendlyName
} else {
return component
}
}
// HighestSliceIndex returns the highest index of the given values in the slice (-1 if no value is found in the slice)
func HighestSliceIndex(slice []string, values []string) int {
highest := -1
for _, val := range values {
for i, v := range slice {
if v == val && i > highest {
highest = i
}
}
}
return highest
}
type ForgeRecommended struct {
Homepage string `json:"homepage"`
Versions map[string]string `json:"promos"`
}
// getForgeRecommended gets the recommended version of Forge for the given Minecraft version
func getForgeRecommended(q VersionListQuery) string {
res, err := GetWithUA("https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json", "application/json")
if err != nil {
return ""
}
dec := json.NewDecoder(res.Body)
out := ForgeRecommended{}
err = dec.Decode(&out)
if err != nil {
fmt.Println(err)
return ""
}
// Get mcVersion-recommended, if it doesn't exist then get mcVersion-latest
// If neither exist, return empty string
recommendedString := fmt.Sprintf("%s-recommended", q.McVersion)
if out.Versions[recommendedString] != "" {
return out.Versions[recommendedString]
}
latestString := fmt.Sprintf("%s-latest", q.McVersion)
if out.Versions[latestString] != "" {
return out.Versions[latestString]
}
return ""
}