diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b6bbf6 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# MinecraftSkinFixer + +MinecraftSkinFixer is a simple program which allows old versions of Minecraft to download Skins from the new Skin servers. +~~The [Minecraft Forum Thread](https://www.minecraftforum.net/forums/mapping-and-modding-java-edition/minecraft-tools/2923190-minecraftskinfixer-skins-in-old-minecraft-versions) contains more information.~~ +*The Minecraft Forum Thread dissappeared around December 2018/January 2019, together with my Minecraft Forum Account.* + +**NOTE: Versions 2.2 and 2.3 were removed.** +This is because the mods for 1.6.2, 1.6.4 and 1.7.2 which shipped with them did not comply with Minecraft's EULA. +I hope to eventually release a version 2.5 with new, compliant mods. +*And in case you are wondering: 1.x, 2.0 and 2.1 were never released in the first place.* + +--- + +### Supported Minecraft Versions + +All Minecraft versions since the introduction of custom skins up to release 1.5.2 are supported. +However, if you are using a version between release 1.0 and release 1.2.5, you need to supply the `--flip-bottoms=off` parameter when launching the program to make the skins render correctly. + +--- + +### Usage instructions: +1. Download the MinecraftSkinFixer .jar file from the releases tab +2. Start the Minecraft launcher +3. Start MinecraftSkinFixer +4. If this is your first time using MinecraftSkinFixer, configure your launcher profile as instructed by the program +5. Launch Minecraft + +--- + +The main program has two dependencies: +* [Non-standard JRE HTTP server](https://docs.oracle.com/javase/8/docs/jre/api/net/httpserver/spec/index.html) (included in most JREs) +* [org.json:json:20150729](https://search.maven.org/artifact/org.json/json/20150729/jar) (included in the release jars) + +To be able to use it, you need to have Java 8 or later installed on your computer. + diff --git a/main/minecraftskinfixer/HTTPProxyServer.java b/main/minecraftskinfixer/HTTPProxyServer.java new file mode 100644 index 0000000..5507a56 --- /dev/null +++ b/main/minecraftskinfixer/HTTPProxyServer.java @@ -0,0 +1,72 @@ +package minecraftskinfixer; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URL; +import java.net.URLConnection; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +public class HTTPProxyServer implements HttpHandler { + + private static SuperSimpleLogger logger; + private static int connectionIdCounter = 0; + + public static void start(int port, SuperSimpleLogger logger) { + HTTPProxyServer.logger = logger; + HttpServer server = null; + try { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); + } catch (IOException e) { + logger.log("[HTTP Proxy] Unable to create HTTP Server: " + e.getClass().getName() + ": " + e.getMessage()); + return; + } + server.createContext("/", new HTTPProxyServer()); + server.start(); + logger.log("[HTTP Proxy] HTTP Server successfully started on port " + port); + } + + @Override + public void handle(HttpExchange he) throws IOException { + String connectionId = "[HTTP #" + connectionIdCounter++ + "] "; + Headers requestHeaders = he.getRequestHeaders(); + String requestedHost = requestHeaders.get("Host").get(0); + String requestedUrl = he.getRequestURI().toString(); + if(requestedUrl.contains(requestedHost)) { + requestedUrl = requestedUrl.substring(requestedUrl.indexOf(requestedHost) + requestedHost.length()); + } + logger.log("[HTTP Proxy] " + connectionId + "Received a HTTP " + he.getRequestMethod() + " request for " + requestedHost + requestedUrl); + if(he.getRequestMethod().equals("GET") && ( + requestedHost.equals("www.minecraft.net") && requestedUrl.startsWith("/skin/") || + requestedHost.equals("s3.amazonaws.com") && requestedUrl.startsWith("/MinecraftSkins/") || + requestedHost.equals("s3.amazonaws.com") && requestedUrl.startsWith("/MinecraftCloaks/") || + requestedHost.equals("skins.minecraft.net") && requestedUrl.startsWith("/MinecraftSkins/") || + requestedHost.equals("skins.minecraft.net") && requestedUrl.startsWith("/MinecraftCloaks/"))) { + logger.log("[HTTP Proxy] " + connectionId + "Identified request as Minecraft skin request - forwarding to Minecraft skin request handler."); + SkinFixer.runSkinFixer(connectionId, he, requestedHost, requestedUrl); + } else if(he.getRequestMethod().equals("GET")) { + logger.log("[HTTP Proxy] " + connectionId + "Request does not look like a Minecraft skin request - being a really bad but at least possibly kind of working proxy server."); + URL url = new URL("http://" + requestedHost + requestedUrl); + URLConnection conn = url.openConnection(); + he.sendResponseHeaders(200, conn.getContentLengthLong()); + InputStream is = conn.getInputStream(); + OutputStream os = he.getResponseBody(); + byte[] buff = new byte[4096]; + int read; + while((read = is.read(buff)) != -1) { + os.write(buff, 0, read); + } + he.close(); + } else { + logger.log("[HTTP Proxy] " + connectionId + "Request is not a GET request and therefore not supported by this program. Responding with 500 Internal Server Error."); + he.sendResponseHeaders(500, 0); + he.close(); + } + } + +} diff --git a/main/minecraftskinfixer/LogWindow.java b/main/minecraftskinfixer/LogWindow.java new file mode 100644 index 0000000..b29bed2 --- /dev/null +++ b/main/minecraftskinfixer/LogWindow.java @@ -0,0 +1,52 @@ +package minecraftskinfixer; + +import java.awt.BorderLayout; +import java.awt.Font; +import java.util.Calendar; + +import javax.swing.JFrame; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; + +@SuppressWarnings("serial") +public class LogWindow extends JFrame implements SuperSimpleLogger { + + private JTextArea text; + private JScrollPane scroll; + + public LogWindow() { + super("Minecraft Skin Fixer " + SkinFixer2Main.VERSION); + text = new JTextArea(); + text.setFont(new Font("Monospaced", 0, 12)); + text.setEditable(false); + scroll = new JScrollPane(text, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS, JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS); + setLayout(new BorderLayout()); + add(scroll, BorderLayout.CENTER); + setSize(600, 400); + setDefaultCloseOperation(EXIT_ON_CLOSE); + setVisible(true); + revalidate(); + repaint(); + } + + @Override + public void log(String s) { + Calendar c = Calendar.getInstance(); + String timestamp = String.format("[%d-%02d-%02d %02d:%02d:%02d.%03d] ", + c.get(Calendar.YEAR), + c.get(Calendar.MONTH) + 1, + c.get(Calendar.DAY_OF_MONTH), + c.get(Calendar.HOUR_OF_DAY), + c.get(Calendar.MINUTE), + c.get(Calendar.SECOND), + c.get(Calendar.MILLISECOND)); + System.out.println(timestamp + s); + if(text.getText().length() == 0) { + text.setText(timestamp + s); + } else { + text.setText(text.getText() + '\n' + timestamp + s); + } + scroll.getVerticalScrollBar().setValue(scroll.getVerticalScrollBar().getMaximum()); + } + +} diff --git a/main/minecraftskinfixer/SkinConverter.java b/main/minecraftskinfixer/SkinConverter.java new file mode 100644 index 0000000..ec7ff5c --- /dev/null +++ b/main/minecraftskinfixer/SkinConverter.java @@ -0,0 +1,88 @@ +package minecraftskinfixer; + +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; + +public class SkinConverter { + + public static BufferedImage convert(BufferedImage original, boolean isSlim, boolean flipBottoms, SuperSimpleLogger logger, String connectionId) { + if(original.getHeight() <= 32 && !isSlim && !flipBottoms) { + logger.log("[Skin Converter] " + connectionId + "Skin is compatible - not converting."); + return original; + } + BufferedImage converted = new BufferedImage(64, 32, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = converted.createGraphics(); + if(isSlim) { + logger.log("[Skin Converter] " + connectionId + "Converting from slim to normal model"); + //head and second layer of head + g.drawImage(original.getSubimage(0, 0, 64, 16), 0, 0, null); + //legs, body and + //arm: right, front pt. 1 and top pt. 1 + g.drawImage(original.getSubimage(0, 16, 46, 16), 0, 16, null); + //arm: front pt. 2 and top pt. 2 + g.drawImage(original.getSubimage(45, 16, 2, 16), 46, 16, null); + //arm: left, bottom pt. 1 and back pt. 1 + g.drawImage(original.getSubimage(47, 16, 6, 16), 48, 16, null); + //arm: bottom pt. 2 + g.drawImage(original.getSubimage(48, 16, 2, 4), 50, 16, null); + //arm: back pt. 2 + g.drawImage(original.getSubimage(52, 20, 2, 12), 54, 20, null); + } else { + g.drawImage(original.getSubimage(0, 0, 64, 32), 0, 0, null); + } + if(original.getHeight() >= 64) { + logger.log("[Skin Converter] " + connectionId + "Merging second layer from 1.8+ skin onto first layer"); + if(isSlim) { + //legs, body and + //arm: right, front pt. 1 and top pt. 1 + g.drawImage(original.getSubimage(0, 32, 46, 16), 0, 16, null); + //arm: front pt. 2 and top pt. 2 + g.drawImage(original.getSubimage(45, 32, 2, 16), 46, 16, null); + //arm: left, bottom pt. 1 and back pt. 1 + g.drawImage(original.getSubimage(47, 32, 6, 16), 48, 16, null); + //arm: bottom pt. 2 + g.drawImage(original.getSubimage(48, 32, 2, 4), 50, 16, null); + //arm: back pt. 2 + g.drawImage(original.getSubimage(52, 36, 2, 12), 54, 20, null); + } else { + g.drawImage(original.getSubimage(0, 32, 56, 16), 0, 16, null); + } + } + if(flipBottoms) { + logger.log("[Skin Converter]" + connectionId + "Flipping bottom pieces"); + BufferedImage buffer = converted; + converted = new BufferedImage(64, 32, BufferedImage.TYPE_INT_ARGB); + g = converted.createGraphics(); + //head top + g.drawImage(buffer.getSubimage(8, 0, 8, 8), 8, 0, null); + //head2 top + g.drawImage(buffer.getSubimage(40, 0, 8, 8), 40, 0, null); + //head and head2 right, front, left and back + g.drawImage(buffer.getSubimage(0, 8, 64, 8), 0, 8, null); + //legs top + g.drawImage(buffer.getSubimage(4, 16, 4, 4), 4, 16, null); + //body top + g.drawImage(buffer.getSubimage(20, 16, 8, 4), 20, 16, null); + //arms top + g.drawImage(buffer.getSubimage(44, 16, 4, 4), 44, 16, null); + //legs, body and arms right, front, left and back + g.drawImage(buffer.getSubimage(0, 20, 56, 12), 0, 20, null); + for(int i = 0; i < 8; i++) { + //head bottom + g.drawImage(buffer.getSubimage(16, i, 8, 1), 16, 7 - i, null); + //head2 bottom + g.drawImage(buffer.getSubimage(48, i, 8, 1), 48, 7 - i, null); + } + for(int i = 0; i < 4; i++) { + //legs bottom + g.drawImage(buffer.getSubimage(8, 16 + i, 4, 1), 8, 16 + 3 - i, null); + //body bottom + g.drawImage(buffer.getSubimage(28, 16 + i, 8, 1), 28, 16 + 3 - i, null); + //arms bottom + g.drawImage(buffer.getSubimage(48, 16 + i, 4, 1), 48, 16 + 3 - i, null); + } + } + return converted; + } + +} diff --git a/main/minecraftskinfixer/SkinFixer.java b/main/minecraftskinfixer/SkinFixer.java new file mode 100644 index 0000000..0fe74ca --- /dev/null +++ b/main/minecraftskinfixer/SkinFixer.java @@ -0,0 +1,162 @@ +package minecraftskinfixer; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.util.Base64; +import java.util.HashMap; + +import javax.imageio.ImageIO; + +import org.json.JSONArray; +import org.json.JSONObject; + +import com.sun.net.httpserver.HttpExchange; + +public class SkinFixer { + + /** + * 0 = automatically decide if bottom sides of skins need to be flipped.
+ * does not work for versions from release 1.0 to release 1.2.5
+ * 1 = don't convert skins
+ * 2 = never flip bottom sides of skin parts
+ * 3 = always flip bottom sides of skin parts
+ */ + public static byte CONVERT_MODE = 0; + + private static SuperSimpleLogger logger; + private static HashMap remoteFileCache = new HashMap(); + + public static void gimmeLogger(SuperSimpleLogger logger) { + SkinFixer.logger = logger; + } + + public static void runSkinFixer(String connectionId, HttpExchange exchange, String hostname, String url) throws IOException { + byte[] imageFile = doRunSkinFixer(connectionId, hostname, url); + if(imageFile.length != 0) { + exchange.getResponseHeaders().add("Content-Type", "image/png"); + exchange.sendResponseHeaders(200, imageFile.length); + exchange.getResponseBody().write(imageFile); + exchange.close(); + } else { + exchange.sendResponseHeaders(404, 0); + exchange.close(); + } + logger.log("[Skin Fixer] " + connectionId + "Request served successfully. HTTP Connection to game closed."); + } + + private static byte[] doRunSkinFixer(String connectionId, String hostname, String url) throws IOException { + String username; + String textureType; + boolean autoWouldFlipBottoms; + if(hostname.equals("www.minecraft.net") && url.startsWith("/skin/")) { + username = url.substring("/skin/".length(), url.length() - ".png".length()); + textureType = "SKIN"; + autoWouldFlipBottoms = true; + logger.log("[Skin Fixer] " + connectionId + "Received Generation 1 request for skin of player " + username); + } else if(hostname.equals("s3.amazonaws.com") && url.startsWith("/MinecraftSkins/")) { + username = url.substring("/MinecraftSkins/".length(), url.length() - ".png".length()); + textureType = "SKIN"; + autoWouldFlipBottoms = true; + logger.log("[Skin Fixer] " + connectionId + "Received Generation 2 request for skin of player " + username); + } else if(hostname.equals("s3.amazonaws.com") && url.startsWith("/MinecraftCloaks/")) { + username = url.substring("/MinecraftCloaks/".length(), url.length() - ".png".length()); + textureType = "CAPE"; + autoWouldFlipBottoms = true; + logger.log("[Skin Fixer] " + connectionId + "Received Generation 2 request for cape of player " + username); + } else if(hostname.equals("skins.minecraft.net") && url.startsWith("/MinecraftSkins/")) { + username = url.substring("/MinecraftSkins/".length(), url.length() - ".png".length()); + textureType = "SKIN"; + autoWouldFlipBottoms = false; + logger.log("[Skin Fixer] " + connectionId + "Received Generation 3 request for skin of player " + username); + } else if(hostname.equals("skins.minecraft.net") && url.startsWith("/MinecraftCloaks/")) { + username = url.substring("/MinecraftCloaks/".length(), url.length() - ".png".length()); + textureType = "CAPE"; + autoWouldFlipBottoms = false; + logger.log("[Skin Fixer] " + connectionId + "Received Generation 3 request for cape of player " + username); + } else { + throw new RuntimeException(); + } + String uuid = new JSONObject(new String(getRemoteFile("https://api.mojang.com/users/profiles/minecraft/" + username, connectionId))).getString("id"); + logger.log("[Skin Fixer] " + connectionId + "UUID of player " + username + " is " + uuid); + JSONObject profileData = new JSONObject(new String(getRemoteFile("https://sessionserver.mojang.com/session/minecraft/profile/" + uuid, connectionId))); + JSONArray profileProperties = profileData.getJSONArray("properties"); + JSONObject textureProperties = null; + for(Object o : profileProperties) { + if(o instanceof JSONObject) { + JSONObject obj = (JSONObject)o; + if(obj.has("name") && obj.get("name").equals("textures")) { + textureProperties = obj; + } + } + } + if(textureProperties == null) { + logger.log("[Skin Fixer] " + connectionId + "Player does not have texture properties."); + return new byte[0]; + } + String texturePropsStr = textureProperties.getString("value"); + JSONObject texProps = new JSONObject(new String(Base64.getDecoder().decode(texturePropsStr))); + if(!(texProps.has("textures") && texProps.get("textures") instanceof JSONObject)) { + logger.log("[Skin Fixer] " + connectionId + "Texture properties are missing the textures object."); + return new byte[0]; + } + JSONObject textures = texProps.getJSONObject("textures"); + if(!(textures.has(textureType) && textures.get(textureType) instanceof JSONObject)) { + logger.log("[Skin Fixer] " + connectionId + "Player doesn't have a " + textureType.toLowerCase()); + return new byte[0]; + } + JSONObject texture = textures.getJSONObject(textureType); + if(!(texture.has("url") && texture.get("url") instanceof String)) { + logger.log("[Skin Fixer] " + connectionId + "Player doesn't have a " + textureType.toLowerCase()); + return new byte[0]; + } + String textureUrl = texture.getString("url"); + String textureId = textureUrl.substring("http://textures.minecraft.net/texture/".length()); + logger.log("[Skin Fixer] " + connectionId + "ID of " + textureType.toLowerCase() + " texture is " + textureId); + if(textureType.equals("SKIN") && CONVERT_MODE != 1) { + byte[] skinFile = getRemoteFile(textureUrl, connectionId); + BufferedImage skinImage = ImageIO.read(new ByteArrayInputStream(skinFile)); + boolean slim = false; + if(texture.has("metadata") && texture.get("metadata") instanceof JSONObject) { + JSONObject metadata = texture.getJSONObject("metadata"); + if(metadata.has("model") && metadata.get("model") instanceof String && metadata.getString("model").equals("slim")) { + slim = true; + } + } + boolean flipBottoms = CONVERT_MODE == 0 ? autoWouldFlipBottoms : CONVERT_MODE == 3; + BufferedImage convertedSkinImage = SkinConverter.convert(skinImage, slim, flipBottoms, logger, connectionId); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(convertedSkinImage, "PNG", baos); + return baos.toByteArray(); + } else { + return getRemoteFile(textureUrl, connectionId); + } + } + + private static byte[] getRemoteFile(String url, String connectionId) throws IOException { + if(remoteFileCache.containsKey(url)) { + logger.log("[Download] " + connectionId + "No need to download " + url + " - file is stored in cache."); + return remoteFileCache.get(url); + } else { + logger.log("[Download] " + connectionId + "Downloading " + url); + ByteArrayOutputStream baos = new ByteArrayOutputStream(4096); + URLConnection conn = new URL(url).openConnection(); + InputStream is = conn.getInputStream(); + byte[] buff = new byte[4096]; + int read; + while((read = is.read(buff)) != -1) { + baos.write(buff, 0, read); + } + byte[] file = baos.toByteArray(); + baos.close(); + logger.log("[Download] " + connectionId + "Successfully downloaded " + url + " (" + file.length + " bytes)"); + remoteFileCache.put(url, file); + return file; + } + } + +} diff --git a/main/minecraftskinfixer/SkinFixer2Main.java b/main/minecraftskinfixer/SkinFixer2Main.java new file mode 100644 index 0000000..39aa951 --- /dev/null +++ b/main/minecraftskinfixer/SkinFixer2Main.java @@ -0,0 +1,83 @@ +package minecraftskinfixer; + +public class SkinFixer2Main { + + public static final String VERSION = "2.4"; + + private static SuperSimpleLogger logger; + + public static void main(String[] args) { + logger = new LogWindow(); + SkinFixer.gimmeLogger(logger); + logger.log("Welcome to Minecraft Skin Fixer " + VERSION); + logger.log("Made by https://github.com/DoubleNegation"); + logger.log("Source code available at https://github.com/DoubleNegation/MinecraftSkinFixer"); + logger.log("Running on Java " + System.getProperty("java.version")); + logger.log("Close this window to exit."); + logger.log("(Build Date: 2019-05-31)"); + logger.log("----------------------------------------"); + int overrideHttpPort = -1; + boolean printHelp = false; + for(String s : args) { + if(s.startsWith("--httpport=")) { + String portStr = s.substring("--httpport=".length()); + int portNum = -1; + try { + portNum = Integer.parseInt(portStr); + } catch(NumberFormatException e) { + logger.log("WARNING: Invalid value for command line argument httpport: \"" + portStr + "\" is not a number."); + } + if(!(portNum >= 1 && portNum <= 65535)) { + logger.log("WARNING: Invalid port number for httpport - Must be in range 1..65535"); + portNum = -1; + } + overrideHttpPort = portNum; + } else if(s.equals("--help")) { + printHelp = true; + } else if(s.equals("--no-convert")) { + logger.log("Skin converter was disabled via command-line argument."); + SkinFixer.CONVERT_MODE = 1; + } else if(s.startsWith("--flip-bottoms=")) { + String valueStr = s.substring("--flip-bottoms=".length()); + if(valueStr.equals("on")) { + if(SkinFixer.CONVERT_MODE != 1) SkinFixer.CONVERT_MODE = 3; + } else if(valueStr.equals("off")) { + if(SkinFixer.CONVERT_MODE != 1) SkinFixer.CONVERT_MODE = 2; + } else if(valueStr.equals("auto")) { + if(SkinFixer.CONVERT_MODE != 1) SkinFixer.CONVERT_MODE = 0; + } else { + logger.log("WARNING: Invalid value \"" + valueStr + "\" for option --flip-bottoms. Allowed values are: on, off, auto"); + } + } + } + int usedHttpPort = overrideHttpPort == -1 ? 8080 : overrideHttpPort; + if(overrideHttpPort == -1) { + logger.log("Using default port 8080 for HTTP proxy."); + } else { + logger.log("Using user-specified port " + usedHttpPort + " for HTTP proxy."); + } + if(SkinFixer.CONVERT_MODE == 2) logger.log("Bottom sides of skins will never be flipped (specified with argument --flip-bottoms=off)"); + else if(SkinFixer.CONVERT_MODE == 3) logger.log("Bottom sides of skins will always be flipped (specified with argument --flip-bottoms=on)"); + else if(SkinFixer.CONVERT_MODE == 1) logger.log("Skin converter mode is on automatic mode. This will not work with all versions from Minecraft 1.0 to Minecraft 1.2.5. Specify argument --flip-bottoms=off to use MinecraftSkinFixer with thoes versions."); + logger.log("Use command-line option --help to get a list of available command-line arguments."); + if(printHelp) { + logger.log("----------------------------------------"); + logger.log("Available Command-Line options:"); + logger.log("--help Causes this help screen to be displayed."); + logger.log("--no-convert Causes the skin converter to be disabled."); + logger.log("--flip-bottoms=on|off|auto Changes the behaviour of the skin converter."); + logger.log(" This has no effect if --no-convert is specified."); + logger.log(" Default: auto"); + logger.log(" auto does not work with versions 1.0 - 1.2.5. Use off for these versions."); + logger.log("--httpport= Causes SkinFixer to use a different port for the HTTP proxy (default is 8080)."); + } + logger.log("----------------------------------------"); + logger.log("Add the following arguments to the \"JVM Arguments\" option of your minecraft launch configuration / launcher profile to use the skin fixer:"); + logger.log("-Dhttp.proxyHost=127.0.0.1"); + logger.log("-Dhttp.proxyPort=" + usedHttpPort); + logger.log("----------------------------------------"); + HTTPProxyServer.start(usedHttpPort, logger); + logger.log("----------------------------------------"); + } + +} diff --git a/main/minecraftskinfixer/SuperSimpleLogger.java b/main/minecraftskinfixer/SuperSimpleLogger.java new file mode 100644 index 0000000..d4f2ea9 --- /dev/null +++ b/main/minecraftskinfixer/SuperSimpleLogger.java @@ -0,0 +1,7 @@ +package minecraftskinfixer; + +public interface SuperSimpleLogger { + + public void log(String s); + +}