Skip to main content

workshop_rs/output/
compare.rs

1//! Behavioral comparison of canonical Workshop programs.
2//!
3//! [`equivalent`] reports whether two [`Program`](crate::Program)s behave the
4//! same; [`difference`] returns the first behavior-relevant difference with
5//! enough location to debug it. The comparison is the canonical equivalence
6//! used by source-language round trips such as Workshop→OPY reconstruction.
7//!
8//! Compared, in order: settings; the declared global-variable,
9//! player-variable, and subroutine index sets; non-subroutine rules in
10//! program order; and subroutine-event rules paired by the subroutine index
11//! they bind. Rules compare their event including team/hero/slot filters,
12//! enabled conditions in order, and enabled actions and values recursively,
13//! with variable and subroutine references resolved to declaration indices.
14//!
15//! Differences that cannot change behavior are allowed:
16//!
17//! - rule names — display metadata; nothing dispatches on them;
18//! - variable and subroutine names — identity is the declaration index;
19//! - declaration table order — only the name → index binding matters;
20//! - the relative order of subroutine-event rules — they run only when
21//!   called, never by program position;
22//! - `Set x = x <op> v` in place of `Modify x <op> v` on the same variable —
23//!   the same canonical effect;
24//! - an `EachPlayer` event vs `EachPlayer` with `All`/`All` filters — the
25//!   same player selection;
26//! - the presence or absence of disabled rules, conditions, and actions —
27//!   disabled constructs never execute;
28//! - the presence or absence of rules with no actions — they have no effect;
29//! - source spans, file identities, and text layout — not program semantics.
30//!
31//! Everything else is a difference — in particular algebraic identities,
32//! reordering independent `Set`s, and relocating non-subroutine rules
33//! (initializer rules keep their position). [`crate::roundtrip::equivalent`]
34//! remains the strict structural identity check; every program pair it
35//! accepts is also accepted here.
36
37use crate::ModifyOp;
38use crate::core::format::format_number;
39use crate::wir;
40
41use super::roundtrip::{ambiguous_enum_equivalent, canonical_value_name, settings_equivalent};
42
43/// Which program of a [`difference`] pair a report refers to.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ProgramSide {
46    /// `difference`'s first argument.
47    First,
48    /// `difference`'s second argument.
49    Second,
50}
51
52/// A declaration table in a [`Program`](crate::Program).
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum DeclarationTable {
55    /// `program.global_variables`
56    GlobalVariables,
57    /// `program.player_variables`
58    PlayerVariables,
59    /// `program.subroutines`
60    Subroutines,
61}
62
63/// One step from a compared element to the differing child element.
64///
65/// Indices count only compared elements: disabled conditions, disabled
66/// actions, and inert rules are skipped before comparison.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum PathStep {
70    /// The n-th enabled condition of the compared rules; a `Value(0)` step
71    /// after it enters the condition's value.
72    Condition(usize),
73    /// The n-th enabled action of the current action sequence — the rule's
74    /// actions with each control-flow group folded into its header action
75    /// (`If`, `While`, `For`), or a nested list entered through `Branch`,
76    /// `ElseBody`, or `Body`.
77    Action(usize),
78    /// The n-th value operand of an action — its value arguments in
79    /// declaration order (`If` carries one condition per branch) — or the
80    /// n-th child of a value: `Array` elements, `Call` arguments, `Vector`
81    /// x/y/z, or a `PlayerVariable`'s player. For the `Set`/`Modify` spelling
82    /// pair the steps index the `Set` value's call arguments; for a
83    /// `PlayerVariable`/`memberAccess` pair `Value(1)` is the memberAccess's
84    /// member operand.
85    Value(usize),
86    /// The body of the n-th `if`/`else if` branch of an `If` action.
87    Branch(usize),
88    /// The `else` body of an `If` action.
89    ElseBody,
90    /// The body of a `While` or `For` action.
91    Body,
92}
93
94/// What differs at a [`Difference`]'s location.
95#[derive(Debug, Clone, PartialEq)]
96#[non_exhaustive]
97pub enum DifferenceKind {
98    /// A program could not be materialized into canonical form, so the
99    /// comparison is undefined for it.
100    Malformed {
101        /// Which program failed to materialize.
102        program: ProgramSide,
103        /// The materialization error's message.
104        message: String,
105    },
106    /// The programs' `settings` differ.
107    Settings,
108    /// A declaration table declares a different index set.
109    DeclarationTable {
110        /// The declaration table that differs.
111        table: DeclarationTable,
112        /// The sorted indices the first program declares.
113        first: Vec<u32>,
114        /// The sorted indices the second program declares.
115        second: Vec<u32>,
116    },
117    /// Compared element counts differ; `element` names the compared list —
118    /// `"rules"`, `"subroutine rules"`, `"conditions"`, `"actions"`,
119    /// `"branches"`, `"arguments"`, or `"elements"`.
120    Count {
121        /// The compared list.
122        element: &'static str,
123        /// The count in the first program.
124        first: usize,
125        /// The count in the second program.
126        second: usize,
127    },
128    /// The compared rules' events differ; `first` and `second` describe the
129    /// two events.
130    Event { first: String, second: String },
131    /// The compared nodes have different kinds, e.g. `"setGlobalVariable"`
132    /// vs `"modifyGlobalVariable"` or `"number"` vs `"call"`.
133    Shape {
134        /// The first program's node kind.
135        first: &'static str,
136        /// The second program's node kind.
137        second: &'static str,
138    },
139    /// Variable or subroutine references resolve to different declaration
140    /// indices.
141    Reference {
142        /// The declaration table the reference resolves through.
143        table: DeclarationTable,
144        /// The index the first program's reference resolves to.
145        first: u32,
146        /// The index the second program's reference resolves to.
147        second: u32,
148    },
149    /// Modify operations differ (`"add"`, `"subtract"`, …).
150    Operation { first: String, second: String },
151    /// Scalar payloads differ — numbers, strings, enum members, call ids.
152    Scalar { first: String, second: String },
153}
154
155/// The first behavior-relevant difference [`difference`] found.
156#[derive(Debug, Clone, PartialEq)]
157#[non_exhaustive]
158pub struct Difference {
159    /// The compared rules' positions in each program's `rules`, or `None`
160    /// for differences above the rule level.
161    pub rules: Option<(usize, usize)>,
162    /// The path to the differing element inside the compared rules; empty
163    /// for rule-level differences (the event, or an element count).
164    pub path: Vec<PathStep>,
165    /// What differs at that location.
166    pub kind: DifferenceKind,
167}
168
169/// Whether two programs are behaviorally equivalent under this module's
170/// [comparison contract](self).
171pub fn equivalent(first: &crate::Program, second: &crate::Program) -> bool {
172    difference(first, second).is_none()
173}
174
175/// The first behavior-relevant difference between `first` and `second`
176/// under this module's [comparison contract](self), or `None` when the
177/// programs are equivalent.
178pub fn difference(first: &crate::Program, second: &crate::Program) -> Option<Difference> {
179    let a = match first.to_wir() {
180        Ok(program) => program,
181        Err(error) => return Some(malformed(ProgramSide::First, error)),
182    };
183    let b = match second.to_wir() {
184        Ok(program) => program,
185        Err(error) => return Some(malformed(ProgramSide::Second, error)),
186    };
187    compare(&a, &b)
188}
189
190fn malformed(program: ProgramSide, error: crate::WorkshopError) -> Difference {
191    Difference {
192        rules: None,
193        path: Vec::new(),
194        kind: DifferenceKind::Malformed {
195            program,
196            message: error.to_string(),
197        },
198    }
199}
200
201fn program_level(kind: DifferenceKind) -> Difference {
202    Difference {
203        rules: None,
204        path: Vec::new(),
205        kind,
206    }
207}
208
209fn compare(a: &wir::Program, b: &wir::Program) -> Option<Difference> {
210    if !settings_equivalent(a.settings.as_ref(), b.settings.as_ref()) {
211        return Some(program_level(DifferenceKind::Settings));
212    }
213    for (table, first, second) in [
214        (
215            DeclarationTable::GlobalVariables,
216            index_set(a.global_variables.iter().map(|variable| variable.index)),
217            index_set(b.global_variables.iter().map(|variable| variable.index)),
218        ),
219        (
220            DeclarationTable::PlayerVariables,
221            index_set(a.player_variables.iter().map(|variable| variable.index)),
222            index_set(b.player_variables.iter().map(|variable| variable.index)),
223        ),
224        (
225            DeclarationTable::Subroutines,
226            index_set(a.subroutines.iter().map(|subroutine| subroutine.index)),
227            index_set(b.subroutines.iter().map(|subroutine| subroutine.index)),
228        ),
229    ] {
230        if first != second {
231            return Some(program_level(DifferenceKind::DeclarationTable {
232                table,
233                first,
234                second,
235            }));
236        }
237    }
238    let first_rules = effective_rules(a);
239    let second_rules = effective_rules(b);
240    if first_rules.ordered.len() != second_rules.ordered.len() {
241        return Some(program_level(DifferenceKind::Count {
242            element: "rules",
243            first: first_rules.ordered.len(),
244            second: second_rules.ordered.len(),
245        }));
246    }
247    for (&(first, left), &(second, right)) in first_rules.ordered.iter().zip(&second_rules.ordered)
248    {
249        if let Some((path, kind)) = rule_difference(a, b, left, right) {
250            return Some(Difference {
251                rules: Some((first, second)),
252                path,
253                kind,
254            });
255        }
256    }
257    if first_rules.subroutines.len() != second_rules.subroutines.len() {
258        return Some(program_level(DifferenceKind::Count {
259            element: "subroutine rules",
260            first: first_rules.subroutines.len(),
261            second: second_rules.subroutines.len(),
262        }));
263    }
264    for (&(_, first, left), &(_, second, right)) in first_rules
265        .subroutines
266        .iter()
267        .zip(&second_rules.subroutines)
268    {
269        if let Some((path, kind)) = rule_difference(a, b, left, right) {
270            return Some(Difference {
271                rules: Some((first, second)),
272                path,
273                kind,
274            });
275        }
276    }
277    None
278}
279
280fn index_set(indices: impl Iterator<Item = u32>) -> Vec<u32> {
281    let mut indices: Vec<u32> = indices.collect();
282    indices.sort_unstable();
283    indices
284}
285
286/// The behavior-bearing rules of one program: rules that are enabled and
287/// carry at least one enabled action.
288struct EffectiveRules<'p> {
289    /// Non-subroutine rules in program order, with their `rules` positions.
290    ordered: Vec<(usize, &'p wir::Rule)>,
291    /// Subroutine-event rules keyed by the subroutine index they bind, with
292    /// their `rules` positions, sorted by key.
293    subroutines: Vec<(u32, usize, &'p wir::Rule)>,
294}
295
296fn effective_rules(program: &wir::Program) -> EffectiveRules<'_> {
297    let mut ordered = Vec::new();
298    let mut subroutines = Vec::new();
299    for (position, rule) in program.rules.iter().enumerate() {
300        if rule.disabled
301            || !rule
302                .actions
303                .iter()
304                .any(|action| !is_disabled(program, *action))
305        {
306            continue;
307        }
308        if let wir::Event::Subroutine { subroutine, .. } = &rule.event {
309            subroutines.push((subroutine_index(program, *subroutine), position, rule));
310        } else {
311            ordered.push((position, rule));
312        }
313    }
314    subroutines.sort_by_key(|(index, ..)| *index);
315    EffectiveRules {
316        ordered,
317        subroutines,
318    }
319}
320
321fn is_disabled(program: &wir::Program, action: wir::ActionId) -> bool {
322    matches!(
323        program.actions.get(action),
324        Some(wir::Action::Disabled { .. })
325    )
326}
327
328fn enabled_actions(program: &wir::Program, actions: &[wir::ActionId]) -> Vec<wir::ActionId> {
329    actions
330        .iter()
331        .copied()
332        .filter(|action| !is_disabled(program, *action))
333        .collect()
334}
335
336fn global_index(program: &wir::Program, variable: wir::GlobalVarId) -> u32 {
337    program
338        .global_variables
339        .get(variable)
340        .map_or(u32::MAX, |variable| variable.index)
341}
342
343fn player_index(program: &wir::Program, variable: wir::PlayerVarId) -> u32 {
344    program
345        .player_variables
346        .get(variable)
347        .map_or(u32::MAX, |variable| variable.index)
348}
349
350fn subroutine_index(program: &wir::Program, subroutine: wir::SubroutineId) -> u32 {
351    program
352        .subroutines
353        .get(subroutine)
354        .map_or(u32::MAX, |subroutine| subroutine.index)
355}
356
357fn rule_difference(
358    a: &wir::Program,
359    b: &wir::Program,
360    left: &wir::Rule,
361    right: &wir::Rule,
362) -> Option<(Vec<PathStep>, DifferenceKind)> {
363    let mut path = Vec::new();
364    let kind = rule_kind(a, b, left, right, &mut path)?;
365    Some((path, kind))
366}
367
368fn rule_kind(
369    a: &wir::Program,
370    b: &wir::Program,
371    left: &wir::Rule,
372    right: &wir::Rule,
373    path: &mut Vec<PathStep>,
374) -> Option<DifferenceKind> {
375    if !event_equivalent(a, &left.event, b, &right.event) {
376        return Some(DifferenceKind::Event {
377            first: describe_event(a, &left.event),
378            second: describe_event(b, &right.event),
379        });
380    }
381    let left_conditions: Vec<wir::Condition> = left
382        .conditions
383        .iter()
384        .copied()
385        .filter(|condition| !condition.disabled)
386        .collect();
387    let right_conditions: Vec<wir::Condition> = right
388        .conditions
389        .iter()
390        .copied()
391        .filter(|condition| !condition.disabled)
392        .collect();
393    if left_conditions.len() != right_conditions.len() {
394        return Some(DifferenceKind::Count {
395            element: "conditions",
396            first: left_conditions.len(),
397            second: right_conditions.len(),
398        });
399    }
400    for (index, (left, right)) in left_conditions.iter().zip(&right_conditions).enumerate() {
401        path.push(PathStep::Condition(index));
402        if let Some(kind) = value_difference(a, b, left.value, right.value, path) {
403            return Some(kind);
404        }
405        path.pop();
406    }
407    action_list_difference(a, b, &left.actions, &right.actions, path)
408}
409
410fn event_equivalent(
411    a: &wir::Program,
412    left: &wir::Event,
413    b: &wir::Program,
414    right: &wir::Event,
415) -> bool {
416    match (left, right) {
417        (wir::Event::Global, wir::Event::Global) => true,
418        (wir::Event::EachPlayer, wir::Event::EachPlayer) => true,
419        (wir::Event::EachPlayer, wir::Event::EachPlayerWithFilters { team, target })
420        | (wir::Event::EachPlayerWithFilters { team, target }, wir::Event::EachPlayer) => {
421            *team == wir::EventTeam::All && *target == wir::EventTarget::All
422        }
423        (
424            wir::Event::EachPlayerWithFilters {
425                team: first_team,
426                target: first_target,
427            },
428            wir::Event::EachPlayerWithFilters {
429                team: second_team,
430                target: second_target,
431            },
432        ) => first_team == second_team && first_target == second_target,
433        (
434            wir::Event::Player {
435                kind: first_kind,
436                team: first_team,
437                target: first_target,
438            },
439            wir::Event::Player {
440                kind: second_kind,
441                team: second_team,
442                target: second_target,
443            },
444        ) => {
445            first_kind == second_kind && first_team == second_team && first_target == second_target
446        }
447        (
448            wir::Event::Subroutine {
449                subroutine: first, ..
450            },
451            wir::Event::Subroutine {
452                subroutine: second, ..
453            },
454        ) => subroutine_index(a, *first) == subroutine_index(b, *second),
455        _ => false,
456    }
457}
458
459fn describe_event(program: &wir::Program, event: &wir::Event) -> String {
460    match event {
461        wir::Event::Global => "global".to_string(),
462        wir::Event::EachPlayer => "eachPlayer".to_string(),
463        wir::Event::EachPlayerWithFilters { team, target } => {
464            format!("eachPlayer(team={team:?}, target={target:?})")
465        }
466        wir::Event::Player { kind, team, target } => {
467            format!("{}(team={team:?}, target={target:?})", kind.catalog_id())
468        }
469        wir::Event::Subroutine { subroutine, .. } => {
470            format!(
471                "subroutine(index={})",
472                subroutine_index(program, *subroutine)
473            )
474        }
475    }
476}
477
478fn action_list_difference(
479    a: &wir::Program,
480    b: &wir::Program,
481    left: &[wir::ActionId],
482    right: &[wir::ActionId],
483    path: &mut Vec<PathStep>,
484) -> Option<DifferenceKind> {
485    let left = enabled_actions(a, left);
486    let right = enabled_actions(b, right);
487    if left.len() != right.len() {
488        return Some(DifferenceKind::Count {
489            element: "actions",
490            first: left.len(),
491            second: right.len(),
492        });
493    }
494    for (index, (&left, &right)) in left.iter().zip(&right).enumerate() {
495        path.push(PathStep::Action(index));
496        if let Some(kind) = action_difference(a, b, left, right, path) {
497            return Some(kind);
498        }
499        path.pop();
500    }
501    None
502}
503
504fn action_difference(
505    a: &wir::Program,
506    b: &wir::Program,
507    left: wir::ActionId,
508    right: wir::ActionId,
509    path: &mut Vec<PathStep>,
510) -> Option<DifferenceKind> {
511    let (Some(first), Some(second)) = (a.actions.get(left), b.actions.get(right)) else {
512        return Some(DifferenceKind::Shape {
513            first: "action",
514            second: "missing",
515        });
516    };
517    match (first, second) {
518        (
519            wir::Action::SetGlobalVariable {
520                variable: first_variable,
521                value: first_value,
522                ..
523            },
524            wir::Action::SetGlobalVariable {
525                variable: second_variable,
526                value: second_value,
527                ..
528            },
529        ) => {
530            let difference = reference_difference(
531                global_index(a, *first_variable),
532                global_index(b, *second_variable),
533                DeclarationTable::GlobalVariables,
534            );
535            if difference.is_some() {
536                return difference;
537            }
538            argument_difference(a, b, path, 0, *first_value, *second_value)
539        }
540        (
541            wir::Action::ModifyGlobalVariable {
542                variable: first_variable,
543                op: first_op,
544                value: first_value,
545                ..
546            },
547            wir::Action::ModifyGlobalVariable {
548                variable: second_variable,
549                op: second_op,
550                value: second_value,
551                ..
552            },
553        ) => {
554            let difference = reference_difference(
555                global_index(a, *first_variable),
556                global_index(b, *second_variable),
557                DeclarationTable::GlobalVariables,
558            );
559            if difference.is_some() {
560                return difference;
561            }
562            if first_op != second_op {
563                return Some(DifferenceKind::Operation {
564                    first: first_op.catalog_id().to_string(),
565                    second: second_op.catalog_id().to_string(),
566                });
567            }
568            argument_difference(a, b, path, 0, *first_value, *second_value)
569        }
570        (
571            wir::Action::SetGlobalVariable {
572                variable: first_variable,
573                value: first_value,
574                ..
575            },
576            wir::Action::ModifyGlobalVariable {
577                variable: second_variable,
578                op: second_op,
579                value: second_value,
580                ..
581            },
582        ) => {
583            let first_index = global_index(a, *first_variable);
584            let second_index = global_index(b, *second_variable);
585            let difference =
586                reference_difference(first_index, second_index, DeclarationTable::GlobalVariables);
587            if difference.is_some() {
588                return difference;
589            }
590            self_modify_difference(
591                SelfModify {
592                    set_program: a,
593                    modify_program: b,
594                    set_is_first: true,
595                    target: ModifyTarget::Global { index: first_index },
596                    set_value: *first_value,
597                    op: *second_op,
598                    modify_value: *second_value,
599                },
600                path,
601            )
602        }
603        (
604            wir::Action::ModifyGlobalVariable {
605                variable: first_variable,
606                op: first_op,
607                value: first_value,
608                ..
609            },
610            wir::Action::SetGlobalVariable {
611                variable: second_variable,
612                value: second_value,
613                ..
614            },
615        ) => {
616            let first_index = global_index(a, *first_variable);
617            let second_index = global_index(b, *second_variable);
618            let difference =
619                reference_difference(first_index, second_index, DeclarationTable::GlobalVariables);
620            if difference.is_some() {
621                return difference;
622            }
623            self_modify_difference(
624                SelfModify {
625                    set_program: b,
626                    modify_program: a,
627                    set_is_first: false,
628                    target: ModifyTarget::Global { index: first_index },
629                    set_value: *second_value,
630                    op: *first_op,
631                    modify_value: *first_value,
632                },
633                path,
634            )
635        }
636        (
637            wir::Action::SetPlayerVariable {
638                player: first_player,
639                variable: first_variable,
640                value: first_value,
641                ..
642            },
643            wir::Action::SetPlayerVariable {
644                player: second_player,
645                variable: second_variable,
646                value: second_value,
647                ..
648            },
649        ) => {
650            if let Some(kind) = argument_difference(a, b, path, 0, *first_player, *second_player) {
651                return Some(kind);
652            }
653            let difference = reference_difference(
654                player_index(a, *first_variable),
655                player_index(b, *second_variable),
656                DeclarationTable::PlayerVariables,
657            );
658            if difference.is_some() {
659                return difference;
660            }
661            argument_difference(a, b, path, 1, *first_value, *second_value)
662        }
663        (
664            wir::Action::ModifyPlayerVariable {
665                player: first_player,
666                variable: first_variable,
667                op: first_op,
668                value: first_value,
669                ..
670            },
671            wir::Action::ModifyPlayerVariable {
672                player: second_player,
673                variable: second_variable,
674                op: second_op,
675                value: second_value,
676                ..
677            },
678        ) => {
679            if let Some(kind) = argument_difference(a, b, path, 0, *first_player, *second_player) {
680                return Some(kind);
681            }
682            let difference = reference_difference(
683                player_index(a, *first_variable),
684                player_index(b, *second_variable),
685                DeclarationTable::PlayerVariables,
686            );
687            if difference.is_some() {
688                return difference;
689            }
690            if first_op != second_op {
691                return Some(DifferenceKind::Operation {
692                    first: first_op.catalog_id().to_string(),
693                    second: second_op.catalog_id().to_string(),
694                });
695            }
696            argument_difference(a, b, path, 1, *first_value, *second_value)
697        }
698        (
699            wir::Action::SetPlayerVariable {
700                player: first_player,
701                variable: first_variable,
702                value: first_value,
703                ..
704            },
705            wir::Action::ModifyPlayerVariable {
706                player: second_player,
707                variable: second_variable,
708                op: second_op,
709                value: second_value,
710                ..
711            },
712        ) => {
713            if let Some(kind) = argument_difference(a, b, path, 0, *first_player, *second_player) {
714                return Some(kind);
715            }
716            let first_index = player_index(a, *first_variable);
717            let second_index = player_index(b, *second_variable);
718            let difference =
719                reference_difference(first_index, second_index, DeclarationTable::PlayerVariables);
720            if difference.is_some() {
721                return difference;
722            }
723            self_modify_difference(
724                SelfModify {
725                    set_program: a,
726                    modify_program: b,
727                    set_is_first: true,
728                    target: ModifyTarget::Player {
729                        index: first_index,
730                        player: *first_player,
731                    },
732                    set_value: *first_value,
733                    op: *second_op,
734                    modify_value: *second_value,
735                },
736                path,
737            )
738        }
739        (
740            wir::Action::ModifyPlayerVariable {
741                player: first_player,
742                variable: first_variable,
743                op: first_op,
744                value: first_value,
745                ..
746            },
747            wir::Action::SetPlayerVariable {
748                player: second_player,
749                variable: second_variable,
750                value: second_value,
751                ..
752            },
753        ) => {
754            if let Some(kind) = argument_difference(a, b, path, 0, *first_player, *second_player) {
755                return Some(kind);
756            }
757            let first_index = player_index(a, *first_variable);
758            let second_index = player_index(b, *second_variable);
759            let difference =
760                reference_difference(first_index, second_index, DeclarationTable::PlayerVariables);
761            if difference.is_some() {
762                return difference;
763            }
764            self_modify_difference(
765                SelfModify {
766                    set_program: b,
767                    modify_program: a,
768                    set_is_first: false,
769                    target: ModifyTarget::Player {
770                        index: first_index,
771                        player: *second_player,
772                    },
773                    set_value: *second_value,
774                    op: *first_op,
775                    modify_value: *first_value,
776                },
777                path,
778            )
779        }
780        (
781            wir::Action::AssignMember {
782                target: first_target,
783                op: first_op,
784                value: first_value,
785                ..
786            },
787            wir::Action::AssignMember {
788                target: second_target,
789                op: second_op,
790                value: second_value,
791                ..
792            },
793        ) => {
794            if first_op != second_op {
795                return Some(DifferenceKind::Operation {
796                    first: first_op.map_or("assign", ModifyOp::catalog_id).to_string(),
797                    second: second_op.map_or("assign", ModifyOp::catalog_id).to_string(),
798                });
799            }
800            if let Some(kind) = argument_difference(a, b, path, 0, *first_target, *second_target) {
801                return Some(kind);
802            }
803            argument_difference(a, b, path, 1, *first_value, *second_value)
804        }
805        (
806            wir::Action::CallSubroutine {
807                subroutine: first, ..
808            },
809            wir::Action::CallSubroutine {
810                subroutine: second, ..
811            },
812        ) => reference_difference(
813            subroutine_index(a, *first),
814            subroutine_index(b, *second),
815            DeclarationTable::Subroutines,
816        ),
817        (
818            wir::Action::If {
819                branches: first_branches,
820                else_body: first_else,
821                ..
822            },
823            wir::Action::If {
824                branches: second_branches,
825                else_body: second_else,
826                ..
827            },
828        ) => {
829            if first_branches.len() != second_branches.len() {
830                return Some(DifferenceKind::Count {
831                    element: "branches",
832                    first: first_branches.len(),
833                    second: second_branches.len(),
834                });
835            }
836            for (index, (first, second)) in first_branches.iter().zip(second_branches).enumerate() {
837                path.push(PathStep::Value(index));
838                if let Some(kind) = value_difference(a, b, first.condition, second.condition, path)
839                {
840                    return Some(kind);
841                }
842                path.pop();
843                path.push(PathStep::Branch(index));
844                if let Some(kind) = action_list_difference(a, b, &first.body, &second.body, path) {
845                    return Some(kind);
846                }
847                path.pop();
848            }
849            match (first_else, second_else) {
850                (None, None) => None,
851                (Some(first), Some(second)) => {
852                    path.push(PathStep::ElseBody);
853                    let difference = action_list_difference(a, b, first, second, path);
854                    if difference.is_none() {
855                        path.pop();
856                    }
857                    difference
858                }
859                (first, second) => Some(DifferenceKind::Shape {
860                    first: if first.is_some() { "if/else" } else { "if" },
861                    second: if second.is_some() { "if/else" } else { "if" },
862                }),
863            }
864        }
865        (
866            wir::Action::While {
867                condition: first_condition,
868                body: first_body,
869                ..
870            },
871            wir::Action::While {
872                condition: second_condition,
873                body: second_body,
874                ..
875            },
876        ) => {
877            if let Some(kind) =
878                argument_difference(a, b, path, 0, *first_condition, *second_condition)
879            {
880                return Some(kind);
881            }
882            path.push(PathStep::Body);
883            let difference = action_list_difference(a, b, first_body, second_body, path);
884            if difference.is_none() {
885                path.pop();
886            }
887            difference
888        }
889        (
890            wir::Action::ForGlobalVariable {
891                variable: first_variable,
892                start: first_start,
893                stop: first_stop,
894                step: first_step,
895                body: first_body,
896                ..
897            },
898            wir::Action::ForGlobalVariable {
899                variable: second_variable,
900                start: second_start,
901                stop: second_stop,
902                step: second_step,
903                body: second_body,
904                ..
905            },
906        ) => {
907            let difference = reference_difference(
908                global_index(a, *first_variable),
909                global_index(b, *second_variable),
910                DeclarationTable::GlobalVariables,
911            );
912            if difference.is_some() {
913                return difference;
914            }
915            for (index, (&first, &second)) in [
916                (first_start, second_start),
917                (first_stop, second_stop),
918                (first_step, second_step),
919            ]
920            .into_iter()
921            .enumerate()
922            {
923                if let Some(kind) = argument_difference(a, b, path, index, first, second) {
924                    return Some(kind);
925                }
926            }
927            path.push(PathStep::Body);
928            let difference = action_list_difference(a, b, first_body, second_body, path);
929            if difference.is_none() {
930                path.pop();
931            }
932            difference
933        }
934        (
935            wir::Action::ForPlayerVariable {
936                player: first_player,
937                variable: first_variable,
938                start: first_start,
939                stop: first_stop,
940                step: first_step,
941                body: first_body,
942                ..
943            },
944            wir::Action::ForPlayerVariable {
945                player: second_player,
946                variable: second_variable,
947                start: second_start,
948                stop: second_stop,
949                step: second_step,
950                body: second_body,
951                ..
952            },
953        ) => {
954            if let Some(kind) = argument_difference(a, b, path, 0, *first_player, *second_player) {
955                return Some(kind);
956            }
957            let difference = reference_difference(
958                player_index(a, *first_variable),
959                player_index(b, *second_variable),
960                DeclarationTable::PlayerVariables,
961            );
962            if difference.is_some() {
963                return difference;
964            }
965            for (index, (&first, &second)) in [
966                (first_start, second_start),
967                (first_stop, second_stop),
968                (first_step, second_step),
969            ]
970            .into_iter()
971            .enumerate()
972            {
973                if let Some(kind) = argument_difference(a, b, path, index + 1, first, second) {
974                    return Some(kind);
975                }
976            }
977            path.push(PathStep::Body);
978            let difference = action_list_difference(a, b, first_body, second_body, path);
979            if difference.is_none() {
980                path.pop();
981            }
982            difference
983        }
984        (
985            wir::Action::Call {
986                name: first_name,
987                args: first_args,
988                ..
989            },
990            wir::Action::Call {
991                name: second_name,
992                args: second_args,
993                ..
994            },
995        ) => {
996            if first_name != second_name {
997                return Some(DifferenceKind::Scalar {
998                    first: first_name.clone(),
999                    second: second_name.clone(),
1000                });
1001            }
1002            if first_args.len() != second_args.len() {
1003                return Some(DifferenceKind::Count {
1004                    element: "arguments",
1005                    first: first_args.len(),
1006                    second: second_args.len(),
1007                });
1008            }
1009            for (index, (&first, &second)) in first_args.iter().zip(second_args).enumerate() {
1010                if let Some(kind) = argument_difference(a, b, path, index, first, second) {
1011                    return Some(kind);
1012                }
1013            }
1014            None
1015        }
1016        _ => Some(DifferenceKind::Shape {
1017            first: action_shape(first),
1018            second: action_shape(second),
1019        }),
1020    }
1021}
1022
1023fn reference_difference(
1024    first: u32,
1025    second: u32,
1026    table: DeclarationTable,
1027) -> Option<DifferenceKind> {
1028    (first != second).then_some(DifferenceKind::Reference {
1029        table,
1030        first,
1031        second,
1032    })
1033}
1034
1035fn argument_difference(
1036    a: &wir::Program,
1037    b: &wir::Program,
1038    path: &mut Vec<PathStep>,
1039    index: usize,
1040    first: wir::ValueId,
1041    second: wir::ValueId,
1042) -> Option<DifferenceKind> {
1043    path.push(PathStep::Value(index));
1044    let difference = value_difference(a, b, first, second, path);
1045    if difference.is_none() {
1046        path.pop();
1047    }
1048    difference
1049}
1050
1051/// A `Set x = x <op> v` ↔ `Modify x <op> v` pair: `set_value` must be the
1052/// call `<assigned variable> <op> <operand>` over the same variable, and the
1053/// operand must match `modify_value`.
1054struct SelfModify<'p> {
1055    /// The program whose action is the `Set`.
1056    set_program: &'p wir::Program,
1057    /// The program whose action is the `Modify`.
1058    modify_program: &'p wir::Program,
1059    /// Whether `set_program` is the comparison's first program.
1060    set_is_first: bool,
1061    /// The assigned variable, already compared equal across both programs.
1062    target: ModifyTarget,
1063    /// The `Set` action's value.
1064    set_value: wir::ValueId,
1065    /// The `Modify` action's operation.
1066    op: ModifyOp,
1067    /// The `Modify` action's value.
1068    modify_value: wir::ValueId,
1069}
1070
1071#[derive(Clone, Copy)]
1072enum ModifyTarget {
1073    Global {
1074        index: u32,
1075    },
1076    /// The variable index plus the `Set` action's player operand in
1077    /// `set_program`.
1078    Player {
1079        index: u32,
1080        player: wir::ValueId,
1081    },
1082}
1083
1084impl ModifyTarget {
1085    fn table(&self) -> DeclarationTable {
1086        match self {
1087            Self::Global { .. } => DeclarationTable::GlobalVariables,
1088            Self::Player { .. } => DeclarationTable::PlayerVariables,
1089        }
1090    }
1091
1092    fn index(&self) -> u32 {
1093        match self {
1094            Self::Global { index } | Self::Player { index, .. } => *index,
1095        }
1096    }
1097
1098    fn shapes(&self) -> (&'static str, &'static str) {
1099        match self {
1100            Self::Global { .. } => ("setGlobalVariable", "modifyGlobalVariable"),
1101            Self::Player { .. } => ("setPlayerVariable", "modifyPlayerVariable"),
1102        }
1103    }
1104
1105    fn value_step(&self) -> usize {
1106        match self {
1107            Self::Global { .. } => 0,
1108            Self::Player { .. } => 1,
1109        }
1110    }
1111}
1112
1113/// When `value` reads a variable of `target`'s table, the index it resolves
1114/// to plus its player operand (player variables only).
1115fn variable_read(
1116    program: &wir::Program,
1117    value: wir::ValueId,
1118    target: ModifyTarget,
1119) -> Option<(u32, Option<wir::ValueId>)> {
1120    match (target, program.values.get(value).map(|node| &node.value)) {
1121        (ModifyTarget::Global { .. }, Some(wir::Value::GlobalVariable(variable))) => {
1122            Some((global_index(program, *variable), None))
1123        }
1124        (
1125            ModifyTarget::Player { .. },
1126            Some(wir::Value::PlayerVariable {
1127                player, variable, ..
1128            }),
1129        ) => Some((player_index(program, *variable), Some(*player))),
1130        (ModifyTarget::Player { .. }, Some(wir::Value::Call { name, args }))
1131            if name == "memberAccess" && args.len() == 2 =>
1132        {
1133            // The member spelling binds the variable by name in its own
1134            // program's table.
1135            let Some(wir::Value::String(member)) =
1136                program.values.get(args[1]).map(|node| &node.value)
1137            else {
1138                return None;
1139            };
1140            Some((member_binding(program, member), Some(args[0])))
1141        }
1142        _ => None,
1143    }
1144}
1145
1146fn self_modify_difference(
1147    pair: SelfModify<'_>,
1148    path: &mut Vec<PathStep>,
1149) -> Option<DifferenceKind> {
1150    let base = path.len();
1151    let difference = self_modify_inner(pair, path);
1152    if difference.is_none() {
1153        path.truncate(base);
1154    }
1155    difference
1156}
1157
1158fn self_modify_inner(pair: SelfModify<'_>, path: &mut Vec<PathStep>) -> Option<DifferenceKind> {
1159    let (set_shape, modify_shape) = pair.target.shapes();
1160    let (first_shape, second_shape) = if pair.set_is_first {
1161        (set_shape, modify_shape)
1162    } else {
1163        (modify_shape, set_shape)
1164    };
1165    let Some(wir::Value::Call { name, args }) = pair
1166        .set_program
1167        .values
1168        .get(pair.set_value)
1169        .map(|node| &node.value)
1170    else {
1171        return Some(DifferenceKind::Shape {
1172            first: first_shape,
1173            second: second_shape,
1174        });
1175    };
1176    if args.len() != 2 {
1177        return Some(DifferenceKind::Shape {
1178            first: first_shape,
1179            second: second_shape,
1180        });
1181    }
1182    path.push(PathStep::Value(pair.target.value_step()));
1183    // The first operand must read the assigned variable itself.
1184    let Some((found, read_player)) = variable_read(pair.set_program, args[0], pair.target) else {
1185        path.pop();
1186        return Some(DifferenceKind::Shape {
1187            first: first_shape,
1188            second: second_shape,
1189        });
1190    };
1191    if found != pair.target.index() {
1192        path.push(PathStep::Value(0));
1193        let (first, second) = if pair.set_is_first {
1194            (found, pair.target.index())
1195        } else {
1196            (pair.target.index(), found)
1197        };
1198        return Some(DifferenceKind::Reference {
1199            table: pair.target.table(),
1200            first,
1201            second,
1202        });
1203    }
1204    if let ModifyTarget::Player { player, .. } = pair.target
1205        && let Some(read_player) = read_player
1206    {
1207        // The operand's player receiver must be the action's player.
1208        path.push(PathStep::Value(0));
1209        let difference = argument_difference(
1210            pair.set_program,
1211            pair.set_program,
1212            path,
1213            0,
1214            read_player,
1215            player,
1216        );
1217        match difference {
1218            Some(difference) => return Some(difference),
1219            None => {
1220                path.pop();
1221            }
1222        }
1223    }
1224    if canonical_value_name(name) != pair.op.catalog_id() {
1225        let (first, second) = if pair.set_is_first {
1226            (
1227                canonical_value_name(name).to_string(),
1228                pair.op.catalog_id().to_string(),
1229            )
1230        } else {
1231            (
1232                pair.op.catalog_id().to_string(),
1233                canonical_value_name(name).to_string(),
1234            )
1235        };
1236        return Some(DifferenceKind::Operation { first, second });
1237    }
1238    path.push(PathStep::Value(1));
1239    let (a, b, left, right) = if pair.set_is_first {
1240        (
1241            pair.set_program,
1242            pair.modify_program,
1243            args[1],
1244            pair.modify_value,
1245        )
1246    } else {
1247        (
1248            pair.modify_program,
1249            pair.set_program,
1250            pair.modify_value,
1251            args[1],
1252        )
1253    };
1254    value_difference(a, b, left, right, path)
1255}
1256
1257fn value_difference(
1258    a: &wir::Program,
1259    b: &wir::Program,
1260    left: wir::ValueId,
1261    right: wir::ValueId,
1262    path: &mut Vec<PathStep>,
1263) -> Option<DifferenceKind> {
1264    let (Some(first), Some(second)) = (
1265        a.values.get(left).map(|node| &node.value),
1266        b.values.get(right).map(|node| &node.value),
1267    ) else {
1268        return Some(DifferenceKind::Shape {
1269            first: "value",
1270            second: "missing",
1271        });
1272    };
1273    match (first, second) {
1274        (wir::Value::Number { value: first, .. }, wir::Value::Number { value: second, .. }) => {
1275            if *first == *second || (first.is_nan() && second.is_nan()) {
1276                None
1277            } else {
1278                Some(DifferenceKind::Scalar {
1279                    first: format_number(*first),
1280                    second: format_number(*second),
1281                })
1282            }
1283        }
1284        (wir::Value::String(first), wir::Value::String(second)) => {
1285            if first == second {
1286                None
1287            } else {
1288                Some(DifferenceKind::Scalar {
1289                    first: first.clone(),
1290                    second: second.clone(),
1291                })
1292            }
1293        }
1294        (wir::Value::LocalizedString(first), wir::Value::LocalizedString(second)) => {
1295            if first == second {
1296                None
1297            } else {
1298                Some(DifferenceKind::Scalar {
1299                    first: first.clone(),
1300                    second: second.clone(),
1301                })
1302            }
1303        }
1304        (wir::Value::Bool(first), wir::Value::Bool(second)) => {
1305            if first == second {
1306                None
1307            } else {
1308                Some(DifferenceKind::Scalar {
1309                    first: first.to_string(),
1310                    second: second.to_string(),
1311                })
1312            }
1313        }
1314        (wir::Value::Null, wir::Value::Null) => None,
1315        (wir::Value::Array(first), wir::Value::Array(second)) => {
1316            if first.len() != second.len() {
1317                return Some(DifferenceKind::Count {
1318                    element: "elements",
1319                    first: first.len(),
1320                    second: second.len(),
1321                });
1322            }
1323            for (index, (&first, &second)) in first.iter().zip(second).enumerate() {
1324                if let Some(kind) = argument_difference(a, b, path, index, first, second) {
1325                    return Some(kind);
1326                }
1327            }
1328            None
1329        }
1330        (
1331            wir::Value::Vector {
1332                x: first_x,
1333                y: first_y,
1334                z: first_z,
1335            },
1336            wir::Value::Vector {
1337                x: second_x,
1338                y: second_y,
1339                z: second_z,
1340            },
1341        ) => {
1342            for (index, (&first, &second)) in [
1343                (first_x, second_x),
1344                (first_y, second_y),
1345                (first_z, second_z),
1346            ]
1347            .into_iter()
1348            .enumerate()
1349            {
1350                if let Some(kind) = argument_difference(a, b, path, index, first, second) {
1351                    return Some(kind);
1352                }
1353            }
1354            None
1355        }
1356        (
1357            wir::Value::Enum {
1358                value_type: first_type,
1359                value: first_value,
1360            },
1361            wir::Value::Enum {
1362                value_type: second_type,
1363                value: second_value,
1364            },
1365        ) => {
1366            if first_type == second_type && first_value == second_value {
1367                None
1368            } else {
1369                Some(DifferenceKind::Scalar {
1370                    first: format!("{first_type}.{first_value}"),
1371                    second: format!("{second_type}.{second_value}"),
1372                })
1373            }
1374        }
1375        (wir::Value::GlobalVariable(first), wir::Value::GlobalVariable(second)) => {
1376            reference_difference(
1377                global_index(a, *first),
1378                global_index(b, *second),
1379                DeclarationTable::GlobalVariables,
1380            )
1381        }
1382        (
1383            wir::Value::PlayerVariable {
1384                player: first_player,
1385                variable: first_variable,
1386            },
1387            wir::Value::PlayerVariable {
1388                player: second_player,
1389                variable: second_variable,
1390            },
1391        ) => {
1392            if let Some(kind) = argument_difference(a, b, path, 0, *first_player, *second_player) {
1393                return Some(kind);
1394            }
1395            reference_difference(
1396                player_index(a, *first_variable),
1397                player_index(b, *second_variable),
1398                DeclarationTable::PlayerVariables,
1399            )
1400        }
1401        (wir::Value::Subroutine(first), wir::Value::Subroutine(second)) => reference_difference(
1402            subroutine_index(a, *first),
1403            subroutine_index(b, *second),
1404            DeclarationTable::Subroutines,
1405        ),
1406        (wir::Value::EventPlayer, wir::Value::EventPlayer) => None,
1407        (wir::Value::PlayerVariable { player, variable }, wir::Value::Call { name, args })
1408        | (wir::Value::Call { name, args }, wir::Value::PlayerVariable { player, variable })
1409            if name == "memberAccess" && args.len() == 2 =>
1410        {
1411            member_access_difference(
1412                a,
1413                b,
1414                matches!(first, wir::Value::PlayerVariable { .. }),
1415                *player,
1416                *variable,
1417                args,
1418                path,
1419            )
1420        }
1421        (
1422            wir::Value::Call {
1423                name: first_name,
1424                args: first_args,
1425            },
1426            wir::Value::Call {
1427                name: second_name,
1428                args: second_args,
1429            },
1430        ) => {
1431            if first_name == wir::AMBIGUOUS_ENUM_CALL && second_name == wir::AMBIGUOUS_ENUM_CALL {
1432                return (!ambiguous_enum_equivalent(a, b, first_args, second_args)).then_some(
1433                    DifferenceKind::Scalar {
1434                        first: describe_ambiguous_enum(a, first_args),
1435                        second: describe_ambiguous_enum(b, second_args),
1436                    },
1437                );
1438            }
1439            if first_name == "memberAccess"
1440                && second_name == "memberAccess"
1441                && first_args.len() == 2
1442                && second_args.len() == 2
1443            {
1444                return member_access_pair_difference(a, b, first_args, second_args, path);
1445            }
1446            if canonical_value_name(first_name) != canonical_value_name(second_name) {
1447                return Some(DifferenceKind::Scalar {
1448                    first: first_name.clone(),
1449                    second: second_name.clone(),
1450                });
1451            }
1452            if first_args.len() != second_args.len() {
1453                return Some(DifferenceKind::Count {
1454                    element: "arguments",
1455                    first: first_args.len(),
1456                    second: second_args.len(),
1457                });
1458            }
1459            for (index, (&first, &second)) in first_args.iter().zip(second_args).enumerate() {
1460                if let Some(kind) = argument_difference(a, b, path, index, first, second) {
1461                    return Some(kind);
1462                }
1463            }
1464            None
1465        }
1466        _ => Some(DifferenceKind::Shape {
1467            first: value_shape(first),
1468            second: value_shape(second),
1469        }),
1470    }
1471}
1472
1473/// The `PlayerVariable` ↔ `memberAccess(player, "name")` pair accepted by the
1474/// strict comparator: the memberAccess member names a player variable in its
1475/// own program's table, and the resolved indices must match.
1476fn member_access_difference(
1477    a: &wir::Program,
1478    b: &wir::Program,
1479    variable_is_first: bool,
1480    player: wir::ValueId,
1481    variable: wir::PlayerVarId,
1482    args: &[wir::ValueId],
1483    path: &mut Vec<PathStep>,
1484) -> Option<DifferenceKind> {
1485    let base = path.len();
1486    let difference = member_access_inner(a, b, variable_is_first, player, variable, args, path);
1487    if difference.is_none() {
1488        path.truncate(base);
1489    }
1490    difference
1491}
1492
1493fn member_access_inner(
1494    a: &wir::Program,
1495    b: &wir::Program,
1496    variable_is_first: bool,
1497    player: wir::ValueId,
1498    variable: wir::PlayerVarId,
1499    args: &[wir::ValueId],
1500    path: &mut Vec<PathStep>,
1501) -> Option<DifferenceKind> {
1502    let (variable_program, member_program) = if variable_is_first { (a, b) } else { (b, a) };
1503    let difference = if variable_is_first {
1504        argument_difference(a, b, path, 0, player, args[0])
1505    } else {
1506        argument_difference(a, b, path, 0, args[0], player)
1507    };
1508    if difference.is_some() {
1509        return difference;
1510    }
1511    let member_node = member_program.values.get(args[1]).map(|node| &node.value);
1512    let Some(wir::Value::String(member)) = member_node else {
1513        // The member operand is not a name string, so the memberAccess is
1514        // not a variable spelling at all.
1515        let (first, second) = if variable_is_first {
1516            ("playerVariable", "call")
1517        } else {
1518            ("call", "playerVariable")
1519        };
1520        return Some(DifferenceKind::Shape { first, second });
1521    };
1522    path.push(PathStep::Value(1));
1523    let member_index = member_binding(member_program, member);
1524    let variable_index = player_index(variable_program, variable);
1525    let (first, second) = if variable_is_first {
1526        (variable_index, member_index)
1527    } else {
1528        (member_index, variable_index)
1529    };
1530    reference_difference(first, second, DeclarationTable::PlayerVariables)
1531}
1532
1533/// The player-variable index a `memberAccess` member names in its own
1534/// program's declaration table, or `u32::MAX` when the member names nothing.
1535fn member_binding(program: &wir::Program, member: &str) -> u32 {
1536    program
1537        .player_variables
1538        .iter()
1539        .find(|variable| variable.name == member)
1540        .map_or(u32::MAX, |variable| variable.index)
1541}
1542
1543/// A `memberAccess` ↔ `memberAccess` pair: each member names a player
1544/// variable in its own program's table, so differing spellings are still
1545/// equal when they resolve to the same index.
1546fn member_access_pair_difference(
1547    a: &wir::Program,
1548    b: &wir::Program,
1549    first_args: &[wir::ValueId],
1550    second_args: &[wir::ValueId],
1551    path: &mut Vec<PathStep>,
1552) -> Option<DifferenceKind> {
1553    let base = path.len();
1554    let difference = member_access_pair_inner(a, b, first_args, second_args, path);
1555    if difference.is_none() {
1556        path.truncate(base);
1557    }
1558    difference
1559}
1560
1561fn member_access_pair_inner(
1562    a: &wir::Program,
1563    b: &wir::Program,
1564    first_args: &[wir::ValueId],
1565    second_args: &[wir::ValueId],
1566    path: &mut Vec<PathStep>,
1567) -> Option<DifferenceKind> {
1568    if let Some(kind) = argument_difference(a, b, path, 0, first_args[0], second_args[0]) {
1569        return Some(kind);
1570    }
1571    let members = (
1572        a.values.get(first_args[1]).map(|node| &node.value),
1573        b.values.get(second_args[1]).map(|node| &node.value),
1574    );
1575    let (Some(wir::Value::String(first_member)), Some(wir::Value::String(second_member))) = members
1576    else {
1577        // A member operand that is not a name string is not a variable
1578        // spelling; compare the operands as values.
1579        return argument_difference(a, b, path, 1, first_args[1], second_args[1]);
1580    };
1581    path.push(PathStep::Value(1));
1582    let first_index = member_binding(a, first_member);
1583    let second_index = member_binding(b, second_member);
1584    if first_index != second_index {
1585        return Some(DifferenceKind::Reference {
1586            table: DeclarationTable::PlayerVariables,
1587            first: first_index,
1588            second: second_index,
1589        });
1590    }
1591    if first_index == u32::MAX && first_member != second_member {
1592        // Both members name nothing; the spellings are their only identity.
1593        return Some(DifferenceKind::Scalar {
1594            first: first_member.clone(),
1595            second: second_member.clone(),
1596        });
1597    }
1598    None
1599}
1600
1601fn describe_ambiguous_enum(program: &wir::Program, args: &[wir::ValueId]) -> String {
1602    match wir::ambiguous_enum_parts_by_args(program, args) {
1603        Some((spelling, candidates)) => format!("{spelling} over {candidates:?}"),
1604        None => "ambiguous enum".to_string(),
1605    }
1606}
1607
1608fn action_shape(action: &wir::Action) -> &'static str {
1609    match action {
1610        wir::Action::SetGlobalVariable { .. } => "setGlobalVariable",
1611        wir::Action::ModifyGlobalVariable { .. } => "modifyGlobalVariable",
1612        wir::Action::SetPlayerVariable { .. } => "setPlayerVariable",
1613        wir::Action::ModifyPlayerVariable { .. } => "modifyPlayerVariable",
1614        wir::Action::AssignMember { op, .. } => {
1615            if op.is_some() {
1616                "modifyMember"
1617            } else {
1618                "assignMember"
1619            }
1620        }
1621        wir::Action::CallSubroutine { .. } => "callSubroutine",
1622        wir::Action::If { .. } => "if",
1623        wir::Action::While { .. } => "while",
1624        wir::Action::ForGlobalVariable { .. } => "forGlobalVariable",
1625        wir::Action::ForPlayerVariable { .. } => "forPlayerVariable",
1626        wir::Action::Disabled { .. } => "disabled",
1627        wir::Action::Call { .. } => "call",
1628    }
1629}
1630
1631fn value_shape(value: &wir::Value) -> &'static str {
1632    match value {
1633        wir::Value::Number { .. } => "number",
1634        wir::Value::String(_) => "string",
1635        wir::Value::LocalizedString(_) => "localizedString",
1636        wir::Value::Bool(_) => "bool",
1637        wir::Value::Null => "null",
1638        wir::Value::Array(_) => "array",
1639        wir::Value::Vector { .. } => "vector",
1640        wir::Value::Enum { .. } => "enum",
1641        wir::Value::GlobalVariable(_) => "globalVariable",
1642        wir::Value::PlayerVariable { .. } => "playerVariable",
1643        wir::Value::Subroutine(_) => "subroutine",
1644        wir::Value::EventPlayer => "eventPlayer",
1645        wir::Value::Call { .. } => "call",
1646    }
1647}