Skip to main content

opy_rs/compiler/
reconstruct.rs

1//! Workshop IR → OPY reconstruction (issue #124).
2//!
3//! Consumes a validated [`workshop_rs::wir::Program`] and emits deterministic,
4//! byte-stable canonical OPY source that the native `opy-rs` parser accepts
5//! and that re-lowers to a structurally equivalent WIR
6//! program under `workshop_rs::roundtrip::equivalent`.
7//!
8//! Scope and ownership:
9//!
10//! * Builtin action/value/member/enum identities resolve only through the
11//!   OPY semantic compatibility manifest ([`Manifest`]) and the Workshop
12//!   catalog ([`Catalog`]) — no new content/signature tables and no invented
13//!   OPY syntax.
14//! * Reconstructed OPY is simple low-level valid OPY: it does not recover
15//!   comments, macros, functions, or source abstractions. Names must be valid
16//!   OPY identifiers; calls must use the OPY source names the manifest
17//!   declares (`len`, `wait`, `playEffect`, …), because the frontend's own
18//!   lowering stamps those names into the recompiled WIR and the equivalence
19//!   contract compares them exactly.
20//! * Every WIR construct the frontend cannot recompile identically is
21//!   rejected with a structured [`ReconstructIssue`] naming the construct —
22//!   never partial or misleading OPY. This includes the per-player loop
23//!   form, disabled rules, arbitrary-player variable targets, negative and
24//!   non-finite number literals (the OPY lexer has no negative-literal
25//!   token), Workshop-spelled call names with no manifest source form
26//!   (`add`, `countOf`, `createBeamEffect`, …), enums outside the manifest's
27//!   declared domains, `Remove From Array` modifies, calls the frontend
28//!   lowers to dedicated nodes (`append`, `vect`), and any
29//!   rule layout the frontend's deterministic re-lowering cannot reproduce
30//!   (non-leading initializer rules, out-of-table-order subroutine rules,
31//!   unsorted global slots, non-canonical subroutine indices).
32//! * Arrays, vectors, and `format` are emitted in their OPY source forms
33//!   (`[...]`, `vect(x, y, z)`, `"text".format(...)`) from the dedicated WIR
34//!   nodes. Debug and print lower to canonical Workshop calls and are therefore
35//!   outside this reconstruction surface.
36//!
37//! Pipeline: [`reconstruct`] validates the table layout, then emits the
38//! declarations (variables, subroutines), the `def` bodies, and the rules in
39//! deterministic arena order. Any issue collected anywhere fails the whole
40//! reconstruction with all collected diagnostics.
41
42use 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/// A structured reconstruction diagnostic naming one non-representable WIR
51/// construct. Stable `code`, a human-readable `message`, and the offending
52/// source span when the WIR carries one.
53#[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/// All reconstruction failures for one program, in deterministic arena
61/// order. The emitter never returns partial output: a non-empty issue list
62/// means no OPY was produced.
63#[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
86/// Reconstruct a validated WIR program into deterministic OPY source.
87///
88/// Resolves builtin identities through the built-in OPY semantic manifest
89/// and the built-in Workshop catalog (`en-US`), the declared surface for
90/// reconstruction (issue #124). Returns an error carrying every
91/// non-representable construct diagnostic when the program cannot be
92/// reconstructed.
93pub 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
123/// The context-sensitive form of [`reconstruct`]: resolves identities through
124/// the supplied manifest and catalog. The locale selects the catalog
125/// spellings used for cross-checks (reconstruction emits OPY, which is
126/// locale-independent; `en-US` is the catalog's declared surface).
127pub 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
144/// OPY names the parser treats as keywords or literals; a WIR table name that
145/// collides with one of these can never be referenced or declared faithfully.
146const 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
171/// Whether `name` is a valid OPY identifier (the lexer's identifier rule).
172fn 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
181/// Binary operator spellings the OPY frontend lowers to `Value::Call`s with
182/// the same name (source operators, not Workshop spellings like `add`).
183const BINARY_OPS: &[&str] = &[
184    "+", "-", "*", "/", "%", "**", "==", "!=", "<", "<=", ">", ">=", "and", "or",
185];
186
187/// Call names the frontend lowers to dedicated WIR nodes (never `Call`s).
188const 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, for call-vs-subroutine ambiguity checks.
199    subroutine_names: std::collections::HashSet<String>,
200}
201
202/// The canonical rule layout the frontend's re-lowering reproduces.
203struct RuleLayout<'a> {
204    /// The leading "Initialize global variables" rule, converted to
205    /// declaration initializers.
206    global_init: Option<Vec<wir::ActionId>>,
207    /// The leading "Initialize player variables" rule, converted to
208    /// declaration initializers.
209    player_init: Option<Vec<wir::ActionId>>,
210    /// Subroutine-body rules (defs), in subroutine table order.
211    sub_rules: Vec<&'a wir::Rule>,
212    /// Everything else, in input order.
213    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    // ---- diagnostics ----
249
250    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    // ---- table validation ----
259
260    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        // Global table: unique names, valid OPY identifiers, non-decreasing
269        // slot order (the frontend's re-lowering sorts the table by index,
270        // so only slot-ordered input reproduces the same table).
271        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        // Player table: unique names and valid identifiers; player slots are
296        // explicit in the `playervar name <index>` form, so no order rule.
297        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        // Subroutine table: unique names, valid identifiers, and indices
307        // exactly equal to table position (the OPY `subroutine name`
308        // declaration cannot carry an index; the re-lowered index is the
309        // table position).
310        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    /// Whether a name repeats an earlier entry of its table (duplicates
355    /// cannot be declared or referenced faithfully on the OPY surface).
356    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    /// The canonical rule layout: optional leading initializer rules, then
396    /// all subroutine-body rules in subroutine table order, then the normal
397    /// rules. Any other arrangement cannot be reproduced by the frontend's
398    /// deterministic re-lowering and is rejected.
399    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        // Subroutine rules must be in subroutine table order, and each rule
460        // must carry the exact name the re-lowering synthesizes for its def.
461        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    /// Validate a leading initializer rule: exactly the synthesized shape
503    /// (name, event, empty conditions, all-Set actions). Returns the action
504    /// ids to convert into declaration initializers, or records an issue.
505    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    // ---- emission ----
550
551    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        // Declarations.
557        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        // Subroutine bodies.
599        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        // Rules.
614        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    /// Map initializer rule actions onto declaration positions (table order),
670    /// validating the rule's Sets are in table order like the frontend's
671    /// synthesized initializer rule.
672    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    /// A declaration initializer: same value emission, but zero literals are
775    /// spelled `0.0` because the frontend drops integer-`0` initializers
776    /// (matching the reference adapter).
777    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    /// The OPY declaration `globalvar name = value` cannot carry an explicit
792    /// slot, so the frontend re-lowering assigns the lowest free slot. An
793    /// initializer-bearing global is only representable when that slot equals
794    /// its WIR index; otherwise the reconstructed table would differ.
795    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    /// `x = x <op> v` (or the player form) re-lowers to a Modify action, so a
1041    /// Set whose value matches the pattern cannot be reconstructed as a Set.
1042    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    /// Whether a value node is the event-player pseudo-symbol.
1076    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    /// A generic or member action call in statement position.
1156    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    /// Emit a manifest function call with explicit full-arity arguments, no
1230    /// indent and no trailing newline (the caller frames the line). The OPY
1231    /// frontend fills declared defaults at recompile time, so any WIR call
1232    /// that omits a defaulted or required parameter cannot be reconstructed
1233    /// identically and is rejected.
1234    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        // Every parameter beyond the provided arguments must be omittable
1272        // (`optional`). A required parameter (with or without a declared
1273        // default) cannot be omitted: the OPY frontend would reject it or
1274        // fill its default, changing the recompiled WIR.
1275        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        // Cross-check through the Workshop catalog: a manifest entry with a
1298        // declared `catalogId` must resolve there under the matching kind and
1299        // the reconstruction locale (mirroring the manifest's own catalog
1300        // cross-check test), so the reconstruction identity layer never
1301        // drifts from the catalog.
1302        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    /// A member call: `receiver.name(args...)`.
1338    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    /// Validate a provided argument against its manifest parameter: enum
1351    /// domains are enforced (like the frontend) and `variable`-required
1352    /// parameters must be variable references.
1353    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) = &param.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    // ---- value emission ----
1437
1438    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 name == "localPlayer" && args.is_empty() {
1581            self.out.push_str(name);
1582            return;
1583        }
1584        // Binary and unary operator calls keep their source spelling.
1585        if BINARY_OPS.contains(&name) && args.len() == 2 {
1586            self.out.push('(');
1587            self.emit_value(args[0]);
1588            self.out.push(' ');
1589            self.out.push_str(name);
1590            self.out.push(' ');
1591            self.emit_value(args[1]);
1592            self.out.push(')');
1593            return;
1594        }
1595        if name == "not" && args.len() == 1 {
1596            self.out.push_str("(not ");
1597            self.emit_value(args[0]);
1598            self.out.push(')');
1599            return;
1600        }
1601        if name == "-" && args.len() == 1 {
1602            self.out.push_str("(-");
1603            self.emit_value(args[0]);
1604            self.out.push(')');
1605            return;
1606        }
1607        // The `format` special form: `"text".format(args...)`.
1608        if name == "format" {
1609            let Some(first) = args.first() else {
1610                self.issue(
1611                    "unsupported-value-call",
1612                    "format call without a receiver is outside the reconstruction surface",
1613                    span,
1614                );
1615                return;
1616            };
1617            let Some(Value::String(text)) = self.program.values.get(*first).map(|node| &node.value)
1618            else {
1619                self.issue(
1620                    "unsupported-value-call",
1621                    "format call without a string receiver is outside the \
1622                     reconstruction surface",
1623                    span,
1624                );
1625                return;
1626            };
1627            self.emit_string_literal(text);
1628            self.out.push_str(".format(");
1629            for (index, arg) in args.iter().skip(1).enumerate() {
1630                if index > 0 {
1631                    self.out.push_str(", ");
1632                }
1633                self.emit_value(*arg);
1634            }
1635            self.out.push(')');
1636            return;
1637        }
1638        if DEDICATED_VALUE_NAMES.contains(&name) {
1639            self.issue(
1640                "unsupported-value-call",
1641                format!(
1642                    "value call '{name}' is lowered to a dedicated WIR node by the \
1643                     OPY frontend and has no reconstructible call form"
1644                ),
1645                span,
1646            );
1647            return;
1648        }
1649        let Some(entry) = self.manifest.resolve_function(name) else {
1650            match self.manifest.resolve_member(name) {
1651                Some(entry) if entry.kind.is_value() => {
1652                    self.emit_manifest_call(entry, args, true, span);
1653                }
1654                Some(_) => {
1655                    self.issue(
1656                        "unsupported-value-call",
1657                        format!(
1658                            "member action '{name}' cannot be emitted as a value on \
1659                             the reconstruction surface"
1660                        ),
1661                        span,
1662                    );
1663                }
1664                None => {
1665                    self.issue(
1666                        "unsupported-value-call",
1667                        format!(
1668                            "value call '{name}' has no OPY source form on the \
1669                             reconstruction surface"
1670                        ),
1671                        span,
1672                    );
1673                }
1674            }
1675            return;
1676        };
1677        if !entry.kind.is_value() {
1678            self.issue(
1679                "unsupported-value-call",
1680                format!(
1681                    "action function '{name}' cannot be emitted as a value on the \
1682                     reconstruction surface"
1683                ),
1684                span,
1685            );
1686            return;
1687        }
1688        if entry.context.is_some() {
1689            self.issue(
1690                "unsupported-value-call",
1691                format!(
1692                    "value call '{name}' is only valid as a for-loop iterable on \
1693                     the OPY surface"
1694                ),
1695                span,
1696            );
1697            return;
1698        }
1699        self.emit_manifest_call(entry, args, false, span);
1700    }
1701
1702    fn emit_string_literal(&mut self, value: &str) {
1703        self.out.push('"');
1704        for ch in value.chars() {
1705            match ch {
1706                '\\' => self.out.push_str("\\\\"),
1707                '"' => self.out.push_str("\\\""),
1708                '\n' => self.out.push_str("\\n"),
1709                '\t' => self.out.push_str("\\t"),
1710                '\r' => self.out.push_str("\\r"),
1711                other => self.out.push(other),
1712            }
1713        }
1714        self.out.push('"');
1715    }
1716}
1717
1718fn kind_label(kind: FunctionKind) -> &'static str {
1719    match kind {
1720        FunctionKind::Action => "action",
1721        FunctionKind::Value => "value",
1722        FunctionKind::MemberAction => "member action",
1723        FunctionKind::MemberValue => "member value",
1724    }
1725}