Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ All notable changes to ModShift are documented here. The format follows

- An update that keeps the same version number (a file rebuilt for the target Minecraft
version, e.g. `3.3.6 → 3.3.6`) is now explained in the report instead of looking like an error.
- Resource packs and shaders listed in a pack (e.g. Faithful 32x in a CurseForge export) were
searched as mods for the target loader and reported as not found. They are now looked up
under their own Modrinth loaders, installed in `resourcepacks/` or `shaderpacks/`, linked to
their right Modrinth page, and never get mod alternatives.

## [0.1.0] - 2026-10-20

Expand Down
41 changes: 41 additions & 0 deletions src/core/contentTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Types de contenu d'un pack autres que les mods : packs de ressources et shaders.
//
// Sources (code de Modrinth, validateurs de labrinth) : apps/labrinth/src/validate/resourcepack.rs
// accepte le loader « minecraft » ; shader.rs accepte « optifine » et « iris », « canvas »
// (CanvasShaderValidator) et « vanilla ». Dossiers d'installation : « resourcepacks » (packwiz,
// curseforge.go) et « shaderpacks » (dossier lu par Iris et OptiFine).

import type { MigrationTarget } from '../types.js';
import { acceptedLoaders } from './loaders.js';

export type ContentType = 'mod' | 'resourcepack' | 'shader';

export function contentTypeOf(projectType: string | undefined): ContentType {
return projectType === 'resourcepack' || projectType === 'shader' ? projectType : 'mod';
}

// Loaders Modrinth sous lesquels chercher une version pour la cible. Un pack de ressources ne
// dépend pas du loader de mods : seule la version de Minecraft compte.
export function loadersFor(type: ContentType, target: MigrationTarget): string[] {
if (type === 'resourcepack') return ['minecraft'];
if (type === 'shader') return ['iris', 'optifine', 'canvas', 'vanilla'];
return acceptedLoaders(target.loader);
}

export const CONTENT_FOLDERS: Record<ContentType, string> = {
mod: 'mods',
resourcepack: 'resourcepacks',
shader: 'shaderpacks',
};

// Libellé lisible dans les raisons (« No version for Minecraft 26.3 (resource pack). »).
export const CONTENT_LABELS: Record<ContentType, string> = {
mod: 'mod',
resourcepack: 'resource pack',
shader: 'shader',
};

