Add MapMerge2 from /tg/

This commit is contained in:
Arokha Sieyes
2018-01-27 23:38:52 -05:00
parent 88cab44124
commit ae08a6896f
23 changed files with 943 additions and 623 deletions

52
tools/mapmerge2/README.md Normal file
View File

@@ -0,0 +1,52 @@
# Map Merge 2
**Map Merge 2** is an improvement over previous map merging scripts, with
better merge-conflict prevention, multi-Z support, and automatic handling of
key overflow. For up-to-date tips and tricks, also visit the [Map Merger] wiki article.
## What Map Merging Is
The "map merge" operation describes the process of rewriting a map file written
by the DreamMaker map editor to A) use a format more amenable to Git's conflict
resolution and B) differ in the least amount textually from the previous
version of the map while maintaining all the actual changes. It requires an old
version of the map to use as a reference and a new version of the map which
contains the desired changes.
## Installation
To install Python dependencies, run `requirements-install.bat`, or run
`python -m pip install -r requirements.txt` directly. See the [Git hooks]
documentation to install the Git pre-commit hook which runs the map merger
automatically, or use `tools/mapmerge/Prepare Maps.bat` to save backups before
running `mapmerge.bat`.
For up-to-date installation and detailed troubleshooting instructions, visit
the [Map Merger] wiki article.
## Code Structure
Frontend scripts are meant to be run directly. They obey the environment
variables `TGM` to set whether files are saved in TGM (1) or DMM (0) format,
and `MAPROOT` to determine where maps are kept. By default, TGM is used and
the map root is autodetected. Each script may either prompt for the desired map
or be run with command-line parameters indicating which maps to act on. The
scripts include:
* `convert.py` for converting maps to and from the TGM format. Used by
`tgm2dmm.bat` and `dmm2tgm.bat`.
* `mapmerge.py` for running the map merge on map backups saved by
`Prepare Maps.bat`. Used by `mapmerge.bat`
Implementation modules:
* `dmm.py` includes the map reader and writer.
* `mapmerge.py` includes the implementation of the map merge operation.
* `frontend.py` includes the common code for the frontend scripts.
`precommit.py` is run by the [Git hooks] if installed, and merges the new
version of any map saved in the index (`git add`ed) with the old version stored
in Git when run.
[Map Merger]: https://tgstation13.org/wiki/Map_Merger
[Git hooks]: ../hooks/README.md

View File

@@ -0,0 +1,52 @@
# Map Merge 2
**Map Merge 2** is an improvement over previous map merging scripts, with
better merge-conflict prevention, multi-Z support, and automatic handling of
key overflow. For up-to-date tips and tricks, also visit the [Map Merger] wiki article.
## What Map Merging Is
The "map merge" operation describes the process of rewriting a map file written
by the DreamMaker map editor to A) use a format more amenable to Git's conflict
resolution and B) differ in the least amount textually from the previous
version of the map while maintaining all the actual changes. It requires an old
version of the map to use as a reference and a new version of the map which
contains the desired changes.
## Installation
To install Python dependencies, run `requirements-install.bat`, or run
`python -m pip install -r requirements.txt` directly. See the [Git hooks]
documentation to install the Git pre-commit hook which runs the map merger
automatically, or use `tools/mapmerge/Prepare Maps.bat` to save backups before
running `mapmerge.bat`.
For up-to-date installation and detailed troubleshooting instructions, visit
the [Map Merger] wiki article.
## Code Structure
Frontend scripts are meant to be run directly. They obey the environment
variables `TGM` to set whether files are saved in TGM (1) or DMM (0) format,
and `MAPROOT` to determine where maps are kept. By default, TGM is used and
the map root is autodetected. Each script may either prompt for the desired map
or be run with command-line parameters indicating which maps to act on. The
scripts include:
* `convert.py` for converting maps to and from the TGM format. Used by
`tgm2dmm.bat` and `dmm2tgm.bat`.
* `mapmerge.py` for running the map merge on map backups saved by
`Prepare Maps.bat`. Used by `mapmerge.bat`
Implementation modules:
* `dmm.py` includes the map reader and writer.
* `mapmerge.py` includes the implementation of the map merge operation.
* `frontend.py` includes the common code for the frontend scripts.
`precommit.py` is run by the [Git hooks] if installed, and merges the new
version of any map saved in the index (`git add`ed) with the old version stored
in Git when run.
[Map Merger]: https://tgstation13.org/wiki/Map_Merger
[Git hooks]: ../hooks/README.md

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env python3
import frontend
import dmm
if __name__ == '__main__':
settings = frontend.read_settings()
for fname in frontend.process(settings, "convert"):
dmm.DMM.from_file(fname).to_file(fname, settings.tgm)

459
tools/mapmerge2/dmm.py Normal file
View File

