exp-runtime-engine 0.5.0__tar.gz
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_engine-0.5.0/LICENSE +21 -0
- exp_runtime_engine-0.5.0/MANIFEST.in +4 -0
- exp_runtime_engine-0.5.0/PKG-INFO +49 -0
- exp_runtime_engine-0.5.0/README.md +15 -0
- exp_runtime_engine-0.5.0/pyproject.toml +23 -0
- exp_runtime_engine-0.5.0/setup.cfg +4 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/audio.py +13 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/background.py +58 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/character_picker.py +92 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/choice.py +116 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/choice_hints.py +153 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/content.py +742 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/dialogue.py +151 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/dialogue_animation.py +345 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/dialogue_notice.py +51 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/engine.py +609 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/episode_catalog.py +171 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/game_state.py +9 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/games.py +35 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/ios_fonts.py +228 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/launcher.py +92 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/loading.py +37 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/menu.py +177 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/message_panel.py +36 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/minigames.py +76 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/randomness.py +61 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/rar.py +145 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/relationships.py +131 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/runtime.py +879 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/scene_badge.py +65 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/speaker_names.py +258 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/text_input.py +55 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/title_screen.py +104 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/trace.py +76 -0
- exp_runtime_engine-0.5.0/src/exp_runtime/vm.py +391 -0
- exp_runtime_engine-0.5.0/src/exp_runtime_engine.egg-info/SOURCES.txt +33 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Josh Mcdaniel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: exp-runtime-engine
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Headless KiWi VM, sessions, content libraries and saves
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2025 Josh Mcdaniel
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: exp-runtime-formats==0.5.0
|
|
31
|
+
Provides-Extra: fonts
|
|
32
|
+
Requires-Dist: Pillow>=10; extra == "fonts"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# EXP Runtime — engine
|
|
36
|
+
|
|
37
|
+
Headless KiWi VM, sessions, content libraries and saves. This is a component of the single
|
|
38
|
+
[EXP Runtime project](https://github.com/joshrmcdaniel/exp-runtime), maintained
|
|
39
|
+
and released together with its other wheels. Python imports use the shared
|
|
40
|
+
`exp_runtime` namespace.
|
|
41
|
+
|
|
42
|
+
Install `exp-runtime[tools]` for file tools, `exp-runtime[engine,shs]` or
|
|
43
|
+
`exp-runtime[engine,cod]` for a headless game, or `exp-runtime[player]` for
|
|
44
|
+
the desktop player with both games. Individual component wheels also support
|
|
45
|
+
selective installs. See the project's `docs/SDK.md` for APIs and examples.
|
|
46
|
+
|
|
47
|
+
No original game content is included. Users supply their own game files.
|
|
48
|
+
The inherited MIT license and attribution are included in LICENSE; see
|
|
49
|
+
[provenance](https://github.com/joshrmcdaniel/exp-runtime/blob/main/docs/PROVENANCE.md).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# EXP Runtime — engine
|
|
2
|
+
|
|
3
|
+
Headless KiWi VM, sessions, content libraries and saves. This is a component of the single
|
|
4
|
+
[EXP Runtime project](https://github.com/joshrmcdaniel/exp-runtime), maintained
|
|
5
|
+
and released together with its other wheels. Python imports use the shared
|
|
6
|
+
`exp_runtime` namespace.
|
|
7
|
+
|
|
8
|
+
Install `exp-runtime[tools]` for file tools, `exp-runtime[engine,shs]` or
|
|
9
|
+
`exp-runtime[engine,cod]` for a headless game, or `exp-runtime[player]` for
|
|
10
|
+
the desktop player with both games. Individual component wheels also support
|
|
11
|
+
selective installs. See the project's `docs/SDK.md` for APIs and examples.
|
|
12
|
+
|
|
13
|
+
No original game content is included. Users supply their own game files.
|
|
14
|
+
The inherited MIT license and attribution are included in LICENSE; see
|
|
15
|
+
[provenance](https://github.com/joshrmcdaniel/exp-runtime/blob/main/docs/PROVENANCE.md).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "exp-runtime-engine"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "Headless KiWi VM, sessions, content libraries and saves"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {file = "LICENSE"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["exp-runtime-formats==0.5.0"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
fonts = ["Pillow>=10"]
|
|
16
|
+
|
|
17
|
+
[tool.setuptools]
|
|
18
|
+
include-package-data = false
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
22
|
+
include = ["exp_runtime", "exp_runtime.*"]
|
|
23
|
+
namespaces = true
|
|
@@ -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')
|
|
@@ -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'
|