// Segment d'URL des pages de projet Modrinth (modrinth.com/<segment>/<slug>).
export function modrinthPageUrl(project: { slug: string; projectType?: string }): string {
return `https://modrinth.com/${contentTypeOf(project.projectType)}/${project.slug}`;
}
21 changes: 16 additions & 5 deletions src/core/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ModrinthProject, ModrinthVersion } from '../modrinth/types.js';
import type { Loader, MigrationTarget, ModEntry, ModPack } from '../types.js';
import { DEFAULT_CONCURRENCY, mapConcurrent } from '../concurrency.js';
import { findAlternatives } from './alternatives.js';
import { CONTENT_LABELS, contentTypeOf, loadersFor } from './contentTypes.js';
import { resolveDependencies, type SelectedVersion } from './dependencies.js';
import {
acceptedLoaders,
Expand Down Expand Up @@ -194,11 +195,14 @@ export async function analyzePack(
let done = 0;
tick(0, distinctProjects.length, distinctProjects[0]?.title ?? 'done');
await mapConcurrent(distinctProjects, concurrency, async (project) => {
// Un pack de ressources ou un shader se cherche sous ses propres loaders, pas sous le loader
// de mods de la cible.
const loaders = loadersFor(contentTypeOf(project.project_type), target);
const versions = await api.getProjectVersions(project.id, {
loaders: acceptedLoaders(target.loader),
loaders,
gameVersions: [target.minecraftVersion],
});
bestByProject.set(project.id, pickBestVersion(versions, target));
bestByProject.set(project.id, pickBestVersion(versions, target, loaders));
done++;
if (done < distinctProjects.length) tick(done, distinctProjects.length, project.title);
});
Expand All @@ -212,6 +216,8 @@ export async function analyzePack(
pack.mods.flatMap((entry) => {
const id = identified.get(entry);
if (ignoredEntries.has(entry) || mapped.has(entry) || !id) return [];
// Alternatives et couches de compatibilité ne concernent que les mods.
if (contentTypeOf(id.project.project_type) !== 'mod') return [];
if (id.currentVersion && versionSupportsTarget(id.currentVersion, target)) return [];
if (bestByProject.get(id.project.id)) return [];
return [[id.project.id, id.project] as const];
Expand Down Expand Up @@ -302,7 +308,9 @@ export async function analyzePack(
currentVersion: current ? toVersionRef(current) : undefined,
};

if (current && versionSupportsTarget(current, target)) {
const contentType = contentTypeOf(id.project.project_type);
const contentLoaders = loadersFor(contentType, target);
if (current && versionSupportsTarget(current, target, contentLoaders)) {
const fallback = fallbackCompatibility(current, target);
mods.push({
...common,
Expand All @@ -318,7 +326,7 @@ export async function analyzePack(
const best = bestByProject.get(project.id);
if (best) {
const fallback = fallbackCompatibility(best, target);
const change = loaderChange(pack.loader, best, target);
const change = contentType === 'mod' ? loaderChange(pack.loader, best, target) : undefined;
mods.push({
...common,
status: 'update-found',
Expand All @@ -340,7 +348,10 @@ export async function analyzePack(
const { alternatives, compatLayer } = found;
// Une seule fois par projet, même s'il apparaît dans plusieurs fichiers.
warnings.push(...found.warnings.splice(0));
const noVersion = `${idNote}No version for Minecraft ${target.minecraftVersion} / ${target.loader}.`;
const noVersion =
contentType === 'mod'
? `${idNote}No version for Minecraft ${target.minecraftVersion} / ${target.loader}.`
: `${idNote}No version for Minecraft ${target.minecraftVersion} (${CONTENT_LABELS[contentType]}).`;
const adopted = options.adoptAlternatives
? await adoptAlternative(api, alternatives, target, selected)
: undefined;
Expand Down
12 changes: 9 additions & 3 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ export function loaderChangeReason(change: LoaderChange): string {
return `Same project: its ${to} version replaces the ${from} one (multi-loader mod).`;
}

export function versionSupportsTarget(version: ModrinthVersion, target: MigrationTarget): boolean {
const loaders = acceptedLoaders(target.loader);
// `loaders` : loaders acceptés (ceux de la cible par défaut ; « minecraft » pour un pack de
// ressources, voir src/core/contentTypes.ts).
export function versionSupportsTarget(
version: ModrinthVersion,
target: MigrationTarget,
loaders: string[] = acceptedLoaders(target.loader),
): boolean {
return (
version.game_versions.includes(target.minecraftVersion) &&
version.loaders.some((l) => loaders.includes(l))
Expand All @@ -59,10 +64,11 @@ const VERSION_TYPE_RANK: Record<VersionType, number> = { release: 0, beta: 1, al
export function pickBestVersion(
versions: ModrinthVersion[],
target: MigrationTarget,
loaders?: string[],
): ModrinthVersion | undefined {
const native = (v: ModrinthVersion) => (v.loaders.includes(target.loader) ? 0 : 1);
return versions
.filter((v) => versionSupportsTarget(v, target))
.filter((v) => versionSupportsTarget(v, target, loaders))
.filter((v) => v.status === undefined || v.status === 'listed' || v.status === 'archived')
.sort(
(a, b) =>
Expand Down
4 changes: 4 additions & 0 deletions src/core/refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export function toProjectRef(project: ModrinthProject): ProjectRef {
slug: project.slug,
title: project.title,
categories: project.categories ?? [],
// Seulement pour les packs de ressources, shaders… (page Modrinth et dossier différents).
...(project.project_type && project.project_type !== 'mod'
? { projectType: project.project_type }
: {}),
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface ProjectRef {
slug: string;
title: string;
categories: string[];
// Type de projet Modrinth quand ce n'est pas un mod (« resourcepack », « shader »…).
projectType?: string;
}

export interface VersionRef {
Expand Down
3 changes: 3 additions & 0 deletions src/curseforge/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export interface CurseForgeMod {
name: string;
slug: string;
summary?: string;
// Classe CurseForge : 6 mod, 12 pack de ressources, 6552 shader, 17 monde (Prism Launcher,
// FlameAPI.cpp ; packwiz, curseforge.go).
classId?: number;
links?: { websiteUrl?: string };
}

Expand Down
9 changes: 8 additions & 1 deletion src/curseforge/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export async function resolveCurseForgeRefs(
}
resolved++;
ref.resolved = true;
const path = `mods/${file.fileName}`;
const path = `${folderForClass(mod?.classId)}/${file.fileName}`;
if (!file.fileName.includes('/') && isSpecSafePath(path)) {
entry.fileName = file.fileName;
entry.path = path;
Expand All @@ -55,6 +55,13 @@ export async function resolveCurseForgeRefs(
return { resolved, unresolved: refs.length - resolved };
}

// Dossier d'installation d'un fichier selon sa classe CurseForge (voir CurseForgeMod.classId).
function folderForClass(classId: number | undefined): string {
if (classId === 12) return 'resourcepacks';
if (classId === 6552) return 'shaderpacks';
return 'mods';
}

// Avertissement quand des références CurseForge restent non résolues faute de clé.
export function missingKeyWarning(pack: ModPack): string | undefined {
const count = pack.mods.filter((m) => m.curseforge && !m.curseforge.resolved).length;
Expand Down
11 changes: 9 additions & 2 deletions src/migrate/plan.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CONTENT_FOLDERS, contentTypeOf } from '../core/contentTypes.js';
import { mrpackEnv, type ModSide, neededOn, type TargetSide } from '../core/sides.js';
import type { AnalysisResult, LoaderChange, ModAnalysis, VersionRef } from '../core/types.js';
import { sideLabel } from '../report/labels.js';
Expand Down Expand Up @@ -29,6 +30,8 @@ export interface PlannedMod {
requiredBy?: string[];
// Mod identifié seulement par son mod id (le fichier d'origine n'est pas connu de Modrinth).
unverified?: boolean;
// Type de projet Modrinth quand ce n'est pas un mod (dossier du fichier dans le pack).
projectType?: string;
// Version retenue pour un loader de repli (« fallback compatibility, to test »).
fallbackLoader?: string;
// Même projet, version d'un autre loader (mod multi-loader), ex. { from: 'forge', to: 'fabric' }.
Expand Down Expand Up @@ -90,10 +93,12 @@ function normalizeEnv(env: ModPack['mods'][number]['env']): MrpackFile['env'] {
function fileFromVersion(
version: VersionRef,
env: MrpackFile['env'],
projectType?: string,
): MrpackFile | { error: string } {
const file = version.file;
if (!file) return { error: 'the Modrinth version has no file' };
const path = `mods/${file.fileName}`;
// Packs de ressources et shaders vont dans leur propre dossier, pas dans mods/.
const path = `${CONTENT_FOLDERS[contentTypeOf(projectType)]}/${file.fileName}`;
if (!isSpecSafePath(path) || file.fileName.includes('/')) {
return { error: `unsafe file name "${file.fileName}"` };
}
Expand Down Expand Up @@ -156,7 +161,7 @@ export function buildMigrationPlan(
// Ajoute un mod s'il n'est pas déjà présent (même projet ou même chemin).
const add = (mod: Omit<PlannedMod, 'file'>, env: MrpackFile['env']): boolean => {
if (projectIds.has(mod.projectId)) return true;
const file = fileFromVersion(mod.version, env);
const file = fileFromVersion(mod.version, env, mod.projectType);
if ('error' in file) {
warnings.push(`${mod.title}: not included, ${file.error}`);
return false;
Expand Down Expand Up @@ -222,6 +227,7 @@ export function buildMigrationPlan(
source: mod.status === 'compatible' ? 'kept' : 'updated',
title: mod.project.title,
projectId: mod.project.id,
...(mod.project.projectType ? { projectType: mod.project.projectType } : {}),
version: mod.targetVersion,
replaces: mod.fileName,
...(mod.identification === 'mod-id' ? { unverified: true } : {}),
Expand Down Expand Up @@ -266,6 +272,7 @@ export function buildMigrationPlan(
source: 'dependency',
title: dep.project.title,
projectId: dep.project.id,
...(dep.project.projectType ? { projectType: dep.project.projectType } : {}),
version: dep.version,
requiredBy: dep.requiredBy,
...(dep.fallback ? { fallbackLoader: dep.fallback.loader } : {}),
Expand Down
3 changes: 2 additions & 1 deletion src/report/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AnalysisResult } from '../core/types.js';
import { modrinthPageUrl } from '../core/contentTypes.js';
import { MOD_STATUSES } from '../core/types.js';
import {
CONFLICT_LABELS,
Expand Down Expand Up @@ -58,7 +59,7 @@ export function renderMarkdown(result: AnalysisResult): string {
['Status', 'Mod', 'File', 'Current', 'Target', 'Notes'],
result.mods.map((m) => [
`${STATUS_ICONS[m.status]} ${STATUS_LABELS[m.status]}`,
m.project ? `[${m.project.title}](https://modrinth.com/mod/${m.project.slug})` : m.fileName,
m.project ? `[${m.project.title}](${modrinthPageUrl(m.project)})` : m.fileName,
m.fileName,
versionLabel(m.currentVersion),
versionLabel(m.targetVersion),
Expand Down
Loading
Loading