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/engine.py
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""Verified subset of the native yield dispatcher for headless tracing.
|
|
2
|
+
|
|
3
|
+
These handlers reconstruct game state. Presentation requests and unknown
|
|
4
|
+
handlers remain pending; episode exits are terminal application handoffs.
|
|
5
|
+
See docs/ENGINE_ABI.md for native addresses and current limitations.
|
|
6
|
+
"""
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
from .games import GAMES
|
|
12
|
+
|
|
13
|
+
from .randomness import NativeRandom, Random48, pipe_list
|
|
14
|
+
from .minigames import WordGame
|
|
15
|
+
from .game_state import GameState
|
|
16
|
+
from .character_picker import CharacterPicker
|
|
17
|
+
from .scene_badge import SceneBadge
|
|
18
|
+
from .loading import LoadingScreen
|
|
19
|
+
from .message_panel import MessagePanel
|
|
20
|
+
from .speaker_names import SpeakerNames
|
|
21
|
+
from .title_screen import TitleScreen
|
|
22
|
+
from .relationships import RelationshipChange
|
|
23
|
+
from .dialogue_animation import DialogueAnimation, DialogueHistory
|
|
24
|
+
from .background import BackgroundPan
|
|
25
|
+
from .dialogue_notice import notice_lifetime
|
|
26
|
+
from .vm import KiwiVM, StopKind, VMError, VMStop, signed16
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class EngineAction:
|
|
31
|
+
name: str
|
|
32
|
+
request: VMStop
|
|
33
|
+
completed: bool
|
|
34
|
+
details: dict = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class PanelState:
|
|
39
|
+
background_id: int = -1
|
|
40
|
+
character_id: int = -1
|
|
41
|
+
expression: int = 0
|
|
42
|
+
speaker: str = ''
|
|
43
|
+
text: str = ''
|
|
44
|
+
mode: int = 0
|
|
45
|
+
presentation_mode: int = 0
|
|
46
|
+
theme: int = -1
|
|
47
|
+
emphasis_theme: int = 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class EngineState:
|
|
52
|
+
# Derived from the validated library, never accepted from saved engine data.
|
|
53
|
+
game_key: ClassVar[str] = 'shs'
|
|
54
|
+
app_version: ClassVar[str | None] = None
|
|
55
|
+
numbers: dict[int, int] = field(default_factory=dict)
|
|
56
|
+
strings: dict[str, str] = field(default_factory=dict)
|
|
57
|
+
character_names: dict[int, str] = field(default_factory=dict)
|
|
58
|
+
character_art_bases: dict[int, int] = field(default_factory=dict)
|
|
59
|
+
dynamic_strings: list[str] = field(default_factory=lambda: [''] * 11)
|
|
60
|
+
ui_defaults: dict[int, int] = field(default_factory=dict)
|
|
61
|
+
scheduled_scripts: list[tuple[int, bool]] = field(default_factory=list)
|
|
62
|
+
audio_stopped: bool = False
|
|
63
|
+
character_art_variants: dict[int, list[int]] = field(default_factory=dict)
|
|
64
|
+
character_expressions: dict[int, int] = field(default_factory=dict)
|
|
65
|
+
panel: PanelState = field(default_factory=PanelState)
|
|
66
|
+
background_pan: BackgroundPan = field(default_factory=BackgroundPan)
|
|
67
|
+
music_id: int = -1
|
|
68
|
+
music_flag: bool = False
|
|
69
|
+
sound_id: int = -1
|
|
70
|
+
sound_serial: int = 0
|
|
71
|
+
sound_ids: list[int] = field(default_factory=list)
|
|
72
|
+
scene_value: int = 0
|
|
73
|
+
last_input: str = ''
|
|
74
|
+
result_cells: list[int | None] = field(default_factory=lambda: [None] * 10)
|
|
75
|
+
choice_builder: dict | None = None
|
|
76
|
+
random48: Random48 = field(default_factory=Random48)
|
|
77
|
+
word_game: WordGame | None = None
|
|
78
|
+
random: NativeRandom = field(default_factory=NativeRandom)
|
|
79
|
+
word_grid: GameState | None = None
|
|
80
|
+
grid_tutorials_seen: bool = False
|
|
81
|
+
football: GameState | None = None
|
|
82
|
+
football_scores: list[int | None] = field(default_factory=lambda: [None, None])
|
|
83
|
+
next_dialogue_notice: str = ''
|
|
84
|
+
notice: str = ''
|
|
85
|
+
notice_ms: int = 0
|
|
86
|
+
dialogue_animation: DialogueAnimation | None = None
|
|
87
|
+
dialogue_history: DialogueHistory = field(default_factory=DialogueHistory)
|
|
88
|
+
character_picker: CharacterPicker | None = None
|
|
89
|
+
scene_badge: SceneBadge | None = None
|
|
90
|
+
next_dialogue_wobble: bool = False
|
|
91
|
+
loading: LoadingScreen | None = None
|
|
92
|
+
message_panel: MessagePanel | None = None
|
|
93
|
+
speaker_names: SpeakerNames = field(default_factory=SpeakerNames)
|
|
94
|
+
title_screen: TitleScreen | None = None
|
|
95
|
+
|
|
96
|
+
def clear_scene_expressions(self):
|
|
97
|
+
"""loadScript09NoGUI clears both 200-byte expression tables."""
|
|
98
|
+
for table in (self.character_expressions, self.dialogue_history.expressions):
|
|
99
|
+
for character in list(table):
|
|
100
|
+
if 0 <= character < 200:
|
|
101
|
+
del table[character]
|
|
102
|
+
|
|
103
|
+
def close_episode(self):
|
|
104
|
+
"""FUN_0007e614/0007e59c: dispose scene UI and cancel its work."""
|
|
105
|
+
self.scheduled_scripts.clear()
|
|
106
|
+
self.numbers.clear() # FUN_0009623c frees the numeric key/value arrays.
|
|
107
|
+
# The same reset zeros 200 expression bytes, but retains names, art,
|
|
108
|
+
# replacement strings, dynamic strings, UI results and random streams.
|
|
109
|
+
self.clear_scene_expressions()
|
|
110
|
+
self.panel = PanelState()
|
|
111
|
+
self.background_pan.center()
|
|
112
|
+
self.background_pan.automatic = True # 0007e59c resets automatic panning.
|
|
113
|
+
self.dialogue_animation = self.scene_badge = None
|
|
114
|
+
self.dialogue_history = DialogueHistory()
|
|
115
|
+
self.word_game = self.word_grid = self.football = None
|
|
116
|
+
self.character_picker = self.loading = self.message_panel = self.title_screen = None
|
|
117
|
+
self.choice_builder = None
|
|
118
|
+
self.next_dialogue_notice = self.notice = self.last_input = ''
|
|
119
|
+
self.notice_ms = self.scene_value = 0
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def number_key(owner: int, key: int) -> int:
|
|
123
|
+
# Native addition of signed short operands, not OR of unsigned halves.
|
|
124
|
+
return (signed16(owner) * 0x10000 + signed16(key)) & 0xFFFFFFFF
|
|
125
|
+
|
|
126
|
+
def dialogue_presentation(self, character: int, expression: int):
|
|
127
|
+
"""FUN_000ab048/000ab344: visible character and panel variant.
|
|
128
|
+
|
|
129
|
+
Service 75 selects the narrator by ID. Its script name is metadata;
|
|
130
|
+
no string (including the usual name 'Event') identifies narration.
|
|
131
|
+
"""
|
|
132
|
+
if character == self.ui_defaults.get(75, -1):
|
|
133
|
+
return 4, -1, -1
|
|
134
|
+
variants = self.character_art_variants.get(character, ())
|
|
135
|
+
if character < 0 or not variants or variants[expression % len(variants)] < 0:
|
|
136
|
+
return 3, -1, -1
|
|
137
|
+
mode = 1 if character == self.ui_defaults.get(74, -1) else 2
|
|
138
|
+
return mode, character, self.numbers.get(self.number_key(character, 651), 0)
|
|
139
|
+
|
|
140
|
+
def present_dialogue(self, character: int, expression: int, raw_text: str, mode: int, *,
|
|
141
|
+
settled_relationship=False, speaker: str | None = None):
|
|
142
|
+
"""Keep script prefixes separate from panel modes and explicit speakers."""
|
|
143
|
+
text = '(' + raw_text + ')' if mode == -2 else '`' + raw_text + '`' if mode == -3 else raw_text
|
|
144
|
+
text = self.substitute(text)
|
|
145
|
+
old = self.dialogue_animation.portrait if self.dialogue_animation else None
|
|
146
|
+
initial_expression = self.dialogue_history.expressions.get(character, 0) if (
|
|
147
|
+
old is not None and old.character_id == character) else 0
|
|
148
|
+
if speaker is None:
|
|
149
|
+
presentation, visible, theme = self.dialogue_presentation(character, expression)
|
|
150
|
+
if presentation in (1, 2) and self.dialogue_presentation(character, initial_expression)[0] == 3:
|
|
151
|
+
presentation, visible, theme = 3, -1, -1
|
|
152
|
+
if presentation in (1, 2):
|
|
153
|
+
self.panel.speaker = self.substitute(self.character_names.get(character, ''))
|
|
154
|
+
else:
|
|
155
|
+
# Service 76: ab344(3) -> ab048(-1,0), then aa028(name).
|
|
156
|
+
# This is named dialogue without an NPC or portrait, not narration.
|
|
157
|
+
presentation, visible, theme = 3, -1, -1
|
|
158
|
+
self.panel.speaker = self.substitute(speaker)
|
|
159
|
+
# The name remains on the panel; narrator mode 4 only hides it.
|
|
160
|
+
speaker = '' if presentation == 4 else self.panel.speaker
|
|
161
|
+
if theme in (1, 2):
|
|
162
|
+
self.panel.emphasis_theme = theme
|
|
163
|
+
self.panel.character_id, self.panel.expression = visible, expression
|
|
164
|
+
self.panel.text, self.panel.mode = text, mode
|
|
165
|
+
self.panel.presentation_mode, self.panel.theme = presentation, theme
|
|
166
|
+
self.background_pan.dialogue(presentation)
|
|
167
|
+
if self.next_dialogue_notice:
|
|
168
|
+
self.notice, self.next_dialogue_notice = self.next_dialogue_notice, ''
|
|
169
|
+
self.notice_ms = notice_lifetime(self.notice)
|
|
170
|
+
wobble, self.next_dialogue_wobble = self.next_dialogue_wobble, False
|
|
171
|
+
relationship = self.prepare_relationship(visible if presentation in (1, 2) else -1, settled=settled_relationship)
|
|
172
|
+
return dict(text=text, raw_text=raw_text, character_id=character,
|
|
173
|
+
visible_character_id=visible, speaker=speaker, expression=expression,
|
|
174
|
+
initial_expression=initial_expression,
|
|
175
|
+
mode=mode, presentation_mode=presentation, theme=theme,
|
|
176
|
+
emphasis_theme=self.panel.emphasis_theme, box_wobble=wobble,
|
|
177
|
+
relationship=asdict(relationship) if relationship else None)
|
|
178
|
+
|
|
179
|
+
def prepare_relationship(self, character: int, *, settled=False):
|
|
180
|
+
"""FUN_000ab048: snapshot and write UI caches once per NPC dialogue."""
|
|
181
|
+
if character <= 0:
|
|
182
|
+
return None
|
|
183
|
+
owned = lambda key: self.numbers.get(self.number_key(character, key), 0)
|
|
184
|
+
value = owned(407) # 0x197 in FUN_000ab048 and FUN_000a90f0.
|
|
185
|
+
heart, skull, smile = GAMES[self.game_key].RELATIONSHIP_ASSETS
|
|
186
|
+
asset = (-1 if owned(629) == 1 else skull if value < 0 else
|
|
187
|
+
heart if character == self.numbers.get(self.number_key(0, 601), 0) else smile)
|
|
188
|
+
count = {-3: 3, -2: 2, -1: 1, 0: 1, 1: 2, 2: 3}.get(value, 4)
|
|
189
|
+
change = RelationshipChange(character, asset, count,
|
|
190
|
+
asset if settled else signed16(owned(3000)),
|
|
191
|
+
count if settled else owned(3001))
|
|
192
|
+
change.validate()
|
|
193
|
+
self.numbers[self.number_key(character, 3000)] = asset
|
|
194
|
+
self.numbers[self.number_key(character, 3001)] = count
|
|
195
|
+
if not settled and change.sound is not None:
|
|
196
|
+
self.sound_id, self.sound_ids = change.sound, [change.sound]
|
|
197
|
+
self.sound_serial += 1
|
|
198
|
+
return change
|
|
199
|
+
|
|
200
|
+
def read_text(self, vm: KiwiVM, reference: int) -> str:
|
|
201
|
+
"""FUN_0009f420/0009f258: packed text or a runtime string slot."""
|
|
202
|
+
reference = signed16(reference)
|
|
203
|
+
if reference == -1:
|
|
204
|
+
return ''
|
|
205
|
+
if reference >= 0x7FF5:
|
|
206
|
+
return self.dynamic_strings[reference - 0x7FF5]
|
|
207
|
+
chars = bytearray()
|
|
208
|
+
for address in range(reference, 0x7FF5):
|
|
209
|
+
word = vm.read_word(address) & 0xFFFF
|
|
210
|
+
for byte in (word >> 8, word & 0xFF):
|
|
211
|
+
if byte == 0:
|
|
212
|
+
return chars.decode('latin-1')
|
|
213
|
+
chars.append(byte)
|
|
214
|
+
raise VMError(f'unterminated text at data word {reference}')
|
|
215
|
+
|
|
216
|
+
def substitute(self, text: str) -> str:
|
|
217
|
+
"""FUN_00096c9c; retain cycle checks for unsupported replacement loops."""
|
|
218
|
+
seen = set()
|
|
219
|
+
for _ in range(100):
|
|
220
|
+
if text in seen:
|
|
221
|
+
raise VMError('cyclic text substitution')
|
|
222
|
+
seen.add(text)
|
|
223
|
+
changed = False
|
|
224
|
+
for key, value in self.strings.items():
|
|
225
|
+
if key and key in text:
|
|
226
|
+
if key in value:
|
|
227
|
+
raise VMError(f'self-referencing text substitution {key!r}')
|
|
228
|
+
text = text.replace(key, value)
|
|
229
|
+
changed = True
|
|
230
|
+
if not changed:
|
|
231
|
+
return text
|
|
232
|
+
raise VMError('text substitution limit exceeded')
|
|
233
|
+
|
|
234
|
+
def resolve_text(self, vm: KiwiVM, reference: int) -> str:
|
|
235
|
+
"""FUN_0009f9fc/00096c9c applies stored text substitutions repeatedly."""
|
|
236
|
+
return self.substitute(self.read_text(vm, reference))
|
|
237
|
+
|
|
238
|
+
def title_details(self, vm: KiwiVM, args):
|
|
239
|
+
# 0009fa3c does not dereference a negative title. Its Android
|
|
240
|
+
# promotional branch is separate from the title card itself.
|
|
241
|
+
return dict(title='' if args[0] < 0 else self.resolve_text(vm, args[0]),
|
|
242
|
+
subtitle=self.resolve_text(vm, args[1]), asset_id=args[2], flag=args[3])
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def write_text(vm: KiwiVM, address: int, text: str):
|
|
246
|
+
"""FUN_00056b78: complete packed words, including NUL and zero padding."""
|
|
247
|
+
data = text.split('\0', 1)[0].encode('latin-1') + b'\0'
|
|
248
|
+
if len(data) % 2:
|
|
249
|
+
data += b'\0'
|
|
250
|
+
addresses = [signed16(address + i) for i in range(len(data) // 2)]
|
|
251
|
+
if any(not (0 <= a < len(vm.data) or vm.stack_base <= a < 0x7ff5) for a in addresses):
|
|
252
|
+
raise VMError('Packed string destination exceeds writable VM memory')
|
|
253
|
+
for offset, destination in enumerate(addresses):
|
|
254
|
+
vm.write_word(destination, int.from_bytes(data[offset * 2:offset * 2 + 2], 'big'))
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _format_name(text: str) -> str:
|
|
258
|
+
# Case 49 changes case at spaces, preserving $VARIABLE tokens.
|
|
259
|
+
if text.startswith('$'):
|
|
260
|
+
return text
|
|
261
|
+
result = []
|
|
262
|
+
first = True
|
|
263
|
+
for char in text:
|
|
264
|
+
byte = ord(char)
|
|
265
|
+
if byte == 0x20:
|
|
266
|
+
first = True
|
|
267
|
+
else:
|
|
268
|
+
if first and (0x61 <= byte <= 0x7A or 0xE0 <= byte <= 0xFC):
|
|
269
|
+
byte -= 0x20
|
|
270
|
+
elif not first and (0x41 <= byte <= 0x5A or 0xC0 <= byte <= 0xDC):
|
|
271
|
+
byte += 0x20
|
|
272
|
+
first = False
|
|
273
|
+
result.append(chr(byte))
|
|
274
|
+
return ''.join(result)
|
|
275
|
+
|
|
276
|
+
def dispatch(self, vm: KiwiVM, *, resource_exists=None, dialogue_override=None) -> EngineAction:
|
|
277
|
+
request = vm.pending
|
|
278
|
+
if request is None or request.kind != StopKind.YIELD:
|
|
279
|
+
raise VMError('engine dispatch requires a pending yield')
|
|
280
|
+
y, args = request.yield_id, request.args
|
|
281
|
+
if GAMES[self.game_key].DISPATCH is not None:
|
|
282
|
+
action = GAMES[self.game_key].DISPATCH(self, vm, resource_exists=resource_exists)
|
|
283
|
+
if action is not None:
|
|
284
|
+
return action
|
|
285
|
+
|
|
286
|
+
def need(count):
|
|
287
|
+
if len(args) != count:
|
|
288
|
+
raise VMError(f'yield {y} at pc {request.pc}: expected {count} arguments, got {len(args)}')
|
|
289
|
+
|
|
290
|
+
def at_least(count):
|
|
291
|
+
if len(args) < count:
|
|
292
|
+
raise VMError(f'yield {y} at pc {request.pc}: needs at least {count} arguments')
|
|
293
|
+
|
|
294
|
+
def complete(action_name, result=0, **details):
|
|
295
|
+
vm.resume(result)
|
|
296
|
+
return EngineAction(action_name, request, True, dict(result=result, **details))
|
|
297
|
+
|
|
298
|
+
if y in (7, 63):
|
|
299
|
+
# Both cases enter 0007e614 without reading any arguments. The
|
|
300
|
+
# native scene destroys its VMs; there is no script continuation.
|
|
301
|
+
# Retain this final frame for diagnostics/save validation, never
|
|
302
|
+
# as a callback that could run the following instruction.
|
|
303
|
+
self.close_episode()
|
|
304
|
+
return EngineAction('episode_exit', request, False)
|
|
305
|
+
if y == 0:
|
|
306
|
+
at_least(1)
|
|
307
|
+
slot, format_at = (1 - args[0], 1) if args[0] < 0 else (0, 0)
|
|
308
|
+
if not 0 <= slot < len(self.dynamic_strings):
|
|
309
|
+
raise VMError('Formatting destination is outside the native string slots')
|
|
310
|
+
at_least(format_at + 1)
|
|
311
|
+
pattern = self.read_text(vm, args[format_at]) # Copy before clearing aliased scratch slot 10.
|
|
312
|
+
self.dynamic_strings[10] = ''
|
|
313
|
+
position, argument = 0, format_at + 1
|
|
314
|
+
while position < len(pattern):
|
|
315
|
+
char = pattern[position]
|
|
316
|
+
if char == '%' and position + 1 < len(pattern) and pattern[position + 1] in 'cds':
|
|
317
|
+
at_least(argument + 1)
|
|
318
|
+
spec, value = pattern[position + 1], args[argument]
|
|
319
|
+
char = (self.read_text(vm, value) if spec == 's' else str(signed16(value))
|
|
320
|
+
if spec == 'd' else chr(value & 0xff))
|
|
321
|
+
position += 1
|
|
322
|
+
argument += 1
|
|
323
|
+
self.dynamic_strings[10] += char
|
|
324
|
+
position += 1
|
|
325
|
+
self.dynamic_strings[slot] = self.dynamic_strings[10]
|
|
326
|
+
return complete('format_string', 0x7ff5 + slot, slot=slot)
|
|
327
|
+
if y in (50, 51):
|
|
328
|
+
need(4 if y == 50 else 3)
|
|
329
|
+
owner, key, bit = args[:3]
|
|
330
|
+
address = self.number_key(owner, key)
|
|
331
|
+
value = self.numbers.get(address, 0)
|
|
332
|
+
shift = bit & 0xff # ARM register-shift amount, not modulo 32.
|
|
333
|
+
if y == 51:
|
|
334
|
+
return complete('get_number_bit', (value >> shift) & 1, owner=owner, key=key, bit=bit)
|
|
335
|
+
mask = (1 << shift) if shift < 32 else 0
|
|
336
|
+
value = (value | mask) if args[3] == 1 else (value & ~mask)
|
|
337
|
+
value &= 0xffffffff
|
|
338
|
+
self.numbers[address] = value - 0x100000000 if value & 0x80000000 else value
|
|
339
|
+
return complete('set_number_bit', owner=owner, key=key, bit=bit, value=args[3])
|
|
340
|
+
if y in (44, 54, 52):
|
|
341
|
+
need(3 if y == 52 else 2)
|
|
342
|
+
owner, key, value = args if y == 52 else (0, *args)
|
|
343
|
+
self.numbers[self.number_key(owner, key)] = value
|
|
344
|
+
return complete('set_number', owner=owner, key=key, value=value)
|
|
345
|
+
if y in (45, 55, 53):
|
|
346
|
+
need(2 if y == 53 else 1)
|
|
347
|
+
owner, key = args if y == 53 else (0, args[0])
|
|
348
|
+
result = self.numbers.get(self.number_key(owner, key), 0)
|
|
349
|
+
return complete('get_number', result, owner=owner, key=key)
|
|
350
|
+
if y == 46:
|
|
351
|
+
need(2)
|
|
352
|
+
key, value = (self.read_text(vm, ref) for ref in args)
|
|
353
|
+
self.strings[key] = value
|
|
354
|
+
return complete('set_string', key=key, value=value)
|
|
355
|
+
if y == 47:
|
|
356
|
+
need(3)
|
|
357
|
+
slot = ~args[2]
|
|
358
|
+
if not 0 <= slot < len(self.dynamic_strings):
|
|
359
|
+
raise VMError(f'yield 47: invalid string slot selector {args[2]}')
|
|
360
|
+
key = self.read_text(vm, args[0])
|
|
361
|
+
value = self.strings.get(key, '')
|
|
362
|
+
if not value:
|
|
363
|
+
value = self.read_text(vm, args[1])
|
|
364
|
+
self.strings[key] = value
|
|
365
|
+
self.dynamic_strings[slot] = value
|
|
366
|
+
return complete('get_string_with_default', 0x7FF5 + slot,
|
|
367
|
+
key=key, value=value, slot=slot)
|
|
368
|
+
if y in (49, 64):
|
|
369
|
+
need(3 if y == 49 else 2)
|
|
370
|
+
name = self.read_text(vm, args[1])
|
|
371
|
+
if y == 49 and args[2]:
|
|
372
|
+
name = self._format_name(name)
|
|
373
|
+
self.character_names[args[0]] = name
|
|
374
|
+
return complete('set_character_name', character_id=args[0], name=name)
|
|
375
|
+
if y == 72:
|
|
376
|
+
need(2)
|
|
377
|
+
self.character_art_bases[args[0]] = args[1]
|
|
378
|
+
base = args[1]
|
|
379
|
+
self.character_art_variants[args[0]] = [
|
|
380
|
+
base + n if base > 0 and resource_exists and resource_exists(base + n) else base
|
|
381
|
+
for n in range(5)]
|
|
382
|
+
return complete('set_character_art_base', character_id=args[0], asset_id=args[1])
|
|
383
|
+
if y == 73:
|
|
384
|
+
need(1)
|
|
385
|
+
if args[0] not in self.character_art_bases:
|
|
386
|
+
raise VMError(f'Art requested for uninitialized character {args[0]}')
|
|
387
|
+
return complete('get_character_art_base', self.character_art_bases[args[0]])
|
|
388
|
+
if y == 5:
|
|
389
|
+
need(2)
|
|
390
|
+
byte = args[1] & 255
|
|
391
|
+
self.character_expressions[args[0]] = byte - 256 if byte > 127 else byte
|
|
392
|
+
return complete('set_character_expression')
|
|
393
|
+
if y == 39:
|
|
394
|
+
# FUN_000a7cd0 marks panel 3 for removal; no arguments are read
|
|
395
|
+
# and no UI callback is requested. Retire its presentation state.
|
|
396
|
+
self.panel = PanelState()
|
|
397
|
+
self.background_pan.center()
|
|
398
|
+
self.dialogue_animation = None
|
|
399
|
+
self.next_dialogue_notice, self.notice, self.notice_ms = '', '', 0
|
|
400
|
+
return complete('close_dialogue_panel')
|
|
401
|
+
if y in (11, 34, 35, 86):
|
|
402
|
+
at_least(4 if y == 35 else 3 if y == 86 else 2)
|
|
403
|
+
base, variant = (args[2], args[1]) if y == 35 else ((args[0], args[1]) if y == 86 else (args[1], args[0]))
|
|
404
|
+
if base != -2:
|
|
405
|
+
candidate = base + variant if base >= 0 and variant in (1, 2) else base
|
|
406
|
+
self.panel.background_id = candidate if not resource_exists or resource_exists(candidate) else base
|
|
407
|
+
if base >= 0:
|
|
408
|
+
# 000a92f4 replaces the node even when its ID is unchanged.
|
|
409
|
+
self.background_pan.center()
|
|
410
|
+
if y == 35:
|
|
411
|
+
self.panel.character_id, self.panel.expression = args[0], max(0, args[3])
|
|
412
|
+
self.panel.presentation_mode, _, _ = self.dialogue_presentation(
|
|
413
|
+
self.panel.character_id, self.panel.expression)
|
|
414
|
+
self.background_pan.dialogue(self.panel.presentation_mode)
|
|
415
|
+
return complete('set_background', asset_id=self.panel.background_id)
|
|
416
|
+
if y == 97:
|
|
417
|
+
at_least(2)
|
|
418
|
+
self.background_pan.configure(args[0] != 0, args[1])
|
|
419
|
+
return complete('set_background_pan', automatic=self.background_pan.automatic,
|
|
420
|
+
alignment=self.background_pan.alignment)
|
|
421
|
+
if y == 16:
|
|
422
|
+
need(1)
|
|
423
|
+
self.scene_value = args[0]
|
|
424
|
+
return complete('set_scene_value', value=args[0])
|
|
425
|
+
if y in (74, 75):
|
|
426
|
+
need(1)
|
|
427
|
+
self.ui_defaults[y] = args[0]
|
|
428
|
+
return complete('set_ui_default', slot=y, value=args[0])
|
|
429
|
+
if y == 10:
|
|
430
|
+
need(2)
|
|
431
|
+
self.scheduled_scripts.append((args[0], args[1] != 0))
|
|
432
|
+
return complete('schedule_script', file_id=args[0], flag=args[1] != 0)
|
|
433
|
+
if y == 81:
|
|
434
|
+
need(1)
|
|
435
|
+
self.audio_stopped = True
|
|
436
|
+
self.music_id = -1
|
|
437
|
+
return complete('stop_audio')
|
|
438
|
+
if y == 82:
|
|
439
|
+
need(0)
|
|
440
|
+
# FUN_000a3040 requests 1000ms from the optional vibrator, only
|
|
441
|
+
# when present and enabled. Desktop has no vibrator backend.
|
|
442
|
+
return complete('vibrate', duration_ms=1000)
|
|
443
|
+
if y in (79, 80):
|
|
444
|
+
need(1 if y == 79 else 2)
|
|
445
|
+
if y == 80 or args[0] in GAMES[self.game_key].MUSIC_RANGE:
|
|
446
|
+
self.music_id = args[0]
|
|
447
|
+
self.music_flag = bool(args[1]) if y == 80 else False
|
|
448
|
+
self.audio_stopped = False
|
|
449
|
+
else:
|
|
450
|
+
self.sound_id = args[0]
|
|
451
|
+
self.sound_ids = [args[0]]
|
|
452
|
+
self.sound_serial += 1
|
|
453
|
+
return complete('request_audio', asset_id=args[0])
|
|
454
|
+
if y == 18: # Explicit default/no-op in this Android dispatcher.
|
|
455
|
+
need(0)
|
|
456
|
+
return complete('native_noop')
|
|
457
|
+
if y == 99:
|
|
458
|
+
need(1)
|
|
459
|
+
# The supported release's FUN_00082adc is literally MOV R0,#0;
|
|
460
|
+
# BX LR. The dispatcher takes its ordinary completion path.
|
|
461
|
+
return complete('native_noop')
|
|
462
|
+
if y == 88:
|
|
463
|
+
need(1)
|
|
464
|
+
self.next_dialogue_notice = self.resolve_text(vm, args[0])
|
|
465
|
+
return complete('set_next_dialogue_notice', text=self.next_dialogue_notice)
|
|
466
|
+
if y == 89:
|
|
467
|
+
need(0)
|
|
468
|
+
self.next_dialogue_wobble = True
|
|
469
|
+
return complete('wobble_next_dialogue')
|
|
470
|
+
if y == 90:
|
|
471
|
+
need(2)
|
|
472
|
+
text = self.resolve_text(vm, args[1]) if args[1] != -1 else ''
|
|
473
|
+
if args[0] == -1:
|
|
474
|
+
self.scene_badge = None
|
|
475
|
+
else:
|
|
476
|
+
previous = self.scene_badge
|
|
477
|
+
self.scene_badge = SceneBadge(args[0], text, previous.blue if previous else text == 'Free Time',
|
|
478
|
+
previous.elapsed_ms if previous else 0)
|
|
479
|
+
self.scene_badge.validate()
|
|
480
|
+
return complete('set_scene_badge', asset_id=args[0], text=text)
|
|
481
|
+
if y == 8:
|
|
482
|
+
need(4)
|
|
483
|
+
self.title_screen = TitleScreen()
|
|
484
|
+
return EngineAction('presentation', request, False, self.title_details(vm, args))
|
|
485
|
+
if y == 33:
|
|
486
|
+
at_least(3)
|
|
487
|
+
# 0009f420, not 0009f9fc: these two texts are NOT substituted.
|
|
488
|
+
panel = MessagePanel(self.read_text(vm, args[0]), self.read_text(vm, args[1]), args[2])
|
|
489
|
+
panel.validate()
|
|
490
|
+
self.message_panel = panel
|
|
491
|
+
return EngineAction('message_panel', request, False)
|
|
492
|
+
if y in (1, 2):
|
|
493
|
+
need(8 if y == 1 else 6)
|
|
494
|
+
title = self.resolve_text(vm, args[0])
|
|
495
|
+
text = self.resolve_text(vm, args[2]) if y == 1 and args[2] >= 0 else ''
|
|
496
|
+
if y == 1:
|
|
497
|
+
options = self.resolve_text(vm, args[1]).split('|')
|
|
498
|
+
# FUN_000b04b8 leaves a blank last field when it is whitespace.
|
|
499
|
+
if options and not options[-1].strip():
|
|
500
|
+
options[-1] = ''
|
|
501
|
+
character = self.ui_defaults.get(74, -1) if args[5] == -2 else args[5]
|
|
502
|
+
return EngineAction('choice', request, False, dict(
|
|
503
|
+
title=title, text=text, options=options, values=list(range(len(options))),
|
|
504
|
+
enabled=[True] * len(options), timeout_ms=max(0, args[3]),
|
|
505
|
+
timeout_result=args[4], character_id=character, portrait_mode=args[7],
|
|
506
|
+
))
|
|
507
|
+
self.choice_builder = dict(title=title, text=self.resolve_text(vm, args[1]),
|
|
508
|
+
options=[], values=[], enabled=[], timeout_ms=max(0, args[2]),
|
|
509
|
+
timeout_result=args[3], character_id=args[4], portrait_mode=args[5])
|
|
510
|
+
return complete('begin_choice')
|
|
511
|
+
if y == 78:
|
|
512
|
+
need(3)
|
|
513
|
+
text, count, address = args
|
|
514
|
+
if not 1 <= count <= 5:
|
|
515
|
+
raise VMError('Character selection requires 1 to 5 character IDs')
|
|
516
|
+
characters = [signed16(vm.read_word(address + i)) for i in range(count)]
|
|
517
|
+
prompt = self.resolve_text(vm, text)
|
|
518
|
+
self.character_picker = CharacterPicker.start(characters)
|
|
519
|
+
return EngineAction('character_picker', request, False, dict(text=prompt))
|
|
520
|
+
if y == 71:
|
|
521
|
+
need(8)
|
|
522
|
+
good, bad = (pipe_list(self.read_text(vm, ref)) for ref in args[2:4])
|
|
523
|
+
if len(good) < 2 or len(bad) < 3 or args[4] <= 0:
|
|
524
|
+
return EngineAction('unhandled_yield', request, False, dict(
|
|
525
|
+
reason='Unsupported word-game lists or nonpositive duration'))
|
|
526
|
+
game = WordGame(good, bad, args[4], args[7], args[4], args[7])
|
|
527
|
+
game.deal(self.random48)
|
|
528
|
+
self.word_game = game
|
|
529
|
+
# FUN_000b06bc deliberately supplies an empty title to b033c;
|
|
530
|
+
# unlike ordinary choices these text arguments are not substituted.
|
|
531
|
+
return EngineAction('word_game', request, False, dict(
|
|
532
|
+
title='', text=self.read_text(vm, args[1]),
|
|
533
|
+
character_id=args[5], portrait_mode=args[6]))
|
|
534
|
+
if y == 3:
|
|
535
|
+
need(3)
|
|
536
|
+
if self.choice_builder is None:
|
|
537
|
+
raise VMError('Choice option without a choice builder')
|
|
538
|
+
builder = self.choice_builder
|
|
539
|
+
builder['options'].append(self.resolve_text(vm, args[0]))
|
|
540
|
+
builder['values'].append(len(builder['values']) if args[1] == -999 else args[1])
|
|
541
|
+
builder['enabled'].append(args[2] != 0)
|
|
542
|
+
return complete('add_choice_option')
|
|
543
|
+
if y == 4:
|
|
544
|
+
need(1)
|
|
545
|
+
if self.choice_builder is None:
|
|
546
|
+
raise VMError('Choice display without a choice builder')
|
|
547
|
+
details = deepcopy(self.choice_builder)
|
|
548
|
+
if args[0]:
|
|
549
|
+
# 000aeee4: one draw per row from the shared game LCG, with
|
|
550
|
+
# every partner drawn from the full list (not Fisher-Yates).
|
|
551
|
+
# Only labels and return mappings move; row flags stay put.
|
|
552
|
+
count = len(details['options'])
|
|
553
|
+
for index in range(count):
|
|
554
|
+
other = self.random.below(count)
|
|
555
|
+
for key in ('options', 'values'):
|
|
556
|
+
items = details[key]
|
|
557
|
+
items[index], items[other] = items[other], items[index]
|
|
558
|
+
self.choice_builder = None
|
|
559
|
+
return EngineAction('choice', request, False, details)
|
|
560
|
+
if y in (17, 40):
|
|
561
|
+
need(3)
|
|
562
|
+
return EngineAction('text_input', request, False, dict(
|
|
563
|
+
title=self.resolve_text(vm, args[0]), prompt=self.resolve_text(vm, args[1]),
|
|
564
|
+
default=self.resolve_text(vm, args[2])))
|
|
565
|
+
if y == 24:
|
|
566
|
+
need(2)
|
|
567
|
+
return complete('text_equal', int(self.read_text(vm, args[0]) == self.read_text(vm, args[1])))
|
|
568
|
+
if y == 25:
|
|
569
|
+
need(2)
|
|
570
|
+
text = self.read_text(vm, args[0]) + self.read_text(vm, args[1])
|
|
571
|
+
self.write_text(vm, args[0], text)
|
|
572
|
+
return complete('append_text', destination=args[0])
|
|
573
|
+
if y == 27:
|
|
574
|
+
need(1)
|
|
575
|
+
return complete('random_below', self.random.below(args[0]) if args[0] > 0 else 0, bound=args[0])
|
|
576
|
+
if y == 28:
|
|
577
|
+
need(1)
|
|
578
|
+
self.write_text(vm, args[0], self.last_input)
|
|
579
|
+
return complete('copy_last_input', destination=args[0])
|
|
580
|
+
if y == 29:
|
|
581
|
+
need(1)
|
|
582
|
+
if not 0 <= args[0] < len(self.result_cells) or self.result_cells[args[0]] is None:
|
|
583
|
+
raise VMError('Read of uninitialized UI result cell')
|
|
584
|
+
return complete('get_ui_result', self.result_cells[args[0]])
|
|
585
|
+
if y == 76:
|
|
586
|
+
at_least(2)
|
|
587
|
+
speaker, text = (self.read_text(vm, ref) for ref in args[:2])
|
|
588
|
+
return EngineAction('dialogue', request, False,
|
|
589
|
+
self.present_dialogue(-1, 0, text, 0, speaker=speaker))
|
|
590
|
+
if y in (13, 65):
|
|
591
|
+
if len(args) < 2:
|
|
592
|
+
raise VMError(f'yield 13 at pc {request.pc}: incomplete dialogue arguments')
|
|
593
|
+
text_index = 1 if y == 65 or args[0] < 0 else 0
|
|
594
|
+
if y == 65:
|
|
595
|
+
need(3)
|
|
596
|
+
if len(args) <= text_index + 1:
|
|
597
|
+
raise VMError(f'yield 13 at pc {request.pc}: missing speaker argument')
|
|
598
|
+
character_id = args[0] if y == 65 else args[text_index + 1]
|
|
599
|
+
override_at = 2 if y == 65 else text_index + 2
|
|
600
|
+
expression = self.character_expressions.get(character_id, 0)
|
|
601
|
+
if len(args) > override_at and args[override_at] != -1:
|
|
602
|
+
expression = (args[override_at] + 128) % 256 - 128
|
|
603
|
+
expression = max(0, expression)
|
|
604
|
+
raw_text = (self.read_text(vm, args[text_index]) if dialogue_override is None
|
|
605
|
+
else dialogue_override)
|
|
606
|
+
mode = args[0] if y == 13 and text_index and args[0] in (-2, -3) else 0
|
|
607
|
+
return EngineAction('dialogue', request, False,
|
|
608
|
+
self.present_dialogue(character_id, expression, raw_text, mode))
|
|
609
|
+
return EngineAction('unhandled_yield', request, False)
|