nwn 0.0.2__tar.gz
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.
- nwn-0.0.2/.gitignore +12 -0
- nwn-0.0.2/LICENSE +18 -0
- nwn-0.0.2/PKG-INFO +32 -0
- nwn-0.0.2/README.rst +15 -0
- nwn-0.0.2/pyproject.toml +40 -0
- nwn-0.0.2/src/nwn/__init__.py +4 -0
- nwn-0.0.2/src/nwn/erf.py +240 -0
- nwn-0.0.2/src/nwn/gff/__init__.py +51 -0
- nwn-0.0.2/src/nwn/gff/_impl.py +50 -0
- nwn-0.0.2/src/nwn/gff/_reader.py +157 -0
- nwn-0.0.2/src/nwn/gff/_types.py +109 -0
- nwn-0.0.2/src/nwn/shared.py +165 -0
- nwn-0.0.2/src/nwn/tileset.py +196 -0
nwn-0.0.2/.gitignore
ADDED
nwn-0.0.2/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Copyright Bernhard Stöckner <n@e-ix.net> and contributors. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
|
4
|
+
software and associated documentation files (the "Software"), to deal in the Software
|
|
5
|
+
without restriction, including without limitation the rights to use, copy, modify,
|
|
6
|
+
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
|
|
7
|
+
permit persons to whom the Software is furnished to do so, subject to the following
|
|
8
|
+
conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies
|
|
11
|
+
or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
14
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
|
15
|
+
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
16
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
17
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
|
18
|
+
OR OTHER DEALINGS IN THE SOFTWARE.
|
nwn-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nwn
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: collection of modules for working with Neverwinter Nights 1: Enhanced Edition
|
|
5
|
+
Project-URL: Source Code, https://github.com/niv/nwn.py
|
|
6
|
+
Project-URL: Issue Tracker, https://github.com/niv/nwn.py/issues
|
|
7
|
+
Project-URL: Documentation, https://niv.github.io/nwn.py/
|
|
8
|
+
Author-email: Bernhard Stoeckner <n@e-ix.net>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Games/Entertainment :: Role-Playing
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/x-rst
|
|
17
|
+
|
|
18
|
+
nwn
|
|
19
|
+
===
|
|
20
|
+
|
|
21
|
+
A python package with various modules for accessing Neverwinter Nights: Enhanced Edition
|
|
22
|
+
data formats and functionality.
|
|
23
|
+
|
|
24
|
+
Stability
|
|
25
|
+
---------
|
|
26
|
+
|
|
27
|
+
This package is currently in ALPHA state. API stability is not guaranteed.
|
|
28
|
+
|
|
29
|
+
Documentation
|
|
30
|
+
-------------
|
|
31
|
+
|
|
32
|
+
See the `documentation <https://niv.github.io/nwn.py/>`_ for usage.
|
nwn-0.0.2/README.rst
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
nwn
|
|
2
|
+
===
|
|
3
|
+
|
|
4
|
+
A python package with various modules for accessing Neverwinter Nights: Enhanced Edition
|
|
5
|
+
data formats and functionality.
|
|
6
|
+
|
|
7
|
+
Stability
|
|
8
|
+
---------
|
|
9
|
+
|
|
10
|
+
This package is currently in ALPHA state. API stability is not guaranteed.
|
|
11
|
+
|
|
12
|
+
Documentation
|
|
13
|
+
-------------
|
|
14
|
+
|
|
15
|
+
See the `documentation <https://niv.github.io/nwn.py/>`_ for usage.
|
nwn-0.0.2/pyproject.toml
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nwn"
|
|
3
|
+
version = "0.0.2"
|
|
4
|
+
description = "collection of modules for working with Neverwinter Nights 1: Enhanced Edition"
|
|
5
|
+
readme = "README.rst"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Bernhard Stoeckner", email = "n@e-ix.net" }]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = []
|
|
10
|
+
classifiers = [
|
|
11
|
+
"License :: OSI Approved :: MIT License",
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
14
|
+
"Topic :: Games/Entertainment :: Role-Playing",
|
|
15
|
+
]
|
|
16
|
+
[project.urls]
|
|
17
|
+
"Source Code" = "https://github.com/niv/nwn.py"
|
|
18
|
+
"Issue Tracker" = "https://github.com/niv/nwn.py/issues"
|
|
19
|
+
"Documentation" = "https://niv.github.io/nwn.py/"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = ["pydoctor>=24.11.2", "pytest>=8.3.4"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
pythonpath = ["src"]
|
|
30
|
+
addopts = ["--import-mode=importlib"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build]
|
|
33
|
+
include = ["src/**", "README.rst", "LICENSE"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/nwn"]
|
|
37
|
+
|
|
38
|
+
[tool.pydoctor]
|
|
39
|
+
add-package = ["src/nwn"]
|
|
40
|
+
docformat = "google"
|
nwn-0.0.2/src/nwn/erf.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Read and write ERF (Encapsulated Resource Format) archives."""
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from typing import NamedTuple, IO
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from datetime import date, timedelta
|
|
7
|
+
|
|
8
|
+
from nwn.shared import (
|
|
9
|
+
get_nwn_encoding,
|
|
10
|
+
Language,
|
|
11
|
+
restype_to_extension,
|
|
12
|
+
extension_to_restype,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Reader:
|
|
17
|
+
class Version(Enum):
|
|
18
|
+
V_1_0 = "V1.0"
|
|
19
|
+
# E_1_0 = "E1.0"
|
|
20
|
+
|
|
21
|
+
class Header(NamedTuple):
|
|
22
|
+
file_type: str
|
|
23
|
+
file_version: "Reader.Version"
|
|
24
|
+
locstr_count: int
|
|
25
|
+
locstr_sz: int
|
|
26
|
+
entry_count: int
|
|
27
|
+
offset_to_locstr: int
|
|
28
|
+
offset_to_keylist: int
|
|
29
|
+
offset_to_reslist: int
|
|
30
|
+
build_year: int
|
|
31
|
+
build_day: int
|
|
32
|
+
description_strref: int
|
|
33
|
+
|
|
34
|
+
class Entry(NamedTuple):
|
|
35
|
+
original_filename: str
|
|
36
|
+
resref: int
|
|
37
|
+
offset: int
|
|
38
|
+
disk_size: int
|
|
39
|
+
uncompressed_size: int
|
|
40
|
+
|
|
41
|
+
def _seek(self, relative_to_start):
|
|
42
|
+
self._file.seek(self._root_offset + relative_to_start)
|
|
43
|
+
|
|
44
|
+
def __init__(self, file, max_entries=65535, max_locstr=100):
|
|
45
|
+
self._file = file
|
|
46
|
+
self._root_offset = self._file.tell()
|
|
47
|
+
|
|
48
|
+
ft = self._file.read(4).decode("ASCII")
|
|
49
|
+
fv = self.Version(self._file.read(4).decode("ASCII"))
|
|
50
|
+
va = struct.unpack("IIIIIIIII", self._file.read(36))
|
|
51
|
+
self._header = self.Header(ft, fv, *va)
|
|
52
|
+
|
|
53
|
+
if self._header.entry_count > max_entries:
|
|
54
|
+
raise ValueError("Too many resources")
|
|
55
|
+
|
|
56
|
+
if self._header.locstr_count > max_locstr:
|
|
57
|
+
raise ValueError("Too many localized strings")
|
|
58
|
+
|
|
59
|
+
self._seek(self._header.offset_to_locstr)
|
|
60
|
+
loc_str = {}
|
|
61
|
+
for _ in range(self._header.locstr_count):
|
|
62
|
+
lid = struct.unpack("I", self._file.read(4))[0]
|
|
63
|
+
sz = struct.unpack("I", self._file.read(4))[0]
|
|
64
|
+
st = self._file.read(sz).decode(get_nwn_encoding())
|
|
65
|
+
loc_str[lid] = st
|
|
66
|
+
|
|
67
|
+
self._seek(self._header.offset_to_reslist)
|
|
68
|
+
resources = []
|
|
69
|
+
for _ in range(self._header.entry_count):
|
|
70
|
+
offset = struct.unpack("i", self._file.read(4))[0]
|
|
71
|
+
disk_size = struct.unpack("i", self._file.read(4))[0]
|
|
72
|
+
uncompressed = disk_size
|
|
73
|
+
resources.append((offset, disk_size, uncompressed))
|
|
74
|
+
|
|
75
|
+
self._seek(self._header.offset_to_keylist)
|
|
76
|
+
keys = []
|
|
77
|
+
for _ in range(self._header.entry_count):
|
|
78
|
+
resref = self._file.read(16).split(b"\x00")[0].decode("ASCII")
|
|
79
|
+
_ = struct.unpack("I", self._file.read(4))[0] # res_id unused
|
|
80
|
+
res_type = struct.unpack("H", self._file.read(2))[0]
|
|
81
|
+
_ = self._file.read(2) # unused
|
|
82
|
+
keys.append((resref, res_type))
|
|
83
|
+
|
|
84
|
+
self._localized_strings = loc_str
|
|
85
|
+
|
|
86
|
+
self._files = {
|
|
87
|
+
f"{resref.lower()}.{restype_to_extension(restype)}": self.Entry(
|
|
88
|
+
resref, restype, o, d, u
|
|
89
|
+
)
|
|
90
|
+
for (resref, restype), (o, d, u) in zip(keys, resources)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def build_date(self):
|
|
95
|
+
return date(1900 + self._header.build_year, 1, 1) + timedelta(
|
|
96
|
+
days=self._header.build_day
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def localized_strings(self) -> dict[Language, str]:
|
|
101
|
+
return self._localized_strings
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def description_strref(self):
|
|
105
|
+
return self._header.description_strref
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def filenames(self) -> list[str]:
|
|
109
|
+
"""
|
|
110
|
+
Property that returns the filenames in the ERF archive.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
list[str]: A list of filenames present in the ERF archive.
|
|
114
|
+
"""
|
|
115
|
+
return list(self._files.keys())
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def filemap(self):
|
|
119
|
+
"""
|
|
120
|
+
Returns the mapping of files.
|
|
121
|
+
|
|
122
|
+
This method returns the internal dictionary that maps file names to their
|
|
123
|
+
corresponding file data.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
dict[str, Entry]: File names mapping to internal Entry tuple.
|
|
127
|
+
"""
|
|
128
|
+
return self._files
|
|
129
|
+
|
|
130
|
+
def read_file(self, filename: str) -> bytes:
|
|
131
|
+
"""
|
|
132
|
+
Retrieve the contents of a file from the archive.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
filename: The name of the file to retrieve.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
bytes: The contents of the file as a byte string.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
KeyError: If the file is not found in the archive.
|
|
142
|
+
ValueError: If the filename is of a unknown restype.
|
|
143
|
+
"""
|
|
144
|
+
resource = self._files[filename.lower()]
|
|
145
|
+
self._seek(resource.offset)
|
|
146
|
+
return self._file.read(resource.disk_size)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Writer:
|
|
150
|
+
class Entry(NamedTuple):
|
|
151
|
+
resref: str
|
|
152
|
+
restype: int
|
|
153
|
+
offset: int
|
|
154
|
+
size: int
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
file,
|
|
159
|
+
file_type="ERF ",
|
|
160
|
+
build_date=date.today(),
|
|
161
|
+
):
|
|
162
|
+
self._file = file
|
|
163
|
+
self._entries = []
|
|
164
|
+
self._locstr = {}
|
|
165
|
+
|
|
166
|
+
self._file_type = file_type
|
|
167
|
+
self._build_year = build_date.year - 1900
|
|
168
|
+
self._build_day = build_date.timetuple().tm_yday - 1
|
|
169
|
+
|
|
170
|
+
def __enter__(self):
|
|
171
|
+
self._file.write(self._file_type.encode("ASCII"))
|
|
172
|
+
self._file.write(Reader.Version.V_1_0.value.encode("ASCII"))
|
|
173
|
+
self._file.write(b"\x00" * 36)
|
|
174
|
+
self._file.write(b"\x00" * 116) # reserved bytes as per spec
|
|
175
|
+
assert self._file.tell() == 160
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
def add_localized_string(self, lang, text):
|
|
179
|
+
self._locstr[lang] = text
|
|
180
|
+
|
|
181
|
+
def add_file(self, filename, data: bytes | IO):
|
|
182
|
+
if hasattr(data, "read"):
|
|
183
|
+
data = data.read()
|
|
184
|
+
|
|
185
|
+
offset = self._file.tell()
|
|
186
|
+
size = len(data)
|
|
187
|
+
# ensure we have a restype
|
|
188
|
+
base, ext = filename.split(".")
|
|
189
|
+
if len(base) > 16:
|
|
190
|
+
raise ValueError("Resource name too long")
|
|
191
|
+
rt = extension_to_restype(ext)
|
|
192
|
+
self._entries.append(Writer.Entry(base, rt, offset, size))
|
|
193
|
+
self._file.write(data)
|
|
194
|
+
assert self._file.tell() == offset + size
|
|
195
|
+
|
|
196
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
197
|
+
if exc_type is not None:
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
locstr_offset = self._file.tell()
|
|
201
|
+
for lid, text in self._locstr.items():
|
|
202
|
+
encoded_text = text.encode(get_nwn_encoding())
|
|
203
|
+
self._file.write(struct.pack("I", lid))
|
|
204
|
+
self._file.write(struct.pack("I", len(encoded_text)))
|
|
205
|
+
self._file.write(encoded_text)
|
|
206
|
+
locstr_size = self._file.tell() - locstr_offset
|
|
207
|
+
|
|
208
|
+
keylist_offset = self._file.tell()
|
|
209
|
+
for resref, restype, offset, size in self._entries:
|
|
210
|
+
res_ref = resref.ljust(16, "\x00").encode("ASCII")
|
|
211
|
+
res_id = 0 # res_id unused
|
|
212
|
+
self._file.write(res_ref)
|
|
213
|
+
self._file.write(struct.pack("I", res_id))
|
|
214
|
+
self._file.write(struct.pack("H", restype))
|
|
215
|
+
self._file.write(b"\x00\x00") # Unused
|
|
216
|
+
|
|
217
|
+
reslist_offset = self._file.tell()
|
|
218
|
+
for _, _, offset, size in self._entries:
|
|
219
|
+
self._file.write(struct.pack("i", offset))
|
|
220
|
+
self._file.write(struct.pack("i", size))
|
|
221
|
+
|
|
222
|
+
eof_offset = self._file.tell()
|
|
223
|
+
|
|
224
|
+
self._file.seek(8)
|
|
225
|
+
self._file.write(
|
|
226
|
+
struct.pack(
|
|
227
|
+
"IIIIIIIII",
|
|
228
|
+
len(self._locstr),
|
|
229
|
+
locstr_size,
|
|
230
|
+
len(self._entries),
|
|
231
|
+
locstr_offset,
|
|
232
|
+
keylist_offset,
|
|
233
|
+
reslist_offset,
|
|
234
|
+
self._build_year,
|
|
235
|
+
self._build_day,
|
|
236
|
+
0, # description_strref
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
self._file.seek(eof_offset)
|
|
240
|
+
return False
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Transform GFF (Generic File Format) files from/to native python types.
|
|
3
|
+
|
|
4
|
+
Python representation is (Struct) and (List) objects, which can be nested.
|
|
5
|
+
They behave just like native python dictionaries and lists, but with some
|
|
6
|
+
additional methods and properties to make life easier.
|
|
7
|
+
|
|
8
|
+
Since GFF has strong typing beyond what python offers natively, the module
|
|
9
|
+
provides a number of custom types to represent the various field types that
|
|
10
|
+
can be found in a GFF file.
|
|
11
|
+
|
|
12
|
+
All field types are subclasses of the native python types, and are used to
|
|
13
|
+
enforce the GFF type system.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from nwn.gff._reader import read
|
|
17
|
+
from nwn.gff._types import (
|
|
18
|
+
Byte,
|
|
19
|
+
Char,
|
|
20
|
+
Word,
|
|
21
|
+
Short,
|
|
22
|
+
Dword,
|
|
23
|
+
Int,
|
|
24
|
+
Dword64,
|
|
25
|
+
Int64,
|
|
26
|
+
Float,
|
|
27
|
+
Double,
|
|
28
|
+
CExoString,
|
|
29
|
+
ResRef,
|
|
30
|
+
Struct,
|
|
31
|
+
List,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"read",
|
|
37
|
+
"Byte",
|
|
38
|
+
"Char",
|
|
39
|
+
"Word",
|
|
40
|
+
"Short",
|
|
41
|
+
"Dword",
|
|
42
|
+
"Int",
|
|
43
|
+
"Dword64",
|
|
44
|
+
"Int64",
|
|
45
|
+
"Float",
|
|
46
|
+
"Double",
|
|
47
|
+
"CExoString",
|
|
48
|
+
"ResRef",
|
|
49
|
+
"List",
|
|
50
|
+
"Struct",
|
|
51
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
from typing import NamedTuple
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class FieldKind(IntEnum):
|
|
6
|
+
BYTE = 0
|
|
7
|
+
CHAR = 1
|
|
8
|
+
WORD = 2
|
|
9
|
+
SHORT = 3
|
|
10
|
+
DWORD = 4
|
|
11
|
+
INT = 5
|
|
12
|
+
DWORD64 = 6
|
|
13
|
+
INT64 = 7
|
|
14
|
+
FLOAT = 8
|
|
15
|
+
DOUBLE = 9
|
|
16
|
+
CEXOSTRING = 10
|
|
17
|
+
RESREF = 11
|
|
18
|
+
CEXOLOCSTRING = 12
|
|
19
|
+
VOID = 13
|
|
20
|
+
STRUCT = 14
|
|
21
|
+
LIST = 15
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Header(NamedTuple):
|
|
25
|
+
file_type: str
|
|
26
|
+
file_version: str
|
|
27
|
+
struct_offset: int
|
|
28
|
+
struct_count: int
|
|
29
|
+
field_offset: int
|
|
30
|
+
field_count: int
|
|
31
|
+
label_offset: int
|
|
32
|
+
label_count: int
|
|
33
|
+
field_data_offset: int
|
|
34
|
+
field_data_size: int
|
|
35
|
+
field_indices_offset: int
|
|
36
|
+
field_indices_size: int
|
|
37
|
+
list_indices_offset: int
|
|
38
|
+
list_indices_size: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StructEntry(NamedTuple):
|
|
42
|
+
id: int
|
|
43
|
+
data_or_offset: int
|
|
44
|
+
field_count: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FieldEntry(NamedTuple):
|
|
48
|
+
type: FieldKind
|
|
49
|
+
label_index: int
|
|
50
|
+
data_or_offset: int
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import struct
|
|
2
|
+
from typing import BinaryIO
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
from nwn.shared import get_nwn_encoding, CExoLocString
|
|
6
|
+
from nwn.gff._types import (
|
|
7
|
+
Byte,
|
|
8
|
+
Char,
|
|
9
|
+
Word,
|
|
10
|
+
Short,
|
|
11
|
+
Dword,
|
|
12
|
+
Int,
|
|
13
|
+
Float,
|
|
14
|
+
CExoString,
|
|
15
|
+
ResRef,
|
|
16
|
+
Struct,
|
|
17
|
+
List,
|
|
18
|
+
)
|
|
19
|
+
from nwn.gff._impl import FieldKind, Header, FieldEntry, StructEntry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read(file: BinaryIO):
|
|
23
|
+
"""
|
|
24
|
+
Read a GFF data from a binary stream.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
file: The binary stream to read from.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
(Struct, str): The root structure of the GFF file and the file type.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
root_offset = file.tell()
|
|
34
|
+
labels = []
|
|
35
|
+
fields = []
|
|
36
|
+
structs = []
|
|
37
|
+
list_indices = []
|
|
38
|
+
field_indices = []
|
|
39
|
+
resolved_structs = {}
|
|
40
|
+
struct_parents = {}
|
|
41
|
+
|
|
42
|
+
header = Header(
|
|
43
|
+
file.read(4).decode("ascii"),
|
|
44
|
+
file.read(4).decode("ascii"),
|
|
45
|
+
*struct.unpack("<12i", file.read(48)),
|
|
46
|
+
)
|
|
47
|
+
if header.file_version != "V3.2":
|
|
48
|
+
raise ValueError(f"Unsupported GFF version: {header.file_version}")
|
|
49
|
+
|
|
50
|
+
file.seek(root_offset + header.label_offset)
|
|
51
|
+
for _ in range(header.label_count):
|
|
52
|
+
labels.append(file.read(16).split(b"\x00")[0].decode("ascii"))
|
|
53
|
+
|
|
54
|
+
file.seek(root_offset + header.field_offset)
|
|
55
|
+
for _ in range(header.field_count):
|
|
56
|
+
data = struct.unpack("<III", file.read(12))
|
|
57
|
+
fields.append(FieldEntry(FieldKind(data[0]), data[1], data[2]))
|
|
58
|
+
|
|
59
|
+
file.seek(root_offset + header.field_indices_offset)
|
|
60
|
+
for _ in range(header.field_indices_size // 4):
|
|
61
|
+
field_indices.append(struct.unpack("<I", file.read(4))[0])
|
|
62
|
+
|
|
63
|
+
file.seek(root_offset + header.list_indices_offset)
|
|
64
|
+
for _ in range(header.list_indices_size // 4):
|
|
65
|
+
list_indices.append(struct.unpack("<I", file.read(4))[0])
|
|
66
|
+
|
|
67
|
+
file.seek(root_offset + header.struct_offset)
|
|
68
|
+
for _ in range(header.struct_count):
|
|
69
|
+
data = struct.unpack("<III", file.read(12))
|
|
70
|
+
structs.append(StructEntry(data[0], data[1], data[2]))
|
|
71
|
+
|
|
72
|
+
def _read_field_value(field):
|
|
73
|
+
simple_types = {
|
|
74
|
+
FieldKind.BYTE: ("B", Byte),
|
|
75
|
+
FieldKind.CHAR: ("b", Char),
|
|
76
|
+
FieldKind.WORD: ("H", Word),
|
|
77
|
+
FieldKind.SHORT: ("h", Short),
|
|
78
|
+
FieldKind.DWORD: ("I", Dword),
|
|
79
|
+
FieldKind.INT: ("i", Int),
|
|
80
|
+
FieldKind.FLOAT: ("f", Float),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if field.type in simple_types:
|
|
84
|
+
us, cls = simple_types[field.type]
|
|
85
|
+
data = struct.pack("<I", field.data_or_offset)[: struct.calcsize(us)]
|
|
86
|
+
up = struct.unpack("<" + us, data)
|
|
87
|
+
return cls(up[0])
|
|
88
|
+
|
|
89
|
+
file.seek(root_offset + header.field_data_offset + field.data_or_offset)
|
|
90
|
+
|
|
91
|
+
if field.type == FieldKind.CEXOSTRING:
|
|
92
|
+
sz = struct.unpack("<I", file.read(4))[0]
|
|
93
|
+
if sz > 0xFFFF:
|
|
94
|
+
raise ValueError("String too long")
|
|
95
|
+
return CExoString(file.read(sz).decode(get_nwn_encoding()))
|
|
96
|
+
|
|
97
|
+
if field.type == FieldKind.RESREF:
|
|
98
|
+
sz = struct.unpack("<b", file.read(1))[0]
|
|
99
|
+
if sz > 16:
|
|
100
|
+
raise ValueError("Resref too long")
|
|
101
|
+
return ResRef(file.read(sz).decode(get_nwn_encoding()))
|
|
102
|
+
|
|
103
|
+
if field.type == FieldKind.CEXOLOCSTRING:
|
|
104
|
+
_ = struct.unpack("<I", file.read(4))[0]
|
|
105
|
+
strref = struct.unpack("<I", file.read(4))[0]
|
|
106
|
+
count = struct.unpack("<I", file.read(4))[0]
|
|
107
|
+
entries = {}
|
|
108
|
+
for _ in range(count):
|
|
109
|
+
fid = struct.unpack("<I", file.read(4))[0]
|
|
110
|
+
sz = struct.unpack("<I", file.read(4))[0]
|
|
111
|
+
entries[fid] = file.read(sz).decode(get_nwn_encoding())
|
|
112
|
+
return CExoLocString(strref, entries)
|
|
113
|
+
|
|
114
|
+
if field.type == FieldKind.LIST:
|
|
115
|
+
offset = field.data_or_offset // 4
|
|
116
|
+
size = list_indices[offset]
|
|
117
|
+
start = offset + 1
|
|
118
|
+
end = start + size
|
|
119
|
+
|
|
120
|
+
return List(*[_read_struct(field, lid) for lid in list_indices[start:end]])
|
|
121
|
+
|
|
122
|
+
if field.type == FieldKind.STRUCT:
|
|
123
|
+
return _read_struct(field, field.data_or_offset)
|
|
124
|
+
|
|
125
|
+
raise NotImplementedError(f"Field type {field.type} not implemented")
|
|
126
|
+
|
|
127
|
+
def _read_struct(parent, struct_idx) -> Struct:
|
|
128
|
+
if struct_idx in resolved_structs:
|
|
129
|
+
if struct_parents[struct_idx] != parent:
|
|
130
|
+
raise ValueError("Struct already resolved with different parent")
|
|
131
|
+
return resolved_structs[struct_idx]
|
|
132
|
+
|
|
133
|
+
struct_entry = structs[struct_idx]
|
|
134
|
+
|
|
135
|
+
if struct_entry.field_count == 1:
|
|
136
|
+
field_array_indices = [struct_entry.data_or_offset]
|
|
137
|
+
else:
|
|
138
|
+
start = struct_entry.data_or_offset // 4
|
|
139
|
+
end = start + struct_entry.field_count
|
|
140
|
+
if end < start:
|
|
141
|
+
raise ValueError("Field index array out of bounds")
|
|
142
|
+
|
|
143
|
+
field_array_indices = field_indices[start:end]
|
|
144
|
+
|
|
145
|
+
resolved_structs[struct_idx] = Struct(
|
|
146
|
+
struct_entry.id,
|
|
147
|
+
**{
|
|
148
|
+
labels[fld.label_index]: _read_field_value(fld)
|
|
149
|
+
for fld in map(lambda x: fields[x], field_array_indices)
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
struct_parents[struct_idx] = parent
|
|
154
|
+
return resolved_structs[struct_idx]
|
|
155
|
+
|
|
156
|
+
root = _read_struct(None, 0)
|
|
157
|
+
return root, header.file_type
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
class Byte(int):
|
|
2
|
+
def __new__(cls, value):
|
|
3
|
+
if not 0 <= value <= 255:
|
|
4
|
+
raise ValueError(f"BYTE value out of bounds: {value}")
|
|
5
|
+
return super().__new__(cls, value)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Char(int):
|
|
9
|
+
def __new__(cls, value):
|
|
10
|
+
if not -128 <= value <= 127:
|
|
11
|
+
raise ValueError(f"CHAR value out of bounds: {value}")
|
|
12
|
+
return super().__new__(cls, value)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Word(int):
|
|
16
|
+
def __new__(cls, value):
|
|
17
|
+
if not 0 <= value <= 65535:
|
|
18
|
+
raise ValueError(f"WORD value out of bounds: {value}")
|
|
19
|
+
return super().__new__(cls, value)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Short(int):
|
|
23
|
+
def __new__(cls, value):
|
|
24
|
+
if not -32768 <= value <= 32767:
|
|
25
|
+
raise ValueError(f"SHORT value out of bounds: {value}")
|
|
26
|
+
return super().__new__(cls, value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Dword(int):
|
|
30
|
+
def __new__(cls, value):
|
|
31
|
+
if not 0 <= value <= 4294967295:
|
|
32
|
+
raise ValueError(f"DWORD value out of bounds: {value}")
|
|
33
|
+
return super().__new__(cls, value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Int(int):
|
|
37
|
+
def __new__(cls, value):
|
|
38
|
+
if not -2147483648 <= value <= 2147483647:
|
|
39
|
+
raise ValueError(f"INT value out of bounds: {value}")
|
|
40
|
+
return super().__new__(cls, value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Dword64(int):
|
|
44
|
+
def __new__(cls, value):
|
|
45
|
+
if not 0 <= value <= 18446744073709551615:
|
|
46
|
+
raise ValueError(f"DWORD64 value out of bounds: {value}")
|
|
47
|
+
return super().__new__(cls, value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Int64(int):
|
|
51
|
+
def __new__(cls, value):
|
|
52
|
+
if not -9223372036854775808 <= value <= 9223372036854775807:
|
|
53
|
+
raise ValueError(f"INT64 value out of bounds: {value}")
|
|
54
|
+
return super().__new__(cls, value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Float(float):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Double(float):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CExoString(str):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ResRef(str):
|
|
70
|
+
def __new__(cls, value):
|
|
71
|
+
if len(value) > 16:
|
|
72
|
+
raise ValueError(f"ResRef value too long: {value}")
|
|
73
|
+
return super().__new__(cls, value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Struct(dict):
|
|
77
|
+
"""GFF Structs are just python dicts with .attr access and some metadata."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, struct_id, **kwargs):
|
|
80
|
+
super().__init__(**kwargs)
|
|
81
|
+
object.__setattr__(self, "_struct_id", struct_id)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def struct_id(self):
|
|
85
|
+
"""The struct ID of the struct."""
|
|
86
|
+
return object.__getattribute__(self, "_struct_id")
|
|
87
|
+
|
|
88
|
+
def __getattr__(self, item):
|
|
89
|
+
try:
|
|
90
|
+
return self[item]
|
|
91
|
+
except KeyError as exc:
|
|
92
|
+
raise AttributeError(
|
|
93
|
+
f"'{self.__class__.__name__}' object has no attribute '{item}'"
|
|
94
|
+
) from exc
|
|
95
|
+
|
|
96
|
+
def __setattr__(self, name, value):
|
|
97
|
+
self[name] = value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class List(list[Struct]):
|
|
101
|
+
"""
|
|
102
|
+
GFF Lists are just python lists of Structs. They carry no metadata.
|
|
103
|
+
|
|
104
|
+
This class exists as a convenience for type checking and
|
|
105
|
+
future extensibility.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, *args, **kwargs):
|
|
109
|
+
super().__init__(*args, **kwargs)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Shared types and helpers useful across the whole library."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
from typing import NamedTuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CExoLocString(NamedTuple):
|
|
8
|
+
"""Represents a localized string in the NWN engine."""
|
|
9
|
+
|
|
10
|
+
strref: int
|
|
11
|
+
entries: dict[int, str]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Language(IntEnum):
|
|
15
|
+
"""Maps engine language IDs."""
|
|
16
|
+
|
|
17
|
+
ENGLISH = 0
|
|
18
|
+
FRENCH = 1
|
|
19
|
+
GERMAN = 2
|
|
20
|
+
ITALIAN = 3
|
|
21
|
+
SPANISH = 4
|
|
22
|
+
POLISH = 5
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_nwn_encoding():
|
|
26
|
+
"""
|
|
27
|
+
A stand-in to enable dynamic configuration later.
|
|
28
|
+
|
|
29
|
+
Currently hardcoded to "windows-1252".
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
str: The encoding used by NWN.
|
|
33
|
+
"""
|
|
34
|
+
return "windows-1252"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_restype_to_extension = {
|
|
38
|
+
0: "res",
|
|
39
|
+
1: "bmp",
|
|
40
|
+
2: "mve",
|
|
41
|
+
3: "tga",
|
|
42
|
+
4: "wav",
|
|
43
|
+
5: "wfx",
|
|
44
|
+
6: "plt",
|
|
45
|
+
7: "ini",
|
|
46
|
+
8: "bmu",
|
|
47
|
+
9: "mpg",
|
|
48
|
+
10: "txt",
|
|
49
|
+
2000: "plh",
|
|
50
|
+
2001: "tex",
|
|
51
|
+
2002: "mdl",
|
|
52
|
+
2003: "thg",
|
|
53
|
+
2005: "fnt",
|
|
54
|
+
2007: "lua",
|
|
55
|
+
2008: "slt",
|
|
56
|
+
2009: "nss",
|
|
57
|
+
2010: "ncs",
|
|
58
|
+
2011: "mod",
|
|
59
|
+
2012: "are",
|
|
60
|
+
2013: "set",
|
|
61
|
+
2014: "ifo",
|
|
62
|
+
2015: "bic",
|
|
63
|
+
2016: "wok",
|
|
64
|
+
2017: "2da",
|
|
65
|
+
2018: "tlk",
|
|
66
|
+
2022: "txi",
|
|
67
|
+
2023: "git",
|
|
68
|
+
2024: "bti",
|
|
69
|
+
2025: "uti",
|
|
70
|
+
2026: "btc",
|
|
71
|
+
2027: "utc",
|
|
72
|
+
2029: "dlg",
|
|
73
|
+
2030: "itp",
|
|
74
|
+
2031: "btt",
|
|
75
|
+
2032: "utt",
|
|
76
|
+
2033: "dds",
|
|
77
|
+
2034: "bts",
|
|
78
|
+
2035: "uts",
|
|
79
|
+
2036: "ltr",
|
|
80
|
+
2037: "gff",
|
|
81
|
+
2038: "fac",
|
|
82
|
+
2039: "bte",
|
|
83
|
+
2040: "ute",
|
|
84
|
+
2041: "btd",
|
|
85
|
+
2042: "utd",
|
|
86
|
+
2043: "btp",
|
|
87
|
+
2044: "utp",
|
|
88
|
+
2045: "dft",
|
|
89
|
+
2046: "gic",
|
|
90
|
+
2047: "gui",
|
|
91
|
+
2048: "css",
|
|
92
|
+
2049: "ccs",
|
|
93
|
+
2050: "btm",
|
|
94
|
+
2051: "utm",
|
|
95
|
+
2052: "dwk",
|
|
96
|
+
2053: "pwk",
|
|
97
|
+
2054: "btg",
|
|
98
|
+
2055: "utg",
|
|
99
|
+
2056: "jrl",
|
|
100
|
+
2057: "sav",
|
|
101
|
+
2058: "utw",
|
|
102
|
+
2059: "4pc",
|
|
103
|
+
2060: "ssf",
|
|
104
|
+
2061: "hak",
|
|
105
|
+
2062: "nwm",
|
|
106
|
+
2063: "bik",
|
|
107
|
+
2064: "ndb",
|
|
108
|
+
2065: "ptm",
|
|
109
|
+
2066: "ptt",
|
|
110
|
+
2067: "bak",
|
|
111
|
+
2068: "dat",
|
|
112
|
+
2069: "shd",
|
|
113
|
+
2070: "xbc",
|
|
114
|
+
2071: "wbm",
|
|
115
|
+
2072: "mtr",
|
|
116
|
+
2073: "ktx",
|
|
117
|
+
2074: "ttf",
|
|
118
|
+
2075: "sql",
|
|
119
|
+
2076: "tml",
|
|
120
|
+
2077: "sq3",
|
|
121
|
+
2078: "lod",
|
|
122
|
+
2079: "gif",
|
|
123
|
+
2080: "png",
|
|
124
|
+
2081: "jpg",
|
|
125
|
+
2082: "caf",
|
|
126
|
+
2083: "jui",
|
|
127
|
+
9996: "ids",
|
|
128
|
+
9997: "erf",
|
|
129
|
+
9998: "bif",
|
|
130
|
+
9999: "key",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def restype_to_extension(restype: int) -> str:
|
|
135
|
+
"""
|
|
136
|
+
Convert a resource type to its corresponding file extension.
|
|
137
|
+
Args:
|
|
138
|
+
restype: The resource type to convert.
|
|
139
|
+
Returns:
|
|
140
|
+
str: The corresponding file extension for the given resource type.
|
|
141
|
+
Raises:
|
|
142
|
+
ValueError: If the given resource type is unknown.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
return _restype_to_extension[restype]
|
|
146
|
+
except KeyError as e:
|
|
147
|
+
raise ValueError(f"Unknown restype: {restype}") from e
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def extension_to_restype(extension: str) -> int:
|
|
151
|
+
"""
|
|
152
|
+
Convert a file extension to its corresponding resource type identifier.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
extension: The file extension to convert.
|
|
156
|
+
Returns:
|
|
157
|
+
int: The resource type identifier corresponding to the given extension.
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: If the extension is not recognized.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
return {v: k for k, v in _restype_to_extension.items()}[extension]
|
|
164
|
+
except KeyError as e:
|
|
165
|
+
raise ValueError(f"Unknown extension: {extension}") from e
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""A parser for .set files (tilesets configuration)."""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import re
|
|
6
|
+
from typing import BinaryIO
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Terrain:
|
|
11
|
+
name: str = ""
|
|
12
|
+
strref: int = 0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Crosser:
|
|
17
|
+
name: str = ""
|
|
18
|
+
strref: int = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Rule:
|
|
23
|
+
placed: str
|
|
24
|
+
placedheight: int
|
|
25
|
+
adjacent: str
|
|
26
|
+
adjacentheight: int
|
|
27
|
+
changed: str
|
|
28
|
+
changedheight: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Door:
|
|
33
|
+
type: int
|
|
34
|
+
x: float = 0
|
|
35
|
+
y: float = 0
|
|
36
|
+
z: float = 0
|
|
37
|
+
orientation: float = 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Tile:
|
|
42
|
+
doors: list[Door]
|
|
43
|
+
|
|
44
|
+
model: str
|
|
45
|
+
walkmesh: str = ""
|
|
46
|
+
topleft: str = ""
|
|
47
|
+
topleftheight: int = 0
|
|
48
|
+
topright: str = ""
|
|
49
|
+
toprightheight: int = 0
|
|
50
|
+
bottomleft: str = ""
|
|
51
|
+
bottomleftheight: int = 0
|
|
52
|
+
bottomright: str = ""
|
|
53
|
+
bottomrightheight: int = 0
|
|
54
|
+
top: str = ""
|
|
55
|
+
right: str = ""
|
|
56
|
+
bottom: str = ""
|
|
57
|
+
left: str = ""
|
|
58
|
+
mainlight1: int = 0
|
|
59
|
+
mainlight2: int = 0
|
|
60
|
+
sourcelight1: int = 0
|
|
61
|
+
sourcelight2: int = 0
|
|
62
|
+
animloop1: int = 0
|
|
63
|
+
animloop2: int = 0
|
|
64
|
+
animloop3: int = 0
|
|
65
|
+
sounds: int = 0
|
|
66
|
+
pathnode: str = ""
|
|
67
|
+
orientation: float = 0
|
|
68
|
+
imagemap2d: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Group:
|
|
73
|
+
name: str
|
|
74
|
+
rows: int
|
|
75
|
+
columns: int
|
|
76
|
+
tiles: list[int] = field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class SetGrass:
|
|
81
|
+
grass: int
|
|
82
|
+
density: float
|
|
83
|
+
height: float
|
|
84
|
+
ambientred: float
|
|
85
|
+
ambientgreen: float
|
|
86
|
+
ambientblue: float
|
|
87
|
+
diffusered: float
|
|
88
|
+
diffusegreen: float
|
|
89
|
+
diffuseblue: float
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Set:
|
|
94
|
+
name: str
|
|
95
|
+
type: str
|
|
96
|
+
version: str
|
|
97
|
+
interior: int
|
|
98
|
+
hasheighttransition: int
|
|
99
|
+
envmap: str
|
|
100
|
+
transition: float
|
|
101
|
+
displayname: int
|
|
102
|
+
border: str
|
|
103
|
+
default: str
|
|
104
|
+
floor: str
|
|
105
|
+
|
|
106
|
+
grass: SetGrass = None
|
|
107
|
+
terrains: list[Terrain] = field(default_factory=list)
|
|
108
|
+
crossers: list[Crosser] = field(default_factory=list)
|
|
109
|
+
primary_rules: list[Rule] = field(default_factory=list)
|
|
110
|
+
tiles: list[Tile] = field(default_factory=list)
|
|
111
|
+
groups: list[Group] = field(default_factory=list)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _dataclass_name(s):
|
|
115
|
+
return "".join(x or "_" for x in s.split("_"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _read_value(ty, v):
|
|
119
|
+
if ty == int:
|
|
120
|
+
return int(v)
|
|
121
|
+
if ty == bool:
|
|
122
|
+
return v in {"1"}
|
|
123
|
+
if ty == float:
|
|
124
|
+
return float(v)
|
|
125
|
+
if ty == str:
|
|
126
|
+
return str(v)
|
|
127
|
+
if (hasattr(ty, "__origin__") and ty.__origin__ == list) or ty == list:
|
|
128
|
+
# We manually load these in
|
|
129
|
+
return []
|
|
130
|
+
raise ValueError(f"Unsupported type: {ty}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read_dataclass(cls, **kwargs):
|
|
134
|
+
return cls(
|
|
135
|
+
**{
|
|
136
|
+
field.name: _read_value(field.type, kwargs[_dataclass_name(field.name)])
|
|
137
|
+
for field in cls.__dataclass_fields__.values()
|
|
138
|
+
if _dataclass_name(field.name) in kwargs and not field.name.startswith("_")
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def read_set(file: BinaryIO) -> Set:
|
|
144
|
+
"""
|
|
145
|
+
Reads a .set file and parses its contents into a Set object.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
general = None
|
|
149
|
+
|
|
150
|
+
data = configparser.ConfigParser()
|
|
151
|
+
data.read_file(file)
|
|
152
|
+
|
|
153
|
+
for s in data.sections():
|
|
154
|
+
if s == "GENERAL":
|
|
155
|
+
general = _read_dataclass(Set, **data[s])
|
|
156
|
+
|
|
157
|
+
if not general:
|
|
158
|
+
raise ValueError("No GENERAL section found")
|
|
159
|
+
|
|
160
|
+
if s == "GRASS":
|
|
161
|
+
grass = _read_dataclass(SetGrass, **data[s])
|
|
162
|
+
general.grass = grass
|
|
163
|
+
|
|
164
|
+
if ma := re.match(r"^TERRAIN(\d+)$", s):
|
|
165
|
+
tid = int(ma.group(1))
|
|
166
|
+
terrain = _read_dataclass(Terrain, **data[s])
|
|
167
|
+
general.terrains.append(terrain)
|
|
168
|
+
|
|
169
|
+
if ma := re.match(r"^CROSSER(\d+)$", s):
|
|
170
|
+
terrain = _read_dataclass(Crosser, **data[s])
|
|
171
|
+
general.crossers.append(terrain)
|
|
172
|
+
|
|
173
|
+
if ma := re.match(r"^PRIMARY RULE(\d+)$", s):
|
|
174
|
+
rule = _read_dataclass(Rule, **data[s])
|
|
175
|
+
general.primary_rules.append(rule)
|
|
176
|
+
|
|
177
|
+
if ma := re.match(r"^TILE(\d+)$", s):
|
|
178
|
+
tid = int(ma.group(1))
|
|
179
|
+
tile = _read_dataclass(Tile, **data[s])
|
|
180
|
+
general.tiles.append(tile)
|
|
181
|
+
|
|
182
|
+
if ma := re.match(r"^TILE(\d+)DOOR(\d+)$", s):
|
|
183
|
+
tid = int(ma.group(1))
|
|
184
|
+
door = _read_dataclass(Door, **data[s])
|
|
185
|
+
tile = general.tiles[tid]
|
|
186
|
+
tile.doors.append(door)
|
|
187
|
+
|
|
188
|
+
if ma := re.match(r"^GROUP(\d+)$", s):
|
|
189
|
+
group = _read_dataclass(Group, **data[s])
|
|
190
|
+
count = group.rows * group.columns
|
|
191
|
+
for i in range(count):
|
|
192
|
+
tile = data[s][f"Tile{i}"]
|
|
193
|
+
group.tiles.append(int(tile))
|
|
194
|
+
general.groups.append(group)
|
|
195
|
+
|
|
196
|
+
return general
|