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 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))
@@ -0,0 +1 @@
1
+ SWZeroCompany-5.6.1-196320+++ProjectBruno+Stable-a1e7f571
Binary file
@@ -0,0 +1,6 @@
1
+ """The editing APIs, one module per kind of game data."""
2
+
3
+ from .assets import AssetEditor
4
+ from .datatable import DataTable, DataTableError
5
+
6
+ __all__ = ["AssetEditor", "DataTable", "DataTableError"]