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/runtime.py ADDED
@@ -0,0 +1,879 @@
1
+ """Presentation-independent game session and versioned, local JSON saves."""
2
+ from copy import deepcopy
3
+ from dataclasses import asdict, fields, is_dataclass
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import tempfile
8
+ from types import UnionType
9
+ from typing import get_args, get_origin, get_type_hints
10
+
11
+ from .content import EpisodeResources, digest
12
+ from .games import GAMES, game_id
13
+ from .engine import EngineAction, EngineState
14
+ from .dialogue_animation import DialogueAnimation, DialoguePortrait, DialogueHistory, EXPRESSION_MS
15
+ from .background import BackgroundPan
16
+ from .dialogue_notice import notice_lifetime
17
+ from .relationships import RelationshipAnimation, RelationshipChange
18
+ from .speaker_names import SpeakerNames
19
+ from .title_screen import TitleScreen
20
+ from .loading import loading_waits
21
+ from .text_input import input_fonts, validate_name
22
+ from .randomness import NativeRandom, RAND48_INITIAL, RAND48_MASK
23
+ from .vm import KiwiVM, StopKind, VMError, VMStop, signed16
24
+
25
+
26
+ class SaveError(ValueError):
27
+ pass
28
+
29
+
30
+ def _typed_value(value, annotation, *, state_types=None):
31
+ """Decode dataclass state without permitting arbitrary object construction."""
32
+ origin, args = get_origin(annotation), get_args(annotation)
33
+ if origin is UnionType:
34
+ for candidate in args:
35
+ try:
36
+ return _typed_value(value, candidate)
37
+ except SaveError:
38
+ pass
39
+ raise SaveError('Invalid optional state value')
40
+ if is_dataclass(annotation):
41
+ names = {f.name for f in fields(annotation)}
42
+ if not isinstance(value, dict) or set(value) != names:
43
+ raise SaveError('Engine snapshot fields do not match this runtime')
44
+ hints = get_type_hints(annotation)
45
+ if annotation is EngineState:
46
+ # Only the selected installed adapter supplies these concrete
47
+ # dataclasses. Other games must have null fields, even when SHS
48
+ # happens to be installed. Saved input cannot select a type.
49
+ hints.update(word_grid=type(None), football=type(None))
50
+ hints.update(state_types or {})
51
+ return annotation(**{key: _typed_value(item, hints[key]) for key, item in value.items()})
52
+ if origin is dict:
53
+ if not isinstance(value, dict):
54
+ raise SaveError('Expected a state map')
55
+ result = {}
56
+ for key, item in value.items():
57
+ if args[0] is int and isinstance(key, str):
58
+ try:
59
+ converted = int(key)
60
+ except ValueError:
61
+ raise SaveError('Invalid numeric state key') from None
62
+ if str(converted) != key:
63
+ raise SaveError('Noncanonical numeric state key')
64
+ key = converted
65
+ result[_typed_value(key, args[0])] = _typed_value(item, args[1])
66
+ return result
67
+ if origin in (list, tuple):
68
+ if not isinstance(value, (list, tuple)):
69
+ raise SaveError('Expected a state sequence')
70
+ if origin is list:
71
+ return [_typed_value(item, args[0]) for item in value]
72
+ if len(value) != len(args):
73
+ raise SaveError('Invalid state tuple size')
74
+ return tuple(_typed_value(item, hint) for item, hint in zip(value, args))
75
+ if type(value) is not annotation:
76
+ raise SaveError(f'Expected state value of type {annotation.__name__}')
77
+ return deepcopy(value)
78
+
79
+
80
+ def _validate_choice(details):
81
+ try:
82
+ options, values, enabled = (details[k] for k in ('options', 'values', 'enabled'))
83
+ if not (isinstance(options, list) and isinstance(values, list) and isinstance(enabled, list)
84
+ and len(options) == len(values) == len(enabled)
85
+ and all(isinstance(v, str) for v in options)
86
+ and all(type(v) is int and -32768 <= v <= 32767 for v in values)
87
+ and all(type(v) is bool for v in enabled)
88
+ and type(details['timeout_ms']) is int and 0 <= details['timeout_ms'] <= 32767
89
+ and type(details['timeout_result']) is int
90
+ and -32768 <= details['timeout_result'] <= 32767
91
+ and all(isinstance(details[k], str) for k in ('title', 'text'))
92
+ and all(type(details[k]) is int for k in ('character_id', 'portrait_mode'))):
93
+ raise SaveError('Invalid choice data')
94
+ except (KeyError, TypeError) as error:
95
+ raise SaveError('Incomplete choice data') from error
96
+
97
+
98
+ class Session:
99
+ """Execute original KiWi code until real player input is required.
100
+
101
+ Unknown engine services stay suspended. A bounded advance prevents an
102
+ unsupported script loop from freezing the desktop event loop forever.
103
+ """
104
+
105
+ def __init__(self, resources: EpisodeResources, *, start_script: int = 25001):
106
+ GAMES[game_id(resources)] # Fail before executing with a missing adapter.
107
+ self.resources = resources
108
+ self.scene = start_script
109
+ self.vm = KiwiVM(resources.program(start_script))
110
+ self.engine = EngineState()
111
+ self.engine.game_key = game_id(resources)
112
+ self.engine.app_version = getattr(resources.library, 'app_info', {}).get('CFBundleVersion')
113
+ self.pending: EngineAction | None = None
114
+ self.remaining_ms: int | None = None
115
+ self.scene_loads = 0
116
+
117
+ @property
118
+ def save_path(self) -> Path:
119
+ return (self.resources.library.directory / 'saves'
120
+ / (self.resources.identity['episode_sha256'] + '.shs-save.json'))
121
+
122
+ @property
123
+ def episode_exited(self) -> bool:
124
+ return self.pending is not None and self.pending.name == 'episode_exit'
125
+
126
+ def dialogue_page(self):
127
+ """Layout is a session contract, shared by headless and desktop input."""
128
+ action = self.pending
129
+ if not action or action.name != 'dialogue' or not hasattr(self.resources, 'dialogue_layout'):
130
+ return None
131
+ return self.resources.dialogue_layout().page(action.details, action.details.get('page_start', 0),
132
+ names=self.engine.speaker_names)
133
+
134
+ def name_input_metrics(self):
135
+ """Use the selected game's glyph advances for both input paths."""
136
+ if not hasattr(self.resources, 'dialogue_layout'):
137
+ return dict(font=None, cursor_width=0)
138
+ layout = self.resources.dialogue_layout()
139
+ name, cursor = input_fonts(self.resources)
140
+ return dict(font=layout.font(name), cursor_width=layout.font(cursor).glyph('|').advance)
141
+
142
+ def _prepare_dialogue(self, *, new_name=False):
143
+ details = self.pending.details
144
+ if new_name and hasattr(self.resources, 'dialogue_layout'):
145
+ displayed = self.resources.dialogue_layout().prepare_name(details, self.engine.speaker_names)
146
+ if details['presentation_mode'] != 4:
147
+ # Native display-only spacing corrections modify the panel's
148
+ # retained string; the script's character metadata stays intact.
149
+ self.engine.panel.speaker = displayed
150
+ details.setdefault('page_start', 0)
151
+ page = self.dialogue_page()
152
+ details['page_end'] = page.end if page else len(details['text'])
153
+
154
+ def advance(self, *, max_steps: int = 100_000, max_events: int = 10_000,
155
+ observe=None) -> EngineAction:
156
+ if self.pending is not None:
157
+ return self.pending
158
+ if max_steps < 1 or max_events < 1:
159
+ raise ValueError('Execution budgets must be positive')
160
+ deadline = self.vm.steps_executed + max_steps
161
+ for _ in range(max_events):
162
+ remaining = deadline - self.vm.steps_executed
163
+ if remaining <= 0:
164
+ raise VMError('Session instruction budget exceeded before the next input')
165
+ request = self.vm.run(remaining)
166
+ if request.kind == StopKind.YIELD:
167
+ action = self.engine.dispatch(self.vm, resource_exists=self.resources.exists)
168
+ if observe is not None:
169
+ observe(action)
170
+ if action.completed:
171
+ continue
172
+ if action.name == 'choice':
173
+ _validate_choice(action.details)
174
+ self.remaining_ms = action.details['timeout_ms'] or None
175
+ self.pending = action
176
+ if action.name == 'dialogue':
177
+ self._prepare_dialogue(new_name=True)
178
+ self.engine.dialogue_animation = DialogueAnimation.start(
179
+ action.details, self.engine.character_art_variants, self.engine.dialogue_animation,
180
+ self.engine.dialogue_history)
181
+ # Native addUIElement covers the dialogue panel; removing the
182
+ # temporary panel returns to the same speaker/expression.
183
+ # Hidden dialogue does not tick. Only explicit teardown or a
184
+ # new scene discards it.
185
+ return action
186
+ if request.kind == StopKind.HALT and self.engine.scheduled_scripts:
187
+ scene, _flag = self.engine.scheduled_scripts[-1]
188
+ program = self.resources.program(scene)
189
+ self.engine.scheduled_scripts.pop()
190
+ self.vm.load_next(program)
191
+ # runScript09 clears the old scene UI before loadScript09.
192
+ # The global hidden portrait position outlives that panel.
193
+ self.engine.dialogue_animation = None
194
+ self.engine.clear_scene_expressions()
195
+ self.scene, self.scene_loads = scene, self.scene_loads + 1
196
+ continue
197
+ if request.kind == StopKind.BUDGET:
198
+ raise VMError('Session instruction budget exceeded before the next input')
199
+ self.pending = EngineAction('finished' if request.kind == StopKind.HALT else 'vm_pause',
200
+ request, False)
201
+ if self.pending.name == 'finished':
202
+ self.engine.dialogue_animation = None
203
+ return self.pending
204
+ raise VMError('Session service budget exceeded before the next input')
205
+
206
+ def _complete_choice(self, result: int):
207
+ self.engine.result_cells[0] = signed16(result) # FUN_000add90
208
+ self.vm.resume(result)
209
+ self.pending, self.remaining_ms = None, None
210
+
211
+ def answer(self, value=None) -> EngineAction:
212
+ action = self.pending
213
+ if action is None:
214
+ raise VMError('There is no pending input')
215
+ if self.episode_exited:
216
+ raise VMError('The episode has ended; there is no pending input')
217
+ if action.name == 'choice':
218
+ options = action.details
219
+ if (type(value) is not int or not 0 <= value < len(options['values'])
220
+ or not options['enabled'][value]):
221
+ raise ValueError('Choose an enabled option by its zero-based index')
222
+ self._complete_choice(options['values'][value])
223
+ elif action.name == 'character_picker':
224
+ picker = self.engine.character_picker
225
+ if value is not None:
226
+ if picker.select(value):
227
+ self.engine.sound_id, self.engine.sound_ids = 8013, [8013]
228
+ self.engine.sound_serial += 1
229
+ return action
230
+ self.engine.sound_id, self.engine.sound_ids = 8010, [8010]
231
+ self.engine.sound_serial += 1
232
+ self._complete_choice(picker.selected) # FUN_000d1b48: original index, not ID.
233
+ self.engine.character_picker = None
234
+ elif action.name == 'word_game':
235
+ game = self.engine.word_game
236
+ if game.pick(value, self.engine.random48):
237
+ self.engine.sound_id = 8001 if game.last_delta > 0 else 8002
238
+ self.engine.sound_ids = [self.engine.sound_id]
239
+ self.engine.sound_serial += 1
240
+ return action
241
+ elif action.name == 'word_grid':
242
+ if value is not None:
243
+ raise ValueError('Grid input uses pointer phases and cell indices')
244
+ return self.grid_pointer('down', None)
245
+ elif action.name == 'football':
246
+ game = self.engine.football
247
+ before = game.sound_serial
248
+ game.tap(value, self.engine.random)
249
+ self._game_sound(game, before)
250
+ return action
251
+ elif action.name == 'text_input':
252
+ validate_name(value, **self.name_input_metrics())
253
+ self.engine.dynamic_strings[0] = self.engine.last_input = value
254
+ self.vm.resume(0x7ff5) # FUN_000d3f5c
255
+ self.pending = None
256
+ elif action.name in ('message_panel', 'survey_confirmation'):
257
+ if value is not None:
258
+ raise ValueError('This screen expects an acknowledgement')
259
+ if not self.engine.message_panel.ready:
260
+ return action # 000abb3c ignores early input; it is not queued.
261
+ if action.name == 'survey_confirmation':
262
+ self._complete_survey(action)
263
+ else:
264
+ self._complete_panel()
265
+ self.engine.message_panel = None
266
+ elif action.name == 'presentation':
267
+ if value is not None:
268
+ raise ValueError('This screen expects an acknowledgement')
269
+ if not self.engine.title_screen.acknowledge():
270
+ return action
271
+ self._complete_panel()
272
+ self.engine.title_screen = None
273
+ elif action.name in ('dialogue', 'vm_pause'):
274
+ if value is not None:
275
+ raise ValueError('This screen expects an acknowledgement')
276
+ if action.name == 'dialogue':
277
+ # 000a9868 hides the notice even when this tap only requests
278
+ # the unfinished text reveal, or turns a page in the same call.
279
+ self.engine.notice, self.engine.notice_ms = '', 0
280
+ if action.name == 'dialogue' and not self.engine.dialogue_animation.complete:
281
+ self.engine.dialogue_animation.finish()
282
+ return action
283
+ if action.name == 'dialogue' and action.details['page_end'] < len(action.details['text']):
284
+ action.details['page_start'] = action.details['page_end']
285
+ self._prepare_dialogue()
286
+ self.engine.dialogue_animation.next_page(len(action.details['text']) - action.details['page_start'])
287
+ return action
288
+ if action.name == 'vm_pause':
289
+ self.vm.continue_after_pause()
290
+ else:
291
+ self._complete_panel()
292
+ self.pending = None
293
+ else:
294
+ raise VMError(f'Cannot answer {action.name}; its native contract is not implemented')
295
+ return self.advance()
296
+
297
+ def _complete_survey(self, action):
298
+ from .shs.survey import RESPONSE_STEPS, SURVEY_TEXT, script_response
299
+ response = script_response(self.vm, self.engine.read_text)
300
+ if response is None or response.pc != action.details['response_pc']:
301
+ raise VMError('Survey response continuation changed while awaiting acknowledgement')
302
+ self.vm.resume(0) # Local status: not uploaded; leave aggregate memory alone.
303
+ request = self.vm.run(RESPONSE_STEPS)
304
+ if request != response:
305
+ raise VMError('Survey response did not match its verified continuation')
306
+ # The player has already acknowledged the replacement for this exact
307
+ # connection-error notice. Execute its real frame and callback once,
308
+ # preserving speaker/panel state and the queued transition's effects.
309
+ self.pending = self.engine.dispatch(self.vm, resource_exists=self.resources.exists,
310
+ dialogue_override=SURVEY_TEXT)
311
+ self._prepare_dialogue(new_name=True)
312
+ self._complete_panel()
313
+ self.engine.message_panel = None
314
+
315
+ def _complete_panel(self):
316
+ # 000ab8b8 / 0007efe8, shared by title, message and dialogue panels. A queued
317
+ # random transition consumes one libc draw, even before its animation
318
+ # is implemented. 000a6448 returns zero without writing result cells.
319
+ if self.engine.scene_value == 20:
320
+ self.engine.random48.next()
321
+ self.engine.scene_value = 0
322
+ self.vm.resume(0)
323
+ self.pending = None
324
+
325
+ def _game_sound(self, game, before):
326
+ if game.sound_serial != before:
327
+ self.engine.sound_ids = game.sound_ids[:]
328
+ self.engine.sound_id = game.sound_ids[0]
329
+ self.engine.sound_serial += 1
330
+
331
+ def grid_pointer(self, phase, index=None):
332
+ """Feed a down/move/up/cancel event to the pending original grid."""
333
+ if not self.pending or self.pending.name != 'word_grid':
334
+ raise VMError('There is no pending grid game')
335
+ game = self.engine.word_grid
336
+ before = game.sound_serial
337
+ game.pointer(phase, index, self.engine.random)
338
+ self._game_sound(game, before)
339
+ return self.pending
340
+
341
+ def tick(self, elapsed_ms: int) -> EngineAction:
342
+ """Advance active time only; time spent outside the app is excluded."""
343
+ if type(elapsed_ms) is not int or elapsed_ms < 0:
344
+ raise ValueError('Elapsed time must be nonnegative milliseconds')
345
+ if self.episode_exited:
346
+ return self.pending
347
+ self.engine.background_pan.tick(elapsed_ms)
348
+ if self.engine.scene_badge:
349
+ self.engine.scene_badge.tick(elapsed_ms)
350
+ self.engine.notice_ms = max(0, self.engine.notice_ms - elapsed_ms)
351
+ if not self.engine.notice_ms:
352
+ self.engine.notice = ''
353
+ if self.engine.loading:
354
+ if self.engine.loading.tick(elapsed_ms):
355
+ if self.engine.loading.blocking:
356
+ self.vm.resume(0) # No choice-result cell is written.
357
+ self.engine.loading = None
358
+ self.pending = None
359
+ return self.advance()
360
+ return self.pending
361
+ if self.pending and self.pending.name == 'vm_pause':
362
+ return self.answer()
363
+ if self.pending and self.pending.name == 'dialogue':
364
+ motion = self.engine.dialogue_animation
365
+ motion.tick(elapsed_ms)
366
+ if motion.portrait is not None and motion.presentation_ms >= EXPRESSION_MS:
367
+ value = motion.expression & 255
368
+ self.engine.dialogue_history.expressions[motion.portrait.character_id] = value - 256 if value > 127 else value
369
+ return self.pending
370
+ if self.pending and self.pending.name == 'character_picker':
371
+ self.engine.character_picker.tick(elapsed_ms)
372
+ return self.pending
373
+ if self.pending and self.pending.name in ('message_panel', 'survey_confirmation'):
374
+ self.engine.message_panel.tick(elapsed_ms)
375
+ return self.pending # Reading time never acknowledges the panel.
376
+ if self.pending and self.pending.name == 'presentation':
377
+ self.engine.title_screen.tick(elapsed_ms)
378
+ return self.pending
379
+ if self.pending and self.pending.name == 'word_game':
380
+ game = self.engine.word_game
381
+ if game.tick(elapsed_ms):
382
+ self._complete_choice(game.score) # FUN_000add90, mode 2.
383
+ self.engine.word_game = None
384
+ return self.advance()
385
+ return self.pending
386
+ if self.pending and self.pending.name in ('word_grid', 'football'):
387
+ kind = self.pending.name
388
+ game = getattr(self.engine, kind)
389
+ before = game.sound_serial
390
+ finished = game.tick(elapsed_ms, self.engine.random)
391
+ self._game_sound(game, before)
392
+ if finished:
393
+ if kind == 'football':
394
+ self.engine.football_scores = [game.home, game.away]
395
+ self.vm.resume(game.result) # 000b10d8: result cells are unchanged.
396
+ self.pending = None
397
+ setattr(self.engine, kind, None)
398
+ return self.advance()
399
+ return self.pending
400
+ if self.remaining_ms is not None:
401
+ remaining = self.remaining_ms - elapsed_ms
402
+ self.remaining_ms = max(0, remaining)
403
+ if remaining < 0: # 000ae4f4: exactly zero remains selectable.
404
+ # For incremental choices the native callback maps the timeout
405
+ # selection through the per-option return-value table as well.
406
+ details = self.pending.details
407
+ selected = details['timeout_result']
408
+ if self.pending.request.yield_id == 4:
409
+ if not 0 <= selected < len(details['values']):
410
+ raise VMError('Incremental choice timeout index is out of range')
411
+ selected = details['values'][selected]
412
+ self._complete_choice(selected)
413
+ return self.advance()
414
+ return self.advance()
415
+
416
+ def snapshot(self) -> dict:
417
+ return dict(format='exp-runtime-save', version=21,
418
+ content=self.resources.identity, scene=self.scene,
419
+ script_sha256=digest(self.vm.program.to_bytes()),
420
+ vm=self.vm.snapshot(), engine=asdict(self.engine),
421
+ pending=dict(name=self.pending.name, details=deepcopy(self.pending.details))
422
+ if self.pending else None,
423
+ remaining_ms=self.remaining_ms, scene_loads=self.scene_loads)
424
+
425
+ @classmethod
426
+ def from_snapshot(cls, resources: EpisodeResources, state: dict):
427
+ try:
428
+ if state['format'] not in ('shs-runtime-save', 'exp-runtime-save') or state['version'] not in range(1, 22):
429
+ raise SaveError('Unsupported save format or version')
430
+ legacy = state['version'] == 1
431
+ if state['version'] < 21:
432
+ state = deepcopy(state)
433
+ motion = state['engine'].get('dialogue_animation')
434
+ history = DialogueHistory()
435
+ if motion is not None:
436
+ portrait = motion.get('portrait')
437
+ expression = state['engine']['panel']['expression']
438
+ mode = portrait['mode'] if portrait else 0
439
+ history.anchor_mode = mode
440
+ if portrait:
441
+ byte = expression & 255
442
+ history.expressions[portrait['character_id']] = byte - 256 if byte > 127 else byte
443
+ defaults = dict(native_lifecycle=False, anchor_mode=mode, initial_portrait=portrait,
444
+ initial_expression=expression, expression=expression,
445
+ presentation_ms=2400, visuals_finished=False, housing_clipped=False)
446
+ for key, value in defaults.items():
447
+ motion.setdefault(key, value)
448
+ state['engine'].setdefault('dialogue_history', asdict(history))
449
+ if state.get('pending') and state['pending']['name'] == 'dialogue':
450
+ details = state['pending']['details']
451
+ details.setdefault('initial_expression', details['expression'])
452
+ if state['version'] < 20 and state['engine'].get('dialogue_animation') is not None:
453
+ state = deepcopy(state)
454
+ # The old frame was already full-sized. Preserve its text,
455
+ # clocks and VM without replaying the newly supported grow.
456
+ state['engine']['dialogue_animation']['box_grow'] = False
457
+ if state['version'] < 19:
458
+ state = deepcopy(state)
459
+ # Preserve the older renderer's centered view until the next
460
+ # native alignment request; never replay dialogue on load.
461
+ state['engine']['background_pan'] = asdict(BackgroundPan())
462
+ if state['version'] < 18 and state['engine'].get('word_grid') is not None:
463
+ state = deepcopy(state)
464
+ state['engine']['word_grid'].update(pop_ms={}, explosions=[], glint_ms=100_000_000)
465
+ if state['version'] < 17 and state['engine'].get('dialogue_animation') is not None:
466
+ state = deepcopy(state)
467
+ motion = state['engine']['dialogue_animation']
468
+ motion['continue_ms'] = 250 if motion['complete'] else 0
469
+ if state['version'] < 11:
470
+ state = deepcopy(state)
471
+ state['engine']['title_screen'] = None
472
+ if state['version'] < 10:
473
+ state = deepcopy(state)
474
+ state['engine']['speaker_names'] = asdict(SpeakerNames())
475
+ if state['version'] < 9:
476
+ state = deepcopy(state)
477
+ state['engine']['message_panel'] = None
478
+ if state['version'] < 8 and state['engine'].get('football') is not None:
479
+ state = deepcopy(state)
480
+ football = state['engine']['football']
481
+ # Old saves lack both camera history and individual native
482
+ # messages. Keep their current text/timers until the next play.
483
+ football.update(visual_ms=0, camera_position=float(football['position']),
484
+ hud_position=float(football['position']), message_ids=[],
485
+ message_values=[], message_hold_ms=800, effects=[])
486
+ if state['version'] < 7 and state['engine'].get('word_grid') is not None:
487
+ state = deepcopy(state)
488
+ # Older saves have no decorative clock or outgoing tile face.
489
+ # Resume their board and VM exactly, with settled decoration.
490
+ state['engine']['word_grid'].update(visual_ms=0, board_entry_ms=2000,
491
+ banner=None, transition=None)
492
+ if state['version'] < 6:
493
+ state = deepcopy(state)
494
+ state['engine']['loading'] = None
495
+ if state['engine'].get('dialogue_animation') is not None:
496
+ state['engine']['dialogue_animation']['relationship'] = None
497
+ if state['version'] < 5:
498
+ state = deepcopy(state)
499
+ state['engine']['character_picker'] = None
500
+ state['engine']['scene_badge'] = None
501
+ state['engine']['next_dialogue_wobble'] = False
502
+ if state['engine'].get('dialogue_animation') is not None:
503
+ state['engine']['dialogue_animation']['wobble_direction'] = 0
504
+ if state['version'] < 4:
505
+ state = deepcopy(state)
506
+ state['engine']['dialogue_animation'] = None
507
+ if state['version'] < 3:
508
+ state = deepcopy(state)
509
+ state['engine'].update(random48=dict(state=RAND48_INITIAL), word_game=None,
510
+ random=asdict(NativeRandom()), word_grid=None,
511
+ grid_tutorials_seen=False, sound_ids=[],
512
+ football=None, football_scores=[None, None],
513
+ next_dialogue_notice='', notice='', notice_ms=0)
514
+ if legacy:
515
+ state = deepcopy(state)
516
+ state['engine']['panel'].update(presentation_mode=0, theme=-1, emphasis_theme=1)
517
+ if state['content'] != resources.identity:
518
+ raise SaveError('Save requires the same imported game and episode')
519
+ if type(state['scene']) is not int or not 0 <= state['scene'] <= 65535:
520
+ raise SaveError('Invalid saved scene ID')
521
+ session = cls(resources, start_script=state['scene'])
522
+ if state['script_sha256'] != digest(session.vm.program.to_bytes()):
523
+ raise SaveError('Saved script does not match the loaded script')
524
+ session.vm = KiwiVM.from_snapshot(session.vm.program, state['vm'])
525
+ session.engine = _typed_value(state['engine'], EngineState,
526
+ state_types=GAMES[game_id(resources)].STATE_TYPES)
527
+ engine = session.engine
528
+ engine.game_key = game_id(resources)
529
+ engine.app_version = getattr(resources.library, 'app_info', {}).get('CFBundleVersion')
530
+ engine.background_pan.validate()
531
+ engine.dialogue_history.validate()
532
+ engine.speaker_names.validate()
533
+ if engine.title_screen is not None:
534
+ engine.title_screen.validate()
535
+ if not 0 <= engine.random48.state <= RAND48_MASK:
536
+ raise SaveError('Invalid random generator state')
537
+ if not 0 <= engine.random.state <= 0xffffffff:
538
+ raise SaveError('Invalid native random generator state')
539
+ if engine.word_game is not None:
540
+ engine.word_game.validate()
541
+ if engine.word_grid is not None:
542
+ engine.word_grid.validate()
543
+ if engine.football is not None:
544
+ engine.football.validate()
545
+ if engine.character_picker is not None:
546
+ engine.character_picker.validate()
547
+ if engine.scene_badge is not None:
548
+ engine.scene_badge.validate()
549
+ if engine.loading is not None:
550
+ engine.loading.validate()
551
+ if engine.message_panel is not None:
552
+ engine.message_panel.validate()
553
+ if (len(engine.football_scores) != 2 or
554
+ any(v is not None and not -32768 <= v <= 32767 for v in engine.football_scores)):
555
+ raise SaveError('Invalid football scores')
556
+ if not 0 <= engine.notice_ms <= notice_lifetime(engine.notice):
557
+ raise SaveError('Invalid dialogue notice timer')
558
+ if len(engine.dynamic_strings) != 11 or len(engine.result_cells) != 10:
559
+ raise SaveError('Invalid engine slot count')
560
+ if any(v is not None and not -32768 <= v <= 32767 for v in engine.result_cells):
561
+ raise SaveError('Invalid engine result word')
562
+ if engine.choice_builder is not None:
563
+ _validate_choice(engine.choice_builder)
564
+ pending = state['pending']
565
+ if pending is None and session.vm.pending is not None:
566
+ raise SaveError('Suspended VM is missing its pending screen')
567
+ if pending is not None:
568
+ request = session.vm.pending
569
+ name, details = pending['name'], pending['details']
570
+ if not isinstance(details, dict):
571
+ raise SaveError('Invalid pending screen details')
572
+ if name == 'loading' and engine.loading and not engine.loading.blocking:
573
+ # A nonwaiting service 91 has already popped its frame. The host
574
+ # gate pauses before the very next instruction, so the
575
+ # completed call is recoverable from the program and stack
576
+ # backing without inventing a suspended VM frame.
577
+ pc = session.vm.pc - 1
578
+ if request is not None or not 0 <= pc < len(session.vm.program.instructions):
579
+ raise SaveError('Invalid completed loading call')
580
+ instruction = session.vm.program.instructions[pc]
581
+ if (not ((instruction.opcode == 0x1f and instruction.operand >> 8 == 91)
582
+ or (instruction.opcode == 0x1e and instruction.operand == 91))
583
+ or session.vm.result != 0
584
+ or session.vm.read_word(session.vm.stack_base + session.vm.sp) != 0):
585
+ raise SaveError('Loading gate does not match the completed VM call')
586
+ # Older register-count saves could only contain one argument.
587
+ count = (instruction.operand & 255 if instruction.opcode == 0x1f
588
+ else details.get('argument_count', 1))
589
+ address = session.vm.stack_base + session.vm.sp
590
+ if type(count) is not int or not 0 <= count <= 0x7ff5 - address:
591
+ raise SaveError('Invalid completed loading argument count')
592
+ args = tuple(session.vm.read_word(address + i) for i in range(count))
593
+ request = VMStop(StopKind.YIELD, pc, instruction.byte_offset, 91, args)
594
+ if request is None:
595
+ raise SaveError('Pending screen has no suspended VM request')
596
+ expected_kind = {'finished': StopKind.HALT, 'vm_pause': StopKind.PAUSE}.get(name, StopKind.YIELD)
597
+ if request.kind != expected_kind:
598
+ raise SaveError('Pending screen does not match the VM stop')
599
+ allowed = {'choice': (1, 4), 'character_picker': (78,), 'word_game': (71,), 'word_grid': (96,), 'football': (94,), 'dialogue': (13, 15, 65, 76), 'text_input': (17, 40),
600
+ 'presentation': (8,), 'loading': (91,), 'message_panel': (33,),
601
+ 'survey_confirmation': (9,), 'episode_exit': (7, 63),
602
+ 'finished': (None,), 'vm_pause': (None,)}
603
+ if game_id(resources) == 'cod':
604
+ for unsupported in ('word_grid', 'football', 'loading', 'survey_confirmation'):
605
+ allowed.pop(unsupported)
606
+ if name != 'unhandled_yield' and (name not in allowed or request.yield_id not in allowed[name]):
607
+ raise SaveError('Pending screen does not match its native service')
608
+ if name == 'choice':
609
+ _validate_choice(details)
610
+ elif name == 'episode_exit':
611
+ closed = deepcopy(engine)
612
+ closed.close_episode()
613
+ if state['version'] < 12 or details or engine != closed:
614
+ raise SaveError('Episode exit does not match its cleared scene')
615
+ elif name == 'survey_confirmation':
616
+ from .shs.survey import SURVEY_TEXT, SURVEY_TITLE, is_survey, script_response
617
+ panel = engine.message_panel
618
+ response = script_response(session.vm, engine.read_text)
619
+ if (state['version'] < 13 or not is_survey(request.args) or response is None
620
+ or details != dict(response_pc=response.pc)
621
+ or type(details['response_pc']) is not int or panel is None
622
+ or (panel.title, panel.text, panel.argument) != (SURVEY_TITLE, SURVEY_TEXT, 0)):
623
+ raise SaveError('Survey confirmation does not match its submission frame')
624
+ elif name == 'loading':
625
+ expected_details = {}
626
+ if (engine.loading and not engine.loading.blocking and len(request.args) != 1
627
+ and session.vm.program.instructions[request.pc].opcode == 0x1e):
628
+ expected_details['argument_count'] = len(request.args)
629
+ if (engine.loading is None or details != expected_details
630
+ or engine.loading.blocking != loading_waits(session.vm, request)):
631
+ raise SaveError('Loading screen does not match its VM arguments')
632
+ elif name == 'message_panel':
633
+ panel = engine.message_panel
634
+ if (panel is None or len(request.args) < 3 or details
635
+ or panel.argument != request.args[2]
636
+ or panel.title != engine.read_text(session.vm, request.args[0])
637
+ or panel.text != engine.read_text(session.vm, request.args[1])):
638
+ raise SaveError('Message panel does not match its VM arguments')
639
+ elif name == 'character_picker':
640
+ _typed_value(details['text'], str)
641
+ if engine.character_picker is None or len(request.args) != 3:
642
+ raise SaveError('Character selection is missing its native state')
643
+ text, count, address = request.args
644
+ if (not 1 <= count <= 5 or
645
+ engine.character_picker.characters != [signed16(session.vm.read_word(address + i))
646
+ for i in range(count)]
647
+ or details['text'] != engine.resolve_text(session.vm, text)):
648
+ raise SaveError('Character selection does not match its VM arguments')
649
+ elif name == 'word_game':
650
+ for key in ('title', 'text'):
651
+ _typed_value(details[key], str)
652
+ for key in ('character_id', 'portrait_mode'):
653
+ _typed_value(details[key], int)
654
+ elif name == 'word_grid':
655
+ for key in ('background_id', 'left_character', 'left_expression', 'right_character', 'right_expression'):
656
+ _typed_value(details[key], int)
657
+ elif name == 'dialogue':
658
+ for key in ('text', 'raw_text', 'speaker'):
659
+ _typed_value(details[key], str)
660
+ for key in ('character_id', 'expression', 'initial_expression', 'mode'):
661
+ _typed_value(details[key], int)
662
+ if legacy:
663
+ details = engine.present_dialogue(details['character_id'], details['expression'],
664
+ details['raw_text'], details['mode'], settled_relationship=True)
665
+ engine.background_pan.center()
666
+ else:
667
+ for key in ('visible_character_id', 'presentation_mode', 'theme',
668
+ 'emphasis_theme', 'page_start', 'page_end'):
669
+ _typed_value(details[key], int)
670
+ if (details['presentation_mode'] not in (1, 2, 3, 4)
671
+ or details['emphasis_theme'] not in (1, 2)
672
+ or not 0 <= details['page_start'] <= details['page_end'] <= len(details['text'])):
673
+ raise SaveError('Invalid dialogue presentation or page')
674
+ if state['version'] < 6:
675
+ change = engine.prepare_relationship(details['visible_character_id'], settled=True)
676
+ details['relationship'] = asdict(change) if change else None
677
+ change = _typed_value(details['relationship'], RelationshipChange | None)
678
+ if change is not None:
679
+ change.validate()
680
+ if ((change.asset_id != -1 and change.asset_id not in GAMES[engine.game_key].RELATIONSHIP_ASSETS)
681
+ or change.character_id != details['visible_character_id']
682
+ or details['presentation_mode'] not in (1, 2)
683
+ or engine.numbers.get(engine.number_key(change.character_id, 3000)) != change.asset_id
684
+ or engine.numbers.get(engine.number_key(change.character_id, 3001)) != change.count):
685
+ raise SaveError('Relationship indicators do not match their NPC cache')
686
+ elif details['visible_character_id'] > 0 and details['presentation_mode'] in (1, 2):
687
+ raise SaveError('NPC dialogue is missing its relationship state')
688
+ if request.yield_id in (15, 76):
689
+ refs = request.args
690
+ if request.yield_id == 15 and game_id(resources) == 'shs':
691
+ from .shs.services import named_dialogue_refs
692
+ refs = named_dialogue_refs(request)
693
+ if (len(refs) < 2
694
+ or details['speaker'] != engine.resolve_text(session.vm, refs[0])
695
+ or details['raw_text'] != engine.read_text(session.vm, refs[1])
696
+ or details['text'] != engine.substitute(details['raw_text'])
697
+ or (details['character_id'], details['visible_character_id'],
698
+ details['expression'], details['mode'], details['presentation_mode'],
699
+ details['theme'], details['relationship']) != (-1, -1, 0, 0, 3, -1, None)):
700
+ raise SaveError('Named dialogue does not match its VM arguments')
701
+ elif name == 'presentation':
702
+ for key in ('title', 'subtitle'):
703
+ _typed_value(details[key], str)
704
+ for key in ('asset_id', 'flag'):
705
+ _typed_value(details[key], int)
706
+ if len(request.args) != 4 or details != engine.title_details(session.vm, request.args):
707
+ raise SaveError('Title screen does not match its VM arguments')
708
+ if state['version'] < 11:
709
+ engine.title_screen = TitleScreen.settled()
710
+ elif name == 'text_input':
711
+ for key in ('title', 'prompt', 'default'):
712
+ _typed_value(details[key], str)
713
+ if 'draft' in details:
714
+ _typed_value(details['draft'], str)
715
+ session.pending = EngineAction(name, request, False, deepcopy(details))
716
+ if name == 'dialogue':
717
+ saved_end = details.get('page_end')
718
+ reflow_ipa = (state['version'] < 16
719
+ and getattr(getattr(resources, 'library', None), 'kind', None) == 'ipa')
720
+ rebuild_names = reflow_ipa and state['version'] < 15
721
+ if rebuild_names:
722
+ # v14 used a separate IPA layout with no name history.
723
+ # Earlier IPA saves retain the state before this name:
724
+ # use that basis so we do not advance it twice.
725
+ engine.speaker_names = SpeakerNames(deepcopy(engine.speaker_names.basis or {}))
726
+ if state['version'] >= 10 and not rebuild_names and hasattr(resources, 'dialogue_layout'):
727
+ names = engine.speaker_names
728
+ if names.basis is None:
729
+ raise SaveError('Dialogue is missing its speaker layout state')
730
+ check = SpeakerNames(deepcopy(names.basis))
731
+ resources.dialogue_layout().prepare_name(session.pending.details, check)
732
+ if check.fonts != names.fonts:
733
+ raise SaveError('Speaker layout does not match its saved font state')
734
+ session._prepare_dialogue(new_name=state['version'] < 10 or rebuild_names)
735
+ # Earlier saves either lack font history or used different
736
+ # IPA placement/metrics. Keep the read offset and reflow
737
+ # that page once without executing the pending VM call.
738
+ if state['version'] >= 10 and not reflow_ipa and session.pending.details['page_end'] != saved_end:
739
+ raise SaveError('Saved dialogue page does not match its layout')
740
+ if state['version'] < 4:
741
+ session.pending.details['initial_expression'] = session.pending.details['expression']
742
+ engine.dialogue_animation = DialogueAnimation.start(
743
+ session.pending.details, engine.character_art_variants,
744
+ history=engine.dialogue_history)
745
+ engine.dialogue_animation.native_lifecycle = False
746
+ engine.dialogue_animation.box_grow = False
747
+ engine.dialogue_animation.settle()
748
+ if state['version'] < 6 and engine.dialogue_animation is not None:
749
+ engine.dialogue_animation.relationship = RelationshipAnimation(change, 0) if change else None
750
+ if engine.dialogue_animation.relationship:
751
+ engine.dialogue_animation.relationship.settle()
752
+ motion = engine.dialogue_animation
753
+ if motion is not None:
754
+ motion.validate()
755
+ if not session.pending or session.pending.name in ('finished', 'episode_exit'):
756
+ raise SaveError('Dialogue animation does not match its pending screen')
757
+ if motion.native_lifecycle and motion.portrait is not None:
758
+ expression = motion.expression if motion.presentation_ms >= EXPRESSION_MS else motion.initial_expression
759
+ byte = expression & 255
760
+ if (engine.dialogue_history.anchor_mode != motion.anchor_mode or
761
+ engine.dialogue_history.expressions.get(motion.portrait.character_id) !=
762
+ (byte - 256 if byte > 127 else byte)):
763
+ raise SaveError('Dialogue expression does not match its portrait history')
764
+ if session.pending and session.pending.name == 'dialogue':
765
+ details = session.pending.details
766
+ if (motion is None or motion.text_length != len(details['text']) - details['page_start']
767
+ or motion.portrait != DialoguePortrait.from_details(details, engine.character_art_variants)
768
+ or motion.initial_portrait != DialoguePortrait.from_details(
769
+ dict(details, expression=details['initial_expression']), engine.character_art_variants)
770
+ or motion.expression != details['expression']
771
+ or motion.initial_expression != details['initial_expression']
772
+ or (asdict(motion.relationship.change) if motion.relationship else None) != details['relationship']):
773
+ raise SaveError('Dialogue animation does not match its text or portrait')
774
+ if (engine.word_game is not None) != bool(session.pending and session.pending.name == 'word_game'):
775
+ raise SaveError('Word game does not match its pending screen')
776
+ if (engine.word_grid is not None) != bool(session.pending and session.pending.name == 'word_grid'):
777
+ raise SaveError('Grid game does not match its pending screen')
778
+ if (engine.football is not None) != bool(session.pending and session.pending.name == 'football'):
779
+ raise SaveError('Football game does not match its pending screen')
780
+ if (engine.character_picker is not None) != bool(session.pending and session.pending.name == 'character_picker'):
781
+ raise SaveError('Character selection does not match its pending screen')
782
+ if (engine.loading is not None) != bool(session.pending and session.pending.name == 'loading'):
783
+ raise SaveError('Loading state does not match its pending screen')
784
+ if (engine.message_panel is not None) != bool(session.pending and session.pending.name in
785
+ ('message_panel', 'survey_confirmation')):
786
+ raise SaveError('Message panel does not match its pending screen')
787
+ if (engine.title_screen is not None) != bool(session.pending and session.pending.name == 'presentation'):
788
+ raise SaveError('Title screen does not match its pending screen')
789
+ session.remaining_ms = state['remaining_ms']
790
+ if session.remaining_ms is not None:
791
+ if (session.pending is None or session.pending.name != 'choice'
792
+ or type(session.remaining_ms) is not int
793
+ or not 0 <= session.remaining_ms <= session.pending.details['timeout_ms']):
794
+ raise SaveError('Invalid saved choice timer')
795
+ elif session.pending and session.pending.name == 'choice' and session.pending.details['timeout_ms']:
796
+ raise SaveError('Timed choice is missing its remaining time')
797
+ session.scene_loads = _typed_value(state['scene_loads'], int)
798
+ if session.scene_loads < 0:
799
+ raise SaveError('Invalid scene transition count')
800
+ if (game_id(resources) == 'shs' and state['version'] < 3 and session.pending and session.pending.name == 'unhandled_yield'
801
+ and session.pending.request.yield_id in (71, 94, 96)):
802
+ session.pending = engine.dispatch(session.vm, resource_exists=resources.exists)
803
+ if (game_id(resources) == 'shs' and state['version'] < 5 and session.pending and session.pending.name == 'unhandled_yield'
804
+ and session.pending.request.yield_id == 78):
805
+ session.pending = engine.dispatch(session.vm, resource_exists=resources.exists)
806
+ engine.dialogue_animation = None
807
+ if (game_id(resources) == 'shs' and state['version'] < 6 and session.pending and session.pending.name == 'unhandled_yield'
808
+ and session.pending.request.yield_id == 91):
809
+ session.pending = engine.dispatch(session.vm, resource_exists=resources.exists)
810
+ engine.dialogue_animation = None
811
+ newly_supported = ((70, 94, 96, 97, 100) if game_id(resources) == 'cod'
812
+ else (4, 7, 9, 14, 15, 21, 33, 39, 63, 70, 76, 97))
813
+ if (session.pending and session.pending.name == 'unhandled_yield'
814
+ and session.pending.request.yield_id in newly_supported):
815
+ # Dispatch only the newly supported call from its validated
816
+ # frame, without replaying previous input or random draws.
817
+ if session.pending.request.yield_id == 4:
818
+ if len(session.pending.request.args) != 1 or engine.choice_builder is None:
819
+ raise SaveError('Choice stop is missing its builder or shuffle argument')
820
+ if session.pending.request.yield_id == 9 and len(session.pending.request.args) < 2:
821
+ raise SaveError('Survey stop is missing its submission arguments')
822
+ if (engine.dialogue_animation is None and engine.panel.presentation_mode and
823
+ (game_id(resources) == 'cod' or session.pending.request.yield_id in (14, 15, 21, 70, 76, 97))):
824
+ # Unsupported stops discarded their animation, but retained
825
+ # the visible panel. Recover its outgoing portrait identity
826
+ # for the new dialogue's entrance, without replaying its text.
827
+ panel = engine.panel
828
+ previous = dict(visible_character_id=panel.character_id,
829
+ presentation_mode=panel.presentation_mode,
830
+ expression=panel.expression, theme=panel.theme)
831
+ portrait = DialoguePortrait.from_details(previous, engine.character_art_variants)
832
+ engine.dialogue_animation = DialogueAnimation(
833
+ 0, portrait=portrait, initial_portrait=portrait,
834
+ anchor_mode=portrait.mode if portrait else 0,
835
+ expression=panel.expression, initial_expression=panel.expression)
836
+ engine.dialogue_animation.settle()
837
+ if portrait is not None:
838
+ engine.dialogue_history.anchor_mode = portrait.mode
839
+ byte = panel.expression & 255
840
+ engine.dialogue_history.expressions[portrait.character_id] = byte - 256 if byte > 127 else byte
841
+ session.pending = None
842
+ session.advance()
843
+ return session
844
+ except (KeyError, TypeError, ValueError, AttributeError) as error:
845
+ if isinstance(error, SaveError):
846
+ raise
847
+ raise SaveError(f'Invalid save: {error}') from error
848
+
849
+ def save(self, path: Path | None = None) -> Path:
850
+ if self.pending is None:
851
+ raise SaveError('Save requires a suspended screen or completed episode')
852
+ path = Path(path) if path is not None else self.save_path
853
+ path.parent.mkdir(parents=True, exist_ok=True)
854
+ temporary = None
855
+ try:
856
+ with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=path.parent,
857
+ prefix='.save-', delete=False) as stream:
858
+ temporary = Path(stream.name)
859
+ json.dump(self.snapshot(), stream, ensure_ascii=False, separators=(',', ':'))
860
+ stream.write('\n')
861
+ stream.flush()
862
+ os.fsync(stream.fileno())
863
+ temporary.replace(path)
864
+ finally:
865
+ if temporary is not None:
866
+ temporary.unlink(missing_ok=True)
867
+ return path
868
+
869
+ @classmethod
870
+ def load(cls, resources: EpisodeResources, path: Path):
871
+ try:
872
+ path = Path(path)
873
+ if path.stat().st_size > 8 * 1024 * 1024:
874
+ raise SaveError('Save exceeds supported size')
875
+ return cls.from_snapshot(resources, json.loads(path.read_text(encoding='utf-8')))
876
+ except (OSError, ValueError) as error:
877
+ if isinstance(error, SaveError):
878
+ raise
879
+ raise SaveError(f'Cannot load save: {error}') from error