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