@@ -0,0 +1,459 @@
# Tools for working with DreamMaker maps
import io
import bidict
import random
from collections import namedtuple
TGM_HEADER = "//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE"
ENCODING = 'utf-8'
Coordinate = namedtuple('Coordinate', ['x', 'y', 'z'])
class DMM:
__slots__ = ['key_length', 'size', 'dictionary', 'grid', 'header']
def __init__(self, key_length, size):
self.key_length = key_length
self.size = size
self.dictionary = bidict.bidict()
self.grid = {}
self.header = None
@staticmethod
def from_file(fname):
# stream the file rather than forcing all its contents to memory
with open(fname, 'r', encoding=ENCODING) as f:
return _parse(iter(lambda: f.read(1), ''))
@staticmethod
def from_bytes(bytes):
return _parse(bytes.decode(ENCODING))
def to_file(self, fname, tgm = True):
with open(fname, 'w', newline='\n', encoding=ENCODING) as f:
(save_tgm if tgm else save_dmm)(self, f)
def to_bytes(self, tgm = True):
bio = io.BytesIO()
with io.TextIOWrapper(bio, newline='\n', encoding=ENCODING) as f:
(save_tgm if tgm else save_dmm)(self, f)
f.flush()
return bio.getvalue()
def generate_new_key(self):
# ensure that free keys exist by increasing the key length if necessary
free_keys = (BASE ** self.key_length) - len(self.dictionary)
while free_keys <= 0:
self.key_length += 1
free_keys = (BASE ** self.key_length) - len(self.dictionary)
# choose one of the free keys at random
key = 0
while free_keys:
if key not in self.dictionary:
# this construction is used to avoid needing to construct the
# full set in order to random.choice() from it
if random.random() < 1 / free_keys:
return key
free_keys -= 1
key += 1
raise RuntimeError("ran out of keys, this shouldn't happen")
@property
def coords_zyx(self):
for z in range(1, self.size.z + 1):
for y in range(1, self.size.y + 1):
for x in range(1, self.size.x + 1):
yield (z, y, x)
@property
def coords_z(self):
return range(1, self.size.z + 1)
@property
def coords_yx(self):
for y in range(1, self.size.y + 1):
for x in range(1, self.size.x + 1):
yield (y, x)
# ----------
# key handling
# Base 52 a-z A-Z dictionary for fast conversion
BASE = 52
base52 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
base52_r = {x: i for i, x in enumerate(base52)}
assert len(base52) == BASE and len(base52_r) == BASE
def key_to_num(key):
num = 0
for ch in key:
num = BASE * num + base52_r[ch]
return num
def num_to_key(num, key_length):
if num >= BASE ** key_length:
raise KeyTooLarge(f"num={num} does not fit in key_length={key_length}")
result = ''
while num:
result = base52[num % BASE] + result
num //= BASE
assert len(result) <= key_length
return base52[0] * (key_length - len(result)) + result
class KeyTooLarge(Exception):
pass
# ----------
# An actual atom parser
def parse_map_atom(atom):
try:
i = atom.index('{')
except ValueError:
return atom, {}
path, rest = atom[:i], atom[i+1:]
vars = {}
in_string = False
in_name = False
escaping = False
current_name = ''
current = ''
for ch in rest:
if escaping:
escaping = False
current += ch
elif ch == '\\':
escaping = True
elif ch == '"':
in_string = not in_string
current += ch
elif in_string:
current += ch
elif ch == ';':
vars[current_name.strip()] = current.strip()
current_name = current = ''
elif ch == '=':
current_name = current
current = ''
elif ch == '}':
vars[current_name.strip()] = current.strip()
break
elif ch not in ' ':
current += ch
return path, vars
# ----------
# TGM writer
def save_tgm(dmm, output):
output.write(f"{TGM_HEADER}\n")
if dmm.header:
output.write(f"{dmm.header}\n")
# write dictionary in tgm format
for key, value in sorted(dmm.dictionary.items()):
output.write(f'"{num_to_key(key, dmm.key_length)}" = (\n')
for idx, thing in enumerate(value):
in_quote_block = False
in_varedit_block = False
for char in thing:
if in_quote_block:
if char == '"':
in_quote_block = False
output.write(char)
elif char == '"':
in_quote_block = True
output.write(char)
elif not in_varedit_block:
if char == "{":
in_varedit_block = True
output.write("{\n\t")
else:
output.write(char)
elif char == ";":
output.write(";\n\t")
elif char == "}":
output.write("\n\t}")
in_varedit_block = False
else:
output.write(char)
if idx < len(value) - 1:
output.write(",\n")
output.write(")\n")
# thanks to YotaXP for finding out about this one
max_x, max_y, max_z = dmm.size
for z in range(1, max_z + 1):
output.write("\n")
for x in range(1, max_x + 1):
output.write(f"({x},{1},{z}) = {{\"\n")
for y in range(1, max_y + 1):
output.write(f"{num_to_key(dmm.grid[x, y, z], dmm.key_length)}\n")
output.write("\"}\n")
# ----------
# DMM writer
def save_dmm(dmm, output):
if dmm.header:
output.write(f"{dmm.header}\n")
# writes a tile dictionary the same way Dreammaker does
for key, value in sorted(dmm.dictionary.items()):
output.write(f'"{num_to_key(key, dmm.key_length)}" = ({",".join(value)})\n')
output.write("\n")
# writes a map grid the same way Dreammaker does
max_x, max_y, max_z = dmm.size
for z in range(1, max_z + 1):
output.write(f"(1,1,{z}) = {{\"\n")
for y in range(1, max_y + 1):
for x in range(1, max_x + 1):
try:
output.write(num_to_key(dmm.grid[x, y, z], dmm.key_length))
except KeyError:
print(f"Key error: ({x}, {y}, {z})")
output.write("\n")
output.write("\"}\n")
# ----------
# Parser
def _parse(map_raw_text):
in_comment_line = False
comment_trigger = False
in_quote_block = False
in_key_block = False
in_data_block = False
in_varedit_block = False
after_data_block = False
escaping = False
skip_whitespace = False
dictionary = bidict.bidict()
duplicate_keys = {}
curr_key_len = 0
curr_key = 0
curr_datum = ""
curr_data = list()
in_map_block = False
in_coord_block = False
in_map_string = False
iter_x = 0
adjust_y = True
curr_num = ""
reading_coord = "x"
key_length = 0
maxx = 0
maxy = 0
maxz = 0
curr_x = 0
curr_y = 0
curr_z = 0
grid = dict()
it = iter(map_raw_text)
# map block
for char in it:
if char == "\n":
in_comment_line = False
comment_trigger = False
continue
elif in_comment_line:
continue
elif char == "\t":
continue
if char == "/" and not in_quote_block:
if comment_trigger:
in_comment_line = True
continue
else:
comment_trigger = True
else:
comment_trigger = False
if in_data_block:
if in_varedit_block:
if in_quote_block:
if char == "\\":
curr_datum = curr_datum + char
escaping = True
elif escaping:
curr_datum = curr_datum + char
escaping = False
elif char == "\"":
curr_datum = curr_datum + char
in_quote_block = False
else:
curr_datum = curr_datum + char
else:
if skip_whitespace and char == " ":
skip_whitespace = False
continue
skip_whitespace = False
if char == "\"":
curr_datum = curr_datum + char
in_quote_block = True
elif char == ";":
skip_whitespace = True
curr_datum = curr_datum + char
elif char == "}":
curr_datum = curr_datum + char
in_varedit_block = False
else:
curr_datum = curr_datum + char
elif char == "{":
curr_datum = curr_datum + char
in_varedit_block = True
elif char == ",":
curr_data.append(curr_datum)
curr_datum = ""
elif char == ")":
curr_data.append(curr_datum)
curr_data = tuple(curr_data)
try:
dictionary[curr_key] = curr_data
except bidict.ValueDuplicationError:
# if the map has duplicate values, eliminate them now
duplicate_keys[curr_key] = dictionary.inv[curr_data]
curr_data = list()
curr_datum = ""
curr_key = 0
curr_key_len = 0
in_data_block = False
after_data_block = True
else:
curr_datum = curr_datum + char
elif in_key_block:
if char == "\"":
in_key_block = False
if key_length == 0:
key_length = curr_key_len
else:
assert key_length == curr_key_len
else:
curr_key = BASE * curr_key + base52_r[char]
curr_key_len += 1
# else we're looking for a key block, a data block or the map block
elif char == "\"":
in_key_block = True
after_data_block = False
elif char == "(":
if after_data_block:
in_coord_block = True
after_data_block = False
curr_key = 0
curr_key_len = 0
break
else:
in_data_block = True
after_data_block = False
# grid block
for char in it:
if in_coord_block:
if char == ",":
if reading_coord == "x":
curr_x = int(curr_num)
if curr_x > maxx:
maxx = curr_x
iter_x = 0
curr_num = ""
reading_coord = "y"
elif reading_coord == "y":
curr_y = int(curr_num)
if curr_y > maxy:
maxy = curr_y
curr_num = ""
reading_coord = "z"
else:
raise ValueError("too many dimensions")
elif char == ")":
curr_z = int(curr_num)
if curr_z > maxz:
maxz = curr_z
in_coord_block = False
reading_coord = "x"
curr_num = ""
else:
curr_num = curr_num + char
elif in_map_string:
if char == "\"":
in_map_string = False
adjust_y = True
curr_y -= 1
elif char == "\n":
if adjust_y:
adjust_y = False
else:
curr_y += 1
if curr_x > maxx:
maxx = curr_x
if iter_x > 1:
curr_x = 1
iter_x = 0
else:
curr_key = BASE * curr_key + base52_r[char]
curr_key_len += 1
if curr_key_len == key_length:
iter_x += 1
if iter_x > 1:
curr_x += 1
grid[curr_x, curr_y, curr_z] = duplicate_keys.get(curr_key, curr_key)
curr_key = 0
curr_key_len = 0
# else look for coordinate block or a map string
elif char == "(":
in_coord_block = True
elif char == "\"":
in_map_string = True
if curr_y > maxy:
maxy = curr_y
data = DMM(key_length, Coordinate(maxx, maxy, maxz))
data.dictionary = dictionary
data.grid = grid
return data

