diff --git a/.gitattributes b/.gitattributes index e564f9c8a3..2f9e769c26 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,12 +1,6 @@ -# dmm map merger hook -# 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 +# merger hooks, run tools/hooks/install.bat or install.sh to set up +*.dmm merge=dmm +*.dmi merge=dmi # force changelog merging to use union html/changelog.html merge=union - - diff --git a/tools/hooks/README.md b/tools/hooks/README.md index 844f3a3952..b15fb493d8 100644 --- a/tools/hooks/README.md +++ b/tools/hooks/README.md @@ -14,6 +14,10 @@ automatically by GUI tools like TortoiseGit and GitHub for Windows, but ## Current Hooks * **Pre-commit**: Runs [mapmerge2] on changed maps, if any. +* **DMI merger**: Attempts to [fix icon conflicts] when performing a git merge. + If it succeeds, the file is marked merged. If it fails, it logs what states + are still in conflict and adds them to the .dmi file, where the desired + resolution can be chosen. ## Adding New Hooks @@ -34,3 +38,4 @@ version of Python is found. [merge drivers]: https://git-scm.com/docs/gitattributes#_performing_a_three_way_merge [Git for Windows]: https://gitforwindows.org/ [mapmerge2]: ../mapmerge2/README.md +[fix icon conflicts]: ../mapmerge2/merge_driver_dmi.py diff --git a/tools/hooks/dmi.merge b/tools/hooks/dmi.merge new file mode 100644 index 0000000000..4e2717867e --- /dev/null +++ b/tools/hooks/dmi.merge @@ -0,0 +1,2 @@ +#!/bin/bash +exec tools/hooks/python.sh -m merge_driver_dmi "$@" diff --git a/tools/hooks/install.bat b/tools/hooks/install.bat index c4f864b5c6..7a11129a2a 100644 --- a/tools/hooks/install.bat +++ b/tools/hooks/install.bat @@ -10,5 +10,7 @@ for %%f in (*.merge) do ( driver = tools/hooks/%%f %%P %%O %%A %%B %%L >> ..\..\.git\config ) +echo Installing Python dependencies +python -m pip install -r ..\mapmerge2\requirements.txt echo Done pause diff --git a/tools/hooks/install.sh b/tools/hooks/install.sh index 32183a7ce8..ccc4cf5227 100644 --- a/tools/hooks/install.sh +++ b/tools/hooks/install.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e shopt -s nullglob cd "$(dirname "$0")" for f in *.hook; do @@ -9,4 +10,6 @@ for f in *.merge; do echo Installing merge driver: ${f%.merge} git config --replace-all merge.${f%.merge}.driver "tools/hooks/$f %P %O %A %B %L" done +echo Installing Python dependencies +./python.sh -m pip install -r ../mapmerge2/requirements.txt echo "Done" diff --git a/tools/mapmerge2/dmi.py b/tools/mapmerge2/dmi.py new file mode 100644 index 0000000000..f0a8182c9f --- /dev/null +++ b/tools/mapmerge2/dmi.py @@ -0,0 +1,253 @@ +# Tools for working with modern DreamMaker icon files (PNGs + metadata) + +import math +from PIL import Image +from PIL.PngImagePlugin import PngInfo + +DEFAULT_SIZE = 32, 32 +LOOP_UNLIMITED = 0 +LOOP_ONCE = 1 + +NORTH = 1 +SOUTH = 2 +EAST = 4 +WEST = 8 +SOUTHEAST = SOUTH|EAST +SOUTHWEST = SOUTH|WEST +NORTHEAST = NORTH|EAST +NORTHWEST = NORTH|WEST + +CARDINALS = [NORTH, SOUTH, EAST, WEST] +DIR_ORDER = [SOUTH, NORTH, EAST, WEST, SOUTHEAST, SOUTHWEST, NORTHEAST, NORTHWEST] +DIR_NAMES = { + 'SOUTH': SOUTH, + 'NORTH': NORTH, + 'EAST': EAST, + 'WEST': WEST, + 'SOUTHEAST': SOUTHEAST, + 'SOUTHWEST': SOUTHWEST, + 'NORTHEAST': NORTHEAST, + 'NORTHWEST': NORTHWEST, + **{str(x): x for x in DIR_ORDER}, + **{x: x for x in DIR_ORDER}, + '0': SOUTH, + None: SOUTH, +} + +class Dmi: + version = "4.0" + + def __init__(self, width, height): + self.width = width + self.height = height + self.states = [] + + @classmethod + def from_file(cls, fname): + image = Image.open(fname) + + # no metadata = regular image file + if 'Description' not in image.info: + dmi = Dmi(*image.size) + state = dmi.state("") + state.frame(image) + return dmi + + # read metadata + metadata = image.info['Description'] + line_iter = iter(metadata.splitlines()) + assert next(line_iter) == "# BEGIN DMI" + assert next(line_iter) == f"version = {cls.version}" + + dmi = Dmi(*DEFAULT_SIZE) + state = None + + for line in line_iter: + if line == "# END DMI": + break + key, value = line.lstrip().split(" = ") + if key == 'width': + dmi.width = int(value) + elif key == 'height': + dmi.height = int(value) + elif key == 'state': + state = dmi.state(unescape(value)) + elif key == 'dirs': + state.dirs = int(value) + elif key == 'frames': + state._nframes = int(value) + elif key == 'delay': + state.delays = [parse_num(x) for x in value.split(',')] + elif key == 'loop': + state.loop = int(value) + elif key == 'rewind': + state.rewind = parse_bool(value) + elif key == 'hotspot': + x, y, frm = [int(x) for x in value.split(',')] + state.hotspot(frm - 1, x, y) + elif key == 'movement': + state.movement = parse_bool(value) + else: + raise NotImplementedError(key) + + # cut image into frames + width, height = image.size + gridwidth = width // dmi.width + i = 0 + for state in dmi.states: + for frame in range(state._nframes): + for dir in range(state.dirs): + px = dmi.width * (i % gridwidth) + py = dmi.height * (i // gridwidth) + im = image.crop((px, py, px + dmi.width, py + dmi.height)) + assert im.size == (dmi.width, dmi.height) + state.frames.append(im) + i += 1 + state._nframes = None + + return dmi + + def state(self, *args, **kwargs): + s = State(self, *args, **kwargs) + self.states.append(s) + return s + + @property + def default_state(self): + return self.states[0] + + def get_state(self, name): + for state in self.states: + if state.name == name: + return state + raise KeyError(name) + return self.default_state + + def _assemble_comment(self): + comment = "# BEGIN DMI\n" + comment += f"version = {self.version}\n" + comment += f"\twidth = {self.width}\n" + comment += f"\theight = {self.height}\n" + for state in self.states: + comment += f"state = {escape(state.name)}\n" + comment += f"\tdirs = {state.dirs}\n" + comment += f"\tframes = {state.framecount}\n" + if state.framecount > 1 and len(state.delays): #any(x != 1 for x in state.delays): + comment += "\tdelay = " + ",".join(map(str, state.delays)) + "\n" + if state.loop != 0: + comment += f"\tloop = {state.loop}\n" + if state.rewind: + comment += "\trewind = 1\n" + if state.movement: + comment += "\tmovement = 1\n" + if state.hotspots and any(state.hotspots): + current = None + for i, value in enumerate(state.hotspots): + if value != current: + x, y = value + comment += f"\thotspot = {x},{y},{i + 1}\n" + current = value + comment += "# END DMI" + return comment + + def to_file(self, filename, *, palette=False): + # assemble comment + comment = self._assemble_comment() + + # assemble spritesheet + W, H = self.width, self.height + num_frames = sum(len(state.frames) for state in self.states) + sqrt = math.ceil(math.sqrt(num_frames)) + output = Image.new('RGBA', (sqrt * W, math.ceil(num_frames / sqrt) * H)) + + i = 0 + for state in self.states: + for frame in state.frames: + output.paste(frame, ((i % sqrt) * W, (i // sqrt) * H)) + i += 1 + + # save + pnginfo = PngInfo() + pnginfo.add_text('Description', comment, zip=True) + if palette: + output = output.convert('P') + output.save(filename, 'png', optimize=True, pnginfo=pnginfo) + +class State: + def __init__(self, dmi, name, *, loop=LOOP_UNLIMITED, rewind=False, movement=False, dirs=1): + self.dmi = dmi + self.name = name + self.loop = loop + self.rewind = rewind + self.movement = movement + self.dirs = dirs + + self._nframes = None # used during loading only + self.frames = [] + self.delays = [] + self.hotspots = None + + @property + def framecount(self): + if self._nframes is not None: + return self._nframes + else: + return len(self.frames) // self.dirs + + def frame(self, image, *, delay=1): + assert image.size == (self.dmi.width, self.dmi.height) + self.delays.append(delay) + self.frames.append(image) + + def hotspot(self, first_frame, x, y): + if self.hotspots is None: + self.hotspots = [None] * self.framecount + for i in range(first_frame, self.framecount): + self.hotspots[i] = x, y + + def _frame_index(self, frame=0, dir=None): + ofs = DIR_ORDER.index(DIR_NAMES[dir]) + if ofs >= self.dirs: + ofs = 0 + return frame * self.dirs + ofs + + def get_frame(self, *args, **kwargs): + return self.frames[self._frame_index(*args, **kwargs)] + +def escape(text): + assert '\\' not in text and '"' not in text + return f'"{text}"' + +def unescape(text, quote='"'): + if text == 'null': + return None + if not (text.startswith(quote) and text.endswith(quote)): + raise ValueError(text) + text = text[1:-1] + assert '\\' not in text and quote not in text + return text + +def parse_num(value): + if '.' in value: + return float(value) + return int(value) + +def parse_bool(value): + if value not in ('0', '1'): + raise ValueError(value) + return value == '1' + +if __name__ == '__main__': + # test: can we load every DMI in the tree + import os + + count = 0 + for dirpath, dirnames, filenames in os.walk('.'): + if '.git' in dirnames: + dirnames.remove('.git') + for filename in filenames: + if filename.endswith('.dmi'): + Dmi.from_file(os.path.join(dirpath, filename)) + count += 1 + + print(f"Successfully parsed {count} dmi files") diff --git a/tools/mapmerge2/merge_driver_dmi.py b/tools/mapmerge2/merge_driver_dmi.py new file mode 100644 index 0000000000..37c0abd954 --- /dev/null +++ b/tools/mapmerge2/merge_driver_dmi.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +import sys +import dmi + +def images_equal(left, right): + if left.size != right.size: + return False + w, h = left.size + left_load, right_load = left.load(), right.load() + for y in range(0, h): + for x in range(0, w): + lpixel, rpixel = left_load[x, y], right_load[x, y] + # quietly ignore changes where both pixels are fully transparent + if lpixel != rpixel and (lpixel[3] != 0 or rpixel[3] != 0): + return False + return True + +def states_equal(left, right): + result = True + + # basic properties + for attr in ('loop', 'rewind', 'movement', 'dirs', 'delays', 'hotspots', 'framecount'): + lval, rval = getattr(left, attr), getattr(right, attr) + if lval != rval: + result = False + + # frames + for (left_frame, right_frame) in zip(left.frames, right.frames): + if not images_equal(left_frame, right_frame): + result = False + + return result + +def key_of(state): + return (state.name, state.movement) + +def dictify(sheet): + result = {} + for state in sheet.states: + k = key_of(state) + if k in result: + print(f" duplicate {k!r}") + result[k] = state + return result + +def three_way_merge(base, left, right): + base_dims = base.width, base.height + if base_dims != (left.width, left.height) or base_dims != (right.width, right.height): + print("Dimensions have changed:") + print(f" Base: {base.width} x {base.height}") + print(f" Ours: {left.width} x {left.height}") + print(f" Theirs: {right.width} x {right.height}") + return True, None + + base_states, left_states, right_states = dictify(base), dictify(left), dictify(right) + + new_left = {k: v for k, v in left_states.items() if k not in base_states} + new_right = {k: v for k, v in right_states.items() if k not in base_states} + new_both = {} + conflicts = [] + for key, state in list(new_left.items()): + in_right = new_right.get(key, None) + if in_right: + if states_equal(state, in_right): + # allow it + new_both[key] = state + else: + # generate conflict states + print(f" C: {state.name!r}: added differently in both!") + state.name = f"{state.name} !CONFLICT! left" + conflicts.append(state) + in_right.name = f"{state.name} !CONFLICT! right" + conflicts.append(in_right) + # don't add it a second time + del new_left[key] + del new_right[key] + + final_states = [] + # add states that are currently in the base + for state in base.states: + in_left = left_states.get(key_of(state), None) + in_right = right_states.get(key_of(state), None) + left_equals = in_left and states_equal(state, in_left) + right_equals = in_right and states_equal(state, in_right) + + if not in_left and not in_right: + # deleted in both left and right, it's just deleted + print(f" {state.name!r}: deleted in both") + elif not in_left: + # left deletes + print(f" {state.name!r}: deleted in left") + if not right_equals: + print(f" ... but modified in right") + final_states.append(in_right) + elif not in_right: + # right deletes + print(f" {state.name!r}: deleted in right") + if not left_equals: + print(f" ... but modified in left") + final_states.append(in_left) + elif left_equals and right_equals: + # changed in neither + #print(f"Same in both: {state.name!r}") + final_states.append(state) + elif left_equals: + # changed only in right + print(f" {state.name!r}: changed in left") + final_states.append(in_right) + elif right_equals: + # changed only in left + print(f" {state.name!r}: changed in right") + final_states.append(in_left) + elif states_equal(in_left, in_right): + # changed in both, to the same thing + print(f" {state.name!r}: changed same in both") + final_states.append(in_left) # either or + else: + # changed in both + name = state.name + print(f" C: {name!r}: changed differently in both!") + state.name = f"{name} !CONFLICT! base" + conflicts.append(state) + in_left.name = f"{name} !CONFLICT! left" + conflicts.append(in_left) + in_right.name = f"{name} !CONFLICT! right" + conflicts.append(in_right) + + # add states which both left and right added the same + for key, state in new_both.items(): + print(f" {state.name!r}: added same in both") + final_states.append(state) + + # add states that are brand-new in the left + for key, state in new_left.items(): + print(f" {state.name!r}: added in left") + final_states.append(state) + + # add states that are brand-new in the right + for key, state in new_right.items(): + print(f" {state.name!r}: added in right") + final_states.append(state) + + final_states.extend(conflicts) + merged = dmi.Dmi(base.width, base.height) + merged.states = final_states + return len(conflicts), merged + +def main(path, original, left, right): + print(f"Merging icon: {path}") + + icon_orig = dmi.Dmi.from_file(original) + icon_left = dmi.Dmi.from_file(left) + icon_right = dmi.Dmi.from_file(right) + + trouble, merged = three_way_merge(icon_orig, icon_left, icon_right) + if merged: + merged.to_file(left) + if trouble: + print("!!! Manual merge required!") + if merged: + print(" A best-effort merge was performed. You must edit the icon and remove all") + print(" icon states marked with !CONFLICT!, leaving only the desired icon.") + else: + print(" The icon was totally unable to be merged, you must start with one version") + print(" or the other and manually resolve the conflict.") + print(" Information about which states conflicted is listed above.") + return trouble + +if __name__ == '__main__': + if len(sys.argv) != 6: + print("DMI merge driver called with wrong number of arguments") + print(" usage: merge-driver-dmi %P %O %A %B %L") + exit(1) + + # "left" is also the file that ought to be overwritten + _, path, original, left, right, conflict_size_marker = sys.argv + exit(main(path, original, left, right)) diff --git a/tools/mapmerge2/requirements.txt b/tools/mapmerge2/requirements.txt index d01a2c6ccf..46d0fa64c5 100644 --- a/tools/mapmerge2/requirements.txt +++ b/tools/mapmerge2/requirements.txt @@ -1,2 +1,3 @@ pygit2==0.26.0 bidict==0.13.1 +Pillow=5.1.0