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