View File

@@ -0,0 +1,5 @@
@echo off
set MAPROOT=../../_maps/
set TGM=1
python convert.py
pause

127
tools/mapmerge2/frontend.py Normal file
View File

@@ -0,0 +1,127 @@
# Common code for the frontend interface of map tools
import sys
import os
import pathlib
import shutil
from collections import namedtuple
Settings = namedtuple('Settings', ['map_folder', 'tgm'])
MapsToRun = namedtuple('MapsToRun', ['files', 'indices'])
def string_to_num(s):
try:
return int(s)
except ValueError:
return -1
def read_settings():
# discover map folder if needed
try:
map_folder = os.environ['MAPROOT']
except KeyError:
map_folder = '_maps/'
for _ in range(8):
if os.path.exists(map_folder):
break
map_folder = os.path.join('..', map_folder)
else:
map_folder = None
# assume TGM is True by default
tgm = os.environ.get('TGM', "1") == "1"
return Settings(map_folder, tgm)
def pretty_path(settings, path_str):
if settings.map_folder:
return path_str[len(os.path.commonpath([settings.map_folder, path_str]))+1:]
else:
return path_str
def prompt_maps(settings, verb):
if not settings.map_folder:
print("Could not autodetect the _maps folder, set MAPROOT")
exit(1)
list_of_files = list()
for root, directories, filenames in os.walk(settings.map_folder):
for filename in [f for f in filenames if f.endswith(".dmm")]:
list_of_files.append(pathlib.Path(root, filename))
last_dir = ""
for i, this_file in enumerate(list_of_files):
this_dir = this_file.parent
if last_dir != this_dir:
print("--------------------------------")
last_dir = this_dir
print("[{}]: {}".format(i, pretty_path(settings, str(this_file))))
print("--------------------------------")
in_list = input("List the maps you want to " + verb + " (example: 1,3-5,12):\n")
in_list = in_list.replace(" ", "")
in_list = in_list.split(",")
valid_indices = list()
for m in in_list:
index_range = m.split("-")
if len(index_range) == 1:
index = string_to_num(index_range[0])
if index >= 0 and index < len(list_of_files):
valid_indices.append(index)
elif len(index_range) == 2:
index0 = string_to_num(index_range[0])
index1 = string_to_num(index_range[1])
if index0 >= 0 and index0 <= index1 and index1 < len(list_of_files):
valid_indices.extend(range(index0, index1 + 1))
return MapsToRun(list_of_files, valid_indices)
def process(settings, verb, *, modify=True, backup=None):
if backup is None:
backup = modify # by default, backup when we modify
assert modify or not backup # doesn't make sense to backup when not modifying
if len(sys.argv) > 1:
maps = sys.argv[1:]
else:
maps = prompt_maps(settings, verb)
maps = [str(maps.files[i]) for i in maps.indices]
print()
if not maps:
print("No maps selected.")
return
if modify:
print(f"Maps WILL{'' if settings.tgm else ' NOT'} be converted to tgm.")
if backup:
print("Backups will be created with a \".before\" extension.")
else:
print("Warning: backups are NOT being taken.")
print(f"\nWill {verb} these maps:")
for path_str in maps:
print(pretty_path(settings, path_str))
try:
confirm = input(f"\nPress Enter to {verb}...\n")
except KeyboardInterrupt:
confirm = "^C"
if confirm != "":
print(f"\nAborted.")
return
for path_str in maps:
print(f' - {pretty_path(settings, path_str)}')
if backup:
shutil.copyfile(path_str, path_str + ".before")
try:
yield path_str
except Exception as e:
print(f"Error: {e}")
else:
print("Succeeded.")
print("\nFinished.")

