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