1use std::fmt;
44
45use workshop_rs::catalog::{Catalog, Locale};
46use workshop_rs::source::Span;
47use workshop_rs::wir::{self, Action, Event, ModifyOp, Value};
48
49use opy_rs::manifest::{Function, FunctionKind, Manifest};
50
51#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ReconstructIssue {
56 pub code: &'static str,
57 pub message: String,
58 pub span: Option<Span>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ReconstructError {
66 pub issues: Vec<ReconstructIssue>,
67}
68
69impl fmt::Display for ReconstructError {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 for (index, issue) in self.issues.iter().enumerate() {
72 if index > 0 {
73 writeln!(f)?;
74 }
75 let location = match issue.span {
76 Some(span) => format!(" at {}:{}", span.start.line, span.start.col),
77 None => String::new(),
78 };
79 write!(f, "{}: {}{location}", issue.code, issue.message)?;
80 }
81 Ok(())
82 }
83}
84
85impl std::error::Error for ReconstructError {}
86
87pub fn reconstruct(program: &wir::Program) -> Result<String, ReconstructError> {
95 let manifest = match Manifest::builtin() {
96 Ok(manifest) => manifest,
97 Err(error) => {
98 return Err(ReconstructError {
99 issues: vec![ReconstructIssue {
100 code: "manifest-error",
101 message: format!(
102 "cannot load the OPY semantic compatibility manifest: {error}"
103 ),
104 span: None,
105 }],
106 });
107 }
108 };
109 let catalog = match Catalog::builtin() {
110 Ok(catalog) => catalog,
111 Err(error) => {
112 return Err(ReconstructError {
113 issues: vec![ReconstructIssue {
114 code: "catalog-error",
115 message: format!("cannot load the Workshop catalog: {error}"),
116 span: None,
117 }],
118 });
119 }
120 };
121 reconstruct_with(program, manifest, &catalog, &Locale::new("en-US"))
122}
123
124pub fn reconstruct_with(
129 program: &wir::Program,
130 manifest: &Manifest,
131 catalog: &Catalog,
132 locale: &Locale,
133) -> Result<String, ReconstructError> {
134 let mut emitter = Emitter::new(program, manifest, catalog, locale);
135 emitter.run();
136 if emitter.issues.is_empty() {
137 Ok(emitter.out)
138 } else {
139 Err(ReconstructError {
140 issues: emitter.issues,
141 })
142 }
143}
144
145const RESERVED_NAMES: &[&str] = &[
148 "true",
149 "false",
150 "None",
151 "null",
152 "eventPlayer",
153 "rule",
154 "def",
155 "globalvar",
156 "playervar",
157 "subroutine",
158 "enum",
159 "macro",
160 "if",
161 "for",
162 "while",
163 "pass",
164 "elif",
165 "else",
166 "in",
167 "and",
168 "or",
169 "not",
170];
171
172fn is_opy_identifier(name: &str) -> bool {
174 let mut chars = name.chars();
175 let Some(first) = chars.next() else {
176 return false;
177 };
178 (first.is_ascii_alphabetic() || first == '_')
179 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
180}
181
182const BINARY_OPS: &[&str] = &[
185 "+", "-", "*", "/", "%", "**", "==", "!=", "<", "<=", ">", ">=", "and", "or",
186];
187
188const DEDICATED_ACTION_NAMES: &[&str] = &["debug", "print", "append"];
190const DEDICATED_VALUE_NAMES: &[&str] = &["vect", "range", "chase"];
191
192struct Emitter<'a> {
193 program: &'a wir::Program,
194 manifest: &'a Manifest,
195 catalog: &'a Catalog,
196 locale: &'a Locale,
197 issues: Vec<ReconstructIssue>,
198 out: String,
199 subroutine_names: std::collections::HashSet<String>,
201}
202
203struct RuleLayout<'a> {
205 global_init: Option<Vec<wir::ActionId>>,
208 player_init: Option<Vec<wir::ActionId>>,
211 sub_rules: Vec<&'a wir::Rule>,
213 normal_rules: Vec<&'a wir::Rule>,
215}
216
217impl<'a> Emitter<'a> {
218 fn new(
219 program: &'a wir::Program,
220 manifest: &'a Manifest,
221 catalog: &'a Catalog,
222 locale: &'a Locale,
223 ) -> Self {
224 let subroutine_names = program
225 .subroutines
226 .iter()
227 .map(|subroutine| subroutine.name.clone())
228 .collect();
229 Emitter {
230 program,
231 manifest,
232 catalog,
233 locale,
234 issues: Vec::new(),
235 out: String::new(),
236 subroutine_names,
237 }
238 }
239
240 fn run(&mut self) {
241 self.validate_tables();
242 if self.issues.is_empty() {
243 let layout = self.classify_rules();
244 if self.issues.is_empty() {
245 self.emit_program(&layout);
246 }
247 }
248 }
249 fn issue(&mut self, code: &'static str, message: impl Into<String>, span: Option<Span>) {
252 self.issues.push(ReconstructIssue {
253 code,
254 message: message.into(),
255 span,
256 });
257 }
258
259 fn validate_tables(&mut self) {
262 if self.program.settings.is_some() {
263 self.issue(
264 "unsupported-settings",
265 "custom-game-settings are outside the reconstruction surface",
266 None,
267 );
268 }
269 let mut previous_index: Option<u32> = None;
273 for (position, variable) in self.program.global_variables.iter().enumerate() {
274 self.check_variable_name(variable.name.as_str(), variable.span, "global variable");
275 self.check_duplicate_name(
276 variable.name.as_str(),
277 position,
278 "global variable",
279 variable.span,
280 );
281 if let Some(previous) = previous_index {
282 if variable.index < previous {
283 self.issue(
284 "unsupported-global-order",
285 format!(
286 "global variables must be in ascending index order \
287 (slot {} precedes slot {})",
288 previous, variable.index
289 ),
290 variable.span,
291 );
292 }
293 }
294 previous_index = Some(variable.index);
295 }
296 for (position, variable) in self.program.player_variables.iter().enumerate() {
299 self.check_variable_name(variable.name.as_str(), variable.span, "player variable");
300 self.check_duplicate_name(
301 variable.name.as_str(),
302 position,
303 "player variable",
304 variable.span,
305 );
306 }
307 for (position, subroutine) in self.program.subroutines.iter().enumerate() {
312 self.check_variable_name(subroutine.name.as_str(), subroutine.span, "subroutine");
313 self.check_duplicate_name(
314 subroutine.name.as_str(),
315 position,
316 "subroutine",
317 subroutine.span,
318 );
319 if subroutine.index as usize != position {
320 self.issue(
321 "unsupported-subroutine-index",
322 format!(
323 "subroutine '{}' has index {} but the OPY surface requires \
324 table position {} (subroutine declarations cannot carry an index)",
325 subroutine.name, subroutine.index, position
326 ),
327 subroutine.span,
328 );
329 }
330 }
331 }
332
333 fn check_variable_name(&mut self, name: &str, span: Option<Span>, kind: &str) {
334 if !is_opy_identifier(name) {
335 self.issue(
336 "unsupported-name",
337 format!(
338 "{kind} name '{name}' is not a valid OPY identifier on the \
339 reconstruction surface"
340 ),
341 span,
342 );
343 } else if RESERVED_NAMES.contains(&name) {
344 self.issue(
345 "unsupported-name",
346 format!(
347 "{kind} name '{name}' collides with an OPY keyword or literal \
348 and cannot be referenced on the reconstruction surface"
349 ),
350 span,
351 );
352 }
353 }
354
355 fn check_duplicate_name(
358 &mut self,
359 name: &str,
360 position: usize,
361 kind: &str,
362 span: Option<Span>,
363 ) {
364 let duplicate = match kind {
365 "global variable" => self
366 .program
367 .global_variables
368 .iter()
369 .enumerate()
370 .take(position)
371 .any(|(_, other)| other.name == name),
372 "player variable" => self
373 .program
374 .player_variables
375 .iter()
376 .enumerate()
377 .take(position)
378 .any(|(_, other)| other.name == name),
379 _ => self
380 .program
381 .subroutines
382 .iter()
383 .enumerate()
384 .take(position)
385 .any(|(_, other)| other.name == name),
386 };
387 if duplicate {
388 self.issue(
389 "unsupported-duplicate-name",
390 format!("duplicate {kind} name '{name}'"),
391 span,
392 );
393 }
394 }
395
396 fn classify_rules(&mut self) -> RuleLayout<'a> {
401 let rules: Vec<&wir::Rule> = self.program.rules.iter().collect();
402 let mut index = 0;
403 let mut global_init = None;
404 let mut player_init = None;
405 if let Some(rule) = rules.first() {
406 if rule.name == "Initialize global variables" {
407 global_init = self.canonical_init(rule, true);
408 index = 1;
409 } else if rule.name == "Initialize player variables" {
410 player_init = self.canonical_init(rule, false);
411 index = 1;
412 }
413 }
414 if index == 1 {
415 if let Some(rule) = rules.get(1) {
416 if rule.name == "Initialize player variables" && global_init.is_some() {
417 player_init = self.canonical_init(rule, false);
418 index = 2;
419 }
420 }
421 }
422
423 let mut sub_rules = Vec::new();
424 let mut normal_rules = Vec::new();
425 let mut in_sub_rules = true;
426 for rule in rules.iter().copied().skip(index) {
427 match &rule.event {
428 Event::Subroutine(_) => {
429 if !in_sub_rules {
430 self.issue(
431 "unsupported-rule-order",
432 format!(
433 "subroutine-body rule '{}' appears after a normal rule; \
434 the frontend re-lowering emits subroutine rules first",
435 rule.name
436 ),
437 rule.span,
438 );
439 }
440 if !rule.conditions.is_empty() {
441 self.issue(
442 "unsupported-rule-order",
443 format!(
444 "subroutine-body rule '{}' carries conditions; `def` \
445 bodies cannot express them",
446 rule.name
447 ),
448 rule.span,
449 );
450 }
451 sub_rules.push(rule);
452 }
453 _ => {
454 in_sub_rules = false;
455 normal_rules.push(rule);
456 }
457 }
458 }
459
460 let mut expected = 0usize;
463 for rule in &sub_rules {
464 let Event::Subroutine(subroutine) = &rule.event else {
465 continue;
466 };
467 if subroutine.index() != expected {
468 self.issue(
469 "unsupported-rule-order",
470 format!(
471 "subroutine-body rules must appear in subroutine table order; \
472 '{}' is out of order",
473 rule.name
474 ),
475 rule.span,
476 );
477 }
478 expected += 1;
479 if let Some(definition) = self.program.subroutines.get(*subroutine) {
480 let expected_name = format!("Subroutine {}", definition.name);
481 if rule.name != expected_name {
482 self.issue(
483 "unsupported-rule-order",
484 format!(
485 "subroutine-body rule name '{}' does not match the def \
486 form '{}' the frontend synthesizes",
487 rule.name, expected_name
488 ),
489 rule.span,
490 );
491 }
492 }
493 }
494
495 RuleLayout {
496 global_init,
497 player_init,
498 sub_rules,
499 normal_rules,
500 }
501 }
502
503 fn canonical_init(&mut self, rule: &wir::Rule, global: bool) -> Option<Vec<wir::ActionId>> {
507 let expected_name = if global {
508 "Initialize global variables"
509 } else {
510 "Initialize player variables"
511 };
512 if !rule.conditions.is_empty() {
513 self.issue(
514 "unsupported-init-rule",
515 format!(
516 "initializer rule '{expected_name}' carries conditions; the \
517 frontend synthesizes it from declarations with none"
518 ),
519 rule.span,
520 );
521 return None;
522 }
523 let mut actions = Vec::with_capacity(rule.actions.len());
524 for action in &rule.actions {
525 let Some(node) = self.program.actions.get(*action) else {
526 self.issue("unsupported-dangling", "dangling action id", rule.span);
527 return None;
528 };
529 let set = matches!(
530 (global, node),
531 (true, Action::SetGlobalVariable { .. })
532 | (false, Action::SetPlayerVariable { .. })
533 );
534 if !set {
535 self.issue(
536 "unsupported-init-rule",
537 format!(
538 "initializer rule '{expected_name}' mixes non-Set actions; \
539 the frontend's synthesized initializer rule is all-Set"
540 ),
541 node.span(),
542 );
543 return None;
544 }
545 actions.push(*action);
546 }
547 Some(actions)
548 }
549
550 fn emit_program(&mut self, layout: &RuleLayout) {
553 let global_initializers = self.collect_global_initializers(&layout.global_init);
554 let player_initializers = self.collect_player_initializers(&layout.player_init);
555 self.check_initializer_slot(&global_initializers);
556
557 for (position, variable) in self.program.global_variables.iter().enumerate() {
559 self.out.push_str("globalvar ");
560 self.out.push_str(&variable.name);
561 match global_initializers.get(&position) {
562 Some(value) => {
563 self.out.push_str(" = ");
564 self.emit_initializer(*value);
565 }
566 None => {
567 self.out.push(' ');
568 self.out.push_str(&variable.index.to_string());
569 }
570 }
571 self.out.push('\n');
572 }
573 for (position, variable) in self.program.player_variables.iter().enumerate() {
574 self.out.push_str("playervar ");
575 self.out.push_str(&variable.name);
576 match player_initializers.get(&position) {
577 Some(value) => {
578 self.out.push_str(" = ");
579 self.emit_initializer(*value);
580 }
581 None => {
582 self.out.push(' ');
583 self.out.push_str(&variable.index.to_string());
584 }
585 }
586 self.out.push('\n');
587 }
588 if self.program.subroutines.is_empty() {
589 self.out.push('\n');
590 } else {
591 for subroutine in self.program.subroutines.iter() {
592 self.out.push_str("subroutine ");
593 self.out.push_str(&subroutine.name);
594 self.out.push('\n');
595 }
596 self.out.push('\n');
597 }
598
599 for rule in &layout.sub_rules {
601 let Event::Subroutine(subroutine) = &rule.event else {
602 continue;
603 };
604 let Some(definition) = self.program.subroutines.get(*subroutine) else {
605 continue;
606 };
607 self.out.push_str("def ");
608 self.out.push_str(&definition.name);
609 self.out.push_str("():\n");
610 self.emit_actions(&rule.actions, 1);
611 self.out.push('\n');
612 }
613
614 for rule in &layout.normal_rules {
616 if rule.disabled {
617 self.issue(
618 "unsupported-disabled-rule",
619 format!(
620 "rule '{}' is disabled; the OPY surface cannot express it",
621 rule.name
622 ),
623 rule.span,
624 );
625 continue;
626 }
627 if rule.actions.is_empty() {
628 continue;
629 }
630 self.out.push_str("rule \"");
631 self.out.push_str(&rule.name);
632 self.out.push_str("\":\n");
633 match &rule.event {
634 Event::Global => self.out.push_str(" @Event global\n"),
635 Event::EachPlayer => self.out.push_str(" @Event eachPlayer\n"),
636 Event::EachPlayerWithFilters {
637 team: workshop_rs::wir::EventTeam::All,
638 target: workshop_rs::wir::EventTarget::All,
639 } => self.out.push_str(" @Event eachPlayer\n"),
640 Event::EachPlayerWithFilters { .. } | Event::Player { .. } => {
641 self.issue(
642 "unsupported-rule-event",
643 format!("rule '{}' uses an event outside the OPY surface", rule.name),
644 rule.span,
645 );
646 continue;
647 }
648 Event::Subroutine(_) => {
649 self.issue(
650 "unsupported-rule-order",
651 format!(
652 "rule '{}' has a subroutine event outside the def layout",
653 rule.name
654 ),
655 rule.span,
656 );
657 continue;
658 }
659 }
660 for condition in &rule.conditions {
661 self.out.push_str(" @Condition ");
662 self.emit_value(*condition);
663 self.out.push('\n');
664 }
665 self.emit_actions(&rule.actions, 1);
666 self.out.push('\n');
667 }
668 }
669
670 fn collect_global_initializers(
674 &mut self,
675 actions: &Option<Vec<wir::ActionId>>,
676 ) -> std::collections::HashMap<usize, wir::ValueId> {
677 let mut initializers = std::collections::HashMap::new();
678 let Some(actions) = actions else {
679 return initializers;
680 };
681 let mut previous: Option<usize> = None;
682 for action in actions {
683 let span = self
684 .program
685 .actions
686 .get(*action)
687 .and_then(|node| node.span());
688 let Some(Action::SetGlobalVariable {
689 variable, value, ..
690 }) = self.program.actions.get(*action)
691 else {
692 continue;
693 };
694 let variable_position = variable.index();
695 let name = self
696 .program
697 .global_variables
698 .get(*variable)
699 .map(|variable| variable.name.clone())
700 .unwrap_or_default();
701 if let Some(previous_position) = previous {
702 if variable_position <= previous_position {
703 self.issue(
704 "unsupported-init-rule",
705 format!(
706 "initializer rule Sets '{name}' out of global table order; \
707 the frontend synthesizes initializers in declaration order"
708 ),
709 span,
710 );
711 }
712 }
713 previous = Some(variable_position);
714 initializers.insert(variable_position, *value);
715 }
716 initializers
717 }
718
719 fn collect_player_initializers(
720 &mut self,
721 actions: &Option<Vec<wir::ActionId>>,
722 ) -> std::collections::HashMap<usize, wir::ValueId> {
723 let mut initializers = std::collections::HashMap::new();
724 let Some(actions) = actions else {
725 return initializers;
726 };
727 let mut previous: Option<usize> = None;
728 for action in actions {
729 let span = self
730 .program
731 .actions
732 .get(*action)
733 .and_then(|node| node.span());
734 let Some(Action::SetPlayerVariable {
735 player,
736 variable,
737 value,
738 ..
739 }) = self.program.actions.get(*action)
740 else {
741 continue;
742 };
743 if !self.is_event_player(*player) {
744 self.issue(
745 "unsupported-init-rule",
746 "player initializer targets a non-event-player expression",
747 span,
748 );
749 }
750 let variable_position = variable.index();
751 let name = self
752 .program
753 .player_variables
754 .get(*variable)
755 .map(|variable| variable.name.clone())
756 .unwrap_or_default();
757 if let Some(previous_position) = previous {
758 if variable_position <= previous_position {
759 self.issue(
760 "unsupported-init-rule",
761 format!(
762 "initializer rule Sets '{name}' out of player table order; \
763 the frontend synthesizes initializers in declaration order"
764 ),
765 span,
766 );
767 }
768 }
769 previous = Some(variable_position);
770 initializers.insert(variable_position, *value);
771 }
772 initializers
773 }
774
775 fn emit_initializer(&mut self, value: wir::ValueId) {
779 let Some(node) = self.program.values.get(value) else {
780 self.issue("unsupported-dangling", "dangling value id", None);
781 return;
782 };
783 if let Value::Number { value: number, .. } = &node.value {
784 if *number == 0.0 {
785 self.out.push_str("0.0");
786 return;
787 }
788 }
789 self.emit_value(value);
790 }
791
792 fn check_initializer_slot(
797 &mut self,
798 initializers: &std::collections::HashMap<usize, wir::ValueId>,
799 ) {
800 let mut taken: std::collections::HashSet<u32> = std::collections::HashSet::new();
801 for (position, variable) in self.program.global_variables.iter().enumerate() {
802 if initializers.contains_key(&position) {
803 let mut next_free = 0u32;
804 while taken.contains(&next_free) {
805 next_free += 1;
806 }
807 if next_free != variable.index {
808 self.issues.push(ReconstructIssue {
809 code: "unsupported-indexed-initializer",
810 message: format!(
811 "initializer-bearing global '{}' occupies slot {} but the \
812 OPY `globalvar name = value` form assigns the lowest free \
813 slot ({}) on re-lowering",
814 variable.name, variable.index, next_free
815 ),
816 span: variable.span,
817 });
818 }
819 taken.insert(next_free);
820 } else {
821 taken.insert(variable.index);
822 }
823 }
824 }
825
826 fn emit_actions(&mut self, actions: &[wir::ActionId], level: usize) {
827 for action in actions {
828 self.emit_action(*action, level);
829 }
830 }
831
832 fn indent(level: usize) -> String {
833 " ".repeat(level)
834 }
835
836 fn emit_action(&mut self, id: wir::ActionId, level: usize) {
837 let Some(node) = self.program.actions.get(id) else {
838 self.issue("unsupported-dangling", "dangling action id", None);
839 return;
840 };
841 let span = node.span();
842 let indent = Self::indent(level);
843 match node {
844 Action::SetGlobalVariable {
845 variable, value, ..
846 } => {
847 let variable_id = *variable;
848 let Some(variable) = self.program.global_variables.get(variable_id) else {
849 self.issue("unsupported-dangling", "dangling global variable id", span);
850 return;
851 };
852 if self.set_has_modify_pattern(*value, variable_id.index(), true) {
853 self.issue(
854 "unsupported-set-binary",
855 format!(
856 "Set Global Variable('{}', <binary over the same variable>) \
857 re-lowers to a Modify action; emit the modify form",
858 variable.name
859 ),
860 span,
861 );
862 return;
863 }
864 self.out.push_str(&indent);
865 self.out.push_str(&variable.name);
866 self.out.push_str(" = ");
867 self.emit_value(*value);
868 self.out.push('\n');
869 }
870 Action::ModifyGlobalVariable {
871 variable,
872 op,
873 value,
874 ..
875 } => {
876 let Some(variable) = self.program.global_variables.get(*variable) else {
877 self.issue("unsupported-dangling", "dangling global variable id", span);
878 return;
879 };
880 self.emit_modify(level, &variable.name, *op, *value, span);
881 }
882 Action::SetPlayerVariable {
883 player,
884 variable,
885 value,
886 ..
887 } => {
888 let variable_id = *variable;
889 let Some(variable) = self.program.player_variables.get(variable_id) else {
890 self.issue("unsupported-dangling", "dangling player variable id", span);
891 return;
892 };
893 if !self.is_event_player(*player) {
894 self.issue(
895 "unsupported-arbitrary-player-target",
896 "Set Player Variable targets a non-event-player expression; \
897 the OPY surface only exposes eventPlayer.member"
898 .to_string(),
899 span,
900 );
901 return;
902 }
903 if self.set_has_modify_pattern(*value, variable_id.index(), false) {
904 self.issue(
905 "unsupported-set-binary",
906 format!(
907 "Set Player Variable('{}', <binary over the same variable>) \
908 re-lowers to a Modify action; emit the modify form",
909 variable.name
910 ),
911 span,
912 );
913 return;
914 }
915 self.out.push_str(&indent);
916 self.out.push_str("eventPlayer.");
917 self.out.push_str(&variable.name);
918 self.out.push_str(" = ");
919 self.emit_value(*value);
920 self.out.push('\n');
921 }
922 Action::ModifyPlayerVariable {
923 player,
924 variable,
925 op,
926 value,
927 ..
928 } => {
929 let Some(variable) = self.program.player_variables.get(*variable) else {
930 self.issue("unsupported-dangling", "dangling player variable id", span);
931 return;
932 };
933 if !self.is_event_player(*player) {
934 self.issue(
935 "unsupported-arbitrary-player-target",
936 "Modify Player Variable targets a non-event-player expression; \
937 the OPY surface only exposes eventPlayer.member"
938 .to_string(),
939 span,
940 );
941 return;
942 }
943 self.emit_modify(
944 level,
945 &format!("eventPlayer.{}", variable.name),
946 *op,
947 *value,
948 span,
949 );
950 }
951 Action::AssignMember { span, .. } => {
952 self.issue(
953 "unsupported-member-assignment",
954 "dynamic member assignments are outside the OPY reconstruction surface",
955 *span,
956 );
957 }
958 Action::CallSubroutine {
959 subroutine, span, ..
960 } => {
961 let Some(subroutine) = self.program.subroutines.get(*subroutine) else {
962 self.issue("unsupported-dangling", "dangling subroutine id", *span);
963 return;
964 };
965 self.out.push_str(&indent);
966 self.out.push_str(&subroutine.name);
967 self.out.push_str("()\n");
968 }
969 Action::If {
970 branches,
971 else_body,
972 span,
973 } => {
974 for (index, branch) in branches.iter().enumerate() {
975 let keyword = if index == 0 { "if" } else { "elif" };
976 self.out.push_str(&indent);
977 self.out.push_str(keyword);
978 self.out.push(' ');
979 self.emit_value(branch.condition);
980 self.out.push_str(":\n");
981 self.emit_actions(&branch.body, level + 1);
982 }
983 if let Some(else_body) = else_body {
984 self.out.push_str(&indent);
985 self.out.push_str("else:\n");
986 self.emit_actions(else_body, level + 1);
987 }
988 let _ = span;
989 }
990 Action::While {
991 condition,
992 body,
993 span,
994 } => {
995 self.out.push_str(&indent);
996 self.out.push_str("while ");
997 self.emit_value(*condition);
998 self.out.push_str(":\n");
999 self.emit_actions(body, level + 1);
1000 let _ = span;
1001 }
1002 Action::ForGlobalVariable {
1003 variable,
1004 start,
1005 stop,
1006 step,
1007 body,
1008 span,
1009 ..
1010 } => {
1011 let Some(variable) = self.program.global_variables.get(*variable) else {
1012 self.issue("unsupported-dangling", "dangling loop variable id", *span);
1013 return;
1014 };
1015 self.out.push_str(&indent);
1016 self.out.push_str("for ");
1017 self.out.push_str(&variable.name);
1018 self.out.push_str(" in range(");
1019 self.emit_value(*start);
1020 self.out.push_str(", ");
1021 self.emit_value(*stop);
1022 self.out.push_str(", ");
1023 self.emit_value(*step);
1024 self.out.push_str("):\n");
1025 self.emit_actions(body, level + 1);
1026 }
1027 Action::ForPlayerVariable { span, .. } => {
1028 self.issue(
1029 "unsupported-per-player-loop",
1030 "For Player Variable is outside the reconstruction surface \
1031 (the OPY `for` form binds a global variable)",
1032 *span,
1033 );
1034 }
1035 Action::Debug { value, span } => {
1036 self.out.push_str(&indent);
1037 self.out.push_str("debug(");
1038 self.emit_value(*value);
1039 self.out.push_str(")\n");
1040 let _ = span;
1041 }
1042 Action::Print { message, span } => {
1043 self.out.push_str(&indent);
1044 self.out.push_str("print(");
1045 self.emit_value(*message);
1046 self.out.push_str(")\n");
1047 let _ = span;
1048 }
1049 Action::Call { name, args, span } => {
1050 self.emit_call_action(name, args, &indent, *span);
1051 }
1052 }
1053 }
1054
1055 fn set_has_modify_pattern(
1058 &self,
1059 value: wir::ValueId,
1060 variable_index: usize,
1061 global: bool,
1062 ) -> bool {
1063 let Some(node) = self.program.values.get(value) else {
1064 return false;
1065 };
1066 let Value::Call { name, args } = &node.value else {
1067 return false;
1068 };
1069 if !matches!(name.as_str(), "+" | "-" | "*" | "/" | "%" | "**") {
1070 return false;
1071 }
1072 if args.len() != 2 {
1073 return false;
1074 }
1075 args.iter().any(|operand| {
1076 let Some(node) = self.program.values.get(*operand) else {
1077 return false;
1078 };
1079 if global {
1080 matches!(node.value, Value::GlobalVariable(id) if id.index() == variable_index)
1081 } else {
1082 matches!(
1083 node.value,
1084 Value::PlayerVariable { variable: id, .. } if id.index() == variable_index
1085 )
1086 }
1087 })
1088 }
1089
1090 fn is_event_player(&self, value: wir::ValueId) -> bool {
1092 matches!(
1093 self.program.values.get(value).map(|node| &node.value),
1094 Some(Value::EventPlayer)
1095 )
1096 }
1097
1098 fn emit_modify(
1099 &mut self,
1100 level: usize,
1101 name: &str,
1102 op: ModifyOp,
1103 value: wir::ValueId,
1104 span: Option<Span>,
1105 ) {
1106 let indent = Self::indent(level);
1107 match op {
1108 ModifyOp::AppendToArray => {
1109 self.out.push_str(&indent);
1110 self.out.push_str(name);
1111 self.out.push_str(".append(");
1112 self.emit_value(value);
1113 self.out.push_str(")\n");
1114 }
1115 ModifyOp::RemoveFromArray => {
1116 self.issue(
1117 "unsupported-modify-op",
1118 "Modify ... Remove From Array is outside the reconstruction surface \
1119 (the OPY surface has no remove-from-array form)",
1120 span,
1121 );
1122 }
1123 ModifyOp::RemoveFromArrayByIndex => {
1124 self.issue(
1125 "unsupported-modify-op",
1126 "Modify ... Remove From Array By Index is outside the reconstruction \
1127 surface (the OPY surface has no indexed remove-from-array form)",
1128 span,
1129 );
1130 }
1131 ModifyOp::Min | ModifyOp::Max => {
1132 self.issue(
1133 "unsupported-modify-op",
1134 format!(
1135 "Modify ... {} is outside the reconstruction surface \
1136 (the OPY surface has no equivalent modification form)",
1137 op.as_str()
1138 ),
1139 span,
1140 );
1141 }
1142 ModifyOp::Add
1143 | ModifyOp::Subtract
1144 | ModifyOp::Multiply
1145 | ModifyOp::Divide
1146 | ModifyOp::Modulo
1147 | ModifyOp::RaiseToPower => {
1148 let operator = match op {
1149 ModifyOp::Add => "+",
1150 ModifyOp::Subtract => "-",
1151 ModifyOp::Multiply => "*",
1152 ModifyOp::Divide => "/",
1153 ModifyOp::Modulo => "%",
1154 ModifyOp::RaiseToPower => "**",
1155 _ => unreachable!(),
1156 };
1157 self.out.push_str(&indent);
1158 self.out.push_str(name);
1159 self.out.push_str(" = ");
1160 self.out.push_str(name);
1161 self.out.push(' ');
1162 self.out.push_str(operator);
1163 self.out.push(' ');
1164 self.emit_value(value);
1165 self.out.push('\n');
1166 }
1167 }
1168 }
1169
1170 fn emit_call_action(
1172 &mut self,
1173 name: &str,
1174 args: &[wir::ValueId],
1175 indent: &str,
1176 span: Option<Span>,
1177 ) {
1178 if DEDICATED_ACTION_NAMES.contains(&name) {
1179 self.issue(
1180 "unsupported-action-call",
1181 format!(
1182 "action call '{name}' is lowered to a dedicated WIR node by the \
1183 OPY frontend and has no reconstructible call form"
1184 ),
1185 span,
1186 );
1187 return;
1188 }
1189 let Some(entry) = self.manifest.resolve_function(name) else {
1190 match self.manifest.resolve_member(name) {
1191 Some(entry) if entry.kind.is_action() => {
1192 self.emit_member_call(entry, args, indent, span);
1193 }
1194 Some(_) => {
1195 self.issue(
1196 "unsupported-action-call",
1197 format!(
1198 "member value '{name}' cannot be emitted as an action on \
1199 the reconstruction surface"
1200 ),
1201 span,
1202 );
1203 }
1204 None => {
1205 self.issue(
1206 "unsupported-action-call",
1207 format!(
1208 "action call '{name}' has no OPY source form on the \
1209 reconstruction surface"
1210 ),
1211 span,
1212 );
1213 }
1214 }
1215 return;
1216 };
1217 if !entry.kind.is_action() {
1218 self.issue(
1219 "unsupported-action-call",
1220 format!(
1221 "value function '{name}' cannot be emitted as an action on \
1222 the reconstruction surface"
1223 ),
1224 span,
1225 );
1226 return;
1227 }
1228 if args.is_empty() && self.subroutine_names.contains(name) {
1229 self.issue(
1230 "unsupported-action-call",
1231 format!(
1232 "action '{name}' with no arguments is ambiguous with a subroutine \
1233 of the same name on the OPY surface"
1234 ),
1235 span,
1236 );
1237 return;
1238 }
1239 self.out.push_str(indent);
1240 self.emit_manifest_call(entry, args, false, span);
1241 self.out.push('\n');
1242 }
1243
1244 fn emit_manifest_call(
1250 &mut self,
1251 entry: &Function,
1252 args: &[wir::ValueId],
1253 member: bool,
1254 span: Option<Span>,
1255 ) {
1256 let (receiver, params) = if member {
1257 match args.split_first() {
1258 Some((receiver, rest)) => (Some(*receiver), rest),
1259 None => {
1260 self.issue(
1261 "unsupported-invalid-arity",
1262 format!("member '{}' requires a receiver argument", entry.id),
1263 span,
1264 );
1265 return;
1266 }
1267 }
1268 } else {
1269 (None, args)
1270 };
1271 let name = entry.id.as_str();
1272 if params.len() > entry.params.len() {
1273 self.issue(
1274 "unsupported-invalid-arity",
1275 format!(
1276 "{} '{}' expects at most {} arguments but the WIR carries {}",
1277 kind_label(entry.kind),
1278 name,
1279 entry.params.len(),
1280 params.len()
1281 ),
1282 span,
1283 );
1284 return;
1285 }
1286 for (_index, param) in entry.params.iter().enumerate().skip(params.len()) {
1291 if !param.optional {
1292 self.issue(
1293 "unsupported-missing-argument",
1294 format!(
1295 "{} '{}' omits parameter '{}'; the OPY frontend would \
1296 reject or default-fill it and change the recompiled WIR",
1297 kind_label(entry.kind),
1298 name,
1299 param.name
1300 ),
1301 span,
1302 );
1303 }
1304 }
1305
1306 if let Some(receiver) = receiver {
1307 self.emit_value(receiver);
1308 self.out.push('.');
1309 }
1310 self.out.push_str(name);
1311 self.out.push('(');
1312 if let Some(catalog_id) = &entry.catalog_id {
1318 let expected_kind = match entry.kind {
1319 FunctionKind::Action | FunctionKind::MemberAction => {
1320 workshop_rs::catalog::Kind::Action
1321 }
1322 FunctionKind::Value | FunctionKind::MemberValue => {
1323 workshop_rs::catalog::Kind::Value
1324 }
1325 };
1326 if self
1327 .catalog
1328 .spelling(expected_kind, self.locale, catalog_id)
1329 .is_none()
1330 {
1331 self.issue(
1332 "catalog-error",
1333 format!(
1334 "manifest entry '{}' links catalogId '{catalog_id}' which is \
1335 missing from the Workshop catalog",
1336 entry.id
1337 ),
1338 span,
1339 );
1340 }
1341 }
1342 for (index, arg) in params.iter().enumerate() {
1343 if index > 0 {
1344 self.out.push_str(", ");
1345 }
1346 self.check_param_argument(entry, index, *arg, span);
1347 self.emit_value(*arg);
1348 }
1349 self.out.push(')');
1350 }
1351
1352 fn emit_member_call(
1354 &mut self,
1355 entry: &Function,
1356 args: &[wir::ValueId],
1357 indent: &str,
1358 span: Option<Span>,
1359 ) {
1360 self.out.push_str(indent);
1361 self.emit_manifest_call(entry, args, true, span);
1362 self.out.push('\n');
1363 }
1364
1365 fn check_param_argument(
1369 &mut self,
1370 entry: &Function,
1371 index: usize,
1372 arg: wir::ValueId,
1373 span: Option<Span>,
1374 ) {
1375 let Some(param) = entry.params.get(index) else {
1376 return;
1377 };
1378 let Some(node) = self.program.values.get(arg) else {
1379 return;
1380 };
1381 if let Some(domain) = ¶m.domain {
1382 match &node.value {
1383 Value::Enum { value_type, value } if value_type == domain => {
1384 if !self.enum_member_in_domain(domain, value) {
1385 self.issue(
1386 "unsupported-enum-member",
1387 format!(
1388 "argument {} of '{}' uses enum member '{domain}.{value}' \
1389 which is outside the manifest's declared domain",
1390 index + 1,
1391 entry.id
1392 ),
1393 span,
1394 );
1395 }
1396 }
1397 Value::Enum { value_type, .. } => {
1398 self.issue(
1399 "unsupported-enum-domain-mismatch",
1400 format!(
1401 "argument {} of '{}' expects enum domain '{domain}' but \
1402 the WIR carries '{value_type}'",
1403 index + 1,
1404 entry.id
1405 ),
1406 span,
1407 );
1408 }
1409 _ => {
1410 self.issue(
1411 "unsupported-enum-domain-mismatch",
1412 format!(
1413 "argument {} of '{}' expects an enum member of domain \
1414 '{domain}'",
1415 index + 1,
1416 entry.id
1417 ),
1418 span,
1419 );
1420 }
1421 }
1422 }
1423 if param.variable {
1424 let is_variable = matches!(
1425 node.value,
1426 Value::GlobalVariable(_) | Value::PlayerVariable { .. }
1427 );
1428 if !is_variable {
1429 self.issue(
1430 "unsupported-invalid-argument",
1431 format!(
1432 "argument {} of '{}' must be a variable reference",
1433 index + 1,
1434 entry.id
1435 ),
1436 span,
1437 );
1438 }
1439 }
1440 }
1441
1442 fn enum_member_in_domain(&self, domain: &str, member: &str) -> bool {
1443 self.catalog.enum_domain(domain).is_some_and(|domain| {
1444 domain
1445 .members
1446 .iter()
1447 .any(|candidate| candidate.member == member)
1448 })
1449 }
1450
1451 fn emit_value(&mut self, id: wir::ValueId) {
1454 let Some(node) = self.program.values.get(id) else {
1455 self.issue("unsupported-dangling", "dangling value id", None);
1456 return;
1457 };
1458 match &node.value {
1459 Value::Number { value, .. } => {
1460 if !value.is_finite() {
1461 self.issue(
1462 "unsupported-non-finite-number",
1463 format!("non-finite number literal '{value}' has no OPY spelling"),
1464 node.span,
1465 );
1466 } else if *value < 0.0 {
1467 self.issue(
1468 "unsupported-negative-number",
1469 format!(
1470 "negative number literal '{}' has no OPY literal form \
1471 (the lexer has no negative-number token)",
1472 workshop_rs::format::format_number(*value)
1473 ),
1474 node.span,
1475 );
1476 } else {
1477 self.out
1478 .push_str(&workshop_rs::format::format_number(*value));
1479 }
1480 }
1481 Value::String(value) => self.emit_string_literal(value),
1482 Value::LocalizedString(value) => {
1483 self.issue(
1484 "unsupported-localized-string",
1485 format!("localized Workshop preset string '{value}' has no OPY source representation"),
1486 node.span,
1487 );
1488 }
1489 Value::Bool(value) => {
1490 self.out.push_str(if *value { "true" } else { "false" });
1491 }
1492 Value::Null => {
1493 self.out.push_str("None");
1494 }
1495 Value::Array(elements) => {
1496 self.out.push('[');
1497 for (index, element) in elements.iter().enumerate() {
1498 if index > 0 {
1499 self.out.push_str(", ");
1500 }
1501 self.emit_value(*element);
1502 }
1503 self.out.push(']');
1504 }
1505 Value::Vector { x, y, z } => {
1506 self.out.push_str("vect(");
1507 self.emit_value(*x);
1508 self.out.push_str(", ");
1509 self.emit_value(*y);
1510 self.out.push_str(", ");
1511 self.emit_value(*z);
1512 self.out.push(')');
1513 }
1514 Value::Enum { value_type, value } => {
1515 self.emit_enum(value_type, value, node.span);
1516 }
1517 Value::GlobalVariable(variable) => {
1518 let Some(variable) = self.program.global_variables.get(*variable) else {
1519 self.issue(
1520 "unsupported-dangling",
1521 "dangling global variable id",
1522 node.span,
1523 );
1524 return;
1525 };
1526 self.out.push_str(&variable.name);
1527 }
1528 Value::PlayerVariable { player, variable } => {
1529 if !self.is_event_player(*player) {
1530 self.issue(
1531 "unsupported-arbitrary-player-target",
1532 "a player-variable access on a non-event-player expression is \
1533 outside the reconstruction surface (only eventPlayer.member \
1534 is representable)",
1535 node.span,
1536 );
1537 return;
1538 }
1539 let Some(variable) = self.program.player_variables.get(*variable) else {
1540 self.issue(
1541 "unsupported-dangling",
1542 "dangling player variable id",
1543 node.span,
1544 );
1545 return;
1546 };
1547 self.out.push_str("eventPlayer.");
1548 self.out.push_str(&variable.name);
1549 }
1550 Value::Subroutine(_) => {
1551 self.issue(
1552 "unsupported-subroutine-value",
1553 "subroutine values are outside the OPY reconstruction surface",
1554 node.span,
1555 );
1556 }
1557 Value::EventPlayer => {
1558 self.out.push_str("eventPlayer");
1559 }
1560 Value::Call { name, args } => {
1561 self.emit_value_call(name, args, node.span);
1562 }
1563 }
1564 }
1565
1566 fn emit_enum(&mut self, value_type: &str, value: &str, span: Option<Span>) {
1567 let Some(domain) = self.catalog.enum_domain(value_type) else {
1568 self.issue(
1569 "unsupported-enum-domain",
1570 format!(
1571 "enum domain '{value_type}' is outside the manifest's declared \
1572 reconstruction surface"
1573 ),
1574 span,
1575 );
1576 return;
1577 };
1578 if !domain.members.iter().any(|member| member.member == value) {
1579 self.issue(
1580 "unsupported-enum-member",
1581 format!(
1582 "enum member '{value_type}.{value}' is outside the manifest's \
1583 declared domain"
1584 ),
1585 span,
1586 );
1587 return;
1588 }
1589 self.out.push_str(value_type);
1590 self.out.push('.');
1591 self.out.push_str(value);
1592 }
1593
1594 fn emit_value_call(&mut self, name: &str, args: &[wir::ValueId], span: Option<Span>) {
1595 if BINARY_OPS.contains(&name) && args.len() == 2 {
1597 self.out.push('(');
1598 self.emit_value(args[0]);
1599 self.out.push(' ');
1600 self.out.push_str(name);
1601 self.out.push(' ');
1602 self.emit_value(args[1]);
1603 self.out.push(')');
1604 return;
1605 }
1606 if name == "not" && args.len() == 1 {
1607 self.out.push_str("(not ");
1608 self.emit_value(args[0]);
1609 self.out.push(')');
1610 return;
1611 }
1612 if name == "-" && args.len() == 1 {
1613 self.out.push_str("(-");
1614 self.emit_value(args[0]);
1615 self.out.push(')');
1616 return;
1617 }
1618 if name == "format" {
1620 let Some(first) = args.first() else {
1621 self.issue(
1622 "unsupported-value-call",
1623 "format call without a receiver is outside the reconstruction surface",
1624 span,
1625 );
1626 return;
1627 };
1628 let Some(Value::String(text)) = self.program.values.get(*first).map(|node| &node.value)
1629 else {
1630 self.issue(
1631 "unsupported-value-call",
1632 "format call without a string receiver is outside the \
1633 reconstruction surface",
1634 span,
1635 );
1636 return;
1637 };
1638 self.emit_string_literal(text);
1639 self.out.push_str(".format(");
1640 for (index, arg) in args.iter().skip(1).enumerate() {
1641 if index > 0 {
1642 self.out.push_str(", ");
1643 }
1644 self.emit_value(*arg);
1645 }
1646 self.out.push(')');
1647 return;
1648 }
1649 if DEDICATED_VALUE_NAMES.contains(&name) {
1650 self.issue(
1651 "unsupported-value-call",
1652 format!(
1653 "value call '{name}' is lowered to a dedicated WIR node by the \
1654 OPY frontend and has no reconstructible call form"
1655 ),
1656 span,
1657 );
1658 return;
1659 }
1660 let Some(entry) = self.manifest.resolve_function(name) else {
1661 match self.manifest.resolve_member(name) {
1662 Some(entry) if entry.kind.is_value() => {
1663 self.emit_manifest_call(entry, args, true, span);
1664 }
1665 Some(_) => {
1666 self.issue(
1667 "unsupported-value-call",
1668 format!(
1669 "member action '{name}' cannot be emitted as a value on \
1670 the reconstruction surface"
1671 ),
1672 span,
1673 );
1674 }
1675 None => {
1676 self.issue(
1677 "unsupported-value-call",
1678 format!(
1679 "value call '{name}' has no OPY source form on the \
1680 reconstruction surface"
1681 ),
1682 span,
1683 );
1684 }
1685 }
1686 return;
1687 };
1688 if !entry.kind.is_value() {
1689 self.issue(
1690 "unsupported-value-call",
1691 format!(
1692 "action function '{name}' cannot be emitted as a value on the \
1693 reconstruction surface"
1694 ),
1695 span,
1696 );
1697 return;
1698 }
1699 if entry.context.is_some() {
1700 self.issue(
1701 "unsupported-value-call",
1702 format!(
1703 "value call '{name}' is only valid as a for-loop iterable on \
1704 the OPY surface"
1705 ),
1706 span,
1707 );
1708 return;
1709 }
1710 self.emit_manifest_call(entry, args, false, span);
1711 }
1712
1713 fn emit_string_literal(&mut self, value: &str) {
1714 self.out.push('"');
1715 for ch in value.chars() {
1716 match ch {
1717 '\\' => self.out.push_str("\\\\"),
1718 '"' => self.out.push_str("\\\""),
1719 '\n' => self.out.push_str("\\n"),
1720 '\t' => self.out.push_str("\\t"),
1721 '\r' => self.out.push_str("\\r"),
1722 other => self.out.push(other),
1723 }
1724 }
1725 self.out.push('"');
1726 }
1727}
1728
1729fn kind_label(kind: FunctionKind) -> &'static str {
1730 match kind {
1731 FunctionKind::Action => "action",
1732 FunctionKind::Value => "value",
1733 FunctionKind::MemberAction => "member action",
1734 FunctionKind::MemberValue => "member value",
1735 }
1736}