View File

@@ -0,0 +1,36 @@
# Git Integration Hooks
This folder contains installable scripts for [Git hooks] and [merge drivers].
Use of these hooks and drivers is optional and they must be installed
explicitly before they take effect.
To install the current set of hooks, or update if new hooks are added, run
`install.bat` (Windows) or `install.sh` (Unix-like) as appropriate.
Hooks expect a Unix-like environment on the backend. Usually this is handled
automatically by GUI tools like TortoiseGit and GitHub for Windows, but
[Git for Windows] is an option if you prefer to use a CLI even on Windows.
## Current Hooks
* **Pre-commit**: Runs [mapmerge2] on changed maps, if any.
## Adding New Hooks
New [Git hooks] may be added by creating a file named `<hook-name>.hook` in
this directory. Git determines what hooks are available and what their names
are. The install script copies the `.hook` file into `.git/hooks`, so editing
the `.hook` file will require a reinstall.
New [merge drivers] may be added by adding a shell script named `<ext>.merge`
and updating `.gitattributes` in the root of the repository to include the line
`*.<ext> merge=<ext>`. The install script will set up the merge driver to point
to the `.merge` file directly, and editing it will not require a reinstall.
`tools/hooks/python.sh` may be used as a trampoline to ensure that the correct
version of Python is found.
[Git hooks]: https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks
[merge drivers]: https://git-scm.com/docs/gitattributes#_performing_a_three_way_merge
[Git for Windows]: https://gitforwindows.org/
[mapmerge2]: ../mapmerge2/README.md

View File

@@ -0,0 +1,15 @@
@echo off
cd %~dp0
for %%f in (*.hook) do (
echo Installing hook: %%~nf
mkdir ..\..\..\.git\hooks
copy %%f ..\..\..\.git\hooks\%%~nf >nul
)
for %%f in (*.merge) do (
echo Installing merge driver: %%~nf
echo [merge "%%~nf"]^
driver = tools/hooks/%%f %%P %%O %%A %%B %%L >> ..\..\..\.git\config
)
echo Done
pause

View File

@@ -0,0 +1,11 @@
#!/bin/bash
cd "$(dirname "$0")"
for f in *.hook; do
echo Installing hook: ${f%.hook}
cp $f ../../../.git/hooks/${f%.hook}
done
for f in *.merge; do
echo Installing merge driver: ${f%.merge}
git config --replace-all merge.${f%.merge}.driver "tools/mapmerge2/hooks/$f %P %O %A %B %L"
done
echo "Done"

View File

@@ -0,0 +1,2 @@
#!/bin/bash
exec tools/mapmerge2/hooks/python.sh -m precommit

