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.
- exp_runtime_formats-0.5.0/LICENSE +21 -0
- exp_runtime_formats-0.5.0/MANIFEST.in +4 -0
- exp_runtime_formats-0.5.0/PKG-INFO +48 -0
- exp_runtime_formats-0.5.0/README.md +15 -0
- exp_runtime_formats-0.5.0/pyproject.toml +27 -0
- exp_runtime_formats-0.5.0/setup.cfg +4 -0
- exp_runtime_formats-0.5.0/src/exp_runtime/__main__.py +5 -0
- exp_runtime_formats-0.5.0/src/exp_runtime/atlas.py +203 -0
- exp_runtime_formats-0.5.0/src/exp_runtime/cli.py +186 -0
- exp_runtime_formats-0.5.0/src/exp_runtime/decode/__init__.py +1 -0
- exp_runtime_formats-0.5.0/src/exp_runtime/decode/bytecode.py +119 -0
- exp_runtime_formats-0.5.0/src/exp_runtime/exp.py +121 -0
- exp_runtime_formats-0.5.0/src/exp_runtime/fonts.py +280 -0
- exp_runtime_formats-0.5.0/src/exp_runtime/formats.py +18 -0
- exp_runtime_formats-0.5.0/src/exp_runtime/languages.py +23 -0
- exp_runtime_formats-0.5.0/src/exp_runtime/menu_assets.py +85 -0
- exp_runtime_formats-0.5.0/src/exp_runtime/ui_assets.py +285 -0
- exp_runtime_formats-0.5.0/src/exp_runtime_formats.egg-info/SOURCES.txt +15 -0
|
@@ -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,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,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))
|