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 ADDED
@@ -0,0 +1,13 @@
1
+ """Music playback routing, separate from exact script resource lookups."""
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class MusicCue:
7
+ asset_id: int
8
+ start_ms: int = 0
9
+
10
+
11
+ def music_cue(resource_id: int, *, game: str = 'shs') -> MusicCue:
12
+ from .games import GAMES
13
+ return GAMES[game].MUSIC_CUE(resource_id)
@@ -0,0 +1,58 @@
1
+ """Native location alignment, independent of image decoding and drawing.
2
+
3
+ Android 0007b790/00136364 move node 1000 linearly over 250 ms. SHS and
4
+ CoD iOS use the same left/center/right targets. Store the fraction of the
5
+ image's horizontal overflow so saves and headless execution need no pixels.
6
+ """
7
+ from dataclasses import dataclass
8
+
9
+
10
+ PAN_MS = 250
11
+
12
+
13
+ @dataclass
14
+ class BackgroundPan:
15
+ automatic: bool = True
16
+ alignment: int = 0 # Only consulted after service 97 supplies a value.
17
+ start: float = 0.5
18
+ target: float = 0.5
19
+ elapsed_ms: int = PAN_MS
20
+
21
+ @property
22
+ def position(self):
23
+ return self.start + (self.target - self.start) * (self.elapsed_ms / PAN_MS)
24
+
25
+ def center(self):
26
+ """A replacement background node starts at (160,180)."""
27
+ self.start = self.target = 0.5
28
+ self.elapsed_ms = PAN_MS
29
+
30
+ def align(self, alignment):
31
+ if not self.automatic:
32
+ alignment = self.alignment
33
+ self.start = self.position
34
+ # Native switch defaults to the node's current position.
35
+ self.target = {1: 0.0, 2: 0.5, 3: 1.0}.get(alignment, self.start)
36
+ self.elapsed_ms = 0
37
+
38
+ def dialogue(self, presentation):
39
+ if presentation in (1, 2):
40
+ self.align(1 if presentation == 1 else 3)
41
+
42
+ def configure(self, automatic, alignment):
43
+ """Service 97 also immediately requests its supplied alignment."""
44
+ self.automatic, self.alignment = automatic, alignment
45
+ self.align(alignment)
46
+
47
+ def tick(self, elapsed_ms):
48
+ self.elapsed_ms = min(PAN_MS, self.elapsed_ms + elapsed_ms)
49
+
50
+ def origin(self, width, height):
51
+ # Do not scale or clamp small images: native targets use their width.
52
+ return int((320 - width) * self.position), 180 - height // 2
53
+
54
+ def validate(self):
55
+ if (not -32768 <= self.alignment <= 32767
56
+ or not 0.0 <= self.start <= 1.0 or not 0.0 <= self.target <= 1.0
57
+ or not 0 <= self.elapsed_ms <= PAN_MS):
58
+ raise ValueError('Invalid background pan state')
@@ -0,0 +1,92 @@
1
+ """Service 78's five-position portrait selector (native UI type 5)."""
2
+ from dataclasses import dataclass
3
+ import math
4
+
5
+
6
+ # DAT_0025b830, converted from GL to top-left 320 x 480 coordinates.
7
+ POSITIONS = ((160, 275), (52, 241), (115, 170), (206, 170), (269, 241))
8
+ SWAP_MS = 50
9
+
10
+
11
+ @dataclass
12
+ class PortraitMotion:
13
+ start: tuple[float, float, float]
14
+ end: tuple[float, float, float]
15
+ elapsed_ms: int = SWAP_MS
16
+ opacity: int = 255
17
+
18
+ @property
19
+ def pose(self):
20
+ t = self.elapsed_ms / SWAP_MS
21
+ return tuple(a + (b - a) * t for a, b in zip(self.start, self.end))
22
+
23
+
24
+ @dataclass
25
+ class CharacterPicker:
26
+ characters: list[int]
27
+ order: list[int]
28
+ portraits: list[PortraitMotion]
29
+
30
+ @classmethod
31
+ def start(cls, characters):
32
+ if not 1 <= len(characters) <= 5:
33
+ raise ValueError('Character selection requires 1 to 5 character IDs')
34
+ portraits = []
35
+ for slot in range(len(characters)):
36
+ x, y = POSITIONS[slot]
37
+ pose = (float(x), float(y), 1.0 if slot == 0 else 0.6)
38
+ portraits.append(PortraitMotion(pose, pose, opacity=255 if slot == 0 else 91))
39
+ return cls(list(characters), list(range(len(characters))), portraits)
40
+
41
+ @property
42
+ def selected(self):
43
+ return self.order[0]
44
+
45
+ def select(self, index):
46
+ """Select an original array index, without completing the VM call."""
47
+ if type(index) is not int or not 0 <= index < len(self.characters):
48
+ raise ValueError('Select a portrait by its original zero-based index')
49
+ slot = self.order.index(index)
50
+ if slot == 0:
51
+ return False
52
+ previous = self.order[0]
53
+ self.order[0], self.order[slot] = index, previous
54
+ incoming, outgoing = self.portraits[index], self.portraits[previous]
55
+ # FUN_000d1d78 swaps indices even during motion. It starts new actions
56
+ # when at least one of the two widgets has no running action.
57
+ if incoming.elapsed_ms == SWAP_MS or outgoing.elapsed_ms == SWAP_MS:
58
+ first, second = incoming.pose, outgoing.pose
59
+ incoming.start, incoming.end = first, (second[0], second[1], 1.0)
60
+ outgoing.start, outgoing.end = second, (first[0], first[1], 0.6)
61
+ incoming.elapsed_ms = outgoing.elapsed_ms = 0
62
+ for position, original in enumerate(self.order):
63
+ self.portraits[original].opacity = 255 if position == 0 else 91
64
+ return True
65
+
66
+ def tick(self, elapsed_ms):
67
+ for portrait in self.portraits:
68
+ portrait.elapsed_ms = min(SWAP_MS, portrait.elapsed_ms + elapsed_ms)
69
+
70
+ def hit(self, point):
71
+ """Native fixed circular hit regions, independent of animated widgets."""
72
+ x, y = point
73
+ for slot, original in enumerate(self.order):
74
+ cx, cy = POSITIONS[slot]
75
+ radius = 61 * (1 if slot == 0 else 0.6)
76
+ if (x - cx) ** 2 + (y - cy) ** 2 <= radius ** 2:
77
+ return original
78
+ return None
79
+
80
+ def validate(self):
81
+ count = len(self.characters)
82
+ if (not 1 <= count <= 5 or sorted(self.order) != list(range(count))
83
+ or len(self.portraits) != count
84
+ or any(not -32768 <= c <= 32767 for c in self.characters)):
85
+ raise ValueError('Invalid character selection state')
86
+ for portrait in self.portraits:
87
+ if not 0 <= portrait.elapsed_ms <= SWAP_MS or portrait.opacity not in (91, 255):
88
+ raise ValueError('Invalid portrait animation')
89
+ for x, y, scale in (portrait.start, portrait.end):
90
+ if (not all(math.isfinite(v) for v in (x, y, scale))
91
+ or not 52 <= x <= 269 or not 170 <= y <= 275 or not 0.6 <= scale <= 1):
92
+ raise ValueError('Invalid portrait position or scale')
exp_runtime/choice.py ADDED
@@ -0,0 +1,116 @@
1
+ """Choice-panel geometry in the original 320 by 480 coordinate system.
2
+
3
+ The supplied iPhone screenshots define the visual target. Fonts, skins and
4
+ insets come from the APK; the 44-pixel rows also occur in FUN_000d9038.
5
+ See docs/UI_FIDELITY.md for differences from the Android Cocos drawing path.
6
+ """
7
+ from dataclasses import dataclass, replace
8
+ from functools import lru_cache
9
+ import math
10
+
11
+ from .fonts import load_font, TextLayout, TextStyle, layout_text
12
+ from .ui_assets import LayoutBank, Rect, read_ui
13
+
14
+
15
+ BODY_FONT = 'ArialRoundedMTBold16'
16
+ FOOTER_FONT = 'ArialMT11'
17
+ TITLE_FONTS = {1: 'PajamaHip26', 2: 'PajamaHip266', 3: 'PajamaHipG26'}
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ChoiceRow:
22
+ index: int
23
+ rect: Rect
24
+ text: TextLayout
25
+ origin: tuple[float, float]
26
+ enabled: bool
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ChoicePage:
31
+ box: Rect # Outer border, unlike DialoguePage's center-fill rectangle.
32
+ title_font: str
33
+ title: TextLayout
34
+ title_origin: tuple[float, float]
35
+ description: TextLayout
36
+ description_origin: tuple[float, float]
37
+ portrait: Rect | None
38
+ rows: tuple[ChoiceRow, ...]
39
+ theme: int
40
+ max_scroll: int
41
+ timer_panel: Rect | None = None
42
+
43
+
44
+ class ChoiceLayout:
45
+ def __init__(self, resources):
46
+ self.resources = resources
47
+ self.bank = LayoutBank.parse(read_ui(resources, 14))
48
+
49
+ @lru_cache(maxsize=8)
50
+ def font(self, name):
51
+ return load_font(self.resources.library, name)
52
+
53
+ def page(self, details, *, theme=1, has_portrait=False):
54
+ minigame = details.get('minigame', False)
55
+ timed = not minigame and details.get('timeout_ms', 0) > 0
56
+ width = self.bank.layouts[65].width
57
+ header = self.bank.rectangle(7 if has_portrait else 8, 9)
58
+ title_font = TITLE_FONTS.get(theme, 'PajamaHipY26')
59
+ title_indent = 20 if details.get('portrait_mode', 1) == 1 else 5
60
+ title_style = TextStyle(26, 8, indents=(0, title_indent) if has_portrait else ())
61
+ title = layout_text(self.font(title_font), details['title'], header.width, title_style)
62
+ if not has_portrait:
63
+ # A character-free heading occupies the full top of the panel.
64
+ glyphs = tuple(replace(g, x=g.x + (header.width - line.width) / 2)
65
+ for line in title.lines for g in title.glyphs
66
+ if line.start <= g.index < line.end)
67
+ title = replace(title, glyphs=glyphs)
68
+
69
+ color = {1: (1, 76, 215), 2: (166, 45, 45), 3: (108, 108, 108)}.get(theme, (223, 163, 52))
70
+ body_font = self.font(BODY_FONT)
71
+ body_rect = self.bank.rectangle(65, 4)
72
+ # FUN_000d9038 measures progressively around the portrait; the actual
73
+ # text uses the smaller nominal height configured by FUN_000d6afc.
74
+ indents = ()
75
+ if has_portrait:
76
+ for count in (1, 2, 3):
77
+ indents = (105, 95, 85)[:count]
78
+ measured = layout_text(body_font, details['text'], body_rect.width,
79
+ TextStyle(16, 10, color, indents=indents))
80
+ if len(measured.lines) <= count:
81
+ break
82
+ description = layout_text(body_font, details['text'], body_rect.width,
83
+ TextStyle(10, 10, color, indents=indents))
84
+ description_y = max(26, title.height + 2)
85
+ header_height = math.ceil(max(112 if has_portrait else 38,
86
+ description_y + description.height + 6))
87
+ laid_out = []
88
+ for label, enabled in zip(details['options'], details['enabled']):
89
+ text = layout_text(body_font, label, width - 16,
90
+ TextStyle(12, 1, color if enabled else (150, 150, 150)))
91
+ _, top, _, bottom = text.ink_bounds
92
+ laid_out.append((text, max(37 if minigame else 44, math.ceil(bottom - top + 16)), enabled))
93
+
94
+ footer_height = self.bank.layouts[22].height if timed else (80 if minigame else 20)
95
+ height = header_height + sum(h for _, h, _ in laid_out) + footer_height
96
+ # The reference's panel is centered below its overlapping heading.
97
+ # Taller lists can scroll without changing the script's option order.
98
+ box = Rect((320 - width) // 2, max(32, (480 - height) // 2 + (0 if minigame else 11)), width, height)
99
+ rows, y = [], box.y + header_height
100
+ for index, (text, height, enabled) in enumerate(laid_out):
101
+ _, top, _, bottom = text.ink_bounds
102
+ rect = Rect(box.x + 3, y, width - 6, height)
103
+ origin = (box.x + 14, y + (height - (bottom - top)) / 2 - top + 2)
104
+ rows.append(ChoiceRow(index, rect, text, origin, enabled))
105
+ y += height
106
+ portrait = Rect(box.x - 27, box.y - 24, 128, 128) if has_portrait else None
107
+ timer_panel = None
108
+ max_scroll = max(0, box.y + box.height - 421)
109
+ if timed:
110
+ # Layout 22 supplies the timer's lower panel. Keep it visible when
111
+ # the desktop's expanded rows need scrolling above the footer.
112
+ timer_panel = Rect(box.x, min(y, 421 - footer_height), width, footer_height)
113
+ max_scroll = max(0, y - timer_panel.y)
114
+ return ChoicePage(box, title_font, title, (box.x + header.x, box.y - 9),
115
+ description, (box.x + 7, box.y + description_y), portrait,
116
+ tuple(rows), theme, max_scroll, timer_panel)
@@ -0,0 +1,153 @@
1
+ """Optional hints from native word scores or isolated story-choice previews.
2
+
3
+ These are near-term outcome hints, not a solver for an episode's best ending.
4
+ Recognized relationship changes, score notices and per-game answer-feedback
5
+ sounds establish outcomes. Arbitrary counters and dialogue words do not.
6
+ """
7
+ from copy import deepcopy
8
+ from dataclasses import dataclass
9
+
10
+ from .content import ContentError
11
+ from .games import GAMES
12
+ from .vm import VMError
13
+
14
+
15
+ # Observed in the supplied SHS and CoD scripts. Unrecognized/localized notices
16
+ # stay unknown; ordinary dialogue containing these words is not evidence.
17
+ SCORE_NOTICES = frozenset(('Score Up!', 'Detective Score Up!'))
18
+ DECISIONS = frozenset(('choice', 'character_picker', 'text_input', 'word_game',
19
+ 'word_grid', 'football', 'finished', 'episode_exit'))
20
+ PANELS = frozenset(('dialogue', 'presentation', 'message_panel'))
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ChoiceHint:
25
+ kind: str # gain, loss, no_gain, mixed, unknown
26
+
27
+
28
+ def preview_choices(session, *, max_steps=6000, max_panels=48, max_options=16,
29
+ max_decisions=4, max_branches=192):
30
+ """Compare native outcomes on bounded, isolated runtime copies.
31
+
32
+ Immediate feedback takes precedence. For deferred feedback, compare the
33
+ same later answers across every initial option. An option dominates another
34
+ only if it is never worse and is better for at least one continuation.
35
+ Unmatched decisions, unknown services or exhausted budgets remain unknown.
36
+ """
37
+ if session.pending and session.pending.name == 'word_game':
38
+ # Service 71 supplies explicit good/bad answers. Read the current deal's
39
+ # weights, including duplicate labels and its native random order.
40
+ game = session.engine.word_game
41
+ return tuple(ChoiceHint('gain' if weight > 0 else 'loss' if weight < 0 else 'no_gain')
42
+ for weight in game.weights) if game else ()
43
+ if not session.pending or session.pending.name != 'choice':
44
+ return ()
45
+ if min(max_steps, max_panels, max_options, max_decisions, max_branches) < 1:
46
+ raise ValueError('Preview budgets must be positive')
47
+ details = session.pending.details
48
+ feedback_sounds = GAMES[session.engine.game_key].CHOICE_FEEDBACK_SOUNDS
49
+ deadline = session.vm.steps_executed + max_steps
50
+ branches = 0
51
+
52
+ def follow(source, value, effects=None, panels=0):
53
+ nonlocal branches
54
+ branches += 1
55
+ if branches > max_branches:
56
+ return None
57
+ preview = deepcopy(source, {id(source.resources): source.resources,
58
+ id(source.vm.program): source.vm.program})
59
+ effects = dict(effects or {})
60
+
61
+ def observe(action):
62
+ if action.name == 'set_next_dialogue_notice' and action.details['text'] in SCORE_NOTICES:
63
+ effects['score_notice'] = effects.get('score_notice', 0) + 1
64
+ elif action.name == 'request_audio' and action.request.yield_id == 79:
65
+ feedback = feedback_sounds.get(action.details['asset_id'], 0)
66
+ if feedback:
67
+ # Explicit correct/wrong feedback can accompany script-local
68
+ # quiz scoring without a relationship or Score Up! notice.
69
+ # Keep opposite cues separate so mixed feedback cannot cancel.
70
+ key = 'correct_feedback' if feedback > 0 else 'incorrect_feedback'
71
+ effects[key] = effects.get(key, 0) + feedback
72
+ elif action.name in ('set_number', 'set_number_bit'):
73
+ owner, key = action.details['owner'], action.details['key']
74
+ if owner > 0 and key == 407:
75
+ address = preview.engine.number_key(owner, key)
76
+ effects[address] = preview.engine.numbers[address] - session.engine.numbers.get(address, 0)
77
+
78
+ try:
79
+ preview._complete_choice(value)
80
+ while preview.vm.steps_executed < deadline:
81
+ action = preview.advance(max_steps=deadline - preview.vm.steps_executed,
82
+ max_events=2000, observe=observe)
83
+ if action.name in DECISIONS:
84
+ return preview, effects, panels
85
+ if action.name not in PANELS or panels == max_panels:
86
+ break
87
+ panels += 1
88
+ preview._complete_panel()
89
+ preview.engine.title_screen = preview.engine.message_panel = None
90
+ except (ContentError, VMError, OSError, ValueError):
91
+ pass
92
+ return None
93
+
94
+ nodes = [follow(session, value) if enabled and index < max_options else None
95
+ for index, (value, enabled) in enumerate(zip(details['values'], details['enabled']))]
96
+ hints = [ChoiceHint(_kind(node[1]) if node else 'unknown') for node in nodes]
97
+ if not any(node and any(node[1].values()) for node in nodes):
98
+ enabled = [i for i, value in enumerate(details['enabled']) if value]
99
+
100
+ def outcomes(group, depth):
101
+ if not group or any(node is None for node in group):
102
+ return None
103
+ if any(any(node[1].values()) for node in group):
104
+ return [[node[1] for node in group]]
105
+ actions = [node[0].pending for node in group]
106
+ if all(a.name in ('finished', 'episode_exit') for a in actions):
107
+ return [[{} for _ in group]]
108
+ if depth >= max_decisions or any(a.name != 'choice' for a in actions):
109
+ return None
110
+ # Match the actual script decision and result values, independently
111
+ # of native randomized display order. Do not guess how unrelated
112
+ # questions, disabled options or minigames correspond.
113
+ signatures = [(node[0].scene, a.request.pc,
114
+ tuple(sorted(v for v, on in zip(a.details['values'], a.details['enabled']) if on)))
115
+ for node, a in zip(group, actions)]
116
+ if any(s != signatures[0] for s in signatures) or not signatures[0][2]:
117
+ return None
118
+ values = tuple(dict.fromkeys(signatures[0][2]))
119
+ if len(values) > max_options:
120
+ return None
121
+ result = []
122
+ for value in values:
123
+ children = [follow(preview, value, effects, panels) for preview, effects, panels in group]
124
+ leaves = outcomes(children, depth + 1)
125
+ if leaves is None:
126
+ return None
127
+ result.extend(leaves)
128
+ return result
129
+
130
+ leaves = outcomes([nodes[i] for i in enabled], 1)
131
+ if leaves is not None:
132
+ dominates = set()
133
+ for a in range(len(enabled)):
134
+ for b in range(len(enabled)):
135
+ deltas = [row[a].get(key, 0) - row[b].get(key, 0)
136
+ for row in leaves for key in row[a].keys() | row[b].keys()]
137
+ if deltas and min(deltas) >= 0 and max(deltas) > 0:
138
+ dominates.add((a, b))
139
+ for index, original in enumerate(enabled):
140
+ if any(b == index for _, b in dominates):
141
+ hints[original] = ChoiceHint('no_gain')
142
+ elif any(a == index for a, _ in dominates):
143
+ hints[original] = ChoiceHint('gain')
144
+ # Absence of recognized feedback alone is not proof of a wrong answer.
145
+ # Mark non-scoring alternatives only when this decision has a known gain.
146
+ has_gain = any(hint.kind == 'gain' for hint in hints)
147
+ return tuple(ChoiceHint('unknown') if hint.kind == 'no_gain' and not has_gain else hint
148
+ for hint in hints)
149
+
150
+
151
+ def _kind(effects):
152
+ gain, loss = any(v > 0 for v in effects.values()), any(v < 0 for v in effects.values())
153
+ return 'mixed' if gain and loss else 'gain' if gain else 'loss' if loss else 'no_gain'