View File

@@ -0,0 +1,17 @@
#!/bin/bash
set -e
if command -v python3 >/dev/null 2>&1; then
PY=python3
else
PY=python
fi
PATHSEP=$($PY - <<'EOF'
import sys, os
if sys.version_info.major != 3 or sys.version_info.minor < 6:
sys.stderr.write("Python 3.6+ is required: " + sys.version + "\n")
exit(1)
print(os.pathsep)
EOF
)
export PYTHONPATH=tools/mapmerge2/${PATHSEP}${PYTHONPATH}
$PY "$@"

View File

@@ -0,0 +1,5 @@
@echo off
set MAPROOT=../../maps/tether
set TGM=0
python mapmerge.py
pause

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
import frontend
import shutil
from dmm import *
from collections import defaultdict
def merge_map(new_map, old_map, delete_unused=False):
if new_map.key_length != old_map.key_length:
print("Warning: Key lengths differ, taking new map")
print(f" Old: {old_map.key_length}")
print(f" New: {new_map.key_length}")
return new_map
if new_map.size != old_map.size:
print("Warning: Map dimensions differ, taking new map")
print(f" Old: {old_map.size}")
print(f" New: {new_map.size}")
return new_map
key_length, size = old_map.key_length, old_map.size
merged = DMM(key_length, size)
merged.dictionary = old_map.dictionary.copy()
known_keys = dict() # mapping fron 'new' key to 'merged' key
unused_keys = set(old_map.dictionary.keys()) # keys going unused
# step one: parse the new version, compare it to the old version, merge both
for z, y, x in new_map.coords_zyx:
new_key = new_map.grid[x, y, z]
# if this key has been processed before, it can immediately be merged
try:
merged.grid[x, y, z] = known_keys[new_key]
continue
except KeyError:
pass
def select_key(assigned):
merged.grid[x, y, z] = known_keys[new_key] = assigned
old_key = old_map.grid[x, y, z]
old_tile = old_map.dictionary[old_key]
new_tile = new_map.dictionary[new_key]
# this tile is the exact same as before, so the old key is used
if new_tile == old_tile:
select_key(old_key)
unused_keys.remove(old_key)
# the tile is different here, but if it exists in the merged dictionary, that key can be used
elif new_tile in merged.dictionary.inv:
newold_key = merged.dictionary.inv[new_tile]
select_key(newold_key)
unused_keys.remove(newold_key)
# the tile is brand new and it needs a new key, but if the old key isn't being used any longer it can be used instead
elif old_tile not in new_map.dictionary.inv and old_key in unused_keys:
merged.dictionary[old_key] = new_tile
select_key(old_key)
unused_keys.remove(old_key)
# all other options ruled out, a brand new key is generated for the brand new tile
else:
fresh_key = merged.generate_new_key()
merged.dictionary[fresh_key] = new_tile
select_key(fresh_key)
# step two: delete unused keys
if unused_keys:
print(f"Notice: Trimming {len(unused_keys)} unused dictionary keys.")
for key in unused_keys:
del merged.dictionary[key]
# sanity check: that the merged map equals the new map
for z, y, x in new_map.coords_zyx:
new_tile = new_map.dictionary[new_map.grid[x, y, z]]
merged_tile = merged.dictionary[merged.grid[x, y, z]]
if new_tile != merged_tile:
print(f"Error: the map has been mangled! This is a mapmerge bug!")
print(f"At {x},{y},{z}.")
print(f"Should be {new_tile}")
print(f"Instead is {merged_tile}")
raise RuntimeError()
return merged
def main(settings):
for fname in frontend.process(settings, "merge", backup=True):
shutil.copyfile(fname, fname + ".before")
old_map = DMM.from_file(fname + ".backup")
new_map = DMM.from_file(fname)
merge_map(new_map, old_map).to_file(fname, settings.tgm)
if __name__ == '__main__':
main(frontend.read_settings())

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python3
import os
import pygit2
import dmm
from mapmerge import merge_map
def main(repo):
if repo.index.conflicts:
print("You need to resolve merge conflicts first.")
return 1
changed = 0
for path, status in repo.status().items():
if path.endswith(".dmm") and (status & (pygit2.GIT_STATUS_INDEX_MODIFIED | pygit2.GIT_STATUS_INDEX_NEW)):
# read the index
index_entry = repo.index[path]
index_map = dmm.DMM.from_bytes(repo[index_entry.id].read_raw())
try:
head_blob = repo[repo[repo.head.target].tree[path].id]
except KeyError:
# New map, no entry in HEAD
print(f"Converting new map: {path}")
assert (status & pygit2.GIT_STATUS_INDEX_NEW)
merged_map = index_map
else:
# Entry in HEAD, merge the index over it
print(f"Merging map: {path}")
assert not (status & pygit2.GIT_STATUS_INDEX_NEW)
head_map = dmm.DMM.from_bytes(head_blob.read_raw())
merged_map = merge_map(index_map, head_map)
# write to the index
blob_id = repo.create_blob(merged_map.to_bytes())
repo.index.add(pygit2.IndexEntry(path, blob_id, index_entry.mode))
changed += 1
# write to the working directory if that's clean
if status & (pygit2.GIT_STATUS_WT_DELETED | pygit2.GIT_STATUS_WT_MODIFIED):
print(f"Warning: {path} has unindexed changes, not overwriting them")
else:
merged_map.to_file(os.path.join(repo.workdir, path))
if changed:
repo.index.write()
print(f"Merged {changed} maps.")
return 0
if __name__ == '__main__':
exit(main(pygit2.Repository(pygit2.discover_repository(os.getcwd()))))

