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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Service 90's nonblocking scene label (FUN_0007b97c)."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from .fonts import TextStyle, layout_text
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SceneBadge:
|
|
9
|
+
asset_id: int
|
|
10
|
+
text: str
|
|
11
|
+
blue: bool
|
|
12
|
+
elapsed_ms: int = 0
|
|
13
|
+
|
|
14
|
+
def tick(self, elapsed_ms):
|
|
15
|
+
self.elapsed_ms = min(200, self.elapsed_ms + elapsed_ms)
|
|
16
|
+
|
|
17
|
+
def validate(self):
|
|
18
|
+
if not 0 <= self.asset_id <= 32767 or not 0 <= self.elapsed_ms <= 200:
|
|
19
|
+
raise ValueError('Invalid scene label')
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def text_position(self):
|
|
23
|
+
# Native label node centers in a 171x60 sprite (GL local coordinates).
|
|
24
|
+
n = len(self.text)
|
|
25
|
+
if 15 <= n <= 17:
|
|
26
|
+
return 123, 55, 1.0
|
|
27
|
+
if n < 13:
|
|
28
|
+
x = {'Before Class': 120, 'Mascot Theft': 122, 'Driving Home': 120}.get(self.text, 125)
|
|
29
|
+
return x, 45, 1.0
|
|
30
|
+
if n < 19:
|
|
31
|
+
x, y = {'Before School': (117, 45), "Chuck's Party": (120, 45),
|
|
32
|
+
'Football Game': (120, 45), 'After Practice': (120, 45),
|
|
33
|
+
"Counselor's Office": (123, 53), 'Unsupervised Study': (120, 53),
|
|
34
|
+
'Saturday Night': (119, 45)}.get(self.text, (117, 53))
|
|
35
|
+
return x, y, .9 if self.text == 'Before School' else 1.0
|
|
36
|
+
return 121, 55, 1.0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def text_origin(self):
|
|
40
|
+
# FUN_0007b97c: a 110x20 label with (.5,.5) anchor and flags 0x11
|
|
41
|
+
# (wrapped, left/top aligned). Text draws downward from local zero;
|
|
42
|
+
# converting the anchor to screen Y therefore adds half its height.
|
|
43
|
+
x, y, scale = self.text_position
|
|
44
|
+
return x - 55 * scale, 60 - y + 10 * scale, scale
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def outline_badge_text(text, font, region, color):
|
|
48
|
+
"""CSFont's wrapped, left/center-aligned layout-67 text (flags 0x19).
|
|
49
|
+
|
|
50
|
+
Both iOS games use Arial 14, cap-height line boxes and no added gap.
|
|
51
|
+
Bound substitute-font ink to the same region without changing the label
|
|
52
|
+
or any saved state. Android's separate bitmap-label rules remain above.
|
|
53
|
+
"""
|
|
54
|
+
# A substitute font may have a glyph wider than the entire native region.
|
|
55
|
+
# Let that glyph wrap alone, then fit its ink with the rest of the label.
|
|
56
|
+
width = max(region.width, max((font.glyph(c).advance
|
|
57
|
+
for c in text.split('\x00', 1)[0] if c != '\n'), default=0))
|
|
58
|
+
layout = layout_text(font, text, width, TextStyle(font.cap_height, 0, color))
|
|
59
|
+
left, top, right, bottom = layout.ink_bounds
|
|
60
|
+
scale = min(1, region.width / max(1, right - left),
|
|
61
|
+
region.height / max(1, bottom - top))
|
|
62
|
+
x, y = region.x, region.y + (region.height - layout.height * scale) / 2
|
|
63
|
+
x = max(region.x - left * scale, min(x, region.x + region.width - right * scale))
|
|
64
|
+
y = max(region.y - top * scale, min(y, region.y + region.height - bottom * scale))
|
|
65
|
+
return layout, (x, y), scale
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Native speaker label state and layout (000a7fa8 / 000a8544).
|
|
2
|
+
|
|
3
|
+
The four font objects survive dialogue changes. Their previous line counts,
|
|
4
|
+
content sizes and line spacing are inputs to the next layout, not caches that
|
|
5
|
+
can be recomputed from the next speaker's name. See docs/UI_FIDELITY.md.
|
|
6
|
+
"""
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
|
|
10
|
+
from .fonts import BitmapFont, TextLayout, TextStyle, layout_label, layout_text
|
|
11
|
+
from .ui_assets import LayoutBank, Rect
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
NAME_FONTS = {-1: 'PajamaHipY26', 2: 'PajamaHip266', 3: 'PajamaHipG26'}
|
|
15
|
+
DEFAULT_FONT = 'PajamaHip26'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class NameFontState:
|
|
20
|
+
width: int = 0
|
|
21
|
+
height: int = 0
|
|
22
|
+
gap: int = -16
|
|
23
|
+
# 0007cbbc initializes each label with "SHS", then sets nominal height 16.
|
|
24
|
+
lines: int = 1
|
|
25
|
+
indents: list[int] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def style(self):
|
|
29
|
+
return TextStyle(16, self.gap, indents=tuple(self.indents))
|
|
30
|
+
|
|
31
|
+
def validate(self):
|
|
32
|
+
if (not 0 <= self.width <= 65535 or not 0 <= self.height <= 65535
|
|
33
|
+
or self.gap not in (-16, 8) or not 0 <= self.lines <= 65535
|
|
34
|
+
or self.indents not in ([], [0, 5], [0, 20])):
|
|
35
|
+
raise ValueError('Invalid speaker font state')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class SpeakerNames:
|
|
40
|
+
fonts: dict[str, NameFontState] = field(default_factory=dict)
|
|
41
|
+
# State before the current dialogue: redraws and page turns replay only
|
|
42
|
+
# this layout, without advancing the live font objects a second time.
|
|
43
|
+
basis: dict[str, NameFontState] | None = None
|
|
44
|
+
|
|
45
|
+
def validate(self):
|
|
46
|
+
for bank in (self.fonts, self.basis):
|
|
47
|
+
if bank is not None:
|
|
48
|
+
if set(bank) - {DEFAULT_FONT, *NAME_FONTS.values()}:
|
|
49
|
+
raise ValueError('Invalid speaker font bank')
|
|
50
|
+
for state in bank.values():
|
|
51
|
+
state.validate()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class SpeakerLabel:
|
|
56
|
+
text: str
|
|
57
|
+
font: str
|
|
58
|
+
layout: TextLayout
|
|
59
|
+
origin: tuple[float, float]
|
|
60
|
+
scale: float
|
|
61
|
+
extra: int
|
|
62
|
+
content_size: tuple[int, int]
|
|
63
|
+
flags: int
|
|
64
|
+
# Outline fonts use CSFont's top-left title region, rather than a Cocos
|
|
65
|
+
# label with retained content size. Keep that distinction at the font
|
|
66
|
+
# adapter boundary, within the common dialogue renderer.
|
|
67
|
+
header: Rect | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def speaker_label(text: str, mode: int, theme: int, bank: LayoutBank, font_loader,
|
|
71
|
+
states: dict[str, NameFontState]) -> SpeakerLabel:
|
|
72
|
+
"""Execute one native name layout, updating only the supplied font bank."""
|
|
73
|
+
font_name = NAME_FONTS.get(theme, DEFAULT_FONT)
|
|
74
|
+
font: BitmapFont = font_loader(font_name)
|
|
75
|
+
if mode == 4:
|
|
76
|
+
return SpeakerLabel('', font_name, layout_text(font, '', 1, TextStyle(16, -16)),
|
|
77
|
+
(0, 0), .9, 8, (0, 0), 0x19)
|
|
78
|
+
state = states.setdefault(font_name, NameFontState())
|
|
79
|
+
# 000a7fa8 measures theme -1 with the default font, although 000a8544
|
|
80
|
+
# draws it with the separate yellow font object.
|
|
81
|
+
measure_name = NAME_FONTS.get(theme, DEFAULT_FONT) if theme in (2, 3) else DEFAULT_FONT
|
|
82
|
+
measure_state = states.setdefault(measure_name, NameFontState())
|
|
83
|
+
measure_font = font_loader(measure_name)
|
|
84
|
+
rect = bank.rectangle(17, {1: 0x2e, 2: 0x4c, 3: 0x6a}[mode])
|
|
85
|
+
if mode == 2:
|
|
86
|
+
large = layout_text(measure_font, text, 2**31, measure_state.style).width > rect.width
|
|
87
|
+
else:
|
|
88
|
+
measured = layout_text(measure_font, text, rect.width, measure_state.style)
|
|
89
|
+
measure_state.lines = len(measured.lines)
|
|
90
|
+
large = measured.height > rect.height
|
|
91
|
+
extra = 40 if large else 8
|
|
92
|
+
if large:
|
|
93
|
+
rect = bank.rectangle(17, {1: 0x25, 2: 0x43, 3: 0x61}[mode])
|
|
94
|
+
if mode in (1, 2):
|
|
95
|
+
rect = replace(rect, y=rect.y - 40)
|
|
96
|
+
if mode == 1:
|
|
97
|
+
rect = replace(rect, x=rect.x + 1)
|
|
98
|
+
elif mode == 2:
|
|
99
|
+
rect = replace(rect, x=rect.x + (1 if len(text) == 8 else 5))
|
|
100
|
+
|
|
101
|
+
unwrapped = layout_text(font, text, 2**31, state.style).width
|
|
102
|
+
w, h = rect.width, rect.height
|
|
103
|
+
scale, offset, flags = .9, 0, 0x1a
|
|
104
|
+
if mode == 1:
|
|
105
|
+
flags = 0x19 if large else 0x1b
|
|
106
|
+
offset = -23 if not large and int(unwrapped) < 127 else 0
|
|
107
|
+
if text != 'Brendizzle': # Native branch deliberately retains the previous size.
|
|
108
|
+
state.height = h + 30
|
|
109
|
+
if state.lines < 2:
|
|
110
|
+
state.width = w + 20
|
|
111
|
+
else:
|
|
112
|
+
state.width = w + (80 if len(text) < 14 else 40)
|
|
113
|
+
scale = .8
|
|
114
|
+
state.indents = [0, 20]
|
|
115
|
+
elif mode == 2:
|
|
116
|
+
if large:
|
|
117
|
+
state.gap = 8
|
|
118
|
+
flags = 0x19
|
|
119
|
+
if text in ("Howard's Mom", "Howard's Dad", 'French Teacher', "Neighbor's Wife"):
|
|
120
|
+
state.width, state.height, scale = {
|
|
121
|
+
"Howard's Mom": (235, h + 32, .9),
|
|
122
|
+
"Howard's Dad": (223, h + 32, .9),
|
|
123
|
+
'French Teacher': (225, h + 50, .8),
|
|
124
|
+
"Neighbor's Wife": (w + 80, h + 60, .8),
|
|
125
|
+
}[text]
|
|
126
|
+
flags = 0x3d
|
|
127
|
+
elif len(text) == 8:
|
|
128
|
+
if text == 'The Boss':
|
|
129
|
+
text = 'Th e Boss '
|
|
130
|
+
state.width, state.height = w, h + 34
|
|
131
|
+
elif len(text) == 11:
|
|
132
|
+
state.width, state.height, flags = w, h + 82, 0x3d
|
|
133
|
+
elif state.lines < 2:
|
|
134
|
+
text = {'The Mayor': 'Th e Mayor', 'Judge Tigh': 'Judge Ti gh',
|
|
135
|
+
'Animal Thief': 'Animal Th ief'}.get(text, text)
|
|
136
|
+
state.width, state.height, flags = w + 20, h + 32, 0x19
|
|
137
|
+
elif len(text) < 19:
|
|
138
|
+
state.width, state.height, scale = w + 80, h + 30, .8
|
|
139
|
+
if text == 'Spud The Stud':
|
|
140
|
+
text, scale = 'Spud Th e Stud', .7
|
|
141
|
+
else:
|
|
142
|
+
state.width, state.height, scale = 300, h + 32, .7
|
|
143
|
+
state.indents = [0, 5]
|
|
144
|
+
offset = 23 if int(unwrapped) < 127 else 0
|
|
145
|
+
else:
|
|
146
|
+
if len(text) == 8:
|
|
147
|
+
if text == 'The team':
|
|
148
|
+
text = 'Th e team'
|
|
149
|
+
state.width, state.height = w + 20, h + 34
|
|
150
|
+
elif len(text) < 16:
|
|
151
|
+
text = {'The Whole Room': 'Th e Whole Room', 'The Crowd': 'Th e Crowd'}.get(text, text)
|
|
152
|
+
state.indents = []
|
|
153
|
+
state.width, state.height = w + 20, h + 20
|
|
154
|
+
else:
|
|
155
|
+
state.width, state.height, scale = 580, h + 30, .6
|
|
156
|
+
|
|
157
|
+
laid_out = layout_label(font, text, state.width, state.height, state.style, flags)
|
|
158
|
+
state.lines = len(laid_out.lines)
|
|
159
|
+
# 000a8544 sets the node position before setString. Glyph coordinates from
|
|
160
|
+
# layout_label are local to its bottom-left corner, with downward Y.
|
|
161
|
+
px = rect.x + offset - 10 + (w + 20) // 2
|
|
162
|
+
py = 480 - state.height + 19 - rect.y + (h + 20) // 2
|
|
163
|
+
origin = (px - scale * state.width / 2, 480 - py + scale * state.height / 2)
|
|
164
|
+
return SpeakerLabel(text, font_name, laid_out, origin, scale, extra,
|
|
165
|
+
(state.width, state.height), flags)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def preview_speaker(details, bank, font_loader, names: SpeakerNames | None = None):
|
|
169
|
+
font_name = NAME_FONTS.get(details['theme'], DEFAULT_FONT)
|
|
170
|
+
font = font_loader(font_name)
|
|
171
|
+
if getattr(font, 'cap_height', None) is not None:
|
|
172
|
+
return outline_speaker_label(details['speaker'], details['presentation_mode'],
|
|
173
|
+
font_name, bank, font)
|
|
174
|
+
basis = deepcopy(names.basis) if names is not None and names.basis is not None else {}
|
|
175
|
+
return speaker_label(details['speaker'], details['presentation_mode'], details['theme'],
|
|
176
|
+
bank, font_loader, basis)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def outline_speaker_label(text, mode, font_name, bank, font):
|
|
180
|
+
"""SHS/CoD CSFont title layout, independent of Android label history.
|
|
181
|
+
|
|
182
|
+
updateTitleBounds (CoD 00036adc / SHS 0007ce08) measures at the normal
|
|
183
|
+
region's width and selects the tall region if wrapped height exceeds it.
|
|
184
|
+
animateTitleLayer sets the offsets/flags below, then drawTextInRegion
|
|
185
|
+
centers the line boxes vertically. CSFont uses cap height and gap 8;
|
|
186
|
+
Cocos's width expansion, nominal height 16 and 0.9 scale do not apply.
|
|
187
|
+
"""
|
|
188
|
+
style = TextStyle(font.cap_height, 8)
|
|
189
|
+
rect = bank.rectangle(17, {1: 0x2e, 2: 0x4c, 3: 0x6a}[mode])
|
|
190
|
+
measured = layout_text(font, text, rect.width, style)
|
|
191
|
+
large = measured.height > rect.height
|
|
192
|
+
if large:
|
|
193
|
+
rect = bank.rectangle(17, {1: 0x25, 2: 0x43, 3: 0x61}[mode])
|
|
194
|
+
unwrapped = layout_text(font, text, 2**31, style).width
|
|
195
|
+
flags, offset = 0x1a, 0
|
|
196
|
+
if mode == 1:
|
|
197
|
+
flags = 0x19 if large else 0x1b
|
|
198
|
+
offset = -15 if not large and int(unwrapped) < 135 else 0
|
|
199
|
+
rect = replace(rect, x=rect.x + 1)
|
|
200
|
+
style = replace(style, indents=(0, 20))
|
|
201
|
+
elif mode == 2:
|
|
202
|
+
flags = 0x19
|
|
203
|
+
offset = 15 if int(unwrapped) < 135 else 0
|
|
204
|
+
rect = replace(rect, x=rect.x - 2)
|
|
205
|
+
style = replace(style, indents=(0, 5))
|
|
206
|
+
layout = layout_text(font, text, rect.width, style)
|
|
207
|
+
alignment = {2: .5, 3: 1}.get(flags & 3, 0)
|
|
208
|
+
lines, glyphs = [], []
|
|
209
|
+
for index, line in enumerate(layout.lines):
|
|
210
|
+
indent = style.indents[index] if index < len(style.indents) else 0
|
|
211
|
+
shift = (rect.width - abs(indent) - line.width) * alignment
|
|
212
|
+
lines.append(replace(line, x=line.x + shift))
|
|
213
|
+
glyphs.extend(replace(g, x=g.x + shift) for g in layout.glyphs
|
|
214
|
+
if line.start <= g.index < line.end)
|
|
215
|
+
layout = replace(layout, lines=tuple(lines), glyphs=tuple(glyphs))
|
|
216
|
+
origin = (rect.x + offset, rect.y + (rect.height - layout.height) / 2)
|
|
217
|
+
return SpeakerLabel(text, font_name, layout, origin, 1, 40 if large else 8,
|
|
218
|
+
(rect.width, rect.height), flags, rect)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def fit_speaker_ink(label: SpeakerLabel, bounds: Rect):
|
|
222
|
+
"""Fit complete visible ink to the header attached to the current box.
|
|
223
|
+
|
|
224
|
+
The native routine sizes line boxes using height 16, although the imported
|
|
225
|
+
name glyphs can be taller than 30. Its retained gap can also put a later
|
|
226
|
+
single-line name far above or below the header. Separate overlapping rows,
|
|
227
|
+
then fit both dimensions to the selected normal/tall header. Outline
|
|
228
|
+
fonts retain their native position unless ink exceeds that region or the
|
|
229
|
+
body/portrait boundary. This drawing correction never changes font history.
|
|
230
|
+
"""
|
|
231
|
+
layout = label.layout
|
|
232
|
+
if not layout.glyphs:
|
|
233
|
+
return layout, label.origin, label.scale
|
|
234
|
+
lines, glyphs, previous_bottom, shift = [], [], None, 0
|
|
235
|
+
for line in layout.lines:
|
|
236
|
+
row = [g for g in layout.glyphs if line.start <= g.index < line.end]
|
|
237
|
+
if row:
|
|
238
|
+
top = min(g.y for g in row) + shift
|
|
239
|
+
if previous_bottom is not None and top < previous_bottom:
|
|
240
|
+
shift += previous_bottom - top
|
|
241
|
+
previous_bottom = max(g.y + g.glyph.height for g in row) + shift
|
|
242
|
+
lines.append(replace(line, y=line.y + shift))
|
|
243
|
+
glyphs.extend(replace(g, y=g.y + shift) for g in row)
|
|
244
|
+
layout = replace(layout, lines=tuple(lines), glyphs=tuple(glyphs))
|
|
245
|
+
ink_left, ink_top, ink_right, ink_bottom = layout.ink_bounds
|
|
246
|
+
scale = min(label.scale, bounds.width / (ink_right - ink_left),
|
|
247
|
+
bounds.height / (ink_bottom - ink_top))
|
|
248
|
+
if label.header is not None:
|
|
249
|
+
x = min(max(label.origin[0], bounds.x - ink_left * scale),
|
|
250
|
+
bounds.x + bounds.width - ink_right * scale)
|
|
251
|
+
y = min(max(label.origin[1], bounds.y - ink_top * scale),
|
|
252
|
+
bounds.y + bounds.height - ink_bottom * scale)
|
|
253
|
+
return layout, (x, y), scale
|
|
254
|
+
alignment = {2: .5, 3: 1}.get(label.flags & 3, 0)
|
|
255
|
+
spare = bounds.width - (ink_right - ink_left) * scale
|
|
256
|
+
x = bounds.x + spare * alignment - ink_left * scale
|
|
257
|
+
y = bounds.y + bounds.height - ink_bottom * scale
|
|
258
|
+
return layout, (x, y), scale
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Native services 17/40 name editing, independent of the desktop and VM.
|
|
2
|
+
|
|
3
|
+
See docs/NAME_INPUT.md. The width check is on the existing text plus the
|
|
4
|
+
cursor, before appending the next character (FUN_000d3cb0).
|
|
5
|
+
"""
|
|
6
|
+
from .fonts import BitmapFont
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
MAX_NAME_LENGTH = 16
|
|
10
|
+
NAME_FONT = 'ArialRoundedMTBold28'
|
|
11
|
+
CURSOR_FONT = 'PajamaHip26'
|
|
12
|
+
NAME_ENTRY_ERROR = (
|
|
13
|
+
'The name entry screen only supports the following alphanumeric '
|
|
14
|
+
'characters: A-Z and 0-9.'
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def input_fonts(resources):
|
|
19
|
+
if getattr(getattr(resources, 'library', None), 'kind', 'apk') == 'ipa':
|
|
20
|
+
return 'PajamaHip24', 'PajamaHip24'
|
|
21
|
+
return NAME_FONT, CURSOR_FONT
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _alphanumeric(text: str) -> bool:
|
|
25
|
+
return all('a' <= c <= 'z' or 'A' <= c <= 'Z' or '0' <= c <= '9' for c in text)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _has_room(current: str, font: BitmapFont | None, cursor_width: float) -> bool:
|
|
29
|
+
# Kerning shifts ink, not the native measuring pen. Authored headless
|
|
30
|
+
# fixtures without APK fonts can still exercise the byte/length rules.
|
|
31
|
+
return font is None or sum(font.glyph(c).advance for c in current) + cursor_width < 240
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def accept_name_character(current: str, char: str, *, font: BitmapFont | None = None,
|
|
35
|
+
cursor_width: float = 0) -> tuple[str, str | None]:
|
|
36
|
+
if (len(char) != 1 or not _alphanumeric(char) or len(current) >= MAX_NAME_LENGTH
|
|
37
|
+
or not _alphanumeric(current) or not _has_room(current, font, cursor_width)):
|
|
38
|
+
return current, NAME_ENTRY_ERROR
|
|
39
|
+
if current and 'A' <= char <= 'Z':
|
|
40
|
+
char = char.lower()
|
|
41
|
+
return current + char, None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_name(value: str, *, font: BitmapFont | None = None, cursor_width: float = 0):
|
|
45
|
+
"""Validate a confirmed value without recasing script defaults or callers.
|
|
46
|
+
|
|
47
|
+
A final character may cross the width threshold: native checks the prefix
|
|
48
|
+
before accepting it. Empty Return hides the keyboard; it never resumes.
|
|
49
|
+
"""
|
|
50
|
+
if not isinstance(value, str) or len(value) > MAX_NAME_LENGTH or not _alphanumeric(value):
|
|
51
|
+
raise ValueError(NAME_ENTRY_ERROR)
|
|
52
|
+
if not value:
|
|
53
|
+
raise ValueError('Enter a name before continuing.')
|
|
54
|
+
if not _has_room(value[:-1], font, cursor_width):
|
|
55
|
+
raise ValueError(NAME_ENTRY_ERROR)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Service 8 title cards: native wipe, input gate and external-atlas lettering."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from .atlas import AtlasFont
|
|
5
|
+
from .ui_assets import LayoutBank
|
|
6
|
+
from .vm import VMError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TitleScreen:
|
|
11
|
+
elapsed_ms: int = 0
|
|
12
|
+
reveal_width: int = 0
|
|
13
|
+
ready: bool = False
|
|
14
|
+
|
|
15
|
+
def tick(self, elapsed_ms):
|
|
16
|
+
# 000a650c adds five pixels per update. Use the application's
|
|
17
|
+
# configured 30 Hz cadence, independent of the desktop render rate.
|
|
18
|
+
before = self.elapsed_ms * 30 // 1000
|
|
19
|
+
self.elapsed_ms = min(4000, self.elapsed_ms + elapsed_ms)
|
|
20
|
+
steps = self.elapsed_ms * 30 // 1000 - before
|
|
21
|
+
if steps:
|
|
22
|
+
# The native gate tests the OLD width before adding five.
|
|
23
|
+
self.ready |= self.reveal_width + 5 * (steps - 1) > 240
|
|
24
|
+
self.reveal_width = min(320, self.reveal_width + 5 * steps)
|
|
25
|
+
|
|
26
|
+
def acknowledge(self):
|
|
27
|
+
if not self.ready:
|
|
28
|
+
# 000a6800: an early tap completes the wipe, without closing.
|
|
29
|
+
# The following native update opens the acknowledgement gate.
|
|
30
|
+
self.reveal_width = 320
|
|
31
|
+
return False
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def background_alpha(self):
|
|
36
|
+
return max(0., min(1., (self.elapsed_ms - 1000) / 3000))
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def subtitle_scale_y(self):
|
|
40
|
+
return .5 + .5 * max(0., min(1., (self.elapsed_ms - 500) / 500))
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def settled(cls):
|
|
44
|
+
# Older saves contain no entrance history; preserve their readable,
|
|
45
|
+
# immediately acknowledgeable title instead of replaying the intro.
|
|
46
|
+
return cls(4000, 320, True)
|
|
47
|
+
|
|
48
|
+
def validate(self):
|
|
49
|
+
if (type(self.elapsed_ms) is not int or not 0 <= self.elapsed_ms <= 4000
|
|
50
|
+
or type(self.reveal_width) is not int or not 0 <= self.reveal_width <= 320
|
|
51
|
+
or type(self.ready) is not bool):
|
|
52
|
+
raise VMError('Invalid title-screen animation state')
|
|
53
|
+
steps = self.elapsed_ms * 30 // 1000
|
|
54
|
+
if (self.reveal_width not in (min(320, steps * 5), 320)
|
|
55
|
+
or (steps >= 50 and not self.ready)
|
|
56
|
+
or (self.ready and (steps == 0 or self.reveal_width <= 245))):
|
|
57
|
+
raise VMError('Title-screen wipe does not match its clock or input gate')
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class TitleGlyph:
|
|
62
|
+
character: str
|
|
63
|
+
x: int
|
|
64
|
+
y: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class TitleLabel:
|
|
69
|
+
asset_id: int
|
|
70
|
+
origin: tuple[float, float]
|
|
71
|
+
scale: float
|
|
72
|
+
glyphs: tuple[TitleGlyph, ...]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _glyphs(font: AtlasFont, text: str, width: int, *, right=False, subtitle=False):
|
|
76
|
+
"""00054148/00054490/0005373c, converted from local GL to downward Y."""
|
|
77
|
+
result, pen_y = [], 0
|
|
78
|
+
# Native subtitle fitting adjusts the first glyph's local Y. Subsequent
|
|
79
|
+
# rows then descend because the title panel sets host flag +0x6ac43.
|
|
80
|
+
adjust = subtitle and 24 < len(text) < 100 and font.height == 23
|
|
81
|
+
for line in font.wrap(text, width):
|
|
82
|
+
x = width - font.width(line) if right else 0
|
|
83
|
+
for character in line:
|
|
84
|
+
if character != ' ' and font.glyph(character) is not None:
|
|
85
|
+
if adjust:
|
|
86
|
+
if pen_y >= font.height - 2:
|
|
87
|
+
pen_y = 3
|
|
88
|
+
elif pen_y == 0:
|
|
89
|
+
pen_y = font.height - 4
|
|
90
|
+
result.append(TitleGlyph(character, x, -pen_y - font.height))
|
|
91
|
+
x += font.char_width(character) + font.tracking
|
|
92
|
+
pen_y -= font.height + font.line_gap
|
|
93
|
+
return tuple(result)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def title_labels(bank: LayoutBank, title_font: AtlasFont, subtitle_font: AtlasFont,
|
|
97
|
+
title: str, subtitle: str):
|
|
98
|
+
"""000a70b4: zero-anchor labels with independent node positions/scales."""
|
|
99
|
+
heading, subheading = bank.rectangle(48, 9), bank.rectangle(48, 10)
|
|
100
|
+
title_origin = heading.x - 10, heading.height // 2 - 10
|
|
101
|
+
subtitle_y = subheading.y - subheading.height // 2 - (75 if len(subtitle) < 100 else 0)
|
|
102
|
+
return (TitleLabel(528, title_origin, .85, _glyphs(title_font, title, 380)),
|
|
103
|
+
TitleLabel(530, (0, 480 - subtitle_y), 1.,
|
|
104
|
+
_glyphs(subtitle_font, subtitle, 320, right=True, subtitle=True)))
|
exp_runtime/trace.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Execute an EXP's KiWi code to the first pending action or diagnostic stop."""
|
|
2
|
+
from dataclasses import asdict
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .decode.bytecode import decode_program
|
|
7
|
+
from .content import ExpArchive
|
|
8
|
+
from .engine import EngineState
|
|
9
|
+
from .games import GAMES
|
|
10
|
+
from .vm import KiwiVM, StopKind, VMError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def trace_archive(path: Path, *, max_events: int = 10_000, max_steps: int = 100_000, game='shs') -> dict:
|
|
14
|
+
"""Trace a fresh game state. Never answer presentation/unknown yields.
|
|
15
|
+
|
|
16
|
+
Scheduled scripts are popped in LIFO order, as in FUN_0007f5f4.
|
|
17
|
+
Resource decoding/rendering, native saves, and minigames are outside
|
|
18
|
+
this trace. The result contains only state this handler subset models.
|
|
19
|
+
"""
|
|
20
|
+
if max_events < 1 or max_steps < 1:
|
|
21
|
+
raise ValueError('trace budgets must be positive')
|
|
22
|
+
if game not in GAMES:
|
|
23
|
+
raise ValueError(f'Unknown game: {game}')
|
|
24
|
+
data = path.read_bytes()
|
|
25
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
26
|
+
archive = ExpArchive(data)
|
|
27
|
+
programs = {}
|
|
28
|
+
for resource_id in archive.entries:
|
|
29
|
+
payload = archive.read(resource_id)
|
|
30
|
+
if payload.startswith(b'kiwi'):
|
|
31
|
+
programs[resource_id] = decode_program(payload)
|
|
32
|
+
if not programs:
|
|
33
|
+
raise ValueError(f'no KiWi scripts in {path}')
|
|
34
|
+
scene = next(iter(programs))
|
|
35
|
+
vm = KiwiVM(programs[scene])
|
|
36
|
+
engine = EngineState()
|
|
37
|
+
engine.game_key = game
|
|
38
|
+
events = []
|
|
39
|
+
stop = 'event_budget'
|
|
40
|
+
error = None
|
|
41
|
+
for _ in range(max_events):
|
|
42
|
+
remaining = max_steps - vm.steps_executed
|
|
43
|
+
if remaining <= 0:
|
|
44
|
+
stop = 'instruction_budget'
|
|
45
|
+
break
|
|
46
|
+
try:
|
|
47
|
+
event = vm.run(remaining)
|
|
48
|
+
if event.kind == StopKind.YIELD:
|
|
49
|
+
action = engine.dispatch(vm)
|
|
50
|
+
events.append(dict(scene=scene, **asdict(action)))
|
|
51
|
+
if not action.completed:
|
|
52
|
+
stop = action.name
|
|
53
|
+
break
|
|
54
|
+
elif event.kind == StopKind.HALT and engine.scheduled_scripts:
|
|
55
|
+
next_scene, flag = engine.scheduled_scripts[-1]
|
|
56
|
+
if next_scene not in programs:
|
|
57
|
+
stop = 'missing_script'
|
|
58
|
+
error = f'scheduled script {next_scene} is absent from this archive'
|
|
59
|
+
break
|
|
60
|
+
engine.scheduled_scripts.pop()
|
|
61
|
+
events.append(dict(scene=scene, name='load_script', file_id=next_scene, flag=flag))
|
|
62
|
+
scene = next_scene
|
|
63
|
+
vm.load_next(programs[scene])
|
|
64
|
+
else:
|
|
65
|
+
stop = event.kind.value
|
|
66
|
+
break
|
|
67
|
+
except VMError as exc:
|
|
68
|
+
stop, error = 'vm_error', str(exc)
|
|
69
|
+
break
|
|
70
|
+
return dict(
|
|
71
|
+
archive=str(path), sha256=digest, status=stop, error=error,
|
|
72
|
+
scene=scene, pc=vm.pc, sp=vm.sp, fp=vm.fp,
|
|
73
|
+
steps=vm.steps_executed, events=events, state=asdict(engine),
|
|
74
|
+
executed_opcodes={f'0x{op:02x}': count for op, count in sorted(vm.opcode_counts.items())},
|
|
75
|
+
recent_pcs=list(vm.recent_pcs),
|
|
76
|
+
)
|