EXTENSIONS_FLUFF_IMAGE_FORMAT_SET = Set.of(EXTENSIONS_FLUFF_IMAGE_FORMATS);
+
+ /** The model subdirectory name that units with an empty model match, e.g. fluff/Mek/Chassis/Chassis ---empty---. */
+ static final String EMPTY_MODEL_DIR_NAME = "---empty---";
+
/**
* Returns a fluff image for the given unit/object to be shown e.g. in the unit summary.
*
*
* If a fluff image is stored in the unit/object itself, e.g. if it was part of the unit's file or is created by the
- * unit itself, this is returned. Note that this is not used for canon units, but may be used in custom ones by
+ * unit itself, this
+ * is returned. Note that this is not used for canon units, but may be used in custom ones by
* adding a fluff image to the unit in MML.
*
*
* Otherwise, the fluff images directories are searched. First searches the user dir, then the internal dir. Tries
- * to match the image by chassis + model or chassis alone. Chassis and model names are cleaned from " and /
- * characters before matching. For Meks with clan names, both names and the combinations are searched. The model
- * alone is not used to search.
+ * to match the image by
+ * chassis + model or chassis alone. Chassis and model names are cleaned from " and /
+ * characters before matching. For Meks with clan
+ * names, both names and the combinations are searched. The model alone is not used to search.
*
* Returns null if no fluff image can be found.
*
@@ -100,7 +121,7 @@ public final class FluffImageHelper {
if (unit == null) {
return null;
}
- File fluffImageFile = findFluffFile(unit, true);
+ File fluffImageFile = findFluffFiles(unit, true).stream().findFirst().orElse(null);
if (fluffImageFile != null) {
return fluffImageFile.toString();
} else {
@@ -108,10 +129,50 @@ public final class FluffImageHelper {
}
}
+ /**
+ * Returns a list of all fluff images for the given unit/object to be shown e.g. in the unit summary.
+ *
+ *
If a fluff image is stored in the unit/object itself, e.g. if it was part of the
+ * unit's file or is created by the unit itself, only this is returned. Note that this is not used for canon units, but may be used in
+ * custom ones by adding a fluff image to the unit in MML.
+ *
+ * Otherwise, the fluff image directories are searched. First searches the user dir,
+ * then the internal dir. Tries to match the image by chassis + model or chassis alone. Chassis and model names are cleaned from " and /
+ * characters before matching. For Meks with clan names, both names and the combinations are searched. The model alone is not used to
+ * search.
+ *
+ * @param unit The unit
+ * @return a list of fluff images, or an empty list if none are found
+ */
+ public static List getFluffImages(@Nullable BTObject unit) {
+ return getFluffImageList(unit, false);
+ }
+
+ /**
+ * Returns a list of all fluff image records for the given unit/object to be shown e.g. in the unit summary.
+ *
+ * If a fluff image is stored in the unit/object itself, e.g. if it was part of the
+ * unit's file or is created by the unit itself, only this is returned. Note that this is not used for canon units, but may be used in
+ * custom ones by adding a fluff image to the unit in MML.
+ *
+ * Otherwise, the fluff image directories are searched. First searches the user dir,
+ * then the internal dir. Tries to match the image by chassis + model or chassis alone. Chassis and model names are cleaned from " and /
+ * characters before matching. For Meks with clan names, both names and the combinations are searched. The model alone is not used to
+ * search.
+ *
+ * @param unit The unit
+ * @return a list of fluff image records, or an empty list if none are found
+ */
+ public static List getFluffRecords(@Nullable BTObject unit) {
+ return getFluffImageRecords(unit, false);
+ }
+
/**
* Returns a fluff image for the given unit for the record sheet, with a fallback file named "hud.png" if that is
- * present in the right fluff directory, or null if nothing can be found. See {@link #getFluffImage(BTObject)} for
- * further comments on how the fluff image is searched.
+ * present in the right
+ * fluff directory, or {@code null} if nothing can be found. See {@link #getFluffImage(BTObject)} for
+ * further comments on how the fluff image is
+ * searched.
*
* @param unit The unit
*
@@ -122,24 +183,65 @@ public final class FluffImageHelper {
}
private static @Nullable Image getFluffImage(@Nullable BTObject unit, boolean recordSheet) {
- if (unit == null) {
+ List fluffImages = getFluffImageList(unit, recordSheet);
+ if (!fluffImages.isEmpty()) {
+ return fluffImages.get(0);
+ } else {
return null;
}
+ }
+
+ /**
+ * Returns a list of available fluff images. If a fluff image is embedded in the unit file,
+ * only that image is returned, even if others are available from the fluff directories. The returned
+ * list may be empty, but not {@code null}.
+ *
+ * @param unit The unit
+ * @param recordSheet True if this image search is meant for a record sheet (used in MML)
+ * @return Available fluff images or the embedded fluff image
+ */
+ private static List getFluffImageRecords(@Nullable BTObject unit, boolean recordSheet) {
+ if (unit == null) {
+ return new ArrayList<>();
+ }
Image embeddedFluffImage = unit.getFluffImage();
if (embeddedFluffImage != null) {
- return embeddedFluffImage;
+ return List.of(new FluffImageRecord(embeddedFluffImage, null));
} else {
- File fluffImageFile = findFluffFile(unit, recordSheet);
- if (fluffImageFile != null) {
- return new ImageIcon(fluffImageFile.toString()).getImage();
- } else {
- return null;
- }
+ return findFluffFiles(unit, recordSheet).stream().map(FluffImageRecord::toRecord).toList();
+ }
+ }
+
+ /**
+ * Returns a list of available fluff images. If a fluff image is embedded in the unit file,
+ * only that image is returned, even if others are available from the fluff directories. The returned
+ * list may be empty, but not {@code null}.
+ *
+ * @param unit The unit
+ * @param recordSheet True if this image search is meant for a record sheet
+ * @return Available fluff images or the embedded fluff image
+ */
+ private static List getFluffImageList(@Nullable BTObject unit, boolean recordSheet) {
+ if (unit == null) {
+ return new ArrayList<>();
+ }
+ Image embeddedFluffImage = unit.getFluffImage();
+ if (embeddedFluffImage != null) {
+ return List.of(embeddedFluffImage);
+ } else {
+ return findFluffFiles(unit, recordSheet).stream()
+ .map(File::toString)
+ .map(ImageIcon::new)
+ .map(ImageIcon::getImage)
+ .collect(Collectors.toList());
}
}
- private static @Nullable File findFluffFile(BTObject unit, boolean recordSheet) {
- List fileCandidates = new ArrayList<>();
+ private static Set findFluffFiles(BTObject unit, boolean recordSheet) {
+ // A LinkedHashSet keeps the search order while removing duplicates. The order is significant:
+ // getFluffImage(BTObject) shows the first entry, and the directories below are searched from
+ // most to least specific so that the most specific art wins.
+ Set fileCandidates = new LinkedHashSet<>();
List nameCandidates = nameCandidates(unit);
@@ -162,10 +264,12 @@ public final class FluffImageHelper {
fileCandidates.addAll(findMatchingFiles(rsFluffUserDir, nameCandidates));
}
fileCandidates.addAll(findMatchingFiles(fluffUserDir, nameCandidates));
+ fileCandidates.addAll(getFluffInChassisDirs(unit, fluffUserDir));
}
// Internal fluff path matches
fileCandidates.addAll(findMatchingFiles(fluffDir, nameCandidates));
+ fileCandidates.addAll(getFluffInChassisDirs(unit, fluffDir));
// Fallback for units other than HHWs.
// The HHW fallback image is embedded into the RS template.
@@ -177,12 +281,83 @@ public final class FluffImageHelper {
}
}
- for (File possibleFile : fileCandidates) {
- if (possibleFile.exists() && !possibleFile.isDirectory()) {
- return possibleFile;
+ fileCandidates.removeIf(candidate -> !candidate.exists() || candidate.isDirectory());
+ return fileCandidates;
+ }
+
+ /**
+ * With the addition of multiple fluff images, file matching depends on the directory a file is in.
+ *
- In the main fluff/[unittype]/ directory the old rules apply, i.e. a file is valid if it
+ * matches the model exactly or if the filename is only the chassis and matches the unit's chassis.
+ * The filename may now contain additional information after an underscore (atlas_xyz.jpg matches for
+ * any Atlas mek).
+ *
- In a chassis subdirectory fluff/[unittype]/[chassis], all files match if [chassis]
+ * matches the unit's chassis (even if the filename has the wrong model) AND if there is no
+ * [model] subdirectory matching the unit's model. Empty models match the directory "---empty---".
+ * The filename doesn't matter for matching.
+ *
- In a model subdirectory fluff/[unittype]/[chassis]/[model], all files match if the
+ * unit's chassis and model match [chassis] and [model]. The filename doesn't matter for matching.
+ */
+ static List getFluffInChassisDirs(BTObject unit, File unitTypeFluffDir) {
+ List result = new ArrayList<>();
+ for (String nameCandidate : chassisNameCandidates(unit)) {
+ var chassisDir = new File(unitTypeFluffDir, nameCandidate);
+ if (chassisDir.isDirectory()) {
+ result.addAll(getFluffInChassisDir(unit, chassisDir));
}
}
- return null;
+ return result;
+ }
+
+ /**
+ * @return For the unit, returns the possible chassis lookup strings, which is simply the chassis
+ * (the list has only one entry) for all units except Clan Meks with a double name, where the list
+ * includes the four variations on Timber Wolf (Mad Cat), Mad Cat (Timber Wolf), Mad Cat and
+ * Timber Wolf. Note that a few units have X (Y) chassis that are not clan double names. Those
+ * will return only the full chassis X (Y).
+ */
+ private static List chassisNameCandidates(BTObject unit) {
+ List result = new ArrayList<>();
+ String sanitizedChassis = sanitize(unit.generalName());
+ result.add(sanitizedChassis);
+ if ((unit instanceof Mek) && !((Mek) unit).getClanChassisName().isBlank()) {
+ String sanitizedClanChassis = sanitize(((Mek) unit).getClanChassisName());
+ result.add(sanitizedClanChassis + " (" + sanitizedChassis + ")");
+ result.add(sanitizedChassis + " (" + sanitizedClanChassis + ")");
+ result.add(sanitizedClanChassis);
+ }
+ return result;
+ }
+
+ private static List getFluffInChassisDir(BTObject unit, File chassisDir) {
+ String sanitizedModel = sanitize(unit.specificName());
+ if (sanitizedModel.isBlank()) {
+ sanitizedModel = EMPTY_MODEL_DIR_NAME;
+ }
+ List result = new ArrayList<>();
+ for (String chassisNameCandidate : chassisNameCandidates(unit)) {
+ var modelDir = new File(chassisDir, chassisNameCandidate + " " + sanitizedModel);
+ if (modelDir.isDirectory()) {
+ result.addAll(getFluffInDir(modelDir));
+ }
+ }
+ if (result.isEmpty()) {
+ result.addAll(getFluffInDir(chassisDir));
+ }
+ return result;
+ }
+
+ private static List getFluffInDir(File dir) {
+ List result = new ArrayList<>();
+ // Files.list is a shallow listing and, unlike Files.walk(dir, 1), does not include the directory itself
+ try (Stream entries = Files.list(dir.toPath())) {
+ result.addAll(entries.map(Path::toFile).toList());
+ result.removeIf(FluffImageHelper::isNoImageFile);
+ } catch (IOException exception) {
+ LOGGER.warn("Error while reading files from {}", dir, exception);
+ }
+ result.sort(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER));
+ return result;
}
static File userFluffDir(String userDir, boolean recordSheet, String fluffPath) {
@@ -240,7 +415,6 @@ private static String sanitize(String original) {
private static List nameCandidates(BTObject unit) {
List candidates = new ArrayList<>();
-
String sanitizedChassis = sanitize(unit.generalName());
String sanitizedModel = sanitize(unit.specificName());
// Check for an empty model so the order more specific -> less specific name candidate is always kept
@@ -254,7 +428,7 @@ private static List nameCandidates(BTObject unit) {
addClanChassisVariants(mekSummary.getFullChassis(), candidates, sanitizedModel,
mekSummary.getClanChassisName());
}
- candidates.add(sanitizedChassis);
+ candidates.addAll(chassisNameCandidates(unit));
return candidates;
}
@@ -349,4 +523,58 @@ public static List getFluffPaths(BTObject unit) {
case EMPLACEMENT -> null;
};
}
+
+ /**
+ * A fluff image that is either already loaded (when it was embedded in the unit file) or still on disk, in which
+ * case it is only read when it is actually shown. Exactly one of the two is set.
+ *
+ * @param image The already loaded image, or {@code null} when the image must be read from {@code file}
+ * @param file The file holding the image, or {@code null} when {@code image} is already loaded
+ */
+ public record FluffImageRecord(@Nullable Image image, @Nullable File file) {
+
+ /**
+ * @param file The image file to read when the image is shown
+ *
+ * @return A record for a fluff image that has not been loaded yet
+ */
+ public static FluffImageRecord toRecord(File file) {
+ return new FluffImageRecord(null, file);
+ }
+
+ /**
+ * Returns the fluff image, reading it from disk if it has not been loaded yet.
+ *
+ * @return The fluff image, or {@code null} if this record has neither an image nor a file
+ *
+ * @throws IOException When the image file is present but cannot be read
+ */
+ public @Nullable Image getImage() throws IOException {
+ if (image != null) {
+ return image;
+ } else if (file != null) {
+ return ImageIO.read(file);
+ } else {
+ return null;
+ }
+ }
+ }
+
+ private static boolean isNoImageFile(File file) {
+ Optional extension = getExtension(file.toString());
+ return extension.isEmpty() || !EXTENSIONS_FLUFF_IMAGE_FORMAT_SET.contains(extension.get());
+ }
+
+ /**
+ * Returns the file extension of a given filename.
+ * source: baeldung.com/java-file-extension
+ *
+ * @param filename The filename, potentially with directories
+ * @return The extension, including the dot
+ */
+ public static Optional getExtension(String filename) {
+ return Optional.ofNullable(filename)
+ .filter(name -> name.contains("."))
+ .map(name -> name.substring(name.lastIndexOf(".")));
+ }
}
diff --git a/megamek/unittests/megamek/client/ui/util/FluffImageHelperChassisDirTest.java b/megamek/unittests/megamek/client/ui/util/FluffImageHelperChassisDirTest.java
new file mode 100644
index 00000000000..cb295c5b17b
--- /dev/null
+++ b/megamek/unittests/megamek/client/ui/util/FluffImageHelperChassisDirTest.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2026 The MegaMek Team. All Rights Reserved.
+ *
+ * This file is part of MegaMek.
+ *
+ * MegaMek is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPL),
+ * version 3 or (at your option) any later version,
+ * as published by the Free Software Foundation.
+ *
+ * MegaMek is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the GNU General Public License for more details.
+ *
+ * A copy of the GPL should have been included with this project;
+ * if not, see .
+ *
+ * NOTICE: The MegaMek organization is a non-profit group of volunteers
+ * creating free software for the BattleTech community.
+ *
+ * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks
+ * of The Topps Company, Inc. All Rights Reserved.
+ *
+ * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of
+ * InMediaRes Productions, LLC.
+ *
+ * MechWarrior Copyright Microsoft Corporation. MegaMek was created under
+ * Microsoft's "Game Content Usage Rules"
+ * and it is not endorsed by or
+ * affiliated with Microsoft.
+ */
+
+package megamek.client.ui.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import megamek.common.units.BipedMek;
+import megamek.common.units.Mek;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Tests the chassis and model subdirectory matching rules for fluff images, i.e. the contents of
+ * fluff/[unittype]/[chassis] and fluff/[unittype]/[chassis]/[chassis model].
+ */
+class FluffImageHelperChassisDirTest {
+
+ private static Mek mek(String chassis, String model) {
+ Mek mek = new BipedMek();
+ mek.setChassis(chassis);
+ mek.setModel(model);
+ return mek;
+ }
+
+ private static void writeImage(Path directory, String fileName) throws IOException {
+ Files.createDirectories(directory);
+ Files.write(directory.resolve(fileName), new byte[] { 1, 2, 3 });
+ }
+
+ private static List fileNames(List files) {
+ return files.stream().map(File::getName).toList();
+ }
+
+ @Test
+ void findsEveryImageInTheChassisDirectoryRegardlessOfFileName(@TempDir Path fluffDir) throws IOException {
+ writeImage(fluffDir.resolve("Atlas"), "anything at all.png");
+ writeImage(fluffDir.resolve("Atlas"), "second.jpg");
+
+ List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile());
+
+ assertEquals(List.of("anything at all.png", "second.jpg"), fileNames(found));
+ }
+
+ @Test
+ void modelDirectoryWinsOverChassisDirectory(@TempDir Path fluffDir) throws IOException {
+ writeImage(fluffDir.resolve("Atlas"), "generic atlas.png");
+ writeImage(fluffDir.resolve("Atlas").resolve("Atlas AS7-D"), "specific.png");
+
+ List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile());
+
+ assertEquals(List.of("specific.png"), fileNames(found));
+ }
+
+ @Test
+ void chassisDirectoryIsUsedWhenNoModelDirectoryMatches(@TempDir Path fluffDir) throws IOException {
+ writeImage(fluffDir.resolve("Atlas"), "generic atlas.png");
+ writeImage(fluffDir.resolve("Atlas").resolve("Atlas AS7-K"), "other model.png");
+
+ List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile());
+
+ assertEquals(List.of("generic atlas.png"), fileNames(found));
+ }
+
+ @Test
+ void emptyModelMatchesTheEmptyModelDirectory(@TempDir Path fluffDir) throws IOException {
+ writeImage(fluffDir.resolve("Atlas"), "generic atlas.png");
+ writeImage(fluffDir.resolve("Atlas").resolve("Atlas " + FluffImageHelper.EMPTY_MODEL_DIR_NAME),
+ "no model.png");
+
+ List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", ""), fluffDir.toFile());
+
+ assertEquals(List.of("no model.png"), fileNames(found));
+ }
+
+ @Test
+ void multipleImagesAreReturnedInDeterministicOrder(@TempDir Path fluffDir) throws IOException {
+ writeImage(fluffDir.resolve("Atlas"), "charlie.png");
+ writeImage(fluffDir.resolve("Atlas"), "alpha.png");
+ writeImage(fluffDir.resolve("Atlas"), "bravo.png");
+
+ List firstCall = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile());
+ List secondCall = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile());
+
+ assertEquals(List.of("alpha.png", "bravo.png", "charlie.png"), fileNames(firstCall));
+ assertEquals(fileNames(firstCall), fileNames(secondCall));
+ }
+
+ @Test
+ void nonImageFilesAreIgnored(@TempDir Path fluffDir) throws IOException {
+ writeImage(fluffDir.resolve("Atlas"), "picture.png");
+ writeImage(fluffDir.resolve("Atlas"), "picture data.yaml");
+ writeImage(fluffDir.resolve("Atlas"), "readme.txt");
+
+ List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile());
+
+ assertEquals(List.of("picture.png"), fileNames(found));
+ }
+
+ @Test
+ void clanChassisVariantDirectoriesAreFound(@TempDir Path fluffDir) throws IOException {
+ Mek timberWolf = mek("Mad Cat", "Prime");
+ timberWolf.setClanChassisName("Timber Wolf");
+ writeImage(fluffDir.resolve("Timber Wolf (Mad Cat)"), "timberwolf.png");
+
+ List found = FluffImageHelper.getFluffInChassisDirs(timberWolf, fluffDir.toFile());
+
+ assertEquals(List.of("timberwolf.png"), fileNames(found));
+ }
+
+ @Test
+ void aFileWhereAChassisDirectoryWouldBeIsIgnored(@TempDir Path fluffDir) throws IOException {
+ // A stray file named like a chassis must not be treated as a directory to scan
+ writeImage(fluffDir, "Atlas");
+
+ List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile());
+
+ assertTrue(found.isEmpty(), "A regular file named like the chassis must not yield fluff images");
+ }
+
+ @Test
+ void missingChassisDirectoryYieldsNoImages(@TempDir Path fluffDir) {
+ List found = FluffImageHelper.getFluffInChassisDirs(mek("Atlas", "AS7-D"), fluffDir.toFile());
+
+ assertTrue(found.isEmpty());
+ }
+}