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/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)