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.
- exp_runtime/__main__.py +5 -0
- exp_runtime/atlas.py +203 -0
- exp_runtime/cli.py +186 -0
- exp_runtime/decode/__init__.py +1 -0
- exp_runtime/decode/bytecode.py +119 -0
- exp_runtime/exp.py +121 -0
- exp_runtime/fonts.py +280 -0
- exp_runtime/formats.py +18 -0
- exp_runtime/languages.py +23 -0
- exp_runtime/menu_assets.py +85 -0
- exp_runtime/ui_assets.py +285 -0
- exp_runtime_formats-0.5.0.dist-info/METADATA +48 -0
- exp_runtime_formats-0.5.0.dist-info/RECORD +17 -0
- exp_runtime_formats-0.5.0.dist-info/WHEEL +5 -0
- exp_runtime_formats-0.5.0.dist-info/entry_points.txt +3 -0
- exp_runtime_formats-0.5.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_formats-0.5.0.dist-info/top_level.txt +1 -0
exp_runtime/__main__.py
ADDED
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
|
+
|