|
| 1 | +package me.devnatan.inventoryframework.intellij |
| 2 | + |
| 3 | +import java.awt.RenderingHints |
| 4 | +import java.awt.image.BufferedImage |
| 5 | +import java.io.File |
| 6 | +import java.util.Locale |
| 7 | +import java.util.zip.ZipFile |
| 8 | +import javax.imageio.ImageIO |
| 9 | + |
| 10 | +private const val ICON_SIZE = 16 |
| 11 | +private const val TEXTURE_ROOT = "assets/minecraft/textures" |
| 12 | +private const val MODEL_ROOT = "assets/minecraft/models" |
| 13 | + |
| 14 | +// Preference order when a model declares more than one texture layer/face - layer0 is the |
| 15 | +// standard flat-icon key ("item/generated" models), the rest are common cube-model face names; |
| 16 | +// picking any single one is only ever an approximation of the real (sometimes multi-layered or |
| 17 | +// tinted) icon, but it's a much closer one than the placeholder. |
| 18 | +private val TEXTURE_KEY_PRIORITY = listOf("layer0", "all", "side", "particle", "texture", "top", "cross") |
| 19 | + |
| 20 | +// Reads item icons directly from a Minecraft client jar already installed on this machine, on |
| 21 | +// demand and entirely locally - the plugin itself never bundles or serves any extracted game |
| 22 | +// asset, since Mojang's usage guidelines prohibit that for third-party tools (see |
| 23 | +// "No real item icons" in TOOLING_SUPPORT.md). If no client jar can be found, every lookup |
| 24 | +// returns null and callers fall back to the existing placeholder rendering. |
| 25 | +object ItemIconProvider { |
| 26 | + |
| 27 | + // Sentinel distinguishing "never resolved a jar yet" from a resolved-but-null result, so a |
| 28 | + // client jar that's genuinely missing isn't retried (and re-scanned) on every single lookup. |
| 29 | + private object Unresolved |
| 30 | + |
| 31 | + private var resolvedForSetting: Any? = Unresolved |
| 32 | + private var cachedJar: ZipFile? = null |
| 33 | + private val cache = mutableMapOf<String, BufferedImage?>() |
| 34 | + |
| 35 | + @Synchronized |
| 36 | + fun iconFor(material: String): BufferedImage? { |
| 37 | + val key = material.lowercase(Locale.ROOT) |
| 38 | + return cache.getOrPut(key) { loadIcon(key) } |
| 39 | + } |
| 40 | + |
| 41 | + private fun loadIcon(name: String): BufferedImage? { |
| 42 | + val jar = clientJar() ?: return null |
| 43 | + val direct = readEntry(jar, "$TEXTURE_ROOT/item/$name.png") ?: readEntry(jar, "$TEXTURE_ROOT/block/$name.png") |
| 44 | + val raw = direct ?: modelTexturePath(jar, name)?.let { readEntry(jar, "$TEXTURE_ROOT/$it.png") } |
| 45 | + return raw?.let(::normalize) |
| 46 | + } |
| 47 | + |
| 48 | + // Some items have no texture file that matches their own name - a stained glass pane's icon, |
| 49 | + // for instance, is just its plain glass block's texture (see models/item/*_pane.json's |
| 50 | + // "layer0"), not a "*_pane.png" that doesn't exist. Rather than hardcoding every such case, |
| 51 | + // read the reference straight out of the item's own model JSON. Only resolves one model deep |
| 52 | + // (no parent-chain walk, no "#variable" texture substitution), so composite block-shaped |
| 53 | + // items whose model only inherits a texture from a parent (fences, walls, carpets, stairs...) |
| 54 | + // are a known remaining gap - they still fall back to the placeholder, same as before. |
| 55 | + private fun modelTexturePath(jar: ZipFile, name: String): String? { |
| 56 | + val json = readText(jar, "$MODEL_ROOT/item/$name.json") ?: return null |
| 57 | + val texturesBlock = Regex(""""textures"\s*:\s*\{([^}]*)}""").find(json)?.groupValues?.get(1) ?: return null |
| 58 | + val entries = Regex(""""(\w+)"\s*:\s*"([^"]+)"""").findAll(texturesBlock) |
| 59 | + .associate { it.groupValues[1] to it.groupValues[2] } |
| 60 | + val value = TEXTURE_KEY_PRIORITY.firstNotNullOfOrNull { entries[it] } ?: entries.values.firstOrNull() |
| 61 | + return value?.removePrefix("minecraft:")?.takeUnless { it.startsWith("#") } |
| 62 | + } |
| 63 | + |
| 64 | + private fun readText(jar: ZipFile, path: String): String? { |
| 65 | + val entry = jar.getEntry(path) ?: return null |
| 66 | + return jar.getInputStream(entry).use { runCatching { it.readBytes().toString(Charsets.UTF_8) }.getOrNull() } |
| 67 | + } |
| 68 | + |
| 69 | + // Re-resolves whenever the configured override changes (e.g. the dev just saved a new path in |
| 70 | + // Settings > Tools > Inventory Framework), rather than caching it for the plugin's whole |
| 71 | + // lifetime like a plain `by lazy` would - otherwise editing the setting would need an IDE |
| 72 | + // restart to take effect. |
| 73 | + @Synchronized |
| 74 | + private fun clientJar(): ZipFile? { |
| 75 | + val configuredHome = MinecraftIconSettings.getInstance().minecraftHome.ifBlank { null } |
| 76 | + if (resolvedForSetting != configuredHome) { |
| 77 | + resolvedForSetting = configuredHome |
| 78 | + cache.clear() |
| 79 | + cachedJar = locateClientJar(configuredHome)?.let { runCatching { ZipFile(it) }.getOrNull() } |
| 80 | + } |
| 81 | + return cachedJar |
| 82 | + } |
| 83 | + |
| 84 | + private fun readEntry(jar: ZipFile, path: String): BufferedImage? { |
| 85 | + val entry = jar.getEntry(path) ?: return null |
| 86 | + return jar.getInputStream(entry).use { runCatching { ImageIO.read(it) }.getOrNull() } |
| 87 | + } |
| 88 | + |
| 89 | + // Animated textures are stored as a vertical strip of square frames (width == frame size); |
| 90 | + // the first frame is the top width-by-width square. Anything still bigger than one icon cell |
| 91 | + // afterwards (e.g. 32x32 items) is then downscaled. |
| 92 | + private fun normalize(image: BufferedImage): BufferedImage { |
| 93 | + val square = if (image.width != image.height) { |
| 94 | + image.getSubimage(0, 0, image.width, minOf(image.width, image.height)) |
| 95 | + } else { |
| 96 | + image |
| 97 | + } |
| 98 | + if (square.width == ICON_SIZE && square.height == ICON_SIZE) return square |
| 99 | + |
| 100 | + val scaled = BufferedImage(ICON_SIZE, ICON_SIZE, BufferedImage.TYPE_INT_ARGB) |
| 101 | + val g = scaled.createGraphics() |
| 102 | + try { |
| 103 | + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) |
| 104 | + g.drawImage(square, 0, 0, ICON_SIZE, ICON_SIZE, null) |
| 105 | + } finally { |
| 106 | + g.dispose() |
| 107 | + } |
| 108 | + return scaled |
| 109 | + } |
| 110 | + |
| 111 | + // Picks the newest installed release-named version whose jar actually contains item |
| 112 | + // textures, falling back to whatever else is there (e.g. a modloader profile jar) sorted by |
| 113 | + // recency. Modloader profiles set up by the vanilla launcher often "inheritsFrom" a vanilla |
| 114 | + // version instead of bundling assets themselves - those are skipped by the texture check |
| 115 | + // rather than treated as an error, since the real vanilla version is usually also installed. |
| 116 | + // |
| 117 | + // `configuredHome` is the dev's override from Settings > Tools > Inventory Framework |
| 118 | + // (MinecraftIconSettings); null means it's unset and the platform default guess is used. |
| 119 | + private fun locateClientJar(configuredHome: String?): File? { |
| 120 | + val home = configuredHome?.let(::File) ?: minecraftHome() ?: return null |
| 121 | + val versionsDir = File(home, "versions").takeIf { it.isDirectory } ?: return null |
| 122 | + val candidates = versionsDir.listFiles { f -> f.isDirectory } |
| 123 | + ?.mapNotNull { dir -> File(dir, "${dir.name}.jar").takeIf { it.isFile } } |
| 124 | + ?: return null |
| 125 | + |
| 126 | + val releasePattern = Regex("""^\d+\.\d+(\.\d+)?$""") |
| 127 | + val releases = candidates.filter { releasePattern.matches(it.parentFile.name) } |
| 128 | + .sortedByDescending { versionSortKey(it.parentFile.name) } |
| 129 | + val rest = candidates.filterNot { it in releases }.sortedByDescending { it.lastModified() } |
| 130 | + |
| 131 | + return (releases + rest).firstOrNull(::hasItemTextures) |
| 132 | + } |
| 133 | + |
| 134 | + private fun versionSortKey(version: String): Int { |
| 135 | + val parts = version.split('.').map { it.toIntOrNull() ?: 0 } |
| 136 | + return parts.getOrElse(0) { 0 } * 1_000_000 + parts.getOrElse(1) { 0 } * 1_000 + parts.getOrElse(2) { 0 } |
| 137 | + } |
| 138 | + |
| 139 | + private fun hasItemTextures(jar: File): Boolean = |
| 140 | + runCatching { ZipFile(jar).use { it.getEntry("$TEXTURE_ROOT/item/apple.png") != null } }.getOrDefault(false) |
| 141 | + |
| 142 | + private fun minecraftHome(): File? { |
| 143 | + val home = System.getProperty("user.home") ?: return null |
| 144 | + val os = System.getProperty("os.name")?.lowercase(Locale.ROOT).orEmpty() |
| 145 | + val dir = when { |
| 146 | + os.contains("win") -> System.getenv("APPDATA")?.let { File(it, ".minecraft") } ?: File(home, "AppData/Roaming/.minecraft") |
| 147 | + os.contains("mac") -> File(home, "Library/Application Support/minecraft") |
| 148 | + else -> File(home, ".minecraft") |
| 149 | + } |
| 150 | + return dir.takeIf { it.isDirectory } |
| 151 | + } |
| 152 | +} |
0 commit comments