View File

@@ -0,0 +1,3 @@
@echo off
python -m pip install -r requirements.txt
pause

View File

@@ -0,0 +1,2 @@
pygit2==0.26.0
bidict==0.13.1

View File

@@ -0,0 +1,5 @@
@echo off
set MAPROOT=../../_maps/
set TGM=0
python convert.py
pause

View File

@@ -1,12 +0,0 @@
@echo off
cd ../../maps/
for /R %%f in (*.dmm) do copy "%%f" "%%f.backup"
cls
echo All dmm files in maps directories have been backed up
echo Now you can make your changes...
echo ---
echo Remember to run Run_Map_Merge.bat just before you commit your changes!
echo ---
pause

View File

@@ -1,4 +0,0 @@
@echo off
set MAPROOT="../../maps/"
python mapmerger.py %1 %MAPROOT%
pause

View File

@@ -1,524 +0,0 @@
import collections
maxx = 0
maxy = 0
key_length = 1
def reset_globals():
global key_length
global maxx
global maxy
key_length = 1
maxx = 0
maxy = 0
def merge_map(newfile, backupfile, tgm):
reset_globals()
shitmap = parse_map(newfile)
shitDict = shitmap["dictionary"] #key to tile data dictionary
shitGrid = shitmap["grid"] #x,y coords to tiles (keys) dictionary (the map's layout)
originalmap = parse_map(backupfile)
originalDict = originalmap["dictionary"]
originalGrid = originalmap["grid"]
mergeGrid = dict() #final map layout
known_keys = dict() #mapping known keys to original keys
tempGrid = dict() #saving tiles with newly generated keys for later processing
temp_keys = dict() #mapping known keys to newly generated keys
unused_keys = list(originalDict.keys()) #list with all existing keys that aren't being used
tempDict = collections.OrderedDict() #mapping new keys to new data
originalDict_size = len(originalDict)
for y in range(1,maxy+1):
for x in range(1,maxx+1):
shitKey = shitGrid[x,y]
#if this key was seen before, add it to the pile immediately
if shitKey in known_keys:
mergeGrid[x,y] = known_keys[shitKey]
continue
#if this key was seen before, add it to the pile immediately
if shitKey in temp_keys:
tempGrid[x,y] = temp_keys[shitKey]
continue
shitData = shitDict[shitKey]
originalKey = originalGrid[x,y]
originalData = originalDict[originalKey]
#if new tile data at x,y is the same as original tile data at x,y, add to the pile
if shitData == originalData:
mergeGrid[x,y] = originalKey
known_keys[shitKey] = originalKey
unused_keys.remove(originalKey)
else:
#search for the new tile data in the original dictionary, if a key is found add it to the pile, else generate a new key
newKey = search_key(originalDict, shitData)
if newKey != None:
try:
unused_keys.remove(newKey)
except ValueError: #caused by a duplicate entry
print("WARNING: Correcting duplicate dictionary entry. ({})".format(shitKey))
mergeGrid[x,y] = newKey
known_keys[shitKey] = newKey
#if data at original x,y no longer exists we reuse the key immediately
elif search_key(shitDict, originalData) == None:
mergeGrid[x,y] = originalKey
originalDict[originalKey] = shitData
unused_keys.remove(originalKey)
known_keys[shitKey] = originalKey
else:
if len(tempDict) == 0:
newKey = generate_new_key(originalDict)
else:
newKey = generate_new_key(tempDict)
if newKey == "OVERFLOW": #if this happens, merging is impossible
print("ERROR: Key overflow detected.")
return 0
tempGrid[x,y] = newKey
temp_keys[shitKey] = newKey
tempDict[newKey] = shitData
sort = 0
#find gaps in the dictionary keys sequence and add the missing keys to be recycled
dict_list = list(originalDict.keys())
for index in range(0, len(dict_list)):
if index + 1 == len(dict_list):
break
key = dict_list[index]
next_key = dict_list[index+1]
difference = key_difference(key, next_key)
if difference > 1:
i = 1
nextnew = key
while i < difference:
nextnew = get_next_key(nextnew)
unused_keys.append(nextnew)
i += 1
sort = 1
#Recycle outdated keys with any new tile data, starting from the bottom of the dictionary
i = 0
for key, value in reversed(tempDict.items()):
recycled_key = key
if len(unused_keys) > 0:
recycled_key = unused_keys.pop()
for coord, gridkey in tempGrid.items():
if gridkey == None:
continue
if gridkey == key:
mergeGrid[coord] = recycled_key
tempGrid[coord] = None
originalDict[recycled_key] = value
#if gaps in the key sequence were found, sort the dictionary for cleanliness
if sort == 1:
sorted_dict = collections.OrderedDict()
next_key = get_next_key("")
while len(sorted_dict) < len(originalDict):
try:
sorted_dict[next_key] = originalDict[next_key]
except KeyError:
pass
next_key = get_next_key(next_key)
originalDict = sorted_dict
if tgm:
with open(newfile, "w") as output:
write_dictionary_tgm(output, originalDict)
write_grid_coord_small(output, mergeGrid)
else:
with open(newfile, "wt", encoding='cp1252', newline='\n') as output:
write_dictionary_dmm(output, originalDict)
write_grid_dmm(output, mergeGrid)
return 1
#write dictionary in tgm format
def write_dictionary_tgm(output, dictionary):
output.write("//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE \n")
for key, list_ in dictionary.items():
output.write("\"{}\" = (\n".format(key))
for thing in list_:
buffer = ""
in_quote_block = False
in_varedit_block = False
for char in thing:
if in_quote_block:
if char == "\"":
in_quote_block = False
buffer = buffer + char
continue
elif char == "\"":
in_quote_block = True
buffer = buffer + char
continue
if not in_varedit_block:
if char == "{":
in_varedit_block = True
buffer = buffer + "{\n\t"
continue
else:
if char == ";":
buffer = buffer + ";\n\t"
continue
elif char == "}":
buffer = buffer + "\n\t}"
in_varedit_block = False
continue
buffer = buffer + char
if list_.index(thing) != len(list_) - 1:
buffer = buffer + ",\n"
output.write(buffer)
output.write(")\n")
#thanks to YotaXP for finding out about this one
def write_grid_coord_small(output, grid):
output.write("\n")
for x in range(1, maxx+1):
output.write("({},{},1) = {{\"\n".format(x, 1, 1))
for y in range(1, maxy):
output.write("{}\n".format(grid[x,y]))
output.write("{}\n\"}}\n".format(grid[x,maxy]))
def search_key(dictionary, data):
for key, value in dictionary.items():
if value == data:
return key
return None
def generate_new_key(dictionary):
last_key = next(reversed(dictionary))
return get_next_key(last_key)
def get_next_key(key):
if key == "":
return "".join("a" for _ in range(key_length))
length = len(key)
new_key = ""
carry = 1
for char in key[::-1]:
if carry <= 0:
new_key = new_key + char
continue
if char == 'Z':
new_key = new_key + 'a'
carry += 1
length -= 1
if length <= 0:
return "OVERFLOW"
elif char == 'z':
new_key = new_key + 'A'
else:
new_key = new_key + chr(ord(char) + 1)
if carry > 0:
carry -= 1
return new_key[::-1]
#still does not support more than one z level per file, but should parse any format
def parse_map(map_file):
with open(map_file, "r") as map_input:
characters = map_input.read()
in_quote_block = False
in_key_block = False
in_data_block = False
in_varedit_block = False
after_data_block = False
escaping = False
skip_whitespace = False
dictionary = collections.OrderedDict()
curr_key = ""
curr_datum = ""
curr_data = list()
in_map_block = False
in_coord_block = False
in_map_string = False
iter_x = 0
adjust_y = True
curr_num = ""
reading_coord = "x"
global key_length
global maxx
global maxy
curr_x = 0
curr_y = 0
curr_z = 1
grid = dict()
for char in characters:
if not in_map_block:
if char == "\n" or char == "\t":
continue
if in_data_block:
if in_varedit_block:
if in_quote_block:
if char == "\\":
curr_datum = curr_datum + char
escaping = True
continue
if escaping:
curr_datum = curr_datum + char
escaping = False
continue
if char == "\"":
curr_datum = curr_datum + char
in_quote_block = False
continue
curr_datum = curr_datum + char
continue
if skip_whitespace and char == " ":
skip_whitespace = False
continue
skip_whitespace = False
if char == "\"":
curr_datum = curr_datum + char
in_quote_block = True
continue
if char == ";":
skip_whitespace = False
curr_datum = curr_datum + char
continue
if char == "}":
curr_datum = curr_datum + char
in_varedit_block = False
continue
curr_datum = curr_datum + char
continue
if char == "{":
curr_datum = curr_datum + char
in_varedit_block = True
continue
if char == ",":
curr_data.append(curr_datum)
curr_datum = ""
continue
if char == ")":
curr_data.append(curr_datum)
dictionary[curr_key] = tuple(curr_data)
curr_data = list()
curr_datum = ""
curr_key = ""
in_data_block = False
after_data_block = True
continue
curr_datum = curr_datum + char
continue
if in_key_block:
if char == "\"":
in_key_block = False
key_length = len(curr_key)
else:
curr_key = curr_key + char
continue
#else we're looking for a key block, a data block or the map block
if char == "\"":
in_key_block = True
after_data_block = False
continue
if char == "(":
if after_data_block:
in_map_block = True
in_coord_block = True
after_data_block = False
curr_key = ""
continue
else:
in_data_block = True
after_data_block = False
continue
else:
if in_coord_block:
if char == ",":
if reading_coord == "x":
curr_x = string_to_num(curr_num)
if curr_x > maxx:
maxx = curr_x
iter_x = 0
curr_num = ""
reading_coord = "y"
elif reading_coord == "y":
curr_y = string_to_num(curr_num)
if curr_y > maxy:
maxy = curr_y
curr_num = ""
reading_coord = "z"
else:
pass
continue
if char == ")":
in_coord_block = False
reading_coord = "x"
curr_num = ""
#read z here if needed
continue
curr_num = curr_num + char
continue
if in_map_string:
if char == "\"":
in_map_string = False
adjust_y = True
curr_y -= 1
continue
if char == "\n":
if adjust_y:
adjust_y = False
else:
curr_y += 1
if curr_x > maxx:
maxx = curr_x
if iter_x > 1:
curr_x = 1
iter_x = 0
continue
curr_key = curr_key + char
if len(curr_key) == key_length:
iter_x += 1
if iter_x > 1:
curr_x += 1
grid[curr_x, curr_y] = curr_key
curr_key = ""
continue
#else look for coordinate block or a map string
if char == "(":
in_coord_block = True
continue
if char == "\"":
in_map_string = True
continue
if curr_y > maxy:
maxy = curr_y
data = dict()
data["dictionary"] = dictionary
data["grid"] = grid
return data
#subtract keyB from keyA
def key_difference(keyA, keyB):
if len(keyA) != len(keyB):
return "you fucked up"
Ayek = keyA[::-1]
Byek = keyB[::-1]
result = 0
for i in range(0, len(keyA)):
base = 52**i
A = 26 if Ayek[i].isupper() else 0
B = 26 if Byek[i].isupper() else 0
result += ( (ord(Byek[i].lower()) + B) - (ord(Ayek[i].lower()) + A) ) * base
return result
def string_to_num(s):
try:
return int(s)
except ValueError:
return -1
#writes a tile data dictionary the same way Dreammaker does
def write_dictionary_dmm(output, dictionary):
for key, value in dictionary.items():
output.write("\"{}\" = ({})\n".format(key, ",".join(value)))
#writes a map grid the same way Dreammaker does
def write_grid_dmm(output, grid):
output.write("\n")
output.write("(1,1,1) = {\"\n")
for y in range(1, maxy+1):
for x in range(1, maxx+1):
try:
output.write(grid[x,y])
except KeyError:
print("Key error: ({},{})".format(x,y))
output.write("\n")
output.write("\"}")
output.write("\n")
#inflated map grid; unused
def write_grid_coord(filename, grid):
with open(filename, "a") as output:
output.write("\n")
for y in range(1, maxy+1):
for x in range(1, maxx+1):
output.write("({},{},1) = {{\"{}\"}}\n".format(x, y, grid[x,y]))
def key_compare(keyA, keyB): #thanks byond for not respecting ascii
pos = 0
for a in keyA:
pos += 1
count = pos
for b in keyB:
if(count > 1):
count -= 1
continue
if a.islower() and b.islower():
if(a < b):
return -1
if(a > b):
return 1
break
if a.islower() and b.isupper():
return -1
if a.isupper() and b.islower():
return 1
if a.isupper() and b.isupper():
if(a < b):
return -1
if(a > b):
return 1
break
return 0

