zcmodkit 0.1.0__py3-none-any.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.
- zcmodkit/__init__.py +211 -0
- zcmodkit/data/BUILD.txt +1 -0
- zcmodkit/data/mappings.usmap +0 -0
- zcmodkit/domains/__init__.py +6 -0
- zcmodkit/domains/assets.py +522 -0
- zcmodkit/domains/datatable.py +162 -0
- zcmodkit/domains/properties.py +275 -0
- zcmodkit/formats/__init__.py +17 -0
- zcmodkit/formats/iostore.py +497 -0
- zcmodkit/formats/locres.py +139 -0
- zcmodkit/formats/oodle.py +72 -0
- zcmodkit/formats/pak.py +248 -0
- zcmodkit/formats/usmap.py +328 -0
- zcmodkit/formats/zen.py +498 -0
- zcmodkit/game.py +52 -0
- zcmodkit/mod.py +117 -0
- zcmodkit-0.1.0.dist-info/METADATA +891 -0
- zcmodkit-0.1.0.dist-info/RECORD +21 -0
- zcmodkit-0.1.0.dist-info/WHEEL +5 -0
- zcmodkit-0.1.0.dist-info/licenses/LICENSE +674 -0
- zcmodkit-0.1.0.dist-info/top_level.txt +1 -0
zcmodkit/__init__.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""ZeroCompany ModKit. Build Star Wars: Zero Company mods from Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import struct
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from functools import cached_property
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import game
|
|
12
|
+
from .domains import AssetEditor
|
|
13
|
+
from .formats import (
|
|
14
|
+
IoStoreReader,
|
|
15
|
+
IoStoreWriter,
|
|
16
|
+
LocRes,
|
|
17
|
+
PakReader,
|
|
18
|
+
PakWriter,
|
|
19
|
+
package_id,
|
|
20
|
+
)
|
|
21
|
+
from .formats.iostore import CHUNK_EXPORT_BUNDLE_DATA
|
|
22
|
+
from .formats.zen import ZenPackage
|
|
23
|
+
from .mod import DEFAULT_PRIORITY, Mod
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AssetEditor",
|
|
28
|
+
"InstalledMod",
|
|
29
|
+
"IoStoreReader",
|
|
30
|
+
"IoStoreWriter",
|
|
31
|
+
"LocRes",
|
|
32
|
+
"Mod",
|
|
33
|
+
"ModKit",
|
|
34
|
+
"PakReader",
|
|
35
|
+
"PakWriter",
|
|
36
|
+
"open",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
_CONTAINERS = ("pakchunk0-Windows.utoc", "pakchunk1-Windows.utoc")
|
|
40
|
+
_BASE_PAK = "pakchunk0-Windows.pak"
|
|
41
|
+
_LOCRES = "SWZeroCompany/Content/Localization/Game/en/Game.locres"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class InstalledMod:
|
|
46
|
+
"""A mod container sat in the game's ~mods folder."""
|
|
47
|
+
|
|
48
|
+
path: Path
|
|
49
|
+
package_ids: list[int]
|
|
50
|
+
assets: list[str]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def stem(self) -> str:
|
|
54
|
+
return self.path.stem
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def priority(self) -> int:
|
|
58
|
+
"""The number the filename starts with. Lower wins. 999 if there is none."""
|
|
59
|
+
head = self.stem.split("_", 1)[0]
|
|
60
|
+
return int(head) if head.isdigit() else 999
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def name(self) -> str:
|
|
64
|
+
"""The mod name, with the priority prefix and _P suffix stripped off."""
|
|
65
|
+
head, sep, rest = self.stem.partition("_")
|
|
66
|
+
body = rest if sep and head.isdigit() else self.stem
|
|
67
|
+
return body.removesuffix("_P")
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
n = len(self.assets)
|
|
71
|
+
return f"<InstalledMod {self.priority:03d} {self.name!r} assets={n}>"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ModKit:
|
|
75
|
+
"""A read only view of the installed game, and where mods start."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, root: Path):
|
|
78
|
+
self.root = root
|
|
79
|
+
self.paks = game.paks_dir(root)
|
|
80
|
+
self._readers: list[IoStoreReader] | None = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def containers(self) -> list[IoStoreReader]:
|
|
84
|
+
"""The game's IoStore containers, opened the first time they are used."""
|
|
85
|
+
if self._readers is None:
|
|
86
|
+
self._readers = [
|
|
87
|
+
IoStoreReader(self.paks / n)
|
|
88
|
+
for n in _CONTAINERS
|
|
89
|
+
if (self.paks / n).is_file()
|
|
90
|
+
]
|
|
91
|
+
return self._readers
|
|
92
|
+
|
|
93
|
+
def read_package(self, package_path: str) -> bytes:
|
|
94
|
+
"""Read a cooked package out of the game by its /Game/... path."""
|
|
95
|
+
pid = package_id(package_path)
|
|
96
|
+
for r in self.containers:
|
|
97
|
+
chunk = r.by_package.get(pid)
|
|
98
|
+
if chunk is not None:
|
|
99
|
+
return r.read_chunk(chunk)
|
|
100
|
+
raise KeyError(f"{package_path} is not in any container")
|
|
101
|
+
|
|
102
|
+
def read_companions(self, package_path: str) -> list[tuple[bytes, bytes]]:
|
|
103
|
+
"""Every chunk a package owns bar its exports, as (chunk id, payload).
|
|
104
|
+
|
|
105
|
+
About a quarter of packages keep bulk data this way, and a mod has to
|
|
106
|
+
ship it next to the package it edits.
|
|
107
|
+
"""
|
|
108
|
+
pid = package_id(package_path)
|
|
109
|
+
for r in self.containers:
|
|
110
|
+
if pid not in r.by_package:
|
|
111
|
+
continue
|
|
112
|
+
return [
|
|
113
|
+
(c.id, r.read_chunk(c))
|
|
114
|
+
for c in r.chunks_for(pid)
|
|
115
|
+
if c.type != CHUNK_EXPORT_BUNDLE_DATA
|
|
116
|
+
]
|
|
117
|
+
raise KeyError(f"{package_path} is not in any container")
|
|
118
|
+
|
|
119
|
+
def imported_packages(self, package_path: str) -> list[int]:
|
|
120
|
+
"""The package ids a package depends on, from the game's own header.
|
|
121
|
+
|
|
122
|
+
A mod has to pass these along or the package it ships will not load.
|
|
123
|
+
"""
|
|
124
|
+
pid = package_id(package_path)
|
|
125
|
+
for r in self.containers:
|
|
126
|
+
if pid in r.by_package:
|
|
127
|
+
return r.imported_packages(pid)
|
|
128
|
+
raise KeyError(f"{package_path} is not in any container")
|
|
129
|
+
|
|
130
|
+
def public_exports(self, package_path: str) -> dict[str, int]:
|
|
131
|
+
"""The objects in a package that other packages are allowed to import.
|
|
132
|
+
|
|
133
|
+
Keyed by object name, valued by the hash the importer has to quote.
|
|
134
|
+
Exports with no hash are private to the package and left out.
|
|
135
|
+
"""
|
|
136
|
+
data = self.read_package(package_path)
|
|
137
|
+
pkg = ZenPackage(data)
|
|
138
|
+
out = {}
|
|
139
|
+
for i in range(len(pkg.exports)):
|
|
140
|
+
at = pkg.export_entry_offset(i)
|
|
141
|
+
(name_index,) = struct.unpack_from("<I", data, at + 16)
|
|
142
|
+
(export_hash,) = struct.unpack_from("<Q", data, at + 56)
|
|
143
|
+
if export_hash and name_index < len(pkg.names):
|
|
144
|
+
out[pkg.names[name_index]] = export_hash
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
def public_export_hash(
|
|
148
|
+
self, package_path: str, object_name: str | None = None
|
|
149
|
+
) -> int:
|
|
150
|
+
"""The hash needed to import one object out of a package.
|
|
151
|
+
|
|
152
|
+
Defaults to the object named after the package itself, which is the
|
|
153
|
+
main asset and nearly always the one worth importing.
|
|
154
|
+
"""
|
|
155
|
+
exports = self.public_exports(package_path)
|
|
156
|
+
wanted = object_name or package_path.rsplit("/", 1)[-1]
|
|
157
|
+
try:
|
|
158
|
+
return exports[wanted]
|
|
159
|
+
except KeyError:
|
|
160
|
+
raise KeyError(
|
|
161
|
+
f"{package_path} has no public export called {wanted!r}. "
|
|
162
|
+
f"It offers: {', '.join(sorted(exports)) or 'none'}"
|
|
163
|
+
) from None
|
|
164
|
+
|
|
165
|
+
def has_package(self, package_path: str) -> bool:
|
|
166
|
+
"""Whether the game has this package at all."""
|
|
167
|
+
pid = package_id(package_path)
|
|
168
|
+
return any(pid in r.by_package for r in self.containers)
|
|
169
|
+
|
|
170
|
+
@cached_property
|
|
171
|
+
def text(self) -> LocRes:
|
|
172
|
+
"""The game's text table. Handy for finding a string and its key."""
|
|
173
|
+
with PakReader(self.paks / _BASE_PAK) as pak:
|
|
174
|
+
return LocRes.loads(pak.read(_LOCRES))
|
|
175
|
+
|
|
176
|
+
def create_mod(self, name: str, priority: int = DEFAULT_PRIORITY) -> Mod:
|
|
177
|
+
"""Start a new mod. `priority` settles who wins if two mods clash."""
|
|
178
|
+
return Mod(self, name, priority)
|
|
179
|
+
|
|
180
|
+
def installed_mods(self) -> list[InstalledMod]:
|
|
181
|
+
"""Installed mods, best priority first, which is the order they win in."""
|
|
182
|
+
out = []
|
|
183
|
+
for utoc in (self.paks / "~mods").glob("*.utoc"):
|
|
184
|
+
with IoStoreReader(utoc) as r:
|
|
185
|
+
out.append(InstalledMod(utoc, sorted(r.by_package), list(r.files)))
|
|
186
|
+
return sorted(out, key=lambda m: (m.priority, m.name))
|
|
187
|
+
|
|
188
|
+
def conflicts(self) -> dict[str, list[InstalledMod]]:
|
|
189
|
+
"""Assets that more than one installed mod edits, keyed by file name.
|
|
190
|
+
|
|
191
|
+
Only the best-priority copy of an asset gets loaded, so anything in
|
|
192
|
+
here means one mod is quietly overriding another.
|
|
193
|
+
"""
|
|
194
|
+
owners: dict[str, list[InstalledMod]] = {}
|
|
195
|
+
for mod in self.installed_mods():
|
|
196
|
+
for name in mod.assets:
|
|
197
|
+
owners.setdefault(name, []).append(mod)
|
|
198
|
+
return {k: v for k, v in owners.items() if len(v) > 1}
|
|
199
|
+
|
|
200
|
+
def close(self) -> None:
|
|
201
|
+
for r in self._readers or []:
|
|
202
|
+
r.close()
|
|
203
|
+
self._readers = None
|
|
204
|
+
|
|
205
|
+
def __repr__(self) -> str:
|
|
206
|
+
return f"<ModKit {self.root}>"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def open(path: str | os.PathLike | None = None) -> ModKit: # noqa: A001
|
|
210
|
+
"""Open the game install. Finds Steam and Epic on its own if you let it."""
|
|
211
|
+
return ModKit(game.find_install(path))
|
zcmodkit/data/BUILD.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SWZeroCompany-5.6.1-196320+++ProjectBruno+Stable-a1e7f571
|
|
Binary file
|