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    // Hash-bucket index over `entries()`: a path resolves to a small bucket
638    // verified by full slice equality instead of scanning the whole table.
639    entry_bucket(path).find(|entry| entry.path == path)
640}
641
642/// The hash bucket entries sharing `path`'s hash occupy. The table is
643/// deduplicated by `path_string`, so distinct entries in one bucket differ
644/// in path and equality resolves to at most one entry.
645fn entry_bucket(path: &[PathPart<'_>]) -> impl Iterator<Item = &'static TableEntry> {
646    static INDEX: OnceLock<std::collections::HashMap<u64, Vec<&'static TableEntry>>> =
647        OnceLock::new();
648    let index = INDEX.get_or_init(|| {
649        let mut index: std::collections::HashMap<u64, Vec<&'static TableEntry>> =
650            std::collections::HashMap::new();
651        for entry in entries() {
652            index.entry(path_hash(entry.path)).or_default().push(entry);
653        }
654        index
655    });
656    index
657        .get(&path_hash(path))
658        .into_iter()
659        .flat_map(|bucket| bucket.iter().copied())
660}
661
662fn path_hash(path: &[PathPart<'_>]) -> u64 {
663    use std::hash::{Hash, Hasher};
664    let mut hasher = std::collections::hash_map::DefaultHasher::new();
665    path.hash(&mut hasher);
666    hasher.finish()
667}
668
669/// Mode keys with declared `gamemodes.<mode>` entries — the reviewed mode
670/// slots that inherit the `gamemodes.general` leaf set.
671fn declared_modes() -> &'static std::collections::HashSet<&'static str> {
672    static MODES: OnceLock<std::collections::HashSet<&'static str>> = OnceLock::new();
673    MODES.get_or_init(|| {
674        entries()
675            .filter_map(|entry| {
676                let [PathPart::Part("gamemodes"), PathPart::Part(mode), ..] = entry.path else {
677                    return None;
678                };
679                Some(*mode)
680            })
681            .collect()
682    })
683}
684
685/// Table entries that are direct children of `parent` — a parent-path hash
686/// bucket verified by prefix equality. Callers that previously scanned
687/// `entries()` for `path.len() == parent.len() + 1 && prefix == parent`
688/// share this index.
689pub(crate) fn child_entries(parent: &[PathPart<'_>]) -> Vec<&'static TableEntry> {
690    static CHILDREN: OnceLock<std::collections::HashMap<u64, Vec<(usize, &'static TableEntry)>>> =
691        OnceLock::new();
692    let children = CHILDREN.get_or_init(|| {
693        let mut children: std::collections::HashMap<u64, Vec<(usize, &'static TableEntry)>> =
694            std::collections::HashMap::new();
695        for entry in entries() {
696            if let Some((_, parent)) = entry.path.split_last() {
697                children
698                    .entry(path_hash(parent))
699                    .or_default()
700                    .push((entry.path.len() - 1, entry));
701            }
702        }
703        children
704    });
705    children
706        .get(&path_hash(parent))
707        .map(|bucket| {
708            bucket
709                .iter()
710                .filter(|(parent_len, entry)| {
711                    *parent_len == parent.len() && entry.path[..parent.len()] == *parent
712                })
713                .map(|(_, entry)| *entry)
714                .collect()
715        })
716        .unwrap_or_default()
717}
718
719/// The `gamemodes.general` leaf keys `elimination` inherits (the pinned
720/// oracle's restricted copy list; every other declared mode inherits the full
721/// general set).
722static ELIMINATION_GENERAL_KEYS: &[&str] = &[
723    "disabledMaps",
724    "enableEnemyHealthBars",
725    "enableKillCam",
726    "enableKillFeed",
727    "enableSkins",
728    "enabledMaps",
729    "gamemodeStartTrigger",
730    "healthPackRespawnTime%",
731    "perkEliminationCatchupLevelAmount%",
732    "perkGeneration%",
733    "spawnHealthPacks",
734    "teamOverlay",
735];
736
737pub(crate) fn mode_inherited_entry(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
738    let [
739        PathPart::Part("gamemodes"),
740        PathPart::Part(mode),
741        PathPart::Part(key),
742    ] = path
743    else {
744        return None;
745    };
746    if *mode == "general" {
747        return None;
748    }
749    let inherits = if *mode == "elimination" {
750        ELIMINATION_GENERAL_KEYS.contains(key)
751    } else {
752        // A mode with declared entries is a reviewed mode slot and inherits
753        // the general leaf set.
754        declared_modes().contains(mode)
755    };
756    if !inherits {
757        return None;
758    }
759    exact_entry(&[
760        PathPart::Part("gamemodes"),
761        PathPart::Part("general"),
762        PathPart::Part(key),
763    ])
764}
765
766/// Iterate the reviewed settings inventory with the hand-written projection
767/// taking precedence over the generated export projection. Duplicate paths
768/// are represented once in the semantic catalog while the parser and emitter
769/// continue to use the same lookup table.
770pub(crate) fn entries() -> impl Iterator<Item = &'static TableEntry> {
771    deduplicated_entries(ENTRIES.iter().chain(GENERATED_ENTRIES.iter()))
772}
773
774/// Iterate both catalog projections without applying effective lookup
775/// precedence. The semantic validator uses this to compare duplicate paths
776/// instead of allowing `entries()` to hide stale or conflicting data.
777pub(crate) fn raw_entries() -> impl Iterator<Item = ProjectedEntry> {
778    ENTRIES
779        .iter()
780        .map(|entry| ProjectedEntry {
781            source: ProjectionSource::FixtureTable,
782            entry,
783        })
784        .chain(GENERATED_ENTRIES.iter().map(|entry| ProjectedEntry {
785            source: ProjectionSource::WorkshopDataExport,
786            entry,
787        }))
788}
789
790/// One setting leaf before effective lookup resolves duplicated projections.
791#[derive(Debug, Clone, Copy)]
792pub(crate) struct ProjectedEntry {
793    pub(crate) source: ProjectionSource,
794    pub(crate) entry: &'static TableEntry,
795}
796
797#[derive(Debug, Clone, Copy)]
798pub(crate) enum ProjectionSource {
799    FixtureTable,
800    WorkshopDataExport,
801}
802
803impl ProjectionSource {
804    pub(crate) const fn label(self) -> &'static str {
805        match self {
806            Self::FixtureTable => "fixture table",
807            Self::WorkshopDataExport => "Workshop-data export",
808        }
809    }
810}
811
812fn deduplicated_entries(
813    entries: impl Iterator<Item = &'static TableEntry>,
814) -> impl Iterator<Item = &'static TableEntry> {
815    let mut paths = std::collections::HashSet::new();
816    entries.filter(move |entry| paths.insert(path_string(entry.path)))
817}
818
819pub(crate) fn is_generated_entry(entry: &TableEntry) -> bool {
820    GENERATED_ENTRIES
821        .iter()
822        .any(|candidate| std::ptr::eq(candidate, entry))
823}
824
825pub(crate) fn ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
826    match path.last() {
827        Some(PathPart::Part("ability1Cooldown%" | "enableAbility1")) => Some("ability1"),
828        Some(PathPart::Part("ability2Cooldown%" | "enableAbility2")) => Some("ability2"),
829        Some(PathPart::Part("ability3Cooldown%" | "enableAbility3")) => Some("ability3"),
830        Some(PathPart::Part(
831            "secondaryFireCooldown%"
832            | "secondaryFireEnergyChargeRate%"
833            | "secondaryFireMaximumTime%"
834            | "secondaryFireRechargeRate%"
835            | "enableSecondaryFire"
836            | "enableGenericSecondaryFire",
837        )) => Some("secondaryFire"),
838        Some(PathPart::Part("combatUltGen%" | "passiveUltGen%" | "ultGen%" | "enableUlt")) => {
839            Some("ultimate")
840        }
841        Some(PathPart::Part("enablePassive")) => Some("passive"),
842        _ => None,
843    }
844}
845
846pub(crate) fn hero_setting_name(hero: &str, key: &str, locale: &str) -> Option<&'static str> {
847    // A producer alias is the spelling pinned OverPy writes, so it is emitted
848    // in preference to the export label.
849    hero_setting_aliases()
850        .iter()
851        .find(|alias| {
852            alias.hero == hero && alias.key == key && alias.locale.eq_ignore_ascii_case(locale)
853        })
854        .map(|alias| alias.display.as_str())
855        .or_else(|| {
856            GENERATED_HERO_SETTING_NAMES
857                .iter()
858                .find(|entry| entry.hero == hero && entry.key == key)
859                .and_then(|entry| entry.localized(locale))
860        })
861        .or_else(|| {
862            if locale.eq_ignore_ascii_case("en-US") {
863                GENERATED_HERO_SETTING_NAMES
864                    .iter()
865                    .find(|entry| entry.hero == hero && entry.key == key)
866                    .filter(|entry| {
867                        entry.locales.iter().any(|(known, value)| {
868                            known.eq_ignore_ascii_case(locale)
869                                && (value.trim().is_empty() || value.starts_with(' '))
870                        })
871                    })
872                    .map(|entry| entry.key)
873            } else {
874                None
875            }
876        })
877}
878
879/// Parse-side display match against every generated row for `(hero, key)`.
880/// Emission reads only the first row, but duplicate upstream rows carry
881/// additional reviewed spellings the parser must still accept. A producer
882/// alias shadows the export labels entirely: emission prefers the alias, so
883/// accepting a shadowed generated spelling would bind a display the emitter
884/// cannot reproduce.
885pub(crate) fn hero_setting_display_matches(
886    hero: &str,
887    key: &str,
888    locale: &str,
889    display: &str,
890) -> bool {
891    if hero_setting_has_alias(hero, key, locale) {
892        return false;
893    }
894    GENERATED_HERO_SETTING_NAMES
895        .iter()
896        .filter(|entry| entry.hero == hero && entry.key == key)
897        .filter_map(|entry| entry.localized(locale))
898        .any(|name| names_eq(name, display))
899}
900
901/// The `%1$s` composition template a hero-scoped leaf key carries: the
902/// generated export's placeholder form, which the emitter fills with the
903/// hero's gameplay ability name when no reviewed per-hero label exists.
904pub(crate) fn hero_key_template(key: &str) -> Option<&'static str> {
905    GENERATED_ENTRIES
906        .iter()
907        .find(|entry| {
908            matches!(
909                entry.path,
910                [
911                    PathPart::Part("heroes"),
912                    PathPart::Team,
913                    PathPart::Hero,
914                    PathPart::Part(candidate)
915                ] if *candidate == key
916            )
917        })
918        .map(|entry| entry.workshop_name)
919        .filter(|name| name.contains("%1$s"))
920}
921
922#[derive(Deserialize)]
923struct HeroSettingAlias {
924    hero: String,
925    key: String,
926    locale: String,
927    display: String,
928}
929
930fn hero_setting_aliases() -> &'static [HeroSettingAlias] {
931    static ALIASES: OnceLock<Vec<HeroSettingAlias>> = OnceLock::new();
932    ALIASES.get_or_init(|| {
933        serde_json::from_str(include_str!("data/hero_setting_aliases.json"))
934            .expect("hero setting alias data is valid JSON")
935    })
936}
937
938/// Whether a reviewed producer alias exists for `(hero, key)` in `locale` —
939/// the alias shadows the export's generated labels and `%1$s` templates.
940pub(crate) fn hero_setting_has_alias(hero: &str, key: &str, locale: &str) -> bool {
941    hero_setting_aliases().iter().any(|alias| {
942        alias.hero == hero && alias.key == key && alias.locale.eq_ignore_ascii_case(locale)
943    })
944}
945
946/// Reviewed producer aliases observed in the pinned AI-PVE artifact. These
947/// labels omit the export's `倍率` suffix or use the producer's shorter
948/// ability label, but identify the same canonical setting path.
949pub(crate) fn hero_setting_alias(hero: &str, key: &str, locale: &str, display: &str) -> bool {
950    hero_setting_aliases().iter().any(|alias| {
951        alias.hero == hero
952            && alias.key == key
953            && alias.locale.eq_ignore_ascii_case(locale)
954            && names_eq(&alias.display, display)
955    })
956}
957
958/// Boolean value spellings the Workshop client localizes inconsistently for
959/// one specific key: zh-CN writes `禁用` ("disabled") where the setting is an
960/// off switch. Position-specific on purpose; `禁用` is not a boolean spelling
961/// elsewhere.
962const BOOL_VALUE_ALIASES: &[(&str, &str, &str, bool)] =
963    &[("enableSecondaryFire", "zh-CN", "禁用", false)];
964
965pub(crate) fn bool_value_alias(key: &str, locale: &str, display: &str) -> Option<bool> {
966    BOOL_VALUE_ALIASES
967        .iter()
968        .find(|(alias_key, alias_locale, spelling, _)| {
969            *alias_key == key
970                && alias_locale.eq_ignore_ascii_case(locale)
971                && names_eq(spelling, display)
972        })
973        .map(|(_, _, _, value)| *value)
974}
975
976fn name_in(maps: &[NameMap], key: &str) -> Option<&'static str> {
977    maps.iter().find(|m| m.key == key).map(|m| m.name)
978}
979
980/// Settings-name comparison: the Workshop client matches setting keys and
981/// value spellings without regard to case (Deltinteger's `TextToElement`
982/// settings matcher is `caseSensitive: false`; the pinned oracle's
983/// decompiler lowercases both sides). Whitespace differences in a reviewed
984/// spelling (`66号公路`/`66 号公路`, `1팀`/`1 팀`, #448) are likewise
985/// insignificant. Accents remain significant: `Chateau Guillard` does not
986/// match `Château Guillard`.
987pub(crate) fn names_eq(left: &str, right: &str) -> bool {
988    left == right
989        || left.to_lowercase() == right.to_lowercase()
990        || crate::catalog::normalize_spelling(left).to_lowercase()
991            == crate::catalog::normalize_spelling(right).to_lowercase()
992}
993
994pub(crate) fn map_spellings() -> impl Iterator<Item = &'static str> {
995    MAP_NAMES
996        .iter()
997        .chain(GENERATED_MAP_NAMES.iter())
998        .map(|m| m.name)
999}
1000
1001pub(crate) fn hero_spellings() -> impl Iterator<Item = &'static str> {
1002    HERO_NAMES
1003        .iter()
1004        .chain(GENERATED_HERO_NAMES.iter())
1005        .map(|m| m.name)
1006}
1007
1008pub(crate) fn team_spellings() -> impl Iterator<Item = &'static str> {
1009    TEAM_NAMES.iter().map(|m| m.name)
1010}
1011
1012pub(crate) fn enum_spellings(domain: &str) -> impl Iterator<Item = &'static str> {
1013    enum_members(domain).map(|member| member.name)
1014}
1015
1016/// Canonical `workshop_name` spellings of the leaf entries valid directly
1017/// under `parent`, including the `gamemodes.general` leaves a mode inherits.
1018/// Mirrors `lookup`/`mode_inherited_entry`: `elimination` inherits only
1019/// [`ELIMINATION_GENERAL_KEYS`], other modes inherit the full set only when
1020/// they have declared entries, and `general` itself has no fallback.
1021pub(crate) fn key_spellings(parent: &[PathPart<'_>]) -> Vec<&'static str> {
1022    let mut spellings: Vec<&'static str> = child_entries(parent)
1023        .iter()
1024        .filter(|entry| {
1025            // `%1$s` placeholder names are emission templates, not
1026            // spellings a source could carry.
1027            !entry.workshop_name.contains("%1$s")
1028        })
1029        .map(|entry| entry.workshop_name)
1030        .collect();
1031    let [PathPart::Part("gamemodes"), PathPart::Part(mode)] = parent else {
1032        return spellings;
1033    };
1034    if *mode == "general" {
1035        return spellings;
1036    }
1037    if *mode == "elimination" {
1038        spellings.extend(
1039            child_entries(&[PathPart::Part("gamemodes"), PathPart::Part("general")])
1040                .iter()
1041                .filter_map(|entry| {
1042                    let PathPart::Part(key) = entry.path.last()? else {
1043                        return None;
1044                    };
1045                    ELIMINATION_GENERAL_KEYS
1046                        .contains(key)
1047                        .then_some(entry.workshop_name)
1048                }),
1049        );
1050    } else if declared_modes().contains(mode) {
1051        spellings.extend(
1052            child_entries(&[PathPart::Part("gamemodes"), PathPart::Part("general")])
1053                .iter()
1054                .filter_map(|entry| {
1055                    (!entry.workshop_name.contains("%1$s")).then_some(entry.workshop_name)
1056                }),
1057        );
1058    }
1059    spellings
1060}
1061
1062pub(crate) fn mode_name(key: &str) -> Option<&'static str> {
1063    name_in(MODE_NAMES, key).or_else(|| name_in(GENERATED_MODE_NAMES, key))
1064}
1065
1066pub(crate) fn map_name(key: &str) -> Option<&'static str> {
1067    name_in(MAP_NAMES, key).or_else(|| name_in(GENERATED_MAP_NAMES, key))
1068}
1069
1070pub(crate) fn hero_name(key: &str) -> Option<&'static str> {
1071    name_in(HERO_NAMES, key).or_else(|| name_in(GENERATED_HERO_NAMES, key))
1072}
1073
1074pub(crate) fn team_name(key: &str) -> Option<&'static str> {
1075    name_in(TEAM_NAMES, key)
1076}
1077
1078pub(crate) fn enum_name(domain: &str, member: &str) -> Option<&'static str> {
1079    ENUM_MEMBERS
1080        .iter()
1081        .find(|m| m.domain == domain && m.member == member)
1082        .map(|m| m.name)
1083        .or_else(|| {
1084            GENERATED_ENUM_MEMBERS
1085                .iter()
1086                .find(|m| m.domain == domain && m.member == member)
1087                .map(|m| m.name)
1088        })
1089}
1090
1091pub(crate) fn enum_members(domain: &str) -> impl Iterator<Item = &'static EnumMember> {
1092    ENUM_MEMBERS
1093        .iter()
1094        .chain(GENERATED_ENUM_MEMBERS.iter())
1095        .filter(move |member| member.domain == domain)
1096}
1097
1098pub(crate) fn path_string(path: &[PathPart<'_>]) -> String {
1099    path.iter()
1100        .map(|part| match part {
1101            PathPart::Part(name) => (*name).to_string(),
1102            PathPart::Team => "<team>".to_string(),
1103            PathPart::Hero => "<hero>".to_string(),
1104        })
1105        .collect::<Vec<_>>()
1106        .join(".")
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112
1113    /// The pre-index linear scan kept as the resolution oracle: a path
1114    /// resolves to the first deduplicated entry whose path equals it, with
1115    /// the `gamemodes.<mode>` → `gamemodes.general` inheritance fallback.
1116    fn scan_exact(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
1117        entries().find(|entry| entry.path == path)
1118    }
1119
1120    fn scan_mode_inherited(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
1121        let [
1122            PathPart::Part("gamemodes"),
1123            PathPart::Part(mode),
1124            PathPart::Part(key),
1125        ] = path
1126        else {
1127            return None;
1128        };
1129        if *mode == "general" {
1130            return None;
1131        }
1132        let inherits = if *mode == "elimination" {
1133            ELIMINATION_GENERAL_KEYS.contains(key)
1134        } else {
1135            entries().any(|entry| {
1136                matches!(
1137                    entry.path,
1138                    [PathPart::Part("gamemodes"), PathPart::Part(declared), ..]
1139                        if *declared == *mode
1140                )
1141            })
1142        };
1143        if !inherits {
1144            return None;
1145        }
1146        scan_exact(&[
1147            PathPart::Part("gamemodes"),
1148            PathPart::Part("general"),
1149            PathPart::Part(key),
1150        ])
1151    }
1152
1153    fn scan_lookup(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
1154        scan_exact(path).or_else(|| scan_mode_inherited(path))
1155    }
1156
1157    /// Every path the table itself declares — exact and inherited-mode
1158    /// paths — plus paths that must not resolve agree with the scan oracle.
1159    #[test]
1160    fn index_matches_scan_for_every_table_path() {
1161        let mut checked = 0usize;
1162        for entry in entries() {
1163            assert_eq!(
1164                scan_lookup(entry.path).map(std::ptr::from_ref),
1165                lookup(entry.path).map(std::ptr::from_ref),
1166                "path {}",
1167                path_string(entry.path),
1168            );
1169            checked += 1;
1170            // Inherited-mode projection: swap a `gamemodes.<mode>` segment
1171            // for every other declared mode and for `elimination`.
1172            if let [
1173                PathPart::Part("gamemodes"),
1174                PathPart::Part(_),
1175                PathPart::Part(key),
1176            ] = entry.path
1177            {
1178                for mode in declared_modes().iter().copied().chain(["elimination"]) {
1179                    let path = [
1180                        PathPart::Part("gamemodes"),
1181                        PathPart::Part(mode),
1182                        PathPart::Part(key),
1183                    ];
1184                    assert_eq!(
1185                        scan_lookup(&path).map(std::ptr::from_ref),
1186                        lookup(&path).map(std::ptr::from_ref),
1187                        "path {}",
1188                        path_string(&path),
1189                    );
1190                    checked += 1;
1191                }
1192            }
1193        }
1194
1195        // Paths that must not resolve: truncation, extension, unknown
1196        // namespaces/keys, wrong slot kinds, and undeclared modes.
1197        let non_resolving: &[&[PathPart<'_>]] = &[
1198            &[],
1199            &[PathPart::Part("main")],
1200            &[PathPart::Part("lobby")],
1201            &[PathPart::Part("unknownNamespace"), PathPart::Part("key")],
1202            &[PathPart::Part("lobby"), PathPart::Part("unknownKey")],
1203            &[PathPart::Part("gamemodes"), PathPart::Part("general")],
1204            &[
1205                PathPart::Part("gamemodes"),
1206                PathPart::Part("undeclaredMode"),
1207                PathPart::Part("enabledMaps"),
1208            ],
1209            &[
1210                PathPart::Part("gamemodes"),
1211                PathPart::Part("elimination"),
1212                PathPart::Part("healthPackRespawnTime"),
1213            ],
1214            &[PathPart::Part("heroes"), PathPart::Team],
1215        ];
1216        for path in non_resolving {
1217            assert_eq!(
1218                scan_lookup(path).map(std::ptr::from_ref),
1219                lookup(path).map(std::ptr::from_ref),
1220                "path {}",
1221                path_string(path),
1222            );
1223            checked += 1;
1224        }
1225        // Slot-kind mismatch: a table `heroes` path queried with `Part`
1226        // where the table declares `Hero`/`Team` slots must not resolve.
1227        for entry in entries() {
1228            let mismatched: Vec<PathPart<'_>> = entry
1229                .path
1230                .iter()
1231                .map(|part| match part {
1232                    PathPart::Part(_) | PathPart::Team => PathPart::Hero,
1233                    PathPart::Hero => PathPart::Team,
1234                })
1235                .collect();
1236            if mismatched == *entry.path {
1237                continue;
1238            }
1239            assert_eq!(
1240                scan_lookup(&mismatched).map(std::ptr::from_ref),
1241                lookup(&mismatched).map(std::ptr::from_ref),
1242                "path {}",
1243                path_string(&mismatched),
1244            );
1245            checked += 1;
1246        }
1247        assert!(checked > 500, "derived coverage was thin: {checked} paths");
1248    }
1249
1250    /// `key_spellings` keeps the scan's results for every table parent —
1251    /// every proper prefix of every declared path, plus unrelated parents.
1252    #[test]
1253    fn key_spellings_match_scan_for_every_table_parent() {
1254        fn scan_key_spellings(parent: &[PathPart<'_>]) -> Vec<&'static str> {
1255            let mut spellings: Vec<&'static str> = entries()
1256                .filter(|entry| {
1257                    entry.path.len() == parent.len() + 1
1258                        && entry.path[..parent.len()] == *parent
1259                        && !entry.workshop_name.contains("%1$s")
1260                })
1261                .map(|entry| entry.workshop_name)
1262                .collect();
1263            let [PathPart::Part("gamemodes"), PathPart::Part(mode)] = parent else {
1264                return spellings;
1265            };
1266            if *mode == "general" {
1267                return spellings;
1268            }
1269            if *mode == "elimination" {
1270                spellings.extend(entries().filter_map(|entry| {
1271                    let [
1272                        PathPart::Part("gamemodes"),
1273                        PathPart::Part("general"),
1274                        PathPart::Part(key),
1275                    ] = entry.path
1276                    else {
1277                        return None;
1278                    };
1279                    ELIMINATION_GENERAL_KEYS
1280                        .contains(key)
1281                        .then_some(entry.workshop_name)
1282                }));
1283            } else if entries().any(|entry| {
1284                matches!(
1285                    entry.path,
1286                    [PathPart::Part("gamemodes"), PathPart::Part(declared), ..]
1287                        if *declared == *mode
1288                )
1289            }) {
1290                spellings.extend(entries().filter_map(|entry| {
1291                    let [
1292                        PathPart::Part("gamemodes"),
1293                        PathPart::Part("general"),
1294                        PathPart::Part(_),
1295                    ] = entry.path
1296                    else {
1297                        return None;
1298                    };
1299                    (!entry.workshop_name.contains("%1$s")).then_some(entry.workshop_name)
1300                }));
1301            }
1302            spellings
1303        }
1304
1305        let mut parents: Vec<Vec<PathPart<'_>>> = Vec::new();
1306        for entry in entries() {
1307            for len in 0..entry.path.len() {
1308                let parent = entry.path[..len].to_vec();
1309                if !parents.contains(&parent) {
1310                    parents.push(parent);
1311                }
1312            }
1313        }
1314        parents.push(vec![PathPart::Part("unknownNamespace")]);
1315        parents.push(vec![
1316            PathPart::Part("gamemodes"),
1317            PathPart::Part("undeclaredMode"),
1318        ]);
1319        for parent in &parents {
1320            assert_eq!(
1321                scan_key_spellings(parent),
1322                key_spellings(parent),
1323                "parent {}",
1324                path_string(parent),
1325            );
1326        }
1327    }
1328}