exp-runtime-engine 0.5.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.
- exp_runtime/audio.py +13 -0
- exp_runtime/background.py +58 -0
- exp_runtime/character_picker.py +92 -0
- exp_runtime/choice.py +116 -0
- exp_runtime/choice_hints.py +153 -0
- exp_runtime/content.py +742 -0
- exp_runtime/dialogue.py +151 -0
- exp_runtime/dialogue_animation.py +345 -0
- exp_runtime/dialogue_notice.py +51 -0
- exp_runtime/engine.py +609 -0
- exp_runtime/episode_catalog.py +171 -0
- exp_runtime/game_state.py +9 -0
- exp_runtime/games.py +35 -0
- exp_runtime/ios_fonts.py +228 -0
- exp_runtime/launcher.py +92 -0
- exp_runtime/loading.py +37 -0
- exp_runtime/menu.py +177 -0
- exp_runtime/message_panel.py +36 -0
- exp_runtime/minigames.py +76 -0
- exp_runtime/randomness.py +61 -0
- exp_runtime/rar.py +145 -0
- exp_runtime/relationships.py +131 -0
- exp_runtime/runtime.py +879 -0
- exp_runtime/scene_badge.py +65 -0
- exp_runtime/speaker_names.py +258 -0
- exp_runtime/text_input.py +55 -0
- exp_runtime/title_screen.py +104 -0
- exp_runtime/trace.py +76 -0
- exp_runtime/vm.py +391 -0
- exp_runtime_engine-0.5.0.dist-info/METADATA +49 -0
- exp_runtime_engine-0.5.0.dist-info/RECORD +34 -0
- exp_runtime_engine-0.5.0.dist-info/WHEEL +5 -0
- exp_runtime_engine-0.5.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_engine-0.5.0.dist-info/top_level.txt +1 -0
exp_runtime/content.py
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
"""User-supplied game content, isolated from the distributable runtime.
|
|
2
|
+
|
|
3
|
+
APKs, IPAs and episode ZIPs/RARs are opened as data only. No supplied native code
|
|
4
|
+
is executed, and archive paths are never used as output paths. Imported files use
|
|
5
|
+
content-derived names.
|
|
6
|
+
"""
|
|
7
|
+
from contextlib import ExitStack, contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import plistlib
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import stat
|
|
17
|
+
import struct
|
|
18
|
+
import tempfile
|
|
19
|
+
from zipfile import BadZipFile, ZipFile, ZipInfo
|
|
20
|
+
from xml.parsers.expat import ExpatError
|
|
21
|
+
from zlib import error as ZlibError
|
|
22
|
+
|
|
23
|
+
from .exp import ContentError, ExpArchive, Metadata, MAX_PAYLOAD
|
|
24
|
+
from .decode.bytecode import decode_program
|
|
25
|
+
from .games import COD_IOS, GAMES, PROFILES, SHS_ANDROID, SHS_IOS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PROFILE = SHS_ANDROID
|
|
29
|
+
IOS_PROFILE = SHS_IOS
|
|
30
|
+
MAX_EPISODE_ZIP_MEMBERS = 10_000
|
|
31
|
+
MAX_EPISODE_ZIP_BYTES = 2 * 1024 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _inspect_apk(apk):
|
|
35
|
+
return GAMES['shs'].inspect_apk(apk)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def __getattr__(name):
|
|
39
|
+
# Compatibility for existing diagnostics without making all libraries
|
|
40
|
+
# import the optional Android adapter.
|
|
41
|
+
if name in ('NATIVE_MEMBER', 'NATIVE_SHA256'):
|
|
42
|
+
GAMES['shs']
|
|
43
|
+
from .shs import content
|
|
44
|
+
return getattr(content, name)
|
|
45
|
+
if name == 'IOS_DOWNLOADED_MUSIC':
|
|
46
|
+
return GAMES['shs'].DOWNLOADED_MUSIC
|
|
47
|
+
raise AttributeError(name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def digest(data: bytes) -> str:
|
|
51
|
+
return hashlib.sha256(data).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_bundled(record: dict) -> bool:
|
|
55
|
+
return any(key in record for key in ('apk_member', 'ipa_member')) or record.get('builtin') in ('football-star', 'volume-one')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _with_episode_alias(record, name):
|
|
59
|
+
"""Remember a duplicate's filename without changing its identity or source."""
|
|
60
|
+
if name.casefold() in {n.casefold() for n in (record['name'], *record.get('aliases', []))}:
|
|
61
|
+
return record
|
|
62
|
+
return dict(record, aliases=[*record.get('aliases', []), name])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def file_digest(path: Path) -> str:
|
|
66
|
+
with path.open('rb') as stream:
|
|
67
|
+
result = hashlib.sha256()
|
|
68
|
+
for block in iter(lambda: stream.read(1024 * 1024), b''):
|
|
69
|
+
result.update(block)
|
|
70
|
+
return result.hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _zip_read(apk: ZipFile, name: str | ZipInfo, *, limit=MAX_PAYLOAD) -> bytes:
|
|
75
|
+
kind = Path(str(apk.filename)).suffix[1:].upper()
|
|
76
|
+
kind = kind if kind in ('IPA', 'APK') else 'ZIP'
|
|
77
|
+
label = name.filename if isinstance(name, ZipInfo) else name
|
|
78
|
+
try:
|
|
79
|
+
info = name if isinstance(name, ZipInfo) else apk.getinfo(name)
|
|
80
|
+
if info.file_size > limit:
|
|
81
|
+
raise ContentError(f'{kind} member exceeds supported size: {label}')
|
|
82
|
+
return apk.read(info)
|
|
83
|
+
except KeyError:
|
|
84
|
+
raise ContentError(f'{kind} is missing {label}') from None
|
|
85
|
+
except (BadZipFile, RuntimeError, NotImplementedError, EOFError, ZlibError) as error:
|
|
86
|
+
raise ContentError(f'Cannot read {kind} member {label}: {error}') from error
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ipa_game(identifier):
|
|
90
|
+
if isinstance(identifier, str):
|
|
91
|
+
for game, profile in GAMES.items():
|
|
92
|
+
if re.fullmatch(profile.BUNDLE_PATTERN, identifier, re.IGNORECASE):
|
|
93
|
+
return game
|
|
94
|
+
raise ContentError(f'IPA bundle identifier {identifier!r} does not identify SHS or Cause of Death')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _inspect_ipa(package: ZipFile):
|
|
98
|
+
"""Identify compatible assets without requiring a particular executable."""
|
|
99
|
+
names = package.namelist()
|
|
100
|
+
if len(names) != len(set(names)):
|
|
101
|
+
raise ContentError('IPA contains ambiguous duplicate member names')
|
|
102
|
+
plists = [name for name in names if re.fullmatch(r'Payload/[^/]+\.app/Info\.plist', name)]
|
|
103
|
+
if len(plists) != 1:
|
|
104
|
+
raise ContentError('IPA must contain exactly one Payload app with Info.plist')
|
|
105
|
+
root = plists[0].removesuffix('Info.plist')
|
|
106
|
+
try:
|
|
107
|
+
info = plistlib.loads(_zip_read(package, plists[0]))
|
|
108
|
+
except (ValueError, TypeError, OverflowError, ExpatError) as error:
|
|
109
|
+
raise ContentError('Invalid IPA Info.plist') from error
|
|
110
|
+
if not isinstance(info, dict):
|
|
111
|
+
raise ContentError('Invalid IPA Info.plist dictionary')
|
|
112
|
+
identifier = info.get('CFBundleIdentifier')
|
|
113
|
+
game = _ipa_game(identifier)
|
|
114
|
+
if game == 'cod':
|
|
115
|
+
from .cod.assets import validate_assets
|
|
116
|
+
else:
|
|
117
|
+
from .shs.ios_assets import validate_assets
|
|
118
|
+
validate_assets(lambda resource: _zip_read(package, root + 'res_generated/' + str(resource)))
|
|
119
|
+
# Version fields are informative. Source identity is the complete
|
|
120
|
+
# archive hash, including repacks or differently encrypted executables.
|
|
121
|
+
details = {key: info[key] for key in ('CFBundleIdentifier', 'CFBundleShortVersionString', 'CFBundleVersion')
|
|
122
|
+
if isinstance(info.get(key), str)}
|
|
123
|
+
return root, details
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class _ZippedEpisodeInput:
|
|
128
|
+
package: ZipFile
|
|
129
|
+
member: ZipInfo
|
|
130
|
+
name: str
|
|
131
|
+
|
|
132
|
+
def read_bytes(self, *, limit=MAX_PAYLOAD):
|
|
133
|
+
return _zip_read(self.package, self.member, limit=limit)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class _StagedEpisodeInput:
|
|
138
|
+
path: Path
|
|
139
|
+
name: str
|
|
140
|
+
|
|
141
|
+
def read_bytes(self, *, limit=MAX_PAYLOAD):
|
|
142
|
+
with self.path.open('rb') as stream:
|
|
143
|
+
data = stream.read(limit + 1)
|
|
144
|
+
if len(data) > limit:
|
|
145
|
+
raise ContentError(f'RAR member exceeds supported size: {self.name}')
|
|
146
|
+
return data
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _episode_rar_inputs(path, catalog_name, stack):
|
|
150
|
+
from .episode_catalog import MAX_CATALOG_BYTES
|
|
151
|
+
from .rar import RarReader, RarError
|
|
152
|
+
stage = Path(stack.enter_context(tempfile.TemporaryDirectory(prefix='exp-rar-')))
|
|
153
|
+
episodes, sidecars, seen, total = [], [], set(), 0
|
|
154
|
+
try:
|
|
155
|
+
with RarReader(path) as reader:
|
|
156
|
+
for index, member in enumerate(reader):
|
|
157
|
+
if index >= MAX_EPISODE_ZIP_MEMBERS:
|
|
158
|
+
raise ContentError('Episode RAR contains too many entries')
|
|
159
|
+
if member['size'] < 0:
|
|
160
|
+
raise ContentError('Episode RAR has an unknown member size')
|
|
161
|
+
# Solid archives may decode skipped entries to build their
|
|
162
|
+
# dictionary, so bound all declared data, including ignored files.
|
|
163
|
+
total += member['size']
|
|
164
|
+
if total > MAX_EPISODE_ZIP_BYTES:
|
|
165
|
+
raise ContentError('Episode RAR exceeds the supported total size')
|
|
166
|
+
normalized = member['name'].replace('\\', '/')
|
|
167
|
+
parts, name = normalized.split('/'), normalized.rsplit('/', 1)[-1]
|
|
168
|
+
is_catalog = name.lower() == catalog_name
|
|
169
|
+
if (member['mode'] == stat.S_IFDIR or not name or name.startswith('._')
|
|
170
|
+
or any(part.casefold() == '__macosx' for part in parts)
|
|
171
|
+
or not (is_catalog or name.lower().endswith('.exp'))):
|
|
172
|
+
reader.skip()
|
|
173
|
+
continue
|
|
174
|
+
if ('\0' in normalized or normalized.startswith('/') or '..' in parts
|
|
175
|
+
or re.match(r'^[A-Za-z]:', normalized)):
|
|
176
|
+
raise ContentError(f'Invalid episode RAR member path: {member["name"]}')
|
|
177
|
+
normalized = '/'.join(part for part in parts if part not in ('', '.'))
|
|
178
|
+
if member['mode'] not in (0, stat.S_IFREG) or member['link']:
|
|
179
|
+
raise ContentError(f'Episode RAR member is not a regular file: {name}')
|
|
180
|
+
if normalized in seen:
|
|
181
|
+
raise ContentError(f'Episode RAR contains duplicate member names: {normalized}')
|
|
182
|
+
seen.add(normalized)
|
|
183
|
+
if member['encrypted']:
|
|
184
|
+
raise ContentError('Encrypted episode RARs are not supported; extract the EXP files first')
|
|
185
|
+
limit = MAX_CATALOG_BYTES if is_catalog else MAX_PAYLOAD
|
|
186
|
+
if member['size'] > limit:
|
|
187
|
+
raise ContentError(f'RAR member exceeds supported size: {name}')
|
|
188
|
+
destination = stage / str(index) # Never the archive's pathname.
|
|
189
|
+
with destination.open('wb') as output:
|
|
190
|
+
reader.copy_to(output, member['size'])
|
|
191
|
+
source = _StagedEpisodeInput(destination, name)
|
|
192
|
+
(sidecars if is_catalog else episodes).append(source)
|
|
193
|
+
except RarError as error:
|
|
194
|
+
raise ContentError(f'Cannot import episode RAR {path.name}: {error}') from error
|
|
195
|
+
if not episodes and not sidecars:
|
|
196
|
+
raise ContentError(f'No EXP episodes or {catalog_name} found in {path.name}')
|
|
197
|
+
return episodes, sidecars
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _episode_zip_inputs(package, catalog_name):
|
|
201
|
+
"""Select data members without ever materializing their archive paths."""
|
|
202
|
+
from .episode_catalog import MAX_CATALOG_BYTES
|
|
203
|
+
members = package.infolist()
|
|
204
|
+
if len(members) > MAX_EPISODE_ZIP_MEMBERS:
|
|
205
|
+
raise ContentError('Episode ZIP contains too many entries')
|
|
206
|
+
episodes, sidecars, seen, total = [], [], set(), 0
|
|
207
|
+
for member in sorted(members, key=lambda member: member.filename):
|
|
208
|
+
normalized = member.filename.replace('\\', '/')
|
|
209
|
+
parts = normalized.split('/')
|
|
210
|
+
name = parts[-1]
|
|
211
|
+
if (member.is_dir() or not name or name.startswith('._')
|
|
212
|
+
or any(part.casefold() == '__macosx' for part in parts)):
|
|
213
|
+
continue
|
|
214
|
+
is_catalog = name.lower() == catalog_name
|
|
215
|
+
if not is_catalog and not name.lower().endswith('.exp'):
|
|
216
|
+
continue
|
|
217
|
+
if ('\0' in member.orig_filename or normalized.startswith('/') or '..' in parts
|
|
218
|
+
or re.match(r'^[A-Za-z]:', normalized)):
|
|
219
|
+
raise ContentError(f'Invalid episode ZIP member path: {member.filename}')
|
|
220
|
+
normalized = '/'.join(part for part in parts if part not in ('', '.'))
|
|
221
|
+
if stat.S_IFMT(member.external_attr >> 16) not in (0, stat.S_IFREG):
|
|
222
|
+
raise ContentError(f'Episode ZIP member is not a regular file: {member.filename}')
|
|
223
|
+
if normalized in seen:
|
|
224
|
+
raise ContentError(f'Episode ZIP contains duplicate member names: {normalized}')
|
|
225
|
+
seen.add(normalized)
|
|
226
|
+
limit = MAX_CATALOG_BYTES if is_catalog else MAX_PAYLOAD
|
|
227
|
+
if member.file_size > limit:
|
|
228
|
+
raise ContentError(f'ZIP member exceeds supported size: {member.filename}')
|
|
229
|
+
total += member.file_size
|
|
230
|
+
if total > MAX_EPISODE_ZIP_BYTES:
|
|
231
|
+
raise ContentError('Episode ZIP exceeds the supported total size')
|
|
232
|
+
source = _ZippedEpisodeInput(package, member, name)
|
|
233
|
+
(sidecars if is_catalog else episodes).append(source)
|
|
234
|
+
if not episodes and not sidecars:
|
|
235
|
+
raise ContentError(f'No EXP episodes or {catalog_name} found in {Path(package.filename).name}')
|
|
236
|
+
return episodes, sidecars
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@contextmanager
|
|
240
|
+
def _episode_inputs(paths, *, game):
|
|
241
|
+
"""Collect EXPs and optional native category sidecars as one import batch."""
|
|
242
|
+
from .episode_catalog import EpisodeCatalog, MAX_CATALOG_BYTES, read_options_catalog
|
|
243
|
+
catalog_name = GAMES[game].CATALOG_FILENAME
|
|
244
|
+
episodes, sidecars, checked, checked_archives = {}, {}, set(), set()
|
|
245
|
+
# Keep archives open only for this batch, including validation and rollback.
|
|
246
|
+
# Each EXP is read on demand, so a collection is not loaded into RAM at once.
|
|
247
|
+
with ExitStack() as stack:
|
|
248
|
+
for candidate in paths:
|
|
249
|
+
path = Path(candidate)
|
|
250
|
+
if path.is_dir():
|
|
251
|
+
candidates = sorted(p for p in path.rglob('*') if p.is_file())
|
|
252
|
+
elif path.is_file() and (path.suffix.lower() in ('.exp', '.zip', '.rar') or path.name.lower() == catalog_name):
|
|
253
|
+
candidates = [path]
|
|
254
|
+
else:
|
|
255
|
+
raise ContentError(f'Choose an EXP file, episode ZIP/RAR, folder, or {catalog_name}: {path}')
|
|
256
|
+
for item in candidates:
|
|
257
|
+
if item.suffix.lower() in ('.zip', '.rar'):
|
|
258
|
+
key = item.resolve()
|
|
259
|
+
if key in checked_archives:
|
|
260
|
+
continue
|
|
261
|
+
checked_archives.add(key)
|
|
262
|
+
if item.suffix.lower() == '.rar':
|
|
263
|
+
zipped, catalogs = _episode_rar_inputs(item, catalog_name, stack)
|
|
264
|
+
else:
|
|
265
|
+
try:
|
|
266
|
+
package = stack.enter_context(ZipFile(item))
|
|
267
|
+
except (BadZipFile, UnicodeError) as error:
|
|
268
|
+
raise ContentError(f'Invalid episode ZIP: {item.name}') from error
|
|
269
|
+
zipped, catalogs = _episode_zip_inputs(package, catalog_name)
|
|
270
|
+
for entries, sources in ((episodes, zipped), (sidecars, catalogs)):
|
|
271
|
+
for index, source in enumerate(sources):
|
|
272
|
+
entries[key, index] = source
|
|
273
|
+
elif item.suffix.lower() == '.exp':
|
|
274
|
+
episodes[item.resolve()] = item
|
|
275
|
+
parent = item.parent.resolve()
|
|
276
|
+
if parent not in checked:
|
|
277
|
+
checked.add(parent)
|
|
278
|
+
for sibling in parent.glob('*'):
|
|
279
|
+
if sibling.is_file() and sibling.name.lower() == catalog_name:
|
|
280
|
+
sidecars[sibling.resolve()] = sibling
|
|
281
|
+
elif item.name.lower() == catalog_name:
|
|
282
|
+
sidecars[item.resolve()] = item
|
|
283
|
+
catalog = EpisodeCatalog()
|
|
284
|
+
for path in sidecars.values():
|
|
285
|
+
if isinstance(path, (_ZippedEpisodeInput, _StagedEpisodeInput)):
|
|
286
|
+
data = path.read_bytes(limit=MAX_CATALOG_BYTES)
|
|
287
|
+
else:
|
|
288
|
+
with path.open('rb') as stream:
|
|
289
|
+
data = stream.read(MAX_CATALOG_BYTES + 1)
|
|
290
|
+
catalog = catalog.merge(read_options_catalog(data, game=game))
|
|
291
|
+
yield list(episodes.values()), catalog
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _apk_music_members(apk: ZipFile, ipa_members):
|
|
295
|
+
"""Verify only the known iOS download bank; IPA bytes always win."""
|
|
296
|
+
members = {}
|
|
297
|
+
for resource in GAMES['shs'].DOWNLOADED_MUSIC:
|
|
298
|
+
if resource not in ipa_members:
|
|
299
|
+
name = f'assets/Assets/audio/music/{resource}.mp3'
|
|
300
|
+
_zip_read(apk, name) # Validate presence, ZIP integrity and size before publishing.
|
|
301
|
+
members[resource] = name
|
|
302
|
+
return members
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def import_game(apk_path: Path, episode_paths: list[Path], destination: Path, *,
|
|
306
|
+
music_apk: Path | None = None, game: str | None = None) -> dict:
|
|
307
|
+
"""Create a relocatable library atomically; keep original inputs untouched.
|
|
308
|
+
|
|
309
|
+
A failed import leaves no partly usable library. Additional episodes are
|
|
310
|
+
optional because the supplied APK or IPA includes playable episode data.
|
|
311
|
+
The historical ``apk_path`` argument also accepts the supported iOS IPA.
|
|
312
|
+
"""
|
|
313
|
+
apk_path, destination = Path(apk_path), Path(destination)
|
|
314
|
+
kind = apk_path.suffix.lower().lstrip('.')
|
|
315
|
+
if kind not in ('apk', 'ipa'):
|
|
316
|
+
raise ContentError('Choose an SHS Android 1.0.9 APK, SHS IPA or Cause of Death IPA')
|
|
317
|
+
if game is not None:
|
|
318
|
+
GAMES[game]
|
|
319
|
+
if music_apk is not None and kind != 'ipa':
|
|
320
|
+
raise ContentError('An optional music APK can only supplement an SHS IPA library')
|
|
321
|
+
if destination.exists():
|
|
322
|
+
raise ContentError(f'Library already exists: {destination}; choose a new directory')
|
|
323
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
with tempfile.TemporaryDirectory(prefix='.exp-import-', dir=destination.parent) as temporary:
|
|
325
|
+
staged = Path(temporary) / 'library'
|
|
326
|
+
(staged / 'content').mkdir(parents=True)
|
|
327
|
+
# Validate the private copy, so source changes during import cannot
|
|
328
|
+
# leave a manifest describing different bytes from those we retain.
|
|
329
|
+
copied_apk = staged / 'content' / ('input.' + kind)
|
|
330
|
+
shutil.copyfile(apk_path, copied_apk)
|
|
331
|
+
apk_hash = file_digest(copied_apk)
|
|
332
|
+
final_apk = copied_apk.with_name(apk_hash + '.' + kind)
|
|
333
|
+
copied_apk.rename(final_apk)
|
|
334
|
+
records = {}
|
|
335
|
+
|
|
336
|
+
def episode_record(data, name, location):
|
|
337
|
+
archive = ExpArchive(data)
|
|
338
|
+
meta = archive.metadata()
|
|
339
|
+
programs = archive.programs()
|
|
340
|
+
if not programs:
|
|
341
|
+
raise ContentError(f'No KiWi scripts in {name}')
|
|
342
|
+
sha = digest(data)
|
|
343
|
+
if sha not in records:
|
|
344
|
+
records[sha] = dict(id=sha, sha256=sha, name=name,
|
|
345
|
+
pack_id=meta.pack_id, episode_id=meta.episode_id,
|
|
346
|
+
titles=list(meta.titles), scripts=sorted(programs), **location)
|
|
347
|
+
else:
|
|
348
|
+
records[sha] = _with_episode_alias(records[sha], name)
|
|
349
|
+
return sha
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
with ZipFile(final_apk) as apk:
|
|
353
|
+
if kind == 'ipa':
|
|
354
|
+
root, app_info = _inspect_ipa(apk)
|
|
355
|
+
source_game = _ipa_game(app_info['CFBundleIdentifier'])
|
|
356
|
+
assets = root + 'res_generated/'
|
|
357
|
+
else:
|
|
358
|
+
native_hash = _inspect_apk(apk)
|
|
359
|
+
source_game = 'shs'
|
|
360
|
+
root = assets = 'assets/Assets/'
|
|
361
|
+
if game is not None and source_game != game:
|
|
362
|
+
raise ContentError(f'This package belongs to {GAMES[source_game].TITLE}; '
|
|
363
|
+
f'choose assets for {GAMES[game].TITLE}')
|
|
364
|
+
if music_apk is not None and source_game != 'shs':
|
|
365
|
+
raise ContentError('SHS APK music can only supplement an SHS IPA')
|
|
366
|
+
built_in = GAMES[source_game].extract_builtin(apk, assets, ios=kind == 'ipa')
|
|
367
|
+
if built_in is not None:
|
|
368
|
+
data, name, tag = built_in
|
|
369
|
+
sha = digest(data)
|
|
370
|
+
location = dict(file=f'content/{sha}.exp', **tag)
|
|
371
|
+
episode_record(data, name, location)
|
|
372
|
+
(staged / location['file']).write_bytes(data)
|
|
373
|
+
for name in sorted(apk.namelist()):
|
|
374
|
+
rest = name.removeprefix(root)
|
|
375
|
+
if name.startswith(root) and '/' not in rest and rest.lower().endswith('.exp'):
|
|
376
|
+
episode_record(_zip_read(apk, name), rest, {kind + '_member': name})
|
|
377
|
+
except BadZipFile as error:
|
|
378
|
+
raise ContentError(f'The supplied {kind.upper()} is not a valid ZIP archive') from error
|
|
379
|
+
with _episode_inputs(episode_paths, game=source_game) as (external, catalog):
|
|
380
|
+
for path in external:
|
|
381
|
+
data = path.read_bytes()
|
|
382
|
+
sha = digest(data)
|
|
383
|
+
episode_record(data, path.name, dict(file=f'content/{sha}.exp'))
|
|
384
|
+
if 'file' in records[sha]:
|
|
385
|
+
(staged / records[sha]['file']).write_bytes(data)
|
|
386
|
+
if not records:
|
|
387
|
+
raise ContentError('No episodes found; supply episode EXP files')
|
|
388
|
+
profile = COD_IOS if source_game == 'cod' else IOS_PROFILE if kind == 'ipa' else PROFILE
|
|
389
|
+
manifest = dict(format='exp-content-library', version=1, game=source_game, profile=profile,
|
|
390
|
+
episodes=list(records.values()))
|
|
391
|
+
manifest[kind] = dict(file=f'content/{apk_hash}.{kind}', sha256=apk_hash,
|
|
392
|
+
**(dict(app=app_info) if kind == 'ipa' else dict(native_sha256=native_hash)))
|
|
393
|
+
if catalog.entries:
|
|
394
|
+
manifest['episode_catalog'] = catalog.to_data()
|
|
395
|
+
(staged / 'library.json').write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
|
396
|
+
if music_apk is not None:
|
|
397
|
+
with ContentLibrary(staged) as library:
|
|
398
|
+
library.add_music_apk(music_apk)
|
|
399
|
+
manifest = library.manifest
|
|
400
|
+
staged.rename(destination)
|
|
401
|
+
return manifest
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class ContentLibrary:
|
|
405
|
+
def __init__(self, directory: Path):
|
|
406
|
+
self.directory = Path(directory)
|
|
407
|
+
try:
|
|
408
|
+
self.manifest = json.loads((self.directory / 'library.json').read_text(encoding='utf-8'))
|
|
409
|
+
except (OSError, ValueError) as error:
|
|
410
|
+
raise ContentError(f'Cannot open content library: {error}') from error
|
|
411
|
+
m = self.manifest
|
|
412
|
+
if not isinstance(m, dict) or m.get('profile') not in PROFILES:
|
|
413
|
+
raise ContentError('Unsupported content library format or game profile')
|
|
414
|
+
self.profile = m['profile']
|
|
415
|
+
self.game_id, self.kind = PROFILES[self.profile]
|
|
416
|
+
legacy = m.get('format') == 'shs-content-library'
|
|
417
|
+
if legacy:
|
|
418
|
+
valid = (m.get('version'), self.profile) in ((1, PROFILE), (2, IOS_PROFILE), (3, IOS_PROFILE))
|
|
419
|
+
else:
|
|
420
|
+
valid = (m.get('format') == 'exp-content-library' and m.get('version') == 1
|
|
421
|
+
and m.get('game') == self.game_id)
|
|
422
|
+
if not valid or ('game' in m and m['game'] != self.game_id):
|
|
423
|
+
raise ContentError('Unsupported content library format or conflicting game identity')
|
|
424
|
+
self.game_title = GAMES[self.game_id].TITLE
|
|
425
|
+
member_key = self.kind + '_member'
|
|
426
|
+
if (not isinstance(m.get(self.kind), dict) or ('apk' in m and 'ipa' in m)
|
|
427
|
+
or (legacy and ('music_apk' in m) != (m['version'] == 3))
|
|
428
|
+
or ('music_apk' in m and (self.game_id, self.kind) != ('shs', 'ipa'))
|
|
429
|
+
or ('music_apk' in m and not isinstance(m['music_apk'], dict))
|
|
430
|
+
or not isinstance(m.get('episodes'), list) or not m['episodes']):
|
|
431
|
+
raise ContentError('Invalid content library manifest')
|
|
432
|
+
seen = set()
|
|
433
|
+
for record in m['episodes']:
|
|
434
|
+
if (not isinstance(record, dict) or not isinstance(record.get('sha256'), str)
|
|
435
|
+
or not re.fullmatch('[0-9a-f]{64}', record['sha256'])
|
|
436
|
+
or record.get('id') != record['sha256'] or record['id'] in seen
|
|
437
|
+
or not isinstance(record.get('name'), str)
|
|
438
|
+
or not isinstance(record.get('titles'), list) or len(record['titles']) != 5
|
|
439
|
+
or not all(isinstance(title, str) for title in record['titles'])
|
|
440
|
+
or any(type(record.get(key)) is not int or not 0 <= record[key] <= 65535
|
|
441
|
+
for key in ('pack_id', 'episode_id'))
|
|
442
|
+
or sum(key in record for key in ('file', 'apk_member', 'ipa_member')) != 1
|
|
443
|
+
or ('ipa_member' if self.kind == 'apk' else 'apk_member') in record
|
|
444
|
+
or ('builtin' in record and (record['builtin'] != GAMES[self.game_id].BUILTIN or 'file' not in record))
|
|
445
|
+
or not isinstance(record.get('aliases', []), list)
|
|
446
|
+
or not all(isinstance(alias, str) for alias in record.get('aliases', []))
|
|
447
|
+
or (member_key in record and not isinstance(record[member_key], str))):
|
|
448
|
+
raise ContentError('Invalid episode entry in content library manifest')
|
|
449
|
+
seen.add(record['id'])
|
|
450
|
+
from .episode_catalog import EpisodeCatalog
|
|
451
|
+
self.catalog = EpisodeCatalog.from_data(m.get('episode_catalog', []))
|
|
452
|
+
path = self._content_file(m[self.kind], '.' + self.kind)
|
|
453
|
+
try:
|
|
454
|
+
self.package = ZipFile(path)
|
|
455
|
+
# Retain the existing APK API for clients and old test fixtures.
|
|
456
|
+
self.apk = self.package
|
|
457
|
+
if self.kind == 'ipa':
|
|
458
|
+
self.app_root, self.app_info = _inspect_ipa(self.package)
|
|
459
|
+
if _ipa_game(self.app_info['CFBundleIdentifier']) != self.game_id:
|
|
460
|
+
raise ContentError('IPA game does not match the library profile')
|
|
461
|
+
self.asset_root = self.app_root + 'res_generated/'
|
|
462
|
+
else:
|
|
463
|
+
native_hash = _inspect_apk(self.package)
|
|
464
|
+
self.app_root = self.asset_root = 'assets/Assets/'
|
|
465
|
+
if self.kind == 'apk' and native_hash != m[self.kind].get('native_sha256'):
|
|
466
|
+
raise ContentError('Native profile hash does not match the library manifest')
|
|
467
|
+
for record in m['episodes']:
|
|
468
|
+
if member_key in record:
|
|
469
|
+
member = record[member_key]
|
|
470
|
+
relative = member.removeprefix(self.app_root)
|
|
471
|
+
if (not member.startswith(self.app_root) or '/' in relative
|
|
472
|
+
or not relative.lower().endswith('.exp')):
|
|
473
|
+
raise ContentError('Invalid bundled episode path')
|
|
474
|
+
except (BadZipFile, ContentError) as error:
|
|
475
|
+
if hasattr(self, 'apk'):
|
|
476
|
+
self.apk.close()
|
|
477
|
+
raise ContentError(f'Invalid imported {self.kind.upper()}: {error}') from error
|
|
478
|
+
self.episodes = m['episodes']
|
|
479
|
+
self.base_members = {}
|
|
480
|
+
# Only native resource locations participate. UI atlas names such as
|
|
481
|
+
# images/1.png are not aliases of global resource 1.
|
|
482
|
+
for name in self.apk.namelist():
|
|
483
|
+
relative = name.removeprefix(self.asset_root)
|
|
484
|
+
if name.startswith(self.asset_root) and re.fullmatch(r'(0|[1-9][0-9]*)', relative):
|
|
485
|
+
self.base_members[int(relative)] = name
|
|
486
|
+
for name in self.apk.namelist() if self.kind == 'apk' else ():
|
|
487
|
+
if re.fullmatch(r'assets/Assets/audio/(music/[1-9][0-9]*\.mp3|sfx/[1-9][0-9]*\.wav)', name):
|
|
488
|
+
stem = Path(name).stem
|
|
489
|
+
self.base_members.setdefault(int(stem), name)
|
|
490
|
+
# FUN_0004b4fc appends .mp3 to these six resource names. The suffix
|
|
491
|
+
# does not identify an audio stream: resource 16 is an image pack.
|
|
492
|
+
for resource_id in (16, 290, 446, 496, 499, 502) if self.kind == 'apk' else ():
|
|
493
|
+
name = f'assets/Assets/{resource_id}.mp3'
|
|
494
|
+
if name in self.apk.namelist():
|
|
495
|
+
self.base_members[resource_id] = name
|
|
496
|
+
self.music_apk, self.music_members = None, {}
|
|
497
|
+
if 'music_apk' in m:
|
|
498
|
+
try:
|
|
499
|
+
self.music_apk = ZipFile(self._content_file(m['music_apk'], '.apk'))
|
|
500
|
+
if _inspect_apk(self.music_apk) != m['music_apk'].get('native_sha256'):
|
|
501
|
+
raise ContentError('Music APK native profile does not match the library manifest')
|
|
502
|
+
self.music_members = _apk_music_members(self.music_apk, self.base_members)
|
|
503
|
+
except (BadZipFile, ContentError, OSError) as error:
|
|
504
|
+
self.close()
|
|
505
|
+
raise ContentError(f'Invalid imported music APK: {error}') from error
|
|
506
|
+
|
|
507
|
+
def _content_file(self, record: dict, suffix: str) -> Path:
|
|
508
|
+
sha = record.get('sha256')
|
|
509
|
+
if not isinstance(sha, str) or not re.fullmatch('[0-9a-f]{64}', sha):
|
|
510
|
+
raise ContentError('Invalid content hash in library')
|
|
511
|
+
expected = f'content/{sha}{suffix}'
|
|
512
|
+
if record.get('file') != expected:
|
|
513
|
+
raise ContentError('Invalid content path in library')
|
|
514
|
+
path = self.directory / expected
|
|
515
|
+
try:
|
|
516
|
+
actual = file_digest(path)
|
|
517
|
+
except OSError as error:
|
|
518
|
+
raise ContentError(f'Cannot read imported content: {error}') from error
|
|
519
|
+
if actual != sha:
|
|
520
|
+
raise ContentError(f'Imported content changed: {path.name}')
|
|
521
|
+
return path
|
|
522
|
+
|
|
523
|
+
def select(self, selector: str) -> dict:
|
|
524
|
+
matches = [e for e in self.episodes if e['id'].startswith(selector)
|
|
525
|
+
or e['name'] == selector or (selector.strip() and selector in e['titles'])
|
|
526
|
+
or selector.casefold() in [alias.casefold() for alias in e.get('aliases', [])]]
|
|
527
|
+
if len(matches) != 1:
|
|
528
|
+
raise ContentError(f'Episode selector {selector!r} matched {len(matches)} episodes; use a unique ID from list')
|
|
529
|
+
return matches[0]
|
|
530
|
+
|
|
531
|
+
def open_episode(self, selector: str):
|
|
532
|
+
record = self.select(selector)
|
|
533
|
+
if self.kind + '_member' in record:
|
|
534
|
+
data = _zip_read(self.package, record[self.kind + '_member'])
|
|
535
|
+
else:
|
|
536
|
+
data = self._content_file(record, '.exp').read_bytes()
|
|
537
|
+
if digest(data) != record['sha256']:
|
|
538
|
+
raise ContentError('Episode content does not match its imported hash')
|
|
539
|
+
return EpisodeResources(self, record, ExpArchive(data))
|
|
540
|
+
|
|
541
|
+
def read_ui_asset(self, name: str) -> bytes:
|
|
542
|
+
"""Read an exact named UI asset, separately from numeric EXP resources.
|
|
543
|
+
|
|
544
|
+
Descriptors refer to atlases by filename. Keep those references in
|
|
545
|
+
their APK namespace; never resolve them against the host filesystem.
|
|
546
|
+
"""
|
|
547
|
+
if (not isinstance(name, str)
|
|
548
|
+
or not re.fullmatch(r'(fonts|images)/[A-Za-z0-9_][A-Za-z0-9_.-]*', name)):
|
|
549
|
+
raise ContentError(f'Invalid named UI asset: {name!r}')
|
|
550
|
+
if self.kind == 'ipa':
|
|
551
|
+
raise ContentError(f'IPA has no named UI asset {name}; its text uses installed system fonts')
|
|
552
|
+
return _zip_read(self.package, self.asset_root + name)
|
|
553
|
+
|
|
554
|
+
def read_ui_resource(self, role: int) -> bytes:
|
|
555
|
+
"""Resolve a host UI role without changing script-visible numeric IDs."""
|
|
556
|
+
if self.game_id == 'cod':
|
|
557
|
+
from .cod.assets import ui_resource
|
|
558
|
+
return ui_resource(self, role)
|
|
559
|
+
if self.kind == 'ipa':
|
|
560
|
+
from .shs.ios_assets import ui_resource
|
|
561
|
+
return ui_resource(self, role)
|
|
562
|
+
return self.read_asset(role)
|
|
563
|
+
|
|
564
|
+
def read_asset(self, resource_id: int) -> bytes:
|
|
565
|
+
"""Read the base package bank without opening an episode."""
|
|
566
|
+
try:
|
|
567
|
+
return _zip_read(self.apk, self.base_members[resource_id])
|
|
568
|
+
except KeyError:
|
|
569
|
+
if resource_id in self.music_members:
|
|
570
|
+
return _zip_read(self.music_apk, self.music_members[resource_id])
|
|
571
|
+
raise ContentError(f'{self.kind.upper()} resource {resource_id} is missing') from None
|
|
572
|
+
|
|
573
|
+
@property
|
|
574
|
+
def missing_music_ids(self):
|
|
575
|
+
if (self.game_id, self.kind) != ('shs', 'ipa'):
|
|
576
|
+
return ()
|
|
577
|
+
return tuple(resource for resource in GAMES['shs'].DOWNLOADED_MUSIC
|
|
578
|
+
if resource not in self.base_members and resource not in self.music_members)
|
|
579
|
+
|
|
580
|
+
def add_music_apk(self, source: Path) -> int:
|
|
581
|
+
"""Attach optional music transactionally, preserving IPA assets and saves.
|
|
582
|
+
|
|
583
|
+
Only the verified iOS download IDs are exposed. Android fonts, UI,
|
|
584
|
+
scripts, episodes and other resources never enter the IPA's banks.
|
|
585
|
+
"""
|
|
586
|
+
source = Path(source)
|
|
587
|
+
if (self.game_id, self.kind) != ('shs', 'ipa'):
|
|
588
|
+
raise ContentError('An optional music APK can only supplement an SHS IPA library')
|
|
589
|
+
if source.suffix.lower() != '.apk':
|
|
590
|
+
raise ContentError('Choose your SHS Android 1.0.9 APK for missing music')
|
|
591
|
+
if not self.missing_music_ids:
|
|
592
|
+
return 0
|
|
593
|
+
with tempfile.TemporaryDirectory(prefix='.exp-import-', dir=self.directory) as temporary:
|
|
594
|
+
stage = Path(temporary)
|
|
595
|
+
copied = stage / 'music.apk'
|
|
596
|
+
shutil.copyfile(source, copied)
|
|
597
|
+
sha = file_digest(copied)
|
|
598
|
+
try:
|
|
599
|
+
with ZipFile(copied) as apk:
|
|
600
|
+
native = _inspect_apk(apk)
|
|
601
|
+
members = _apk_music_members(apk, self.base_members)
|
|
602
|
+
except BadZipFile as error:
|
|
603
|
+
raise ContentError('The supplied music APK is not a valid ZIP archive') from error
|
|
604
|
+
record = dict(file=f'content/{sha}.apk', sha256=sha, native_sha256=native)
|
|
605
|
+
current = json.loads((self.directory / 'library.json').read_text(encoding='utf-8'))
|
|
606
|
+
if current != self.manifest:
|
|
607
|
+
raise ContentError('The library changed during import; reopen it and try again')
|
|
608
|
+
version = 3 if self.manifest['format'] == 'shs-content-library' else 1
|
|
609
|
+
updated = dict(self.manifest, version=version, music_apk=record)
|
|
610
|
+
manifest_path = stage / 'library.json'
|
|
611
|
+
manifest_path.write_text(json.dumps(updated, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
|
612
|
+
target = self.directory / record['file']
|
|
613
|
+
existed = target.exists()
|
|
614
|
+
copied.replace(target)
|
|
615
|
+
retained = None
|
|
616
|
+
try:
|
|
617
|
+
retained = ZipFile(target)
|
|
618
|
+
manifest_path.replace(self.directory / 'library.json')
|
|
619
|
+
except BaseException:
|
|
620
|
+
if retained is not None:
|
|
621
|
+
retained.close()
|
|
622
|
+
if not existed:
|
|
623
|
+
target.unlink(missing_ok=True)
|
|
624
|
+
raise
|
|
625
|
+
self.manifest, self.music_apk, self.music_members = updated, retained, members
|
|
626
|
+
return len(members)
|
|
627
|
+
|
|
628
|
+
def add_episodes(self, paths: list[Path]) -> int:
|
|
629
|
+
"""Validate a batch before atomically publishing an expanded manifest.
|
|
630
|
+
|
|
631
|
+
Content is copied under its hash; existing content and saves are never
|
|
632
|
+
replaced. Duplicate filenames become aliases for catalog matching. A
|
|
633
|
+
failed validation leaves the entire library unchanged.
|
|
634
|
+
"""
|
|
635
|
+
with _episode_inputs(paths, game=self.game_id) as (candidates, catalog):
|
|
636
|
+
if not candidates and not catalog.entries:
|
|
637
|
+
raise ContentError('No EXP episodes found in the selected files, ZIPs, RARs or folders')
|
|
638
|
+
return self._install_episodes(((path.read_bytes(), path.name, {}) for path in candidates), catalog=catalog)
|
|
639
|
+
|
|
640
|
+
def ensure_builtin_episodes(self) -> int:
|
|
641
|
+
"""Upgrade a library using its retained game package, once per story."""
|
|
642
|
+
profile = GAMES[self.game_id]
|
|
643
|
+
if any(e.get('builtin') == profile.BUILTIN for e in self.episodes):
|
|
644
|
+
return 0
|
|
645
|
+
record = profile.extract_builtin(self.package, self.asset_root, ios=self.kind == 'ipa')
|
|
646
|
+
if record is None:
|
|
647
|
+
return 0
|
|
648
|
+
return self._install_episodes([record])
|
|
649
|
+
|
|
650
|
+
def _install_episodes(self, episodes, *, catalog=None) -> int:
|
|
651
|
+
records = {e['id']: e for e in self.episodes}
|
|
652
|
+
catalog = self.catalog.merge(catalog) if catalog is not None else self.catalog
|
|
653
|
+
with tempfile.TemporaryDirectory(prefix='.exp-import-', dir=self.directory) as temporary:
|
|
654
|
+
stage = Path(temporary)
|
|
655
|
+
additions = []
|
|
656
|
+
for data, name, source in episodes:
|
|
657
|
+
sha = digest(data)
|
|
658
|
+
if sha in records:
|
|
659
|
+
records[sha] = _with_episode_alias(records[sha], name)
|
|
660
|
+
continue
|
|
661
|
+
archive = ExpArchive(data)
|
|
662
|
+
meta, programs = archive.metadata(), archive.programs()
|
|
663
|
+
if not programs:
|
|
664
|
+
raise ContentError(f'No KiWi scripts in {name}')
|
|
665
|
+
record = dict(id=sha, sha256=sha, name=name, pack_id=meta.pack_id,
|
|
666
|
+
episode_id=meta.episode_id, titles=list(meta.titles),
|
|
667
|
+
scripts=sorted(programs), file=f'content/{sha}.exp', **source)
|
|
668
|
+
(stage / (sha + '.exp')).write_bytes(data)
|
|
669
|
+
records[sha] = record
|
|
670
|
+
additions.append(record)
|
|
671
|
+
if (not additions and list(records.values()) == self.episodes
|
|
672
|
+
and catalog.entries == self.catalog.entries):
|
|
673
|
+
return 0
|
|
674
|
+
# Refuse to lose additions made by another importer since we opened.
|
|
675
|
+
current = json.loads((self.directory / 'library.json').read_text(encoding='utf-8'))
|
|
676
|
+
if current != self.manifest:
|
|
677
|
+
raise ContentError('The library changed during import; reopen it and try again')
|
|
678
|
+
updated = dict(self.manifest, episodes=list(records.values()))
|
|
679
|
+
if catalog.entries:
|
|
680
|
+
updated['episode_catalog'] = catalog.to_data()
|
|
681
|
+
manifest_path = stage / 'library.json'
|
|
682
|
+
manifest_path.write_text(json.dumps(updated, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
|
683
|
+
for record in additions:
|
|
684
|
+
(stage / (record['id'] + '.exp')).replace(self.directory / record['file'])
|
|
685
|
+
manifest_path.replace(self.directory / 'library.json')
|
|
686
|
+
self.manifest, self.episodes = updated, updated['episodes']
|
|
687
|
+
self.catalog = catalog
|
|
688
|
+
return len(additions)
|
|
689
|
+
|
|
690
|
+
def close(self):
|
|
691
|
+
self.apk.close()
|
|
692
|
+
if self.music_apk is not None:
|
|
693
|
+
self.music_apk.close()
|
|
694
|
+
|
|
695
|
+
def __enter__(self):
|
|
696
|
+
return self
|
|
697
|
+
|
|
698
|
+
def __exit__(self, *exc):
|
|
699
|
+
self.close()
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
class EpisodeResources:
|
|
703
|
+
def __init__(self, library: ContentLibrary, record: dict, archive: ExpArchive):
|
|
704
|
+
self.library, self.record, self.archive = library, record, archive
|
|
705
|
+
self.programs = archive.programs()
|
|
706
|
+
|
|
707
|
+
@property
|
|
708
|
+
def identity(self):
|
|
709
|
+
# Optional music is presentation-only: attaching it must not invalidate
|
|
710
|
+
# an existing IPA checkpoint. Both source archives are still hashed and
|
|
711
|
+
# checked on open; script/layout/font identities stay with the IPA.
|
|
712
|
+
return dict(profile=self.library.profile, episode_sha256=self.record['sha256'],
|
|
713
|
+
**{self.library.kind + '_sha256': self.library.manifest[self.library.kind]['sha256']})
|
|
714
|
+
|
|
715
|
+
def read_ui_resource(self, role: int) -> bytes:
|
|
716
|
+
return self.library.read_ui_resource(role)
|
|
717
|
+
|
|
718
|
+
def exists(self, resource_id: int) -> bool:
|
|
719
|
+
# FUN_00082bc0 selects the episode bank at 26000 for art resources.
|
|
720
|
+
if resource_id >= 26000:
|
|
721
|
+
return resource_id in self.archive.entries
|
|
722
|
+
return resource_id in self.library.base_members or resource_id in self.library.music_members
|
|
723
|
+
|
|
724
|
+
def read_asset(self, resource_id: int) -> bytes:
|
|
725
|
+
if resource_id >= 26000:
|
|
726
|
+
return self.archive.read(resource_id)
|
|
727
|
+
return self.library.read_asset(resource_id)
|
|
728
|
+
|
|
729
|
+
def program(self, resource_id: int):
|
|
730
|
+
if resource_id not in self.programs:
|
|
731
|
+
# Engine script loading and image-bank selection are different
|
|
732
|
+
# paths: episode scripts in the 25000 range take precedence here.
|
|
733
|
+
try:
|
|
734
|
+
self.programs[resource_id] = decode_program(self.read_asset(resource_id))
|
|
735
|
+
except ValueError as error:
|
|
736
|
+
raise ContentError(f'Cannot load script {resource_id}: {error}') from error
|
|
737
|
+
return self.programs[resource_id]
|
|
738
|
+
|
|
739
|
+
@lru_cache(maxsize=1)
|
|
740
|
+
def dialogue_layout(self):
|
|
741
|
+
from .dialogue import DialogueLayout
|
|
742
|
+
return DialogueLayout(self)
|