exp-runtime-creator 0.6.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/creator/__init__.py +8 -0
- exp_runtime/creator/__main__.py +69 -0
- exp_runtime/creator/assets.py +56 -0
- exp_runtime/creator/catalog.py +30 -0
- exp_runtime/creator/compiler.py +448 -0
- exp_runtime/creator/debugger.py +123 -0
- exp_runtime/creator/diagnostics.py +86 -0
- exp_runtime/creator/editing.py +80 -0
- exp_runtime/creator/expressions.py +81 -0
- exp_runtime/creator/features.py +90 -0
- exp_runtime/creator/graph.py +124 -0
- exp_runtime/creator/kiwi-hammer.svg +73 -0
- exp_runtime/creator/live.py +116 -0
- exp_runtime/creator/mcp_results.py +53 -0
- exp_runtime/creator/mcp_server.py +428 -0
- exp_runtime/creator/mcp_types.py +49 -0
- exp_runtime/creator/media.py +144 -0
- exp_runtime/creator/minigames.py +149 -0
- exp_runtime/creator/model.py +377 -0
- exp_runtime/creator/native.py +174 -0
- exp_runtime/creator/operations.py +83 -0
- exp_runtime/creator/panels.py +221 -0
- exp_runtime/creator/playtest.py +185 -0
- exp_runtime/creator/preview.py +30 -0
- exp_runtime/creator/schema.py +102 -0
- exp_runtime/creator/sharing.py +169 -0
- exp_runtime/creator/ui.py +1041 -0
- exp_runtime/creator/workspace.py +271 -0
- exp_runtime_creator-0.6.0.dist-info/METADATA +75 -0
- exp_runtime_creator-0.6.0.dist-info/RECORD +34 -0
- exp_runtime_creator-0.6.0.dist-info/WHEEL +5 -0
- exp_runtime_creator-0.6.0.dist-info/entry_points.txt +5 -0
- exp_runtime_creator-0.6.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_creator-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Episode authoring API. Importing this module does not initialize a GUI."""
|
|
2
|
+
from .model import Asset, Character, Choice, Node, Project, ProjectError, load_project, save_project
|
|
3
|
+
from .compiler import compile_project, compile_details, export_project, export_native_slot
|
|
4
|
+
|
|
5
|
+
from .native import import_exp
|
|
6
|
+
|
|
7
|
+
__all__ = ['Asset', 'Character', 'Choice', 'Node', 'Project', 'ProjectError', 'load_project',
|
|
8
|
+
'save_project', 'compile_project', 'compile_details', 'export_project', 'export_native_slot', 'import_exp']
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Desktop entry point and headless project export command."""
|
|
2
|
+
import argparse
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from exp_runtime.exp import ContentError
|
|
6
|
+
from .compiler import export_project, export_native_slot
|
|
7
|
+
from .model import load_project, save_project
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv=None):
|
|
11
|
+
parser = argparse.ArgumentParser(description='EXP Runtime episode creator')
|
|
12
|
+
parser.add_argument('project', nargs='?', type=Path, help='Editable .expproj document')
|
|
13
|
+
parser.add_argument('--import-exp', type=Path, help='Import an EXP; save to the project argument')
|
|
14
|
+
parser.add_argument('--language', choices=('en', 'fr', 'it', 'de', 'es'), help='Story language for export')
|
|
15
|
+
parser.add_argument('--smoke-test', action='store_true', help=argparse.SUPPRESS)
|
|
16
|
+
parser.add_argument('--export', type=Path, help='Compile a project without starting the GUI')
|
|
17
|
+
parser.add_argument('--native-slot', type=Path, help='With --export, retain this original EXP catalog identity')
|
|
18
|
+
parser.add_argument('--preview', type=Path, help=argparse.SUPPRESS)
|
|
19
|
+
parser.add_argument('--library', type=Path, help=argparse.SUPPRESS)
|
|
20
|
+
parser.add_argument('--game', choices=('shs', 'cod'), default='shs', help=argparse.SUPPRESS)
|
|
21
|
+
args = parser.parse_args(argv)
|
|
22
|
+
try:
|
|
23
|
+
if args.smoke_test:
|
|
24
|
+
from PySide6.QtWidgets import QApplication
|
|
25
|
+
from .ui import Editor
|
|
26
|
+
from .compiler import compile_project
|
|
27
|
+
app = QApplication.instance() or QApplication([])
|
|
28
|
+
editor = Editor(discover_library=False); editor.show(); app.processEvents()
|
|
29
|
+
assert compile_project(editor.project)
|
|
30
|
+
assert not editor.grab().isNull()
|
|
31
|
+
editor.close(); app.processEvents()
|
|
32
|
+
return
|
|
33
|
+
if args.import_exp:
|
|
34
|
+
if not args.project: parser.error('--import-exp requires an output .expproj path')
|
|
35
|
+
from .native import import_exp
|
|
36
|
+
if args.project.exists(): parser.error('Import destination already exists')
|
|
37
|
+
save_project(import_exp(args.import_exp.read_bytes(), game=args.game), args.project)
|
|
38
|
+
return
|
|
39
|
+
if args.export:
|
|
40
|
+
if not args.project:
|
|
41
|
+
parser.error('--export requires an .expproj document')
|
|
42
|
+
project = load_project(args.project)
|
|
43
|
+
if args.language: project.language = args.language
|
|
44
|
+
if args.native_slot:
|
|
45
|
+
export_native_slot(project, args.native_slot, args.export)
|
|
46
|
+
else:
|
|
47
|
+
export_project(project, args.export)
|
|
48
|
+
return
|
|
49
|
+
if args.native_slot:
|
|
50
|
+
parser.error('--native-slot requires --export')
|
|
51
|
+
if args.preview:
|
|
52
|
+
if not args.library:
|
|
53
|
+
parser.error('--preview requires --library')
|
|
54
|
+
from .preview import play_preview
|
|
55
|
+
play_preview(args.preview, args.library, args.game)
|
|
56
|
+
return
|
|
57
|
+
try:
|
|
58
|
+
from .ui import launch
|
|
59
|
+
except ModuleNotFoundError as error:
|
|
60
|
+
if error.name and error.name.startswith('PySide6'):
|
|
61
|
+
parser.error('The visual editor requires exp-runtime[creator] or exp-runtime-creator[desktop]')
|
|
62
|
+
raise
|
|
63
|
+
launch(args.project)
|
|
64
|
+
except (ContentError, OSError) as error:
|
|
65
|
+
parser.error(str(error))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == '__main__':
|
|
69
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Read-only access to a user's already imported game library."""
|
|
2
|
+
from io import BytesIO
|
|
3
|
+
|
|
4
|
+
from .model import Asset, ProjectError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def player_library(game):
|
|
8
|
+
"""Find a usable library through the player's remembered/default location."""
|
|
9
|
+
from exp_runtime.content import ContentError, ContentLibrary
|
|
10
|
+
from exp_runtime.launcher import default_library
|
|
11
|
+
try:
|
|
12
|
+
path = default_library(game)
|
|
13
|
+
with ContentLibrary(path) as library:
|
|
14
|
+
if library.game_id == game:
|
|
15
|
+
return str(path.resolve())
|
|
16
|
+
except (ContentError, OSError):
|
|
17
|
+
pass
|
|
18
|
+
return ''
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def image_png(data: bytes) -> bytes:
|
|
22
|
+
"""Decode a single original image for the browser or an embedded copy."""
|
|
23
|
+
from PIL import Image
|
|
24
|
+
from exp_runtime.ui_assets import ImagePack
|
|
25
|
+
if data.startswith((b'\x89PNG', b'\xff\xd8')):
|
|
26
|
+
with Image.open(BytesIO(data)) as image:
|
|
27
|
+
image.load()
|
|
28
|
+
image = image.convert('RGBA')
|
|
29
|
+
else:
|
|
30
|
+
pack = ImagePack.parse(data)
|
|
31
|
+
if len(pack.images) != 1 or pack.images[0].mode != 'RGBA':
|
|
32
|
+
raise ProjectError('Select a single portrait or background, not a UI atlas')
|
|
33
|
+
frame = pack.images[0]
|
|
34
|
+
image = Image.frombytes('RGBA', (frame.width, frame.height), frame.pixels)
|
|
35
|
+
output = BytesIO()
|
|
36
|
+
image.save(output, format='PNG')
|
|
37
|
+
return output.getvalue()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def import_library_image(project, library, resource, episode=None):
|
|
41
|
+
if library.game_id != project.game:
|
|
42
|
+
raise ProjectError('The library must match the project game')
|
|
43
|
+
if resource < 25000:
|
|
44
|
+
image_png(library.read_asset(resource)) # Reject scripts, fonts and atlases.
|
|
45
|
+
existing = next((asset for asset in project.assets if asset.id == resource), None)
|
|
46
|
+
if existing:
|
|
47
|
+
return existing
|
|
48
|
+
asset = Asset(resource, f'Base image {resource}')
|
|
49
|
+
project.assets.append(asset)
|
|
50
|
+
return asset
|
|
51
|
+
if resource < 26000 or episode is None:
|
|
52
|
+
raise ProjectError('Select an episode image with an ID of 26000 or higher')
|
|
53
|
+
# Copy to a reserved local slot: no dependency on the source episode,
|
|
54
|
+
# and no accidental expression variants from neighboring original IDs.
|
|
55
|
+
png = image_png(episode.read_asset(resource))
|
|
56
|
+
return project.add_image(f'Episode image {resource}', png)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Paginated, content-derived asset discovery. Never guess original names."""
|
|
2
|
+
from exp_runtime.content import ContentLibrary
|
|
3
|
+
from .graph import page
|
|
4
|
+
from .media import inspect_asset
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def library_assets(workspace, library, *, episode=None, kind=None, query='', offset=0, limit=50):
|
|
8
|
+
page(offset, limit)
|
|
9
|
+
with ContentLibrary(workspace.path(library)) as content:
|
|
10
|
+
resources = content.open_episode(episode) if episode else content
|
|
11
|
+
read = resources.archive.read if episode else content.read_asset
|
|
12
|
+
ids = sorted(resources.archive.entries if episode else content.base_members.keys() | content.music_members.keys())
|
|
13
|
+
found = []
|
|
14
|
+
# Metadata is derived only for matching IDs. A type filter requires
|
|
15
|
+
# inspection, so bound every scan and return a continuation offset.
|
|
16
|
+
index = offset
|
|
17
|
+
while index < len(ids) and index < offset + 200 and len(found) < limit:
|
|
18
|
+
resource = ids[index]; index += 1
|
|
19
|
+
if query.casefold() not in str(resource): continue
|
|
20
|
+
info = inspect_asset(read(resource))
|
|
21
|
+
if kind and info['type'] != kind: continue
|
|
22
|
+
found.append(dict(id=resource, name=None, **info))
|
|
23
|
+
return dict(assets=found, total_resources=len(ids), scanned=index - offset,
|
|
24
|
+
next_offset=index if index < len(ids) else None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_asset(workspace, library, resource, episode=None):
|
|
28
|
+
with ContentLibrary(workspace.path(library)) as content:
|
|
29
|
+
resources = content.open_episode(episode) if episode else content
|
|
30
|
+
return resources.archive.read(resource) if episode else content.read_asset(resource)
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Compile story graphs into native KiWi, with exact source locations for tools."""
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import struct
|
|
7
|
+
|
|
8
|
+
from exp_runtime.exp import ExpArchive, Metadata, encode_exp
|
|
9
|
+
from exp_runtime.authored import RESOURCE as AUTHORING_RESOURCE, MAGIC as AUTHORING_MAGIC
|
|
10
|
+
from exp_runtime.decode.bytecode import decode_program
|
|
11
|
+
from .model import Project, ProjectError, atomic_write, project_record, validate_structure
|
|
12
|
+
from .features import DEFAULTS, INPUT_GATES, parameters, translated
|
|
13
|
+
from . import expressions, minigames
|
|
14
|
+
|
|
15
|
+
VARIABLE_OWNER = -30000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _node_expression(node):
|
|
19
|
+
p = parameters(node)
|
|
20
|
+
if node.kind == 'set':
|
|
21
|
+
return p['expression'] or str(node.value)
|
|
22
|
+
if node.kind == 'branch':
|
|
23
|
+
return p['expression'] or None
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def variable_names(project):
|
|
28
|
+
names = {n.variable for n in project.nodes if n.kind in ('set', 'branch') and n.variable}
|
|
29
|
+
for node in project.nodes:
|
|
30
|
+
p = parameters(node)
|
|
31
|
+
if node.kind in ('random', 'word_game', 'football', 'grid', 'relationship', 'score'):
|
|
32
|
+
names.add(p['result'])
|
|
33
|
+
for expression in ([_node_expression(node)] + [o.condition for o in node.options]):
|
|
34
|
+
if expression:
|
|
35
|
+
names.update(expressions.parse(expression)[1])
|
|
36
|
+
return sorted(names)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_node(project, node, nodes, assets, characters):
|
|
40
|
+
label = node.label or node.id
|
|
41
|
+
if node.kind == 'native':
|
|
42
|
+
raise ProjectError('Native blocks belong to imported native projects')
|
|
43
|
+
for _, target in node.links():
|
|
44
|
+
if target not in nodes:
|
|
45
|
+
raise ProjectError(f'{label}: choose a destination for every connection')
|
|
46
|
+
if node.character and node.character not in characters:
|
|
47
|
+
raise ProjectError(f'{label}: unknown character {node.character}')
|
|
48
|
+
if node.kind == 'choice':
|
|
49
|
+
if not 2 <= len(node.options) <= 8 or any(not o.text.strip() for o in node.options):
|
|
50
|
+
raise ProjectError(f'{label}: provide two to eight nonempty choices')
|
|
51
|
+
if node.timeout_option >= len(node.options):
|
|
52
|
+
raise ProjectError(f'{label}: select an existing answer for timer expiry')
|
|
53
|
+
if node.kind in ('dialogue', 'choice') and not node.text.strip():
|
|
54
|
+
raise ProjectError(f'{label}: enter dialogue or a choice prompt')
|
|
55
|
+
if node.kind == 'set' or node.kind == 'branch' and not parameters(node)['expression']:
|
|
56
|
+
if not node.variable.strip():
|
|
57
|
+
raise ProjectError(f'{label}: enter a variable name')
|
|
58
|
+
if node.kind == 'background' and node.asset not in assets:
|
|
59
|
+
raise ProjectError(f'{label}: select an imported background image')
|
|
60
|
+
if node.kind == 'dialogue' and not node.character and node.portrait is not None:
|
|
61
|
+
if node.portrait not in assets or not node.speaker.strip():
|
|
62
|
+
raise ProjectError(f'{label}: a portrait needs an imported image and a speaker name')
|
|
63
|
+
if node.kind in ('football', 'grid') and project.game != 'shs':
|
|
64
|
+
raise ProjectError(f'{label}: the supplied native CoD game does not implement {node.kind}')
|
|
65
|
+
if set(node.params) - DEFAULTS[node.kind].keys():
|
|
66
|
+
raise ProjectError(f'{label}: unknown parameters {sorted(set(node.params) - DEFAULTS[node.kind].keys())}')
|
|
67
|
+
p = parameters(node)
|
|
68
|
+
for key, default in DEFAULTS[node.kind].items():
|
|
69
|
+
value = p[key]
|
|
70
|
+
if type(value) is not type(default):
|
|
71
|
+
raise ProjectError(f'{label}: {key} must be {type(default).__name__}')
|
|
72
|
+
if type(value) is int and not -32768 <= value <= 32767:
|
|
73
|
+
raise ProjectError(f'{label}: {key} exceeds the native signed-word range')
|
|
74
|
+
if isinstance(value, str) and ('\0' in value or len(value) > 60000):
|
|
75
|
+
raise ProjectError(f'{label}: invalid text in {key}')
|
|
76
|
+
if node.kind == 'score' and p['owner'] == VARIABLE_OWNER:
|
|
77
|
+
raise ProjectError('Owner -30000 is reserved for multi-scene authoring state')
|
|
78
|
+
if node.kind in ('relationship', 'score') and p['operation'] not in ('set', 'add'):
|
|
79
|
+
raise ProjectError(f'{label}: property operation must be set or add')
|
|
80
|
+
if node.kind == 'relationship' and not node.character:
|
|
81
|
+
raise ProjectError(f'{label}: choose a reusable character')
|
|
82
|
+
if node.kind in ('random', 'word_game', 'football', 'grid', 'input', 'relationship', 'score') and not p['result'].strip():
|
|
83
|
+
raise ProjectError(f'{label}: enter a result variable')
|
|
84
|
+
if node.kind == 'random' and p['bound'] <= 0:
|
|
85
|
+
raise ProjectError('Random upper bound must be positive')
|
|
86
|
+
if node.kind in ('music', 'sound') and p['resource'] < 0:
|
|
87
|
+
raise ProjectError('Audio resource IDs must be nonnegative')
|
|
88
|
+
if node.kind == 'music' and p['resource'] not in range(8201, 8210 if project.game == 'cod' else 8233):
|
|
89
|
+
raise ProjectError('Choose a native music ID for the selected game')
|
|
90
|
+
if node.kind == 'sound' and p['resource'] in range(8201, 8210 if project.game == 'cod' else 8233):
|
|
91
|
+
raise ProjectError('This ID selects music; use a Music node')
|
|
92
|
+
if node.kind == 'pan' and p['alignment'] not in (1, 2, 3):
|
|
93
|
+
raise ProjectError('Pan alignment must be 1 (left), 2 (center) or 3 (right)')
|
|
94
|
+
if node.kind in ('word_game', 'football', 'grid'):
|
|
95
|
+
minigames.validate(node.kind, p)
|
|
96
|
+
if node.kind == 'grid':
|
|
97
|
+
for key in ('left_character', 'right_character'):
|
|
98
|
+
if p[key] and p[key] not in characters:
|
|
99
|
+
raise ProjectError(f'{label}: unknown grid character')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def validate(project: Project):
|
|
103
|
+
validate_structure(project)
|
|
104
|
+
if project.native:
|
|
105
|
+
from .native import validate_native
|
|
106
|
+
validate_native(project)
|
|
107
|
+
return
|
|
108
|
+
if not project.title.strip():
|
|
109
|
+
raise ProjectError('Give the episode a title')
|
|
110
|
+
nodes = {node.id: node for node in project.nodes}
|
|
111
|
+
if project.start not in nodes:
|
|
112
|
+
raise ProjectError('Choose a valid starting node')
|
|
113
|
+
assets = {a.id: a for a in project.assets}
|
|
114
|
+
characters = {c.id: c for c in project.characters}
|
|
115
|
+
implicit = {(n.speaker, n.portrait, n.theme) for n in project.nodes
|
|
116
|
+
if n.kind == 'dialogue' and not n.character and n.portrait is not None}
|
|
117
|
+
if len(implicit) + len(characters) > 199:
|
|
118
|
+
raise ProjectError('The native character table allows 199 portrait speakers plus the narrator')
|
|
119
|
+
for c in project.characters:
|
|
120
|
+
for resource in (c.portrait, *c.expressions):
|
|
121
|
+
if resource is not None and resource not in assets:
|
|
122
|
+
raise ProjectError(f'{c.name}: select an imported character image')
|
|
123
|
+
for node in project.nodes:
|
|
124
|
+
validate_node(project, node, nodes, assets, characters)
|
|
125
|
+
variable_names(project) # Validate all expression syntax before writing.
|
|
126
|
+
automatic = {n.id: n for n in project.nodes if n.kind not in INPUT_GATES | {'end'}}
|
|
127
|
+
degrees = {key: 0 for key in automatic}
|
|
128
|
+
for node in automatic.values():
|
|
129
|
+
for _, target in node.links():
|
|
130
|
+
if target in degrees: degrees[target] += 1
|
|
131
|
+
ready, visited = [key for key, degree in degrees.items() if degree == 0], 0
|
|
132
|
+
while ready:
|
|
133
|
+
node = automatic[ready.pop()]
|
|
134
|
+
visited += 1
|
|
135
|
+
for _, target in node.links():
|
|
136
|
+
if target in degrees:
|
|
137
|
+
degrees[target] -= 1
|
|
138
|
+
if degrees[target] == 0: ready.append(target)
|
|
139
|
+
if visited != len(automatic):
|
|
140
|
+
raise ProjectError('An automatic loop needs a dialogue, choice or other input gate')
|
|
141
|
+
|
|
142
|
+
class _Assembler:
|
|
143
|
+
def __init__(self):
|
|
144
|
+
self.words = bytearray()
|
|
145
|
+
self.strings = {}
|
|
146
|
+
self.code = []
|
|
147
|
+
self.labels = {}
|
|
148
|
+
|
|
149
|
+
def text(self, value):
|
|
150
|
+
if value not in self.strings:
|
|
151
|
+
try:
|
|
152
|
+
encoded = value.encode('latin-1') + b'\0'
|
|
153
|
+
except UnicodeError as error:
|
|
154
|
+
raise ProjectError('Story text must fit the native Latin-1 encoding; '
|
|
155
|
+
'use straight quotes/dashes instead of curly punctuation or emoji') from error
|
|
156
|
+
self.strings[value] = len(self.words) // 2
|
|
157
|
+
self.words.extend(encoded + (b'\0' if len(encoded) % 2 else b''))
|
|
158
|
+
return self.strings[value]
|
|
159
|
+
|
|
160
|
+
def emit(self, op, arg=None):
|
|
161
|
+
self.code.append((op, arg))
|
|
162
|
+
|
|
163
|
+
def table(self, values):
|
|
164
|
+
result = len(self.words) // 2
|
|
165
|
+
for value in values:
|
|
166
|
+
if type(value) is not int or not -32768 <= value <= 65535:
|
|
167
|
+
raise ProjectError('Native table value exceeds a word')
|
|
168
|
+
self.words.extend(struct.pack('>H', value & 65535))
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
def call(self, service, *args):
|
|
172
|
+
for arg in args:
|
|
173
|
+
self.emit(0x1a, arg)
|
|
174
|
+
self.emit(0x1f, service << 8 | len(args))
|
|
175
|
+
|
|
176
|
+
def jump(self, target):
|
|
177
|
+
self.emit(0x29, target)
|
|
178
|
+
|
|
179
|
+
def jump_if(self, target):
|
|
180
|
+
# Conditional instructions use relative PCs. Skip an absolute jump
|
|
181
|
+
# on false, keeping long/backward graph edges independent of offsets.
|
|
182
|
+
self.emit(0x2b, 2)
|
|
183
|
+
self.jump(target)
|
|
184
|
+
|
|
185
|
+
def finish(self):
|
|
186
|
+
if len(self.words) // 2 >= 0x7000 or len(self.code) > 32767:
|
|
187
|
+
raise ProjectError('Episode exceeds the native single-script memory/instruction limit; split the episode')
|
|
188
|
+
code = bytearray()
|
|
189
|
+
for op, arg in self.code:
|
|
190
|
+
code.append(op)
|
|
191
|
+
if arg is not None:
|
|
192
|
+
value = self.labels[arg] if not isinstance(arg, int) else arg
|
|
193
|
+
code.extend(struct.pack('>H', value & 65535))
|
|
194
|
+
result = (b'kiwi\x02\x00\x00' + struct.pack('>4H', len(self.words) // 2, 0, 0, len(self.code))
|
|
195
|
+
+ self.words + code)
|
|
196
|
+
return decode_program(bytes(result)).to_bytes()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class Compilation:
|
|
201
|
+
data: bytes
|
|
202
|
+
locations: dict[int, dict[str, int]]
|
|
203
|
+
variables: dict[str, int]
|
|
204
|
+
shared_variables: bool
|
|
205
|
+
characters: dict[str, int]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _character_bank(project, resources):
|
|
209
|
+
slots, definitions = {}, []
|
|
210
|
+
assets = {asset.id: asset for asset in project.assets}
|
|
211
|
+
for c in project.characters:
|
|
212
|
+
portrait = c.portrait if c.portrait is not None else -1
|
|
213
|
+
if c.expressions and any(value is not None for value in c.expressions):
|
|
214
|
+
variants = [value if value is not None else c.portrait for value in c.expressions]
|
|
215
|
+
if all(value == portrait + i for i, value in enumerate(variants)):
|
|
216
|
+
pass # A native base bank already provides the five consecutive slots.
|
|
217
|
+
else:
|
|
218
|
+
if any(value is None or assets[value].data is None for value in variants):
|
|
219
|
+
raise ProjectError(f'{c.name}: custom expression sets require embedded images in all five slots')
|
|
220
|
+
portrait = next((base for base in range(26000, 32763, 5)
|
|
221
|
+
if not set(range(base, base + 5)).intersection(set(resources) | set(assets))), None)
|
|
222
|
+
if portrait is None: raise ProjectError('No space for the character expression bank')
|
|
223
|
+
resources.update({portrait + i: assets[value].data for i, value in enumerate(variants)})
|
|
224
|
+
number = len(definitions) + 1
|
|
225
|
+
slots[c.id] = number
|
|
226
|
+
definitions.append((number, translated(project, f'character.{c.id}.name', c.name), portrait, c.theme))
|
|
227
|
+
for name, portrait, theme in sorted({(n.speaker, n.portrait, n.theme) for n in project.nodes
|
|
228
|
+
if n.kind == 'dialogue' and not n.character and n.portrait is not None}):
|
|
229
|
+
number = len(definitions) + 1
|
|
230
|
+
slots[name, portrait, theme] = number
|
|
231
|
+
definitions.append((number, name, portrait, theme))
|
|
232
|
+
return slots, definitions
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def compile_details(project: Project, *, language=None, start=None, include_project=True) -> Compilation:
|
|
236
|
+
project = deepcopy(project)
|
|
237
|
+
if language is not None: project.language = language
|
|
238
|
+
if start is not None:
|
|
239
|
+
if project.native: raise ProjectError('Imported native blocks retain their original entry point')
|
|
240
|
+
project.start = start
|
|
241
|
+
validate(project)
|
|
242
|
+
if project.native:
|
|
243
|
+
from .native import compile_native
|
|
244
|
+
locations = {}
|
|
245
|
+
for node in project.nodes:
|
|
246
|
+
locations.setdefault(node.params['script'], {})[node.id] = node.params['pc']
|
|
247
|
+
return Compilation(compile_native(project), locations, {}, False, {})
|
|
248
|
+
variables = {name: i for i, name in enumerate(variable_names(project))}
|
|
249
|
+
nodes = {node.id: node for node in project.nodes}
|
|
250
|
+
scenes = sorted({node.scene for node in project.nodes})
|
|
251
|
+
shared = len(scenes) > 1
|
|
252
|
+
if len(scenes) > 998: raise ProjectError('At most 998 authored scenes are supported')
|
|
253
|
+
scripts = {scene: 25002 + i for i, scene in enumerate(scenes)} if shared else {scenes[0]: 25001}
|
|
254
|
+
ordinals = {node.id: i for i, node in enumerate(project.nodes)}
|
|
255
|
+
resources = {asset.id: asset.data for asset in project.assets if asset.data is not None}
|
|
256
|
+
characters, definitions = _character_bank(project, resources)
|
|
257
|
+
locations = {}
|
|
258
|
+
|
|
259
|
+
def initialize(asm):
|
|
260
|
+
asm.call(75, 0); asm.call(74, 0); asm.call(72, 0, -1)
|
|
261
|
+
for number, name, portrait, theme in definitions:
|
|
262
|
+
asm.call(64, number, asm.text(name)); asm.call(72, number, portrait)
|
|
263
|
+
asm.call(52, number, 651, theme)
|
|
264
|
+
|
|
265
|
+
def route(asm, source_scene, target):
|
|
266
|
+
target_node = nodes[target]
|
|
267
|
+
if source_scene == target_node.scene:
|
|
268
|
+
asm.jump(target)
|
|
269
|
+
else:
|
|
270
|
+
asm.call(52, VARIABLE_OWNER, -1, ordinals[target])
|
|
271
|
+
asm.call(10, scripts[target_node.scene], 0)
|
|
272
|
+
asm.emit(0x33)
|
|
273
|
+
|
|
274
|
+
if shared:
|
|
275
|
+
boot = _Assembler()
|
|
276
|
+
initialize(boot)
|
|
277
|
+
for address in variables.values(): boot.call(52, VARIABLE_OWNER, address, 0)
|
|
278
|
+
route(boot, None, project.start)
|
|
279
|
+
resources[25001] = boot.finish()
|
|
280
|
+
for scene in scenes:
|
|
281
|
+
asm = _Assembler()
|
|
282
|
+
if not shared:
|
|
283
|
+
asm.words.extend(b'\0\0' * len(variables))
|
|
284
|
+
initialize(asm)
|
|
285
|
+
asm.jump(project.start)
|
|
286
|
+
else:
|
|
287
|
+
for node in project.nodes:
|
|
288
|
+
if node.scene == scene:
|
|
289
|
+
asm.call(53, VARIABLE_OWNER, -1); asm.emit(0x21)
|
|
290
|
+
asm.emit(0x1a, ordinals[node.id]); asm.emit(0x0a); asm.jump_if(node.id)
|
|
291
|
+
asm.emit(0x33) # Only the compiler's explicit scene-entry selectors can arrive here.
|
|
292
|
+
script_id = scripts[scene]
|
|
293
|
+
locations[script_id] = {}
|
|
294
|
+
|
|
295
|
+
def read_variable(name):
|
|
296
|
+
if shared:
|
|
297
|
+
asm.call(53, VARIABLE_OWNER, variables[name]); asm.emit(0x21)
|
|
298
|
+
else:
|
|
299
|
+
asm.emit(0x40, variables[name])
|
|
300
|
+
|
|
301
|
+
def store_variable(name):
|
|
302
|
+
# Stack result -> A, then the destination and value in native order.
|
|
303
|
+
asm.emit(0x03)
|
|
304
|
+
if shared:
|
|
305
|
+
asm.emit(0x1a, VARIABLE_OWNER); asm.emit(0x1a, variables[name]); asm.emit(0x07)
|
|
306
|
+
asm.emit(0x1f, 52 << 8 | 3)
|
|
307
|
+
else:
|
|
308
|
+
asm.emit(0x1a, variables[name]); asm.emit(0x07); asm.emit(0x3e); asm.emit(0x15)
|
|
309
|
+
|
|
310
|
+
def expression(value):
|
|
311
|
+
expressions.emit(asm, expressions.parse(value)[0], read_variable)
|
|
312
|
+
|
|
313
|
+
for node in project.nodes:
|
|
314
|
+
if node.scene != scene: continue
|
|
315
|
+
asm.labels[node.id] = locations[script_id][node.id] = len(asm.code)
|
|
316
|
+
p = parameters(node)
|
|
317
|
+
base = f'node.{node.id}.'
|
|
318
|
+
def text(field, fallback=None, *, raw=False):
|
|
319
|
+
key = base + field if field in ('text', 'speaker') else base + 'param.' + field
|
|
320
|
+
if fallback is None: fallback = getattr(node, field) if field in ('text', 'speaker') else p[field]
|
|
321
|
+
value = translated(project, key, fallback)
|
|
322
|
+
return value if raw else asm.text(value)
|
|
323
|
+
def character(field='character'):
|
|
324
|
+
key = node.character if field == 'character' else p[field]
|
|
325
|
+
return characters[key] if key else 0
|
|
326
|
+
kind = node.kind
|
|
327
|
+
if kind == 'dialogue':
|
|
328
|
+
if node.character or node.portrait is not None:
|
|
329
|
+
who = character() if node.character else characters[node.speaker, node.portrait, node.theme]
|
|
330
|
+
asm.call(13, text('text'), who, node.expression)
|
|
331
|
+
elif node.speaker:
|
|
332
|
+
asm.call(76, text('speaker'), text('text'))
|
|
333
|
+
else:
|
|
334
|
+
asm.call(13, text('text'), 0, 0)
|
|
335
|
+
elif kind == 'choice':
|
|
336
|
+
asm.call(52, 0, 651, node.theme)
|
|
337
|
+
asm.call(2, text('speaker'), text('text'), node.timeout_ms, node.timeout_option, character(), 0)
|
|
338
|
+
for i, option in enumerate(node.options):
|
|
339
|
+
asm.emit(0x1a, asm.text(translated(project, base + f'option.{i}', option.text)))
|
|
340
|
+
asm.emit(0x1a, i); expression(option.condition or '1'); asm.emit(0x1f, 3 << 8 | 3)
|
|
341
|
+
asm.call(4, int(p['shuffle']))
|
|
342
|
+
for i, option in enumerate(node.options):
|
|
343
|
+
asm.emit(0x21); asm.emit(0x1a, i); asm.emit(0x0a)
|
|
344
|
+
label = ('choice', node.id, i)
|
|
345
|
+
asm.jump_if(label)
|
|
346
|
+
asm.emit(0x33) # Preserve unexpected native outcomes; no invented answer.
|
|
347
|
+
for i, option in enumerate(node.options):
|
|
348
|
+
asm.labels[('choice', node.id, i)] = len(asm.code)
|
|
349
|
+
route(asm, scene, option.target)
|
|
350
|
+
elif kind == 'background':
|
|
351
|
+
asm.call(11, p['variant'], node.asset)
|
|
352
|
+
elif kind == 'set':
|
|
353
|
+
expression(_node_expression(node)); store_variable(node.variable)
|
|
354
|
+
elif kind in ('branch', 'text_branch'):
|
|
355
|
+
if kind == 'text_branch':
|
|
356
|
+
asm.call(47, asm.text('{' + p['variable'] + '}'), asm.text(''), -1)
|
|
357
|
+
asm.call(24, 0x7ff5, text('equals'))
|
|
358
|
+
asm.emit(0x21)
|
|
359
|
+
elif p['expression']:
|
|
360
|
+
expression(p['expression'])
|
|
361
|
+
else:
|
|
362
|
+
read_variable(node.variable); asm.emit(0x1a, node.value)
|
|
363
|
+
asm.emit({'==': 0x0a, '!=': 0x0b, '<': 0x0e, '<=': 0x0f, '>': 0x0c, '>=': 0x0d}[node.operator])
|
|
364
|
+
yes = ('yes', node.id)
|
|
365
|
+
asm.jump_if(yes); route(asm, scene, node.otherwise)
|
|
366
|
+
asm.labels[yes] = len(asm.code); route(asm, scene, node.next)
|
|
367
|
+
elif kind in ('music', 'sound', 'stop_audio'):
|
|
368
|
+
if kind == 'music': asm.call(80, p['resource'], int(p['loop']))
|
|
369
|
+
elif kind == 'sound': asm.call(79, p['resource'])
|
|
370
|
+
else: asm.call(81, p['channel'])
|
|
371
|
+
elif kind in ('relationship', 'score'):
|
|
372
|
+
owner, key = (character(), 407) if kind == 'relationship' else (p['owner'], p['key'])
|
|
373
|
+
asm.emit(0x1a, owner); asm.emit(0x1a, key)
|
|
374
|
+
if p['operation'] == 'add':
|
|
375
|
+
asm.call(53, owner, key); asm.emit(0x21); asm.emit(0x1a, p['value']); asm.emit(0x50)
|
|
376
|
+
else: asm.emit(0x1a, p['value'])
|
|
377
|
+
asm.emit(0x1f, 52 << 8 | 3)
|
|
378
|
+
if kind == 'relationship':
|
|
379
|
+
asm.call(52, owner, 629, int(p['hide']))
|
|
380
|
+
if p['romantic']: asm.call(54, 601, owner)
|
|
381
|
+
elif p['notification']: asm.call(88, text('notification'))
|
|
382
|
+
asm.call(53, owner, key); asm.emit(0x21); store_variable(p['result'])
|
|
383
|
+
elif kind == 'input':
|
|
384
|
+
asm.call(17, text('speaker'), text('prompt'), text('default'))
|
|
385
|
+
asm.call(46, asm.text('{' + p['result'] + '}'), 0x7ff5)
|
|
386
|
+
elif kind == 'title': asm.call(8, text('text'), text('subtitle'), p['background'], p['flag'])
|
|
387
|
+
elif kind == 'message': asm.call(33, text('title'), text('text'), p['flag'])
|
|
388
|
+
elif kind == 'notice': asm.call(88, text('text'))
|
|
389
|
+
elif kind == 'emphasis': asm.call(89)
|
|
390
|
+
elif kind == 'scene_label': asm.call(90, p['icon'], text('text') if node.text else -1)
|
|
391
|
+
elif kind == 'pan': asm.call(97, int(p['automatic']), p['alignment'])
|
|
392
|
+
elif kind == 'transition': asm.call(16, p['selector'])
|
|
393
|
+
elif kind == 'close_dialogue': asm.call(39)
|
|
394
|
+
elif kind == 'random':
|
|
395
|
+
asm.call(27, p['bound']); asm.emit(0x21); store_variable(p['result'])
|
|
396
|
+
elif kind in ('word_game', 'football', 'grid'):
|
|
397
|
+
localized = deepcopy(p)
|
|
398
|
+
for field in ('good', 'bad', 'instructions'):
|
|
399
|
+
if field in localized:
|
|
400
|
+
localized[field] = [text(f'{field}.{i}', v, raw=True) for i, v in enumerate(p[field])]
|
|
401
|
+
for field in ('problems', 'tutorials'):
|
|
402
|
+
for i, record in enumerate(localized.get(field, [])):
|
|
403
|
+
for key, value in list(record.items()):
|
|
404
|
+
if isinstance(value, str): record[key] = text(f'{field}.{i}.{key}', value, raw=True)
|
|
405
|
+
elif key == 'words':
|
|
406
|
+
record[key] = [text(f'{field}.{i}.words.{j}', word, raw=True)
|
|
407
|
+
for j, word in enumerate(value)]
|
|
408
|
+
minigames.validate(kind, localized)
|
|
409
|
+
minigames.emit(asm, kind, p, text, character)
|
|
410
|
+
asm.emit(0x21); store_variable(p['result'])
|
|
411
|
+
elif kind == 'end': asm.call(7); asm.emit(0x33)
|
|
412
|
+
if kind not in ('choice', 'branch', 'text_branch', 'end'):
|
|
413
|
+
route(asm, scene, node.next)
|
|
414
|
+
resources[script_id] = asm.finish()
|
|
415
|
+
titles = tuple(project.locales.get(language, {}).get('title', project.title) for language in ('en', 'fr', 'it', 'de', 'es'))
|
|
416
|
+
resources[1] = Metadata(project.pack_id, project.episode_id, titles).to_bytes()
|
|
417
|
+
record = project_record(project)
|
|
418
|
+
record['library'] = '' # Never distribute the author's private library path.
|
|
419
|
+
for asset in record['assets']:
|
|
420
|
+
if asset['data'] is not None: asset['data'] = '@resource'
|
|
421
|
+
resources[AUTHORING_RESOURCE] = AUTHORING_MAGIC + json.dumps(dict(
|
|
422
|
+
format='exp-runtime-authored', version=1, project=record if include_project else None,
|
|
423
|
+
locations=locations, variables=variables, shared_variables=shared,
|
|
424
|
+
characters={key: value for key, value in characters.items() if isinstance(key, str)}),
|
|
425
|
+
ensure_ascii=True, separators=(',', ':')).encode()
|
|
426
|
+
return Compilation(encode_exp(resources), locations, variables, shared,
|
|
427
|
+
{key: value for key, value in characters.items() if isinstance(key, str)})
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def compile_project(project: Project, *, language=None, start=None) -> bytes:
|
|
431
|
+
return compile_details(project, language=language, start=start).data
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def export_project(project: Project, path, *, language=None):
|
|
435
|
+
atomic_write(path, compile_project(project, language=language))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def export_native_slot(project: Project, reference: Path, destination: Path):
|
|
439
|
+
reference, destination = Path(reference), Path(destination)
|
|
440
|
+
if reference.resolve() == destination.resolve():
|
|
441
|
+
raise ProjectError('Export a separate replacement file; keep the original episode as a backup')
|
|
442
|
+
archive = ExpArchive(reference.read_bytes())
|
|
443
|
+
metadata = archive.metadata()
|
|
444
|
+
if 25001 not in archive.programs():
|
|
445
|
+
raise ProjectError('The reference episode has no native entry script at resource 25001')
|
|
446
|
+
replacement = replace(project, pack_id=metadata.pack_id, episode_id=metadata.episode_id)
|
|
447
|
+
export_project(replacement, destination)
|
|
448
|
+
return metadata
|