open-dragoman 0.2.2__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dragoman/__init__.py ADDED
@@ -0,0 +1,348 @@
1
+ """Dragoman -- translate maps between Open Doctrines and Greater Diplomacy 5.
2
+
3
+ import dragoman
4
+
5
+ report = dragoman.convert("1914.odmap", "base_maps/1914", to="gd5")
6
+ for entry in report.warnings:
7
+ print(entry.code, entry.message)
8
+
9
+ world = dragoman.load("1914.odmap")
10
+ print(world.name, len(world.provinces), "provinces")
11
+
12
+ The whole model is available as plain Python data through `World.to_dict()`,
13
+ so a map can be inspected or edited here and written back out without this
14
+ package needing an accessor for every field the two games have.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ctypes
20
+ import json
21
+ from dataclasses import dataclass
22
+ from typing import Any, Dict, List, Optional, Union
23
+
24
+ from ._ffi import Options, declare, load_library
25
+ from ._version import __version__
26
+
27
+ __all__ = [
28
+ "__version__",
29
+ "abi_version",
30
+ "library_version",
31
+ "Format",
32
+ "Severity",
33
+ "Diagnostic",
34
+ "Report",
35
+ "World",
36
+ "DragomanError",
37
+ "detect",
38
+ "load",
39
+ "convert",
40
+ "roundtrip_check",
41
+ ]
42
+
43
+ _lib = None
44
+
45
+
46
+ def _library():
47
+ global _lib
48
+ if _lib is None:
49
+ _lib = declare(load_library())
50
+ return _lib
51
+
52
+
53
+ class DragomanError(RuntimeError):
54
+ """A conversion that could not be completed.
55
+
56
+ Carries the report as well as the message, because the reason a map failed
57
+ is usually in a diagnostic rather than in the final error string.
58
+ """
59
+
60
+ def __init__(self, message: str, report: Optional["Report"] = None):
61
+ super().__init__(message)
62
+ self.report = report
63
+
64
+
65
+ class Format:
66
+ UNKNOWN = 0
67
+ ODMAP = 1
68
+ GD5 = 2
69
+
70
+ _BY_NAME = {"odmap": ODMAP, "od": ODMAP, "gd5": GD5, "gd": GD5, "unknown": UNKNOWN}
71
+
72
+ @classmethod
73
+ def parse(cls, value: Union[int, str, None]) -> int:
74
+ if value is None:
75
+ return cls.UNKNOWN
76
+ if isinstance(value, int):
77
+ return value
78
+ key = value.strip().lower()
79
+ if key not in cls._BY_NAME:
80
+ raise ValueError(f"unknown format {value!r}; use 'odmap' or 'gd5'")
81
+ return cls._BY_NAME[key]
82
+
83
+ @staticmethod
84
+ def name(value: int) -> str:
85
+ return _library().dg_format_name(value).decode()
86
+
87
+
88
+ class Severity:
89
+ INFO = 0
90
+ WARNING = 1
91
+ ERROR = 2
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class Diagnostic:
96
+ severity: int
97
+ code: str
98
+ message: str
99
+
100
+ @property
101
+ def is_warning(self) -> bool:
102
+ return self.severity == Severity.WARNING
103
+
104
+ @property
105
+ def is_error(self) -> bool:
106
+ return self.severity == Severity.ERROR
107
+
108
+ def __str__(self) -> str:
109
+ label = {0: "note", 1: "warning", 2: "error"}[self.severity]
110
+ return f"{label} {self.code}: {self.message}"
111
+
112
+
113
+ class Report:
114
+ """What the translation could not do exactly, and why."""
115
+
116
+ def __init__(self, entries: List[Diagnostic]):
117
+ self._entries = entries
118
+
119
+ def __iter__(self):
120
+ return iter(self._entries)
121
+
122
+ def __len__(self) -> int:
123
+ return len(self._entries)
124
+
125
+ def __repr__(self) -> str:
126
+ return f"<Report {len(self._entries)} entries, worst={self.worst}>"
127
+
128
+ @property
129
+ def entries(self) -> List[Diagnostic]:
130
+ return list(self._entries)
131
+
132
+ @property
133
+ def warnings(self) -> List[Diagnostic]:
134
+ return [e for e in self._entries if e.severity == Severity.WARNING]
135
+
136
+ @property
137
+ def errors(self) -> List[Diagnostic]:
138
+ return [e for e in self._entries if e.severity == Severity.ERROR]
139
+
140
+ @property
141
+ def worst(self) -> int:
142
+ return max((e.severity for e in self._entries), default=Severity.INFO)
143
+
144
+ @property
145
+ def ok(self) -> bool:
146
+ return self.worst < Severity.ERROR
147
+
148
+
149
+ def _take_report(handle) -> Report:
150
+ """Read a dg_report out and free it. Always called, even on failure."""
151
+ lib = _library()
152
+ entries: List[Diagnostic] = []
153
+ if handle:
154
+ for i in range(lib.dg_report_count(handle)):
155
+ entries.append(
156
+ Diagnostic(
157
+ severity=lib.dg_report_severity(handle, i),
158
+ code=lib.dg_report_code(handle, i).decode(),
159
+ message=lib.dg_report_message(handle, i).decode(),
160
+ )
161
+ )
162
+ lib.dg_report_free(handle)
163
+ return Report(entries)
164
+
165
+
166
+ def _take_string(pointer) -> str:
167
+ """Read a char* the library allocated and hand it back to the library."""
168
+ if not pointer:
169
+ return ""
170
+ lib = _library()
171
+ text = ctypes.cast(pointer, ctypes.c_char_p).value or b""
172
+ lib.dg_string_free(pointer)
173
+ return text.decode()
174
+
175
+
176
+ def _options(
177
+ carry_sidecar: bool = True,
178
+ derive_geometry: bool = True,
179
+ translate_scripts: bool = True,
180
+ strict: bool = False,
181
+ reencode_images: bool = False,
182
+ synthesise_ocean: bool = True,
183
+ ) -> Options:
184
+ opts = Options()
185
+ _library().dg_options_defaults(ctypes.byref(opts))
186
+ opts.carry_sidecar = int(carry_sidecar)
187
+ opts.derive_geometry = int(derive_geometry)
188
+ opts.translate_scripts = int(translate_scripts)
189
+ opts.strict = int(strict)
190
+ opts.reencode_images = int(reencode_images)
191
+ opts.synthesise_ocean = int(synthesise_ocean)
192
+ return opts
193
+
194
+
195
+ class World:
196
+ """A loaded map, in neither game's layout but in the shared model."""
197
+
198
+ def __init__(self, handle, report: Report):
199
+ self._handle = handle
200
+ self._dict: Optional[Dict[str, Any]] = None
201
+ self.report = report
202
+
203
+ def __del__(self):
204
+ self.close()
205
+
206
+ def __enter__(self) -> "World":
207
+ return self
208
+
209
+ def __exit__(self, *exc) -> None:
210
+ self.close()
211
+
212
+ def close(self) -> None:
213
+ if getattr(self, "_handle", None):
214
+ _library().dg_world_free(self._handle)
215
+ self._handle = None
216
+
217
+ def _require(self):
218
+ if not self._handle:
219
+ raise DragomanError("this world has already been closed")
220
+ return self._handle
221
+
222
+ def __repr__(self) -> str:
223
+ return (
224
+ f"<World {self.name!r} {len(self.provinces)} provinces "
225
+ f"{len(self.nations)} nations from {self.origin}>"
226
+ )
227
+
228
+ @property
229
+ def name(self) -> str:
230
+ return _library().dg_world_name(self._require()).decode()
231
+
232
+ @property
233
+ def origin(self) -> str:
234
+ return Format.name(_library().dg_world_origin(self._require()))
235
+
236
+ def to_dict(self) -> Dict[str, Any]:
237
+ """The whole interchange model as plain Python data.
238
+
239
+ Parsed once and kept, because on a world map this is a few megabytes
240
+ of JSON and callers reach for `.provinces` in a loop.
241
+ """
242
+ if self._dict is None:
243
+ self._dict = json.loads(_take_string(_library().dg_world_to_json(self._require())))
244
+ return self._dict
245
+
246
+ @property
247
+ def provinces(self) -> List[Dict[str, Any]]:
248
+ return self.to_dict().get("provinces", [])
249
+
250
+ @property
251
+ def nations(self) -> List[Dict[str, Any]]:
252
+ return self.to_dict().get("nations", [])
253
+
254
+ @property
255
+ def events(self) -> List[Dict[str, Any]]:
256
+ return self.to_dict().get("events", [])
257
+
258
+ @property
259
+ def scripts(self) -> List[Dict[str, Any]]:
260
+ return self.to_dict().get("scripts", [])
261
+
262
+ def save(self, path: str, to: Union[int, str], **options) -> Report:
263
+ lib = _library()
264
+ opts = _options(**options)
265
+ handle = ctypes.c_void_p()
266
+ rc = lib.dg_save(
267
+ self._require(),
268
+ str(path).encode(),
269
+ Format.parse(to),
270
+ ctypes.byref(opts),
271
+ ctypes.byref(handle),
272
+ )
273
+ report = _take_report(handle)
274
+ if rc != 0:
275
+ raise DragomanError(lib.dg_last_error().decode(), report)
276
+ return report
277
+
278
+
279
+ def library_version() -> str:
280
+ """The version of the shared library, which a wheel may outlive."""
281
+ return _library().dg_version_string().decode()
282
+
283
+
284
+ def abi_version() -> int:
285
+ return _library().dg_abi_version()
286
+
287
+
288
+ def detect(path: str) -> str:
289
+ """'odmap', 'gd5' or 'unknown', decided by content rather than by name."""
290
+ return Format.name(_library().dg_detect(str(path).encode()))
291
+
292
+
293
+ def load(path: str, fmt: Union[int, str, None] = None, **options) -> World:
294
+ lib = _library()
295
+ opts = _options(**options)
296
+ handle = ctypes.c_void_p()
297
+ world = lib.dg_load(
298
+ str(path).encode(), Format.parse(fmt), ctypes.byref(opts), ctypes.byref(handle)
299
+ )
300
+ report = _take_report(handle)
301
+ if not world:
302
+ raise DragomanError(lib.dg_last_error().decode(), report)
303
+ return World(world, report)
304
+
305
+
306
+ def convert(src: str, dst: str, to: Union[int, str, None] = None, **options) -> Report:
307
+ """Translate a map. With no `to`, converts to whichever format `src` is not."""
308
+ lib = _library()
309
+ if to is None:
310
+ found = _library().dg_detect(str(src).encode())
311
+ if found == Format.UNKNOWN:
312
+ raise DragomanError(f"cannot tell what {src} is; pass to='odmap' or to='gd5'")
313
+ to = Format.GD5 if found == Format.ODMAP else Format.ODMAP
314
+
315
+ opts = _options(**options)
316
+ handle = ctypes.c_void_p()
317
+ rc = lib.dg_convert(
318
+ str(src).encode(), str(dst).encode(), Format.parse(to), ctypes.byref(opts),
319
+ ctypes.byref(handle),
320
+ )
321
+ report = _take_report(handle)
322
+ if rc != 0:
323
+ raise DragomanError(lib.dg_last_error().decode(), report)
324
+ return report
325
+
326
+
327
+ def roundtrip_check(path: str, to: Union[int, str, None] = None, **options):
328
+ """Convert to `to` and back; return (identical, report).
329
+
330
+ 'Identical' means every province, nation, relation, claim, script and
331
+ carried file returned unchanged and the province raster hashes the same --
332
+ not that the two container files are byte-for-byte equal, which deflate
333
+ does not promise across implementations.
334
+ """
335
+ lib = _library()
336
+ if to is None:
337
+ found = lib.dg_detect(str(path).encode())
338
+ to = Format.GD5 if found == Format.ODMAP else Format.ODMAP
339
+
340
+ opts = _options(**options)
341
+ handle = ctypes.c_void_p()
342
+ rc = lib.dg_roundtrip_check(
343
+ str(path).encode(), Format.parse(to), ctypes.byref(opts), ctypes.byref(handle)
344
+ )
345
+ report = _take_report(handle)
346
+ if rc < 0:
347
+ raise DragomanError(lib.dg_last_error().decode(), report)
348
+ return rc == 1, report
dragoman/__main__.py ADDED
@@ -0,0 +1,139 @@
1
+ """The `dragoman` command, for people who installed with pip.
2
+
3
+ The C++ build produces a command line tool of its own; this is the same set of
4
+ subcommands over the same library, so that `pip install dragoman` gives a
5
+ working command without anyone needing to know there is a binary underneath or
6
+ where it went.
7
+
8
+ dragoman convert 1914.odmap base_maps/1914
9
+ dragoman roundtrip 1914.odmap
10
+ dragoman inspect 1914.odmap --json
11
+ dragoman detect some-file
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+
20
+ from . import DragomanError, Severity, __version__, abi_version, convert
21
+ from . import detect as detect_format
22
+ from . import library_version, load, roundtrip_check
23
+
24
+ _LABEL = {Severity.INFO: "note", Severity.WARNING: "warning", Severity.ERROR: "error"}
25
+
26
+
27
+ def _print_report(report, quiet: bool) -> int:
28
+ for entry in report:
29
+ if quiet and entry.severity == Severity.INFO:
30
+ continue
31
+ stream = sys.stdout if entry.severity == Severity.INFO else sys.stderr
32
+ print(f" {_LABEL[entry.severity]:<7} {entry.code}: {entry.message}", file=stream)
33
+ return report.worst
34
+
35
+
36
+ def _options(args) -> dict:
37
+ return {
38
+ "carry_sidecar": not args.no_sidecar,
39
+ "derive_geometry": not args.no_geometry,
40
+ "translate_scripts": not args.no_scripts,
41
+ "synthesise_ocean": not args.no_ocean,
42
+ "strict": args.strict,
43
+ }
44
+
45
+
46
+ def _add_common(parser) -> None:
47
+ parser.add_argument("--no-sidecar", action="store_true",
48
+ help="do not carry what the target format cannot hold")
49
+ parser.add_argument("--no-geometry", action="store_true",
50
+ help="do not derive adjacency, centres or the land/sea mask")
51
+ parser.add_argument("--no-scripts", action="store_true",
52
+ help="do not translate scripts or scripted events")
53
+ parser.add_argument("--no-ocean", action="store_true",
54
+ help="do not invent sea provinces for a game that draws none")
55
+ parser.add_argument("--strict", action="store_true",
56
+ help="treat any warning as a failure")
57
+ parser.add_argument("--quiet", action="store_true", help="warnings and errors only")
58
+
59
+
60
+ def main(argv=None) -> int:
61
+ parser = argparse.ArgumentParser(
62
+ prog="dragoman",
63
+ description="Translate maps between Open Doctrines and Greater Diplomacy 5.",
64
+ epilog="Documentation: https://github.com/Pr1nted/dragoman/wiki",
65
+ )
66
+ parser.add_argument("--version", action="store_true", help="print versions and exit")
67
+ sub = parser.add_subparsers(dest="command")
68
+
69
+ p = sub.add_parser("convert", help="convert a map to the other format")
70
+ p.add_argument("input")
71
+ p.add_argument("output")
72
+ p.add_argument("--to", choices=["odmap", "gd5"],
73
+ help="target format; inferred from the source when omitted")
74
+ _add_common(p)
75
+
76
+ p = sub.add_parser("roundtrip", help="convert to the other format and back, and compare")
77
+ p.add_argument("map")
78
+ p.add_argument("--to", choices=["odmap", "gd5"])
79
+ _add_common(p)
80
+
81
+ p = sub.add_parser("inspect", help="summarise a map, or dump the whole model")
82
+ p.add_argument("map")
83
+ p.add_argument("--json", action="store_true", help="print the interchange model")
84
+
85
+ p = sub.add_parser("detect", help="say what a file is: odmap, gd5, or unknown")
86
+ p.add_argument("path")
87
+
88
+ args = parser.parse_args(argv)
89
+
90
+ if args.version or args.command is None:
91
+ if args.version:
92
+ print(f"dragoman {__version__} (library {library_version()}, abi {abi_version()})")
93
+ return 0
94
+ parser.print_help()
95
+ return 2
96
+
97
+ try:
98
+ if args.command == "detect":
99
+ found = detect_format(args.path)
100
+ print(found)
101
+ return 1 if found == "unknown" else 0
102
+
103
+ if args.command == "inspect":
104
+ with load(args.map) as world:
105
+ if args.json:
106
+ print(json.dumps(world.to_dict(), indent=1))
107
+ else:
108
+ print(world.name)
109
+ print(f" format {world.origin}")
110
+ print(f" provinces {len(world.provinces)}")
111
+ print(f" nations {len(world.nations)}")
112
+ print(f" scripts {len(world.scripts) + len(world.events)}")
113
+ return 0
114
+
115
+ if args.command == "convert":
116
+ report = convert(args.input, args.output, to=args.to, **_options(args))
117
+ if not args.quiet:
118
+ print(f"converted {args.input} -> {args.output}")
119
+ return 1 if _print_report(report, args.quiet) >= Severity.ERROR else 0
120
+
121
+ if args.command == "roundtrip":
122
+ identical, report = roundtrip_check(args.map, to=args.to, **_options(args))
123
+ print("round trip preserved every modelled field and the province raster"
124
+ if identical else "round trip changed the map -- see below")
125
+ _print_report(report, args.quiet)
126
+ return 0 if identical else 1
127
+
128
+ except DragomanError as exc:
129
+ print(f"dragoman: {exc}", file=sys.stderr)
130
+ if exc.report is not None:
131
+ _print_report(exc.report, quiet=False)
132
+ return 1
133
+
134
+ parser.print_help()
135
+ return 2
136
+
137
+
138
+ if __name__ == "__main__":
139
+ sys.exit(main())
dragoman/_ffi.py ADDED
@@ -0,0 +1,129 @@
1
+ """Finding and declaring the shared library.
2
+
3
+ ctypes rather than a compiled extension on purpose. Greater Diplomacy 5 is a
4
+ pygame project whose players install it by unzipping it, and asking that
5
+ audience for a C compiler to convert a map is asking too much. ctypes needs
6
+ only the .so/.dylib/.dll, which CI builds for each platform and the wheel
7
+ carries.
8
+
9
+ The search order is: an explicit DRAGOMAN_LIBRARY, then a copy shipped inside
10
+ this package (how a wheel is laid out), then a CMake build tree beside the
11
+ checkout (how a contributor runs it), then the system loader.
12
+ """
13
+
14
+ import ctypes
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ _LIB_NAMES = {
20
+ "win32": ["dragoman.dll", "libdragoman.dll"],
21
+ "darwin": ["libdragoman.dylib"],
22
+ }
23
+ _DEFAULT_NAMES = ["libdragoman.so"]
24
+
25
+
26
+ def _candidate_names():
27
+ return _LIB_NAMES.get(sys.platform, _DEFAULT_NAMES)
28
+
29
+
30
+ def _candidate_paths():
31
+ override = os.environ.get("DRAGOMAN_LIBRARY")
32
+ if override:
33
+ yield Path(override)
34
+
35
+ here = Path(__file__).resolve().parent
36
+ for name in _candidate_names():
37
+ yield here / name
38
+
39
+ # A CMake build tree beside the repository, which is what a contributor
40
+ # who just ran `cmake --build build` has.
41
+ repo = here.parents[2]
42
+ for build in ("build", "cmake-build-debug", "cmake-build-release", "out"):
43
+ for name in _candidate_names():
44
+ yield repo / build / name
45
+ yield repo / build / "Release" / name
46
+ yield repo / build / "Debug" / name
47
+
48
+
49
+ def load_library():
50
+ tried = []
51
+ for path in _candidate_paths():
52
+ tried.append(str(path))
53
+ if path.exists():
54
+ return ctypes.CDLL(str(path))
55
+
56
+ # Last resort: let the platform loader look on its own search path.
57
+ for name in _candidate_names():
58
+ try:
59
+ return ctypes.CDLL(name)
60
+ except OSError:
61
+ tried.append(name)
62
+
63
+ raise OSError(
64
+ "could not find the dragoman shared library. Build it with\n"
65
+ " cmake -S . -B build && cmake --build build\n"
66
+ "or set DRAGOMAN_LIBRARY to its path. Looked in:\n "
67
+ + "\n ".join(tried)
68
+ )
69
+
70
+
71
+ class Options(ctypes.Structure):
72
+ _fields_ = [
73
+ ("carry_sidecar", ctypes.c_int),
74
+ ("derive_geometry", ctypes.c_int),
75
+ ("translate_scripts", ctypes.c_int),
76
+ ("strict", ctypes.c_int),
77
+ ("reencode_images", ctypes.c_int),
78
+ ("synthesise_ocean", ctypes.c_int),
79
+ ]
80
+
81
+
82
+ def declare(lib):
83
+ """Give every symbol its signature.
84
+
85
+ Without this ctypes assumes an int return, which silently truncates a
86
+ 64-bit pointer on the first call that returns a handle -- the failure
87
+ looks like a corrupt map rather than a binding mistake, so it is worth
88
+ being exhaustive here.
89
+ """
90
+ c = ctypes
91
+ p = c.c_void_p
92
+ s = c.c_char_p
93
+
94
+ sigs = {
95
+ "dg_version_string": ([], s),
96
+ "dg_version_major": ([], c.c_int),
97
+ "dg_version_minor": ([], c.c_int),
98
+ "dg_version_patch": ([], c.c_int),
99
+ "dg_abi_version": ([], c.c_int),
100
+ "dg_format_name": ([c.c_int], s),
101
+ "dg_detect": ([s], c.c_int),
102
+ "dg_options_defaults": ([c.POINTER(Options)], None),
103
+ "dg_load": ([s, c.c_int, c.POINTER(Options), c.POINTER(p)], p),
104
+ "dg_save": ([p, s, c.c_int, c.POINTER(Options), c.POINTER(p)], c.c_int),
105
+ "dg_convert": ([s, s, c.c_int, c.POINTER(Options), c.POINTER(p)], c.c_int),
106
+ "dg_roundtrip_check": ([s, c.c_int, c.POINTER(Options), c.POINTER(p)], c.c_int),
107
+ "dg_world_free": ([p], None),
108
+ "dg_world_province_count": ([p], c.c_int),
109
+ "dg_world_nation_count": ([p], c.c_int),
110
+ "dg_world_script_count": ([p], c.c_int),
111
+ "dg_world_name": ([p], s),
112
+ "dg_world_origin": ([p], c.c_int),
113
+ "dg_world_to_json": ([p], p),
114
+ "dg_world_from_json": ([s, c.POINTER(p)], p),
115
+ "dg_report_count": ([p], c.c_int),
116
+ "dg_report_severity": ([p, c.c_int], c.c_int),
117
+ "dg_report_code": ([p, c.c_int], s),
118
+ "dg_report_message": ([p, c.c_int], s),
119
+ "dg_report_worst": ([p], c.c_int),
120
+ "dg_report_to_json": ([p], p),
121
+ "dg_report_free": ([p], None),
122
+ "dg_string_free": ([p], None),
123
+ "dg_last_error": ([], s),
124
+ }
125
+ for name, (argtypes, restype) in sigs.items():
126
+ fn = getattr(lib, name)
127
+ fn.argtypes = argtypes
128
+ fn.restype = restype
129
+ return lib
dragoman/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.2"
dragoman/dragoman.dll ADDED
Binary file
@@ -0,0 +1,261 @@
1
+ Metadata-Version: 2.2
2
+ Name: open-dragoman
3
+ Version: 0.2.2
4
+ Summary: Translate maps between Open Doctrines and Greater Diplomacy 5
5
+ Keywords: grand-strategy,map,conversion,opendoctrines,greater-diplomacy,modding
6
+ Author: Dragoman contributors
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Dragoman contributors
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ ---
30
+
31
+ Dragoman is an independent implementation, written from observing the two file
32
+ formats it converts between. No source code from either game is included in or
33
+ derived into it.
34
+
35
+ The games themselves remain under their own licences, and neither project's
36
+ maps are redistributed here:
37
+
38
+ Open Doctrines OpenDoctrines Non-Commercial License
39
+ Greater Diplomacy 5 GNU General Public License v3.0
40
+ https://github.com/GitGetGot415/Greater-Diplomacy-5
41
+
42
+ Vendored third-party code, all permissively licensed:
43
+
44
+ third_party/miniz miniz 11.3.1, MIT
45
+ https://github.com/richgel999/miniz
46
+ third_party/stb stb_image / stb_image_write, MIT or public domain
47
+ https://github.com/nothings/stb
48
+ third_party/json nlohmann/json 3.11.3, MIT
49
+ https://github.com/nlohmann/json
50
+
51
+ Classifier: Development Status :: 4 - Beta
52
+ Classifier: Intended Audience :: Developers
53
+ Classifier: Intended Audience :: End Users/Desktop
54
+ Classifier: License :: OSI Approved :: MIT License
55
+ Classifier: Programming Language :: C++
56
+ Classifier: Programming Language :: Python :: 3
57
+ Classifier: Topic :: Games/Entertainment
58
+ Classifier: Topic :: File Formats
59
+ Project-URL: Homepage, https://github.com/Pr1nted/dragoman
60
+ Project-URL: Documentation, https://github.com/Pr1nted/dragoman/wiki
61
+ Project-URL: Changelog, https://github.com/Pr1nted/dragoman/blob/main/CHANGELOG.md
62
+ Project-URL: Issues, https://github.com/Pr1nted/dragoman/issues
63
+ Requires-Python: >=3.8
64
+ Description-Content-Type: text/markdown
65
+
66
+ # Dragoman
67
+
68
+ Translate maps between **Open Doctrines** (`.odmap`) and **Greater Diplomacy 5**
69
+ (map directories), in both directions, losing as little as the two formats
70
+ allow — and nothing at all on a round trip.
71
+
72
+ A *dragoman* was the interpreter attached to an embassy: the person through
73
+ whom two powers who shared no language could nonetheless sign something. This
74
+ is that, for map files.
75
+
76
+ ```bash
77
+ pip install open-dragoman
78
+
79
+ dragoman convert 1914.odmap base_maps/1914 --to gd5
80
+ dragoman convert base_maps/GD4 gd4.odmap --to odmap
81
+ dragoman roundtrip 1914.odmap # prove nothing was lost
82
+ ```
83
+
84
+ ```python
85
+ import dragoman
86
+
87
+ dragoman.convert("1914.odmap", "base_maps/1914", to="gd5")
88
+
89
+ world = dragoman.load("1914.odmap")
90
+ print(world.name, len(world.provinces), "provinces")
91
+ ```
92
+
93
+ **[Read the wiki →](https://github.com/Pr1nted/dragoman/wiki)** — what it is,
94
+ how to use it, what every message means, and what it cannot do.
95
+
96
+ ## Status
97
+
98
+ Every map both games ship round-trips without losing a single modelled field:
99
+
100
+ | Suite | Result |
101
+ |---|---|
102
+ | Greater Diplomacy 5 maps (12 base maps + 16 scenarios) | **28 / 28** |
103
+ | Open Doctrines maps (5 scenarios + the world map) | **6 / 6** |
104
+ | Unit tests (raster, scripts, ABI, versioning, round trip) | **5 / 5** |
105
+
106
+ Verified against Open Doctrines and against
107
+ [GitGetGot415/Greater-Diplomacy-5](https://github.com/GitGetGot415/Greater-Diplomacy-5).
108
+ Reproduce with `python3 tools/conformance.py <directory of maps>`.
109
+
110
+ Converted maps have also been **played in both games**, not merely loaded by
111
+ this library. Open Doctrines' world map converted to GD5 boots through GD5's
112
+ own `Controller` and `Map` state with all 23 screens, its flags drawn, its
113
+ oceans navigable and every province centre inside its own province; a GD5 map
114
+ converted to Open Doctrines plays five AI turns under `OpenDoctrines
115
+ --simulate`. Nearly everything this library gets right, it gets right because
116
+ somebody opened the result in the game and looked at it — the flag encoding,
117
+ the ocean, the political layer's colour key and the province-border fill were
118
+ all found that way.
119
+
120
+ One thing crossing to Open Doctrines still needs a human: GD5 starts its
121
+ nations with an empty stockpile and Open Doctrines expects a starting
122
+ endowment, so a converted map bankrupts its world on the first turn unless
123
+ treasuries are set. Reported as `od.treasury` rather than guessed at.
124
+
125
+ ## What "lossless" means here
126
+
127
+ The two games are not the same game, so a straight translation always loses
128
+ something: Open Doctrines models a province's population, its port and its
129
+ ethnic minorities, and GD5 has no field for any of them; GD5 models terrain,
130
+ province adjacency, unit rosters and research, and Open Doctrines has no field
131
+ for those.
132
+
133
+ Dragoman writes the difference down. Everything the destination cannot hold
134
+ goes into a **sidecar** beside the map — `dragoman_sidecar/` in a GD5
135
+ directory, `dragoman/` inside a `.odmap` archive. Both games read a fixed list
136
+ of filenames and ignore everything else, so the sidecar is invisible to them
137
+ and costs the converted map nothing.
138
+
139
+ The result:
140
+
141
+ - **Converting one way** is as faithful as the target format permits, and every
142
+ approximation is reported by a stable code you can act on.
143
+ - **Converting back** restores what the target could not hold. `A → B → A` keeps
144
+ every province, nation, relation, claim, core, script and carried file, and
145
+ the province raster hashes identical.
146
+
147
+ `dragoman roundtrip <map>` asserts exactly that. Pass `--no-sidecar` for a
148
+ smaller, genuinely lossy conversion — the round trip then fails, on purpose.
149
+
150
+ Not promised: byte-identical container files. Two zip archives holding
151
+ identical members are different files, because deflate is not reproducible
152
+ across implementations, and Open Doctrines packs with Python's zlib while this
153
+ packs with miniz. Every file *inside* is preserved exactly.
154
+
155
+ ## What crosses
156
+
157
+ | | Open Doctrines | Greater Diplomacy 5 |
158
+ |---|---|---|
159
+ | Province raster | `provinces.png`, id packed big-endian | `id_map.png`, id packed little-endian |
160
+ | Province identity | preserved exactly — the rasters differ only by swapping red and blue |||
161
+ | Owner, name, claims | ✅ | ✅ |
162
+ | Cores | — | ✅ (carried) |
163
+ | Sea provinces | — (**synthesised** for GD5) | ✅ |
164
+ | Population, ports, fortification | ✅ | — (carried) |
165
+ | Minorities, political compass, policies | ✅ | — (carried) |
166
+ | Terrain, adjacency, province centres | — (**derived** from the raster) | ✅ |
167
+ | Units, buildings, research, factions | — (armies carried) | ✅ |
168
+ | Flags | a PNG in the archive | raw 60x40 pixels, base64 |
169
+ | Relations | ally, non-aggression, guarantee | war and alliance only (rest carried) |
170
+ | Scripts | imperative `#OD/MapEngine/1` | declarative scripted events |
171
+
172
+ Adjacency and centroids are computed from the province raster when converting
173
+ to GD5, since Open Doctrines derives both at load and never stores them. The
174
+ centroid finder deliberately handles crescent-shaped provinces, whose mean
175
+ pixel falls outside themselves.
176
+
177
+ Full field-by-field detail: [docs/mapping.md](docs/mapping.md).
178
+
179
+ ## Scripts
180
+
181
+ Open Doctrines' scripting is imperative and line-based, with loops and
182
+ `waitUntil` suspension points. GD5's is a list of declarative events, each a
183
+ set of conditions and a set of actions. The overlap is the shape both express:
184
+ a gate, and things that happen when it opens.
185
+
186
+ An Open Doctrines script written as top-level `waitUntil` stages becomes GD5
187
+ events, one stage at a time. A GD5 event becomes an entry script with a
188
+ `waitUntil` and some `set` lines. Anything outside that overlap — a `foreach`
189
+ over a country's provinces, conditions chained with `XOR` — is reported by name
190
+ and carried unchanged rather than half-translated. GD5 event types this library
191
+ has never heard of pass through untouched, so a GD5 → OD → GD5 trip is lossless
192
+ even for conditions added to the game after this was written.
193
+
194
+ Details and the full vocabulary: [docs/scripting.md](docs/scripting.md).
195
+
196
+ ## Installing
197
+
198
+ ```bash
199
+ pip install open-dragoman
200
+ ```
201
+
202
+ The wheel carries the compiled library inside the package, so there is no
203
+ compiler needed at install time and nothing to locate afterwards. You get both
204
+ the Python API and a `dragoman` command.
205
+
206
+ Prebuilt binaries for people who want nothing to do with Python are attached to
207
+ each [release](https://github.com/Pr1nted/dragoman/releases). The full set of
208
+ options — release archives, source builds, CMake `FetchContent` — is on
209
+ [the Installing page](https://github.com/Pr1nted/dragoman/wiki/Installing).
210
+
211
+ ## Building from source
212
+
213
+ Needs CMake 3.16 and a C++17 compiler. There are no external dependencies —
214
+ miniz, stb and nlohmann/json are vendored, all MIT or public domain.
215
+
216
+ ```bash
217
+ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
218
+ cmake --build build
219
+ ctest --test-dir build --output-on-failure
220
+ ```
221
+
222
+ This produces `libdragoman.a`, a shared `libdragoman.{so,dylib,dll}`, and the
223
+ `dragoman` command line tool.
224
+
225
+ ## Using it from your language
226
+
227
+ The core is C++17 behind a flat **C99 ABI**, so anything with an FFI can call
228
+ it. The whole interchange model is available as JSON through
229
+ `dg_world_to_json`, which is how a binding reads or edits a map without needing
230
+ an accessor per field.
231
+
232
+ - **C** — `#include <dragoman/dragoman.h>`, link `dragoman`. See
233
+ [bindings/c/example.c](bindings/c/example.c).
234
+ - **C++** — `#include <dragoman/dragoman.hpp>` for an RAII wrapper over the same
235
+ ABI. See [bindings/cpp/example.cpp](bindings/cpp/example.cpp).
236
+ - **Python** — `pip install open-dragoman`. Pure ctypes, and the wheel carries the
237
+ library, so no compiler is needed at install time.
238
+ - **Anything else** — Rust, Go, C#, Java, Lua and WebAssembly all bind the same
239
+ header. [docs/abi.md](docs/abi.md) documents the contract.
240
+
241
+ ## Versioning
242
+
243
+ Two numbers that move for different reasons:
244
+
245
+ - **Library version** (`VERSION`, semver) — the release. Currently `0.2.0`.
246
+ - **ABI version** (`DRAGOMAN_ABI_VERSION`) — bumped only when an existing symbol
247
+ changes meaning, so a binding can refuse to load a library it cannot speak to
248
+ without parsing semver.
249
+
250
+ The version lives in `VERSION` and is mirrored into the C header, the Python
251
+ package and the CMake project. `tools/check_version.py` and
252
+ `tests/test_version.cpp` both fail if any copy drifts, and CI runs them on every
253
+ push. Details: [docs/versioning.md](docs/versioning.md).
254
+
255
+ ## Licence
256
+
257
+ Dragoman is MIT. It is an independent implementation written from observing
258
+ both file formats; no code from either game is copied into it. Open Doctrines
259
+ and Greater Diplomacy 5 remain under their own licences (Open Doctrines
260
+ Non-Commercial, and GPL-3.0 respectively), and neither project's maps are
261
+ redistributed here.
@@ -0,0 +1,10 @@
1
+ dragoman/__init__.py,sha256=jlYzSX1zdfBMNDsoqWJFlR42WCqQdE2PoKyrSWH5A4M,10497
2
+ dragoman/__main__.py,sha256=y6JHFD8TiG3h652J1KR9X3fjZXdClBg_0xJwWEBeQxQ,5473
3
+ dragoman/_ffi.py,sha256=vz9PiWxmYP0GFE6G-1QKSIAbzfsLhKuJhIuopH_TdMQ,4528
4
+ dragoman/_version.py,sha256=anBcPGggCP0f-o9w0IsZYV49rl6HvJbHcuDN39x3mxw,23
5
+ dragoman/dragoman.dll,sha256=MUcqF4m-RpBmYyiMIZIPWWdXoEfGElACZjKRS3LrNvk,703488
6
+ open_dragoman-0.2.2.dist-info/METADATA,sha256=lYx8oAoVnQglETSoL69a4S_v2TxY1-nEeohB5cAI9KU,12004
7
+ open_dragoman-0.2.2.dist-info/WHEEL,sha256=k8ugwGhXAI0--PEoWE0F6sXc-nX2zbZqyTgxRg_imsk,102
8
+ open_dragoman-0.2.2.dist-info/entry_points.txt,sha256=oSIpRxsoPRA5OKPlIPTg4zOt1N937ZUMEyocpf9oIY8,53
9
+ open_dragoman-0.2.2.dist-info/licenses/LICENSE,sha256=P7RsI-F89-a0HjEqhSpzC_oMu9Bii2KZT4r8k__7Gx0,1997
10
+ open_dragoman-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: scikit-build-core 1.0.3
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ dragoman = dragoman.__main__:main
3
+
@@ -0,0 +1,43 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dragoman contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ Dragoman is an independent implementation, written from observing the two file
26
+ formats it converts between. No source code from either game is included in or
27
+ derived into it.
28
+
29
+ The games themselves remain under their own licences, and neither project's
30
+ maps are redistributed here:
31
+
32
+ Open Doctrines OpenDoctrines Non-Commercial License
33
+ Greater Diplomacy 5 GNU General Public License v3.0
34
+ https://github.com/GitGetGot415/Greater-Diplomacy-5
35
+
36
+ Vendored third-party code, all permissively licensed:
37
+
38
+ third_party/miniz miniz 11.3.1, MIT
39
+ https://github.com/richgel999/miniz
40
+ third_party/stb stb_image / stb_image_write, MIT or public domain
41
+ https://github.com/nothings/stb
42
+ third_party/json nlohmann/json 3.11.3, MIT
43
+ https://github.com/nlohmann/json