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 ADDED
@@ -0,0 +1,12 @@
1
+ .pytest_cache
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ .venv
9
+ .vscode
10
+ .ruff_cache
11
+ _build
12
+ _gh-pages
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.
@@ -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"
@@ -0,0 +1,4 @@
1
+ """
2
+ This package provides tools for working with Neverwinter Nights data files
3
+ and miscellaneous engine utilities.
4
+ """
@@ -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