1use serde::Deserialize;
12use serde_json::Value;
13use std::sync::OnceLock;
14
15use super::PathPart::{self, Hero, Part, Team};
16
17const 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
29pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct LocalizedSettingName {
50 pub section: &'static str,
53 pub english: &'static str,
55 pub locale: &'static str,
57 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
72pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub(crate) enum KeyKind {
107 Flag,
109 String,
111 Bool,
113 YesNo,
115 BoolEnum(&'static str),
118 Number,
120 Percent,
122 Enum(&'static str),
125 ListMap,
127 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
148pub(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 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
483pub(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
560pub(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
585pub(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
624pub(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 entry_bucket(path).find(|entry| entry.path == path)
640}
641
642fn 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
669fn 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
685pub(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
719static 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 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
766pub(crate) fn entries() -> impl Iterator<Item = &'static TableEntry> {
771 deduplicated_entries(ENTRIES.iter().chain(GENERATED_ENTRIES.iter()))
772}
773
774pub(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#[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 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
879pub(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
901pub(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
938pub(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
946pub(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
958const 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
980pub(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
1016pub(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 !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 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 #[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 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 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 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 #[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}