diff --git a/.gitattributes b/.gitattributes index 6126f5aac22..5aea6a69309 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,9 @@ # needs additional setup, see tools/mapmerge/install.txt *.dmm merge=merge-dmm +# dmi icon merger hook +# needs additional setup, see tools/dmitool/merging.txt +*.dmi merge=merge-dmi + # force changelog merging to use union html/changelog.html merge=union diff --git a/tools/dmitool/README.txt b/tools/dmitool/README.txt new file mode 100644 index 00000000000..409ab873c3d --- /dev/null +++ b/tools/dmitool/README.txt @@ -0,0 +1,5 @@ +Uses PNGJ: https://code.google.com/p/pngj/. + +For help, use "java -jar dmitool.jar help". + +Requires Java 7. \ No newline at end of file diff --git a/tools/dmitool/build.gradle b/tools/dmitool/build.gradle new file mode 100644 index 00000000000..2e5fbc5b08e --- /dev/null +++ b/tools/dmitool/build.gradle @@ -0,0 +1,16 @@ +apply plugin: 'java' + +repositories { + mavenCentral() +} + +dependencies { + compile group: 'ar.com.hjg', name: 'pngj', version: '2.1.0' +} + +jar { + from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } } + manifest { + attributes 'Main-Class': 'dmitool.Main' + } +} \ No newline at end of file diff --git a/tools/dmitool/dmimerge.sh b/tools/dmitool/dmimerge.sh new file mode 100644 index 00000000000..dbe846fc6a9 --- /dev/null +++ b/tools/dmitool/dmimerge.sh @@ -0,0 +1,8 @@ +java -jar tools/dmitool/dmitool.jar merge $1 $2 $3 $2 +if [ "$?" -gt 0 ] +then + echo "Unable to automatically resolve all icon_state conflicts, please merge manually." + exit 1 +fi + +exit 0 diff --git a/tools/dmitool/dmitool.jar b/tools/dmitool/dmitool.jar new file mode 100644 index 00000000000..4cfa49f36de Binary files /dev/null and b/tools/dmitool/dmitool.jar differ diff --git a/tools/dmitool/dmitool.py b/tools/dmitool/dmitool.py new file mode 100644 index 00000000000..390f0d745ff --- /dev/null +++ b/tools/dmitool/dmitool.py @@ -0,0 +1,94 @@ +""" Python 2.7 wrapper for dmitool. +""" + +import os +from subprocess import Popen, PIPE + +_JAVA_PATH = ["java"] +_DMITOOL_CMD = ["-jar", "dmitool.jar"] + +def _dmitool_call(*dmitool_args, **popen_args): + return Popen(_JAVA_PATH + _DMITOOL_CMD + [str(arg) for arg in dmitool_args], **popen_args) + +def _safe_parse(dict, key, deferred_value): + try: + dict[key] = deferred_value() + except Exception as e: + print "Could not parse property '%s': %s"%(key, e) + return e + return False + +def version(): + """ Returns the version as a string. """ + stdout, stderr = _dmitool_call("version", stdout=PIPE).communicate() + return str(stdout).strip() + +def help(): + """ Returns the help text as a string. """ + stdout, stderr = _dmitool_call("help", stdout=PIPE).communicate() + return str(stdout).strip() + +def info(filepath): + """ Totally not a hack that parses the output from dmitool into a dictionary. + May break at any moment. + """ + subproc = _dmitool_call("info", filepath, stdout=PIPE) + stdout, stderr = subproc.communicate() + + result = {} + data = stdout.split(os.linesep)[1:] + #for s in data: print s + + #parse header line + if len(data) > 0: + header = data.pop(0).split(",") + #don't need to parse states, it's redundant + _safe_parse(result, "images", lambda: int(header[0].split()[0].strip())) + _safe_parse(result, "size", lambda: header[2].split()[1].strip()) + + #parse state information + states = [] + for item in data: + if not len(item): continue + + stateinfo = {} + item = item.split(",", 3) + _safe_parse(stateinfo, "name", lambda: item[0].split()[1].strip(" \"")) + _safe_parse(stateinfo, "dirs", lambda: int(item[1].split()[0].strip())) + _safe_parse(stateinfo, "frames", lambda: int(item[2].split()[0].strip())) + if len(item) > 3: + stateinfo["misc"] = item[3] + + states.append(stateinfo) + + result["states"] = states + return result + +def extract_state(input_path, output_path, icon_state, direction=None, frame=None): + """ Extracts an icon state as a png to a given path. + If provided direction should be a string, one of S, N, E, W, SE, SW, NE, NW. + If provided frame should be a frame number or a string of two frame number separated by a dash. + """ + args = ["extract", input_path, icon_state, output_path] + if direction is not None: args.extend(("direction" , str(direction))) + if frame is not None: args.extend(("frame" , str(frame))) + return _dmitool_call(*args) + +def import_state(target_path, input_path, icon_state, replace=False, delays=None, rewind=False, loop=None, ismovement=False, direction=None, frame=None): + """ Inserts an input png given by the input_path into the target_path. + """ + args = ["import", target_path, icon_state, input_path] + + if replace: args.append("nodup") + if rewind: args.append("rewind") + if ismovement: args.append("movement") + if delays: args.extend(("delays", ",".join(delays))) + if direction is not None: args.extend(("direction", direction)) + if frame is not None: args.extend(("frame", frame)) + + if loop in ("inf", "infinity"): + args.append("loop") + elif loop: + args.extend(("loopn", loop)) + + return _dmitool_call(*args) diff --git a/tools/dmitool/git_merge_installer.bat b/tools/dmitool/git_merge_installer.bat new file mode 100644 index 00000000000..18ab7c8026a --- /dev/null +++ b/tools/dmitool/git_merge_installer.bat @@ -0,0 +1,6 @@ +@echo off +set tab= +echo. >> ../../.git/config +echo [merge "merge-dmi"] >> ../../.git/config +echo %tab%name = iconfile merge driver >> ../../.git/config +echo %tab%driver = ./tools/dmitool/dmimerge.sh %%O %%A %%B >> ../../.git/config diff --git a/tools/dmitool/git_merge_installer.sh b/tools/dmitool/git_merge_installer.sh new file mode 100755 index 00000000000..e0c48823b11 --- /dev/null +++ b/tools/dmitool/git_merge_installer.sh @@ -0,0 +1,6 @@ +F="../../.git/config" + +echo '' >> $F +echo '[merge "merge-dmi"]' >> $F +echo ' name = iconfile merge driver' >> $F +echo ' driver = ./tools/dmitool/dmimerge.sh %O %A %B' >> $F diff --git a/tools/dmitool/merging.txt b/tools/dmitool/merging.txt new file mode 100644 index 00000000000..d8943efb985 --- /dev/null +++ b/tools/dmitool/merging.txt @@ -0,0 +1,14 @@ +1. Install java(http://www.java.com/en/download/index.jsp) +2. Make sure java is in your PATH. To test this, open git bash, and type "java". If it says unknown command, you need to add JAVA/bin to your PATH variable (A guide for this can be found at https://www.java.com/en/download/help/path.xml ). + +Merging +The easiest way to do merging is to install the merge driver. For this, open `-tg-station/.git/config` in a text editor, and paste the following lines to the end of it: + +[merge "merge-dmi"] + name = iconfile merge driver + driver = ./tools/dmitool/dmimerge.sh %O %A %B + +You may optionally instead run git_merge_installer.bat or git_merge_installer.sh which should automatically insert these lines for you at the appropriate location. + +After this, merging DMI files should happen automagically unless there are conflicts (an icon_state that both you and someone else changed). +If there are conflicts, you will unfortunately still be stuck with opening both versions in the editor, and manually resolving the issues with those states. diff --git a/tools/dmitool/src/main/java/dmitool/DMI.java b/tools/dmitool/src/main/java/dmitool/DMI.java new file mode 100644 index 00000000000..bb560107ae9 --- /dev/null +++ b/tools/dmitool/src/main/java/dmitool/DMI.java @@ -0,0 +1,455 @@ +package dmitool; + +import ar.com.hjg.pngj.ImageInfo; +import ar.com.hjg.pngj.ImageLineInt; +import ar.com.hjg.pngj.PngReader; +import ar.com.hjg.pngj.PngWriter; +import ar.com.hjg.pngj.PngjInputException; +import ar.com.hjg.pngj.chunks.PngChunkPLTE; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; + +public class DMI implements Comparator { + int w, h; + List images; + int totalImages = 0; + RGBA[] palette; + boolean isPaletted; + + public DMI(int w, int h) { + this.w = w; + this.h = h; + images = new ArrayList<>(); + isPaletted = false; + palette = null; + } + + public DMI(String f) throws DMIException, FileNotFoundException { + this(new File(f)); + } + + public DMI(File f) throws DMIException, FileNotFoundException { + if(f.length() == 0) { // Empty .dmi is empty file + w = 32; + h = 32; + images = new ArrayList<>(); + isPaletted = false; + palette = null; + return; + } + InputStream in = new FileInputStream(f); + PngReader pngr; + try { + pngr = new PngReader(in); + } catch(PngjInputException pie) { + throw new DMIException("Bad file format!", pie); + } + String descriptor = pngr.getMetadata().getTxtForKey("Description"); + String[] lines = descriptor.split("\n"); + + if(Main.VERBOSITY > 0) System.out.println("Descriptor has " + lines.length + " lines."); + if(Main.VERBOSITY > 3) { + System.out.println("Descriptor:"); + System.out.println(descriptor); + } + + /* length 6 is: + # BEGIN DMI + version = 4.0 + state = "state" + dirs = 1 + frames = 1 + # END DMI + */ + if(lines.length < 6) throw new DMIException(null, 0, "Descriptor too short!"); + + if(!"# BEGIN DMI".equals(lines[0])) throw new DMIException(lines, 0, "Expected '# BEGIN DMI'"); + if(!"# END DMI".equals(lines[lines.length-1])) throw new DMIException(lines, lines.length-1, "Expected '# END DMI'"); + if(!"version = 4.0".equals(lines[1])) throw new DMIException(lines, 1, "Unknown version, expected 'version = 4.0'"); + + this.w = 32; + this.h = 32; + + int i = 2; + + if(lines[i].startsWith("\twidth = ")) { + this.w = Integer.parseInt(lines[2].substring("\twidth = ".length())); + i++; + } + if(lines[i].startsWith("\theight = ")) { + this.h = Integer.parseInt(lines[3].substring("\theight = ".length())); + i++; + } + + List states = new ArrayList<>(); + + while(i < lines.length - 1) { + long imagesInState = 1; + if(!lines[i].startsWith("state = \"") || !lines[i].endsWith("\"")) throw new DMIException(lines, i, "Error reading state string"); + String stateName = lines[i].substring("state = \"".length(), lines[i].length()-1); + i++; + int dirs = 1; + int frames = 1; + float[] delays = null; + boolean rewind = false; + int loop = -1; + String hotspot = null; + boolean movement = false; + while(lines[i].startsWith("\t")) { + if(lines[i].startsWith("\tdirs = ")) { + dirs = Integer.parseInt(lines[i].substring("\tdirs = ".length())); + imagesInState *= dirs; + i++; + } else if(lines[i].startsWith("\tframes = ")) { + frames = Integer.parseInt(lines[i].substring("\tframes = ".length())); + imagesInState *= frames; + i++; + } else if(lines[i].startsWith("\tdelay = ")) { + String delayString = lines[i].substring("\tdelay = ".length()); + String[] delayVals = delayString.split(","); + delays = new float[delayVals.length]; + for(int d=0; d 0) System.out.println(pal.getNentries() + " palette entries"); + + palette = new RGBA[pal.getNentries()]; + int[] rgb = new int[3]; + for(int q=0; q 0) System.out.println("Non-paletted image"); + } + + int iw = pngr.imgInfo.cols; + int ih = pngr.imgInfo.rows; + + if(totalImages > iw * ih) + throw new DMIException(null, 0, "Impossible number of images!"); + + if(Main.VERBOSITY > 0) System.out.println("Image size " + iw+"x"+ih); + int[][] px = new int[ih][]; + + for(int y=0; y statesY) + // this should NEVER happen, we pre-check it + throw new DMIException(null, 0, "CRITICAL: End of image reached with states to go!"); + } + } + if(is.delays != null) { + if((Main.STRICT && is.delays.length*is.dirs != img.length) || is.delays.length*is.dirs < img.length) + throw new DMIException(null, 0, "Delay array size mismatch: " + is.delays.length*is.dirs + " vs " + img.length + "!"); + } + is.images = img; + } + } + + public IconState getIconState(String name) { + for(IconState is: images) { + if(is.name.equals(name)) { + return is; + } + } + return null; + } + + /** + * Makes a copy, unless name is null. + */ + public void addIconState(String name, IconState is) { + if(name == null) { + images.add(is); + totalImages += is.dirs * is.frames; + } else { + IconState newState = (IconState)is.clone(); + newState.name = name; + images.add(newState); + totalImages += is.dirs * is.frames; + } + } + + public boolean removeIconState(String name) { + for(IconState is: images) { + if(is.name.equals(name)) { + images.remove(is); + totalImages -= is.dirs * is.frames; + return true; + } + } + return false; + } + + public boolean setIconState(IconState is) { + for(int i=0; i 0) System.out.println("Fixing PNG chunks..."); + out.writeInt(in.readInt()); + out.writeInt(in.readInt()); + + Deque notZTXT = new ArrayDeque<>(); + + PNGChunk c = null; + + while(c == null || c.type != IEND) { + c = new PNGChunk(in); + if(c.type == zTXt && notZTXT != null) { + PNGChunk cc = null; + while(cc == null || cc.type != IHDR) { + cc = notZTXT.pop(); + cc.write(out); + } + c.write(out); + while(notZTXT.size() != 0) { + PNGChunk pc = notZTXT.pop(); + pc.write(out); + } + notZTXT = null; + } else if(notZTXT != null) { + notZTXT.add(c); + } else { + c.write(out); + } + } + if(Main.VERBOSITY > 0) System.out.println("Chunks fixed."); + } + + @Override public int compare(IconState arg0, IconState arg1) { + return arg0.name.compareTo(arg1.name); + } + + public void writeDMI(OutputStream os) throws IOException { + writeDMI(os, false); + } + public void writeDMI(OutputStream os, boolean sortStates) throws IOException { + if(totalImages == 0) { // Empty .dmis are empty files + os.close(); + return; + } + + // Setup chunk-fix buffer + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + if(sortStates) { + Collections.sort(images, this); + } + + // Write the dmi into the buffer + int sx = (int)Math.ceil(Math.sqrt(totalImages)); + int sy = totalImages / sx; + if(sx*sy < totalImages) { + sy++; + } + if(Main.VERBOSITY > 0) System.out.println("Image size: " + w + "x" + h + "; number of images " + sx + "x" + sy + " (" + totalImages + ")"); + int ix = sx * w; + int iy = sy * h; + ImageInfo ii = new ImageInfo(ix, iy, 8, true); + PngWriter out = new PngWriter(baos, ii); + out.setCompLevel(9); // Maximum compression + String description = getDescriptor(); + if(Main.VERBOSITY > 0) System.out.println("Descriptor has " + (description.split("\n").length) + " lines."); + out.getMetadata().setText("Description", description, true, true); + + Image[][] img = new Image[sx][sy]; + { + int k = 0; + int r = 0; + for(IconState is: images) { + for(Image i: is.images) { + img[k++][r] = i; + + if(k == sx) { + k = 0; + r++; + } + } + } + } + + for(int irow=0; irow myIS = new HashMap<>(); + HashMap dmiIS = new HashMap<>(); + + for(IconState is: images) { + myIS.put(is.name, is); + } + for(IconState is: dmi.images) { + dmiIS.put(is.name, is); + } + if(!myIS.keySet().equals(dmiIS.keySet())) return false; + for(String s: myIS.keySet()) { + if(!myIS.get(s).equals(dmiIS.get(s))) return false; + } + + return true; + } +} diff --git a/tools/dmitool/src/main/java/dmitool/DMIDiff.java b/tools/dmitool/src/main/java/dmitool/DMIDiff.java new file mode 100644 index 00000000000..431d9c1d15a --- /dev/null +++ b/tools/dmitool/src/main/java/dmitool/DMIDiff.java @@ -0,0 +1,194 @@ +package dmitool; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class DMIDiff { + Map newIconStates; + Map modifiedIconStates = new HashMap<>(); + Set removedIconStates; + + DMIDiff() { + newIconStates = new HashMap<>(); + removedIconStates = new HashSet<>(); + } + + public DMIDiff(DMI base, DMI mod) { + if(base.h != mod.h || base.w != mod.w) throw new IllegalArgumentException("Cannot compare non-identically-sized DMIs!"); + + HashMap baseIS = new HashMap<>(); + for(IconState is: base.images) { + baseIS.put(is.name, is); + } + + HashMap modIS = new HashMap<>(); + for(IconState is: mod.images) { + modIS.put(is.name, is); + } + + newIconStates = ((HashMap)modIS.clone()); + for(String s: baseIS.keySet()) { + newIconStates.remove(s); + } + + removedIconStates = new HashSet<>(); + removedIconStates.addAll(baseIS.keySet()); + removedIconStates.removeAll(modIS.keySet()); + + Set retainedStates = new HashSet<>(); + retainedStates.addAll(baseIS.keySet()); + retainedStates.retainAll(modIS.keySet()); + + for(String s: retainedStates) { + if(!baseIS.get(s).equals(modIS.get(s))) { + modifiedIconStates.put(s, new IconStateDiff(baseIS.get(s), modIS.get(s))); + } + } + } + /** + * ASSUMES NO MERGE CONFLICTS - MERGE DIFFS FIRST. + */ + public void applyToDMI(DMI dmi) { + for(String s: removedIconStates) { + dmi.removeIconState(s); + } + for(String s: modifiedIconStates.keySet()) { + dmi.setIconState(modifiedIconStates.get(s).newState); + } + for(String s: newIconStates.keySet()) { + dmi.addIconState(null, newIconStates.get(s)); + } + } + + /** + * @param other The diff to merge with + * @param conflictDMI A DMI to add conflicted icon_states to + * @param merged An empty DMIDiff to merge into + * @param aName The log name for this diff + * @param bName The log name for {@code other} + * @return A Set containing all icon_states which conflicted, along with what was done in each diff, in the format "icon_state: here|there"; here and there are one of "added", "modified", and "removed" + */ + public Set mergeDiff(DMIDiff other, DMI conflictDMI, DMIDiff merged, String aName, String bName) { + HashSet myTouched = new HashSet<>(); + myTouched.addAll(removedIconStates); + myTouched.addAll(newIconStates.keySet()); + myTouched.addAll(modifiedIconStates.keySet()); + + HashSet otherTouched = new HashSet<>(); + otherTouched.addAll(other.removedIconStates); + otherTouched.addAll(other.newIconStates.keySet()); + otherTouched.addAll(other.modifiedIconStates.keySet()); + + HashSet bothTouched = (HashSet)myTouched.clone(); + bothTouched.retainAll(otherTouched); // this set now contains the list of icon_states that *both* diffs modified, which we'll put in conflictDMI for manual merge (unless they were deletions + + if(Main.VERBOSITY > 0) { + System.out.println("a: " + Arrays.toString(myTouched.toArray())); + System.out.println("b: " + Arrays.toString(otherTouched.toArray())); + System.out.println("both: " + Arrays.toString(bothTouched.toArray())); + } + + HashSet whatHappened = new HashSet<>(); + + for(String s: bothTouched) { + String here, there; + if(removedIconStates.contains(s)) { + here = "removed"; + } else if(newIconStates.containsKey(s)) { + here = "added"; + } else if(modifiedIconStates.containsKey(s)) { + here = "modified"; + } else { + System.out.println("Unknown error; state="+s); + here = "???"; + } + + if(other.removedIconStates.contains(s)) { + there = "removed"; + } else if(other.newIconStates.containsKey(s)) { + there = "added"; + } else if(other.modifiedIconStates.containsKey(s)) { + there = "modified"; + } else { + System.out.println("Unknown error; state="+s); + there = "???"; + } + + whatHappened.add(s + ": " + here + "|" + there); + } + + // Removals + for(String s: removedIconStates) { + if(!bothTouched.contains(s)) { + merged.removedIconStates.add(s); + } + } + for(String s: other.removedIconStates) { + if(!bothTouched.contains(s)) { + merged.removedIconStates.add(s); + } + } + + // Modifications + for(String s: modifiedIconStates.keySet()) { + if(!bothTouched.contains(s)) { + merged.modifiedIconStates.put(s, modifiedIconStates.get(s)); + } else { + conflictDMI.addIconState(aName + "|" + s, modifiedIconStates.get(s).newState); + } + } + for(String s: other.modifiedIconStates.keySet()) { + if(!bothTouched.contains(s)) { + merged.modifiedIconStates.put(s, other.modifiedIconStates.get(s)); + } else { + conflictDMI.addIconState(bName + "|" + s, other.modifiedIconStates.get(s).newState); + } + } + + // Additions + for(String s: newIconStates.keySet()) { + if(!bothTouched.contains(s)) { + merged.newIconStates.put(s, newIconStates.get(s)); + } else { + conflictDMI.addIconState(aName + s, newIconStates.get(s)); + } + } + for(String s: other.newIconStates.keySet()) { + if(!bothTouched.contains(s)) { + merged.newIconStates.put(s, other.newIconStates.get(s)); + } else { + conflictDMI.addIconState(bName + s, other.newIconStates.get(s)); + } + } + + return whatHappened; + } + + @Override public String toString() { + String s = ""; + String t = "\t"; + String q = "\""; + String n = "\n"; + if(!removedIconStates.isEmpty()) { + s += "Removed:\n"; + for(String state: removedIconStates) + s += t + q + state + q + n; + } + if(!modifiedIconStates.isEmpty()) { + s += "Modified:\n"; + for(String state: modifiedIconStates.keySet()) + s += t + q + state + q + " [" + modifiedIconStates.get(state).toString() + "]\n"; + } + if(!newIconStates.isEmpty()) { + s += "Added:\n"; + for(String state: newIconStates.keySet()) + s += t + q + state + q + " " + newIconStates.get(state).infoStr() + n; + } + if("".equals(s)) + return "No changes"; + return s; + } +} \ No newline at end of file diff --git a/tools/dmitool/src/main/java/dmitool/DMIException.java b/tools/dmitool/src/main/java/dmitool/DMIException.java new file mode 100644 index 00000000000..e9293162894 --- /dev/null +++ b/tools/dmitool/src/main/java/dmitool/DMIException.java @@ -0,0 +1,24 @@ +package dmitool; + +public class DMIException extends Exception { + String[] desc = null; + int line = 0; + public DMIException(String[] descriptor, int line, String what) { + super(what); + desc = descriptor; + this.line = line; + } + public DMIException(String what) { + super(what); + } + public DMIException(String what, Exception cause) { + super(what, cause); + } + + @Override public String getMessage() { + if(desc != null) + return "\"" + desc[line] + "\" - " + super.getMessage(); + + return super.getMessage(); + } +} \ No newline at end of file diff --git a/tools/dmitool/src/main/java/dmitool/IconState.java b/tools/dmitool/src/main/java/dmitool/IconState.java new file mode 100644 index 00000000000..2a2202c71cd --- /dev/null +++ b/tools/dmitool/src/main/java/dmitool/IconState.java @@ -0,0 +1,280 @@ +package dmitool; + +import java.util.Arrays; +import ar.com.hjg.pngj.ImageInfo; +import ar.com.hjg.pngj.ImageLineInt; +import ar.com.hjg.pngj.PngWriter; +import ar.com.hjg.pngj.PngReader; +import ar.com.hjg.pngj.PngjInputException; +import java.io.InputStream; +import java.io.OutputStream; + +public class IconState { + String name; + int dirs; + int frames; + float[] delays; + Image[] images; // dirs come first + boolean rewind; + int loop; + String hotspot; + boolean movement; + + public String getInfoLine() { + String extraInfo = ""; + if(rewind) extraInfo += " rewind"; + if(frames != 1) { + extraInfo += " loop(" + (loop==-1 ? "infinite" : loop) + ")"; + } + if(hotspot != null) extraInfo += " hotspot('" + hotspot + "')"; + if(movement) extraInfo += " movement"; + if(extraInfo.equals("")) { + return String.format("state \"%s\", %d dir(s), %d frame(s)", name, dirs, frames); + } else { + return String.format("state \"%s\", %d dir(s), %d frame(s),%s", name, dirs, frames, extraInfo); + } + } + + @Override public IconState clone() { + IconState is = new IconState(name, dirs, frames, images.clone(), delays==null ? null : delays.clone(), rewind, loop, hotspot, movement); + is.delays = delays != null ? delays.clone() : null; + is.rewind = rewind; + + return is; + } + + public IconState(String name, int dirs, int frames, Image[] images, float[] delays, boolean rewind, int loop, String hotspot, boolean movement) { + if(delays != null) { + if(Main.STRICT && delays.length != frames) { + throw new IllegalArgumentException("Delays and frames must be the same length!"); + } + } + this.name = name; + this.dirs = dirs; + this.frames = frames; + this.images = images; + this.rewind = rewind; + this.loop = loop; + this.hotspot = hotspot; + this.delays = delays; + this.movement = movement; + } + void setDelays(float[] delays) { + this.delays = delays; + } + void setRewind(boolean b) { + rewind = b; + } + @Override public boolean equals(Object obj) { + if(obj == this) return true; + if(!(obj instanceof IconState)) return false; + + IconState is = (IconState)obj; + + if(!is.name.equals(name)) return false; + if(is.dirs != dirs) return false; + if(is.frames != frames) return false; + if(!Arrays.equals(images, is.images)) return false; + if(is.rewind != rewind) return false; + if(is.loop != loop) return false; + if(!Arrays.equals(delays, is.delays)) return false; + if(!(is.hotspot == null ? hotspot == null : is.hotspot.equals(hotspot))) return false; + if(is.movement != movement) return false; + + return true; + } + public String infoStr() { + return "[" + frames + " frame(s), " + dirs + " dir(s)]"; + } + public String getDescriptorFragment() { + String s = ""; + String q = "\""; + String n = "\n"; + s += "state = " + q + name + q + n; + s += "\tdirs = " + dirs + n; + s += "\tframes = " + frames + n; + if(delays != null) { + s += "\tdelay = " + delayArrayToString(delays) + n; + } + if(rewind) { + s += "\trewind = 1\n"; + } + if(loop != -1) { + s += "\tloop = " + loop + n; + } + if(hotspot != null) { + s += "\thotspot = " + hotspot + n; + } + if(movement) { + s += "\tmovement = 1\n"; + } + return s; + } + + private static String delayArrayToString(float[] d) { + String s = ""; + for(float f: d) { + s += ","+f; + } + return s.substring(1); + } + + /** + * Dump the state to the given OutputStream in PNG format. Frames will be dumped along the X axis of the image, and directions will be dumped along the Y. + */ + public void dumpToPNG(OutputStream outS, int minDir, int maxDir, int minFrame, int maxFrame) { + int totalDirs = maxDir - minDir + 1; + int totalFrames = maxFrame - minFrame + 1; + + int w = images[minDir + minFrame * this.dirs].w; + int h = images[minDir + minFrame * this.dirs].h; + + if(Main.VERBOSITY > 0) System.out.println("Writing " + totalDirs + " dir(s), " + totalFrames + " frame(s), " + totalDirs*totalFrames + " image(s) total."); + ImageInfo ii = new ImageInfo(totalFrames * w, totalDirs * h, 8, true); + PngWriter out = new PngWriter(outS, ii); + out.setCompLevel(9); + + Image[][] img = new Image[totalFrames][totalDirs]; + { + for(int i=0; i= frames) + throw new IllegalArgumentException("Provided frame is out of range: " + frame); + if(dir < 0 || dir >= dirs) + throw new IllegalArgumentException("Provided dir is out of range: " + dir); + + images[getIndex(dir, frame)] = splice; + } +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/dmitool/src/main/java/dmitool/IconStateDiff.java b/tools/dmitool/src/main/java/dmitool/IconStateDiff.java new file mode 100644 index 00000000000..a5d30db7fe0 --- /dev/null +++ b/tools/dmitool/src/main/java/dmitool/IconStateDiff.java @@ -0,0 +1,126 @@ +package dmitool; + +import java.util.HashMap; +import java.util.HashSet; + +public class IconStateDiff { + static class ISAddress { + int dir; + int frame; + + public ISAddress(int dir, int frame) { + this.dir = dir; + this.frame = frame; + } + + public String infoStr(int maxDir, int maxFrame) { + if(maxDir == 1 && maxFrame == 1) { + return ""; + } else if(maxDir == 1) { + return "{" + frame + "}"; + } else if(maxFrame == 1) { + return "{" + Main.dirs[dir] + "}"; + } else { + return "{" + Main.dirs[dir] + " " + frame + "}"; + } + } + } + int oldFrameCount = 0; + int oldDirectionCount = 0; + boolean oldRewind = false; + int oldLoop = -1; + String oldHotspot = null; + + int newFrameCount = 0; + int newDirectionCount = 0; + boolean newRewind = false; + int newLoop = -1; + String newHotspot = null; + + IconState newState; + HashMap modifiedFrames = new HashMap<>(); + HashMap newFrames = new HashMap<>(); + HashSet removedFrames = new HashSet<>(); + + public IconStateDiff(IconState base, IconState mod) { + int maxDir = Math.max(base.dirs, mod.dirs); + int maxFrame = Math.max(base.frames, mod.frames); + + oldFrameCount = base.frames; + oldDirectionCount = base.dirs; + oldRewind = base.rewind; + oldLoop = base.loop; + oldHotspot = base.hotspot; + + newFrameCount = mod.frames; + newDirectionCount = mod.dirs; + newRewind = mod.rewind; + newLoop = mod.loop; + newHotspot = mod.hotspot; + + newState = mod; + + Image baseI, modI; + for(int d=0; d d && base.frames > f) { + baseI = base.images[f * base.dirs + d]; + } else baseI = null; + if(mod.dirs > d && mod.frames > f) { + modI = mod.images[f * mod.dirs + d]; + } else modI = null; + + if(baseI == null && modI == null) continue; + + if(baseI == null) newFrames.put(new ISAddress(d, f), modI); + else if(modI == null) removedFrames.add(new ISAddress(d, f)); + else if(!baseI.equals(modI)) { + modifiedFrames.put(new ISAddress(d, f), modI); + } + } + } + } + + @Override public String toString() { + String s = ""; + String tmp; + + if(newDirectionCount != oldDirectionCount) + s += " | dirs " + oldDirectionCount + "->" + newDirectionCount; + + if(newFrameCount != oldFrameCount) + s += " | frames " + oldFrameCount + "->" + newFrameCount; + + if(newRewind != oldRewind) { + s += " | rewind " + oldRewind + "->" + newRewind; + } + + if(newLoop != oldLoop) { + s += " | loop " + oldLoop + "->" + newLoop; + } + + if(newHotspot == null ? oldHotspot != null : !newHotspot.equals(oldHotspot)) { + s += " | hotspot " + oldHotspot + "->" + newHotspot; + } + + if(!modifiedFrames.isEmpty()) { + int total_frames = Math.min(oldFrameCount, newFrameCount) * Math.min(oldDirectionCount, newDirectionCount); + tmp = ""; + for(ISAddress isa: modifiedFrames.keySet()) { + String str = isa.infoStr(oldDirectionCount, oldFrameCount); + if(!"".equals(str)) { + tmp += ", " + str; + } + } + if(!"".equals(tmp)) { + s += " | modified " + modifiedFrames.size() + " of " + total_frames + ": " + tmp.substring(1); + } else { + s += " | modified " + modifiedFrames.size() + " of " + total_frames; + } + } + + if("".equals(s)) + return "No change"; + return s.substring(3); + } +} \ No newline at end of file diff --git a/tools/dmitool/src/main/java/dmitool/Image.java b/tools/dmitool/src/main/java/dmitool/Image.java new file mode 100644 index 00000000000..2ea7ff41920 --- /dev/null +++ b/tools/dmitool/src/main/java/dmitool/Image.java @@ -0,0 +1,32 @@ +package dmitool; + +import java.io.IOException; +import java.io.OutputStream; + +public abstract class Image { + int w, h; + + abstract RGBA getPixel(int x, int y); + + public Image(int w, int h) { + this.w = w; + this.h = h; + } + + @Override public boolean equals(Object obj) { + if(obj == this) return true; + if(!(obj instanceof Image)) return false; + + Image im = (Image) obj; + + if(w != im.w || h != im.h) return false; + + for(int i=0; i argq = new ArrayDeque<>(); + for(String s: args) { + argq.addLast(s); + } + if(argq.size() == 0) { + System.out.println("No command found; use 'help' for help"); + return; + } + String switches = argq.peekFirst(); + if(switches.startsWith("-")) { + for(char c: switches.substring(1).toCharArray()) { + switch(c) { + case 'v': VERBOSITY++; break; + case 'q': VERBOSITY--; break; + case 'S': STRICT = true; break; + } + } + argq.pollFirst(); + } + String op = argq.pollFirst(); + + switch(op) { + case "diff": { + if(argq.size() < 2) { + System.out.println("Insufficient arguments for command!"); + System.out.println(helpStr); + return; + } + String a = argq.pollFirst(); + String b = argq.pollFirst(); + + if(VERBOSITY >= 0) System.out.println("Loading " + a); + DMI dmi = doDMILoad(a); + if(VERBOSITY >= 0) dmi.printInfo(); + + if(VERBOSITY >= 0) System.out.println("Loading " + b); + DMI dmi2 = doDMILoad(b); + if(VERBOSITY >= 0) dmi2.printInfo(); + + DMIDiff dmid = new DMIDiff(dmi, dmi2); + System.out.println(dmid); + break; + } + case "sort": { + if(argq.size() < 1) { + System.out.println("Insufficient arguments for command!"); + System.out.println(helpStr); + return; + } + String f = argq.pollFirst(); + + if(VERBOSITY >= 0) System.out.println("Loading " + f); + DMI dmi = doDMILoad(f); + if(VERBOSITY >= 0) dmi.printInfo(); + + if(VERBOSITY >= 0) System.out.println("Saving " + f); + dmi.writeDMI(new FileOutputStream(f), true); + break; + } + case "merge": { + if(argq.size() < 4) { + System.out.println("Insufficient arguments for command!"); + System.out.println(helpStr); + return; + } + String baseF = argq.pollFirst(), + aF = argq.pollFirst(), + bF = argq.pollFirst(), + mergedF = argq.pollFirst(); + if(VERBOSITY >= 0) System.out.println("Loading " + baseF); + DMI base = doDMILoad(baseF); + if(VERBOSITY >= 0) base.printInfo(); + + if(VERBOSITY >= 0) System.out.println("Loading " + aF); + DMI aDMI = doDMILoad(aF); + if(VERBOSITY >= 0) aDMI.printInfo(); + + if(VERBOSITY >= 0) System.out.println("Loading " + bF); + DMI bDMI = doDMILoad(bF); + if(VERBOSITY >= 0) bDMI.printInfo(); + + DMIDiff aDiff = new DMIDiff(base, aDMI); + DMIDiff bDiff = new DMIDiff(base, bDMI); + DMIDiff mergedDiff = new DMIDiff(); + DMI conflictDMI = new DMI(32, 32); + + Set cf = aDiff.mergeDiff(bDiff, conflictDMI, mergedDiff, aF, bF); + + mergedDiff.applyToDMI(base); + + base.writeDMI(new FileOutputStream(mergedF)); + + if(!cf.isEmpty()) { + if(VERBOSITY >= 0) for(String s: cf) { + System.out.println(s); + } + conflictDMI.writeDMI(new FileOutputStream(mergedF + ".conflict.dmi"), true); + System.out.println("Add/modify conflicts placed in '" + mergedF + ".conflict.dmi'"); + System.exit(1); // Git expects non-zero on merge conflict + } else { + System.out.println("No conflicts"); + System.exit(0); + } + break; + } + case "extract": { + if(argq.size() < 3) { + System.out.println("Insufficient arguments for command!"); + System.out.println(helpStr); + return; + } + String file = argq.pollFirst(), + state = argq.pollFirst(), + outFile = argq.pollFirst(); + + DMI dmi = doDMILoad(file); + if(VERBOSITY >= 0) dmi.printInfo(); + + IconState is = dmi.getIconState(state); + if(is == null) { + System.out.println("icon_state '"+state+"' does not exist!"); + return; + } + // minDir, Maxdir, minFrame, Maxframe + int mDir=0, Mdir=is.dirs-1; + int mFrame=0, Mframe=is.frames-1; + + while(argq.size() > 1) { + String arg = argq.pollFirst(); + + switch(arg) { + case "d": + case "dir": + case "dirs": + case "direction": + case "directions": + String dString = argq.pollFirst(); + if(dString.contains("-")) { + String[] splitD = dString.split("-"); + if(splitD.length == 2) { + mDir = parseDir(splitD[0], is); + Mdir = parseDir(splitD[1], is); + } else { + System.out.println("Illegal dir string: '" + dString + "'!"); + return; + } + } else { + mDir = parseDir(dString, is); + Mdir = mDir; + } + // Invalid value check, warnings are printed in parseDir() + if(mDir == -1 || Mdir == -1) return; + if(Mdir < mDir) { + System.out.println("Maximum dir greater than minimum dir!"); + System.out.println("Textual direction order is S, N, E, W, SE, SW, NE, NW increasing 0 (S) to 7 (NW)"); + return; + } + break; + case "f": + case "frame": + case "frames": + String fString = argq.pollFirst(); + if(fString.contains("-")) { + String[] splitF = fString.split("-"); + if(splitF.length == 2) { + mFrame = parseFrame(splitF[0], is); + Mframe = parseFrame(splitF[1], is); + } else { + System.out.println("Illegal frame string: '" + fString + "'!"); + return; + } + } else { + mFrame = parseFrame(fString, is); + Mframe = mFrame; + } + // Invalid value check, warnings are printed in parseFrame() + if(mFrame == -1 || Mframe == -1) return; + if(Mframe < mFrame) { + System.out.println("Maximum frame greater than minimum frame!"); + return; + } + break; + default: + System.out.println("Unknown argument '" + arg + "' detected, ignoring."); + } + } + if(!argq.isEmpty()) { + System.out.println("Extra argument '" + argq.pollFirst() + "' detected, ignoring."); + } + is.dumpToPNG(new FileOutputStream(outFile), mDir, Mdir, mFrame, Mframe); + break; + } + case "import": { + if(argq.size() < 3) { + System.out.println("Insufficient arguments for command!"); + System.out.println(helpStr); + return; + } + String dmiFile = argq.pollFirst(), + stateName = argq.pollFirst(), + pngFile = argq.pollFirst(); + + boolean noDup = false; + boolean rewind = false; + int loop = 0; + boolean movement = false; + String hotspot = null; + float[] delays = null; + String replaceDir = null; + String replaceFrame = null; + while(!argq.isEmpty()) { + String s = argq.pollFirst(); + switch(s.toLowerCase()) { + case "nodup": + case "nd": + case "n": + noDup = true; + break; + case "rewind": + case "rw": + case "r": + rewind = true; + break; + case "loop": + case "lp": + case "l": + loop = -1; + break; + case "loopn": + case "lpn": + case "ln": + if(!argq.isEmpty()) { + String loopTimes = argq.pollFirst(); + try { + loop = Integer.parseInt(loopTimes); + } catch(NumberFormatException nfe) { + System.out.println("Illegal number '" + loopTimes + "' as argument to '" + s + "'!"); + return; + } + } else { + System.out.println("Argument '" + s + "' requires a numeric argument following it!"); + return; + } + break; + case "movement": + case "move": + case "mov": + case "m": + movement = true; + break; + case "delays": + case "delay": + case "del": + case "d": + if(!argq.isEmpty()) { + String delaysString = argq.pollFirst(); + String[] delaysSplit = delaysString.split(","); + delays = new float[delaysSplit.length]; + for(int i=0; i= 0) System.out.println("Loading " + dmiFile); + DMI toImportTo = doDMILoad(dmiFile); + if(VERBOSITY >= 0) toImportTo.printInfo(); + IconState is = IconState.importFromPNG(toImportTo, new FileInputStream(pngFile), stateName, delays, rewind, loop, hotspot, movement); + + //image insertion + if(replaceDir != null || replaceFrame != null) { + + IconState targetIs = toImportTo.getIconState(stateName); + if(targetIs == null) { + System.out.println("'direction' or 'frame' specified and no icon state '" + stateName + "' found, aborting!"); + return; + } + if(is.images.length == 0) { + System.out.println("'direction' or 'frame' specified and imported is empty, aborting!"); + return; + } + + if(!noDup) targetIs = targetIs.clone(); + + int dirToReplace, frameToReplace; + if(replaceDir != null && replaceFrame != null) { + frameToReplace = parseFrame(replaceFrame, targetIs); + dirToReplace = parseDir(replaceDir, targetIs); + targetIs.insertImage(dirToReplace, frameToReplace, is.images[0]); + } + else if(replaceDir != null) { + dirToReplace = parseDir(replaceDir, targetIs); + targetIs.insertDir(dirToReplace, is.images); + } + else if(replaceFrame != null) { + frameToReplace = parseFrame(replaceFrame, targetIs); + targetIs.insertFrame(frameToReplace, is.images); + } + + if(!noDup) toImportTo.addIconState(null, targetIs); + } + else { + if(noDup) { + if(!toImportTo.setIconState(is)) { + toImportTo.addIconState(null, is); + } + } else { + toImportTo.addIconState(null, is); + } + } + + if(VERBOSITY >= 0) toImportTo.printInfo(); + + if(VERBOSITY >= 0) System.out.println("Saving " + dmiFile); + toImportTo.writeDMI(new FileOutputStream(dmiFile)); + break; + } + case "verify": { + if(argq.size() < 1) { + System.out.println("Insufficient arguments for command!"); + System.out.println(helpStr); + return; + } + String vF = argq.pollFirst(); + if(VERBOSITY >= 0) System.out.println("Loading " + vF); + DMI v = doDMILoad(vF); + if(VERBOSITY >= 0) v.printInfo(); + break; + } + case "info": { + if(argq.size() < 1) { + System.out.println("Insufficient arguments for command!"); + System.out.println(helpStr); + return; + } + String infoFile = argq.pollFirst(); + if(VERBOSITY >= 0) System.out.println("Loading " + infoFile); + DMI info = doDMILoad(infoFile); + info.printInfo(); + info.printStateList(); + break; + } + case "version": + System.out.println(VERSION); + return; + default: + System.out.println("Command '" + op + "' not found!"); + case "help": + System.out.println(helpStr); + break; + } + } + + static int parseDir(String s, IconState is) { + try { + int i = Integer.parseInt(s); + if(0 <= i && i < is.dirs) { + return i; + } else { + System.out.println("Direction not in valid range [0, "+(is.dirs-1)+"]!"); + return -1; + } + } catch(NumberFormatException nfe) { + for(int q=0; q