exp-runtime-engine 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exp_runtime/audio.py +13 -0
- exp_runtime/background.py +58 -0
- exp_runtime/character_picker.py +92 -0
- exp_runtime/choice.py +116 -0
- exp_runtime/choice_hints.py +153 -0
- exp_runtime/content.py +742 -0
- exp_runtime/dialogue.py +151 -0
- exp_runtime/dialogue_animation.py +345 -0
- exp_runtime/dialogue_notice.py +51 -0
- exp_runtime/engine.py +609 -0
- exp_runtime/episode_catalog.py +171 -0
- exp_runtime/game_state.py +9 -0
- exp_runtime/games.py +35 -0
- exp_runtime/ios_fonts.py +228 -0
- exp_runtime/launcher.py +92 -0
- exp_runtime/loading.py +37 -0
- exp_runtime/menu.py +177 -0
- exp_runtime/message_panel.py +36 -0
- exp_runtime/minigames.py +76 -0
- exp_runtime/randomness.py +61 -0
- exp_runtime/rar.py +145 -0
- exp_runtime/relationships.py +131 -0
- exp_runtime/runtime.py +879 -0
- exp_runtime/scene_badge.py +65 -0
- exp_runtime/speaker_names.py +258 -0
- exp_runtime/text_input.py +55 -0
- exp_runtime/title_screen.py +104 -0
- exp_runtime/trace.py +76 -0
- exp_runtime/vm.py +391 -0
- exp_runtime_engine-0.5.0.dist-info/METADATA +49 -0
- exp_runtime_engine-0.5.0.dist-info/RECORD +34 -0
- exp_runtime_engine-0.5.0.dist-info/WHEEL +5 -0
- exp_runtime_engine-0.5.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_engine-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Original episode categories retained in user-supplied native options files.
|
|
2
|
+
|
|
3
|
+
Game profiles select the verified save envelopes; episode records share the
|
|
4
|
+
same wire format. No original catalog ships with the app.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
import struct
|
|
9
|
+
|
|
10
|
+
from .content import ContentError
|
|
11
|
+
from .games import GAMES
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MAX_ENTRIES = 4096
|
|
15
|
+
MAX_CATALOG_BYTES = 4 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def basename(filename):
|
|
19
|
+
# Catalog filenames identify records only. Never open their paths.
|
|
20
|
+
return PurePosixPath(filename.replace('\\', '/')).name.casefold()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class CatalogEntry:
|
|
25
|
+
pack_id: int
|
|
26
|
+
episode_id: int
|
|
27
|
+
title: str
|
|
28
|
+
category: str
|
|
29
|
+
filename: str
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def key(self):
|
|
33
|
+
return self.pack_id, self.episode_id, self.title, basename(self.filename)
|
|
34
|
+
|
|
35
|
+
def validate(self):
|
|
36
|
+
if (any(type(value) is not int or not 0 <= value <= 65535
|
|
37
|
+
for value in (self.pack_id, self.episode_id))
|
|
38
|
+
or any(not isinstance(value, str) or len(value) > 32767 or '\0' in value
|
|
39
|
+
for value in (self.title, self.category, self.filename))):
|
|
40
|
+
raise ContentError('Invalid episode catalog entry')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EpisodeCatalog:
|
|
44
|
+
def __init__(self, entries=()):
|
|
45
|
+
unique = {}
|
|
46
|
+
for entry in entries:
|
|
47
|
+
entry.validate()
|
|
48
|
+
unique[entry.key] = entry
|
|
49
|
+
if len(unique) > MAX_ENTRIES:
|
|
50
|
+
raise ContentError('Episode catalog contains too many entries')
|
|
51
|
+
self.entries = tuple(unique.values())
|
|
52
|
+
self.by_filename, self.by_title = {}, {}
|
|
53
|
+
for entry in self.entries:
|
|
54
|
+
if entry.filename:
|
|
55
|
+
self.by_filename.setdefault(basename(entry.filename), []).append(entry)
|
|
56
|
+
key = entry.pack_id, entry.episode_id, entry.title
|
|
57
|
+
self.by_title.setdefault(key, []).append(entry)
|
|
58
|
+
|
|
59
|
+
def merge(self, other):
|
|
60
|
+
return EpisodeCatalog((*self.entries, *other.entries))
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_data(cls, data):
|
|
64
|
+
if not isinstance(data, list) or len(data) > MAX_ENTRIES:
|
|
65
|
+
raise ContentError('Invalid episode catalog in library')
|
|
66
|
+
try:
|
|
67
|
+
return cls(CatalogEntry(**entry) for entry in data)
|
|
68
|
+
except (TypeError, AttributeError) as error:
|
|
69
|
+
raise ContentError('Invalid episode catalog in library') from error
|
|
70
|
+
|
|
71
|
+
def to_data(self):
|
|
72
|
+
return [asdict(entry) for entry in self.entries]
|
|
73
|
+
|
|
74
|
+
def category(self, record):
|
|
75
|
+
names = {basename(name) for name in (record['name'], *record.get('aliases', []))}
|
|
76
|
+
candidates = [entry for name in names for entry in self.by_filename.get(name, ())]
|
|
77
|
+
if candidates:
|
|
78
|
+
# A catalog filename can recur in different downloads. Prefer the
|
|
79
|
+
# matching title; filenames still handle native synthetic IDs for
|
|
80
|
+
# the bundled Season 1 and novel entries.
|
|
81
|
+
candidates = [e for e in candidates if e.title == record['titles'][0]] or candidates
|
|
82
|
+
else:
|
|
83
|
+
candidates = self.by_title.get((record['pack_id'], record['episode_id'], record['titles'][0]), ())
|
|
84
|
+
categories = {e.category for e in candidates if e.category.strip()}
|
|
85
|
+
return next(iter(categories)) if len(categories) == 1 else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Reader:
|
|
89
|
+
def __init__(self, data, position):
|
|
90
|
+
self.data, self.position = data, position
|
|
91
|
+
|
|
92
|
+
def take(self, count):
|
|
93
|
+
end = self.position + count
|
|
94
|
+
if count < 0 or end > len(self.data):
|
|
95
|
+
raise ContentError('Truncated native episode catalog')
|
|
96
|
+
value = self.data[self.position:end]
|
|
97
|
+
self.position = end
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
def integer(self, format):
|
|
101
|
+
return struct.unpack(format, self.take(struct.calcsize(format)))[0]
|
|
102
|
+
|
|
103
|
+
def boolean(self):
|
|
104
|
+
value = self.integer('B')
|
|
105
|
+
if value not in (0, 1):
|
|
106
|
+
raise ContentError('Invalid native catalog presence flag')
|
|
107
|
+
return bool(value)
|
|
108
|
+
|
|
109
|
+
def string(self):
|
|
110
|
+
raw = self.take(self.integer('>H'))
|
|
111
|
+
if len(raw) > 32767 or b'\0' in raw:
|
|
112
|
+
raise ContentError('Invalid native catalog string')
|
|
113
|
+
try:
|
|
114
|
+
return raw.decode('utf-8')
|
|
115
|
+
except UnicodeDecodeError:
|
|
116
|
+
# Legacy saves contain byte characters in some translated titles.
|
|
117
|
+
# EXP resource 1 remains strictly UTF-8; this is a different format.
|
|
118
|
+
return raw.decode('latin-1')
|
|
119
|
+
|
|
120
|
+
def entry(self):
|
|
121
|
+
if not self.boolean():
|
|
122
|
+
return None
|
|
123
|
+
pack, episode = self.integer('>H'), self.integer('>H')
|
|
124
|
+
titles = [self.string() for _ in range(5)]
|
|
125
|
+
categories = [self.string() for _ in range(5)]
|
|
126
|
+
filename = self.string()
|
|
127
|
+
self.integer('>i') # Native download identifier, not a content hash.
|
|
128
|
+
return CatalogEntry(pack, episode, titles[0], categories[0], basename(filename))
|
|
129
|
+
|
|
130
|
+
def catalog(self, trailing_flags):
|
|
131
|
+
current = self.entry()
|
|
132
|
+
count = self.integer('>i')
|
|
133
|
+
if not 0 <= count <= MAX_ENTRIES:
|
|
134
|
+
raise ContentError('Invalid native catalog entry count')
|
|
135
|
+
entries = [self.entry() for _ in range(count)]
|
|
136
|
+
pairs = self.integer('>h')
|
|
137
|
+
if not 0 <= pairs <= MAX_ENTRIES:
|
|
138
|
+
raise ContentError('Invalid native string-pair count')
|
|
139
|
+
for _ in range(pairs * 2):
|
|
140
|
+
self.string() # Discard native runtime/user strings.
|
|
141
|
+
for _ in range(trailing_flags):
|
|
142
|
+
self.boolean()
|
|
143
|
+
if self.position != len(self.data):
|
|
144
|
+
raise ContentError('Unsupported trailing native options data')
|
|
145
|
+
return EpisodeCatalog(e for e in [current, *entries] if e is not None)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_options_catalog(data, *, game='shs'):
|
|
149
|
+
"""Read categories only; never import native preferences or saved progress.
|
|
150
|
+
|
|
151
|
+
CoD retains the SHS signature but has its own version-17 envelope. Select
|
|
152
|
+
layouts by game, then validate all records, string pairs, flags and EOF;
|
|
153
|
+
never scan for a plausible entry or guess the game from this signature.
|
|
154
|
+
"""
|
|
155
|
+
if game not in GAMES:
|
|
156
|
+
raise ContentError(f'Unknown catalog game: {game!r}')
|
|
157
|
+
profile = GAMES[game]
|
|
158
|
+
if len(data) > MAX_CATALOG_BYTES or len(data) < 16 or data[:12] != b'SHS_OPTIONS\0':
|
|
159
|
+
raise ContentError(f'Not a supported {profile.CATALOG_FILENAME} catalog')
|
|
160
|
+
version = struct.unpack_from('>i', data, 12)[0]
|
|
161
|
+
if version not in profile.CATALOG_LAYOUTS:
|
|
162
|
+
raise ContentError(f'Unsupported native options version: {version}')
|
|
163
|
+
results = []
|
|
164
|
+
for prefix, trailing_flags in profile.CATALOG_LAYOUTS[version]:
|
|
165
|
+
try:
|
|
166
|
+
results.append(_Reader(data, prefix).catalog(trailing_flags))
|
|
167
|
+
except ContentError:
|
|
168
|
+
continue
|
|
169
|
+
if len(results) != 1:
|
|
170
|
+
raise ContentError('Invalid or ambiguous native episode catalog')
|
|
171
|
+
return results[0]
|
exp_runtime/games.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Installed game adapters; each owns its asset and host-service contracts."""
|
|
2
|
+
from importlib import import_module
|
|
3
|
+
from importlib.util import find_spec
|
|
4
|
+
|
|
5
|
+
from .exp import ContentError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GameUnavailableError(ContentError):
|
|
9
|
+
"""The requested game adapter has not been installed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GameRegistry(dict):
|
|
13
|
+
def __missing__(self, key):
|
|
14
|
+
if key in ('shs', 'cod'):
|
|
15
|
+
raise GameUnavailableError(
|
|
16
|
+
f'{key.upper()} support is not installed. Install "exp-runtime[{key}]".')
|
|
17
|
+
raise ContentError(f'Unknown game: {key!r}')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
GAMES = GameRegistry()
|
|
21
|
+
for _key in ('shs', 'cod'):
|
|
22
|
+
# Test the package itself, so a missing dependency *inside* an installed
|
|
23
|
+
# adapter is reported instead of silently hiding a broken installation.
|
|
24
|
+
if find_spec(f'exp_runtime.{_key}') is not None:
|
|
25
|
+
_profile = import_module(f'exp_runtime.{_key}.profile')
|
|
26
|
+
GAMES[_profile.KEY] = _profile
|
|
27
|
+
SHS_ANDROID = 'shs-android-1.0.9'
|
|
28
|
+
SHS_IOS = 'shs-ios-assets-v1'
|
|
29
|
+
COD_IOS = 'cod-ios-assets-v1'
|
|
30
|
+
PROFILES = {SHS_ANDROID: ('shs', 'apk'), SHS_IOS: ('shs', 'ipa'), COD_IOS: ('cod', 'ipa')}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def game_id(resources):
|
|
34
|
+
library = getattr(resources, 'library', resources)
|
|
35
|
+
return getattr(library, 'game_id', 'shs')
|
exp_runtime/ios_fonts.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Local font faces used by the iOS app; no font data is distributed.
|
|
2
|
+
|
|
3
|
+
The IPA bundles Pajama Hip (505) and MonoNumerals (504). Its other fonts are
|
|
4
|
+
system faces, looked up by SHSEngine::initFonts at 0003c440. Desktop glyph
|
|
5
|
+
rasterization uses FreeType/Pillow, not the original CoreGraphics renderer.
|
|
6
|
+
See docs/IPA.md for the resulting fidelity limits.
|
|
7
|
+
"""
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
import base64
|
|
10
|
+
from io import BytesIO
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import struct
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
21
|
+
|
|
22
|
+
from .fonts import BitmapFont, FontError, Glyph
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class IOSFont(BitmapFont):
|
|
27
|
+
cap_height: float
|
|
28
|
+
descent: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _design_metrics(font, source, index):
|
|
32
|
+
"""Read SFNT units, as CSFont uses CGFont metrics before rasterization.
|
|
33
|
+
|
|
34
|
+
Older supplied/system faces have no OS/2 cap-height field. CoreGraphics
|
|
35
|
+
averages the H/O tops for these faces; measure at the font's design size
|
|
36
|
+
without point-size rounding (verified against local CGFont metrics).
|
|
37
|
+
Pillow still owns font decoding, including glyph selection and advances.
|
|
38
|
+
"""
|
|
39
|
+
data = source.getvalue() if isinstance(source, BytesIO) else Path(source).read_bytes()
|
|
40
|
+
try:
|
|
41
|
+
offset = struct.unpack_from('>I', data, 12 + index * 4)[0] if data[:4] == b'ttcf' else 0
|
|
42
|
+
count = struct.unpack_from('>H', data, offset + 4)[0]
|
|
43
|
+
tables = {}
|
|
44
|
+
for n in range(count):
|
|
45
|
+
tag, _, start, length = struct.unpack_from('>4sIII', data, offset + 12 + n * 16)
|
|
46
|
+
if start + length > len(data):
|
|
47
|
+
raise ValueError('Truncated font table')
|
|
48
|
+
tables[tag] = data[start:start + length]
|
|
49
|
+
units = struct.unpack_from('>H', tables[b'head'], 18)[0]
|
|
50
|
+
if not 16 <= units <= 16384:
|
|
51
|
+
raise ValueError('Invalid font units per em')
|
|
52
|
+
design = font.font_variant(size=units)
|
|
53
|
+
cap = 0
|
|
54
|
+
os2 = tables.get(b'OS/2', b'')
|
|
55
|
+
if len(os2) >= 90 and struct.unpack_from('>H', os2)[0] >= 2:
|
|
56
|
+
cap = struct.unpack_from('>h', os2, 88)[0]
|
|
57
|
+
if cap <= 0:
|
|
58
|
+
cap = sum(-design.getbbox(c, anchor='ls')[1] for c in 'HO') // 2
|
|
59
|
+
descent = -struct.unpack_from('>h', tables[b'hhea'], 6)[0]
|
|
60
|
+
return design, units, cap, descent
|
|
61
|
+
except (KeyError, struct.error, ValueError) as error:
|
|
62
|
+
raise FontError(f'Cannot read IPA font metrics: {error}') from error
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class FontFace:
|
|
67
|
+
family: str
|
|
68
|
+
style: str
|
|
69
|
+
filenames: tuple[str, ...]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
FACES = {
|
|
73
|
+
'PajamaHip': FontFace('Pajama Hip', 'Regular', ('PajamaHip.ttf', 'Pajama Hip.ttf')),
|
|
74
|
+
'ArialRoundedMTBold': FontFace('Arial Rounded MT Bold', 'Regular',
|
|
75
|
+
('Arial Rounded Bold.ttf', 'ARLRDBD.TTF')),
|
|
76
|
+
'ArialMT': FontFace('Arial', 'Regular', ('Arial.ttf', 'arial.ttf')),
|
|
77
|
+
'TrebuchetMS_Bold': FontFace('Trebuchet MS', 'Bold', ('Trebuchet MS Bold.ttf', 'trebucbd.ttf')),
|
|
78
|
+
'TrebuchetMS_Italic': FontFace('Trebuchet MS', 'Italic', ('Trebuchet MS Italic.ttf', 'trebucit.ttf')),
|
|
79
|
+
'VerdanaBoldItalic': FontFace('Verdana', 'Bold Italic', ('Verdana Bold Italic.ttf', 'verdanaz.ttf')),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _font_directories():
|
|
84
|
+
paths = [Path('/System/Library/Fonts/Supplemental'), Path('/System/Library/Fonts'),
|
|
85
|
+
Path('/Library/Fonts'), Path.home() / 'Library/Fonts',
|
|
86
|
+
Path('/usr/share/fonts/truetype/msttcorefonts'),
|
|
87
|
+
Path('/usr/share/fonts/truetype/msttcorefonts-installer'),
|
|
88
|
+
Path.home() / '.fonts', Path.home() / '.local/share/fonts']
|
|
89
|
+
if os.environ.get('WINDIR'):
|
|
90
|
+
paths.append(Path(os.environ['WINDIR']) / 'Fonts')
|
|
91
|
+
if os.environ.get('LOCALAPPDATA'):
|
|
92
|
+
paths.append(Path(os.environ['LOCALAPPDATA']) / 'Microsoft/Windows/Fonts')
|
|
93
|
+
return paths
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _candidates(face):
|
|
97
|
+
wanted = {name.casefold() for name in face.filenames}
|
|
98
|
+
for directory in _font_directories():
|
|
99
|
+
try:
|
|
100
|
+
for path in sorted(directory.iterdir()):
|
|
101
|
+
if path.name.casefold() in wanted and path.is_file():
|
|
102
|
+
yield path, 0
|
|
103
|
+
except OSError:
|
|
104
|
+
continue
|
|
105
|
+
# Fontconfig supports arbitrary Linux/user font locations. Its result can
|
|
106
|
+
# be a substitute, so resolve_face verifies the actual face before use.
|
|
107
|
+
try:
|
|
108
|
+
result = subprocess.run(['fc-match', '-f', '%{file}\n%{index}\n',
|
|
109
|
+
f'{face.family}:style={face.style}'],
|
|
110
|
+
capture_output=True, text=True, timeout=5, check=True)
|
|
111
|
+
lines = result.stdout.splitlines()
|
|
112
|
+
if len(lines) == 2 and lines[1].isdigit():
|
|
113
|
+
yield Path(lines[0]), int(lines[1])
|
|
114
|
+
except (OSError, subprocess.SubprocessError, UnicodeError):
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_face(face):
|
|
119
|
+
"""Prefer the original face, then use the platform's default UI font."""
|
|
120
|
+
for path, index in _candidates(face):
|
|
121
|
+
try:
|
|
122
|
+
font = ImageFont.truetype(str(path), 16, index=index, layout_engine=ImageFont.Layout.BASIC)
|
|
123
|
+
if tuple(part.casefold() for part in font.getname()) == (face.family.casefold(), face.style.casefold()):
|
|
124
|
+
return path, index
|
|
125
|
+
except (OSError, ValueError):
|
|
126
|
+
continue
|
|
127
|
+
for path, index in _system_default(face):
|
|
128
|
+
try:
|
|
129
|
+
font = ImageFont.truetype(str(path), 16, index=index, layout_engine=ImageFont.Layout.BASIC)
|
|
130
|
+
except (OSError, ValueError):
|
|
131
|
+
continue
|
|
132
|
+
logging.warning('IPA font %s (%s) is unavailable; using system font %s (%s)',
|
|
133
|
+
face.family, face.style, *font.getname())
|
|
134
|
+
return path, index
|
|
135
|
+
raise FontError(f'No usable system font is installed for {face.family} ({face.style}). '
|
|
136
|
+
'Install a system UI font, then reopen the library. '
|
|
137
|
+
'IPA asset import does not require fonts.')
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _system_default(face):
|
|
141
|
+
if sys.platform == 'darwin':
|
|
142
|
+
for filename in (('SFNSItalic.ttf', 'SFNS.ttf') if face.style == 'Italic' else
|
|
143
|
+
('SFNS.ttf', 'SFNSDisplay.ttf')):
|
|
144
|
+
yield Path('/System/Library/Fonts') / filename, 0
|
|
145
|
+
elif sys.platform == 'win32':
|
|
146
|
+
directory = Path(os.environ.get('WINDIR', 'C:/Windows')) / 'Fonts'
|
|
147
|
+
filename = {'Bold': 'segoeuib.ttf', 'Italic': 'segoeuii.ttf'}.get(face.style, 'segoeui.ttf')
|
|
148
|
+
yield directory / filename, 0
|
|
149
|
+
yield directory / 'segoeui.ttf', 0
|
|
150
|
+
else:
|
|
151
|
+
# Ask fontconfig for its configured generic sans family. Here a
|
|
152
|
+
# substitution is intentional: this is the user's requested fallback.
|
|
153
|
+
generic = FontFace('sans-serif', face.style, ())
|
|
154
|
+
yield from _candidates(generic)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def render_font(library, name):
|
|
158
|
+
"""Return a text-layout font and RGBA atlas, cached only in this library."""
|
|
159
|
+
cache = getattr(library, '_ios_fonts', None)
|
|
160
|
+
if cache is None:
|
|
161
|
+
library._ios_fonts = cache = {}
|
|
162
|
+
if name in cache:
|
|
163
|
+
return cache[name]
|
|
164
|
+
color, stroke, source, index = (255, 255, 255, 255), 0, None, 0
|
|
165
|
+
if name in ('PajamaHip24', 'PajamaHip26', 'PajamaHip266', 'PajamaHipG26', 'PajamaHipY26', 'PajamaHipS26'):
|
|
166
|
+
from .games import GAMES, game_id
|
|
167
|
+
size = 24 if name == 'PajamaHip24' else 26
|
|
168
|
+
face, bundled = GAMES[game_id(library)].NAME_FONT
|
|
169
|
+
if bundled in library.base_members:
|
|
170
|
+
source = BytesIO(library.read_asset(bundled))
|
|
171
|
+
color = (*GAMES[game_id(library)].NAME_COLORS[name], 255)
|
|
172
|
+
stroke = 0 if size == 24 else 2
|
|
173
|
+
else:
|
|
174
|
+
match = re.fullmatch(r'(ArialRoundedMTBold|ArialMT|TrebuchetMS_Bold|TrebuchetMS_Italic)(11|14|15|16|20|22|28|30)', name)
|
|
175
|
+
if match is None:
|
|
176
|
+
raise FontError(f'Unsupported IPA font role: {name}')
|
|
177
|
+
face, size = match[1], int(match[2])
|
|
178
|
+
if sys.platform == 'ios':
|
|
179
|
+
# The host looks up the original system face, then the system default.
|
|
180
|
+
# Layout roles, colors and glyph history stay in this shared module.
|
|
181
|
+
import exp_platform
|
|
182
|
+
request = dict(operation='atlas', face=face, size=size, color=color, stroke=stroke)
|
|
183
|
+
if source is not None:
|
|
184
|
+
request['data'] = base64.b64encode(source.getvalue()).decode('ascii')
|
|
185
|
+
native = json.loads(exp_platform.font(json.dumps(request)))
|
|
186
|
+
glyphs = {values[0]: Glyph(*values) for values in native['glyphs']}
|
|
187
|
+
glyphs[202] = Glyph(202, 0, 0, 0, 0, 0, 0, glyphs[32].advance)
|
|
188
|
+
atlas = Image.open(BytesIO(base64.b64decode(native['png']))).convert('RGBA')
|
|
189
|
+
result = IOSFont(name, size, size, name + '.png', glyphs, {}, native['cap'], native['descent']), atlas
|
|
190
|
+
cache[name] = result
|
|
191
|
+
return result
|
|
192
|
+
if source is None:
|
|
193
|
+
sources = getattr(library, '_ios_font_sources', None)
|
|
194
|
+
if sources is None:
|
|
195
|
+
library._ios_font_sources = sources = {}
|
|
196
|
+
if face not in sources:
|
|
197
|
+
sources[face] = resolve_face(FACES[face])
|
|
198
|
+
path, index = sources[face]
|
|
199
|
+
source = str(path)
|
|
200
|
+
try:
|
|
201
|
+
# BASIC disables shaping across byte glyphs, matching KiWi's
|
|
202
|
+
# individual-character path.
|
|
203
|
+
font = ImageFont.truetype(source, size, index=index, layout_engine=ImageFont.Layout.BASIC)
|
|
204
|
+
except (OSError, ValueError) as error:
|
|
205
|
+
raise FontError(f'Cannot open IPA font {name}: {error}') from error
|
|
206
|
+
design, units, cap, descent = _design_metrics(font, source, index)
|
|
207
|
+
glyphs = {}
|
|
208
|
+
cell = (size + stroke * 2) * 3
|
|
209
|
+
atlas = Image.new('RGBA', (cell * 16, cell * 16))
|
|
210
|
+
draw = ImageDraw.Draw(atlas)
|
|
211
|
+
for code in range(32, 256):
|
|
212
|
+
if 127 <= code < 160:
|
|
213
|
+
continue
|
|
214
|
+
char = chr(code)
|
|
215
|
+
left, top, right, bottom = font.getbbox(char, anchor='ls', stroke_width=stroke)
|
|
216
|
+
x, y = code % 16 * cell, code // 16 * cell
|
|
217
|
+
if right - left > cell or bottom - top > cell:
|
|
218
|
+
raise FontError(f'IPA glyph exceeds its atlas cell: {name} U+{code:04X}')
|
|
219
|
+
draw.text((x - left, y - top), char, font=font, anchor='ls', fill=color,
|
|
220
|
+
stroke_width=stroke, stroke_fill=(255, 255, 255, 255))
|
|
221
|
+
glyphs[code] = Glyph(code, x, y, right - left, bottom - top,
|
|
222
|
+
left, size + top, design.getlength(char) * size / units)
|
|
223
|
+
# SHS text is byte-oriented. 0xCA is a nonbreaking space in CSFontInfo.
|
|
224
|
+
glyphs[202] = Glyph(202, 0, 0, 0, 0, 0, 0, glyphs[32].advance)
|
|
225
|
+
result = IOSFont(name, size, size, name + '.png', glyphs, {},
|
|
226
|
+
cap * size / units, descent * size / units), atlas
|
|
227
|
+
cache[name] = result
|
|
228
|
+
return result
|
exp_runtime/launcher.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Per-game library locations, with read-only discovery of legacy SHS paths."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
from .games import GAMES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def user_data_directory() -> Path:
|
|
12
|
+
if sys.platform == 'ios':
|
|
13
|
+
return Path.home() / 'Documents' / 'EXP Runtime'
|
|
14
|
+
if sys.platform == 'darwin':
|
|
15
|
+
root = Path.home() / 'Library' / 'Application Support'
|
|
16
|
+
elif sys.platform == 'win32':
|
|
17
|
+
root = Path(os.environ.get('LOCALAPPDATA', Path.home() / 'AppData' / 'Local'))
|
|
18
|
+
else:
|
|
19
|
+
root = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local' / 'share'))
|
|
20
|
+
return root / 'EXP Runtime'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def library_locations():
|
|
24
|
+
root = user_data_directory()
|
|
25
|
+
locations = {}
|
|
26
|
+
for directory in (root.with_name('SHS Runtime'), root):
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads((directory / 'launcher.json').read_text(encoding='utf-8'))
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
continue
|
|
31
|
+
if data.get('version') == 1:
|
|
32
|
+
entries = {'shs': data.get('library')}
|
|
33
|
+
elif data.get('version') == 2 and isinstance(data.get('libraries'), dict):
|
|
34
|
+
entries = data['libraries']
|
|
35
|
+
else:
|
|
36
|
+
continue
|
|
37
|
+
for game, path in entries.items():
|
|
38
|
+
if game not in GAMES or not isinstance(path, str):
|
|
39
|
+
continue
|
|
40
|
+
candidate = Path(path)
|
|
41
|
+
if sys.platform == 'ios':
|
|
42
|
+
if candidate.is_absolute() or '..' in candidate.parts:
|
|
43
|
+
continue
|
|
44
|
+
candidate = root / candidate
|
|
45
|
+
if candidate.is_absolute():
|
|
46
|
+
locations[game] = candidate
|
|
47
|
+
except (OSError, ValueError):
|
|
48
|
+
continue
|
|
49
|
+
return locations
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def default_library(game='shs') -> Path:
|
|
53
|
+
if game not in GAMES:
|
|
54
|
+
raise ValueError(f'Unknown game: {game}')
|
|
55
|
+
locations = library_locations()
|
|
56
|
+
if game in locations:
|
|
57
|
+
return locations[game]
|
|
58
|
+
root = user_data_directory()
|
|
59
|
+
if game == 'shs' and sys.platform != 'ios':
|
|
60
|
+
# Preserve both source-checkout and installed-app libraries in place.
|
|
61
|
+
if not getattr(sys, 'frozen', False) and Path('.shs-library/library.json').is_file():
|
|
62
|
+
return Path('.shs-library').resolve()
|
|
63
|
+
for candidate in (root / 'library', root.with_name('SHS Runtime') / 'library'):
|
|
64
|
+
if (candidate / 'library.json').is_file():
|
|
65
|
+
return candidate
|
|
66
|
+
return root / 'libraries' / game
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def remember_library(directory, game='shs'):
|
|
70
|
+
if game not in GAMES:
|
|
71
|
+
raise ValueError(f'Unknown game: {game}')
|
|
72
|
+
locations = library_locations()
|
|
73
|
+
locations[game] = Path(directory).resolve()
|
|
74
|
+
root = user_data_directory()
|
|
75
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
if sys.platform == 'ios':
|
|
77
|
+
# Documents move with the app container. Store paths relative to the
|
|
78
|
+
# shared library root, never the current installation's UUID.
|
|
79
|
+
locations = {key: path.resolve().relative_to(root.resolve()).as_posix() for key, path in locations.items()}
|
|
80
|
+
temporary = None
|
|
81
|
+
try:
|
|
82
|
+
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=root,
|
|
83
|
+
prefix='.launcher-', delete=False) as stream:
|
|
84
|
+
temporary = Path(stream.name)
|
|
85
|
+
json.dump(dict(version=2, libraries={key: str(path) for key, path in locations.items()}), stream)
|
|
86
|
+
stream.write('\n')
|
|
87
|
+
stream.flush()
|
|
88
|
+
os.fsync(stream.fileno())
|
|
89
|
+
temporary.replace(root / 'launcher.json')
|
|
90
|
+
finally:
|
|
91
|
+
if temporary is not None:
|
|
92
|
+
temporary.unlink(missing_ok=True)
|
exp_runtime/loading.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Service 91's application overlay and host execution gate.
|
|
2
|
+
|
|
3
|
+
FUN_0008e7d0 accumulates active milliseconds; FUN_0009ea70 clears the
|
|
4
|
+
overlay only once the counter is strictly greater than 3000.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from .vm import KiwiVM, VMError, VMStop
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def loading_waits(vm: KiwiVM, request: VMStop) -> bool:
|
|
12
|
+
"""Read the dispatcher's first frame slot, even for a zero-word call.
|
|
13
|
+
|
|
14
|
+
FUN_0009fe3c reads stack[SP - argc] without checking argc. With no
|
|
15
|
+
arguments this is retained backing at SP, not an implicit zero. Unknown
|
|
16
|
+
backing must still stop explicitly through read_word().
|
|
17
|
+
"""
|
|
18
|
+
return bool(request.args[0] if request.args else vm.read_word(vm.stack_base + vm.sp))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class LoadingScreen:
|
|
23
|
+
blocking: bool
|
|
24
|
+
elapsed_ms: int = 0
|
|
25
|
+
|
|
26
|
+
def tick(self, elapsed_ms: int) -> bool:
|
|
27
|
+
self.elapsed_ms = min(3001, self.elapsed_ms + elapsed_ms)
|
|
28
|
+
return self.elapsed_ms > 3000
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def frame(self):
|
|
32
|
+
# FUN_0008952c: five common-pack frames, 500 ms each, repeated.
|
|
33
|
+
return 23 + (self.elapsed_ms // 500) % 5
|
|
34
|
+
|
|
35
|
+
def validate(self):
|
|
36
|
+
if type(self.blocking) is not bool or type(self.elapsed_ms) is not int or not 0 <= self.elapsed_ms <= 3000:
|
|
37
|
+
raise VMError('Invalid loading-screen state')
|