Skip to main content

workshop_rs/settings/
table.rs

1//! Fixture-backed settings emission table (#86).
2//!
3//! SOURCE: observed from the pinned oracle 9.7.10 en-US output of the
4//! oracle-success settings programs (`compile.workshop` settings section of
5//! the committed snapshots pixelart/santa/broken-weapons/client-to-server,
6//! plus the parabola/crosshair/inputhud oracle runs) at OverPy commit
7//! `eea67ad`. This is observed-behavior data, not copied OverPy source
8//! (LICENSE-BOUNDARY policy). Additions to the table (e.g. the acquired
9//! candidate snapshots) are data-only.
10
11use serde::Deserialize;
12use serde_json::Value;
13use std::sync::OnceLock;
14
15use super::PathPart::{self, Hero, Part, Team};
16
17/// Locale-specific settings names generated from the reviewed Workshop data
18/// export. The projection contains every reviewed locale as data; adding a
19/// locale changes this file, not the parser or emitter architecture.
20const LOCALE_DATA: &str = include_str!("data/locales.json");
21
22fn locale_data() -> &'static Value {
23    static DATA: OnceLock<Value> = OnceLock::new();
24    DATA.get_or_init(|| {
25        serde_json::from_str(LOCALE_DATA).expect("generated settings locale data is valid JSON")
26    })
27}
28
29/// Resolve a settings display name from the generated locale corpus.
30///
31/// The English table names are intentionally not duplicated in the locale
32/// data. A missing entry means the target locale is not covered and callers
33/// must preserve the explicit missing-mapping contract.
34pub(crate) fn localized_name(locale: &str, section: &str, english: &str) -> Option<&'static str> {
35    let data = locale_data();
36    let aliases = data.get(section)?.get(english)?.as_object()?;
37    aliases.get(locale).and_then(Value::as_str).or_else(|| {
38        aliases.iter().find_map(|(known, value)| {
39            known
40                .eq_ignore_ascii_case(locale)
41                .then(|| value.as_str())
42                .flatten()
43        })
44    })
45}
46
47/// A localized settings name declared by the reviewed locale data (#448).
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct LocalizedSettingName {
50    /// The locale-data section the name belongs to (`namespaces`,
51    /// `labels`, `modes`, `heroes`, `maps`, `teams`, `tokens`, `enums`).
52    pub section: &'static str,
53    /// The en-US display name this row localizes.
54    pub english: &'static str,
55    /// The declaring locale.
56    pub locale: &'static str,
57    /// The declared spelling.
58    pub spelling: &'static str,
59}
60
61const LOCALE_SECTIONS: &[&str] = &[
62    "enums",
63    "heroes",
64    "labels",
65    "maps",
66    "modes",
67    "namespaces",
68    "teams",
69    "tokens",
70];
71
72/// Every localized settings name the reviewed locale data declares, in
73/// deterministic section/name/locale order.
74pub fn localized_setting_names() -> &'static [LocalizedSettingName] {
75    static NAMES: OnceLock<Vec<LocalizedSettingName>> = OnceLock::new();
76    NAMES.get_or_init(|| {
77        let data = locale_data();
78        let mut names = Vec::new();
79        for section in LOCALE_SECTIONS {
80            let Some(rows) = data.get(section).and_then(Value::as_object) else {
81                continue;
82            };
83            for (english, row) in rows {
84                let Some(aliases) = row.as_object() else {
85                    continue;
86                };
87                for (locale, value) in aliases {
88                    let Some(spelling) = value.as_str() else {
89                        continue;
90                    };
91                    names.push(LocalizedSettingName {
92                        section,
93                        english,
94                        locale,
95                        spelling,
96                    });
97                }
98            }
99        }
100        names
101    })
102}
103
104/// A leaf key kind: how a settings leaf renders and validates.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub(crate) enum KeyKind {
107    /// A presence-only extension setting.
108    Flag,
109    /// A quoted string (`Description: "..."`).
110    String,
111    /// A boolean rendered `On`/`Off`.
112    Bool,
113    /// A boolean rendered `Yes`/`No`.
114    YesNo,
115    /// A boolean carrier rendered through a source-supported true-value enum
116    /// token. The false value remains unsupported until independently sourced.
117    BoolEnum(&'static str),
118    /// A plain number.
119    Number,
120    /// A number rendered with a `%` suffix (`Respawn Time Scalar: 30%`).
121    Percent,
122    /// A string-valued enumeration with a per-domain member map
123    /// (`Enum(domain)`).
124    Enum(&'static str),
125    /// A list of map names (`enabled maps`).
126    ListMap,
127    /// A list of hero names (`enabled heroes`).
128    ListHero,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub(crate) struct TableEntry {
133    pub(crate) path: &'static [PathPart<'static>],
134    pub(crate) workshop_name: &'static str,
135    pub(crate) kind: KeyKind,
136}
137
138macro_rules! entry {
139    ($path:expr, $name:expr, $kind:expr) => {
140        TableEntry {
141            path: &$path,
142            workshop_name: $name,
143            kind: $kind,
144        }
145    };
146}
147
148/// The fixture-backed settings surface.
149///
150/// Slot sets (source-backed): teams {allTeams}, heroes {mei} config groups +
151/// the 10 ListHero names. `enabled: true` is not source-backed; it renders with
152/// no prefix. Keys outside this table (e.g. team1Slots, scoreToWin,
153/// gamemodeStartTrigger, spawnHealthPacks, healthPackRespawnTime%,
154/// abilityCooldown%, healingReceived%, primaryFireKb%, enableSpawningWithUlt,
155/// resetPlayersAfterGoalScored, scoreLeadToWin, gameLengthInSec,
156/// heroes.<team>.general, roleLimit under general, heroLimit under a named
157/// mode) are `settings-unknown-key` at validation (only source-backed in
158/// oracle-failing programs; corpus-bounded).
159pub(crate) static ENTRIES: &[TableEntry] = &[
160    entry!(
161        [Part("main"), Part("description")],
162        "Description",
163        KeyKind::String
164    ),
165    entry!(
166        [Part("main"), Part("modeName")],
167        "Mode Name",
168        KeyKind::String
169    ),
170    entry!(
171        [Part("lobby"), Part("ffaSlots")],
172        "Max FFA Players",
173        KeyKind::Number
174    ),
175    entry!(
176        [Part("lobby"), Part("mapRotation")],
177        "Map Rotation",
178        KeyKind::Enum("mapRotation")
179    ),
180    entry!(
181        [Part("lobby"), Part("spectatorSlots")],
182        "Max Spectators",
183        KeyKind::Number
184    ),
185    entry!(
186        [Part("lobby"), Part("enableMatchVoiceChat")],
187        "Match Voice Chat",
188        KeyKind::BoolEnum("matchVoiceChat")
189    ),
190    entry!(
191        [Part("lobby"), Part("team1Slots")],
192        "Max Team 1 Players",
193        KeyKind::Number
194    ),
195    entry!(
196        [Part("lobby"), Part("team2Slots")],
197        "Max Team 2 Players",
198        KeyKind::Number
199    ),
200    entry!(
201        [Part("lobby"), Part("returnToLobby")],
202        "Return To Lobby",
203        KeyKind::Enum("returnToLobby")
204    ),
205    entry!(
206        [Part("lobby"), Part("allowPlayersInQueue")],
207        "Allow Players Who Are In Queue",
208        KeyKind::YesNo
209    ),
210    entry!(
211        [Part("lobby"), Part("swapTeamsAfterMatch")],
212        "Swap Teams After Match",
213        KeyKind::YesNo
214    ),
215    // gamemodes.<mode> — per-key subsets (exact-path entries, #86):
216    // enabledMaps under modes {assault, control, escort, hybrid, skirmish,
217    // ffa}; enabled/roleLimit/enableCompetitiveRules under {assault, control,
218    // escort, hybrid}; heroLimit/respawnTime%/enableHeroSwitching/
219    // enableRandomHeroes under general only (general is a literal group name,
220    // not a mode slot).
221    entry!(
222        [Part("gamemodes"), Part("assault"), Part("enabled")],
223        "enabled",
224        KeyKind::Bool
225    ),
226    entry!(
227        [Part("gamemodes"), Part("control"), Part("enabled")],
228        "enabled",
229        KeyKind::Bool
230    ),
231    entry!(
232        [Part("gamemodes"), Part("escort"), Part("enabled")],
233        "enabled",
234        KeyKind::Bool
235    ),
236    entry!(
237        [Part("gamemodes"), Part("hybrid"), Part("enabled")],
238        "enabled",
239        KeyKind::Bool
240    ),
241    entry!(
242        [Part("gamemodes"), Part("assault"), Part("enabledMaps")],
243        "enabled maps",
244        KeyKind::ListMap
245    ),
246    entry!(
247        [Part("gamemodes"), Part("control"), Part("enabledMaps")],
248        "enabled maps",
249        KeyKind::ListMap
250    ),
251    entry!(
252        [Part("gamemodes"), Part("escort"), Part("enabledMaps")],
253        "enabled maps",
254        KeyKind::ListMap
255    ),
256    entry!(
257        [Part("gamemodes"), Part("hybrid"), Part("enabledMaps")],
258        "enabled maps",
259        KeyKind::ListMap
260    ),
261    entry!(
262        [Part("gamemodes"), Part("skirmish"), Part("enabledMaps")],
263        "enabled maps",
264        KeyKind::ListMap
265    ),
266    entry!(
267        [Part("gamemodes"), Part("assault"), Part("disabledMaps")],
268        "disabled maps",
269        KeyKind::ListMap
270    ),
271    entry!(
272        [Part("gamemodes"), Part("skirmish"), Part("disabledMaps")],
273        "disabled maps",
274        KeyKind::ListMap
275    ),
276    entry!(
277        [Part("gamemodes"), Part("ffa"), Part("enabledMaps")],
278        "enabled maps",
279        KeyKind::ListMap
280    ),
281    entry!(
282        [Part("gamemodes"), Part("tdm"), Part("enabledMaps")],
283        "enabled maps",
284        KeyKind::ListMap
285    ),
286    entry!(
287        [Part("gamemodes"), Part("assault"), Part("roleLimit")],
288        "Limit Roles",
289        KeyKind::Enum("roleLimit")
290    ),
291    entry!(
292        [Part("gamemodes"), Part("control"), Part("roleLimit")],
293        "Limit Roles",
294        KeyKind::Enum("roleLimit")
295    ),
296    entry!(
297        [Part("gamemodes"), Part("escort"), Part("roleLimit")],
298        "Limit Roles",
299        KeyKind::Enum("roleLimit")
300    ),
301    entry!(
302        [Part("gamemodes"), Part("hybrid"), Part("roleLimit")],
303        "Limit Roles",
304        KeyKind::Enum("roleLimit")
305    ),
306    entry!(
307        [Part("gamemodes"), Part("general"), Part("roleLimit")],
308        "Limit Roles",
309        KeyKind::Enum("roleLimit")
310    ),
311    entry!(
312        [
313            Part("gamemodes"),
314            Part("assault"),
315            Part("enableCompetitiveRules")
316        ],
317        "Competitive Rules",
318        KeyKind::Bool
319    ),
320    entry!(
321        [
322            Part("gamemodes"),
323            Part("control"),
324            Part("enableCompetitiveRules")
325        ],
326        "Competitive Rules",
327        KeyKind::Bool
328    ),
329    entry!(
330        [
331            Part("gamemodes"),
332            Part("escort"),
333            Part("enableCompetitiveRules")
334        ],
335        "Competitive Rules",
336        KeyKind::Bool
337    ),
338    entry!(
339        [
340            Part("gamemodes"),
341            Part("hybrid"),
342            Part("enableCompetitiveRules")
343        ],
344        "Competitive Rules",
345        KeyKind::Bool
346    ),
347    entry!(
348        [
349            Part("gamemodes"),
350            Part("general"),
351            Part("enableCompetitiveRules")
352        ],
353        "Competitive Rules",
354        KeyKind::Bool
355    ),
356    entry!(
357        [Part("gamemodes"), Part("general"), Part("enablePerks")],
358        "Enable Perks",
359        KeyKind::Bool
360    ),
361    entry!(
362        [Part("gamemodes"), Part("general"), Part("heroLimit")],
363        "Hero Limit",
364        KeyKind::Enum("heroLimit")
365    ),
366    entry!(
367        [Part("gamemodes"), Part("general"), Part("respawnTime%")],
368        "Respawn Time Scalar",
369        KeyKind::Percent
370    ),
371    entry!(
372        [
373            Part("gamemodes"),
374            Part("general"),
375            Part("enableHeroSwitching")
376        ],
377        "Allow Hero Switching",
378        KeyKind::Bool
379    ),
380    entry!(
381        [
382            Part("gamemodes"),
383            Part("general"),
384            Part("enableRandomHeroes")
385        ],
386        "Respawn As Random Hero",
387        KeyKind::Bool
388    ),
389    entry!(
390        [
391            Part("gamemodes"),
392            Part("general"),
393            Part("gamemodeStartTrigger")
394        ],
395        "Game Mode Start",
396        KeyKind::Enum("gamemodeStartTrigger")
397    ),
398    entry!(
399        [
400            Part("gamemodes"),
401            Part("assault"),
402            Part("gamemodeStartTrigger")
403        ],
404        "Game Mode Start",
405        KeyKind::Enum("gamemodeStartTrigger")
406    ),
407    entry!(
408        [
409            Part("gamemodes"),
410            Part("assault"),
411            Part("tankPassiveHealthBonus")
412        ],
413        "Tank Role Passive Health Bonus",
414        KeyKind::Enum("tankPassiveHealthBonus")
415    ),
416    entry!(
417        [Part("gamemodes"), Part("general"), Part("spawnHealthPacks")],
418        "Spawn Health Packs",
419        KeyKind::Enum("spawnHealthPacks")
420    ),
421    entry!(
422        [Part("heroes"), Team, Part("enabledHeroes")],
423        "enabled heroes",
424        KeyKind::ListHero
425    ),
426    entry!(
427        [Part("heroes"), Team, Part("disabledHeroes")],
428        "disabled heroes",
429        KeyKind::ListHero
430    ),
431    entry!(
432        [Part("heroes"), Part("general"), Part("disabledHeroes")],
433        "disabled heroes",
434        KeyKind::ListHero
435    ),
436    entry!(
437        [Part("heroes"), Team, Hero, Part("enablePrimaryFire")],
438        "Primary Fire",
439        KeyKind::Bool
440    ),
441    entry!(
442        [Part("heroes"), Team, Hero, Part("enableSecondaryFire")],
443        "Secondary Fire",
444        KeyKind::Bool
445    ),
446    entry!(
447        [Part("heroes"), Team, Hero, Part("enableAbility1")],
448        "Ability 1",
449        KeyKind::Bool
450    ),
451    entry!(
452        [Part("heroes"), Team, Hero, Part("enableAbility2")],
453        "Ability 2",
454        KeyKind::Bool
455    ),
456    entry!(
457        [Part("heroes"), Team, Hero, Part("health%")],
458        "Health",
459        KeyKind::Percent
460    ),
461    entry!(
462        [Part("heroes"), Team, Hero, Part("passiveUltGen%")],
463        "Ultimate Generation - Passive Blizzard",
464        KeyKind::Percent
465    ),
466    entry!(
467        [Part("heroes"), Team, Hero, Part("combatUltGen%")],
468        "Ultimate Generation - Combat Blizzard",
469        KeyKind::Percent
470    ),
471];
472
473#[derive(Debug, Clone, Copy)]
474pub(crate) struct NameMap {
475    pub(crate) key: &'static str,
476    pub(crate) name: &'static str,
477}
478
479const fn named(key: &'static str, name: &'static str) -> NameMap {
480    NameMap { key, name }
481}
482
483/// Game-mode names (source-backed: assault, control, escort, hybrid, skirmish,
484/// ffa, tdm, general).
485pub(crate) static MODE_NAMES: &[NameMap] = &[
486    named("assault", "Assault"),
487    named("control", "Control"),
488    named("escort", "Escort"),
489    named("hybrid", "Hybrid"),
490    named("skirmish", "Skirmish"),
491    named("ffa", "Deathmatch"),
492    named("tdm", "Team Deathmatch"),
493    named("general", "General"),
494];
495
496pub(crate) static MAP_NAMES: &[NameMap] = &[
497    named("workshopIsland", "Workshop Island"),
498    named("kingsRowWinter", "King's Row Winter"),
499];
500
501pub(crate) static HERO_NAMES: &[NameMap] = &[
502    named("anran", "Anran"),
503    named("ana", "Ana"),
504    named("ashe", "Ashe"),
505    named("bastion", "Bastion"),
506    named("baptiste", "Baptiste"),
507    named("brigitte", "Brigitte"),
508    named("cassidy", "Cassidy"),
509    named("dmon", "D.Mon"),
510    named("domina", "Domina"),
511    named("dva", "D.Va"),
512    named("doomfist", "Doomfist"),
513    named("echo", "Echo"),
514    named("emre", "Emre"),
515    named("freja", "Freja"),
516    named("genji", "Genji"),
517    named("hanzo", "Hanzo"),
518    named("moira", "Moira"),
519    named("reinhardt", "Reinhardt"),
520    named("hazard", "Hazard"),
521    named("illari", "Illari"),
522    named("juno", "Juno"),
523    named("jetpackCat", "Jetpack Cat"),
524    named("junkerQueen", "Junker Queen"),
525    named("junkrat", "Junkrat"),
526    named("kiriko", "Kiriko"),
527    named("lucio", "Lúcio"),
528    named("mauga", "Mauga"),
529    named("mercy", "Mercy"),
530    named("mizuki", "Mizuki"),
531    named("orisa", "Orisa"),
532    named("pharah", "Pharah"),
533    named("reaper", "Reaper"),
534    named("roadhog", "Roadhog"),
535    named("shion", "Shion"),
536    named("sierra", "Sierra"),
537    named("sigma", "Sigma"),
538    named("ramattra", "Ramattra"),
539    named("lifeweaver", "Lifeweaver"),
540    named("sojourn", "Sojourn"),
541    named("soldier", "Soldier: 76"),
542    named("sombra", "Sombra"),
543    named("symmetra", "Symmetra"),
544    named("torbjorn", "Torbjörn"),
545    named("tracer", "Tracer"),
546    named("venture", "Venture"),
547    named("widowmaker", "Widowmaker"),
548    named("winston", "Winston"),
549    named("wuyang", "Wuyang"),
550    named("wreckingBall", "Wrecking Ball"),
551    named("zarya", "Zarya"),
552    named("zenyatta", "Zenyatta"),
553    named("mei", "Mei"),
554];
555
556include!("data/generated_map_entries.rs");
557include!("data/generated_hero_entries.rs");
558include!("data/generated_mode_entries.rs");
559
560/// Team names inside `heroes` (source-backed: allTeams).
561pub(crate) static TEAM_NAMES: &[NameMap] = &[
562    named("allTeams", "General"),
563    named("team1", "Team 1"),
564    named("team2", "Team 2"),
565];
566
567#[derive(Debug, Clone, Copy)]
568pub(crate) struct EnumMember {
569    pub(crate) domain: &'static str,
570    pub(crate) member: &'static str,
571    pub(crate) name: &'static str,
572}
573
574const fn enum_member(domain: &'static str, member: &'static str, name: &'static str) -> EnumMember {
575    EnumMember {
576        domain,
577        member,
578        name,
579    }
580}
581
582include!("data/generated_entries.rs");
583include!("data/generated_hero_settings.rs");
584
585/// Fixture-owned canonical enum member names. Additional reviewed
586/// Workshop-data export members are retained through
587/// `projection_reconciliation.json`, which maps their source identities into
588/// these canonical domains without replacing fixture-backed display names.
589pub(crate) static ENUM_MEMBERS: &[EnumMember] = &[
590    enum_member("mapRotation", "afterAGame", "After A Game"),
591    enum_member("mapRotation", "afterMirrorMatch", "After A Mirror Match"),
592    enum_member("mapRotation", "paused", "Paused"),
593    enum_member("matchVoiceChat", "enabled", "Enabled"),
594    enum_member("returnToLobby", "never", "Never"),
595    enum_member("returnToLobby", "afterAGame", "After A Game"),
596    enum_member("returnToLobby", "afterMirrorMatch", "After A Mirror Match"),
597    enum_member("gamemodeStartTrigger", "immediately", "Immediately"),
598    enum_member("gamemodeStartTrigger", "manual", "Manual"),
599    enum_member("gamemodeStartTrigger", "allSlotsFilled", "All Slots Filled"),
600    enum_member("spawnHealthPacks", "disabled", "Disabled"),
601    enum_member("spawnHealthPacks", "modeDependent", "Determined By Mode"),
602    enum_member("spawnHealthPacks", "enabled", "Enabled"),
603    enum_member("roleLimit", "2OfEachRolePerTeam", "2 Of Each Role Per Team"),
604    enum_member(
605        "roleLimit",
606        "1Tank2Offense2Support",
607        "1 Tank 2 Offense 2 Support",
608    ),
609    enum_member("roleLimit", "off", "Off"),
610    enum_member("tankPassiveHealthBonus", "alwaysEnabled", "Always Enabled"),
611    enum_member("tankPassiveHealthBonus", "disabled", "Disabled"),
612    enum_member(
613        "tankPassiveHealthBonus",
614        "1Tank2Offense2Support",
615        "1 Tank 2 Offense 2 Support",
616    ),
617    enum_member("heroLimit", "off", "Off"),
618    enum_member("heroLimit", "1PerTeam", "1 Per Team"),
619    enum_member("heroLimit", "2PerTeam", "2 Per Team"),
620    enum_member("heroLimit", "1PerGame", "1 Per Game"),
621    enum_member("heroLimit", "2PerGame", "2 Per Game"),
622];
623
624/// Look up a settings leaf entry by its exact path.
625///
626/// `gamemodes.<mode>` groups inherit the `gamemodes.general` leaf set: the
627/// pinned oracle's settings schema merges `general.values` into every declared
628/// mode, with `elimination` inheriting only [`ELIMINATION_GENERAL_KEYS`]
629/// (source: `computeCustomGameSettingsSchema` in the pinned 9.7.10 oracle).
630/// The fallback resolves to the same `general` table entry, so an inherited
631/// per-mode path shares that entry's canonical identity.
632pub(crate) fn lookup(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
633    exact_entry(path).or_else(|| mode_inherited_entry(path))
634}
635
636fn exact_entry(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
637    entries().find(|entry| entry.path == path)
638}
639
640/// The `gamemodes.general` leaf keys `elimination` inherits (the pinned
641/// oracle's restricted copy list; every other declared mode inherits the full
642/// general set).
643static ELIMINATION_GENERAL_KEYS: &[&str] = &[
644    "disabledMaps",
645    "enableEnemyHealthBars",
646    "enableKillCam",
647    "enableKillFeed",
648    "enableSkins",
649    "enabledMaps",
650    "gamemodeStartTrigger",
651    "healthPackRespawnTime%",
652    "perkEliminationCatchupLevelAmount%",
653    "perkGeneration%",
654    "spawnHealthPacks",
655    "teamOverlay",
656];
657
658pub(crate) fn mode_inherited_entry(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
659    let [
660        PathPart::Part("gamemodes"),
661        PathPart::Part(mode),
662        PathPart::Part(key),
663    ] = path
664    else {
665        return None;
666    };
667    if *mode == "general" {
668        return None;
669    }
670    let inherits = if *mode == "elimination" {
671        ELIMINATION_GENERAL_KEYS.contains(key)
672    } else {
673        // A mode with declared entries is a reviewed mode slot and inherits
674        // the general leaf set.
675        entries().any(|entry| {
676            matches!(
677                entry.path,
678                [PathPart::Part("gamemodes"), PathPart::Part(declared), ..] if *declared == *mode
679            )
680        })
681    };
682    if !inherits {
683        return None;
684    }
685    exact_entry(&[
686        PathPart::Part("gamemodes"),
687        PathPart::Part("general"),
688        PathPart::Part(key),
689    ])
690}
691
692/// Iterate the reviewed settings inventory with the hand-written projection
693/// taking precedence over the generated export projection. Duplicate paths
694/// are represented once in the semantic catalog while the parser and emitter
695/// continue to use the same lookup table.
696pub(crate) fn entries() -> impl Iterator<Item = &'static TableEntry> {
697    deduplicated_entries(ENTRIES.iter().chain(GENERATED_ENTRIES.iter()))
698}
699
700/// Iterate both catalog projections without applying effective lookup
701/// precedence. The semantic validator uses this to compare duplicate paths
702/// instead of allowing `entries()` to hide stale or conflicting data.
703pub(crate) fn raw_entries() -> impl Iterator<Item = ProjectedEntry> {
704    ENTRIES
705        .iter()
706        .map(|entry| ProjectedEntry {
707            source: ProjectionSource::FixtureTable,
708            entry,
709        })
710        .chain(GENERATED_ENTRIES.iter().map(|entry| ProjectedEntry {
711            source: ProjectionSource::WorkshopDataExport,
712            entry,
713        }))
714}
715
716/// One setting leaf before effective lookup resolves duplicated projections.
717#[derive(Debug, Clone, Copy)]
718pub(crate) struct ProjectedEntry {
719    pub(crate) source: ProjectionSource,
720    pub(crate) entry: &'static TableEntry,
721}
722
723#[derive(Debug, Clone, Copy)]
724pub(crate) enum ProjectionSource {
725    FixtureTable,
726    WorkshopDataExport,
727}
728
729impl ProjectionSource {
730    pub(crate) const fn label(self) -> &'static str {
731        match self {
732            Self::FixtureTable => "fixture table",
733            Self::WorkshopDataExport => "Workshop-data export",
734        }
735    }
736}
737
738fn deduplicated_entries(
739    entries: impl Iterator<Item = &'static TableEntry>,
740) -> impl Iterator<Item = &'static TableEntry> {
741    let mut paths = std::collections::HashSet::new();
742    entries.filter(move |entry| paths.insert(path_string(entry.path)))
743}
744
745pub(crate) fn is_generated_entry(entry: &TableEntry) -> bool {
746    GENERATED_ENTRIES
747        .iter()
748        .any(|candidate| std::ptr::eq(candidate, entry))
749}
750
751pub(crate) fn ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
752    match path.last() {
753        Some(PathPart::Part("ability1Cooldown%" | "enableAbility1")) => Some("ability1"),
754        Some(PathPart::Part("ability2Cooldown%" | "enableAbility2")) => Some("ability2"),
755        Some(PathPart::Part("ability3Cooldown%" | "enableAbility3")) => Some("ability3"),
756        Some(PathPart::Part(
757            "secondaryFireCooldown%"
758            | "secondaryFireEnergyChargeRate%"
759            | "secondaryFireMaximumTime%"
760            | "secondaryFireRechargeRate%"
761            | "enableSecondaryFire"
762            | "enableGenericSecondaryFire",
763        )) => Some("secondaryFire"),
764        Some(PathPart::Part("combatUltGen%" | "passiveUltGen%" | "ultGen%" | "enableUlt")) => {
765            Some("ultimate")
766        }
767        Some(PathPart::Part("enablePassive")) => Some("passive"),
768        _ => None,
769    }
770}
771
772pub(crate) fn hero_setting_name(hero: &str, key: &str, locale: &str) -> Option<&'static str> {
773    // A producer alias is the spelling pinned OverPy writes, so it is emitted
774    // in preference to the export label.
775    hero_setting_aliases()
776        .iter()
777        .find(|alias| {
778            alias.hero == hero && alias.key == key && alias.locale.eq_ignore_ascii_case(locale)
779        })
780        .map(|alias| alias.display.as_str())
781        .or_else(|| {
782            GENERATED_HERO_SETTING_NAMES
783                .iter()
784                .find(|entry| entry.hero == hero && entry.key == key)
785                .and_then(|entry| entry.localized(locale))
786        })
787        .or_else(|| {
788            if locale.eq_ignore_ascii_case("en-US") {
789                GENERATED_HERO_SETTING_NAMES
790                    .iter()
791                    .find(|entry| entry.hero == hero && entry.key == key)
792                    .filter(|entry| {
793                        entry.locales.iter().any(|(known, value)| {
794                            known.eq_ignore_ascii_case(locale)
795                                && (value.trim().is_empty() || value.starts_with(' '))
796                        })
797                    })
798                    .map(|entry| entry.key)
799            } else {
800                None
801            }
802        })
803}
804
805/// Parse-side display match against every generated row for `(hero, key)`.
806/// Emission reads only the first row, but duplicate upstream rows carry
807/// additional reviewed spellings the parser must still accept. A producer
808/// alias shadows the export labels entirely: emission prefers the alias, so
809/// accepting a shadowed generated spelling would bind a display the emitter
810/// cannot reproduce.
811pub(crate) fn hero_setting_display_matches(
812    hero: &str,
813    key: &str,
814    locale: &str,
815    display: &str,
816) -> bool {
817    if hero_setting_has_alias(hero, key, locale) {
818        return false;
819    }
820    GENERATED_HERO_SETTING_NAMES
821        .iter()
822        .filter(|entry| entry.hero == hero && entry.key == key)
823        .filter_map(|entry| entry.localized(locale))
824        .any(|name| names_eq(name, display))
825}
826
827/// The `%1$s` composition template a hero-scoped leaf key carries: the
828/// generated export's placeholder form, which the emitter fills with the
829/// hero's gameplay ability name when no reviewed per-hero label exists.
830pub(crate) fn hero_key_template(key: &str) -> Option<&'static str> {
831    GENERATED_ENTRIES
832        .iter()
833        .find(|entry| {
834            matches!(
835                entry.path,
836                [
837                    PathPart::Part("heroes"),
838                    PathPart::Team,
839                    PathPart::Hero,
840                    PathPart::Part(candidate)
841                ] if *candidate == key
842            )
843        })
844        .map(|entry| entry.workshop_name)
845        .filter(|name| name.contains("%1$s"))
846}
847
848#[derive(Deserialize)]
849struct HeroSettingAlias {
850    hero: String,
851    key: String,
852    locale: String,
853    display: String,
854}
855
856fn hero_setting_aliases() -> &'static [HeroSettingAlias] {
857    static ALIASES: OnceLock<Vec<HeroSettingAlias>> = OnceLock::new();
858    ALIASES.get_or_init(|| {
859        serde_json::from_str(include_str!("data/hero_setting_aliases.json"))
860            .expect("hero setting alias data is valid JSON")
861    })
862}
863
864/// Whether a reviewed producer alias exists for `(hero, key)` in `locale` —
865/// the alias shadows the export's generated labels and `%1$s` templates.
866pub(crate) fn hero_setting_has_alias(hero: &str, key: &str, locale: &str) -> bool {
867    hero_setting_aliases().iter().any(|alias| {
868        alias.hero == hero && alias.key == key && alias.locale.eq_ignore_ascii_case(locale)
869    })
870}
871
872/// Reviewed producer aliases observed in the pinned AI-PVE artifact. These
873/// labels omit the export's `倍率` suffix or use the producer's shorter
874/// ability label, but identify the same canonical setting path.
875pub(crate) fn hero_setting_alias(hero: &str, key: &str, locale: &str, display: &str) -> bool {
876    hero_setting_aliases().iter().any(|alias| {
877        alias.hero == hero
878            && alias.key == key
879            && alias.locale.eq_ignore_ascii_case(locale)
880            && names_eq(&alias.display, display)
881    })
882}
883
884/// Boolean value spellings the Workshop client localizes inconsistently for
885/// one specific key: zh-CN writes `禁用` ("disabled") where the setting is an
886/// off switch. Position-specific on purpose; `禁用` is not a boolean spelling
887/// elsewhere.
888const BOOL_VALUE_ALIASES: &[(&str, &str, &str, bool)] =
889    &[("enableSecondaryFire", "zh-CN", "禁用", false)];
890
891pub(crate) fn bool_value_alias(key: &str, locale: &str, display: &str) -> Option<bool> {
892    BOOL_VALUE_ALIASES
893        .iter()
894        .find(|(alias_key, alias_locale, spelling, _)| {
895            *alias_key == key
896                && alias_locale.eq_ignore_ascii_case(locale)
897                && names_eq(spelling, display)
898        })
899        .map(|(_, _, _, value)| *value)
900}
901
902fn name_in(maps: &[NameMap], key: &str) -> Option<&'static str> {
903    maps.iter().find(|m| m.key == key).map(|m| m.name)
904}
905
906/// Settings-name comparison: the Workshop client matches setting keys and
907/// value spellings without regard to case (Deltinteger's `TextToElement`
908/// settings matcher is `caseSensitive: false`; the pinned oracle's
909/// decompiler lowercases both sides). Whitespace differences in a reviewed
910/// spelling (`66号公路`/`66 号公路`, `1팀`/`1 팀`, #448) are likewise
911/// insignificant. Accents remain significant: `Chateau Guillard` does not
912/// match `Château Guillard`.
913pub(crate) fn names_eq(left: &str, right: &str) -> bool {
914    left == right
915        || left.to_lowercase() == right.to_lowercase()
916        || crate::catalog::normalize_spelling(left).to_lowercase()
917            == crate::catalog::normalize_spelling(right).to_lowercase()
918}
919
920pub(crate) fn map_spellings() -> impl Iterator<Item = &'static str> {
921    MAP_NAMES
922        .iter()
923        .chain(GENERATED_MAP_NAMES.iter())
924        .map(|m| m.name)
925}
926
927pub(crate) fn hero_spellings() -> impl Iterator<Item = &'static str> {
928    HERO_NAMES
929        .iter()
930        .chain(GENERATED_HERO_NAMES.iter())
931        .map(|m| m.name)
932}
933
934pub(crate) fn team_spellings() -> impl Iterator<Item = &'static str> {
935    TEAM_NAMES.iter().map(|m| m.name)
936}
937
938pub(crate) fn enum_spellings(domain: &str) -> impl Iterator<Item = &'static str> {
939    enum_members(domain).map(|member| member.name)
940}
941
942/// Canonical `workshop_name` spellings of the leaf entries valid directly
943/// under `parent`, including the `gamemodes.general` leaves a mode inherits.
944/// Mirrors `lookup`/`mode_inherited_entry`: `elimination` inherits only
945/// [`ELIMINATION_GENERAL_KEYS`], other modes inherit the full set only when
946/// they have declared entries, and `general` itself has no fallback.
947pub(crate) fn key_spellings(parent: &[PathPart<'_>]) -> Vec<&'static str> {
948    let mut spellings: Vec<&'static str> = entries()
949        .filter(|entry| {
950            entry.path.len() == parent.len() + 1
951                && entry.path[..parent.len()] == *parent
952                // `%1$s` placeholder names are emission templates, not
953                // spellings a source could carry.
954                && !entry.workshop_name.contains("%1$s")
955        })
956        .map(|entry| entry.workshop_name)
957        .collect();
958    let [PathPart::Part("gamemodes"), PathPart::Part(mode)] = parent else {
959        return spellings;
960    };
961    if *mode == "general" {
962        return spellings;
963    }
964    if *mode == "elimination" {
965        spellings.extend(entries().filter_map(|entry| {
966            let [
967                PathPart::Part("gamemodes"),
968                PathPart::Part("general"),
969                PathPart::Part(key),
970            ] = entry.path
971            else {
972                return None;
973            };
974            ELIMINATION_GENERAL_KEYS
975                .contains(key)
976                .then_some(entry.workshop_name)
977        }));
978    } else if entries().any(|entry| {
979        matches!(
980            entry.path,
981            [PathPart::Part("gamemodes"), PathPart::Part(declared), ..]
982                if *declared == *mode
983        )
984    }) {
985        spellings.extend(entries().filter_map(|entry| {
986            let [
987                PathPart::Part("gamemodes"),
988                PathPart::Part("general"),
989                PathPart::Part(_),
990            ] = entry.path
991            else {
992                return None;
993            };
994            (!entry.workshop_name.contains("%1$s")).then_some(entry.workshop_name)
995        }));
996    }
997    spellings
998}
999
1000pub(crate) fn mode_name(key: &str) -> Option<&'static str> {
1001    name_in(MODE_NAMES, key).or_else(|| name_in(GENERATED_MODE_NAMES, key))
1002}
1003
1004pub(crate) fn map_name(key: &str) -> Option<&'static str> {
1005    name_in(MAP_NAMES, key).or_else(|| name_in(GENERATED_MAP_NAMES, key))
1006}
1007
1008pub(crate) fn hero_name(key: &str) -> Option<&'static str> {
1009    name_in(HERO_NAMES, key).or_else(|| name_in(GENERATED_HERO_NAMES, key))
1010}
1011
1012pub(crate) fn team_name(key: &str) -> Option<&'static str> {
1013    name_in(TEAM_NAMES, key)
1014}
1015
1016pub(crate) fn enum_name(domain: &str, member: &str) -> Option<&'static str> {
1017    ENUM_MEMBERS
1018        .iter()
1019        .find(|m| m.domain == domain && m.member == member)
1020        .map(|m| m.name)
1021        .or_else(|| {
1022            GENERATED_ENUM_MEMBERS
1023                .iter()
1024                .find(|m| m.domain == domain && m.member == member)
1025                .map(|m| m.name)
1026        })
1027}
1028
1029pub(crate) fn enum_members(domain: &str) -> impl Iterator<Item = &'static EnumMember> {
1030    ENUM_MEMBERS
1031        .iter()
1032        .chain(GENERATED_ENUM_MEMBERS.iter())
1033        .filter(move |member| member.domain == domain)
1034}
1035
1036pub(crate) fn path_string(path: &[PathPart<'_>]) -> String {
1037    path.iter()
1038        .map(|part| match part {
1039            PathPart::Part(name) => (*name).to_string(),
1040            PathPart::Team => "<team>".to_string(),
1041            PathPart::Hero => "<hero>".to_string(),
1042        })
1043        .collect::<Vec<_>>()
1044        .join(".")
1045}