exp-runtime-formats 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.
@@ -0,0 +1,5 @@
1
+ """Allow running the tools as ``python -m exp_runtime``."""
2
+
3
+ from .cli import main
4
+
5
+ main()
exp_runtime/atlas.py ADDED
@@ -0,0 +1,203 @@
1
+ """Packed RGBA sprite atlases used by the native minigame renderer.
2
+
3
+ This is the CS sprite-atlas path (000507b8 / 000503dc), separate from
4
+ ImagePack's literal-image path. All dimensions and extents are bounded.
5
+ """
6
+ from dataclasses import dataclass
7
+
8
+ from .exp import MAX_PAYLOAD
9
+ from .ui_assets import Raster, UIAssetError, _Reader
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AtlasFrame:
14
+ x: int
15
+ y: int
16
+ width: int
17
+ height: int
18
+ stored_width: int
19
+ stored_height: int
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class SpriteAtlas:
24
+ flags: int
25
+ sequences: tuple[tuple[int, ...], ...]
26
+ parts: tuple[tuple[tuple[int, int, int], ...], ...]
27
+ transforms: tuple[tuple[int, int], ...]
28
+ frames: tuple[AtlasFrame, ...]
29
+ image: Raster
30
+
31
+ @classmethod
32
+ def parse(cls, data):
33
+ r = _Reader(data)
34
+ flags, capacity, count = r.integer('B'), r.count(), r.count('b')
35
+ if flags & ~0x3f:
36
+ raise UIAssetError('Unsupported sprite-atlas flags')
37
+ sequences = []
38
+ if count:
39
+ base, total = r.integer(), r.count()
40
+ for _ in range(count):
41
+ sequences.append(tuple(base + r.integer('h' if flags & 4 else 'b')
42
+ for __ in range(r.count('b'))))
43
+ if sum(map(len, sequences)) != total:
44
+ raise UIAssetError('Inconsistent atlas sequences')
45
+ count, total = r.count(), r.count()
46
+ parts = []
47
+ for _ in range(count):
48
+ parts.append(tuple((r.integer('h' if flags & 1 else 'b'),
49
+ r.integer('h' if flags & 2 else 'b'),
50
+ r.integer('h' if flags & 2 else 'b'))
51
+ for __ in range(r.count('b'))))
52
+ if sum(map(len, parts)) != total:
53
+ raise UIAssetError('Inconsistent atlas parts')
54
+ transforms = tuple(r.unpack('hb') for _ in range(r.count()))
55
+ scale, size, count = r.integer(), r.count(), r.count()
56
+ if scale <= 0 or size <= 0 or size * size * 4 > MAX_PAYLOAD or count != capacity:
57
+ raise UIAssetError('Invalid sprite-atlas dimensions')
58
+ frames = []
59
+ for _ in range(count):
60
+ x, y, w, h, sw, sh = r.unpack('hhhhhh')
61
+ x, y = x * scale, y * scale
62
+ if (min(x, y, w, h, sw, sh) < 0 or w * h * 4 > MAX_PAYLOAD
63
+ or x + sw > size or y + sh > size):
64
+ raise UIAssetError('Sprite frame is outside its atlas')
65
+ frames.append(AtlasFrame(x, y, w, h, sw, sh))
66
+ data = r.take(size * size * 4)
67
+ r.finish()
68
+ # 000503dc reverses each stored A,B,G,R quadruple before upload.
69
+ rgba = bytearray(len(data))
70
+ rgba[0::4], rgba[1::4], rgba[2::4], rgba[3::4] = data[3::4], data[2::4], data[1::4], data[0::4]
71
+ for frame, transform in transforms:
72
+ if not 0 <= frame < count or not 0 <= transform <= 7:
73
+ raise UIAssetError('Invalid atlas transform')
74
+ for group in parts:
75
+ for index, _, __ in group:
76
+ if not (-len(parts) <= index < count):
77
+ raise UIAssetError('Invalid atlas part reference')
78
+ return cls(flags, tuple(sequences), tuple(parts), transforms, tuple(frames),
79
+ Raster(size, size, bytes(rgba)))
80
+
81
+ def raster(self, index):
82
+ """Decode a positive literal frame; composites retain signed references."""
83
+ if not 0 <= index < len(self.frames):
84
+ raise UIAssetError('Absent atlas frame')
85
+ f = self.frames[index]
86
+ if f.width != f.stored_width or f.height != f.stored_height:
87
+ raise UIAssetError('Scaled atlas frame requires renderer resampling')
88
+ row_size = f.width * 4
89
+ pixels = b''.join(self.image.pixels[(y * self.image.width + f.x) * 4:
90
+ (y * self.image.width + f.x) * 4 + row_size]
91
+ for y in range(f.y, f.y + f.height))
92
+ result = Raster(f.width, f.height, pixels)
93
+ for frame, transform in self.transforms:
94
+ if frame == index:
95
+ result = result.transformed(transform)
96
+ return result
97
+
98
+ def literals(self, index):
99
+ """Flatten signed composite references, preserving painter order/origin."""
100
+ result = []
101
+
102
+ def visit(ref, x, y, ancestors):
103
+ if ref >= 0:
104
+ if ref >= len(self.frames) or len(result) >= 4096:
105
+ raise UIAssetError('Invalid or oversized atlas composite')
106
+ result.append((ref, x, y))
107
+ return
108
+ if ref in ancestors or len(ancestors) >= 64 or ~ref >= len(self.parts):
109
+ raise UIAssetError('Invalid or cyclic atlas composite')
110
+ for child, dx, dy in self.parts[~ref]:
111
+ visit(child, x + dx, y + dy, ancestors + (ref,))
112
+
113
+ visit(index, 0, 0, ())
114
+ return tuple(result)
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class AtlasFont:
119
+ """000538ac: six-byte header, then (byte, s16 x, s16 y, s8 width)."""
120
+ space_width: int
121
+ style: int
122
+ tracking: int
123
+ height: int
124
+ glyphs: dict[int, tuple[int, int, int]]
125
+
126
+ @classmethod
127
+ def parse(cls, data, atlas):
128
+ r = _Reader(data)
129
+ space, style, tracking = r.integer('b'), r.integer('B'), r.integer('b')
130
+ count, height = r.count(), r.integer('b')
131
+ if height <= 0 or space < 0 or count > 256:
132
+ raise UIAssetError('Invalid atlas font header')
133
+ glyphs = {}
134
+ for _ in range(count):
135
+ code, x, y, width = r.unpack('Bhhb')
136
+ if min(x, y, width) < 0 or x + width > atlas.width or y + height > atlas.height:
137
+ raise UIAssetError('Font glyph is outside its atlas')
138
+ glyphs[code] = x, y, width
139
+ r.finish()
140
+ return cls(space, style, tracking, height, glyphs)
141
+
142
+ def glyph(self, char):
143
+ code = ord(char)
144
+ code = {0x91: 39, 0x92: 39, 0x93: 34, 0x94: 34, 0x96: 45}.get(code, code)
145
+ if code not in self.glyphs:
146
+ if 97 <= code <= 122 or 224 <= code <= 252:
147
+ code -= 32
148
+ elif 65 <= code <= 90 or 192 <= code <= 220:
149
+ code += 32
150
+ return self.glyphs.get(code)
151
+
152
+ @property
153
+ def line_gap(self):
154
+ # 000538ac stores header byte 1 at +0x139; 00054490 adds it to
155
+ # each line's height. The older parser called this byte "style".
156
+ return self.style if self.style < 128 else self.style - 256
157
+
158
+ def char_width(self, char, monospace=None):
159
+ if monospace is not None:
160
+ return self.char_width(monospace)
161
+ glyph = self.glyph(char)
162
+ return glyph[2] if glyph is not None else self.space_width
163
+
164
+ def width(self, text, monospace=None):
165
+ """000535fc: trim leading spaces, omit the last tracking interval."""
166
+ return max((max(0, sum(self.char_width(c, monospace) + self.tracking for c in line)
167
+ - self.tracking) if line else 0
168
+ for line in text.lstrip(' ').split('\n')), default=0)
169
+
170
+ def wrap(self, text, width):
171
+ """00054490: byte glyph advances, space wrapping and explicit newlines."""
172
+ if width <= 0:
173
+ raise UIAssetError('Atlas text width must be positive')
174
+ lines = []
175
+ while text:
176
+ text = text.lstrip(' ')
177
+ if not text:
178
+ break
179
+ end, advance = 0, 0
180
+ while end < len(text) and text[end] != '\n':
181
+ glyph_width = self.char_width(text[end])
182
+ if glyph_width > width:
183
+ raise UIAssetError('Atlas text column cannot fit a glyph')
184
+ if advance + glyph_width > width:
185
+ break
186
+ advance += glyph_width + self.tracking
187
+ end += 1
188
+ if end == len(text):
189
+ lines.append(text)
190
+ break
191
+ if text[end] == '\n':
192
+ lines.append(text[:end])
193
+ text = text[end + 1:]
194
+ else:
195
+ space = text.rfind(' ', 0, end + 1)
196
+ if space > 0:
197
+ end = space
198
+ lines.append(text[:end])
199
+ text = text[end:]
200
+ return tuple(lines)
201
+
202
+ def text_height(self, lines):
203
+ return len(lines) * (self.height + self.line_gap) - self.line_gap if lines else 0
exp_runtime/cli.py ADDED
@@ -0,0 +1,186 @@
1
+ """Import, inspect and launch player-supplied EXP game content."""
2
+
3
+ import argparse
4
+ from dataclasses import asdict
5
+ from importlib.util import find_spec
6
+ import json
7
+ import logging
8
+ import sys
9
+ import tempfile
10
+
11
+ from pathlib import Path
12
+
13
+
14
+ def _build_parser() -> argparse.ArgumentParser:
15
+ """Build the argument parser with all subcommands."""
16
+ parser = argparse.ArgumentParser(
17
+ prog="exp-tool",
18
+ description="Inspect EXP files and play SHS or Cause of Death with your own game data.",
19
+ )
20
+ parser.add_argument(
21
+ "-v", "--verbose",
22
+ action="count",
23
+ default=0,
24
+ help="Increase verbosity (-v for INFO, -vv for DEBUG).",
25
+ )
26
+ subparsers = parser.add_subparsers(dest="command")
27
+
28
+ inspect_parser = subparsers.add_parser('inspect', help='Inspect an EXP without installing the engine or games.')
29
+ inspect_parser.add_argument('archive', type=Path)
30
+ extract_parser = subparsers.add_parser('extract', help='Extract EXP resources by numeric ID into a new directory.')
31
+ extract_parser.add_argument('archive', type=Path)
32
+ extract_parser.add_argument('--output', type=Path, required=True)
33
+
34
+ trace_parser = subparsers.add_parser(
35
+ "trace", help="Execute KiWi to the first pending action and print a JSON trace.",
36
+ )
37
+ trace_parser.add_argument("archive", type=Path, help="Path to the .exp archive.")
38
+ trace_parser.add_argument("--max-steps", type=int, default=100_000)
39
+ trace_parser.add_argument("--max-events", type=int, default=10_000)
40
+ trace_parser.add_argument('--game', choices=('shs', 'cod'), default='shs', help='Native host profile (default: shs).')
41
+
42
+ import_parser = subparsers.add_parser("import", help="Create a local library from your APK or IPA and episode files.")
43
+ source = import_parser.add_mutually_exclusive_group(required=True)
44
+ source.add_argument("--apk", type=Path, help="Your SHS Android 1.0.9 APK.")
45
+ source.add_argument("--ipa", type=Path, help="Your SHS or Cause of Death IPA with compatible game assets.")
46
+ import_parser.add_argument('--game', choices=('shs', 'cod'), help='Require assets for this game (otherwise detected).')
47
+ import_parser.add_argument("--music-apk", type=Path,
48
+ help="Optional Android 1.0.9 APK supplying missing IPA music.")
49
+ import_parser.add_argument("--episodes", type=Path, nargs="+", action="extend", default=[],
50
+ help="EXP files, episode ZIPs/RARs or directories; subfolders are scanned recursively.")
51
+ import_parser.add_argument("--library", type=Path, required=True,
52
+ help="New library directory for this game.")
53
+
54
+ list_parser = subparsers.add_parser("list", help="List episodes in a local content library.")
55
+ list_parser.add_argument("--library", type=Path, default=Path(".shs-library"))
56
+ from .languages import TITLE_LANGUAGES
57
+ list_parser.add_argument('--title-language', choices=TITLE_LANGUAGES,
58
+ help='Episode title language; defaults to the library preference. Does not translate story text.')
59
+
60
+ music_parser = subparsers.add_parser("add-music", help="Add missing music to an existing IPA library.")
61
+ music_parser.add_argument("--apk", type=Path, required=True, help="Your SHS Android 1.0.9 APK.")
62
+ music_parser.add_argument("--library", type=Path, required=True, help="Existing IPA library.")
63
+
64
+ play_parser = subparsers.add_parser("play", help="Open the main menu, or start a selected episode.")
65
+ play_parser.add_argument("--library", type=Path, help="Content library; defaults to the existing local library or user application data.")
66
+ play_parser.add_argument("--episode", help="Start directly: unique ID prefix, filename, or title from list.")
67
+ saves = play_parser.add_mutually_exclusive_group()
68
+ saves.add_argument("--resume", action="store_true", help="Load this episode's local save slot.")
69
+ saves.add_argument("--load", type=Path, help="Load a specific runtime JSON save.")
70
+ play_parser.add_argument("--no-audio", action="store_true")
71
+ play_parser.add_argument('--game', choices=('shs', 'cod'), help='Open this game directly instead of the chooser.')
72
+
73
+ return parser
74
+
75
+
76
+ def main(argv: list[str] | None = None) -> None:
77
+ """CLI entry point."""
78
+ parser = _build_parser()
79
+ args = parser.parse_args(argv)
80
+
81
+ # Configure logging
82
+ log_level = logging.WARNING
83
+ if args.verbose >= 2:
84
+ log_level = logging.DEBUG
85
+ elif args.verbose >= 1:
86
+ log_level = logging.INFO
87
+ logging.basicConfig(level=log_level, format="%(levelname)s: %(message)s")
88
+
89
+ if args.command in ('inspect', 'extract'):
90
+ from .exp import ContentError, ExpArchive
91
+ try:
92
+ archive = ExpArchive(args.archive.read_bytes())
93
+ if args.command == 'inspect':
94
+ result = dict(resources=sorted(archive.entries), scripts=sorted(archive.programs()))
95
+ if 1 in archive.entries:
96
+ result['metadata'] = asdict(archive.metadata())
97
+ print(json.dumps(result, indent=2, ensure_ascii=False))
98
+ else:
99
+ destination = args.output
100
+ if destination.exists():
101
+ raise ContentError(f'Output already exists: {destination}; choose a new directory')
102
+ destination.parent.mkdir(parents=True, exist_ok=True)
103
+ # A corrupt record must not leave a partial extraction. IDs
104
+ # are validated integers; original archive paths are not used.
105
+ with tempfile.TemporaryDirectory(dir=destination.parent, prefix='.exp-extract-') as temporary:
106
+ staged = Path(temporary) / 'resources'
107
+ staged.mkdir()
108
+ for resource in archive.entries:
109
+ (staged / f'{resource}.bin').write_bytes(archive.read(resource))
110
+ staged.rename(destination)
111
+ print(f'Extracted {len(archive.entries)} resources into {destination}')
112
+ except (ContentError, OSError) as error:
113
+ parser.error(str(error))
114
+ return
115
+
116
+ if args.command in ('import', 'list', 'play', 'add-music', 'trace'):
117
+ if find_spec('exp_runtime.engine') is None:
118
+ parser.error('This command needs the engine and a game adapter. '
119
+ 'Install "exp-runtime[engine,shs]" or "exp-runtime[engine,cod]".')
120
+ if args.command == 'play' and find_spec('exp_runtime.application') is None:
121
+ parser.error('The player is not installed. Install "exp-runtime[player]".')
122
+
123
+ if args.command in ("import", "list", "play", "add-music"):
124
+ from .content import ContentError, ContentLibrary, import_game
125
+ from .runtime import SaveError
126
+ from .vm import VMError
127
+
128
+ try:
129
+ if args.command == "import":
130
+ manifest = import_game(args.apk or args.ipa, args.episodes, args.library,
131
+ music_apk=args.music_apk, game=args.game)
132
+ print(f"Imported {len(manifest['episodes'])} episodes into {args.library}")
133
+ return
134
+ if args.command == 'play':
135
+ if (args.load or args.resume) and not args.episode:
136
+ parser.error('--load and --resume require --episode; omit them to use the main menu')
137
+ try:
138
+ from .application import Application
139
+ except ModuleNotFoundError as error:
140
+ if error.name != 'pygame':
141
+ raise
142
+ parser.error('Desktop support is not installed. Run uv sync --extra desktop '
143
+ 'or install the package with its [desktop] extra.')
144
+ app = Application(args.library, audio=not args.no_audio, game_key=args.game)
145
+ try:
146
+ if args.episode:
147
+ if not app.library:
148
+ raise ContentError(app.message or 'Import your APK or IPA before selecting an episode')
149
+ app.selected = app.library.select(args.episode)['id']
150
+ app.start(resume=args.resume, load_path=args.load)
151
+ except Exception:
152
+ app.close()
153
+ raise
154
+ app.run()
155
+ return
156
+ with ContentLibrary(args.library) as library:
157
+ if args.command == 'add-music':
158
+ count = library.add_music_apk(args.apk)
159
+ print(f'Added {count} missing music tracks.' if count else 'No missing music tracks to add.')
160
+ return
161
+ library.ensure_builtin_episodes()
162
+ if args.command == "list":
163
+ from .menu import MenuState
164
+ state = MenuState(library)
165
+ if args.title_language is not None:
166
+ state.title_language = args.title_language
167
+ for record in library.episodes:
168
+ print(f"{record['id'][:12]} {state.title(record)} ({record['name']})")
169
+ return
170
+ except (ContentError, SaveError, VMError, OSError) as error:
171
+ parser.error(str(error))
172
+
173
+ elif args.command == "trace":
174
+ from .trace import trace_archive
175
+
176
+ try:
177
+ result = trace_archive(args.archive, max_steps=args.max_steps, max_events=args.max_events, game=args.game)
178
+ except (ValueError, OSError) as error:
179
+ parser.error(str(error))
180
+ print(json.dumps(result, indent=2, ensure_ascii=False))
181
+ if result["status"] in ("vm_error", "missing_script"):
182
+ sys.exit(1)
183
+
184
+ else:
185
+ parser.print_help()
186
+ sys.exit(1)
@@ -0,0 +1 @@
1
+ """Lossless KiWi bytecode decoding; EXP archives are handled by content.py."""
@@ -0,0 +1,119 @@
1
+ """Lossless KiWi v2 decoding, verified against libshs09.so FUN_00057068.
2
+
3
+ This is a binary representation, not a reconstruction of game actions.
4
+ Data words may contain packed text, mutable variables, or other values.
5
+ """
6
+ from dataclasses import dataclass
7
+ import struct
8
+
9
+
10
+ # int32 table at 0x002593a8: only entries equal to 1 consume an operand.
11
+ OPERAND_OPCODES = frozenset({
12
+ 0x01, 0x19, 0x1A, 0x1B, 0x1E, 0x1F, 0x20, 0x23,
13
+ 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x40, 0x41, 0x5C,
14
+ })
15
+
16
+
17
+ class KiwiFormatError(ValueError):
18
+ """Unsupported or truncated KiWi input, with a file byte offset."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class BytecodeInstruction:
23
+ pc: int
24
+ byte_offset: int
25
+ opcode: int
26
+ operand: int | None
27
+
28
+ @property
29
+ def yield_id(self) -> int | None:
30
+ if self.opcode == 0x1E:
31
+ return self.operand
32
+ if self.opcode == 0x1F:
33
+ return self.operand >> 8
34
+ return None
35
+
36
+ def branch_target(self) -> int | None:
37
+ """Statically encoded target in logical instruction indices.
38
+
39
+ Native PC arithmetic wraps at 16 bits. Dynamic return opcode 0x43
40
+ is deliberately not assigned a guessed target.
41
+ """
42
+ if self.opcode == 0x29:
43
+ return self.operand
44
+ if self.opcode in {0x28, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E}:
45
+ return (self.pc + self.operand) & 0xFFFF
46
+ if self.opcode == 0x5C:
47
+ return (self.pc + (self.operand >> 8)) & 0xFFFF
48
+ return None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class KiwiProgram:
53
+ version: int
54
+ previous_flag: int
55
+ previous_data_count: int | None
56
+ previous_instruction_count: int | None
57
+ flag: int
58
+ main_words: tuple[int, ...]
59
+ gap_count: int
60
+ extra_words: tuple[int, ...]
61
+ instructions: tuple[BytecodeInstruction, ...]
62
+
63
+ def to_bytes(self) -> bytes:
64
+ """Re-encode every stored field without interpreting data words."""
65
+ header = b'kiwi' + bytes((self.version, self.previous_flag))
66
+ if self.previous_flag:
67
+ header += struct.pack('>HH', self.previous_data_count,
68
+ self.previous_instruction_count)
69
+ header += bytes((self.flag,))
70
+ header += struct.pack('>4H', len(self.main_words), self.gap_count,
71
+ len(self.extra_words), len(self.instructions))
72
+ words = self.main_words + self.extra_words
73
+ body = struct.pack(f'>{len(words)}H', *words)
74
+ code = bytearray()
75
+ for instruction in self.instructions:
76
+ code.append(instruction.opcode)
77
+ if instruction.operand is not None:
78
+ code.extend(struct.pack('>H', instruction.operand))
79
+ return header + body + code
80
+
81
+
82
+ def decode_program(data: bytes) -> KiwiProgram:
83
+ """Decode v2 strictly; never turn truncated input into invented operands."""
84
+ pos = 0
85
+
86
+ def take(size):
87
+ nonlocal pos
88
+ if pos + size > len(data):
89
+ raise KiwiFormatError(f'truncated KiWi at byte 0x{pos:x}: need {size} bytes')
90
+ result = data[pos:pos + size]
91
+ pos += size
92
+ return result
93
+
94
+ def u16():
95
+ return struct.unpack('>H', take(2))[0]
96
+
97
+ if take(4) != b'kiwi':
98
+ raise KiwiFormatError('invalid KiWi signature at byte 0x0')
99
+ version = take(1)[0]
100
+ if version != 2:
101
+ raise KiwiFormatError(f'unsupported KiWi version {version} at byte 0x4')
102
+ previous_flag = take(1)[0]
103
+ previous_data = u16() if previous_flag else None
104
+ previous_code = u16() if previous_flag else None
105
+ flag = take(1)[0]
106
+ main, gap, extra, count = (u16() for _ in range(4))
107
+ main_words = tuple(u16() for _ in range(main))
108
+ extra_words = tuple(u16() for _ in range(extra))
109
+ instructions = []
110
+ base_pc = previous_code or 0
111
+ for index in range(count):
112
+ offset = pos
113
+ opcode = take(1)[0]
114
+ operand = u16() if opcode in OPERAND_OPCODES else None
115
+ instructions.append(BytecodeInstruction(base_pc + index, offset, opcode, operand))
116
+ if pos != len(data):
117
+ raise KiwiFormatError(f'{len(data) - pos} trailing bytes at byte 0x{pos:x}')
118
+ return KiwiProgram(version, previous_flag, previous_data, previous_code,
119
+ flag, main_words, gap, extra_words, tuple(instructions))
exp_runtime/exp.py ADDED
@@ -0,0 +1,121 @@
1
+ """Validated EXP containers, independent of game profiles and presentation."""
2
+ from dataclasses import dataclass
3
+ from functools import lru_cache
4
+ import lzma
5
+ import struct
6
+
7
+ from .decode.bytecode import decode_program
8
+
9
+ MAX_PAYLOAD = 64 * 1024 * 1024
10
+
11
+
12
+ class ContentError(ValueError):
13
+ """Missing, incompatible, or malformed user content."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Metadata:
18
+ pack_id: int
19
+ episode_id: int
20
+ titles: tuple[str, ...]
21
+
22
+ @property
23
+ def title(self):
24
+ return self.titles[0]
25
+
26
+
27
+ class ExpArchive:
28
+ """Validated, exact-ID CSPUD reader; native grouped fallback is unsupported."""
29
+
30
+ def __init__(self, data: bytes):
31
+ self.data = data
32
+ if len(data) < 9 or data[:5] != b'CSPUD':
33
+ raise ContentError('Not a CSPUD EXP archive')
34
+ count, = struct.unpack_from('>I', data, 5)
35
+ index_end = 9 + 6 * count
36
+ if not count or index_end > len(data):
37
+ raise ContentError('Invalid EXP index extent')
38
+ self.entries = {}
39
+ extents = {}
40
+ for i in range(count):
41
+ resource_id, offset = struct.unpack_from('>HI', data, 9 + 6 * i)
42
+ if resource_id in self.entries:
43
+ raise ContentError(f'Duplicate EXP resource ID {resource_id}')
44
+ if offset < index_end or offset + 12 > len(data):
45
+ raise ContentError(f'Invalid record offset for resource {resource_id}')
46
+ stored, raw, flags = struct.unpack_from('>III', data, offset)
47
+ end = offset + 12 + stored
48
+ if end > len(data) or raw > MAX_PAYLOAD or stored > MAX_PAYLOAD:
49
+ raise ContentError(f'Invalid record size for resource {resource_id}')
50
+ if flags & ~1:
51
+ raise ContentError(f'Unsupported EXP flags 0x{flags:x}')
52
+ if not flags & 1 and stored != raw:
53
+ raise ContentError('Literal EXP record has conflicting sizes')
54
+ self.entries[resource_id] = offset
55
+ extents[offset] = end
56
+ last_end = index_end
57
+ for start, end in sorted(extents.items()):
58
+ if start < last_end:
59
+ raise ContentError('Partially overlapping EXP records')
60
+ last_end = end
61
+
62
+ @lru_cache(maxsize=32)
63
+ def read(self, resource_id: int) -> bytes:
64
+ try:
65
+ offset = self.entries[resource_id]
66
+ except KeyError:
67
+ raise ContentError(f'EXP resource {resource_id} is absent; grouped lookup is unsupported') from None
68
+ stored, raw, flags = struct.unpack_from('>III', self.data, offset)
69
+ payload = self.data[offset + 12:offset + 12 + stored]
70
+ if not flags & 1:
71
+ return payload
72
+ if len(payload) < 13:
73
+ raise ContentError('Truncated EXP LZMA wrapper')
74
+ prop, dictionary, size1, size2 = struct.unpack_from('<BIII', payload)
75
+ if prop >= 225 or dictionary > MAX_PAYLOAD or size1 != raw or size2 != raw:
76
+ raise ContentError('Inconsistent or unsupported EXP LZMA header')
77
+ try:
78
+ # EXP supplies the output boundary; native streams need no end
79
+ # marker. Raw decoding also avoids older liblzma versions rejecting
80
+ # a known-size Alone header when an encoder includes an end marker.
81
+ decoder = lzma.LZMADecompressor(format=lzma.FORMAT_RAW, filters=[dict(
82
+ id=lzma.FILTER_LZMA1, dict_size=max(4096, dictionary),
83
+ lc=prop % 9, lp=(prop // 9) % 5, pb=prop // 45)])
84
+ result = decoder.decompress(payload[13:], max_length=raw)
85
+ except lzma.LZMAError as error:
86
+ raise ContentError(f'Invalid EXP LZMA stream: {error}') from error
87
+ if len(result) != raw:
88
+ raise ContentError('Truncated or oversized decoded EXP payload')
89
+ return result
90
+
91
+ def metadata(self) -> Metadata:
92
+ data = self.read(1)
93
+ if len(data) < 4:
94
+ raise ContentError('Truncated episode metadata')
95
+ pack, episode = struct.unpack_from('>HH', data)
96
+ pos, titles = 4, []
97
+ try:
98
+ for _ in range(5):
99
+ length, = struct.unpack_from('>H', data, pos)
100
+ pos += 2
101
+ if pos + length > len(data):
102
+ raise ContentError('Truncated localized title')
103
+ titles.append(data[pos:pos + length].decode('utf-8'))
104
+ pos += length
105
+ except (struct.error, UnicodeError) as error:
106
+ raise ContentError('Invalid localized episode metadata') from error
107
+ if pos != len(data):
108
+ raise ContentError('Unsupported trailing episode metadata')
109
+ return Metadata(pack, episode, tuple(titles))
110
+
111
+ def programs(self):
112
+ programs = {}
113
+ for resource_id in self.entries:
114
+ data = self.read(resource_id)
115
+ if data.startswith(b'kiwi'):
116
+ try:
117
+ programs[resource_id] = decode_program(data)
118
+ except ValueError as error:
119
+ raise ContentError(f'Invalid KiWi resource {resource_id}: {error}') from error
120
+ return programs
121
+