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
exp_runtime/menu.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Main-menu contracts and original APK string/glyph resources.
|
|
2
|
+
|
|
3
|
+
No game artwork, glyphs, or episode metadata are embedded in the engine.
|
|
4
|
+
Native evidence and deliberate desktop adaptations: docs/MAIN_MENU.md.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
from .content import is_bundled
|
|
13
|
+
from .languages import TITLE_LANGUAGES, episode_title
|
|
14
|
+
from .games import GAMES, game_id
|
|
15
|
+
from .launcher import default_library, remember_library
|
|
16
|
+
from .runtime import SaveError, Session
|
|
17
|
+
from .menu_assets import MenuFont, MenuStrings
|
|
18
|
+
from .ui_assets import Rect
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
EPISODE_HEADER_HEIGHT = 24
|
|
22
|
+
EPISODE_ROW_HEIGHT = 42
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class EpisodeSection:
|
|
27
|
+
key: str
|
|
28
|
+
title: str
|
|
29
|
+
episodes: tuple[dict, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def group_episodes(records, catalog, *, mega_label, novel_label, saved_label, saved=()):
|
|
33
|
+
"""Native category headers with one consolidated section per category.
|
|
34
|
+
|
|
35
|
+
EXP IDs do not determine category names. Keep unidentified loose episodes
|
|
36
|
+
in numeric pack sections until the user supplies their original catalog.
|
|
37
|
+
"""
|
|
38
|
+
from .episode_catalog import basename
|
|
39
|
+
groups = {}
|
|
40
|
+
for record in records:
|
|
41
|
+
label = catalog.category(record)
|
|
42
|
+
if label is None and is_bundled(record):
|
|
43
|
+
label = novel_label if basename(record['name']) == 'novel_bonus_content.exp' else mega_label
|
|
44
|
+
key = f'category:{label.casefold()}' if label is not None else f'pack:{record["pack_id"]}'
|
|
45
|
+
label = label if label is not None else f'Pack {record["pack_id"]}'
|
|
46
|
+
if key not in groups:
|
|
47
|
+
groups[key] = (label, [])
|
|
48
|
+
groups[key][1].append(record)
|
|
49
|
+
sections = [EpisodeSection(key, label, tuple(episodes)) for key, (label, episodes) in groups.items()]
|
|
50
|
+
sections.sort(key=lambda section: (
|
|
51
|
+
section.title.casefold() != mega_label.casefold(),
|
|
52
|
+
min((e['pack_id'], e['episode_id']) for e in section.episodes), section.key))
|
|
53
|
+
resumed = tuple(e for e in records if e['id'] in saved)
|
|
54
|
+
if resumed:
|
|
55
|
+
sections.insert(0, EpisodeSection('saved', saved_label, resumed))
|
|
56
|
+
return sections
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MenuState:
|
|
61
|
+
"""Per-library preferences and selection; saves remain content-bound.
|
|
62
|
+
|
|
63
|
+
Automatic checkpoints have their own file, preserving the player's F5
|
|
64
|
+
slot. Resume uses the newer of those two saves and validates it on load.
|
|
65
|
+
"""
|
|
66
|
+
def __init__(self, library):
|
|
67
|
+
self.library = library
|
|
68
|
+
self.path = library.directory / 'player.json'
|
|
69
|
+
self.music = self.sound = True
|
|
70
|
+
self.choice_hints = False
|
|
71
|
+
self.order = 'episode'
|
|
72
|
+
self.title_language = 'en'
|
|
73
|
+
self.selected = None
|
|
74
|
+
self.warning = ''
|
|
75
|
+
if self.path.exists():
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(self.path.read_text(encoding='utf-8'))
|
|
78
|
+
if (not isinstance(data, dict) or data.get('version') != 1
|
|
79
|
+
or type(data.get('music')) is not bool or type(data.get('sound')) is not bool
|
|
80
|
+
or type(data.get('choice_hints', False)) is not bool
|
|
81
|
+
or not isinstance(data.get('selected'), (str, type(None)))
|
|
82
|
+
or data.get('order', 'episode') not in ('episode', 'title')
|
|
83
|
+
or data.get('title_language', 'en') not in TITLE_LANGUAGES):
|
|
84
|
+
raise ValueError('Invalid player preferences')
|
|
85
|
+
self.music, self.sound, self.selected = data['music'], data['sound'], data['selected']
|
|
86
|
+
self.order = data.get('order', 'episode')
|
|
87
|
+
self.title_language = data.get('title_language', 'en')
|
|
88
|
+
self.choice_hints = data.get('choice_hints', False)
|
|
89
|
+
except (ValueError, OSError) as error:
|
|
90
|
+
self.warning = f'Could not read preferences: {error}'
|
|
91
|
+
ids = {e['id'] for e in library.episodes}
|
|
92
|
+
if self.selected not in ids:
|
|
93
|
+
saved = [(self.resume_path(e['id']), e['id']) for e in library.episodes]
|
|
94
|
+
saved = [(p.stat().st_mtime_ns, key) for p, key in saved if p]
|
|
95
|
+
preferred = GAMES[game_id(library)].DEFAULT_EPISODE
|
|
96
|
+
default = next((e for e in library.episodes if (e.get('pack_id'), e.get('episode_id')) == preferred),
|
|
97
|
+
library.episodes[0])
|
|
98
|
+
self.selected = max(saved)[1] if saved else default['id']
|
|
99
|
+
|
|
100
|
+
def title(self, record):
|
|
101
|
+
return episode_title(record, self.title_language)
|
|
102
|
+
|
|
103
|
+
def save_path(self, episode, *, automatic=False):
|
|
104
|
+
# Never use an arbitrary selector as part of a host path.
|
|
105
|
+
record = self.library.select(episode)
|
|
106
|
+
suffix = '.shs-auto.json' if automatic else '.shs-save.json'
|
|
107
|
+
return self.library.directory / 'saves' / (record['id'] + suffix)
|
|
108
|
+
|
|
109
|
+
def resume_path(self, episode):
|
|
110
|
+
paths = [self.save_path(episode, automatic=auto) for auto in (False, True)]
|
|
111
|
+
path = max((p for p in paths if p.is_file()), key=lambda p: p.stat().st_mtime_ns, default=None)
|
|
112
|
+
if path is not None:
|
|
113
|
+
try:
|
|
114
|
+
if path.stat().st_size > 8 * 1024 * 1024:
|
|
115
|
+
return path # Session.load reports the existing size limit.
|
|
116
|
+
saved = json.loads(path.read_text(encoding='utf-8'))
|
|
117
|
+
pending = saved.get('pending') if isinstance(saved, dict) else None
|
|
118
|
+
if isinstance(pending, dict) and pending.get('name') == 'episode_exit':
|
|
119
|
+
# A validated terminal checkpoint masks older progress.
|
|
120
|
+
# Keep the manual slot available for an explicit load.
|
|
121
|
+
session = Session.from_snapshot(self.library.open_episode(episode), saved)
|
|
122
|
+
if session.episode_exited:
|
|
123
|
+
return None
|
|
124
|
+
except (SaveError, OSError, ValueError, AttributeError):
|
|
125
|
+
# Invalid saves remain resumable so loading reports the error;
|
|
126
|
+
# a malformed completion marker must not discard progress.
|
|
127
|
+
pass
|
|
128
|
+
return path
|
|
129
|
+
|
|
130
|
+
def persist(self):
|
|
131
|
+
data = dict(version=1, selected=self.selected, music=self.music, sound=self.sound,
|
|
132
|
+
order=self.order, title_language=self.title_language, choice_hints=self.choice_hints)
|
|
133
|
+
temporary = None
|
|
134
|
+
try:
|
|
135
|
+
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=self.library.directory,
|
|
136
|
+
prefix='.player-', delete=False) as stream:
|
|
137
|
+
temporary = Path(stream.name)
|
|
138
|
+
json.dump(data, stream)
|
|
139
|
+
stream.write('\n')
|
|
140
|
+
stream.flush()
|
|
141
|
+
os.fsync(stream.fileno())
|
|
142
|
+
temporary.replace(self.path)
|
|
143
|
+
finally:
|
|
144
|
+
if temporary is not None:
|
|
145
|
+
temporary.unlink(missing_ok=True)
|
|
146
|
+
|
|
147
|
+
def session(self, episode, *, resume=True):
|
|
148
|
+
resources = self.library.open_episode(episode)
|
|
149
|
+
path = self.resume_path(episode) if resume else None
|
|
150
|
+
session = Session.load(resources, path) if path else Session(resources)
|
|
151
|
+
self.selected = resources.record['id']
|
|
152
|
+
self.persist()
|
|
153
|
+
return session
|
|
154
|
+
|
|
155
|
+
def checkpoint(self, session):
|
|
156
|
+
path = session.save(self.save_path(session.resources.record['id'], automatic=True))
|
|
157
|
+
self.selected = session.resources.record['id']
|
|
158
|
+
self.persist()
|
|
159
|
+
return path
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main_button_rects(bank):
|
|
163
|
+
"""000df488 + 00147df8: native four-item vertical menu, 5px gaps.
|
|
164
|
+
|
|
165
|
+
The background is layout 74; live buttons are layout 75 (184x28),
|
|
166
|
+
independently positioned over it. The fourth uses the paid/ad-free width.
|
|
167
|
+
"""
|
|
168
|
+
group = bank.rectangle(74, 0)
|
|
169
|
+
button = bank.rectangle(75, 0)
|
|
170
|
+
widths = (button.width, button.width, button.width, 155)
|
|
171
|
+
total_height = button.height * 4 + 15
|
|
172
|
+
top = 480 - (group.height // 2 + 5) - total_height / 2
|
|
173
|
+
travel = int(button.width * 2.6)
|
|
174
|
+
offsets = (-50, -5, 2, -35)
|
|
175
|
+
return tuple(Rect(round(-group.width / 2 - w + offsets[i] + travel - 20 * i - w / 2),
|
|
176
|
+
round(top + i * (button.height + 5)), w, button.height)
|
|
177
|
+
for i, w in enumerate(widths))
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Service 33's raw-text message panel and native acknowledgement gate."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from .vm import VMError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class MessagePanel:
|
|
9
|
+
title: str
|
|
10
|
+
text: str
|
|
11
|
+
argument: int
|
|
12
|
+
elapsed_ms: int = 0
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def ready(self):
|
|
16
|
+
return self.elapsed_ms == 1000
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def countdown(self):
|
|
20
|
+
# FUN_000aca28 uses +900, rather than rounding up with +999.
|
|
21
|
+
return (1000 - self.elapsed_ms + 900) // 1000
|
|
22
|
+
|
|
23
|
+
def tick(self, elapsed_ms):
|
|
24
|
+
self.elapsed_ms = min(1000, self.elapsed_ms + elapsed_ms)
|
|
25
|
+
|
|
26
|
+
def validate(self):
|
|
27
|
+
if (type(self.argument) is not int or not -32768 <= self.argument <= 32767
|
|
28
|
+
or type(self.elapsed_ms) is not int or not 0 <= self.elapsed_ms <= 1000):
|
|
29
|
+
raise VMError('Invalid message-panel argument or clock')
|
|
30
|
+
for value in (self.title, self.text):
|
|
31
|
+
if not isinstance(value, str) or '\0' in value:
|
|
32
|
+
raise VMError('Invalid message-panel text')
|
|
33
|
+
try:
|
|
34
|
+
value.encode('latin-1')
|
|
35
|
+
except UnicodeEncodeError:
|
|
36
|
+
raise VMError('Message-panel text must be Latin-1') from None
|
exp_runtime/minigames.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Shared timed word-game rules; see docs/MINIGAMES.md."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class WordGame:
|
|
7
|
+
good: list[str]
|
|
8
|
+
bad: list[str]
|
|
9
|
+
duration_ms: int
|
|
10
|
+
refresh_ms: int
|
|
11
|
+
remaining_ms: int
|
|
12
|
+
refresh_remaining_ms: int
|
|
13
|
+
options: list[str] = field(default_factory=list)
|
|
14
|
+
weights: list[int] = field(default_factory=list)
|
|
15
|
+
score: int = 0
|
|
16
|
+
round: int = 0
|
|
17
|
+
entry_ms: int = 400
|
|
18
|
+
animation_ms: int = 0
|
|
19
|
+
last_delta: int = 0
|
|
20
|
+
|
|
21
|
+
def deal(self, random):
|
|
22
|
+
"""FUN_000af010: rejection sampling, then exactly twenty pair swaps."""
|
|
23
|
+
if len(self.good) < 2 or len(self.bad) < 3:
|
|
24
|
+
raise ValueError('Word game needs at least two good and three bad entries')
|
|
25
|
+
first, second = random.below(len(self.good)), random.below(len(self.bad))
|
|
26
|
+
third = random.below(len(self.bad))
|
|
27
|
+
while third == second:
|
|
28
|
+
third = random.below(len(self.bad))
|
|
29
|
+
good_fourth = random.next() & 1 == 0
|
|
30
|
+
pool, excluded = (self.good, (first,)) if good_fourth else (self.bad, (second, third))
|
|
31
|
+
fourth = random.below(len(pool))
|
|
32
|
+
while fourth in excluded:
|
|
33
|
+
fourth = random.below(len(pool))
|
|
34
|
+
self.options = [self.good[first], self.bad[second], self.bad[third], pool[fourth]]
|
|
35
|
+
self.weights = [1, -1, -1, 1 if good_fourth else -1]
|
|
36
|
+
for _ in range(20):
|
|
37
|
+
a, b = random.below(4), random.below(4)
|
|
38
|
+
self.options[a], self.options[b] = self.options[b], self.options[a]
|
|
39
|
+
self.weights[a], self.weights[b] = self.weights[b], self.weights[a]
|
|
40
|
+
self.refresh_remaining_ms = self.refresh_ms
|
|
41
|
+
self.round += 1
|
|
42
|
+
|
|
43
|
+
def pick(self, index, random):
|
|
44
|
+
if type(index) is not int or not 0 <= index < 4:
|
|
45
|
+
raise ValueError('Choose a word by its zero-based index (0–3)')
|
|
46
|
+
if self.entry_ms:
|
|
47
|
+
return False # Native transition input gate, FUN_000afee8.
|
|
48
|
+
self.last_delta = self.weights[index]
|
|
49
|
+
self.score = max(0, self.score + self.last_delta)
|
|
50
|
+
self.deal(random)
|
|
51
|
+
self.animation_ms = 200 # New labels slide in; native input stays live.
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
def tick(self, elapsed_ms):
|
|
55
|
+
self.entry_ms = max(0, self.entry_ms - elapsed_ms)
|
|
56
|
+
self.animation_ms = max(0, self.animation_ms - elapsed_ms)
|
|
57
|
+
self.remaining_ms -= elapsed_ms
|
|
58
|
+
self.refresh_remaining_ms -= elapsed_ms
|
|
59
|
+
# FUN_000ae4f4 uses a strict negative comparison. The refresh counter
|
|
60
|
+
# is stored/subtracted, but has no expiry action in this Android build.
|
|
61
|
+
return self.duration_ms > 0 and self.remaining_ms < 0
|
|
62
|
+
|
|
63
|
+
def validate(self):
|
|
64
|
+
if (len(self.good) < 2 or len(self.bad) < 3 or self.duration_ms <= 0
|
|
65
|
+
or self.duration_ms > 32767 or not -32768 <= self.refresh_ms <= 32767
|
|
66
|
+
or not 0 <= self.remaining_ms <= self.duration_ms
|
|
67
|
+
or not self.refresh_ms - self.duration_ms <= self.refresh_remaining_ms <= self.refresh_ms
|
|
68
|
+
or len(self.options) != 4 or len(self.weights) != 4
|
|
69
|
+
or sum(w == 1 for w in self.weights) not in (1, 2)
|
|
70
|
+
or any(w not in (-1, 1) for w in self.weights)
|
|
71
|
+
or any(s not in (self.good if w == 1 else self.bad)
|
|
72
|
+
for s, w in zip(self.options, self.weights))
|
|
73
|
+
or self.round < 1 or not 0 <= self.score < self.round
|
|
74
|
+
or not 0 <= self.entry_ms <= 400 or not 0 <= self.animation_ms <= 200
|
|
75
|
+
or self.last_delta not in (-1, 0, 1)):
|
|
76
|
+
raise ValueError('Invalid saved word game')
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Native random streams and pipe-delimited option parsing.
|
|
2
|
+
|
|
3
|
+
Evidence and argument schemas: docs/MINIGAMES.md. Random permutations are
|
|
4
|
+
part of saved gameplay state, never recomputed by a renderer.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from time import process_time_ns
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
RAND48_INITIAL = 0x1234ABCD330E
|
|
11
|
+
RAND48_MASK = (1 << 48) - 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Random48:
|
|
16
|
+
"""The libc lrand48 stream for word choices, separate from services 4/27."""
|
|
17
|
+
state: int = RAND48_INITIAL
|
|
18
|
+
|
|
19
|
+
def next(self):
|
|
20
|
+
self.state = (self.state * 0x5DEECE66D + 0xB) & RAND48_MASK
|
|
21
|
+
return self.state >> 17
|
|
22
|
+
|
|
23
|
+
def below(self, bound):
|
|
24
|
+
if bound <= 0:
|
|
25
|
+
raise ValueError('Random selection needs a positive bound')
|
|
26
|
+
return self.next() % bound
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class NativeRandom:
|
|
31
|
+
"""0004c248 / 00126fa8 / 00122554: CPU-clock seed and 32-bit LCG.
|
|
32
|
+
|
|
33
|
+
The returned word includes bits from the full multiplication, before
|
|
34
|
+
truncating the stored state. This is not the usual ANSI rand() result.
|
|
35
|
+
"""
|
|
36
|
+
state: int = field(default_factory=lambda: (process_time_ns() // 1000) & 0xffffffff)
|
|
37
|
+
|
|
38
|
+
def next(self):
|
|
39
|
+
product = self.state * 0x41c64e6d + 0x3039
|
|
40
|
+
self.state = product & 0xffffffff
|
|
41
|
+
value = (product >> 16) & 0xffffffff
|
|
42
|
+
return abs(value if value < 0x80000000 else value - 0x100000000)
|
|
43
|
+
|
|
44
|
+
def below(self, bound):
|
|
45
|
+
if bound <= 0:
|
|
46
|
+
raise ValueError('Random selection needs a positive bound')
|
|
47
|
+
return self.next() % bound
|
|
48
|
+
|
|
49
|
+
def signed_below(self, bound):
|
|
50
|
+
value, negative = self.below(bound), self.next() & 1
|
|
51
|
+
return -value if negative else value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pipe_list(text):
|
|
55
|
+
result = text.split('|')
|
|
56
|
+
# The native parser preserves a blank final field, including whitespace.
|
|
57
|
+
if not result[-1].strip():
|
|
58
|
+
result[-1] = ''
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
exp_runtime/rar.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Bounded RAR4/RAR5 reading through libarchive, without external commands.
|
|
2
|
+
|
|
3
|
+
Apple platforms use the OS library. Desktop builds for Windows/Linux bundle
|
|
4
|
+
libarchive; source checkouts can use a locally installed copy. Only the RAR
|
|
5
|
+
decoders are enabled, and archive member paths are never used as output paths.
|
|
6
|
+
"""
|
|
7
|
+
import ctypes as c
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RarError(ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _library_candidates():
|
|
19
|
+
if sys.platform in ('darwin', 'ios'):
|
|
20
|
+
yield '/usr/lib/libarchive.2.dylib'
|
|
21
|
+
return
|
|
22
|
+
name = 'archive.dll' if sys.platform == 'win32' else 'libarchive.so.13'
|
|
23
|
+
if bundle := getattr(sys, '_MEIPASS', None):
|
|
24
|
+
yield str(Path(bundle) / name)
|
|
25
|
+
else:
|
|
26
|
+
# The decoder is built at the workspace root, while this module lives
|
|
27
|
+
# in a component package. Locate the owning build helper instead of
|
|
28
|
+
# assuming a fixed source depth or using the caller's working directory.
|
|
29
|
+
# Installed wheels need no build tools and fall through to the system
|
|
30
|
+
# library. Frozen apps must not borrow a surrounding checkout's decoder.
|
|
31
|
+
for root in Path(__file__).resolve().parents:
|
|
32
|
+
if (root / 'tools/build_rar.py').is_file():
|
|
33
|
+
yield str(root / 'build/rar/install' / ('bin' if sys.platform == 'win32' else 'lib') / name)
|
|
34
|
+
break
|
|
35
|
+
from ctypes.util import find_library
|
|
36
|
+
if system := find_library('archive'):
|
|
37
|
+
yield system
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@lru_cache(maxsize=1)
|
|
41
|
+
def library():
|
|
42
|
+
for path in _library_candidates():
|
|
43
|
+
try:
|
|
44
|
+
lib = c.CDLL(path)
|
|
45
|
+
signatures = {
|
|
46
|
+
'archive_read_new': (c.c_void_p, []),
|
|
47
|
+
'archive_read_support_format_rar': (c.c_int, [c.c_void_p]),
|
|
48
|
+
'archive_read_support_format_rar5': (c.c_int, [c.c_void_p]),
|
|
49
|
+
'archive_read_open_filename': (c.c_int, [c.c_void_p, c.c_char_p, c.c_size_t]),
|
|
50
|
+
'archive_read_next_header': (c.c_int, [c.c_void_p, c.POINTER(c.c_void_p)]),
|
|
51
|
+
'archive_read_data': (c.c_ssize_t, [c.c_void_p, c.c_void_p, c.c_size_t]),
|
|
52
|
+
'archive_read_data_skip': (c.c_int, [c.c_void_p]),
|
|
53
|
+
'archive_read_free': (c.c_int, [c.c_void_p]),
|
|
54
|
+
'archive_error_string': (c.c_char_p, [c.c_void_p]),
|
|
55
|
+
'archive_entry_pathname_utf8': (c.c_char_p, [c.c_void_p]),
|
|
56
|
+
'archive_entry_size': (c.c_int64, [c.c_void_p]),
|
|
57
|
+
'archive_entry_filetype': (c.c_uint, [c.c_void_p]),
|
|
58
|
+
'archive_entry_symlink': (c.c_char_p, [c.c_void_p]),
|
|
59
|
+
'archive_entry_hardlink': (c.c_char_p, [c.c_void_p]),
|
|
60
|
+
'archive_entry_is_encrypted': (c.c_int, [c.c_void_p]),
|
|
61
|
+
}
|
|
62
|
+
if sys.platform == 'win32':
|
|
63
|
+
signatures['archive_read_open_filename_w'] = (c.c_int, [c.c_void_p, c.c_wchar_p, c.c_size_t])
|
|
64
|
+
for name, (result, args) in signatures.items():
|
|
65
|
+
function = getattr(lib, name)
|
|
66
|
+
function.restype, function.argtypes = result, args
|
|
67
|
+
return lib
|
|
68
|
+
except (OSError, AttributeError):
|
|
69
|
+
continue
|
|
70
|
+
raise RarError('RAR support needs libarchive with RAR5 support. Install libarchive or use an '
|
|
71
|
+
'EXP Runtime desktop download. You can also import the extracted EXP files.')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RarReader:
|
|
75
|
+
"""Single-pass archive. Read or skip each member before advancing."""
|
|
76
|
+
def __init__(self, path):
|
|
77
|
+
self.lib, self.handle = library(), None
|
|
78
|
+
with Path(path).open('rb') as stream:
|
|
79
|
+
magic = stream.read(8)
|
|
80
|
+
if not (magic.startswith(b'Rar!\x1a\x07\x00') or magic == b'Rar!\x1a\x07\x01\x00'):
|
|
81
|
+
raise RarError('Invalid RAR signature')
|
|
82
|
+
self.handle = self.lib.archive_read_new()
|
|
83
|
+
if not self.handle:
|
|
84
|
+
raise RarError('Cannot allocate the RAR reader')
|
|
85
|
+
try:
|
|
86
|
+
self.check(self.lib.archive_read_support_format_rar(self.handle))
|
|
87
|
+
self.check(self.lib.archive_read_support_format_rar5(self.handle))
|
|
88
|
+
if sys.platform == 'win32':
|
|
89
|
+
self.check(self.lib.archive_read_open_filename_w(self.handle, str(Path(path)), 65536))
|
|
90
|
+
else:
|
|
91
|
+
self.check(self.lib.archive_read_open_filename(self.handle, os.fsencode(path), 65536))
|
|
92
|
+
except BaseException:
|
|
93
|
+
self.close()
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
def check(self, status):
|
|
97
|
+
if status < 0:
|
|
98
|
+
raw = self.lib.archive_error_string(self.handle)
|
|
99
|
+
raise RarError(raw.decode('utf-8', 'replace') if raw else 'Invalid or unsupported RAR archive')
|
|
100
|
+
return status
|
|
101
|
+
|
|
102
|
+
def __iter__(self):
|
|
103
|
+
while True:
|
|
104
|
+
entry = c.c_void_p()
|
|
105
|
+
status = self.check(self.lib.archive_read_next_header(self.handle, c.byref(entry)))
|
|
106
|
+
if status == 1: # ARCHIVE_EOF
|
|
107
|
+
return
|
|
108
|
+
raw = self.lib.archive_entry_pathname_utf8(entry)
|
|
109
|
+
if raw is None:
|
|
110
|
+
raise RarError('RAR member has an invalid filename')
|
|
111
|
+
try:
|
|
112
|
+
name = raw.decode('utf-8')
|
|
113
|
+
except UnicodeDecodeError as error:
|
|
114
|
+
raise RarError('RAR member has an invalid filename') from error
|
|
115
|
+
yield dict(name=name, size=self.lib.archive_entry_size(entry),
|
|
116
|
+
mode=self.lib.archive_entry_filetype(entry),
|
|
117
|
+
link=bool(self.lib.archive_entry_symlink(entry) or self.lib.archive_entry_hardlink(entry)),
|
|
118
|
+
encrypted=bool(self.lib.archive_entry_is_encrypted(entry)))
|
|
119
|
+
|
|
120
|
+
def skip(self):
|
|
121
|
+
self.check(self.lib.archive_read_data_skip(self.handle))
|
|
122
|
+
|
|
123
|
+
def copy_to(self, destination, size):
|
|
124
|
+
buffer, total = c.create_string_buffer(65536), 0
|
|
125
|
+
while True:
|
|
126
|
+
count = self.check(self.lib.archive_read_data(self.handle, buffer, len(buffer)))
|
|
127
|
+
if count == 0:
|
|
128
|
+
break
|
|
129
|
+
total += count
|
|
130
|
+
if total > size:
|
|
131
|
+
raise RarError('RAR member exceeds its declared size')
|
|
132
|
+
destination.write(buffer.raw[:count])
|
|
133
|
+
if total != size:
|
|
134
|
+
raise RarError('RAR member is truncated')
|
|
135
|
+
|
|
136
|
+
def close(self):
|
|
137
|
+
if self.handle:
|
|
138
|
+
self.lib.archive_read_free(self.handle)
|
|
139
|
+
self.handle = None
|
|
140
|
+
|
|
141
|
+
def __enter__(self):
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, *exc):
|
|
145
|
+
self.close()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""NPC relationship icon selection and native portrait-child actions.
|
|
2
|
+
|
|
3
|
+
Evidence: FUN_000ab048, FUN_000a90f0, FUN_0009ce2c, FUN_0009caf4.
|
|
4
|
+
Asset bytes remain in the player's APK. Drawing never writes relationship state.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
from .games import GAMES
|
|
10
|
+
|
|
11
|
+
FLASH_ASSETS = {asset: flash for game in GAMES.values() for asset, flash in game.RELATIONSHIP_FLASH.items()}
|
|
12
|
+
LOSS_ASSETS = {asset: loss for game in GAMES.values() for asset, loss in game.RELATIONSHIP_LOSS.items()}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fraction(elapsed, duration):
|
|
16
|
+
return min(1.0, max(0.0, elapsed / duration))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class RelationshipChange:
|
|
21
|
+
character_id: int
|
|
22
|
+
asset_id: int
|
|
23
|
+
count: int
|
|
24
|
+
previous_asset: int
|
|
25
|
+
previous_count: int
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def changed(self):
|
|
29
|
+
return self.previous_count != -1 and (self.asset_id, self.count) != (self.previous_asset, self.previous_count)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def loss(self):
|
|
33
|
+
return self.asset_id >= 0 and self.changed and self.asset_id == self.previous_asset and self.count < self.previous_count
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def gain_from(self):
|
|
37
|
+
if (self.asset_id < 0 or not self.changed or self.loss
|
|
38
|
+
or (self.character_id == 45 and self.asset_id in GAMES['shs'].RELATIONSHIP_ASSETS)):
|
|
39
|
+
return None
|
|
40
|
+
return self.previous_count if self.asset_id == self.previous_asset else 0
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def extra_delay_ms(self):
|
|
44
|
+
if self.loss:
|
|
45
|
+
return 600
|
|
46
|
+
first = self.gain_from
|
|
47
|
+
return 0 if first is None else 300 + 200 * (self.count - first - 1)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def sound(self):
|
|
51
|
+
# CoD has no verified mapping to SHS's feedback sound IDs.
|
|
52
|
+
if self.asset_id in GAMES['shs'].RELATIONSHIP_ASSETS:
|
|
53
|
+
from .shs.relationships import sound
|
|
54
|
+
return sound(self)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def validate(self):
|
|
58
|
+
if (not 0 < self.character_id <= 32767 or (self.asset_id != -1 and self.asset_id not in FLASH_ASSETS)
|
|
59
|
+
or not 1 <= self.count <= 4 or not -32768 <= self.previous_asset <= 32767
|
|
60
|
+
or not -1 <= self.previous_count <= 4):
|
|
61
|
+
raise ValueError('Invalid relationship indicator state')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class IndicatorPose:
|
|
66
|
+
asset_id: int
|
|
67
|
+
x: float
|
|
68
|
+
y: float
|
|
69
|
+
rotation: float
|
|
70
|
+
flash_asset: int = -1
|
|
71
|
+
flash_scale: float = 0.0
|
|
72
|
+
flash_alpha: int = 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class RelationshipAnimation:
|
|
77
|
+
change: RelationshipChange
|
|
78
|
+
delay_ms: int
|
|
79
|
+
elapsed_ms: int = 0
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def duration_ms(self):
|
|
83
|
+
first = self.change.gain_from
|
|
84
|
+
if first is not None:
|
|
85
|
+
return self.delay_ms + 200 * (self.change.count - first - 1) + max(800, self.change.extra_delay_ms)
|
|
86
|
+
return max(self.delay_ms, 2000 if self.change.loss else 0)
|
|
87
|
+
|
|
88
|
+
def tick(self, elapsed_ms):
|
|
89
|
+
self.elapsed_ms = min(self.duration_ms, self.elapsed_ms + elapsed_ms)
|
|
90
|
+
|
|
91
|
+
def settle(self):
|
|
92
|
+
self.elapsed_ms = self.duration_ms
|
|
93
|
+
|
|
94
|
+
def poses(self):
|
|
95
|
+
change = self.change
|
|
96
|
+
if change.asset_id < 0 or self.elapsed_ms < self.delay_ms:
|
|
97
|
+
return ()
|
|
98
|
+
count = change.previous_count if change.loss else change.count
|
|
99
|
+
first, result = change.gain_from, []
|
|
100
|
+
for index in range(count):
|
|
101
|
+
asset, angle = change.asset_id, 195 + 20 * index
|
|
102
|
+
drop, flash_asset, flash_scale, flash_alpha = 0.0, -1, 0.0, 0
|
|
103
|
+
if index >= change.count:
|
|
104
|
+
if self.elapsed_ms >= 2000:
|
|
105
|
+
continue
|
|
106
|
+
asset = LOSS_ASSETS[asset]
|
|
107
|
+
# Parent MoveTo(0,-500) starts after one second, independent
|
|
108
|
+
# of the incoming portrait's visibility delay.
|
|
109
|
+
drop = 500 * fraction(self.elapsed_ms - 1000, 1000)
|
|
110
|
+
elif first is not None and index >= first:
|
|
111
|
+
age = self.elapsed_ms - self.delay_ms - 200 * (index - first)
|
|
112
|
+
if age < 0:
|
|
113
|
+
continue
|
|
114
|
+
# RotateTo uses the shortest arc (00146a88), not a 195° spin.
|
|
115
|
+
angle = (angle - 360) * fraction(age, change.extra_delay_ms)
|
|
116
|
+
flash_asset = FLASH_ASSETS[asset]
|
|
117
|
+
burst = fraction(age - 300, 500)
|
|
118
|
+
flash_scale, flash_alpha = .5 + 1.5 * burst, int(200 * (1 - burst))
|
|
119
|
+
x, y = ((61, 16), (62, 17), (62, 18), (64, 19))[index]
|
|
120
|
+
radians = math.radians(angle)
|
|
121
|
+
# Cocos rotates clockwise in GL coordinates. Convert to top Y.
|
|
122
|
+
px = x * math.cos(radians) + y * math.sin(radians)
|
|
123
|
+
py = x * math.sin(radians) - y * math.cos(radians) + drop
|
|
124
|
+
result.append(IndicatorPose(asset, px, py, angle - 190 - 20 * index,
|
|
125
|
+
flash_asset, flash_scale, flash_alpha))
|
|
126
|
+
return tuple(result)
|
|
127
|
+
|
|
128
|
+
def validate(self):
|
|
129
|
+
self.change.validate()
|
|
130
|
+
if not 0 <= self.delay_ms <= 850 or not 0 <= self.elapsed_ms <= self.duration_ms:
|
|
131
|
+
raise ValueError('Invalid relationship animation clock')
|