Skip to main content

workshop_rs/
emitter.rs

1//! Deterministic localized Workshop emitter.
2//!
3//! Serializes validated Workshop IR into localized Workshop text with a
4//! selectable output locale. Canonical catalog identities resolve to
5//! locale-specific spellings; missing target-locale mappings fail explicitly
6//! with a [`WorkshopError::MissingMapping`] diagnostic — never a guess, never
7//! a silent passthrough of another locale's spelling. Fallback to another
8//! declared locale is opt-in ([`EmitOptions`]) and every fell-back identity
9//! is recorded in [`EmitOutput::fallback_ids`]. The formatting is fixed and
10//! presentation-canonical, so the same WIR/config emits byte-stable text that
11//! reparses to equivalent WIR — except for the `settings` section:
12//! settings-bearing emissions are deliberately rejected by the Workshop
13//! parser (a `.ws` decompiler is a non-goal). Settings names are resolved from
14//! the generated locale corpus, with an explicit `en-US` fallback when needed.
15
16use std::fmt::Write;
17
18use crate::catalog::{Catalog, Kind, Locale};
19use crate::error::{Result, WorkshopError};
20use crate::format::format_number;
21use crate::settings::table::{self, KeyKind, PathPart};
22use crate::settings::{Settings as SettingsTree, SettingsNode};
23use crate::wir;
24
25/// The number of native Workshop actions emitted by a canonical WIR action
26/// sequence.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct ActionLayout {
29    /// The action count in the canonical native action stream.
30    pub width: usize,
31}
32
33/// Errors returned while querying canonical native action layout.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ActionLayoutError {
36    /// The WIR does not satisfy its structural invariants.
37    InvalidWIR(crate::wir::error::IrError),
38    /// Canonical emission could not expand the requested actions.
39    Emission(WorkshopError),
40}
41
42impl std::fmt::Display for ActionLayoutError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::InvalidWIR(error) => write!(f, "invalid WIR: {error}"),
46            Self::Emission(error) => write!(f, "action layout emission failed: {error}"),
47        }
48    }
49}
50
51impl std::error::Error for ActionLayoutError {}
52
53/// Query the native Workshop action width of a validated WIR action sequence.
54///
55/// The sequence is expanded using the same recursive action implementation as
56/// [`emit`]. Every action in the sequence is treated as non-rule-final, which
57/// is the canonical stream contract needed for relative action offsets. The
58/// returned width counts native Workshop action lines, including structural
59/// headers and terminators.
60pub fn action_width(
61    program: &wir::Program,
62    catalog: &Catalog,
63    locale: &Locale,
64    actions: &[wir::ActionId],
65) -> std::result::Result<ActionLayout, ActionLayoutError> {
66    program.validate().map_err(ActionLayoutError::InvalidWIR)?;
67    let mut emitter = Emitter {
68        program,
69        catalog,
70        locale: locale.clone(),
71        fallback: None,
72        fallback_ids: Vec::new(),
73        force_hero_constructors: false,
74        out: String::new(),
75        line_count: 0,
76    };
77    for action in actions {
78        emitter
79            .action(*action, 0, false)
80            .map_err(ActionLayoutError::Emission)?;
81    }
82    Ok(ActionLayout {
83        width: emitter.line_count,
84    })
85}
86
87/// Emission options: opt-in fallback for missing target-locale mappings.
88#[derive(Debug, Clone, Default, PartialEq, Eq)]
89pub struct EmitOptions {
90    /// When a canonical identity has no spelling for the target locale, its
91    /// spelling in this declared locale is used instead. `None` (the default)
92    /// keeps missing mappings failing explicitly. The fallback choice is
93    /// visible in [`EmitOutput::fallback_ids`].
94    pub fallback_locale: Option<Locale>,
95}
96
97/// The result of a localized emission.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct EmitOutput {
100    /// The emitted localized Workshop text.
101    pub text: String,
102    /// Canonical identities (and the `settings` marker) whose spelling came
103    /// from the opt-in fallback locale instead of the target locale. Empty
104    /// when no fallback occurred.
105    pub fallback_ids: Vec<String>,
106}
107
108/// Emit a Workshop IR program as localized Workshop text, failing explicitly
109/// on any missing target-locale mapping (no fallback).
110pub fn emit(program: &wir::Program, catalog: &Catalog, locale: &Locale) -> Result<String> {
111    emit_with_options(program, catalog, locale, &EmitOptions::default()).map(|out| out.text)
112}
113
114/// Emit a Workshop IR program as localized Workshop text with emission
115/// options (opt-in fallback locale).
116pub fn emit_with_options(
117    program: &wir::Program,
118    catalog: &Catalog,
119    locale: &Locale,
120    options: &EmitOptions,
121) -> Result<EmitOutput> {
122    emit_with_options_inner(program, catalog, locale, options, false)
123}
124
125pub(crate) fn emit_with_options_for_conversion(
126    program: &wir::Program,
127    catalog: &Catalog,
128    locale: &Locale,
129    options: &EmitOptions,
130) -> Result<EmitOutput> {
131    emit_with_options_inner(program, catalog, locale, options, true)
132}
133
134fn emit_with_options_inner(
135    program: &wir::Program,
136    catalog: &Catalog,
137    locale: &Locale,
138    options: &EmitOptions,
139    force_hero_constructors: bool,
140) -> Result<EmitOutput> {
141    let mut emitter = Emitter {
142        program,
143        catalog,
144        locale: locale.clone(),
145        fallback: options.fallback_locale.clone(),
146        force_hero_constructors,
147        fallback_ids: Vec::new(),
148        out: String::new(),
149        line_count: 0,
150    };
151    emitter.run()?;
152    Ok(EmitOutput {
153        text: emitter.out,
154        fallback_ids: emitter.fallback_ids,
155    })
156}
157
158struct Emitter<'a> {
159    program: &'a wir::Program,
160    catalog: &'a Catalog,
161    locale: Locale,
162    /// The opt-in fallback locale for missing target-locale mappings.
163    fallback: Option<Locale>,
164    /// Canonical ids emitted with a fallback-locale spelling.
165    fallback_ids: Vec<String>,
166    force_hero_constructors: bool,
167    out: String,
168    line_count: usize,
169}
170
171impl Emitter<'_> {
172    fn run(&mut self) -> Result<()> {
173        // Section order: settings, variables, subroutines, rules.
174        if let Some(settings) = &self.program.settings {
175            self.emit_settings(settings)?;
176            self.out.push('\n');
177        }
178        if !self.program.global_variables.is_empty() || !self.program.player_variables.is_empty() {
179            let variables = self.structural("variables")?;
180            self.line(0, &format!("{variables} {{"))?;
181            if !self.program.global_variables.is_empty() {
182                let global = self.structural("global")?;
183                self.line(1, &format!("{global}:"))?;
184                for variable in self.program.global_variables.iter() {
185                    self.line(2, &format!("{}: {}", variable.index, variable.name))?;
186                }
187            }
188            if !self.program.player_variables.is_empty() {
189                let player = self.structural("player")?;
190                self.line(1, &format!("{player}:"))?;
191                for variable in self.program.player_variables.iter() {
192                    self.line(2, &format!("{}: {}", variable.index, variable.name))?;
193                }
194            }
195            self.line(0, "}")?;
196            self.out.push('\n');
197        }
198        if !self.program.subroutines.is_empty() {
199            let subroutines = self.structural("subroutines")?;
200            self.line(0, &format!("{subroutines} {{"))?;
201            for subroutine in self.program.subroutines.iter() {
202                self.line(1, &format!("{}: {}", subroutine.index, subroutine.name))?;
203            }
204            self.line(0, "}")?;
205            self.out.push('\n');
206        }
207        for (emitted_rules, rule) in self.program.rules.iter().enumerate() {
208            if emitted_rules > 0 {
209                self.out.push('\n');
210            }
211            self.rule(rule)?;
212        }
213        // The oracle's raw artifact ends with a trailing blank line (the
214        // committed snapshots strip it via the acquisition normalizer; the
215        // pinned oracle's own output keeps it).
216        if !self.out.is_empty() && !self.out.ends_with("\n\n") {
217            self.out.push('\n');
218        }
219        Ok(())
220    }
221
222    /// Emit the `settings { ... }` section from the validated settings
223    /// carrier, table-driven (fixture-evidenced names). Only runs on
224    /// validated programs, so unknown keys cannot reach this point.
225    fn emit_settings(&mut self, settings: &SettingsTree) -> Result<()> {
226        let settings_keyword = self.structural("settings")?;
227        self.line(0, &format!("{settings_keyword} {{"))?;
228        for child in &settings.children {
229            if let SettingsNode::Workshop { children, .. } = child {
230                self.emit_workshop_settings(children, 1)?;
231                continue;
232            }
233            let SettingsNode::Group { name, children, .. } = child else {
234                return Err(self.malformed("settings block children must be groups"));
235            };
236            match name.as_str() {
237                "main" | "lobby" => {
238                    self.line(1, &format!("{name} {{"))?;
239                    for member in children {
240                        self.settings_member(member, 2, &[PathPart::Part(name)], None)?;
241                    }
242                    self.line(1, "}")?;
243                }
244                "gamemodes" => self.emit_modes(children)?,
245                "heroes" => self.emit_heroes(children)?,
246                "extensions" => {
247                    self.line(1, "extensions {")?;
248                    for member in children {
249                        self.settings_member(member, 2, &[PathPart::Part("extensions")], None)?;
250                    }
251                    self.line(1, "}")?;
252                }
253                _ => self.emit_opaque_group(children, name, 1)?,
254            }
255        }
256        self.line(0, "}")?;
257        Ok(())
258    }
259
260    fn emit_workshop_settings(&mut self, children: &[SettingsNode], level: usize) -> Result<()> {
261        let workshop = self.structural("workshop")?;
262        self.line(level, &format!("{workshop} {{"))?;
263        for child in children {
264            self.emit_workshop_node(child, level + 1)?;
265        }
266        self.line(level, "}")?;
267        Ok(())
268    }
269
270    fn emit_workshop_node(&mut self, node: &SettingsNode, level: usize) -> Result<()> {
271        match node {
272            SettingsNode::Group { name, children, .. } => {
273                self.line(level, &format!("{name} {{"))?;
274                for child in children {
275                    self.emit_workshop_node(child, level + 1)?;
276                }
277                self.line(level, "}")?;
278                Ok(())
279            }
280            SettingsNode::Workshop { children, .. } => self.emit_workshop_settings(children, level),
281            SettingsNode::Raw { name, value, .. } => {
282                if value.is_empty() {
283                    self.line(level, name)
284                } else {
285                    self.line(level, &format!("{name}: {value}"))
286                }
287            }
288            _ => Err(self.malformed("settings.workshop contains a typed builtin setting")),
289        }
290    }
291
292    /// Emit the `modes { <Mode> { ... } }` block of a gamemodes group.
293    fn emit_modes(&mut self, modes: &[SettingsNode]) -> Result<()> {
294        self.line(1, "modes {")?;
295        for mode in modes {
296            let SettingsNode::Group { name, children, .. } = mode else {
297                return Err(self.malformed("mode entries must be groups"));
298            };
299            let display = match table::mode_name(name) {
300                Some(english) => self.setting_name("modes", english, &format!("mode.{name}"))?,
301                None => name.clone(),
302            };
303            // `enabled: false` prefixes the mode header; true renders with no
304            // prefix (only false is evidenced in the corpus, #86).
305            let disabled = children.iter().any(|member| {
306                matches!(
307                    member,
308                    SettingsNode::Bool { name: n, value: false, .. } if n == "enabled"
309                )
310            });
311            let header = if disabled {
312                let disabled_name = self.setting_name("tokens", "disabled", "token.disabled")?;
313                format!("{disabled_name} {display}")
314            } else {
315                display
316            };
317            self.line(2, &format!("{header} {{"))?;
318            for member in children {
319                if matches!(member, SettingsNode::Bool { name: n, .. } if n == "enabled") {
320                    continue;
321                }
322                self.settings_member(
323                    member,
324                    3,
325                    &[PathPart::Part("gamemodes"), PathPart::Part(name)],
326                    None,
327                )?;
328            }
329            self.line(2, "}")?;
330        }
331        self.line(1, "}")?;
332        Ok(())
333    }
334
335    /// Emit the `heroes { <Team> { ... } }` block of a heroes group.
336    fn emit_heroes(&mut self, teams: &[SettingsNode]) -> Result<()> {
337        self.line(1, "heroes {")?;
338        for team in teams {
339            let SettingsNode::Group { name, children, .. } = team else {
340                return Err(self.malformed("team entries must be groups"));
341            };
342            let english = table::team_name(name)
343                .ok_or_else(|| self.malformed(format!("unknown team '{name}'")))?;
344            let display = self.setting_name("teams", english, &format!("team.{name}"))?;
345            self.line(2, &format!("{display} {{"))?;
346            for member in children {
347                match member {
348                    SettingsNode::Group { name, children, .. } => {
349                        let english = table::hero_name(name)
350                            .ok_or_else(|| self.malformed(format!("unknown hero '{name}'")))?;
351                        let hero = self.setting_name("heroes", english, &format!("hero.{name}"))?;
352                        self.line(3, &format!("{hero} {{"))?;
353                        for inner in children {
354                            self.settings_member(
355                                inner,
356                                4,
357                                &[PathPart::Part("heroes"), PathPart::Team, PathPart::Hero],
358                                Some(name),
359                            )?;
360                        }
361                        self.line(3, "}")?;
362                    }
363                    other => self.settings_member(
364                        other,
365                        3,
366                        &[PathPart::Part("heroes"), PathPart::Team],
367                        None,
368                    )?,
369                }
370            }
371            self.line(2, "}")?;
372        }
373        self.line(1, "}")?;
374        Ok(())
375    }
376
377    /// Emit one leaf-level settings member (`Name: value`, lists as blocks).
378    fn settings_member(
379        &mut self,
380        node: &SettingsNode,
381        level: usize,
382        path: &[PathPart],
383        hero: Option<&str>,
384    ) -> Result<()> {
385        if let SettingsNode::Raw { name, value, .. } = node {
386            if value.is_empty() {
387                self.line(level, name)?;
388            } else {
389                self.line(level, &format!("{name}: {value}"))?;
390            }
391            return Ok(());
392        }
393        let name = node.name();
394        let mut full = path.to_vec();
395        full.push(PathPart::Part(name));
396        let entry = table::lookup(&full).ok_or_else(|| {
397            self.malformed(format!(
398                "settings key '{}' is outside the emission table",
399                table::path_string(&full)
400            ))
401        })?;
402        let display_name = if let (Some(hero), Some(key)) = (
403            hero,
404            full.last().and_then(|part| match part {
405                PathPart::Part(key) => Some(*key),
406                _ => None,
407            }),
408        ) {
409            if let Some(name) = table::hero_setting_name(hero, key, self.locale.as_str()) {
410                name.to_string()
411            } else if !matches!(
412                key,
413                "enableAbility1" | "enableAbility2" | "enableAbility3" | "enableSecondaryFire"
414            ) {
415                if let Some(slot) = table::ability_slot_for_path(&full) {
416                    self.gameplay_setting_name(hero, slot, &table::path_string(&full))?
417                } else {
418                    self.setting_name("labels", entry.workshop_name, &table::path_string(&full))?
419                }
420            } else {
421                self.setting_name("labels", entry.workshop_name, &table::path_string(&full))?
422            }
423        } else if let (Some(hero), Some(slot)) = (hero, table::ability_slot_for_path(&full)) {
424            self.gameplay_setting_name(hero, slot, &table::path_string(&full))?
425        } else {
426            self.setting_name("labels", entry.workshop_name, &table::path_string(&full))?
427        };
428        match (node, &entry.kind) {
429            (SettingsNode::Flag { .. }, KeyKind::Flag) => {
430                self.line(level, &display_name)?;
431            }
432            (SettingsNode::String { value, .. }, KeyKind::String) => {
433                self.line(
434                    level,
435                    &format!("{}: \"{}\"", display_name, escape_settings_string(value)),
436                )?;
437            }
438            (SettingsNode::String { value, .. }, KeyKind::Enum(domain)) => {
439                let english = table::enum_name(domain, value).ok_or_else(|| {
440                    self.malformed(format!("unknown value '{value}' for settings key '{name}'"))
441                })?;
442                let display =
443                    self.setting_name("enums", english, &format!("enum.{domain}.{value}"))?;
444                self.line(level, &format!("{display_name}: {display}"))?;
445            }
446            (SettingsNode::Number { value, .. }, KeyKind::Number) => {
447                self.line(level, &format!("{display_name}: {}", format_number(*value)))?;
448            }
449            (SettingsNode::Number { value, .. }, KeyKind::Percent) => {
450                self.line(
451                    level,
452                    &format!("{display_name}: {}%", format_number(*value)),
453                )?;
454            }
455            (SettingsNode::Bool { value, .. }, KeyKind::Bool) => {
456                let rendered = self.setting_name(
457                    "tokens",
458                    if *value { "On" } else { "Off" },
459                    if *value { "token.on" } else { "token.off" },
460                )?;
461                self.line(level, &format!("{display_name}: {rendered}"))?;
462            }
463            (SettingsNode::List { elements, .. }, KeyKind::ListMap) => {
464                self.line(level, &format!("{display_name} {{"))?;
465                for element in elements {
466                    let english = table::map_name(&element.value).ok_or_else(|| {
467                        self.malformed(format!(
468                            "unknown map '{}' in settings list '{name}'",
469                            element.value
470                        ))
471                    })?;
472                    let display =
473                        self.setting_name("maps", english, &format!("map.{}.name", element.value))?;
474                    self.line(level + 1, &display)?;
475                }
476                self.line(level, "}")?;
477            }
478            (SettingsNode::List { elements, .. }, KeyKind::ListHero) => {
479                self.line(level, &format!("{display_name} {{"))?;
480                for element in elements {
481                    let english = table::hero_name(&element.value).ok_or_else(|| {
482                        self.malformed(format!(
483                            "unknown hero '{}' in settings list '{name}'",
484                            element.value
485                        ))
486                    })?;
487                    let display = self.setting_name(
488                        "heroes",
489                        english,
490                        &format!("hero.{}.name", element.value),
491                    )?;
492                    self.line(level + 1, &display)?;
493                }
494                self.line(level, "}")?;
495            }
496            _ => {
497                return Err(self.malformed(format!(
498                    "settings key '{name}' does not match its table kind"
499                )));
500            }
501        }
502        Ok(())
503    }
504
505    fn emit_opaque_group(
506        &mut self,
507        children: &[SettingsNode],
508        name: &str,
509        level: usize,
510    ) -> Result<()> {
511        self.line(level, &format!("{name} {{"))?;
512        for child in children {
513            match child {
514                SettingsNode::Group { name, children, .. } => {
515                    self.emit_opaque_group(children, name, level + 1)?;
516                }
517                _ => self.settings_member(child, level + 1, &[], None)?,
518            }
519        }
520        self.line(level, "}")?;
521        Ok(())
522    }
523
524    /// Resolve a settings spelling from the generated locale corpus. The
525    /// English table remains the explicit fallback only when the caller opts
526    /// into `en-US`, matching the catalog's missing-mapping contract.
527    fn gameplay_setting_name(&mut self, hero: &str, slot: &str, id: &str) -> Result<String> {
528        let resolve = |locale: &Locale| {
529            crate::gameplay_data::builtin().ok().and_then(|catalog| {
530                catalog
531                    .query()
532                    .ability_name(hero, slot, None, locale.as_str())
533                    .ok()
534                    .map(str::to_string)
535            })
536        };
537        if let Some(name) = resolve(&self.locale) {
538            return Ok(name);
539        }
540        if let Some(fallback) = &self.fallback {
541            if let Some(name) = resolve(fallback) {
542                if !self.fallback_ids.iter().any(|value| value == "settings") {
543                    self.fallback_ids.push("settings".to_string());
544                }
545                return Ok(name);
546            }
547        }
548        Err(WorkshopError::MissingMapping {
549            kind: "setting",
550            id: id.to_string(),
551            locale: self.locale.clone(),
552        })
553    }
554
555    fn setting_name(&mut self, section: &str, english: &str, id: &str) -> Result<String> {
556        let en_us = Locale::new("en-US");
557        if self.locale == en_us {
558            return Ok(english.to_string());
559        }
560        if let Some(spelling) = table::localized_name(self.locale.as_str(), section, english) {
561            return Ok(spelling.to_string());
562        }
563        if let Some(fallback) = &self.fallback {
564            if *fallback == en_us {
565                if !self.fallback_ids.iter().any(|value| value == "settings") {
566                    self.fallback_ids.push("settings".to_string());
567                }
568                return Ok(english.to_string());
569            }
570        }
571        Err(WorkshopError::MissingMapping {
572            kind: "setting",
573            id: id.to_string(),
574            locale: self.locale.clone(),
575        })
576    }
577
578    fn malformed(&self, message: impl Into<String>) -> WorkshopError {
579        WorkshopError::Malformed {
580            message: message.into(),
581            span: None,
582        }
583    }
584
585    fn rule(&mut self, rule: &wir::Rule) -> Result<()> {
586        let disabled = if rule.disabled {
587            format!("{} ", self.structural("disabled")?)
588        } else {
589            String::new()
590        };
591        let rule_keyword = self.structural("rule")?;
592        self.line(
593            0,
594            &format!(
595                "{disabled}{rule_keyword} (\"{}\") {{",
596                escape_string(&rule.name)
597            ),
598        )?;
599        let event = self.structural("event")?;
600        self.line(1, &format!("{event} {{"))?;
601        match &rule.event {
602            wir::Event::Global => {
603                let spelling = self.spelling(Kind::Event, "global")?;
604                self.line(2, &format!("{spelling};"))?;
605            }
606            wir::Event::EachPlayer => {
607                let spelling = self.spelling(Kind::Event, "eachPlayer")?;
608                self.line(2, &format!("{spelling};"))?;
609                self.event_filters(wir::EventTeam::All, &wir::EventTarget::All)?;
610            }
611            wir::Event::EachPlayerWithFilters { team, target } => {
612                let spelling = self.spelling(Kind::Event, "eachPlayer")?;
613                self.line(2, &format!("{spelling};"))?;
614                self.event_filters(*team, target)?;
615            }
616            wir::Event::Player { kind, team, target } => {
617                let spelling = self.spelling(Kind::Event, kind.catalog_id())?;
618                self.line(2, &format!("{spelling};"))?;
619                self.event_filters(*team, target)?;
620            }
621            wir::Event::Subroutine(subroutine) => {
622                let spelling = self.spelling(Kind::Event, "subroutine")?;
623                self.line(2, &format!("{spelling};"))?;
624                let name = self
625                    .program
626                    .subroutines
627                    .get(*subroutine)
628                    .map(|s| s.name.clone())
629                    .unwrap_or_else(|| "<dangling>".to_string());
630                self.line(2, &format!("{name};"))?;
631            }
632        }
633        self.line(1, "}")?;
634        if !rule.conditions.is_empty() {
635            let conditions = self.structural("conditions")?;
636            self.line(1, &format!("{conditions} {{"))?;
637            for condition in &rule.conditions {
638                let mut text = String::new();
639                // Reference normalization: comparison conditions render
640                // infix; other conditions render as `value == True`.
641                if let Some(wir::Value::Call { name, args }) =
642                    self.program.values.get(*condition).map(|node| &node.value)
643                {
644                    if is_comparison_operator(name) && args.len() == 2 {
645                        self.value(args[0], &mut text)?;
646                        write!(text, " {name} ").unwrap();
647                        self.value(args[1], &mut text)?;
648                    } else {
649                        self.value(*condition, &mut text)?;
650                        text.push_str(" == True");
651                    }
652                } else {
653                    self.value(*condition, &mut text)?;
654                    text.push_str(" == True");
655                }
656                self.line(2, &format!("{text};"))?;
657            }
658            self.line(1, "}")?;
659        }
660        if !rule.actions.is_empty() {
661            let actions = self.structural("actions")?;
662            self.line(1, &format!("{actions} {{"))?;
663            for (index, action) in rule.actions.iter().enumerate() {
664                let rule_final = index + 1 == rule.actions.len();
665                self.action(*action, 2, rule_final)?;
666            }
667            self.line(1, "}")?;
668        }
669        self.line(0, "}")?;
670        Ok(())
671    }
672
673    fn event_filters(&mut self, team: wir::EventTeam, target: &wir::EventTarget) -> Result<()> {
674        let team = match team {
675            wir::EventTeam::All => "ALL",
676            wir::EventTeam::Team1 => "TEAM_1",
677            wir::EventTeam::Team2 => "TEAM_2",
678        };
679        let team = self.enum_spelling("EventTeam", team)?;
680        self.line(2, &format!("{team};"))?;
681        let target = match target {
682            wir::EventTarget::All => self.enum_spelling("EventPlayer", "ALL")?,
683            wir::EventTarget::Slot(slot) => {
684                self.enum_spelling("EventPlayer", &format!("SLOT_{slot}"))?
685            }
686            wir::EventTarget::Hero(hero) => self.enum_spelling("Hero", hero)?,
687        };
688        self.line(2, &format!("{target};"))?;
689        Ok(())
690    }
691
692    /// Emit one rule action; `rule_final` marks the last action of the rule,
693    /// for which an `if`/`if-else` closes without the trailing `End;`
694    /// (the pinned oracle's spelling, #87).
695    fn action(&mut self, id: wir::ActionId, level: usize, rule_final: bool) -> Result<()> {
696        let Some(action) = self.program.actions.get(id) else {
697            return Err(WorkshopError::Malformed {
698                message: format!("dangling action {id}"),
699                span: None,
700            });
701        };
702        match action {
703            wir::Action::SetGlobalVariable {
704                variable, value, ..
705            } => {
706                let name = self.global_name(*variable)?;
707                let mut value_text = String::new();
708                self.value(*value, &mut value_text)?;
709                let keyword = self.spelling(Kind::Structural, "setGlobalVariable")?;
710                self.line(level, &format!("{keyword}({name}, {value_text});"))?;
711            }
712            wir::Action::ModifyGlobalVariable {
713                variable,
714                op,
715                value,
716                ..
717            } => {
718                let name = self.global_name(*variable)?;
719                let op = self.modify_op_spelling(*op)?;
720                let mut value_text = String::new();
721                self.value(*value, &mut value_text)?;
722                let keyword = self.spelling(Kind::Structural, "modifyGlobalVariable")?;
723                self.line(level, &format!("{keyword}({name}, {op}, {value_text});"))?;
724            }
725            wir::Action::SetPlayerVariable {
726                player,
727                variable,
728                value,
729                ..
730            } => {
731                let mut player_text = String::new();
732                self.value(*player, &mut player_text)?;
733                let name = self.player_name(*variable)?;
734                let mut value_text = String::new();
735                self.value(*value, &mut value_text)?;
736                let keyword = self.spelling(Kind::Structural, "setPlayerVariable")?;
737                self.line(
738                    level,
739                    &format!("{keyword}({player_text}, {name}, {value_text});"),
740                )?;
741            }
742            wir::Action::ModifyPlayerVariable {
743                player,
744                variable,
745                op,
746                value,
747                ..
748            } => {
749                let mut player_text = String::new();
750                self.value(*player, &mut player_text)?;
751                let name = self.player_name(*variable)?;
752                let op = self.modify_op_spelling(*op)?;
753                let mut value_text = String::new();
754                self.value(*value, &mut value_text)?;
755                let keyword = self.spelling(Kind::Structural, "modifyPlayerVariable")?;
756                self.line(
757                    level,
758                    &format!("{keyword}({player_text}, {name}, {op}, {value_text});"),
759                )?;
760            }
761            wir::Action::CallSubroutine { subroutine, .. } => {
762                let name = self
763                    .program
764                    .subroutines
765                    .get(*subroutine)
766                    .map(|s| s.name.clone())
767                    .ok_or_else(|| WorkshopError::Unknown {
768                        kind: "subroutine",
769                        spelling: format!("<{subroutine}>"),
770                        locale: self.locale.clone(),
771                        span: None,
772                    })?;
773                let keyword = self.spelling(Kind::Structural, "callSubroutine")?;
774                self.line(level, &format!("{keyword}({name});"))?;
775            }
776            wir::Action::If {
777                branches,
778                else_body,
779                ..
780            } => {
781                for (index, branch) in branches.iter().enumerate() {
782                    let mut condition = String::new();
783                    self.value(branch.condition, &mut condition)?;
784                    let keyword =
785                        self.spelling(Kind::Structural, if index == 0 { "if" } else { "elseIf" })?;
786                    self.line(level, &format!("{keyword}({condition});"))?;
787                    for action in &branch.body {
788                        self.action(*action, level + 1, false)?;
789                    }
790                }
791                if let Some(else_body) = else_body {
792                    let keyword = self.spelling(Kind::Structural, "else")?;
793                    self.line(level, &format!("{keyword};"))?;
794                    for action in else_body {
795                        self.action(*action, level + 1, false)?;
796                    }
797                }
798                // A rule-final if closes the rule without `End;` (oracle
799                // spelling); nested and middle-of-rule ifs keep it.
800                if !rule_final {
801                    let keyword = self.spelling(Kind::Structural, "end")?;
802                    self.line(level, &format!("{keyword};"))?;
803                }
804            }
805            wir::Action::While {
806                condition, body, ..
807            } => {
808                let mut text = String::new();
809                self.value(*condition, &mut text)?;
810                let keyword = self.spelling(Kind::Structural, "while")?;
811                self.line(level, &format!("{keyword}({text});"))?;
812                for action in body {
813                    self.action(*action, level + 1, false)?;
814                }
815                let end = self.spelling(Kind::Structural, "end")?;
816                self.line(level, &format!("{end};"))?;
817            }
818            wir::Action::ForGlobalVariable {
819                variable,
820                start,
821                stop,
822                step,
823                body,
824                ..
825            } => {
826                let name = self.global_name(*variable)?;
827                let mut start_text = String::new();
828                let mut stop_text = String::new();
829                let mut step_text = String::new();
830                self.value(*start, &mut start_text)?;
831                self.value(*stop, &mut stop_text)?;
832                self.value(*step, &mut step_text)?;
833                let keyword = self.spelling(Kind::Structural, "forGlobalVariable")?;
834                self.line(
835                    level,
836                    &format!("{keyword}({name}, {start_text}, {stop_text}, {step_text});"),
837                )?;
838                for action in body {
839                    self.action(*action, level + 1, false)?;
840                }
841                let end = self.spelling(Kind::Structural, "end")?;
842                self.line(level, &format!("{end};"))?;
843            }
844            wir::Action::ForPlayerVariable {
845                player,
846                variable,
847                start,
848                stop,
849                step,
850                body,
851                ..
852            } => {
853                let keyword = self.structural("forPlayerVariable")?;
854                let mut player_text = String::new();
855                let mut start_text = String::new();
856                let mut stop_text = String::new();
857                let mut step_text = String::new();
858                self.value(*player, &mut player_text)?;
859                self.value(*start, &mut start_text)?;
860                self.value(*stop, &mut stop_text)?;
861                self.value(*step, &mut step_text)?;
862                let name = self.player_name(*variable)?;
863                self.line(
864                    level,
865                    &format!(
866                        "{}({player_text}, {name}, {start_text}, {stop_text}, {step_text});",
867                        keyword
868                    ),
869                )?;
870                for action in body {
871                    self.action(*action, level + 1, false)?;
872                }
873                let end = self.spelling(Kind::Structural, "end")?;
874                self.line(level, &format!("{end};"))?;
875            }
876            wir::Action::Debug { value, .. } => {
877                // `debug(value)` displays the value as HUD text. The
878                // reference formats values with type-aware machinery; Wright
879                // emits a semantically equivalent but presentation-simpler
880                // Create HUD Text (documented intentional difference).
881                self.emit_hud_text(*value, level, true)?;
882            }
883            wir::Action::Print { message, .. } => {
884                self.emit_hud_text(*message, level, false)?;
885            }
886            wir::Action::AssignMember {
887                target, op, value, ..
888            } => {
889                let mut target_text = String::new();
890                let mut value_text = String::new();
891                self.value(*target, &mut target_text)?;
892                self.value(*value, &mut value_text)?;
893                let operator = match op {
894                    None => "=".to_string(),
895                    Some(op) => {
896                        let token = match op {
897                            wir::ModifyOp::Add => "+",
898                            wir::ModifyOp::Subtract => "-",
899                            wir::ModifyOp::Multiply => "*",
900                            wir::ModifyOp::Divide => "/",
901                            wir::ModifyOp::Modulo => "%",
902                            wir::ModifyOp::Min => "min",
903                            wir::ModifyOp::Max => "max",
904                            _ => {
905                                return Err(WorkshopError::Unsupported {
906                                    message: format!(
907                                        "unsupported member assignment operator {op:?}"
908                                    ),
909                                    span: None,
910                                });
911                            }
912                        };
913                        format!("{token}=")
914                    }
915                };
916                self.line(level, &format!("{target_text} {operator} {value_text};"))?;
917            }
918            wir::Action::Call { name, args, .. } => {
919                // The chase family dispatches on the first argument's
920                // variable kind, mirroring the pinned reference: a global
921                // variable emits the global form with the argument list
922                // unchanged; a player variable emits the player form with
923                // the receiver split into `player, name` leading arguments
924                // (the frontend guarantees a variable first argument,
925                // issue #110).
926                if matches!(name.as_str(), "chaseAtRate" | "chaseOverTime") {
927                    let player_var = args.first().and_then(|id| {
928                        self.program
929                            .values
930                            .get(*id)
931                            .and_then(|node| match &node.value {
932                                wir::Value::PlayerVariable { player, variable } => {
933                                    Some((*player, *variable))
934                                }
935                                _ => None,
936                            })
937                    });
938                    let spelling = if let Some((player, variable)) = player_var {
939                        let id = if name == "chaseAtRate" {
940                            "chasePlayerVariableAtRate"
941                        } else {
942                            "chasePlayerVariableOverTime"
943                        };
944                        let spelling = self.spelling(Kind::Action, id)?;
945                        // `Chase Player Variable At Rate(player, name, …)`:
946                        // the receiver splits into `player, name` leading
947                        // arguments (the pinned oracle's spelling).
948                        let mut text = String::new();
949                        self.value(player, &mut text)?;
950                        let mut parts = vec![text, self.player_name(variable)?];
951                        for arg in args.iter().skip(1) {
952                            let mut part = String::new();
953                            self.value(*arg, &mut part)?;
954                            parts.push(part);
955                        }
956                        return self.line(level, &format!("{spelling}({});", parts.join(", ")));
957                    } else {
958                        self.spelling(Kind::Action, name)?
959                    };
960                    let mut args_text = String::new();
961                    self.args(args, &mut args_text)?;
962                    return self.line(level, &format!("{spelling}({args_text});"));
963                }
964                if name == "stopChasingPlayerVariable" {
965                    let Some((player, variable)) = args.first().and_then(|id| {
966                        self.program
967                            .values
968                            .get(*id)
969                            .and_then(|node| match &node.value {
970                                wir::Value::PlayerVariable { player, variable } => {
971                                    Some((*player, *variable))
972                                }
973                                _ => None,
974                            })
975                    }) else {
976                        return Err(WorkshopError::Malformed {
977                            message: "Stop Chasing Player Variable requires a player variable"
978                                .into(),
979                            span: None,
980                        });
981                    };
982                    let spelling = self.spelling(Kind::Action, name)?;
983                    let mut player_text = String::new();
984                    self.value(player, &mut player_text)?;
985                    return self.line(
986                        level,
987                        &format!(
988                            "{spelling}({player_text}, {});",
989                            self.player_name(variable)?
990                        ),
991                    );
992                }
993                // Native `.opy` action names map to canonical catalog ids at
994                // emission (presentation concern).
995                let canonical = match name.as_str() {
996                    "createBeam" => Some("createBeamEffect"),
997                    _ => None,
998                };
999                let spelling = if let Some(canonical) = canonical {
1000                    self.spelling(Kind::Action, canonical)?
1001                } else {
1002                    self.spelling(Kind::Action, name)?
1003                };
1004                if args.is_empty() {
1005                    self.line(level, &format!("{spelling};"))?;
1006                } else {
1007                    let mut args_text = String::new();
1008                    for (index, arg) in args.iter().enumerate() {
1009                        if index > 0 {
1010                            args_text.push_str(", ");
1011                        }
1012                        let variable_position = match name.as_str() {
1013                            "setGlobalVariableAtIndex" | "modifyGlobalVariableAtIndex" => {
1014                                index == 0
1015                            }
1016                            "setPlayerVariableAtIndex" | "modifyPlayerVariableAtIndex" => {
1017                                index == 1
1018                            }
1019                            _ => false,
1020                        };
1021                        if variable_position {
1022                            if let Some(node) = self.program.values.get(*arg) {
1023                                match &node.value {
1024                                    wir::Value::GlobalVariable(variable) => {
1025                                        args_text.push_str(&self.global_name(*variable)?);
1026                                        continue;
1027                                    }
1028                                    wir::Value::PlayerVariable { variable, .. } => {
1029                                        args_text.push_str(&self.player_name(*variable)?);
1030                                        continue;
1031                                    }
1032                                    _ => {}
1033                                }
1034                            }
1035                        }
1036                        self.value(*arg, &mut args_text)?;
1037                    }
1038                    self.line(level, &format!("{spelling}({args_text});"))?;
1039                }
1040            }
1041        }
1042        Ok(())
1043    }
1044
1045    /// Emit a `debug`/`print` action as a `Create HUD Text` effect.
1046    ///
1047    /// `debug` renders the value into the HUD body; `print` renders the
1048    /// message directly (a `format` value already carries the text). Every
1049    /// fixed token resolves through the catalog, so the effect is
1050    /// locale-correct by data and fails explicitly on missing target-locale
1051    /// mappings.
1052    fn emit_hud_text(&mut self, value: wir::ValueId, level: usize, is_debug: bool) -> Result<()> {
1053        let mut body = String::new();
1054        if is_debug {
1055            // Display the value in the HUD body: Custom String("{0}", value).
1056            body.push_str(&self.spelling(Kind::Value, "customString")?);
1057            body.push_str("(\"{0}\", ");
1058            self.value(value, &mut body)?;
1059            body.push(')');
1060        } else {
1061            self.value(value, &mut body)?;
1062        }
1063        // Create HUD Text(All Players(All Teams), Null, header, body, text,
1064        // location, sort order, header color, subheader color, text color,
1065        // reevaluation, spectators) — the canonical catalog layout (probe P6
1066        // emission), so the emitted text reparses against the catalog's
1067        // expected enum domains at the canonical positions.
1068        let mut line = String::new();
1069        line.push_str(&self.spelling(Kind::Action, "createHudText")?);
1070        line.push('(');
1071        line.push_str(&self.spelling(Kind::Value, "allPlayers")?);
1072        line.push('(');
1073        line.push_str(&self.enum_spelling("Team", "ALL")?);
1074        line.push_str("), Null, ");
1075        line.push_str(&body);
1076        line.push_str(", Null, ");
1077        line.push_str(&self.enum_spelling("HudPosition", "LEFT")?);
1078        line.push_str(", -9999, Color(");
1079        line.push_str(&self.enum_spelling("Color", "WHITE")?);
1080        line.push_str("), Color(");
1081        line.push_str(&self.enum_spelling("Color", "WHITE")?);
1082        line.push_str("), Color(");
1083        line.push_str(&self.enum_spelling("Color", "WHITE")?);
1084        line.push_str("), ");
1085        line.push_str(&self.enum_spelling("HudReeval", "VISIBILITY_AND_STRING")?);
1086        line.push_str(", ");
1087        line.push_str(&self.enum_spelling("SpecVisibility", "VISIBLE_ALWAYS")?);
1088        line.push_str(");");
1089        self.line(level, &line)?;
1090        Ok(())
1091    }
1092
1093    fn args(&mut self, args: &[wir::ValueId], out: &mut String) -> Result<()> {
1094        for (index, arg) in args.iter().enumerate() {
1095            if index > 0 {
1096                out.push_str(", ");
1097            }
1098            self.value(*arg, out)?;
1099        }
1100        Ok(())
1101    }
1102
1103    fn value(&mut self, id: wir::ValueId, out: &mut String) -> Result<()> {
1104        let Some(node) = self.program.values.get(id) else {
1105            return Err(WorkshopError::Malformed {
1106                message: format!("dangling value {id}"),
1107                span: None,
1108            });
1109        };
1110        match &node.value {
1111            wir::Value::Number { text, .. } => {
1112                // Literal spellings carry through (the oracle preserves the
1113                // source spelling, e.g. `0.0`; computed values carry the
1114                // formatted spelling, #87).
1115                out.push_str(text);
1116            }
1117            wir::Value::String(value) => {
1118                // Value-position strings wrap in `Custom String("...")` with
1119                // re-escaped content and long-string splitting, the pinned
1120                // oracle's spelling (evidence: array elements, initializers,
1121                // assignments, call arguments, comparisons — #87). The only
1122                // bare string value is the `Custom String` text argument,
1123                // handled in the call arm below.
1124                self.emit_string_value(value, out)?;
1125            }
1126            wir::Value::LocalizedString(id) => {
1127                out.push_str(&self.spelling(Kind::Value, "string")?);
1128                out.push('(');
1129                let spelling = self.localized_string_spelling(id)?;
1130                write!(out, "\"{}\"", escape_value_string(&spelling)).unwrap();
1131                out.push(')');
1132            }
1133            wir::Value::Bool(true) => out.push_str("True"),
1134            wir::Value::Bool(false) => out.push_str("False"),
1135            wir::Value::Null => out.push_str("Null"),
1136            wir::Value::Array(elements) => {
1137                if elements.is_empty() {
1138                    // The canonical empty-array constant (reference emission).
1139                    out.push_str(&self.spelling(Kind::Value, "emptyArray")?);
1140                } else {
1141                    out.push_str(&self.spelling(Kind::Value, "array")?);
1142                    out.push('(');
1143                    self.args(elements, out)?;
1144                    out.push(')');
1145                }
1146            }
1147            wir::Value::Vector { x, y, z } => {
1148                out.push_str(&self.spelling(Kind::Value, "vector")?);
1149                out.push('(');
1150                self.value(*x, out)?;
1151                out.push_str(", ");
1152                self.value(*y, out)?;
1153                out.push_str(", ");
1154                self.value(*z, out)?;
1155                out.push(')');
1156            }
1157            wir::Value::Enum { value_type, value } => {
1158                let spelling = self.enum_spelling(value_type, value)?;
1159                // Color, Team, and Hero values use the constructor form;
1160                // other domains use bare member spellings (the canonical
1161                // corpus form). The
1162                // Team/Color spelling collision (`Team 2` is both a Team and
1163                // a Team color) is the one ambiguity unpinned by the
1164                // catalog's paramDomains, so Team members qualify with the
1165                // constructor form and the emitted text reparses
1166                // deterministically (round-trip contract; pinned P4
1167                // evidence).
1168                if matches!(value_type.as_str(), "Color" | "Map" | "Team")
1169                    || value_type == "Hero"
1170                        && (spelling.contains('.')
1171                            || self.locale != *self.catalog.primary_locale()
1172                            || (self.force_hero_constructors
1173                                && self
1174                                    .program
1175                                    .global_variables
1176                                    .iter()
1177                                    .any(|variable| variable.name == spelling)))
1178                {
1179                    let domain = self
1180                        .catalog
1181                        .enum_domain(value_type)
1182                        .and_then(|entry| entry.spelling(&self.locale))
1183                        .unwrap_or(value_type);
1184                    write!(out, "{domain}({spelling})").unwrap();
1185                } else {
1186                    out.push_str(&spelling);
1187                }
1188            }
1189            wir::Value::GlobalVariable(variable) => {
1190                let name = self.global_name(*variable)?;
1191                write!(out, "Global.{name}").unwrap();
1192            }
1193            wir::Value::PlayerVariable { player, variable } => {
1194                // The oracle's spelling parenthesizes the receiver:
1195                // `Set Global Variable(g, (Event Player).p)` (#87).
1196                out.push('(');
1197                self.value(*player, out)?;
1198                out.push(')');
1199                let name = self.player_name(*variable)?;
1200                write!(out, ".{name}").unwrap();
1201            }
1202            wir::Value::Subroutine(subroutine) => {
1203                let name = self
1204                    .program
1205                    .subroutines
1206                    .get(*subroutine)
1207                    .map(|value| value.name.clone())
1208                    .ok_or_else(|| WorkshopError::Malformed {
1209                        message: format!("dangling subroutine value {subroutine}"),
1210                        span: None,
1211                    })?;
1212                out.push_str(&name);
1213            }
1214            wir::Value::EventPlayer => out.push_str(&self.spelling(Kind::Value, "eventPlayer")?),
1215            wir::Value::Call { name, args } => {
1216                if name == "memberAccess" {
1217                    if args.len() < 2 || args.len() > 3 {
1218                        return Err(WorkshopError::Malformed {
1219                            message: "memberAccess expects two or three arguments".to_string(),
1220                            span: node.span,
1221                        });
1222                    }
1223                    let Some(wir::ValueNode {
1224                        value: wir::Value::String(member),
1225                        ..
1226                    }) = self.program.values.get(args[1])
1227                    else {
1228                        return Err(WorkshopError::Malformed {
1229                            message: "memberAccess member must be a string".to_string(),
1230                            span: node.span,
1231                        });
1232                    };
1233                    let bare_event_player = self
1234                        .program
1235                        .values
1236                        .get(args[0])
1237                        .is_some_and(|node| matches!(node.value, wir::Value::EventPlayer));
1238                    if !bare_event_player {
1239                        out.push('(');
1240                    }
1241                    self.value(args[0], out)?;
1242                    if !bare_event_player {
1243                        out.push(')');
1244                    }
1245                    write!(out, ".{member}").unwrap();
1246                    if let Some(index) = args.get(2) {
1247                        out.push('[');
1248                        self.value(*index, out)?;
1249                        out.push(']');
1250                    }
1251                    return Ok(());
1252                }
1253                if is_comparison_operator(name) {
1254                    // Canonical form: Compare(a, op, b).
1255                    if args.len() != 2 {
1256                        return Err(WorkshopError::Malformed {
1257                            message: format!("comparison call '{name}' must have 2 args"),
1258                            span: None,
1259                        });
1260                    }
1261                    out.push_str(&self.spelling(Kind::Value, "compare")?);
1262                    out.push('(');
1263                    self.value(args[0], out)?;
1264                    write!(out, ", {name}, ").unwrap();
1265                    self.value(args[1], out)?;
1266                    out.push(')');
1267                    return Ok(());
1268                }
1269                // Unary minus renders as Multiply(-1, x); the reference folds
1270                // literal negation, handled by the compat constant-fold pass.
1271                if name == "-" && args.len() == 1 {
1272                    out.push_str(&self.spelling(Kind::Value, "multiply")?);
1273                    out.push_str("(-1, ");
1274                    self.value(args[0], out)?;
1275                    out.push(')');
1276                    return Ok(());
1277                }
1278                // `getAllPlayers()` is OverPy's All Players(All Teams).
1279                if name == "getAllPlayers" && args.is_empty() {
1280                    out.push_str(&self.spelling(Kind::Value, "allPlayers")?);
1281                    out.push('(');
1282                    out.push_str(&self.enum_spelling("Team", "ALL")?);
1283                    out.push(')');
1284                    return Ok(());
1285                }
1286                // Binary arithmetic operators and native `.opy` source names
1287                // map to canonical catalog ids at emission (presentation
1288                // concern; the compat pass folds constants to match the
1289                // reference exactly).
1290                let canonical = match name.as_str() {
1291                    "+" => Some("add"),
1292                    "-" => Some("subtract"),
1293                    "*" => Some("multiply"),
1294                    "/" => Some("divide"),
1295                    "len" => Some("countOf"),
1296                    "abs" => Some("absoluteValue"),
1297                    "sqrt" => Some("squareRoot"),
1298                    "createBeam" => Some("createBeamEffect"),
1299                    "random.uniform" => Some("randomReal"),
1300                    "random.choice" => Some("randomValueInArray"),
1301                    "format" => Some("customString"),
1302                    _ => None,
1303                };
1304                let spelling = if let Some(canonical) = canonical {
1305                    self.spelling(Kind::Value, canonical)?
1306                } else {
1307                    self.spelling(Kind::Value, name)?
1308                };
1309                // `format` (frontend) and `customString` (parsed ws text) are
1310                // the same node.
1311                let is_custom_string = canonical == Some("customString") || name == "customString";
1312                if name == "string" {
1313                    out.push_str(&spelling);
1314                    out.push('(');
1315                    if let Some(first) = args.first() {
1316                        self.localized_string_value(*first, out)?;
1317                        if args.len() > 1 {
1318                            out.push_str(", ");
1319                            self.args(&args[1..], out)?;
1320                        }
1321                    }
1322                    out.push(')');
1323                } else if args.is_empty() {
1324                    // Constants (e.g. Empty Array) emit as bare spellings.
1325                    out.push_str(&spelling);
1326                } else if is_custom_string {
1327                    // `.format()` calls canonicalize: constant numeric
1328                    // arguments fold into the substituted text, implicit
1329                    // `{}` placeholders renumber to the oracle's explicit
1330                    // form, and remaining variable arguments wrap (the
1331                    // oracle spelling, #87). The canonical text feeds the
1332                    // value-string path (re-escaping/splitting) when no
1333                    // arguments remain.
1334                    match self.canonicalize_format_call(args)? {
1335                        Some((text, variable_args)) => {
1336                            if variable_args.is_empty() {
1337                                self.emit_string_value(&text, out)?;
1338                            } else {
1339                                out.push_str(&spelling);
1340                                out.push('(');
1341                                write!(out, "\"{}\"", escape_value_string(&text)).unwrap();
1342                                if !variable_args.is_empty() {
1343                                    out.push_str(", ");
1344                                }
1345                                self.args(&variable_args, out)?;
1346                                out.push(')');
1347                            }
1348                        }
1349                        None => {
1350                            // The `Custom String` text argument stays bare
1351                            // (the oracle spelling); the remaining arguments
1352                            // are values and wrap (#87).
1353                            out.push_str(&spelling);
1354                            out.push('(');
1355                            self.bare_string_value(args[0], out)?;
1356                            if args.len() > 1 {
1357                                out.push_str(", ");
1358                            }
1359                            self.args(&args[1..], out)?;
1360                            out.push(')');
1361                        }
1362                    }
1363                } else {
1364                    out.push_str(&spelling);
1365                    out.push('(');
1366                    self.args(args, out)?;
1367                    out.push(')');
1368                }
1369            }
1370        }
1371        Ok(())
1372    }
1373
1374    fn localized_string_value(&mut self, id: wir::ValueId, out: &mut String) -> Result<()> {
1375        let Some(node) = self.program.values.get(id) else {
1376            return Err(WorkshopError::Malformed {
1377                message: format!("dangling value {id}"),
1378                span: None,
1379            });
1380        };
1381        let wir::Value::LocalizedString(id) = &node.value else {
1382            return Err(WorkshopError::Unsupported {
1383                message: "value 'string' argument 1 must be localized string text".to_string(),
1384                span: node.span,
1385            });
1386        };
1387        let spelling = self.localized_string_spelling(id)?;
1388        write!(out, "\"{}\"", escape_value_string(&spelling)).unwrap();
1389        Ok(())
1390    }
1391
1392    /// Fold a `Custom String` call whose text argument and constant numeric
1393    /// arguments are all literals into the substituted text (the oracle's
1394    /// Canonicalize a `Custom String`/`.format()` call (#87): constant
1395    /// numeric arguments fold into the substituted text (the oracle's
1396    /// spelling), implicit `{}` placeholders renumber positionally to the
1397    /// explicit `{N}` form, and the remaining variable arguments are
1398    /// returned in placeholder order. Returns `None` (rendered unchanged)
1399    /// when nothing canonicalizes: explicit-only texts without constants,
1400    /// texts mixing implicit and explicit placeholders (the oracle rejects
1401    /// those), out-of-range placeholders, or non-String text arguments.
1402    fn canonicalize_format_call(
1403        &self,
1404        args: &[wir::ValueId],
1405    ) -> Result<Option<(String, Vec<wir::ValueId>)>> {
1406        if args.len() < 2 {
1407            return Ok(None);
1408        }
1409        let Some(text) = self.program.values.get(args[0]) else {
1410            return Ok(None);
1411        };
1412        let wir::Value::String(text) = &text.value else {
1413            return Ok(None);
1414        };
1415        let format_args = &args[1..];
1416        // Classify the placeholders: implicit `{}` consumes the next
1417        // argument, explicit `{N}` references argument N.
1418        let mut has_implicit = false;
1419        let mut has_explicit = false;
1420        let mut out_of_range = false;
1421        let mut cursor = 0usize;
1422        let mut chars = text.chars().peekable();
1423        while let Some(ch) = chars.next() {
1424            if ch == '{' {
1425                let mut inner = String::new();
1426                let mut closed = false;
1427                for next in chars.by_ref() {
1428                    if next == '}' {
1429                        closed = true;
1430                        break;
1431                    }
1432                    inner.push(next);
1433                }
1434                if !closed {
1435                    break; // unterminated brace: literal text
1436                }
1437                if inner.is_empty() {
1438                    if cursor >= format_args.len() {
1439                        out_of_range = true;
1440                    }
1441                    cursor += 1;
1442                    has_implicit = true;
1443                } else if inner.chars().all(|c| c.is_ascii_digit()) {
1444                    match inner.parse::<usize>() {
1445                        Ok(index) if index < format_args.len() => has_explicit = true,
1446                        _ => out_of_range = true,
1447                    }
1448                } else {
1449                    out_of_range = true;
1450                }
1451            }
1452        }
1453        if out_of_range || (has_implicit && has_explicit) {
1454            return Ok(None);
1455        }
1456        let mut any_constant = false;
1457        for id in format_args {
1458            let Some(node) = self.program.values.get(*id) else {
1459                return Ok(None);
1460            };
1461            if matches!(node.value, wir::Value::Number { .. }) {
1462                any_constant = true;
1463            }
1464        }
1465        if !has_implicit && !any_constant {
1466            return Ok(None);
1467        }
1468        // Canonicalize: fold constants inline at their placeholder, renumber
1469        // variable placeholders positionally, keep variable arguments in
1470        // placeholder order.
1471        let mut canonical = String::with_capacity(text.len());
1472        let mut variable_args = Vec::new();
1473        let mut variable_index = 0usize;
1474        let mut cursor = 0usize;
1475        let mut chars = text.chars().peekable();
1476        while let Some(ch) = chars.next() {
1477            if ch == '{' {
1478                let mut inner = String::new();
1479                let mut closed = false;
1480                for next in chars.by_ref() {
1481                    if next == '}' {
1482                        closed = true;
1483                        break;
1484                    }
1485                    inner.push(next);
1486                }
1487                if !closed {
1488                    canonical.push('{');
1489                    canonical.push_str(&inner);
1490                    break;
1491                }
1492                let index = if inner.is_empty() {
1493                    let index = cursor;
1494                    cursor += 1;
1495                    index
1496                } else {
1497                    match inner.parse::<usize>() {
1498                        Ok(index) => index,
1499                        Err(_) => {
1500                            canonical.push('{');
1501                            canonical.push_str(&inner);
1502                            canonical.push('}');
1503                            continue;
1504                        }
1505                    }
1506                };
1507                let Some(arg) = format_args.get(index).copied() else {
1508                    canonical.push('{');
1509                    canonical.push_str(&inner);
1510                    canonical.push('}');
1511                    continue;
1512                };
1513                let node = self.program.values.get(arg);
1514                if let Some(wir::Value::Number { value, .. }) = node.map(|node| &node.value) {
1515                    canonical.push_str(&fold_number(*value));
1516                } else {
1517                    write!(canonical, "{{{variable_index}}}").unwrap();
1518                    variable_index += 1;
1519                    variable_args.push(arg);
1520                }
1521            } else {
1522                canonical.push(ch);
1523            }
1524        }
1525        Ok(Some((canonical, variable_args)))
1526    }
1527
1528    /// The localized spelling of a modify operator, resolved through the
1529    /// catalog (fallback-aware).
1530    fn modify_op_spelling(&mut self, op: wir::ModifyOp) -> Result<String> {
1531        self.spelling(Kind::Operator, op.catalog_id())
1532    }
1533
1534    /// The localized spelling of a canonical builtin id, resolving through
1535    /// the catalog: a dangling id is `Unknown`, an id without a target-locale
1536    /// mapping is `MissingMapping` unless an opt-in fallback locale declares
1537    /// one (recorded in [`Emitter::fallback_ids`]).
1538    fn spelling(&mut self, kind: Kind, id: &str) -> Result<String> {
1539        let Some(entry) = self.catalog.entry(kind, id) else {
1540            return Err(WorkshopError::Unknown {
1541                kind: kind.as_str(),
1542                spelling: id.to_string(),
1543                locale: self.locale.clone(),
1544                span: None,
1545            });
1546        };
1547        if let Some(spelling) = entry.spelling(&self.locale) {
1548            return Ok(spelling.to_string());
1549        }
1550        if let Some(fallback) = &self.fallback {
1551            if let Some(spelling) = entry.spelling(fallback) {
1552                self.fallback_ids.push(id.to_string());
1553                return Ok(spelling.to_string());
1554            }
1555        }
1556        Err(WorkshopError::MissingMapping {
1557            kind: kind.as_str(),
1558            id: id.to_string(),
1559            locale: self.locale.clone(),
1560        })
1561    }
1562
1563    fn localized_string_spelling(&mut self, id: &str) -> Result<String> {
1564        if let Some(spelling) = self.catalog.localized_string_spelling(&self.locale, id) {
1565            return Ok(spelling.to_string());
1566        }
1567        if let Some(fallback) = &self.fallback {
1568            if let Some(spelling) = self.catalog.localized_string_spelling(fallback, id) {
1569                self.fallback_ids.push(format!("localizedString.{id}"));
1570                return Ok(spelling.to_string());
1571            }
1572        }
1573        if self.catalog.localized_strings().any(|entry| entry.id == id) {
1574            return Err(WorkshopError::MissingMapping {
1575                kind: "localized string",
1576                id: id.to_string(),
1577                locale: self.locale.clone(),
1578            });
1579        }
1580        Err(WorkshopError::Unknown {
1581            kind: "localized string",
1582            spelling: id.to_string(),
1583            locale: self.locale.clone(),
1584            span: None,
1585        })
1586    }
1587
1588    fn structural(&mut self, id: &str) -> Result<String> {
1589        self.spelling(Kind::Structural, id)
1590    }
1591
1592    /// The localized spelling of a canonical enum member, resolving through
1593    /// the catalog (fallback-aware; see [`Emitter::spelling`]).
1594    fn enum_spelling(&mut self, domain: &str, member: &str) -> Result<String> {
1595        let Some(domain_entry) = self.catalog.enum_domain(domain) else {
1596            return Err(WorkshopError::Unknown {
1597                kind: "enum domain",
1598                spelling: domain.to_string(),
1599                locale: self.locale.clone(),
1600                span: None,
1601            });
1602        };
1603        let Some(member_entry) = domain_entry.members.iter().find(|m| m.member == member) else {
1604            return Err(WorkshopError::Unknown {
1605                kind: "enum member",
1606                spelling: format!("{domain}.{member}"),
1607                locale: self.locale.clone(),
1608                span: None,
1609            });
1610        };
1611        if let Some(spelling) = member_entry.spelling(&self.locale) {
1612            return Ok(spelling.to_string());
1613        }
1614        if let Some(fallback) = &self.fallback {
1615            if let Some(spelling) = member_entry.spelling(fallback) {
1616                self.fallback_ids.push(format!("{domain}.{member}"));
1617                return Ok(spelling.to_string());
1618            }
1619        }
1620        Err(WorkshopError::MissingMapping {
1621            kind: "enum member",
1622            id: format!("{domain}.{member}"),
1623            locale: self.locale.clone(),
1624        })
1625    }
1626
1627    /// Render a value that must stay a bare string (the `Custom String` text
1628    /// argument). Any non-string value falls back to the normal renderer.
1629    fn bare_string_value(&mut self, id: wir::ValueId, out: &mut String) -> Result<()> {
1630        let Some(node) = self.program.values.get(id) else {
1631            return Err(WorkshopError::Malformed {
1632                message: format!("dangling value {id}"),
1633                span: None,
1634            });
1635        };
1636        if let wir::Value::String(value) = &node.value {
1637            write!(out, "\"{}\"", escape_value_string(value)).unwrap();
1638            return Ok(());
1639        }
1640        self.value(id, out)
1641    }
1642
1643    /// Emit a value-position string as `Custom String("...")`, splitting it
1644    /// into a continuation chain when it exceeds the Workshop 128-char limit.
1645    fn emit_string_value(&mut self, value: &str, out: &mut String) -> Result<()> {
1646        let spelling = self.spelling(Kind::Value, "customString")?;
1647        let segments = split_string(value);
1648        emit_string_chain(&spelling, &segments, out);
1649        Ok(())
1650    }
1651
1652    fn global_name(&self, id: wir::GlobalVarId) -> Result<String> {
1653        self.program
1654            .global_variables
1655            .get(id)
1656            .map(|variable| variable.name.clone())
1657            .ok_or_else(|| WorkshopError::Unknown {
1658                kind: "global variable",
1659                spelling: format!("<{id}>"),
1660                locale: self.locale.clone(),
1661                span: None,
1662            })
1663    }
1664
1665    fn player_name(&self, id: wir::PlayerVarId) -> Result<String> {
1666        self.program
1667            .player_variables
1668            .get(id)
1669            .map(|variable| variable.name.clone())
1670            .ok_or_else(|| WorkshopError::Unknown {
1671                kind: "player variable",
1672                spelling: format!("<{id}>"),
1673                locale: self.locale.clone(),
1674                span: None,
1675            })
1676    }
1677
1678    fn line(&mut self, level: usize, text: &str) -> Result<()> {
1679        for _ in 0..level {
1680            self.out.push_str("    ");
1681        }
1682        self.out.push_str(text);
1683        self.out.push('\n');
1684        self.line_count += 1;
1685        Ok(())
1686    }
1687}
1688
1689/// Format a float like the reference frontend: integers print without a
1690/// decimal point, and non-integers print the shortest round-trip
1691/// representation truncated to 16 significant digits (OverPy behavior;
1692/// evidence: the pinned oracle snapshots).
1693fn is_comparison_operator(name: &str) -> bool {
1694    matches!(name, "==" | "!=" | "<" | "<=" | ">" | ">=")
1695}
1696
1697fn escape_string(value: &str) -> String {
1698    value.replace('"', "\\\"")
1699}
1700
1701/// Re-escape a decoded value string the way the pinned oracle does (#87):
1702/// `\`, `"`, newline, and carriage return re-escape; tabs pass through raw
1703/// (byte-measured oracle behavior: `a\tb` emits a real tab, `a\nb` emits the
1704/// literal two-character `\n`).
1705fn escape_value_string(value: &str) -> String {
1706    let mut out = String::with_capacity(value.len());
1707    for ch in value.chars() {
1708        match ch {
1709            '\\' => out.push_str("\\\\"),
1710            '"' => out.push_str("\\\""),
1711            '\n' => out.push_str("\\n"),
1712            '\r' => out.push_str("\\r"),
1713            other => out.push(other),
1714        }
1715    }
1716    out
1717}
1718
1719/// Split a decoded string per the oracle's long-string rule (#87): when the
1720/// decoded length exceeds the Workshop 128-char limit, non-final segments
1721/// hold exactly 125 decoded chars and are emitted with a `{0}` continuation
1722/// placeholder (128 total text chars), chained as nested `Custom String`
1723/// arguments; the final segment holds the remainder without a placeholder.
1724/// Segment texts are re-escaped. Byte-measured basis: chunk sizes are
1725/// counted on the decoded string (70 escaped newlines — 140 escaped chars,
1726/// 70 decoded — emit unsplit; 129 decoded newlines split at 125 decoded).
1727fn split_string(value: &str) -> Vec<String> {
1728    if value.chars().count() <= 128 {
1729        return vec![escape_value_string(value)];
1730    }
1731    let mut segments = Vec::new();
1732    let mut rest = value;
1733    while rest.chars().count() > 125 {
1734        let chunk: String = rest.chars().take(125).collect();
1735        let mut text = escape_value_string(&chunk);
1736        text.push_str("{0}");
1737        segments.push(text);
1738        rest = &rest[chunk.len()..];
1739    }
1740    if !rest.is_empty() {
1741        segments.push(escape_value_string(rest));
1742    }
1743    segments
1744}
1745
1746/// Escape a settings string value the way the pinned oracle does: every
1747/// decode the JSONC parser performed is re-escaped, so decoded values
1748/// round-trip to the oracle's spelling. Evidence: the inputhud description
1749/// (`\n` in the source block) is emitted by the oracle as the literal
1750/// two-character sequence `\n` in the Workshop settings section.
1751fn escape_settings_string(value: &str) -> String {
1752    let mut out = String::with_capacity(value.len());
1753    for ch in value.chars() {
1754        match ch {
1755            '\\' => out.push_str("\\\\"),
1756            '"' => out.push_str("\\\""),
1757            '\n' => out.push_str("\\n"),
1758            '\t' => out.push_str("\\t"),
1759            '\r' => out.push_str("\\r"),
1760            other => out.push(other),
1761        }
1762    }
1763    out
1764}
1765
1766/// Emit the nested continuation chain
1767/// `Custom String(seg0, Custom String(seg1, ...))`; segment texts are
1768/// pre-escaped, non-final segments carry the `{0}` placeholder. Iterative:
1769/// every segment except the first opens a `Custom String` level, then all
1770/// levels close.
1771fn emit_string_chain(spelling: &str, segments: &[String], out: &mut String) {
1772    let Some((first, rest)) = segments.split_first() else {
1773        return;
1774    };
1775    out.push_str(spelling);
1776    out.push('(');
1777    write!(out, "\"{first}\"").unwrap();
1778    for segment in rest {
1779        out.push_str(", ");
1780        out.push_str(spelling);
1781        out.push('(');
1782        write!(out, "\"{segment}\"").unwrap();
1783    }
1784    for _ in 0..=rest.len() {
1785        out.push(')');
1786    }
1787}
1788
1789/// Render a constant format argument the way the oracle folds it: integers
1790/// without decimals, non-integers with exactly two decimals (JS `toFixed(2)`
1791/// rounding: `0.5` -> `0.50`, `0.125` -> `0.13`, #87).
1792fn fold_number(value: f64) -> String {
1793    if value.fract() == 0.0 && value.abs() < 1e15 {
1794        format!("{}", value as i64)
1795    } else {
1796        let scaled = (value * 100.0).round();
1797        let sign = if scaled < 0.0 { "-" } else { "" };
1798        let scaled = scaled.abs() as i64;
1799        format!("{sign}{}.{:02}", scaled / 100, scaled % 100)
1800    }
1801}