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.
@@ -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]
@@ -0,0 +1,9 @@
1
+ """Marker for saved dataclasses owned by an installed game adapter.
2
+
3
+ The selected profile supplies concrete types for these fields. Saves never
4
+ choose a Python class or module, and unsupported fields accept only None.
5
+ """
6
+
7
+
8
+ class GameState:
9
+ pass
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')
@@ -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
@@ -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')