View File

@@ -1,81 +0,0 @@
#!/usr/bin/python3
import sys
import os
import pathlib
import map_helpers
import shutil
def main(map_folder):
list_of_files = list()
for root, directories, filenames in os.walk(map_folder):
for filename in [f for f in filenames if f.endswith(".dmm")]:
list_of_files.append(pathlib.Path(root, filename))
last_dir = ""
for i in range(0, len(list_of_files)):
this_dir = list_of_files[i].parent
if last_dir != this_dir:
print("--------------------------------")
last_dir = this_dir
print("[{}]: {}".format(i, str(list_of_files[i])[len(map_folder):]))
print("--------------------------------")
in_list = input("List the maps you want to merge (example: 1,3-5,12):\n")
in_list = in_list.replace(" ", "")
in_list = in_list.split(",")
valid_indices = list()
for m in in_list:
index_range = m.split("-")
if len(index_range) == 1:
index = string_to_num(index_range[0])
if index >= 0 and index < len(list_of_files):
valid_indices.append(index)
elif len(index_range) == 2:
index0 = string_to_num(index_range[0])
index1 = string_to_num(index_range[1])
if index0 >= 0 and index0 <= index1 and index1 < len(list_of_files):
valid_indices.extend(range(index0, index1 + 1))
# if tgm == "1":
# print("\nMaps will be converted to tgm.")
# tgm = True
# else:
# print("\nMaps will not be converted to tgm.")
# tgm = False
tgm = False
print("\nMerging these maps:")
for i in valid_indices:
print(str(list_of_files[i])[len(map_folder):])
merge = input("\nPress Enter to merge...")
if merge == "abort":
print("\nAborted map merge.")
sys.exit()
else:
for i in valid_indices:
path_str = str(list_of_files[i])
shutil.copyfile(path_str, path_str + ".before")
path_str_pretty = path_str[len(map_folder):]
try:
if map_helpers.merge_map(path_str, path_str + ".backup", tgm) != 1:
print("ERROR MERGING: {}".format(path_str_pretty))
os.remove(path_str + ".before")
continue
print("MERGED: {}".format(path_str_pretty))
except FileNotFoundError:
print("\nERROR: File not found! Make sure you run 'Prepare Maps.bat' before merging.")
print(path_str_pretty + " || " + path_str_pretty + ".backup")
print("\nFinished merging.")
print("\nNOTICE: A version of the map files from before merging have been created for debug purposes.\nDo not delete these files until it is sure your map edits have no undesirable changes.")
def string_to_num(s):
try:
return int(s)
except ValueError:
return -1
if __name__ == "__main__":
main(sys.argv[1])

View File

@@ -1,2 +0,0 @@
#!/bin/bash
find ../../maps | grep \.dmm$ | xargs -l1 -I{} cp {} {}.backup