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/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)