exp-runtime-formats 0.5.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Josh Mcdaniel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ # Only authored component source, metadata and license.
2
+ global-exclude *
3
+ include LICENSE README.md pyproject.toml MANIFEST.in
4
+ recursive-include src/exp_runtime *.py
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: exp-runtime-formats
3
+ Version: 0.5.0
4
+ Summary: EXP containers, KiWi decoding and native resource parsers
5
+ License: MIT License
6
+
7
+ Copyright (c) 2025 Josh Mcdaniel
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Requires-Python: >=3.10
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Provides-Extra: images
31
+ Requires-Dist: Pillow>=10; extra == "images"
32
+ Dynamic: license-file
33
+
34
+ # EXP Runtime — formats
35
+
36
+ EXP containers, KiWi decoding and native resource parsers. This is a component of the single
37
+ [EXP Runtime project](https://github.com/joshrmcdaniel/exp-runtime), maintained
38
+ and released together with its other wheels. Python imports use the shared
39
+ `exp_runtime` namespace.
40
+
41
+ Install `exp-runtime[tools]` for file tools, `exp-runtime[engine,shs]` or
42
+ `exp-runtime[engine,cod]` for a headless game, or `exp-runtime[player]` for
43
+ the desktop player with both games. Individual component wheels also support
44
+ selective installs. See the project's `docs/SDK.md` for APIs and examples.
45
+
46
+ No original game content is included. Users supply their own game files.
47
+ The inherited MIT license and attribution are included in LICENSE; see
48
+ [provenance](https://github.com/joshrmcdaniel/exp-runtime/blob/main/docs/PROVENANCE.md).
@@ -0,0 +1,15 @@
1
+ # EXP Runtime — formats
2
+
3
+ EXP containers, KiWi decoding and native resource parsers. This is a component of the single
4
+ [EXP Runtime project](https://github.com/joshrmcdaniel/exp-runtime), maintained
5
+ and released together with its other wheels. Python imports use the shared
6
+ `exp_runtime` namespace.
7
+
8
+ Install `exp-runtime[tools]` for file tools, `exp-runtime[engine,shs]` or
9
+ `exp-runtime[engine,cod]` for a headless game, or `exp-runtime[player]` for
10
+ the desktop player with both games. Individual component wheels also support
11
+ selective installs. See the project's `docs/SDK.md` for APIs and examples.
12
+
13
+ No original game content is included. Users supply their own game files.
14
+ The inherited MIT license and attribution are included in LICENSE; see
15
+ [provenance](https://github.com/joshrmcdaniel/exp-runtime/blob/main/docs/PROVENANCE.md).
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools >= 61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "exp-runtime-formats"
7
+ version = "0.5.0"
8
+ description = "EXP containers, KiWi decoding and native resource parsers"
9
+ readme = "README.md"
10
+ license = {file = "LICENSE"}
11
+ requires-python = ">=3.10"
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ images = ["Pillow>=10"]
16
+
17
+ [project.scripts]
18
+ exp-tool = "exp_runtime.cli:main"
19
+ shs-tool = "exp_runtime.cli:main"
20
+
21
+ [tool.setuptools]
22
+ include-package-data = false
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+ include = ["exp_runtime", "exp_runtime.*"]
27
+ namespaces = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Allow running the tools as ``python -m exp_runtime``."""
2
+
3
+ from .cli import main
4
+
5
+ main()
@@ -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
@@ -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))