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::AssignMember {
877                target, op, value, ..
878            } => {
879                let mut target_text = String::new();
880                let mut value_text = String::new();
881                self.value(*target, &mut target_text)?;
882                self.value(*value, &mut value_text)?;
883                let operator = match op {
884                    None => "=".to_string(),
885                    Some(op) => {
886                        let token = match op {
887                            wir::ModifyOp::Add => "+",
888                            wir::ModifyOp::Subtract => "-",
889                            wir::ModifyOp::Multiply => "*",
890                            wir::ModifyOp::Divide => "/",
891                            wir::ModifyOp::Modulo => "%",
892                            wir::ModifyOp::Min => "min",
893                            wir::ModifyOp::Max => "max",
894                            _ => {
895                                return Err(WorkshopError::Unsupported {
896                                    message: format!(
897                                        "unsupported member assignment operator {op:?}"
898                                    ),
899                                    span: None,
900                                });
901                            }
902                        };
903                        format!("{token}=")
904                    }
905                };
906                self.line(level, &format!("{target_text} {operator} {value_text};"))?;
907            }
908            wir::Action::Call { name, args, .. } => {
909                // The chase family dispatches on the first argument's
910                // variable kind, mirroring the pinned reference: a global
911                // variable emits the global form with the argument list
912                // unchanged; a player variable emits the player form with
913                // the receiver split into `player, name` leading arguments
914                // (the frontend guarantees a variable first argument,
915                // issue #110).
916                if matches!(name.as_str(), "chaseAtRate" | "chaseOverTime") {
917                    let player_var = args.first().and_then(|id| {
918                        self.program
919                            .values
920                            .get(*id)
921                            .and_then(|node| match &node.value {
922                                wir::Value::PlayerVariable { player, variable } => {
923                                    Some((*player, *variable))
924                                }
925                                _ => None,
926                            })
927                    });
928                    let spelling = if let Some((player, variable)) = player_var {
929                        let id = if name == "chaseAtRate" {
930                            "chasePlayerVariableAtRate"
931                        } else {
932                            "chasePlayerVariableOverTime"
933                        };
934                        let spelling = self.spelling(Kind::Action, id)?;
935                        // `Chase Player Variable At Rate(player, name, …)`:
936                        // the receiver splits into `player, name` leading
937                        // arguments (the pinned oracle's spelling).
938                        let mut text = String::new();
939                        self.value(player, &mut text)?;
940                        let mut parts = vec![text, self.player_name(variable)?];
941                        for arg in args.iter().skip(1) {
942                            let mut part = String::new();
943                            self.value(*arg, &mut part)?;
944                            parts.push(part);
945                        }
946                        return self.line(level, &format!("{spelling}({});", parts.join(", ")));
947                    } else {
948                        self.spelling(Kind::Action, name)?
949                    };
950                    let mut args_text = String::new();
951                    self.args(args, &mut args_text)?;
952                    return self.line(level, &format!("{spelling}({args_text});"));
953                }
954                if name == "stopChasingPlayerVariable" {
955                    let Some((player, variable)) = args.first().and_then(|id| {
956                        self.program
957                            .values
958                            .get(*id)
959                            .and_then(|node| match &node.value {
960                                wir::Value::PlayerVariable { player, variable } => {
961                                    Some((*player, *variable))
962                                }
963                                _ => None,
964                            })
965                    }) else {
966                        return Err(WorkshopError::Malformed {
967                            message: "Stop Chasing Player Variable requires a player variable"
968                                .into(),
969                            span: None,
970                        });
971                    };
972                    let spelling = self.spelling(Kind::Action, name)?;
973                    let mut player_text = String::new();
974                    self.value(player, &mut player_text)?;
975                    return self.line(
976                        level,
977                        &format!(
978                            "{spelling}({player_text}, {});",
979                            self.player_name(variable)?
980                        ),
981                    );
982                }
983                // Native `.opy` action names map to canonical catalog ids at
984                // emission (presentation concern).
985                let canonical = match name.as_str() {
986                    "createBeam" => Some("createBeamEffect"),
987                    _ => None,
988                };
989                let spelling = if let Some(canonical) = canonical {
990                    self.spelling(Kind::Action, canonical)?
991                } else {
992                    self.spelling(Kind::Action, name)?
993                };
994                if args.is_empty() {
995                    self.line(level, &format!("{spelling};"))?;
996                } else {
997                    let mut args_text = String::new();
998                    for (index, arg) in args.iter().enumerate() {
999                        if index > 0 {
1000                            args_text.push_str(", ");
1001                        }
1002                        let variable_position = match name.as_str() {
1003                            "setGlobalVariableAtIndex" | "modifyGlobalVariableAtIndex" => {
1004                                index == 0
1005                            }
1006                            "setPlayerVariableAtIndex" | "modifyPlayerVariableAtIndex" => {
1007                                index == 1
1008                            }
1009                            _ => false,
1010                        };
1011                        if variable_position {
1012                            if let Some(node) = self.program.values.get(*arg) {
1013                                match &node.value {
1014                                    wir::Value::GlobalVariable(variable) => {
1015                                        args_text.push_str(&self.global_name(*variable)?);
1016                                        continue;
1017                                    }
1018                                    wir::Value::PlayerVariable { variable, .. } => {
1019                                        args_text.push_str(&self.player_name(*variable)?);
1020                                        continue;
1021                                    }
1022                                    _ => {}
1023                                }
1024                            }
1025                        }
1026                        self.value(*arg, &mut args_text)?;
1027                    }
1028                    self.line(level, &format!("{spelling}({args_text});"))?;
1029                }
1030            }
1031        }
1032        Ok(())
1033    }
1034
1035    fn args(&mut self, args: &[wir::ValueId], out: &mut String) -> Result<()> {
1036        for (index, arg) in args.iter().enumerate() {
1037            if index > 0 {
1038                out.push_str(", ");
1039            }
1040            self.value(*arg, out)?;
1041        }
1042        Ok(())
1043    }
1044
1045    fn value(&mut self, id: wir::ValueId, out: &mut String) -> Result<()> {
1046        let Some(node) = self.program.values.get(id) else {
1047            return Err(WorkshopError::Malformed {
1048                message: format!("dangling value {id}"),
1049                span: None,
1050            });
1051        };
1052        match &node.value {
1053            wir::Value::Number { text, .. } => {
1054                // Literal spellings carry through (the oracle preserves the
1055                // source spelling, e.g. `0.0`; computed values carry the
1056                // formatted spelling, #87).
1057                out.push_str(text);
1058            }
1059            wir::Value::String(value) => {
1060                // Value-position strings wrap in `Custom String("...")` with
1061                // re-escaped content and long-string splitting, the pinned
1062                // oracle's spelling (evidence: array elements, initializers,
1063                // assignments, call arguments, comparisons — #87). The only
1064                // bare string value is the `Custom String` text argument,
1065                // handled in the call arm below.
1066                self.emit_string_value(value, out)?;
1067            }
1068            wir::Value::LocalizedString(id) => {
1069                out.push_str(&self.spelling(Kind::Value, "string")?);
1070                out.push('(');
1071                let spelling = self.localized_string_spelling(id)?;
1072                write!(out, "\"{}\"", escape_value_string(&spelling)).unwrap();
1073                out.push(')');
1074            }
1075            wir::Value::Bool(true) => out.push_str("True"),
1076            wir::Value::Bool(false) => out.push_str("False"),
1077            wir::Value::Null => out.push_str("Null"),
1078            wir::Value::Array(elements) => {
1079                if elements.is_empty() {
1080                    // The canonical empty-array constant (reference emission).
1081                    out.push_str(&self.spelling(Kind::Value, "emptyArray")?);
1082                } else {
1083                    out.push_str(&self.spelling(Kind::Value, "array")?);
1084                    out.push('(');
1085                    self.args(elements, out)?;
1086                    out.push(')');
1087                }
1088            }
1089            wir::Value::Vector { x, y, z } => {
1090                out.push_str(&self.spelling(Kind::Value, "vector")?);
1091                out.push('(');
1092                self.value(*x, out)?;
1093                out.push_str(", ");
1094                self.value(*y, out)?;
1095                out.push_str(", ");
1096                self.value(*z, out)?;
1097                out.push(')');
1098            }
1099            wir::Value::Enum { value_type, value } => {
1100                let spelling = self.enum_spelling(value_type, value)?;
1101                // Color, Team, and Hero values use the constructor form;
1102                // other domains use bare member spellings (the canonical
1103                // corpus form). The
1104                // Team/Color spelling collision (`Team 2` is both a Team and
1105                // a Team color) is the one ambiguity unpinned by the
1106                // catalog's paramDomains, so Team members qualify with the
1107                // constructor form and the emitted text reparses
1108                // deterministically (round-trip contract; pinned P4
1109                // evidence).
1110                if matches!(value_type.as_str(), "Color" | "Map" | "Team")
1111                    || value_type == "Hero"
1112                        && (spelling.contains('.')
1113                            || self.locale != *self.catalog.primary_locale()
1114                            || (self.force_hero_constructors
1115                                && self
1116                                    .program
1117                                    .global_variables
1118                                    .iter()
1119                                    .any(|variable| variable.name == spelling)))
1120                {
1121                    let domain = self
1122                        .catalog
1123                        .enum_domain(value_type)
1124                        .and_then(|entry| entry.spelling(&self.locale))
1125                        .unwrap_or(value_type);
1126                    write!(out, "{domain}({spelling})").unwrap();
1127                } else {
1128                    out.push_str(&spelling);
1129                }
1130            }
1131            wir::Value::GlobalVariable(variable) => {
1132                let name = self.global_name(*variable)?;
1133                write!(out, "Global.{name}").unwrap();
1134            }
1135            wir::Value::PlayerVariable { player, variable } => {
1136                // The oracle's spelling parenthesizes the receiver:
1137                // `Set Global Variable(g, (Event Player).p)` (#87).
1138                out.push('(');
1139                self.value(*player, out)?;
1140                out.push(')');
1141                let name = self.player_name(*variable)?;
1142                write!(out, ".{name}").unwrap();
1143            }
1144            wir::Value::Subroutine(subroutine) => {
1145                let name = self
1146                    .program
1147                    .subroutines
1148                    .get(*subroutine)
1149                    .map(|value| value.name.clone())
1150                    .ok_or_else(|| WorkshopError::Malformed {
1151                        message: format!("dangling subroutine value {subroutine}"),
1152                        span: None,
1153                    })?;
1154                out.push_str(&name);
1155            }
1156            wir::Value::EventPlayer => out.push_str(&self.spelling(Kind::Value, "eventPlayer")?),
1157            wir::Value::Call { name, args } => {
1158                if name == "memberAccess" {
1159                    if args.len() < 2 || args.len() > 3 {
1160                        return Err(WorkshopError::Malformed {
1161                            message: "memberAccess expects two or three arguments".to_string(),
1162                            span: node.span,
1163                        });
1164                    }
1165                    let Some(wir::ValueNode {
1166                        value: wir::Value::String(member),
1167                        ..
1168                    }) = self.program.values.get(args[1])
1169                    else {
1170                        return Err(WorkshopError::Malformed {
1171                            message: "memberAccess member must be a string".to_string(),
1172                            span: node.span,
1173                        });
1174                    };
1175                    let bare_event_player = self
1176                        .program
1177                        .values
1178                        .get(args[0])
1179                        .is_some_and(|node| matches!(node.value, wir::Value::EventPlayer));
1180                    if !bare_event_player {
1181                        out.push('(');
1182                    }
1183                    self.value(args[0], out)?;
1184                    if !bare_event_player {
1185                        out.push(')');
1186                    }
1187                    write!(out, ".{member}").unwrap();
1188                    if let Some(index) = args.get(2) {
1189                        out.push('[');
1190                        self.value(*index, out)?;
1191                        out.push(']');
1192                    }
1193                    return Ok(());
1194                }
1195                if is_comparison_operator(name) {
1196                    // Canonical form: Compare(a, op, b).
1197                    if args.len() != 2 {
1198                        return Err(WorkshopError::Malformed {
1199                            message: format!("comparison call '{name}' must have 2 args"),
1200                            span: None,
1201                        });
1202                    }
1203                    out.push_str(&self.spelling(Kind::Value, "compare")?);
1204                    out.push('(');
1205                    self.value(args[0], out)?;
1206                    write!(out, ", {name}, ").unwrap();
1207                    self.value(args[1], out)?;
1208                    out.push(')');
1209                    return Ok(());
1210                }
1211                // Unary minus renders as Multiply(-1, x); the reference folds
1212                // literal negation, handled by the compat constant-fold pass.
1213                if name == "-" && args.len() == 1 {
1214                    out.push_str(&self.spelling(Kind::Value, "multiply")?);
1215                    out.push_str("(-1, ");
1216                    self.value(args[0], out)?;
1217                    out.push(')');
1218                    return Ok(());
1219                }
1220                // `getAllPlayers()` is OverPy's All Players(All Teams).
1221                if name == "getAllPlayers" && args.is_empty() {
1222                    out.push_str(&self.spelling(Kind::Value, "allPlayers")?);
1223                    out.push('(');
1224                    out.push_str(&self.enum_spelling("Team", "ALL")?);
1225                    out.push(')');
1226                    return Ok(());
1227                }
1228                // Binary arithmetic operators and native `.opy` source names
1229                // map to canonical catalog ids at emission (presentation
1230                // concern; the compat pass folds constants to match the
1231                // reference exactly).
1232                let canonical = match name.as_str() {
1233                    "+" => Some("add"),
1234                    "-" => Some("subtract"),
1235                    "*" => Some("multiply"),
1236                    "/" => Some("divide"),
1237                    "len" => Some("countOf"),
1238                    "abs" => Some("absoluteValue"),
1239                    "sqrt" => Some("squareRoot"),
1240                    "createBeam" => Some("createBeamEffect"),
1241                    "random.uniform" => Some("randomReal"),
1242                    "random.choice" => Some("randomValueInArray"),
1243                    "format" => Some("customString"),
1244                    _ => None,
1245                };
1246                let spelling = if let Some(canonical) = canonical {
1247                    self.spelling(Kind::Value, canonical)?
1248                } else {
1249                    self.spelling(Kind::Value, name)?
1250                };
1251                // `format` (frontend) and `customString` (parsed ws text) are
1252                // the same node.
1253                let is_custom_string = canonical == Some("customString") || name == "customString";
1254                if name == "string" {
1255                    out.push_str(&spelling);
1256                    out.push('(');
1257                    if let Some(first) = args.first() {
1258                        self.localized_string_value(*first, out)?;
1259                        if args.len() > 1 {
1260                            out.push_str(", ");
1261                            self.args(&args[1..], out)?;
1262                        }
1263                    }
1264                    out.push(')');
1265                } else if args.is_empty() {
1266                    // Constants (e.g. Empty Array) emit as bare spellings.
1267                    out.push_str(&spelling);
1268                } else if is_custom_string {
1269                    // `.format()` calls canonicalize: constant numeric
1270                    // arguments fold into the substituted text, implicit
1271                    // `{}` placeholders renumber to the oracle's explicit
1272                    // form, and remaining variable arguments wrap (the
1273                    // oracle spelling, #87). The canonical text feeds the
1274                    // value-string path (re-escaping/splitting) when no
1275                    // arguments remain.
1276                    match self.canonicalize_format_call(args)? {
1277                        Some((text, variable_args)) => {
1278                            if variable_args.is_empty() {
1279                                self.emit_string_value(&text, out)?;
1280                            } else {
1281                                out.push_str(&spelling);
1282                                out.push('(');
1283                                write!(out, "\"{}\"", escape_value_string(&text)).unwrap();
1284                                if !variable_args.is_empty() {
1285                                    out.push_str(", ");
1286                                }
1287                                self.args(&variable_args, out)?;
1288                                out.push(')');
1289                            }
1290                        }
1291                        None => {
1292                            // The `Custom String` text argument stays bare
1293                            // (the oracle spelling); the remaining arguments
1294                            // are values and wrap (#87).
1295                            out.push_str(&spelling);
1296                            out.push('(');
1297                            self.bare_string_value(args[0], out)?;
1298                            if args.len() > 1 {
1299                                out.push_str(", ");
1300                            }
1301                            self.args(&args[1..], out)?;
1302                            out.push(')');
1303                        }
1304                    }
1305                } else {
1306                    out.push_str(&spelling);
1307                    out.push('(');
1308                    self.args(args, out)?;
1309                    out.push(')');
1310                }
1311            }
1312        }
1313        Ok(())
1314    }
1315
1316    fn localized_string_value(&mut self, id: wir::ValueId, out: &mut String) -> Result<()> {
1317        let Some(node) = self.program.values.get(id) else {
1318            return Err(WorkshopError::Malformed {
1319                message: format!("dangling value {id}"),
1320                span: None,
1321            });
1322        };
1323        let wir::Value::LocalizedString(id) = &node.value else {
1324            return Err(WorkshopError::Unsupported {
1325                message: "value 'string' argument 1 must be localized string text".to_string(),
1326                span: node.span,
1327            });
1328        };
1329        let spelling = self.localized_string_spelling(id)?;
1330        write!(out, "\"{}\"", escape_value_string(&spelling)).unwrap();
1331        Ok(())
1332    }
1333
1334    /// Fold a `Custom String` call whose text argument and constant numeric
1335    /// arguments are all literals into the substituted text (the oracle's
1336    /// Canonicalize a `Custom String`/`.format()` call (#87): constant
1337    /// numeric arguments fold into the substituted text (the oracle's
1338    /// spelling), implicit `{}` placeholders renumber positionally to the
1339    /// explicit `{N}` form, and the remaining variable arguments are
1340    /// returned in placeholder order. Returns `None` (rendered unchanged)
1341    /// when nothing canonicalizes: explicit-only texts without constants,
1342    /// texts mixing implicit and explicit placeholders (the oracle rejects
1343    /// those), out-of-range placeholders, or non-String text arguments.
1344    fn canonicalize_format_call(
1345        &self,
1346        args: &[wir::ValueId],
1347    ) -> Result<Option<(String, Vec<wir::ValueId>)>> {
1348        if args.len() < 2 {
1349            return Ok(None);
1350        }
1351        let Some(text) = self.program.values.get(args[0]) else {
1352            return Ok(None);
1353        };
1354        let wir::Value::String(text) = &text.value else {
1355            return Ok(None);
1356        };
1357        let format_args = &args[1..];
1358        // Classify the placeholders: implicit `{}` consumes the next
1359        // argument, explicit `{N}` references argument N.
1360        let mut has_implicit = false;
1361        let mut has_explicit = false;
1362        let mut out_of_range = false;
1363        let mut cursor = 0usize;
1364        let mut chars = text.chars().peekable();
1365        while let Some(ch) = chars.next() {
1366            if ch == '{' {
1367                let mut inner = String::new();
1368                let mut closed = false;
1369                for next in chars.by_ref() {
1370                    if next == '}' {
1371                        closed = true;
1372                        break;
1373                    }
1374                    inner.push(next);
1375                }
1376                if !closed {
1377                    break; // unterminated brace: literal text
1378                }
1379                if inner.is_empty() {
1380                    if cursor >= format_args.len() {
1381                        out_of_range = true;
1382                    }
1383                    cursor += 1;
1384                    has_implicit = true;
1385                } else if inner.chars().all(|c| c.is_ascii_digit()) {
1386                    match inner.parse::<usize>() {
1387                        Ok(index) if index < format_args.len() => has_explicit = true,
1388                        _ => out_of_range = true,
1389                    }
1390                } else {
1391                    out_of_range = true;
1392                }
1393            }
1394        }
1395        if out_of_range || (has_implicit && has_explicit) {
1396            return Ok(None);
1397        }
1398        let mut any_constant = false;
1399        for id in format_args {
1400            let Some(node) = self.program.values.get(*id) else {
1401                return Ok(None);
1402            };
1403            if matches!(node.value, wir::Value::Number { .. }) {
1404                any_constant = true;
1405            }
1406        }
1407        if !has_implicit && !any_constant {
1408            return Ok(None);
1409        }
1410        // Canonicalize: fold constants inline at their placeholder, renumber
1411        // variable placeholders positionally, keep variable arguments in
1412        // placeholder order.
1413        let mut canonical = String::with_capacity(text.len());
1414        let mut variable_args = Vec::new();
1415        let mut variable_index = 0usize;
1416        let mut cursor = 0usize;
1417        let mut chars = text.chars().peekable();
1418        while let Some(ch) = chars.next() {
1419            if ch == '{' {
1420                let mut inner = String::new();
1421                let mut closed = false;
1422                for next in chars.by_ref() {
1423                    if next == '}' {
1424                        closed = true;
1425                        break;
1426                    }
1427                    inner.push(next);
1428                }
1429                if !closed {
1430                    canonical.push('{');
1431                    canonical.push_str(&inner);
1432                    break;
1433                }
1434                let index = if inner.is_empty() {
1435                    let index = cursor;
1436                    cursor += 1;
1437                    index
1438                } else {
1439                    match inner.parse::<usize>() {
1440                        Ok(index) => index,
1441                        Err(_) => {
1442                            canonical.push('{');
1443                            canonical.push_str(&inner);
1444                            canonical.push('}');
1445                            continue;
1446                        }
1447                    }
1448                };
1449                let Some(arg) = format_args.get(index).copied() else {
1450                    canonical.push('{');
1451                    canonical.push_str(&inner);
1452                    canonical.push('}');
1453                    continue;
1454                };
1455                let node = self.program.values.get(arg);
1456                if let Some(wir::Value::Number { value, .. }) = node.map(|node| &node.value) {
1457                    canonical.push_str(&fold_number(*value));
1458                } else {
1459                    write!(canonical, "{{{variable_index}}}").unwrap();
1460                    variable_index += 1;
1461                    variable_args.push(arg);
1462                }
1463            } else {
1464                canonical.push(ch);
1465            }
1466        }
1467        Ok(Some((canonical, variable_args)))
1468    }
1469
1470    /// The localized spelling of a modify operator, resolved through the
1471    /// catalog (fallback-aware).
1472    fn modify_op_spelling(&mut self, op: wir::ModifyOp) -> Result<String> {
1473        self.spelling(Kind::Operator, op.catalog_id())
1474    }
1475
1476    /// The localized spelling of a canonical builtin id, resolving through
1477    /// the catalog: a dangling id is `Unknown`, an id without a target-locale
1478    /// mapping is `MissingMapping` unless an opt-in fallback locale declares
1479    /// one (recorded in [`Emitter::fallback_ids`]).
1480    fn spelling(&mut self, kind: Kind, id: &str) -> Result<String> {
1481        let Some(entry) = self.catalog.entry(kind, id) else {
1482            return Err(WorkshopError::Unknown {
1483                kind: kind.as_str(),
1484                spelling: id.to_string(),
1485                locale: self.locale.clone(),
1486                span: None,
1487            });
1488        };
1489        if let Some(spelling) = entry.spelling(&self.locale) {
1490            return Ok(spelling.to_string());
1491        }
1492        if let Some(fallback) = &self.fallback {
1493            if let Some(spelling) = entry.spelling(fallback) {
1494                self.fallback_ids.push(id.to_string());
1495                return Ok(spelling.to_string());
1496            }
1497        }
1498        Err(WorkshopError::MissingMapping {
1499            kind: kind.as_str(),
1500            id: id.to_string(),
1501            locale: self.locale.clone(),
1502        })
1503    }
1504
1505    fn localized_string_spelling(&mut self, id: &str) -> Result<String> {
1506        if let Some(spelling) = self.catalog.localized_string_spelling(&self.locale, id) {
1507            return Ok(spelling.to_string());
1508        }
1509        if let Some(fallback) = &self.fallback {
1510            if let Some(spelling) = self.catalog.localized_string_spelling(fallback, id) {
1511                self.fallback_ids.push(format!("localizedString.{id}"));
1512                return Ok(spelling.to_string());
1513            }
1514        }
1515        if self.catalog.localized_strings().any(|entry| entry.id == id) {
1516            return Err(WorkshopError::MissingMapping {
1517                kind: "localized string",
1518                id: id.to_string(),
1519                locale: self.locale.clone(),
1520            });
1521        }
1522        Err(WorkshopError::Unknown {
1523            kind: "localized string",
1524            spelling: id.to_string(),
1525            locale: self.locale.clone(),
1526            span: None,
1527        })
1528    }
1529
1530    fn structural(&mut self, id: &str) -> Result<String> {
1531        self.spelling(Kind::Structural, id)
1532    }
1533
1534    /// The localized spelling of a canonical enum member, resolving through
1535    /// the catalog (fallback-aware; see [`Emitter::spelling`]).
1536    fn enum_spelling(&mut self, domain: &str, member: &str) -> Result<String> {
1537        let Some(domain_entry) = self.catalog.enum_domain(domain) else {
1538            return Err(WorkshopError::Unknown {
1539                kind: "enum domain",
1540                spelling: domain.to_string(),
1541                locale: self.locale.clone(),
1542                span: None,
1543            });
1544        };
1545        let Some(member_entry) = domain_entry.members.iter().find(|m| m.member == member) else {
1546            return Err(WorkshopError::Unknown {
1547                kind: "enum member",
1548                spelling: format!("{domain}.{member}"),
1549                locale: self.locale.clone(),
1550                span: None,
1551            });
1552        };
1553        if let Some(spelling) = member_entry.spelling(&self.locale) {
1554            return Ok(spelling.to_string());
1555        }
1556        if let Some(fallback) = &self.fallback {
1557            if let Some(spelling) = member_entry.spelling(fallback) {
1558                self.fallback_ids.push(format!("{domain}.{member}"));
1559                return Ok(spelling.to_string());
1560            }
1561        }
1562        Err(WorkshopError::MissingMapping {
1563            kind: "enum member",
1564            id: format!("{domain}.{member}"),
1565            locale: self.locale.clone(),
1566        })
1567    }
1568
1569    /// Render a value that must stay a bare string (the `Custom String` text
1570    /// argument). Any non-string value falls back to the normal renderer.
1571    fn bare_string_value(&mut self, id: wir::ValueId, out: &mut String) -> Result<()> {
1572        let Some(node) = self.program.values.get(id) else {
1573            return Err(WorkshopError::Malformed {
1574                message: format!("dangling value {id}"),
1575                span: None,
1576            });
1577        };
1578        if let wir::Value::String(value) = &node.value {
1579            write!(out, "\"{}\"", escape_value_string(value)).unwrap();
1580            return Ok(());
1581        }
1582        self.value(id, out)
1583    }
1584
1585    /// Emit a value-position string as `Custom String("...")`, splitting it
1586    /// into a continuation chain when it exceeds the Workshop 128-char limit.
1587    fn emit_string_value(&mut self, value: &str, out: &mut String) -> Result<()> {
1588        let spelling = self.spelling(Kind::Value, "customString")?;
1589        let segments = split_string(value);
1590        emit_string_chain(&spelling, &segments, out);
1591        Ok(())
1592    }
1593
1594    fn global_name(&self, id: wir::GlobalVarId) -> Result<String> {
1595        self.program
1596            .global_variables
1597            .get(id)
1598            .map(|variable| variable.name.clone())
1599            .ok_or_else(|| WorkshopError::Unknown {
1600                kind: "global variable",
1601                spelling: format!("<{id}>"),
1602                locale: self.locale.clone(),
1603                span: None,
1604            })
1605    }
1606
1607    fn player_name(&self, id: wir::PlayerVarId) -> Result<String> {
1608        self.program
1609            .player_variables
1610            .get(id)
1611            .map(|variable| variable.name.clone())
1612            .ok_or_else(|| WorkshopError::Unknown {
1613                kind: "player variable",
1614                spelling: format!("<{id}>"),
1615                locale: self.locale.clone(),
1616                span: None,
1617            })
1618    }
1619
1620    fn line(&mut self, level: usize, text: &str) -> Result<()> {
1621        for _ in 0..level {
1622            self.out.push_str("    ");
1623        }
1624        self.out.push_str(text);
1625        self.out.push('\n');
1626        self.line_count += 1;
1627        Ok(())
1628    }
1629}
1630
1631/// Format a float like the reference frontend: integers print without a
1632/// decimal point, and non-integers print the shortest round-trip
1633/// representation truncated to 16 significant digits (OverPy behavior;
1634/// evidence: the pinned oracle snapshots).
1635fn is_comparison_operator(name: &str) -> bool {
1636    matches!(name, "==" | "!=" | "<" | "<=" | ">" | ">=")
1637}
1638
1639fn escape_string(value: &str) -> String {
1640    value.replace('"', "\\\"")
1641}
1642
1643/// Re-escape a decoded value string the way the pinned oracle does (#87):
1644/// `\`, `"`, newline, and carriage return re-escape; tabs pass through raw
1645/// (byte-measured oracle behavior: `a\tb` emits a real tab, `a\nb` emits the
1646/// literal two-character `\n`).
1647fn escape_value_string(value: &str) -> String {
1648    let mut out = String::with_capacity(value.len());
1649    for ch in value.chars() {
1650        match ch {
1651            '\\' => out.push_str("\\\\"),
1652            '"' => out.push_str("\\\""),
1653            '\n' => out.push_str("\\n"),
1654            '\r' => out.push_str("\\r"),
1655            other => out.push(other),
1656        }
1657    }
1658    out
1659}
1660
1661/// Split a decoded string per the oracle's long-string rule (#87): when the
1662/// decoded length exceeds the Workshop 128-char limit, non-final segments
1663/// hold exactly 125 decoded chars and are emitted with a `{0}` continuation
1664/// placeholder (128 total text chars), chained as nested `Custom String`
1665/// arguments; the final segment holds the remainder without a placeholder.
1666/// Segment texts are re-escaped. Byte-measured basis: chunk sizes are
1667/// counted on the decoded string (70 escaped newlines — 140 escaped chars,
1668/// 70 decoded — emit unsplit; 129 decoded newlines split at 125 decoded).
1669fn split_string(value: &str) -> Vec<String> {
1670    if value.chars().count() <= 128 {
1671        return vec![escape_value_string(value)];
1672    }
1673    let mut segments = Vec::new();
1674    let mut rest = value;
1675    while rest.chars().count() > 125 {
1676        let chunk: String = rest.chars().take(125).collect();
1677        let mut text = escape_value_string(&chunk);
1678        text.push_str("{0}");
1679        segments.push(text);
1680        rest = &rest[chunk.len()..];
1681    }
1682    if !rest.is_empty() {
1683        segments.push(escape_value_string(rest));
1684    }
1685    segments
1686}
1687
1688/// Escape a settings string value the way the pinned oracle does: every
1689/// decode the JSONC parser performed is re-escaped, so decoded values
1690/// round-trip to the oracle's spelling. Evidence: the inputhud description
1691/// (`\n` in the source block) is emitted by the oracle as the literal
1692/// two-character sequence `\n` in the Workshop settings section.
1693fn escape_settings_string(value: &str) -> String {
1694    let mut out = String::with_capacity(value.len());
1695    for ch in value.chars() {
1696        match ch {
1697            '\\' => out.push_str("\\\\"),
1698            '"' => out.push_str("\\\""),
1699            '\n' => out.push_str("\\n"),
1700            '\t' => out.push_str("\\t"),
1701            '\r' => out.push_str("\\r"),
1702            other => out.push(other),
1703        }
1704    }
1705    out
1706}
1707
1708/// Emit the nested continuation chain
1709/// `Custom String(seg0, Custom String(seg1, ...))`; segment texts are
1710/// pre-escaped, non-final segments carry the `{0}` placeholder. Iterative:
1711/// every segment except the first opens a `Custom String` level, then all
1712/// levels close.
1713fn emit_string_chain(spelling: &str, segments: &[String], out: &mut String) {
1714    let Some((first, rest)) = segments.split_first() else {
1715        return;
1716    };
1717    out.push_str(spelling);
1718    out.push('(');
1719    write!(out, "\"{first}\"").unwrap();
1720    for segment in rest {
1721        out.push_str(", ");
1722        out.push_str(spelling);
1723        out.push('(');
1724        write!(out, "\"{segment}\"").unwrap();
1725    }
1726    for _ in 0..=rest.len() {
1727        out.push(')');
1728    }
1729}
1730
1731/// Render a constant format argument the way the oracle folds it: integers
1732/// without decimals, non-integers with exactly two decimals (JS `toFixed(2)`
1733/// rounding: `0.5` -> `0.50`, `0.125` -> `0.13`, #87).
1734fn fold_number(value: f64) -> String {
1735    if value.fract() == 0.0 && value.abs() < 1e15 {
1736        format!("{}", value as i64)
1737    } else {
1738        let scaled = (value * 100.0).round();
1739        let sign = if scaled < 0.0 { "-" } else { "" };
1740        let scaled = scaled.abs() as i64;
1741        format!("{sign}{}.{:02}", scaled / 100, scaled % 100)
1742    }
1743}