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.
@@ -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
+ )