Skip to main content

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