diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateConfig.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateConfig.cs
new file mode 100644
index 000000000..428598e4d
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateConfig.cs
@@ -0,0 +1,281 @@
+using System;
+using System.Text;
+
+namespace PKHeX.Core;
+
+///
+/// Grammar and prefix/suffix tokens for localization.
+///
+public sealed record BattleTemplateConfig
+{
+ public sealed record BattleTemplateTuple(BattleTemplateToken Token, string Text);
+
+ /// Prefix tokens - e.g. Friendship: {100}
+ public required BattleTemplateTuple[] Left { get; init; }
+
+ /// Suffix tokens - e.g. {Timid} Nature
+ public required BattleTemplateTuple[] Right { get; init; }
+
+ /// Tokens that always display the same text, with no value - e.g. Shiny: Yes
+ public required BattleTemplateTuple[] Center { get; init; }
+
+ ///
+ /// Stat names, ordered with speed in the middle (not last).
+ ///
+ public required StatDisplayConfig StatNames { get; init; }
+
+ ///
+ /// Stat names, ordered with speed in the middle (not last).
+ ///
+ public required StatDisplayConfig StatNamesFull { get; init; }
+
+ public required string Male { get; init; }
+ public required string Female { get; init; }
+
+ ///
+ /// Gets the stat names in the requested format.
+ ///
+ ///
+ public StatDisplayConfig GetStatDisplay(StatDisplayStyle style = StatDisplayStyle.Abbreviated) => style switch
+ {
+ StatDisplayStyle.Abbreviated => StatNames,
+ StatDisplayStyle.Full => StatNamesFull,
+ StatDisplayStyle.HABCDS => StatDisplayConfig.HABCDS,
+ StatDisplayStyle.Raw => StatDisplayConfig.Raw,
+ StatDisplayStyle.Raw00 => StatDisplayConfig.Raw00,
+ _ => throw new ArgumentOutOfRangeException(nameof(style), style, null),
+ };
+
+ public static ReadOnlySpan GetMoveDisplay(MoveDisplayStyle style = MoveDisplayStyle.Fill) => style switch
+ {
+ MoveDisplayStyle.Fill => "----",
+ MoveDisplayStyle.Directional => "↑←↓→",
+ _ => throw new ArgumentOutOfRangeException(nameof(style), style, null),
+ };
+
+ public static bool IsMovePrefix(char c) => c is '-' or '–' or '↑' or '←' or '↓' or '→';
+
+ public static ReadOnlySpan CommunityStandard =>
+ [
+ BattleTemplateToken.FirstLine,
+ BattleTemplateToken.Ability,
+ BattleTemplateToken.Level,
+ BattleTemplateToken.Shiny,
+ BattleTemplateToken.Friendship,
+ BattleTemplateToken.DynamaxLevel,
+ BattleTemplateToken.Gigantamax,
+ BattleTemplateToken.TeraType,
+ BattleTemplateToken.EVs,
+ BattleTemplateToken.Nature,
+ BattleTemplateToken.IVs,
+ BattleTemplateToken.Moves,
+ ];
+
+ public static ReadOnlySpan Showdown => CommunityStandard;
+
+ public static ReadOnlySpan ShowdownNew =>
+ [
+ BattleTemplateToken.FirstLine,
+ BattleTemplateToken.AbilityHeldItem,
+ BattleTemplateToken.Moves,
+ BattleTemplateToken.EVsAppendNature,
+ BattleTemplateToken.IVs,
+ BattleTemplateToken.Level,
+ BattleTemplateToken.Shiny,
+ BattleTemplateToken.Friendship,
+ BattleTemplateToken.DynamaxLevel,
+ BattleTemplateToken.Gigantamax,
+ BattleTemplateToken.TeraType,
+ ];
+
+ public static ReadOnlySpan DefaultHover =>
+ [
+ // First line is handled manually.
+ BattleTemplateToken.HeldItem,
+ BattleTemplateToken.Ability,
+ BattleTemplateToken.Level,
+ BattleTemplateToken.Shiny,
+ BattleTemplateToken.DynamaxLevel,
+ BattleTemplateToken.Gigantamax,
+ BattleTemplateToken.TeraType,
+ BattleTemplateToken.EVs,
+ BattleTemplateToken.IVs,
+ BattleTemplateToken.Nature,
+ BattleTemplateToken.Moves,
+
+ // Other tokens are handled manually (Ganbaru, Awakening) as they are not stored by the battle template interface, only entity objects.
+ ];
+
+ ///
+ /// Tries to parse the line for a token and value, if applicable.
+ ///
+ /// Line to parse
+ /// Value for the token, if applicable
+ /// Token type that was found
+ public BattleTemplateToken TryParse(ReadOnlySpan line, out ReadOnlySpan value)
+ {
+ value = default;
+ if (line.Length == 0)
+ return BattleTemplateToken.None;
+ foreach (var tuple in Left)
+ {
+ if (!line.StartsWith(tuple.Text, StringComparison.OrdinalIgnoreCase))
+ continue;
+ value = line[tuple.Text.Length..];
+ return tuple.Token;
+ }
+ foreach (var tuple in Right)
+ {
+ if (!line.EndsWith(tuple.Text, StringComparison.OrdinalIgnoreCase))
+ continue;
+ value = line[..^tuple.Text.Length];
+ return tuple.Token;
+ }
+ foreach (var tuple in Center)
+ {
+ if (!line.Equals(tuple.Text, StringComparison.OrdinalIgnoreCase))
+ continue;
+ return tuple.Token;
+ }
+ return BattleTemplateToken.None;
+ }
+
+ private string GetToken(BattleTemplateToken token, out bool isLeft)
+ {
+ foreach (var tuple in Left)
+ {
+ if (tuple.Token != token)
+ continue;
+ isLeft = true;
+ return tuple.Text;
+ }
+ foreach (var tuple in Right)
+ {
+ if (tuple.Token != token)
+ continue;
+ isLeft = false;
+ return tuple.Text;
+ }
+ foreach (var tuple in Center)
+ {
+ if (tuple.Token != token)
+ continue;
+ isLeft = false;
+ return tuple.Text;
+ }
+ throw new ArgumentException($"Token {token} not found in config");
+ }
+
+ ///
+ /// Gets the string representation of the token. No value is combined with it.
+ ///
+ public string Push(BattleTemplateToken token) => GetToken(token, out _);
+
+ ///
+ /// Gets the string representation of the token, and combines the value with it.
+ ///
+ public string Push(BattleTemplateToken token, T value)
+ {
+ var str = GetToken(token, out var isLeft);
+ if (isLeft)
+ return $"{str}{value}";
+ return $"{value}{str}";
+ }
+
+ ///
+ public void Push(BattleTemplateToken token, T value, StringBuilder sb)
+ {
+ var str = GetToken(token, out var isLeft);
+ if (isLeft)
+ sb.Append(str).Append(value);
+ else
+ sb.Append(value).Append(str);
+ }
+
+ ///
+ /// Checks all representations of the stat name for a match.
+ ///
+ /// Stat name
+ /// -1 if not found, otherwise the index of the stat
+ public int GetStatIndex(ReadOnlySpan stat)
+ {
+ var index = StatNames.GetStatIndex(stat);
+ if (index != -1)
+ return index;
+ index = StatNamesFull.GetStatIndex(stat);
+ if (index != -1)
+ return index;
+
+ foreach (var set in StatDisplayConfig.Custom)
+ {
+ index = set.GetStatIndex(stat);
+ if (index != -1)
+ return index;
+ }
+ return -1;
+ }
+
+ public StatParseResult TryParseStats(ReadOnlySpan message, Span bestResult)
+ {
+ var result = ParseInternal(message, bestResult);
+ ReorderSpeedNotLast(bestResult);
+ result.TreatAmpsAsSpeedNotLast();
+ return result;
+ }
+
+ private StatParseResult ParseInternal(ReadOnlySpan message, Span bestResult)
+ {
+ Span original = stackalloc int[bestResult.Length];
+ bestResult.CopyTo(original);
+
+ var result = StatNames.TryParse(message, bestResult);
+ if (result.IsParseClean)
+ return result;
+
+ // Check if the others get a better result
+ int bestCount = result.CountParsed;
+ Span tmp = stackalloc int[bestResult.Length];
+ // Check Long Stat names
+ {
+ original.CopyTo(tmp); // restore original defaults
+ var other = StatNamesFull.TryParse(message, tmp);
+ if (other.IsParseClean)
+ {
+ tmp.CopyTo(bestResult);
+ return other;
+ }
+ if (other.CountParsed > bestCount)
+ {
+ bestCount = other.CountParsed;
+ tmp.CopyTo(bestResult);
+ }
+ }
+ // Check custom parsers
+ foreach (var set in StatDisplayConfig.Custom)
+ {
+ original.CopyTo(tmp); // restore original defaults
+ var other = set.TryParse(message, tmp);
+ if (other.IsParseClean)
+ {
+ tmp.CopyTo(bestResult);
+ return other;
+ }
+ if (other.CountParsed > bestCount)
+ {
+ bestCount = other.CountParsed;
+ tmp.CopyTo(bestResult);
+ }
+ }
+
+ return result;
+ }
+
+ private static void ReorderSpeedNotLast(Span arr)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(arr.Length, 6);
+ var speed = arr[5];
+ arr[5] = arr[4];
+ arr[4] = arr[3];
+ arr[3] = speed;
+ }
+}
diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateDisplayStyle.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateDisplayStyle.cs
new file mode 100644
index 000000000..e1691898b
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateDisplayStyle.cs
@@ -0,0 +1,12 @@
+namespace PKHeX.Core;
+
+///
+/// Token order for displaying the battle template.
+///
+public enum BattleTemplateDisplayStyle : sbyte
+{
+ Custom = -1,
+ Showdown = 0, // default
+ Legacy,
+ Brief, // default preview hover style
+}
diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateExportSettings.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateExportSettings.cs
new file mode 100644
index 000000000..e83b2d259
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateExportSettings.cs
@@ -0,0 +1,96 @@
+using System;
+
+namespace PKHeX.Core;
+
+///
+/// Settings for exporting a battle template.
+///
+public readonly ref struct BattleTemplateExportSettings
+{
+ ///
+ /// Order of the tokens in the export.
+ ///
+ public ReadOnlySpan Order { get; init; }
+
+ ///
+ /// Localization for the battle template.
+ ///
+ public BattleTemplateLocalization Localization { get; }
+
+ ///
+ /// Display style for the EVs.
+ ///
+ public StatDisplayStyle StatsEVs { get; init; }
+
+ ///
+ /// Display style for the IVs.
+ ///
+ public StatDisplayStyle StatsIVs { get; init; }
+
+ public StatDisplayStyle StatsOther { get; init; }
+
+ ///
+ /// Display style for the moves.
+ ///
+ public MoveDisplayStyle Moves { get; init; }
+
+ public static BattleTemplateExportSettings Showdown => new(BattleTemplateConfig.Showdown);
+ public static BattleTemplateExportSettings CommunityStandard => new(BattleTemplateConfig.CommunityStandard);
+
+ public BattleTemplateExportSettings(string language) : this(BattleTemplateConfig.Showdown, language) { }
+ public BattleTemplateExportSettings(LanguageID language) : this(BattleTemplateConfig.Showdown, language) { }
+
+ public BattleTemplateExportSettings(ReadOnlySpan order, string language = BattleTemplateLocalization.DefaultLanguage)
+ {
+ Localization = BattleTemplateLocalization.GetLocalization(language);
+ Order = order;
+ }
+
+ public BattleTemplateExportSettings(ReadOnlySpan order, LanguageID language)
+ {
+ Localization = BattleTemplateLocalization.GetLocalization(language);
+ Order = order;
+ }
+
+ ///
+ /// Checks if the token is in the export.
+ ///
+ public bool IsTokenInExport(BattleTemplateToken token)
+ {
+ foreach (var t in Order)
+ {
+ if (t == token)
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Gets the index of the token in the export.
+ ///
+ public int GetTokenIndex(BattleTemplateToken token)
+ {
+ for (int i = 0; i < Order.Length; i++)
+ {
+ if (Order[i] == token)
+ return i;
+ }
+ return -1;
+ }
+
+ ///
+ /// Checks if the token is in the export.
+ ///
+ /// Should be a static method, but is not because it feels better this way.
+ /// Token to check
+ /// Tokens to check against
+ public bool IsTokenInExport(BattleTemplateToken token, ReadOnlySpan tokens)
+ {
+ foreach (var t in tokens)
+ {
+ if (t == token)
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateLocalization.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateLocalization.cs
new file mode 100644
index 000000000..42d581fcd
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateLocalization.cs
@@ -0,0 +1,78 @@
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PKHeX.Core;
+
+///
+/// Provides information for localizing sets.
+///
+/// In-game strings
+/// Grammar and prefix/suffix tokens
+public sealed record BattleTemplateLocalization(GameStrings Strings, BattleTemplateConfig Config)
+{
+ public const string DefaultLanguage = GameLanguage.DefaultLanguage; // English
+
+ private static readonly Dictionary Cache = new();
+ public static readonly BattleTemplateLocalization Default = GetLocalization(DefaultLanguage);
+
+ /// index
+ ///
+ public static BattleTemplateLocalization GetLocalization(LanguageID language) =>
+ GetLocalization(language.GetLanguageCode());
+
+ ///
+ /// Gets the localization for the requested language.
+ ///
+ /// Language code
+ public static BattleTemplateLocalization GetLocalization(string language)
+ {
+ if (Cache.TryGetValue(language, out var result))
+ return result;
+
+ var strings = GameInfo.GetStrings(language);
+ var cfg = GetConfig(language);
+ result = new BattleTemplateLocalization(strings, cfg);
+ Cache[language] = result;
+ return result;
+ }
+
+ private static string GetJson(string language) => Util.GetStringResource($"battle_{language}.json");
+ private static BattleTemplateConfigContext GetContext() => new();
+
+ private static BattleTemplateConfig GetConfig(string language)
+ {
+ var text = GetJson(language);
+ var result = JsonSerializer.Deserialize(text, GetContext().BattleTemplateConfig)
+ ?? throw new JsonException($"Failed to deserialize {nameof(BattleTemplateConfig)} for {language}");
+ return result;
+ }
+
+ ///
+ /// Force loads all localizations.
+ ///
+ public static bool ForceLoadAll()
+ {
+ bool anyLoaded = false;
+ foreach (var lang in GameLanguage.AllSupportedLanguages)
+ {
+ if (Cache.ContainsKey(lang))
+ continue;
+ _ = GetLocalization(lang);
+ anyLoaded = true;
+ }
+ return anyLoaded;
+ }
+
+ ///
+ /// Gets all localizations.
+ ///
+ public static IReadOnlyDictionary GetAll()
+ {
+ _ = ForceLoadAll();
+ return Cache;
+ }
+}
+
+[JsonSerializable(typeof(BattleTemplateConfig))]
+public sealed partial class BattleTemplateConfigContext : JsonSerializerContext;
diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateToken.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateToken.cs
new file mode 100644
index 000000000..e3e8d4fc6
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateToken.cs
@@ -0,0 +1,49 @@
+using System.Text.Json.Serialization;
+
+namespace PKHeX.Core;
+
+///
+/// Enum for the different tokens used in battle templates.
+///
+///
+/// Each token represents a specific aspect of a Pokémon's battle template.
+/// One token per line. Each token can have specific grammar rules depending on the language.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum BattleTemplateToken : byte
+{
+ None = 0, // invalid, used as a magic value to signal that a token is not recognized
+
+ // Standard tokens
+ Shiny,
+ Ability,
+ Nature,
+ Friendship,
+ EVs,
+ IVs,
+ Level,
+ DynamaxLevel,
+ Gigantamax,
+ TeraType,
+
+ // Tokens that can appear multiple times
+ Moves,
+
+ // When present, first line will not contain values for these tokens (instead outputting on separate token line)
+ // Not part of the standard export format, but can be recognized/optionally used in the program
+ HeldItem,
+ Nickname,
+ Gender,
+
+ // Manually appended, not stored or recognized on import
+ AVs,
+ GVs,
+
+ // Future Showdown propositions
+ AbilityHeldItem, // [Ability] Item
+ EVsWithNature, // +/-
+ EVsAppendNature, // +/- and .. (Nature)
+
+ // Omitting the first line (species) shouldn't be done unless it is manually added in the presentation/export.
+ FirstLine = byte.MaxValue,
+}
diff --git a/PKHeX.Core/Editing/BattleTemplate/BattleTemplateTypeSetting.cs b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateTypeSetting.cs
new file mode 100644
index 000000000..b884293fe
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/BattleTemplateTypeSetting.cs
@@ -0,0 +1,77 @@
+using System;
+using System.ComponentModel;
+
+namespace PKHeX.Core;
+
+public sealed class BattleTemplateSettings
+{
+ [LocalizedDescription("Settings for showing details when hovering a slot.")]
+ public BattleTemplateTypeSetting Hover { get; set; } = new(BattleTemplateDisplayStyle.Brief, LanguageID.None, MoveDisplayStyle.Directional);
+
+ [LocalizedDescription("Settings for showing details when exporting a slot.")]
+ public BattleTemplateTypeSetting Export { get; set; } = new(BattleTemplateDisplayStyle.Showdown, LanguageID.English);
+}
+
+[TypeConverter(typeof(ExpandableObjectConverter))]
+public sealed class BattleTemplateTypeSetting
+{
+
+ [LocalizedDescription("Language to use when exporting a battle template. If not specified in settings, will use current language.")]
+ public LanguageID Language { get; set; }
+ public StatDisplayStyle StyleStatEVs { get; set; }
+ public StatDisplayStyle StyleStatIVs { get; set; }
+ public StatDisplayStyle StyleStatOther { get; set; }
+ public MoveDisplayStyle StyleMove { get; set; }
+
+ [LocalizedDescription("Custom stat labels and grammar.")]
+ public StatDisplayConfig StatsCustom { get; set; } = StatDisplayConfig.HABCDS;
+
+ [LocalizedDescription("Display format to use when exporting a battle template from the program.")]
+ public BattleTemplateDisplayStyle TokenOrder { get; set; }
+
+ [LocalizedDescription("Custom ordering for exporting a set, if chosen via export display style.")]
+ public BattleTemplateToken[] TokenOrderCustom { get; set; } = BattleTemplateConfig.Showdown.ToArray();
+
+ public BattleTemplateTypeSetting() { }
+ public BattleTemplateTypeSetting(BattleTemplateDisplayStyle style, LanguageID lang, MoveDisplayStyle move = MoveDisplayStyle.Fill)
+ {
+ TokenOrder = style;
+ Language = lang;
+ StyleMove = move;
+ }
+
+ public override string ToString() => $"{TokenOrder} {Language}";
+
+ private LanguageID GetLanguageExport(LanguageID program) => GetLanguage(Language, program);
+
+ public BattleTemplateExportSettings GetSettings(LanguageID programLanguage, EntityContext context) => new(GetOrder(TokenOrder, TokenOrderCustom), GetLanguageExport(programLanguage))
+ {
+ StatsEVs = StyleStatEVs,
+ StatsIVs = StyleStatIVs,
+ StatsOther = StyleStatOther,
+ Moves = GetMoveDisplayStyle(StyleMove, context),
+ };
+
+ private static LanguageID GetLanguage(LanguageID choice, LanguageID program)
+ {
+ if (choice != LanguageID.None)
+ return choice;
+ if (program == LanguageID.None)
+ return LanguageID.English;
+ return program;
+ }
+
+ private static ReadOnlySpan GetOrder(BattleTemplateDisplayStyle style, ReadOnlySpan custom) => style switch
+ {
+ BattleTemplateDisplayStyle.Legacy => BattleTemplateConfig.CommunityStandard,
+ BattleTemplateDisplayStyle.Brief => BattleTemplateConfig.DefaultHover,
+ BattleTemplateDisplayStyle.Custom => custom,
+ _ => BattleTemplateConfig.Showdown,
+ };
+
+ private static MoveDisplayStyle GetMoveDisplayStyle(MoveDisplayStyle style, EntityContext context) => style switch
+ {
+ //MoveDisplayStyle.Directional when context is EntityContext.Gen9a => MoveDisplayStyle.Directional, TODO ZA
+ _ => MoveDisplayStyle.Fill,
+ };
+}
diff --git a/PKHeX.Core/Editing/IBattleTemplate.cs b/PKHeX.Core/Editing/BattleTemplate/IBattleTemplate.cs
similarity index 95%
rename from PKHeX.Core/Editing/IBattleTemplate.cs
rename to PKHeX.Core/Editing/BattleTemplate/IBattleTemplate.cs
index c91c5a80e..35f8d6723 100644
--- a/PKHeX.Core/Editing/IBattleTemplate.cs
+++ b/PKHeX.Core/Editing/BattleTemplate/IBattleTemplate.cs
@@ -23,6 +23,7 @@ public interface IBattleTemplate : ISpeciesForm, IGigantamaxReadOnly, IDynamaxLe
///
/// of the Set entity.
///
+ /// Depends on for context-specific item lists.
int HeldItem { get; }
///
diff --git a/PKHeX.Core/Editing/BattleTemplate/MoveDisplayStyle.cs b/PKHeX.Core/Editing/BattleTemplate/MoveDisplayStyle.cs
new file mode 100644
index 000000000..7e449098f
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/MoveDisplayStyle.cs
@@ -0,0 +1,17 @@
+namespace PKHeX.Core;
+
+///
+/// Style to display moves.
+///
+public enum MoveDisplayStyle : byte
+{
+ ///
+ /// Moves are slots 1-4, with no empty slots, and correspond to the rectangular grid without empty spaces.
+ ///
+ Fill,
+
+ ///
+ /// Move slots are assigned to the directional pad, and unused directional slots are not displayed.
+ ///
+ Directional,
+}
diff --git a/PKHeX.Core/Editing/BattleTemplate/Showdown/BattleTemplateTeams.cs b/PKHeX.Core/Editing/BattleTemplate/Showdown/BattleTemplateTeams.cs
new file mode 100644
index 000000000..8f1c6aae7
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/Showdown/BattleTemplateTeams.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+
+namespace PKHeX.Core;
+
+///
+/// Logic for retrieving teams from URLs.
+///
+public static class BattleTemplateTeams
+{
+ ///
+ /// Tries to check if the input text is a valid URL for a team, and if so, retrieves the team data.
+ ///
+ /// The input text to check.
+ /// When the method returns, contains the retrieved team data if the text is a valid URL; otherwise, null.
+ /// true if the text is a valid URL and the team data was successfully retrieved; otherwise, false.
+ public static bool TryGetSetLines(string text, [NotNullWhen(true)] out string? content)
+ {
+ if (ShowdownTeam.IsURL(text, out var url))
+ return ShowdownTeam.TryGetSets(url, out content);
+ if (PokepasteTeam.IsURL(text, out url))
+ return PokepasteTeam.TryGetSets(url, out content);
+ content = text;
+ return false;
+ }
+
+ ///
+ /// Attempts to retrieve sets from the provided text. If the text is a valid URL, it retrieves the team data from the URL.
+ ///
+ /// The input text to check.
+ /// An enumerable collection of objects representing the sets.
+ public static IEnumerable TryGetSets(string text)
+ {
+ var ingest = TryGetSetLines(text, out var many) ? many : text;
+ return ShowdownParsing.GetShowdownSets(ingest);
+ }
+}
diff --git a/PKHeX.Core/Editing/Showdown/PokepasteTeam.cs b/PKHeX.Core/Editing/BattleTemplate/Showdown/PokepasteTeam.cs
similarity index 80%
rename from PKHeX.Core/Editing/Showdown/PokepasteTeam.cs
rename to PKHeX.Core/Editing/BattleTemplate/Showdown/PokepasteTeam.cs
index 9c8d53ceb..474a827a6 100644
--- a/PKHeX.Core/Editing/Showdown/PokepasteTeam.cs
+++ b/PKHeX.Core/Editing/BattleTemplate/Showdown/PokepasteTeam.cs
@@ -4,6 +4,12 @@
namespace PKHeX.Core;
+///
+/// Logic for retrieving Showdown teams from URLs.
+///
+///
+///
+///
public static class PokepasteTeam
{
///
@@ -12,8 +18,17 @@ public static class PokepasteTeam
/// The numeric identifier of the team.
/// A string containing the full URL to access the team data.
public static string GetURL(ulong team) => $"https://pokepast.es/{team:x16}/raw";
+
+ ///
+ /// For legacy team indexes (first 255 or so), shouldn't ever be triggered non-test team indexes.
public static string GetURLOld(int team) => $"https://pokepast.es/{team}/raw";
+ ///
+ /// Attempts to retrieve the Showdown team data from a specified URL, and reformats it.
+ ///
+ /// The URL to retrieve the team data from.
+ /// When the method returns, contains the processed team data if retrieval and formatting succeed; otherwise, null.
+ /// true if the team data is successfully retrieved and reformatted; otherwise, false.
public static bool TryGetSets(string url, [NotNullWhen(true)] out string? content)
{
content = null;
@@ -29,7 +44,6 @@ public static bool TryGetSets(string url, [NotNullWhen(true)] out string? conten
///
/// The text to evaluate.
/// When the method returns, contains the normalized API URL if the text represents a valid Showdown team URL; otherwise, null.
- ///
/// true if the text is a valid Showdown team URL; otherwise, false.
public static bool IsURL(ReadOnlySpan text, [NotNullWhen(true)] out string? url)
{
diff --git a/PKHeX.Core/Editing/Showdown/ShowdownParsing.cs b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownParsing.cs
similarity index 55%
rename from PKHeX.Core/Editing/Showdown/ShowdownParsing.cs
rename to PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownParsing.cs
index c54ebebf2..2b654930d 100644
--- a/PKHeX.Core/Editing/Showdown/ShowdownParsing.cs
+++ b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownParsing.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using static PKHeX.Core.Species;
namespace PKHeX.Core;
@@ -11,6 +12,9 @@ public static class ShowdownParsing
{
private static readonly string[] genderForms = ["", "F", ""];
+ ///
+ private const int DefaultListAllocation = ShowdownSet.DefaultListAllocation;
+
///
/// Gets the Form ID from the input .
///
@@ -147,12 +151,13 @@ public static string SetShowdownFormName(ushort species, string form, int abilit
/// Fetches data from the input .
///
/// Raw lines containing numerous multi-line set data.
+ /// Localization data for the set.
/// objects until is consumed.
- public static IEnumerable GetShowdownSets(IEnumerable lines)
+ public static IEnumerable GetShowdownSets(IEnumerable lines, BattleTemplateLocalization localization)
{
// exported sets always have >4 moves; new List will always require 1 resizing, allocate 2x to save 1 reallocation.
// intro, nature, ability, (ivs, evs, shiny, level) 4*moves
- var setLines = new List(8);
+ var setLines = new List(DefaultListAllocation);
foreach (var line in lines)
{
if (!string.IsNullOrWhiteSpace(line))
@@ -162,14 +167,54 @@ public static IEnumerable GetShowdownSets(IEnumerable lines
}
if (setLines.Count == 0)
continue;
- yield return new ShowdownSet(setLines);
+ yield return new ShowdownSet(setLines, localization);
setLines.Clear();
}
if (setLines.Count != 0)
- yield return new ShowdownSet(setLines);
+ yield return new ShowdownSet(setLines, localization);
}
- ///
+ ///
+ public static IEnumerable GetShowdownSets(IEnumerable lines)
+ {
+ var setLines = new List(DefaultListAllocation);
+ foreach (var line in lines)
+ {
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ setLines.Add(line);
+ continue;
+ }
+ if (setLines.Count == 0)
+ continue;
+ yield return TryParseAnyLanguage(setLines, out var set) ? set : new ShowdownSet(setLines);
+ setLines.Clear();
+ }
+ if (setLines.Count != 0)
+ yield return TryParseAnyLanguage(setLines, out var set) ? set : new ShowdownSet(setLines);
+ }
+
+ ///
+ public static IEnumerable GetShowdownSets(ReadOnlyMemory text, BattleTemplateLocalization localization)
+ {
+ int start = 0;
+ do
+ {
+ var span = text.Span;
+ var slice = span[start..];
+ var set = GetShowdownSet(slice, localization, out int length);
+ if (set.Species == 0)
+ break;
+ yield return set;
+ start += length;
+ }
+ while (start < text.Length);
+ }
+
+ ///
+ ///
+ /// Language-unknown version of .
+ ///
public static IEnumerable GetShowdownSets(ReadOnlyMemory text)
{
int start = 0;
@@ -186,17 +231,15 @@ public static IEnumerable GetShowdownSets(ReadOnlyMemory text
while (start < text.Length);
}
- ///
- public static IEnumerable GetShowdownSets(string text) => GetShowdownSets(text.AsMemory());
+ ///
+ public static IEnumerable GetShowdownSets(string text, BattleTemplateLocalization localization) => GetShowdownSets(text.AsMemory(), localization);
private static int GetLength(ReadOnlySpan text)
{
// Find the end of the Showdown Set lines.
// The end is implied when:
- // - we see a complete whitespace or empty line, or
- // - we witness four 'move' definition lines.
+ // - we see a complete whitespace or empty line
int length = 0;
- int moveCount = 4;
while (true)
{
@@ -208,47 +251,75 @@ private static int GetLength(ReadOnlySpan text)
var used = newline + 1;
length += used;
- if (slice.IsEmpty || slice.IsWhiteSpace())
- return length;
- if (slice.TrimStart()[0] is '-' or '–' && --moveCount == 0)
+ if (slice.IsWhiteSpace())
return length;
text = text[used..];
}
}
- public static ShowdownSet GetShowdownSet(ReadOnlySpan text, out int length)
+ ///
+ /// Attempts to parse the input into a object.
+ ///
+ /// Input string to parse.
+ /// Input localization to use.
+ /// Amount of characters consumed from the input string.
+ /// Parsed object if successful, otherwise might be a best-match with some/all unparsed lines.
+ public static ShowdownSet GetShowdownSet(ReadOnlySpan text, BattleTemplateLocalization localization, out int length)
{
length = GetLength(text);
var slice = text[..length];
- var set = new ShowdownSet(slice);
+ var set = new ShowdownSet(slice, localization);
while (length < text.Length && text[length] is '\r' or '\n' or ' ')
length++;
return set;
}
+ ///
+ public static ShowdownSet GetShowdownSet(ReadOnlySpan text, out int length)
+ {
+ length = GetLength(text);
+ var slice = text[..length];
+ if (!TryParseAnyLanguage(slice, out var set))
+ set = new ShowdownSet(slice); // should never fall back
+ while (length < text.Length && text[length] is '\r' or '\n' or ' ')
+ length++;
+ return set;
+ }
+
+ ///
+ public static IEnumerable GetShowdownSets(string text) => GetShowdownSets(text.AsMemory());
+
+ ///
+ public static string GetShowdownText(PKM pk) => GetShowdownText(pk, BattleTemplateExportSettings.Showdown);
+
///
/// Converts the data into an importable set format for Pokémon Showdown.
///
/// PKM to convert to string
+ /// Import localization/style setting
/// Multi line set data
- public static string GetShowdownText(PKM pk)
+ public static string GetShowdownText(PKM pk, in BattleTemplateExportSettings settings)
{
if (pk.Species == 0)
return string.Empty;
- return new ShowdownSet(pk).Text;
+ var set = new ShowdownSet(pk);
+ set.InterpretAsPreview(pk);
+ return set.GetText(settings);
}
///
/// Fetches ShowdownSet lines from the input data.
///
/// Pokémon data to summarize.
- /// Localization setting
+ /// Export localization/style setting
/// Consumable list of lines.
- public static IEnumerable GetShowdownText(IEnumerable data, string lang = ShowdownSet.DefaultLanguage)
+ public static IEnumerable GetShowdownText(IEnumerable data, in BattleTemplateExportSettings settings)
{
+ List result = new();
var sets = GetShowdownSets(data);
foreach (var set in sets)
- yield return set.LocalizedText(lang);
+ result.Add(set.GetText(settings));
+ return result;
}
///
@@ -266,24 +337,123 @@ public static IEnumerable GetShowdownSets(IEnumerable data)
}
}
+ ///
+ public static string GetShowdownSets(IEnumerable data, string separator) => string.Join(separator, GetShowdownText(data, BattleTemplateExportSettings.Showdown));
+
///
/// Fetches ShowdownSet lines from the input data, and combines it into one string.
///
/// Pokémon data to summarize.
/// Splitter between each set.
+ /// Import localization/style setting
/// Single string containing all lines.
- public static string GetShowdownSets(IEnumerable data, string separator) => string.Join(separator, GetShowdownText(data));
+ public static string GetShowdownSets(IEnumerable data, string separator, in BattleTemplateExportSettings settings) => string.Join(separator, GetShowdownText(data, settings));
///
/// Gets a localized string preview of the provided .
///
/// Pokémon data
- /// Language code
+ /// Export settings
/// Multi-line string
- public static string GetLocalizedPreviewText(PKM pk, string language)
+ public static string GetLocalizedPreviewText(PKM pk, in BattleTemplateExportSettings settings)
{
var set = new ShowdownSet(pk);
set.InterpretAsPreview(pk);
- return set.LocalizedText(language);
+ return set.GetText(settings);
+ }
+
+ ///
+ /// Tries to parse the input string into a object.
+ ///
+ /// Input string to parse.
+ /// Parsed object if successful, otherwise might be a best-match with some unparsed lines.
+ /// True if the input was parsed successfully, false otherwise.
+ public static bool TryParseAnyLanguage(ReadOnlySpan message, [NotNullWhen(true)] out ShowdownSet? set)
+ {
+ set = null;
+ if (message.Length == 0)
+ return false;
+
+ var invalid = int.MaxValue;
+ var all = BattleTemplateLocalization.GetAll();
+ foreach (var lang in all)
+ {
+ var local = lang.Value;
+ var tmp = new ShowdownSet(message, local);
+ var bad = tmp.InvalidLines.Count;
+ if (bad == 0)
+ {
+ set = tmp;
+ return true;
+ }
+
+ // Check for invalid lines
+ if (bad >= invalid)
+ continue;
+
+ // Best so far.
+ invalid = bad;
+ set = tmp;
+ }
+ if (set is null)
+ return false;
+ return set.Species != 0;
+ }
+
+ ///
+ public static bool TryParseAnyLanguage(IReadOnlyList setLines, [NotNullWhen(true)] out ShowdownSet? set)
+ {
+ set = null;
+ if (setLines.Count == 0)
+ return false;
+
+ var invalid = int.MaxValue;
+ var all = BattleTemplateLocalization.GetAll();
+ foreach (var lang in all)
+ {
+ var local = lang.Value;
+ var tmp = new ShowdownSet(setLines, local);
+ var bad = tmp.InvalidLines.Count;
+ if (bad == 0)
+ {
+ set = tmp;
+ return true;
+ }
+
+ // Check for invalid lines
+ if (bad >= invalid)
+ continue;
+
+ // Best so far.
+ invalid = bad;
+ set = tmp;
+ }
+ return false;
+ }
+
+ ///
+ /// Tries to translate the input battle template into a localized string.
+ ///
+ /// Input string to parse.
+ /// Export settings
+ /// Translated string if successful.
+ /// true if the input was translated successfully, false otherwise.
+ public static bool TryTranslate(ReadOnlySpan message, BattleTemplateExportSettings outputSettings, [NotNullWhen(true)] out string? translated)
+ {
+ translated = null;
+ if (!TryParseAnyLanguage(message, out var set))
+ return false;
+ translated = set.GetText(outputSettings);
+ return true;
+ }
+
+ ///
+ public static bool TryTranslate(IReadOnlyList message, BattleTemplateExportSettings outputSettings, [NotNullWhen(true)] out string? translated)
+ {
+ translated = null;
+ if (!TryParseAnyLanguage(message, out var set))
+ return false;
+ translated = set.GetText(outputSettings);
+ return true;
}
}
diff --git a/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs
new file mode 100644
index 000000000..ec8f02d79
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownSet.cs
@@ -0,0 +1,1050 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using static PKHeX.Core.Species;
+
+namespace PKHeX.Core;
+
+///
+/// Logic for exporting and importing data in Pokémon Showdown's text format.
+///
+public sealed class ShowdownSet : IBattleTemplate
+{
+ private const char ItemSplit = '@';
+ private const int MAX_SPECIES = (int)MAX_COUNT - 1;
+ private const int MaxMoveCount = 4;
+ private const string DefaultLanguage = BattleTemplateLocalization.DefaultLanguage; // English
+ private static BattleTemplateLocalization DefaultStrings => BattleTemplateLocalization.Default;
+
+ private static ReadOnlySpan DashedSpecies =>
+ [
+ (int)NidoranF, (int)NidoranM,
+ (int)HoOh,
+ (int)Jangmoo, (int)Hakamoo, (int)Kommoo,
+ (int)TingLu, (int)ChienPao, (int)WoChien, (int)ChiYu,
+ ];
+
+ public ushort Species { get; private set; }
+ public EntityContext Context { get; private set; } = RecentTrainerCache.Context;
+ public string Nickname { get; private set; } = string.Empty;
+ public byte? Gender { get; private set; }
+ public int HeldItem { get; private set; }
+ public int Ability { get; private set; } = -1;
+ public byte Level { get; private set; } = 100;
+ public bool Shiny { get; private set; }
+ public byte Friendship { get; private set; } = 255;
+ public Nature Nature { get; private set; } = Nature.Random;
+ public string FormName { get; private set; } = string.Empty;
+ public byte Form { get; private set; }
+ public int[] EVs { get; } = [00, 00, 00, 00, 00, 00];
+ public int[] IVs { get; } = [31, 31, 31, 31, 31, 31];
+ public sbyte HiddenPowerType { get; private set; } = -1;
+ public MoveType TeraType { get; private set; } = MoveType.Any;
+ public ushort[] Moves { get; } = [0, 0, 0, 0];
+ public bool CanGigantamax { get; private set; }
+ public byte DynamaxLevel { get; private set; } = 10;
+
+ ///
+ /// Any lines that failed to be parsed.
+ ///
+ public readonly List InvalidLines = new(0);
+
+ ///
+ /// Loads a new from the input string.
+ ///
+ /// Single-line string which will be split before loading.
+ /// Localization to parse the lines with.
+ public ShowdownSet(ReadOnlySpan input, BattleTemplateLocalization? localization = null) => LoadLines(input.EnumerateLines(), localization ?? DefaultStrings);
+
+ ///
+ /// Loads a new from the input string.
+ ///
+ /// Enumerable list of lines.
+ /// Localization to parse the lines with.
+ public ShowdownSet(IEnumerable lines, BattleTemplateLocalization? localization = null) => LoadLines(lines, localization ?? DefaultStrings);
+
+ private void LoadLines(SpanLineEnumerator lines, BattleTemplateLocalization localization)
+ {
+ ParseLines(lines, localization);
+ SanitizeResult(localization);
+ }
+
+ private void LoadLines(IEnumerable lines, BattleTemplateLocalization localization)
+ {
+ ParseLines(lines, localization);
+ SanitizeResult(localization);
+ }
+
+ private void SanitizeResult(BattleTemplateLocalization localization)
+ {
+ FormName = ShowdownParsing.SetShowdownFormName(Species, FormName, Ability);
+ Form = ShowdownParsing.GetFormFromString(FormName, localization.Strings, Species, Context);
+
+ // Handle edge case with fixed-gender forms.
+ if (Species is (int)Meowstic or (int)Indeedee or (int)Basculegion or (int)Oinkologne)
+ ReviseGenderedForms();
+ }
+
+ private void ReviseGenderedForms()
+ {
+ if (Gender == 1) // Recognized with (F)
+ {
+ FormName = "F";
+ Form = 1;
+ }
+ else
+ {
+ FormName = Form == 1 ? "F" : "M";
+ Gender = Form;
+ }
+ }
+
+ // Skip lines that are too short or too long.
+ // Longest line is ~74 (Gen2 EVs)
+ // Length permitted: 3-80
+ // The shortest Pokémon name in Japanese is "ニ" (Ni) which is the name for the Pokémon, Nidoran♂ (male Nidoran). It has only one letter.
+ // We will handle this 1-2 letter edge case only if the line is the first line of the set, in the rare chance we are importing for a non-English language?
+ private const int MinLength = 3;
+ private const int MaxLength = 80;
+ private static bool IsLengthOutOfRange(ReadOnlySpan trim) => IsLengthOutOfRange(trim.Length);
+ private static bool IsLengthOutOfRange(int length) => (uint)(length - MinLength) > MaxLength - MinLength;
+
+ private void ParseLines(SpanLineEnumerator lines, BattleTemplateLocalization localization)
+ {
+ int countMoves = 0;
+ bool first = true;
+ foreach (var line in lines)
+ {
+ ReadOnlySpan trim = line.Trim();
+ if (IsLengthOutOfRange(trim))
+ {
+ // Try for other languages just in case.
+ if (first && trim.Length != 0)
+ {
+ ParseFirstLine(trim, localization.Strings);
+ first = false;
+ continue;
+ }
+ InvalidLines.Add(line.ToString());
+ continue;
+ }
+
+ if (first)
+ {
+ ParseFirstLine(trim, localization.Strings);
+ first = false;
+ continue;
+ }
+
+ ParseLine(trim, ref countMoves, localization);
+ }
+ }
+
+ private void ParseLines(IEnumerable lines, BattleTemplateLocalization localization)
+ {
+ int countMoves = 0;
+ bool first = true;
+ foreach (var line in lines)
+ {
+ ReadOnlySpan trim = line.Trim();
+ if (IsLengthOutOfRange(trim))
+ {
+ // Try for other languages just in case.
+ if (first && trim.Length != 0)
+ {
+ ParseFirstLine(trim, localization.Strings);
+ first = false;
+ continue;
+ }
+ InvalidLines.Add(line);
+ continue;
+ }
+
+ if (first)
+ {
+ ParseFirstLine(trim, localization.Strings);
+ first = false;
+ continue;
+ }
+
+ ParseLine(trim, ref countMoves, localization);
+ }
+ }
+
+ private void ParseLine(ReadOnlySpan line, ref int movectr, BattleTemplateLocalization localization)
+ {
+ var moves = Moves.AsSpan();
+ var firstChar = line[0];
+ if (firstChar is '-' or '–')
+ {
+ if (movectr >= MaxMoveCount)
+ {
+ InvalidLines.Add($"Too many moves: {line}");
+ return;
+ }
+ var moveString = ParseLineMove(line, localization.Strings);
+ int move = StringUtil.FindIndexIgnoreCase(localization.Strings.movelist, moveString);
+ if (move < 0)
+ InvalidLines.Add($"Unknown Move: {moveString}");
+ else if (moves.Contains((ushort)move))
+ InvalidLines.Add($"Duplicate Move: {moveString}");
+ else
+ moves[movectr++] = (ushort)move;
+ return;
+ }
+
+ var dirMove = BattleTemplateConfig.GetMoveDisplay(MoveDisplayStyle.Directional);
+ var dirMoveIndex = dirMove.IndexOf(firstChar);
+ if (dirMoveIndex != -1)
+ {
+ if (moves[dirMoveIndex] != 0)
+ {
+ InvalidLines.Add($"Move slot already specified: {line}");
+ return;
+ }
+ var moveString = ParseLineMove(line, localization.Strings);
+ int move = StringUtil.FindIndexIgnoreCase(localization.Strings.movelist, moveString);
+ if (move < 0)
+ InvalidLines.Add($"Unknown Move: {moveString}");
+ else if (moves.Contains((ushort)move))
+ InvalidLines.Add($"Duplicate Move: {moveString}");
+ else
+ moves[dirMoveIndex] = (ushort)move;
+ movectr++;
+ return;
+ }
+
+ if (firstChar is '[' or '@') // Ability
+ {
+ ParseLineAbilityBracket(line, localization.Strings);
+ return;
+ }
+
+ var token = localization.Config.TryParse(line, out var value);
+ if (token == BattleTemplateToken.None)
+ {
+ InvalidLines.Add($"Unknown Token: {line}");
+ return;
+ }
+ var valid = ParseEntry(token, value, localization);
+ if (!valid)
+ InvalidLines.Add(line.ToString());
+ }
+
+ private void ParseLineAbilityBracket(ReadOnlySpan line, GameStrings localizationStrings)
+ {
+ // Try to peel off Held Item if it is specified.
+ var itemStart = line.IndexOf(ItemSplit);
+ if (itemStart != -1)
+ {
+ var itemName = line[(itemStart + 1)..].TrimStart();
+ if (!ParseItemName(itemName, localizationStrings))
+ InvalidLines.Add($"Unknown Item: {itemName}");
+ line = line[..itemStart];
+ }
+
+ // Remainder should be [Ability]
+ var abilityEnd = line.IndexOf(']');
+ if (abilityEnd == -1 || line.Length == 1) // '[' should be present if ']' is; length check.
+ {
+ InvalidLines.Add($"Invalid Ability declaration: {line}");
+ return; // invalid line
+ }
+
+ var abilityName = line[1..abilityEnd].Trim();
+ var abilityIndex = StringUtil.FindIndexIgnoreCase(localizationStrings.abilitylist, abilityName);
+ if (abilityIndex < 0)
+ {
+ InvalidLines.Add($"Unknown Ability: {abilityName}");
+ return; // invalid line
+ }
+ Ability = abilityIndex;
+ }
+
+ private bool ParseEntry(BattleTemplateToken token, ReadOnlySpan value, BattleTemplateLocalization localization) => token switch
+ {
+ BattleTemplateToken.Ability => (Ability = StringUtil.FindIndexIgnoreCase(localization.Strings.abilitylist, value)) >= 0,
+ BattleTemplateToken.Nature => (Nature = (Nature)StringUtil.FindIndexIgnoreCase(localization.Strings.natures, value)).IsFixed(),
+ BattleTemplateToken.Shiny => Shiny = true,
+ BattleTemplateToken.Gigantamax => CanGigantamax = true,
+ BattleTemplateToken.HeldItem => ParseItemName(value, localization.Strings),
+ BattleTemplateToken.Nickname => ParseNickname(value),
+ BattleTemplateToken.Gender => ParseGender(value, localization.Config),
+ BattleTemplateToken.Friendship => ParseFriendship(value),
+ BattleTemplateToken.EVs => ParseLineEVs(value, localization),
+ BattleTemplateToken.IVs => ParseLineIVs(value, localization.Config),
+ BattleTemplateToken.Level => ParseLevel(value),
+ BattleTemplateToken.DynamaxLevel => ParseDynamax(value),
+ BattleTemplateToken.TeraType => ParseTeraType(value, localization.Strings.types),
+ _ => false,
+ };
+
+ private bool ParseNickname(ReadOnlySpan value)
+ {
+ if (value.Length == 0)
+ return false;
+ // ignore length, but generally should be <= the Context's max length
+ Nickname = value.ToString();
+ return true;
+ }
+
+ private bool ParseGender(ReadOnlySpan value, BattleTemplateConfig cfg)
+ {
+ if (value.Equals(cfg.Male, StringComparison.OrdinalIgnoreCase))
+ {
+ Gender = EntityGender.Male;
+ return true;
+ }
+ if (value.Equals(cfg.Female, StringComparison.OrdinalIgnoreCase))
+ {
+ Gender = EntityGender.Female;
+ return true;
+ }
+ return false;
+ }
+
+ private bool ParseLevel(ReadOnlySpan value)
+ {
+ if (!byte.TryParse(value.Trim(), out var val))
+ return false;
+ if ((uint)val is 0 or > 100)
+ return false;
+ Level = val;
+ return true;
+ }
+
+ private bool ParseFriendship(ReadOnlySpan value)
+ {
+ if (!byte.TryParse(value.Trim(), out var val))
+ return false;
+ Friendship = val;
+ return true;
+ }
+
+ private bool ParseDynamax(ReadOnlySpan value)
+ {
+ Context = EntityContext.Gen8;
+ var val = Util.ToInt32(value);
+ if ((uint)val > 10)
+ return false;
+ DynamaxLevel = (byte)val;
+ return true;
+ }
+
+ private bool ParseTeraType(ReadOnlySpan value, ReadOnlySpan types)
+ {
+ Context = EntityContext.Gen9;
+ var val = StringUtil.FindIndexIgnoreCase(types, value);
+ if (val < 0)
+ return false;
+ if (val == TeraTypeUtil.StellarTypeDisplayStringIndex)
+ val = TeraTypeUtil.Stellar;
+ TeraType = (MoveType)val;
+ return true;
+ }
+
+ ///
+ /// Gets the standard Text representation of the set details.
+ ///
+ public string Text => GetText(BattleTemplateExportSettings.Showdown);
+
+ ///
+ /// Language code
+ public string GetText(string language = DefaultLanguage) => GetText(new BattleTemplateExportSettings(language));
+
+ ///
+ /// Language ID
+ public string GetText(LanguageID language) => GetText(new BattleTemplateExportSettings(language));
+
+ ///
+ /// Export settings
+ public string GetText(in BattleTemplateExportSettings settings)
+ {
+ if (Species is 0 or > MAX_SPECIES)
+ return string.Empty;
+
+ var result = GetSetLines(settings);
+ return string.Join(Environment.NewLine, result);
+ }
+
+ ///
+ public List GetSetLines(string language = DefaultLanguage) => GetSetLines(new BattleTemplateExportSettings(language));
+
+ ///
+ /// Gets all lines comprising the exported set details.
+ ///
+ /// Export settings
+ /// List of lines comprising the set
+ public List GetSetLines(in BattleTemplateExportSettings settings)
+ {
+ var result = new List(DefaultListAllocation);
+ if (settings.Order.Length == 0)
+ return result;
+ GetSetLines(result, settings);
+ return result;
+ }
+
+ ///
+ public void GetSetLines(List result, in BattleTemplateExportSettings settings)
+ {
+ var tokens = settings.Order;
+ foreach (var token in tokens)
+ PushToken(token, result, settings);
+ }
+
+ private string GetStringFirstLine(string form, in BattleTemplateExportSettings settings)
+ {
+ var strings = settings.Localization.Strings;
+ var speciesList = strings.specieslist;
+ if (Species >= speciesList.Length)
+ return string.Empty; // invalid species
+
+ string specForm = speciesList[Species];
+ var gender = Gender;
+ if (form.Length != 0)
+ {
+ specForm += $"-{form.Replace("Mega ", "Mega-")}";
+ }
+ else if (Species == (int)NidoranM)
+ {
+ specForm = specForm.Replace("♂", "-M");
+ if (gender != EntityGender.Female)
+ gender = null;
+ }
+ else if (Species == (int)NidoranF)
+ {
+ specForm = specForm.Replace("♀", "-F");
+ if (gender != EntityGender.Male)
+ gender = null;
+ }
+
+ var nickname = Nickname;
+ if (settings.IsTokenInExport(BattleTemplateToken.Nickname))
+ nickname = string.Empty; // omit nickname if not in export
+
+ var result = GetSpeciesNickname(specForm, nickname, Species, Context);
+
+ // Append Gender if not default/random.
+ if (gender < EntityGender.Genderless && !settings.IsTokenInExport(BattleTemplateToken.Gender))
+ {
+ if (gender is 0)
+ result += $" {FirstLineMale}";
+ else if (gender is 1)
+ result += $" {FirstLineFemale}";
+ }
+
+ // Append item if specified.
+ if (HeldItem > 0 && !settings.IsTokenInExport(BattleTemplateToken.HeldItem) && !settings.IsTokenInExport(BattleTemplateToken.AbilityHeldItem))
+ {
+ var items = strings.GetItemStrings(Context);
+ if ((uint)HeldItem < items.Length)
+ result += $" {ItemSplit} {items[HeldItem]}";
+ }
+ return result;
+ }
+
+ private static string GetSpeciesNickname(string specForm, string nickname, ushort species, EntityContext context)
+ {
+ if (nickname.Length == 0 || nickname == specForm)
+ return specForm;
+ bool isNicknamed = SpeciesName.IsNicknamedAnyLanguage(species, nickname, context.Generation());
+ if (!isNicknamed)
+ return specForm;
+ return $"{nickname} ({specForm})";
+ }
+
+ private void PushToken(BattleTemplateToken token, List result, in BattleTemplateExportSettings settings)
+ {
+ var cfg = settings.Localization.Config;
+ var strings = settings.Localization.Strings;
+
+ switch (token)
+ {
+ // Core
+ case BattleTemplateToken.FirstLine:
+ var form = ShowdownParsing.GetShowdownFormName(Species, FormName);
+ result.Add(GetStringFirstLine(form, settings));
+ break;
+ case BattleTemplateToken.Ability when (uint)Ability < strings.Ability.Count:
+ result.Add(cfg.Push(BattleTemplateToken.Ability, strings.Ability[Ability]));
+ break;
+ case BattleTemplateToken.Nature when (uint)Nature < strings.Natures.Count:
+ result.Add(cfg.Push(token, strings.Natures[(byte)Nature]));
+ break;
+
+ case BattleTemplateToken.Moves:
+ GetStringMoves(result, settings);
+ break;
+
+ // Stats
+ case BattleTemplateToken.Level when Level != 100:
+ result.Add(cfg.Push(token, Level));
+ break;
+ case BattleTemplateToken.Friendship when Friendship != 255:
+ result.Add(cfg.Push(token, Friendship));
+ break;
+ case BattleTemplateToken.IVs:
+ var maxIV = Context.Generation() < 3 ? 15 : 31;
+ if (!IVs.AsSpan().ContainsAnyExcept(maxIV))
+ break; // skip if all IVs are maxed
+ var nameIVs = cfg.GetStatDisplay(settings.StatsIVs);
+ var ivs = GetStringStats(IVs, maxIV, nameIVs);
+ if (ivs.Length != 0)
+ result.Add(cfg.Push(BattleTemplateToken.IVs, ivs));
+ break;
+
+ // EVs
+ case BattleTemplateToken.EVsWithNature:
+ case BattleTemplateToken.EVsAppendNature:
+ case BattleTemplateToken.EVs when EVs.AsSpan().ContainsAnyExcept(0):
+ AddEVs(result, settings, token);
+ break;
+
+ // Boolean
+ case BattleTemplateToken.Shiny when Shiny:
+ result.Add(cfg.Push(token));
+ break;
+
+ // Gen8
+ case BattleTemplateToken.DynamaxLevel when Context == EntityContext.Gen8 && DynamaxLevel != 10:
+ result.Add(cfg.Push(token, DynamaxLevel));
+ break;
+ case BattleTemplateToken.Gigantamax when Context == EntityContext.Gen8 && CanGigantamax:
+ result.Add(cfg.Push(token));
+ break;
+
+ // Gen9
+ case BattleTemplateToken.TeraType when Context == EntityContext.Gen9 && TeraType != MoveType.Any:
+ if ((uint)TeraType <= TeraTypeUtil.MaxType) // Fairy
+ result.Add(cfg.Push(BattleTemplateToken.TeraType, strings.Types[(int)TeraType]));
+ else if ((uint)TeraType == TeraTypeUtil.Stellar)
+ result.Add(cfg.Push(BattleTemplateToken.TeraType, strings.Types[TeraTypeUtil.StellarTypeDisplayStringIndex]));
+ break;
+
+ // Edge Cases
+ case BattleTemplateToken.HeldItem when HeldItem > 0:
+ var itemNames = strings.GetItemStrings(Context);
+ if ((uint)HeldItem < itemNames.Length)
+ result.Add(cfg.Push(token, itemNames[HeldItem]));
+ break;
+ case BattleTemplateToken.Nickname when !string.IsNullOrWhiteSpace(Nickname):
+ result.Add(cfg.Push(token, Nickname));
+ break;
+ case BattleTemplateToken.Gender when Gender != EntityGender.Genderless:
+ result.Add(cfg.Push(token, Gender == 0 ? cfg.Male : cfg.Female));
+ break;
+
+ case BattleTemplateToken.AbilityHeldItem when Ability >= 0 || HeldItem > 0:
+ result.Add(GetAbilityHeldItem(strings, Ability, HeldItem, Context));
+ break;
+ }
+ }
+
+ private void AddEVs(List result, in BattleTemplateExportSettings settings, BattleTemplateToken token)
+ {
+ var cfg = settings.Localization.Config;
+ var nameEVs = cfg.GetStatDisplay(settings.StatsEVs);
+ var line = token switch
+ {
+ BattleTemplateToken.EVsWithNature => GetStringStatsNatureAmp(EVs, 0, nameEVs, Nature),
+ BattleTemplateToken.EVsAppendNature => GetStringStatsNatureAmp(EVs, 0, nameEVs, Nature),
+ _ => GetStringStats(EVs, 0, nameEVs),
+ };
+ if (token is BattleTemplateToken.EVsAppendNature && Nature.IsFixed())
+ line += $" ({settings.Localization.Strings.natures[(int)Nature]})";
+ result.Add(cfg.Push(BattleTemplateToken.EVs, line));
+ }
+
+ private static string GetAbilityHeldItem(GameStrings strings, int ability, int item, EntityContext context)
+ {
+ var abilityNames = strings.abilitylist;
+
+ if ((uint)ability >= abilityNames.Length)
+ ability = 0; // invalid ability
+ var abilityName = abilityNames[ability];
+
+ var itemNames = strings.GetItemStrings(context);
+ if ((uint)item >= itemNames.Length)
+ item = 0; // invalid item
+ var itemName = itemNames[item];
+
+ if (ability <= 0)
+ return $"{ItemSplit} {itemName}";
+ if (item <= 0)
+ return $"[{abilityName}]";
+ return $"[{abilityName}] {ItemSplit} {itemName}";
+ }
+
+ ///
+ /// Appends the nature amplification to the stat values, if not a neutral nature.
+ public static string GetStringStatsNatureAmp(ReadOnlySpan stats, T ignoreValue, StatDisplayConfig statNames, Nature nature) where T : IEquatable
+ {
+ var (plus, minus) = NatureAmp.GetNatureModification(nature);
+ if (plus == minus)
+ return GetStringStats(stats, ignoreValue, statNames); // neutral nature won't appear any different
+
+ // Shift as HP is not affected by nature.
+ plus++;
+ minus++;
+
+ var count = stats.Length;
+ if (!statNames.AlwaysShow)
+ {
+ for (int i = 0; i < stats.Length; i++)
+ {
+ if (stats[i].Equals(ignoreValue) && i != plus && i != minus)
+ count--; // ignore unused stats
+ }
+ }
+ if (count == 0)
+ return string.Empty;
+
+ var result = new StringBuilder();
+ int ctr = 0;
+ for (int i = 0; i < stats.Length; i++)
+ {
+ var statIndex = GetStatIndexStored(i);
+ var statValue = stats[statIndex];
+
+ var hideValue = statValue.Equals(ignoreValue) && !statNames.AlwaysShow;
+ if (hideValue && statIndex != plus && statIndex != minus)
+ continue; // ignore unused stats
+ var amp = statIndex == plus ? "+" : statIndex == minus ? "-" : string.Empty;
+ if (ctr++ != 0)
+ result.Append(statNames.Separator);
+ statNames.Format(result, i, statValue, amp, hideValue);
+ }
+ return result.ToString();
+ }
+
+ ///
+ /// Gets the string representation of the stats.
+ ///
+ /// Stats to display
+ /// Value to ignore
+ /// Stat names to use
+ public static string GetStringStats(ReadOnlySpan stats, T ignoreValue, StatDisplayConfig statNames) where T : IEquatable
+ {
+ var count = stats.Length;
+ if (!statNames.AlwaysShow)
+ {
+ foreach (var stat in stats)
+ {
+ if (stat.Equals(ignoreValue))
+ count--; // ignore unused stats
+ }
+ }
+ if (count == 0)
+ return "";
+
+ var result = new StringBuilder();
+ int ctr = 0;
+ for (int i = 0; i < stats.Length; i++)
+ {
+ var statIndex = GetStatIndexStored(i);
+ var statValue = stats[statIndex];
+ if (statValue.Equals(ignoreValue) && !statNames.AlwaysShow)
+ continue;
+ if (ctr++ != 0)
+ result.Append(statNames.Separator);
+ statNames.Format(result, i, statValue);
+ }
+ return result.ToString();
+ }
+
+ private void GetStringMoves(List result, in BattleTemplateExportSettings settings)
+ {
+ var strings = settings.Localization.Strings;
+ var moveNames = strings.movelist;
+ var style = settings.Moves;
+ var prefixes = BattleTemplateConfig.GetMoveDisplay(style);
+
+ var added = 0;
+ for (var i = 0; i < Moves.Length; i++)
+ {
+ var move = Moves[i];
+ if (move == 0 && !(style is MoveDisplayStyle.Directional && added != 0))
+ continue;
+ if (move >= moveNames.Length)
+ continue;
+ var moveName = moveNames[move];
+
+ string line;
+ if (move != (int)Move.HiddenPower || HiddenPowerType == -1)
+ {
+ line = $"{prefixes[i]} {moveName}";
+ }
+ else
+ {
+ var type = 1 + HiddenPowerType; // skip Normal
+ var typeName = strings.Types[type];
+ line = $"{prefixes[i]} {moveName} [{typeName}]";
+ }
+
+ result.Add(line);
+ added++;
+ }
+ }
+
+ private static int GetStatIndexStored(int displayIndex) => displayIndex switch
+ {
+ 3 => 4,
+ 4 => 5,
+ 5 => 3,
+ _ => displayIndex,
+ };
+
+ ///
+ /// Forces some properties to indicate the set for future display values.
+ ///
+ /// PKM to convert to string
+ public void InterpretAsPreview(PKM pk)
+ {
+ if (pk.Format <= 2) // Nature preview from IVs
+ Nature = Experience.GetNatureVC(pk.EXP);
+ }
+
+ ///
+ /// Converts the data into an importable set format for Pokémon Showdown.
+ ///
+ /// PKM to convert to string
+ /// Localization to parse the lines with.
+ /// New ShowdownSet object representing the input
+ public ShowdownSet(PKM pk, BattleTemplateLocalization? localization = null)
+ {
+ localization ??= DefaultStrings;
+ if (pk.Species == 0)
+ return;
+
+ Context = pk.Context;
+
+ Nickname = pk.Nickname;
+ Species = pk.Species;
+ HeldItem = pk.HeldItem;
+ Ability = pk.Ability;
+ pk.GetEVs(EVs);
+ pk.GetIVs(IVs);
+
+ var moves = Moves.AsSpan();
+ pk.GetMoves(moves);
+ if (moves.Contains((ushort)Move.HiddenPower))
+ HiddenPowerType = (sbyte)HiddenPower.GetType(IVs, Context);
+
+ Nature = pk.StatNature;
+ Gender = pk.Gender < 2 ? pk.Gender : (byte)2;
+ Friendship = pk.CurrentFriendship;
+ Level = pk.CurrentLevel;
+ Shiny = pk.IsShiny;
+
+ if (pk is PK8 g) // Only set Gigantamax if it is a PK8
+ {
+ CanGigantamax = g.CanGigantamax;
+ DynamaxLevel = g.DynamaxLevel;
+ }
+
+ if (pk is ITeraType t)
+ TeraType = t.TeraType;
+ if (pk is IHyperTrain h)
+ {
+ for (int i = 0; i < 6; i++)
+ {
+ if (h.IsHyperTrained(i))
+ IVs[i] = pk.MaxIV;
+ }
+ }
+
+ FormName = ShowdownParsing.GetStringFromForm(Form = pk.Form, localization.Strings, Species, Context);
+ }
+
+ private void ParseFirstLine(ReadOnlySpan first, GameStrings strings)
+ {
+ int itemSplit = first.IndexOf(ItemSplit);
+ if (itemSplit != -1)
+ {
+ var itemName = first[(itemSplit + 1)..].TrimStart();
+ var speciesName = first[..itemSplit].TrimEnd();
+
+ if (!ParseItemName(itemName, strings))
+ InvalidLines.Add($"Unknown Item: {itemName}");
+ ParseFirstLineNoItem(speciesName, strings);
+ }
+ else
+ {
+ ParseFirstLineNoItem(first, strings);
+ }
+ }
+
+ private bool ParseItemName(ReadOnlySpan itemName, GameStrings strings)
+ {
+ if (TryGetItem(itemName, strings, Context))
+ return true;
+ if (TryGetItem(itemName, strings, EntityContext.Gen3))
+ return true;
+ if (TryGetItem(itemName, strings, EntityContext.Gen2))
+ return true;
+ return false;
+ }
+
+ private bool TryGetItem(ReadOnlySpan itemName, GameStrings strings, EntityContext context)
+ {
+ var items = strings.GetItemStrings(context);
+ var item = StringUtil.FindIndexIgnoreCase(items, itemName);
+ if (item < 0)
+ return false;
+ Context = context;
+ HeldItem = item;
+ return true;
+ }
+
+ private const string FirstLineMale = "(M)";
+ private const string FirstLineFemale = "(F)";
+
+ private void ParseFirstLineNoItem(ReadOnlySpan line, GameStrings strings)
+ {
+ // Gender Detection
+ if (line.EndsWith(FirstLineMale, StringComparison.Ordinal))
+ {
+ line = line[..^3].TrimEnd();
+ Gender = 0;
+ }
+ else if (line.EndsWith(FirstLineFemale, StringComparison.Ordinal))
+ {
+ line = line[..^3].TrimEnd();
+ Gender = 1;
+ }
+
+ // Nickname Detection
+ if (line.IndexOf('(') != -1 && line.IndexOf(')') != -1)
+ ParseSpeciesNickname(line, strings);
+ else
+ ParseSpeciesForm(line, strings);
+ }
+
+ private const string Gmax = "-Gmax";
+
+ ///
+ /// Average count of lines in a Showdown set.
+ ///
+ /// Optimization to skip 1 size update allocation (from 4). Usually first-line, ability, (ivs, evs, shiny, level) 4*moves
+ public const int DefaultListAllocation = 8;
+
+ private bool ParseSpeciesForm(ReadOnlySpan speciesLine, GameStrings strings)
+ {
+ speciesLine = speciesLine.Trim();
+ if (speciesLine.Length == 0)
+ return false;
+
+ if (speciesLine.EndsWith(Gmax, StringComparison.Ordinal))
+ {
+ CanGigantamax = true;
+ speciesLine = speciesLine[..^Gmax.Length];
+ }
+
+ var speciesIndex = StringUtil.FindIndexIgnoreCase(strings.specieslist, speciesLine);
+ if (speciesIndex > 0)
+ {
+ // success, nothing else !
+ Species = (ushort)speciesIndex;
+ return true;
+ }
+
+ // Form string present.
+ int end = speciesLine.IndexOf('-');
+ if (end < 0)
+ return false;
+
+ speciesIndex = StringUtil.FindIndexIgnoreCase(strings.specieslist, speciesLine[..end]);
+ if (speciesIndex > 0)
+ {
+ Species = (ushort)speciesIndex;
+ FormName = speciesLine[(end + 1)..].ToString();
+ return true;
+ }
+
+ // failure to parse, check edge cases
+ foreach (var e in DashedSpecies)
+ {
+ var sn = strings.Species[e];
+ if (!speciesLine.StartsWith(sn.Replace("♂", "-M").Replace("♀", "-F"), StringComparison.Ordinal))
+ continue;
+ Species = e;
+ FormName = speciesLine[sn.Length..].ToString();
+ return true;
+ }
+
+ // Version Megas
+ end = speciesLine[Math.Max(0, end - 1)..].LastIndexOf('-');
+ if (end < 0)
+ return false;
+
+ speciesIndex = StringUtil.FindIndexIgnoreCase(strings.specieslist, speciesLine[..end]);
+ if (speciesIndex > 0)
+ {
+ Species = (ushort)speciesIndex;
+ FormName = speciesLine[(end + 1)..].ToString();
+ return true;
+ }
+ return false;
+ }
+
+ private void ParseSpeciesNickname(ReadOnlySpan line, GameStrings strings)
+ {
+ // Entering into this method requires both ( and ) to be present within the input line.
+ int index = line.LastIndexOf('(');
+ ReadOnlySpan species;
+ ReadOnlySpan nickname;
+ if (index > 1) // parenthesis value after: Nickname (Species), correct.
+ {
+ nickname = line[..index].TrimEnd();
+ species = line[(index + 1)..];
+ if (species.Length != 0 && species[^1] == ')')
+ species = species[..^1];
+ }
+ else // parenthesis value before: (Species) Nickname, incorrect
+ {
+ int start = index + 1;
+ int end = line.LastIndexOf(')');
+ var tmp = line[start..end];
+ if (end < line.Length - 2)
+ {
+ nickname = line[(end + 2)..];
+ species = tmp;
+ }
+ else // (Species), or garbage
+ {
+ species = tmp;
+ nickname = [];
+ }
+ }
+
+ if (ParseSpeciesForm(species, strings))
+ Nickname = nickname.ToString();
+ else if (ParseSpeciesForm(nickname, strings))
+ Nickname = species.ToString();
+ }
+
+ private ReadOnlySpan ParseLineMove(ReadOnlySpan line, GameStrings strings)
+ {
+ line = line[1..].TrimStart();
+
+ // Discard any multi-move options; keep only first.
+ var option = line.IndexOf('/');
+ if (option != -1)
+ line = line[..option].TrimEnd();
+
+ var moveString = line;
+
+ var hiddenPowerName = strings.Move[(int)Move.HiddenPower];
+ if (!moveString.StartsWith(hiddenPowerName, StringComparison.OrdinalIgnoreCase))
+ return moveString; // regular move
+
+ if (moveString.Length == hiddenPowerName.Length)
+ return hiddenPowerName;
+
+ // Defined Hidden Power
+ var type = GetHiddenPowerType(moveString[(hiddenPowerName.Length + 1)..]);
+ var types = strings.types.AsSpan(1, HiddenPower.TypeCount);
+ int hpVal = StringUtil.FindIndexIgnoreCase(types, type); // Get HP Type
+ if (hpVal == -1)
+ return hiddenPowerName;
+
+ HiddenPowerType = (sbyte)hpVal;
+ var maxIV = Context.Generation() < 3 ? 15 : 31;
+ if (IVs.AsSpan().ContainsAnyExcept(maxIV))
+ {
+ if (!HiddenPower.SetIVsForType(hpVal, IVs, Context))
+ InvalidLines.Add($"Invalid IVs for Hidden Power Type: {type}");
+ }
+ else if (hpVal >= 0)
+ {
+ HiddenPower.SetIVs(hpVal, IVs, Context); // Alter IVs
+ }
+ else
+ {
+ InvalidLines.Add($"Invalid Hidden Power Type: {type}");
+ }
+ return hiddenPowerName;
+ }
+
+ private static ReadOnlySpan GetHiddenPowerType(ReadOnlySpan line)
+ {
+ var type = line.Trim();
+ if (type.Length == 0)
+ return type;
+
+ // Allow for both (Type) and [Type]
+ if (type[0] == '(' && type[^1] == ')')
+ return type[1..^1].Trim();
+ if (type[0] == '[' && type[^1] == ']')
+ return type[1..^1].Trim();
+
+ return type;
+ }
+
+ private bool ParseLineEVs(ReadOnlySpan line, BattleTemplateLocalization localization)
+ {
+ // If nature is present, parse it first.
+ var nature = line.IndexOf('(');
+ if (nature != -1)
+ {
+ var natureName = line[(nature + 1)..];
+ var end = natureName.IndexOf(')');
+ if (end == -1)
+ {
+ InvalidLines.Add($"Invalid EV nature: {natureName}");
+ return false; // invalid line
+ }
+ natureName = natureName[..end].Trim();
+ var natureIndex = StringUtil.FindIndexIgnoreCase(localization.Strings.natures, natureName);
+ if (natureIndex == -1)
+ {
+ InvalidLines.Add($"Invalid EV nature: {natureName}");
+ return false; // invalid line
+ }
+
+ if (Nature != Nature.Random)
+ InvalidLines.Add($"EV nature ignored, specified previously: {natureName}");
+ else
+ Nature = (Nature)natureIndex;
+
+ line = line[..nature].TrimEnd();
+ }
+
+ var result = localization.Config.TryParseStats(line, EVs);
+ var success = result.IsParsedAllStats;
+ if (!result.IsParseClean)
+ InvalidLines.Add($"Invalid EVs: {line}");
+
+ if (result is { HasAmps: false })
+ return success;
+ if (Nature != Nature.Random)
+ {
+ InvalidLines.Add($"EV nature +/- ignored, specified previously: {line}");
+ return false;
+ }
+
+ success &= AdjustNature(result.Plus, result.Minus);
+ return success;
+ }
+
+ private bool ParseLineIVs(ReadOnlySpan line, BattleTemplateConfig config)
+ {
+ // Parse stats, with unspecified name representation (try all).
+ var result = config.TryParseStats(line, IVs);
+ var success = result.IsParsedAllStats;
+ if (!result.IsParseClean)
+ InvalidLines.Add($"Invalid IVs: {line}");
+ return success;
+ }
+
+ private bool AdjustNature(int plus, int minus)
+ {
+ if (plus == -1)
+ InvalidLines.Add("Invalid Nature adjustment, missing plus stat.");
+ if (minus == -1)
+ InvalidLines.Add("Invalid Nature adjustment, missing minus stat.");
+ else
+ Nature = NatureAmp.CreateNatureFromAmps(plus, minus);
+ return true;
+ }
+}
diff --git a/PKHeX.Core/Editing/Showdown/ShowdownTeam.cs b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownTeam.cs
similarity index 98%
rename from PKHeX.Core/Editing/Showdown/ShowdownTeam.cs
rename to PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownTeam.cs
index 51707f26b..90a8e0a2f 100644
--- a/PKHeX.Core/Editing/Showdown/ShowdownTeam.cs
+++ b/PKHeX.Core/Editing/BattleTemplate/Showdown/ShowdownTeam.cs
@@ -7,6 +7,9 @@ namespace PKHeX.Core;
///
/// Logic for retrieving Showdown teams from URLs.
///
+///
+///
+///
public static class ShowdownTeam
{
///
@@ -82,7 +85,9 @@ public static bool IsURL(ReadOnlySpan text, [NotNullWhen(true)] out string
text = text.Trim();
if (text.StartsWith("https://psim.us/t/") || // short link
text.StartsWith("https://teams.pokemonshowdown.com/"))
+ {
return TryCheckWeb(text, out url);
+ }
if (text.StartsWith("https://play.pokemonshowdown.com/api/getteam?teamid="))
return TryCheckAPI(text, out url);
diff --git a/PKHeX.Core/Editing/BattleTemplate/StatDisplayConfig.cs b/PKHeX.Core/Editing/BattleTemplate/StatDisplayConfig.cs
new file mode 100644
index 000000000..71a692eb2
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/StatDisplayConfig.cs
@@ -0,0 +1,338 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Text;
+
+namespace PKHeX.Core;
+
+///
+/// Configuration for displaying stats.
+///
+[TypeConverter(typeof(ExpandableObjectConverter))]
+public sealed class StatDisplayConfig
+{
+ ///
+ /// Stat names are displayed without localization; H:X A:X B:X C:X D:X S:X
+ ///
+ public static readonly StatDisplayConfig HABCDS = new()
+ {
+ Names = ["H", "A", "B", "C", "D", "S"],
+ Separator = " ",
+ ValueGap = ":",
+ IsLeft = true,
+ AlwaysShow = true,
+ };
+
+ ///
+ /// Stat names are displayed without localization; X/X/X/X/X/X
+ ///
+ ///
+ /// Same as but with no leading zeroes.
+ ///
+ public static readonly StatDisplayConfig Raw = new()
+ {
+ Names = [],
+ Separator = "/",
+ ValueGap = "",
+ AlwaysShow = true,
+ };
+
+ ///
+ /// Stat names are displayed without localization; XX/XX/XX/XX/XX/XX
+ ///
+ ///
+ /// Same as but with 2 digits (leading zeroes).
+ ///
+ public static readonly StatDisplayConfig Raw00 = new()
+ {
+ Names = [],
+ Separator = "/",
+ ValueGap = "",
+ AlwaysShow = true,
+ MinimumDigits = 2,
+ };
+
+ ///
+ /// List of stat display styles that are commonly used and not specific to a localization.
+ ///
+ public static List Custom { get; } = [HABCDS, Raw]; // Raw00 parses equivalent to Raw
+
+ /// List of stat names to display
+ public required string[] Names { get; init; }
+
+ /// Separator between each stat+value declaration
+ public string Separator { get; init; } = " / ";
+
+ /// Separator between the stat name and value
+ public string ValueGap { get; init; } = " ";
+
+ /// true if the text is displayed on the left side of the value
+ public bool IsLeft { get; init; }
+
+ /// true if the stat is always shown, even if the value is default
+ public bool AlwaysShow { get; init; }
+
+ /// Minimum number of digits to show for the stat value.
+ public int MinimumDigits { get; init; }
+
+ ///
+ /// Gets the index of the displayed stat name (in visual order) via a case-insensitive search.
+ ///
+ /// Stat name, trimmed.
+ /// -1 if not found, otherwise the index of the stat name.
+ public int GetStatIndex(ReadOnlySpan stat)
+ {
+ for (int i = 0; i < Names.Length; i++)
+ {
+ if (stat.Equals(Names[i], StringComparison.OrdinalIgnoreCase))
+ return i;
+ }
+ return -1;
+ }
+
+ public override string ToString() => string.Join(Separator, Names);
+
+ ///
+ /// Formats a stat value into a string builder.
+ ///
+ /// Result string builder
+ /// Display index of the stat
+ /// Stat value
+ /// Optional suffix for the value, to display a stat amplification request
+ /// true to skip the value, only displaying the stat name and amplification (if provided)
+ public void Format(StringBuilder sb, int statIndex, T statValue, ReadOnlySpan valueSuffix = default, bool skipValue = false)
+ {
+ var statName = statIndex < Names.Length ? Names[statIndex] : "";
+ var length = GetStatSize(statName, statValue, valueSuffix, skipValue);
+
+ if (sb.Length + length > sb.Capacity)
+ sb.EnsureCapacity(sb.Length + length);
+ Append(sb, statName, statValue, valueSuffix, skipValue);
+ }
+
+ private void Append(StringBuilder sb, ReadOnlySpan statName, T statValue, ReadOnlySpan valueSuffix, bool skipValue)
+ {
+ int start = sb.Length;
+
+ if (!skipValue)
+ {
+ sb.Append(statValue);
+ var end = sb.Length;
+ if (end < MinimumDigits)
+ sb.Insert(start, "0", MinimumDigits - end);
+ }
+ sb.Append(valueSuffix);
+
+ if (IsLeft)
+ {
+ sb.Insert(start, ValueGap);
+ sb.Insert(start, statName);
+ }
+ else
+ {
+ sb.Append(ValueGap);
+ sb.Append(statName);
+ }
+ }
+
+ private int GetStatSize(ReadOnlySpan statName, T statValue, ReadOnlySpan valueSuffix, bool skipValue)
+ {
+ var length = statName.Length + ValueGap.Length + valueSuffix.Length;
+ if (!skipValue)
+ length += (int)Math.Max(MinimumDigits, Math.Floor(Math.Log10(Convert.ToDouble(statValue)) + 1));
+ return length;
+ }
+
+ ///
+ /// Gets the separator character used for parsing.
+ ///
+ private char GetSeparatorParse() => GetSeparatorParse(Separator);
+
+ private static char GetSeparatorParse(ReadOnlySpan sep) => sep.Length switch
+ {
+ 0 => ' ',
+ 1 => sep[0],
+ _ => sep.Trim()[0]
+ };
+
+ ///
+ /// Imports a list of stats from a string.
+ ///
+ /// Input string
+ /// Result storage
+ /// Parse result
+ public StatParseResult TryParse(ReadOnlySpan message, Span result)
+ {
+ var separator = GetSeparatorParse();
+ var gap = ValueGap.AsSpan().Trim();
+ // If stats are not labeled, parse with the straightforward parser.
+ if (Names.Length == 0)
+ return TryParseRaw(message, result, separator);
+ else if (IsLeft)
+ return TryParseIsLeft(message, result, separator, gap);
+ else
+ return TryParseRight(message, result, separator, gap);
+ }
+
+ private StatParseResult TryParseIsLeft(ReadOnlySpan message, Span result, char separator, ReadOnlySpan valueGap)
+ {
+ var rec = new StatParseResult();
+
+ for (int i = 0; i < Names.Length; i++)
+ {
+ if (message.Length == 0)
+ break;
+
+ var statName = Names[i];
+ var index = message.IndexOf(statName, StringComparison.OrdinalIgnoreCase);
+ if (index == -1)
+ continue;
+
+ if (index != 0)
+ rec.MarkDirty(); // We have something before our stat name, so it isn't clean.
+
+ message = message[statName.Length..].TrimStart();
+ if (valueGap.Length > 0 && message.StartsWith(valueGap))
+ message = message[valueGap.Length..].TrimStart();
+
+ var value = message;
+
+ var indexSeparator = value.IndexOf(separator);
+ if (indexSeparator != -1)
+ value = value[..indexSeparator].Trim();
+ else
+ message = default; // everything remaining belongs in the value we are going to parse.
+
+ if (value.Length != 0)
+ {
+ var amped = TryPeekAmp(ref value, ref rec, i);
+ if (amped && value.Length == 0)
+ rec.MarkParsed(index);
+ else
+ TryParse(result, ref rec, value, i);
+ }
+
+ if (indexSeparator != -1)
+ message = message[(indexSeparator+1)..].TrimStart();
+ else
+ break;
+ }
+
+ if (!message.IsWhiteSpace()) // shouldn't be anything left in the message to parse
+ rec.MarkDirty();
+ rec.FinishParse(Names.Length);
+ return rec;
+ }
+
+ private StatParseResult TryParseRight(ReadOnlySpan message, Span result, char separator, ReadOnlySpan valueGap)
+ {
+ var rec = new StatParseResult();
+
+ for (int i = 0; i < Names.Length; i++)
+ {
+ if (message.Length == 0)
+ break;
+
+ var statName = Names[i];
+ var index = message.IndexOf(statName, StringComparison.OrdinalIgnoreCase);
+ if (index == -1)
+ continue;
+
+ var value = message[..index].Trim();
+ var indexSeparator = value.LastIndexOf(separator);
+ if (indexSeparator != -1)
+ {
+ rec.MarkDirty(); // We have something before our stat name, so it isn't clean.
+ value = value[(indexSeparator + 1)..].TrimStart();
+ }
+
+ if (valueGap.Length > 0 && value.EndsWith(valueGap))
+ value = value[..^valueGap.Length];
+
+ if (value.Length != 0)
+ {
+ var amped = TryPeekAmp(ref value, ref rec, i);
+ if (amped && value.Length == 0)
+ rec.MarkParsed(index);
+ else
+ TryParse(result, ref rec, value, i);
+ }
+
+ message = message[(index + statName.Length)..].TrimStart();
+ if (message.StartsWith(separator))
+ message = message[1..].TrimStart();
+ }
+
+ if (!message.IsWhiteSpace()) // shouldn't be anything left in the message to parse
+ rec.MarkDirty();
+ rec.FinishParse(Names.Length);
+ return rec;
+ }
+
+ ///
+ /// Parses a raw stat string.
+ ///
+ /// Input string
+ /// Output storage
+ /// Separator character
+ public static StatParseResult TryParseRaw(ReadOnlySpan message, Span result, char separator)
+ {
+ var rec = new StatParseResult();
+
+ // Expect the message to contain all entries of `result` separated by the separator and an arbitrary amount of spaces permitted.
+ // The message is split by the separator, and each part is trimmed of whitespace.
+ for (int i = 0; i < result.Length; i++)
+ {
+ var index = message.IndexOf(separator);
+
+ var value = index != -1 ? message[..index].Trim() : message.Trim();
+ message = message[(index+1)..].TrimStart();
+
+ if (value.Length == 0)
+ {
+ rec.MarkDirty(); // Something is wrong with the message, as we have an empty stat.
+ continue; // Maybe it's a duplicate separator; keep parsing and hope that the required amount are parsed.
+ }
+
+ var amped = TryPeekAmp(ref value, ref rec, i);
+ if (amped && value.Length == 0)
+ rec.MarkParsed(index);
+ else
+ TryParse(result, ref rec, value, i);
+ }
+
+ if (!message.IsWhiteSpace()) // shouldn't be anything left in the message to parse
+ rec.MarkDirty();
+ rec.FinishParseOnly(result.Length);
+ return rec;
+ }
+
+ private static void TryParse(Span result, ref StatParseResult rec, ReadOnlySpan value, int statIndex)
+ {
+ if (!int.TryParse(value, out var stat) || stat < 0)
+ {
+ rec.MarkDirty();
+ return;
+ }
+ result[statIndex] = stat;
+ rec.MarkParsed(statIndex);
+ }
+
+ private static bool TryPeekAmp(ref ReadOnlySpan value, ref StatParseResult rec, int statIndex)
+ {
+ var last = value[^1];
+ if (last == '+')
+ {
+ rec.Plus = (sbyte)statIndex;
+ value = value[..^1].TrimEnd();
+ return true;
+ }
+ if (last == '-')
+ {
+ rec.Minus = (sbyte)statIndex;
+ value = value[..^1].TrimEnd();
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/PKHeX.Core/Editing/BattleTemplate/StatDisplayStyle.cs b/PKHeX.Core/Editing/BattleTemplate/StatDisplayStyle.cs
new file mode 100644
index 000000000..29709657d
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/StatDisplayStyle.cs
@@ -0,0 +1,37 @@
+namespace PKHeX.Core;
+
+///
+/// Style to display stat names.
+///
+public enum StatDisplayStyle : sbyte
+{
+ Custom = -1,
+
+ ///
+ /// Stat names are displayed in abbreviated (2-3 characters) localized text.
+ ///
+ Abbreviated,
+
+ ///
+ /// Stat names are displayed in full localized text.
+ ///
+ Full,
+
+ ///
+ /// Stat names are displayed as a single character.
+ ///
+ ///
+ /// This is the typical format used by the Japanese community; HABCDS.
+ ///
+ HABCDS,
+
+ ///
+ /// Stat names are displayed without localization; X/X/X/X/X/X
+ ///
+ Raw,
+
+ ///
+ /// Stat names are displayed without localization; XX/XX/XX/XX/XX/XX
+ ///
+ Raw00,
+}
diff --git a/PKHeX.Core/Editing/BattleTemplate/StatParseResult.cs b/PKHeX.Core/Editing/BattleTemplate/StatParseResult.cs
new file mode 100644
index 000000000..3b32b968b
--- /dev/null
+++ b/PKHeX.Core/Editing/BattleTemplate/StatParseResult.cs
@@ -0,0 +1,128 @@
+using System;
+
+namespace PKHeX.Core;
+
+///
+/// Value result object of parsing a stat string.
+///
+public record struct StatParseResult()
+{
+ private const uint MaxStatCount = 6; // Number of stats in the game
+ private const sbyte NoStatAmp = -1;
+
+ ///
+ /// Count of parsed stats.
+ ///
+ public byte CountParsed { get; private set; } = 0; // could potentially make this a computed value (popcnt), but it's not worth it
+
+ ///
+ /// Indexes of parsed stats.
+ ///
+ public byte IndexesParsed { get; private set; } = 0;
+
+ ///
+ /// Stat index of increased stat.
+ ///
+ public sbyte Plus { get; set; } = NoStatAmp;
+
+ ///
+ /// Stat index of decreased stat.
+ ///
+ public sbyte Minus { get; set; } = NoStatAmp;
+
+ ///
+ /// Indicates if the parsing was clean (no un-parsed text).
+ ///
+ public bool IsParseClean { get; private set; } = true;
+
+ ///
+ /// Indicates if all stat indexes available were parsed.
+ ///
+ public bool IsParsedAllStats { get; private set; } = false;
+
+ ///
+ /// Marks the stat index as parsed, and updates the count of parsed stats.
+ ///
+ /// Visual index of the stat to mark as parsed.
+ /// True if the stat had not been parsed before, false if it was already parsed.
+ public bool MarkParsed(int statIndex)
+ {
+ // Check if the stat index is valid (0-5)
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)statIndex, MaxStatCount);
+ if (WasParsed(statIndex))
+ return false;
+ // Mark the stat index as parsed
+ IndexesParsed |= (byte)(1 << statIndex);
+ ++CountParsed;
+ return true;
+ }
+
+ ///
+ /// Checks if the stat index was parsed.
+ ///
+ /// Visual index of the stat to check.
+ /// True if the stat was parsed, false otherwise.
+ public bool WasParsed(int statIndex)
+ {
+ // Check if the stat index is valid (0-5)
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)statIndex, MaxStatCount);
+ return (IndexesParsed & (1 << statIndex)) != 0;
+ }
+
+ ///
+ /// Marks the parsing as finished, and updates the internal state to indicate if all stats were parsed.
+ ///
+ ///
+ /// This is used when not all stats are required to be parsed.
+ ///
+ ///
+ public void FinishParse(int expect)
+ {
+ if (CountParsed == 0 && !HasAmps)
+ MarkDirty();
+ IsParsedAllStats = CountParsed == expect || IsParseClean;
+ }
+
+ ///
+ /// Marks the parsing as finished, and updates the internal state to indicate if all stats were parsed.
+ ///
+ ///
+ /// This is used when a specific number of stats is expected.
+ ///
+ ///
+ public void FinishParseOnly(int expect) => IsParsedAllStats = CountParsed == expect;
+
+ ///
+ /// Marks the parsing as dirty, indicating that the string was not a clean input string (user modified or the syntax doesn't match the spec).
+ ///
+ public void MarkDirty() => IsParseClean = false;
+
+ ///
+ /// Indicates if any stat has any amplified (+/-) requested, indicative of nature.
+ ///
+ public bool HasAmps => Plus != NoStatAmp || Minus != NoStatAmp;
+
+ ///
+ /// Reorders the speed stat to be in the middle of the stats.
+ ///
+ ///
+ /// Speed is visually represented as the last stat in the list, but it is actually the 3rd stat stored.
+ ///
+ public void TreatAmpsAsSpeedNotLast()
+ {
+ Plus = GetSpeedMiddleIndex(Plus);
+ Minus = GetSpeedMiddleIndex(Minus);
+ }
+
+ // Move speed from index 5 to index 3, and the other stats down to account for HP not being boosted.
+ private static sbyte GetSpeedMiddleIndex(sbyte amp) => amp switch
+ {
+ 0 => -1,
+ 1 => 0, // Atk
+ 2 => 1, // Def
+ 3 => 3, // SpA
+ 4 => 4, // SpD
+ 5 => 2, // Spe
+ _ => amp,
+ };
+}
diff --git a/PKHeX.Core/Editing/Showdown/ShowdownSet.cs b/PKHeX.Core/Editing/Showdown/ShowdownSet.cs
deleted file mode 100644
index 69d7a352c..000000000
--- a/PKHeX.Core/Editing/Showdown/ShowdownSet.cs
+++ /dev/null
@@ -1,784 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-using static PKHeX.Core.Species;
-
-namespace PKHeX.Core;
-
-///
-/// Logic for exporting and importing data in Pokémon Showdown's text format.
-///
-public sealed class ShowdownSet : IBattleTemplate
-{
- private static readonly string[] StatNames = ["HP", "Atk", "Def", "Spe", "SpA", "SpD"];
- private const string LineSplit = ": ";
- private const string ItemSplit = " @ ";
- private const int MAX_SPECIES = (int)MAX_COUNT - 1;
- internal const string DefaultLanguage = GameLanguage.DefaultLanguage;
- private static readonly GameStrings DefaultStrings = GameInfo.GetStrings(DefaultLanguage);
-
- private static ReadOnlySpan DashedSpecies =>
- [
- (int)NidoranF, (int)NidoranM,
- (int)HoOh,
- (int)Jangmoo, (int)Hakamoo, (int)Kommoo,
- (int)TingLu, (int)ChienPao, (int)WoChien, (int)ChiYu,
- ];
-
- public ushort Species { get; private set; }
- public EntityContext Context { get; private set; } = RecentTrainerCache.Context;
- public string Nickname { get; private set; } = string.Empty;
- public byte? Gender { get; private set; }
- public int HeldItem { get; private set; }
- public int Ability { get; private set; } = -1;
- public byte Level { get; private set; } = 100;
- public bool Shiny { get; private set; }
- public byte Friendship { get; private set; } = 255;
- public Nature Nature { get; private set; } = Nature.Random;
- public string FormName { get; private set; } = string.Empty;
- public byte Form { get; private set; }
- public int[] EVs { get; } = [00, 00, 00, 00, 00, 00];
- public int[] IVs { get; } = [31, 31, 31, 31, 31, 31];
- public sbyte HiddenPowerType { get; private set; } = -1;
- public MoveType TeraType { get; private set; } = MoveType.Any;
- public ushort[] Moves { get; } = [0, 0, 0, 0];
- public bool CanGigantamax { get; private set; }
- public byte DynamaxLevel { get; private set; } = 10;
-
- ///
- /// Any lines that failed to be parsed.
- ///
- public readonly List InvalidLines = new(0);
-
- private GameStrings Strings { get; set; } = DefaultStrings;
-
- ///
- /// Loads a new from the input string.
- ///
- /// Single-line string which will be split before loading.
- public ShowdownSet(ReadOnlySpan input) => LoadLines(input.EnumerateLines());
-
- ///
- /// Loads a new from the input string.
- ///
- /// Enumerable list of lines.
- public ShowdownSet(IEnumerable lines) => LoadLines(lines);
-
- private void LoadLines(SpanLineEnumerator lines)
- {
- ParseLines(lines);
- SanitizeResult();
- }
-
- private void LoadLines(IEnumerable lines)
- {
- ParseLines(lines);
- SanitizeResult();
- }
-
- private void SanitizeResult()
- {
- FormName = ShowdownParsing.SetShowdownFormName(Species, FormName, Ability);
- Form = ShowdownParsing.GetFormFromString(FormName, Strings, Species, Context);
-
- // Handle edge case with fixed-gender forms.
- if (Species is (int)Meowstic or (int)Indeedee or (int)Basculegion or (int)Oinkologne)
- ReviseGenderedForms();
- }
-
- private void ReviseGenderedForms()
- {
- if (Gender == 1) // Recognized with (F)
- {
- FormName = "F";
- Form = 1;
- }
- else
- {
- FormName = Form == 1 ? "F" : "M";
- Gender = Form;
- }
- }
-
- private const int MaxMoveCount = 4;
-
- // Skip lines that are too short or too long.
- // Longest line is ~74 (Gen2 EVs)
- // Length permitted: 3-80
- // The shortest Pokémon name in Japanese is "ニ" (Ni) which is the name for the Pokémon, Nidoran♂ (male Nidoran). It has only one letter.
- // We will handle this 1-2 letter edge case only if the line is the first line of the set, in the rare chance we are importing for a non-English language?
- private const int MinLength = 3;
- private const int MaxLength = 80;
- private static bool IsLengthOutOfRange(ReadOnlySpan trim) => IsLengthOutOfRange(trim.Length);
- private static bool IsLengthOutOfRange(int length) => (uint)(length - MinLength) > MaxLength - MinLength;
-
- private void ParseLines(SpanLineEnumerator lines)
- {
- int movectr = 0;
- bool first = true;
- foreach (var line in lines)
- {
- ReadOnlySpan trim = line.Trim();
- if (IsLengthOutOfRange(trim))
- {
- // Try for other languages just in case.
- if (first && trim.Length != 0)
- {
- ParseFirstLine(trim);
- first = false;
- continue;
- }
- InvalidLines.Add(line.ToString());
- continue;
- }
-
- if (first)
- {
- ParseFirstLine(trim);
- first = false;
- continue;
- }
- if (ParseLine(trim, ref movectr))
- return; // End of moves, end of set data
- }
- }
-
- private void ParseLines(IEnumerable lines)
- {
- int movectr = 0;
- bool first = true;
- foreach (var line in lines)
- {
- ReadOnlySpan trim = line.Trim();
- if (IsLengthOutOfRange(trim))
- {
- // Try for other languages just in case.
- if (first && trim.Length != 0)
- {
- ParseFirstLine(trim);
- first = false;
- continue;
- }
- InvalidLines.Add(line);
- continue;
- }
-
- if (first)
- {
- ParseFirstLine(trim);
- first = false;
- continue;
- }
- if (ParseLine(trim, ref movectr))
- return; // End of moves, end of set data
- }
- }
-
- private bool ParseLine(ReadOnlySpan line, ref int movectr)
- {
- var moves = Moves.AsSpan();
- if (line[0] is '-' or '–')
- {
- var moveString = ParseLineMove(line);
- int move = StringUtil.FindIndexIgnoreCase(Strings.movelist, moveString);
- if (move < 0)
- InvalidLines.Add($"Unknown Move: {moveString}");
- else if (moves.Contains((ushort)move))
- InvalidLines.Add($"Duplicate Move: {moveString}");
- else
- moves[movectr++] = (ushort)move;
-
- return movectr == MaxMoveCount;
- }
-
- if (movectr != 0)
- return true;
-
- bool valid;
- var split = line.IndexOf(LineSplit, StringComparison.Ordinal);
- if (split == -1)
- {
- valid = ParseSingle(line); // Nature
- }
- else
- {
- var left = line[..split].Trim();
- var right = line[(split + LineSplit.Length)..].Trim();
- valid = ParseEntry(left, right);
- }
-
- if (!valid)
- InvalidLines.Add(line.ToString());
- return false;
- }
-
- private bool ParseSingle(ReadOnlySpan identifier)
- {
- if (!identifier.EndsWith("Nature", StringComparison.OrdinalIgnoreCase))
- return false;
- var firstSpace = identifier.IndexOf(' ');
- if (firstSpace == -1)
- return false;
- var nature = identifier[..firstSpace];
- return (Nature = (Nature)StringUtil.FindIndexIgnoreCase(Strings.natures, nature)).IsFixed();
- }
-
- private bool ParseEntry(ReadOnlySpan identifier, ReadOnlySpan value) => identifier switch
- {
- "Ability" => (Ability = StringUtil.FindIndexIgnoreCase(Strings.abilitylist, value)) >= 0,
- "Nature" => (Nature = (Nature)StringUtil.FindIndexIgnoreCase(Strings.natures , value)).IsFixed(),
- "Shiny" => Shiny = StringUtil.IsMatchIgnoreCase("Yes", value),
- "Gigantamax" => CanGigantamax = StringUtil.IsMatchIgnoreCase("Yes", value),
- "Friendship" => ParseFriendship(value),
- "EVs" => ParseLineEVs(value),
- "IVs" => ParseLineIVs(value),
- "Level" => ParseLevel(value),
- "Dynamax Level" => ParseDynamax(value),
- "Tera Type" => ParseTeraType(value),
- _ => false,
- };
-
- private bool ParseLevel(ReadOnlySpan value)
- {
- if (!byte.TryParse(value.Trim(), out var val))
- return false;
- if ((uint)val is 0 or > 100)
- return false;
- Level = val;
- return true;
- }
-
- private bool ParseFriendship(ReadOnlySpan value)
- {
- if (!byte.TryParse(value.Trim(), out var val))
- return false;
- Friendship = val;
- return true;
- }
-
- private bool ParseDynamax(ReadOnlySpan value)
- {
- Context = EntityContext.Gen8;
- var val = Util.ToInt32(value);
- if ((uint)val > 10)
- return false;
- DynamaxLevel = (byte)val;
- return true;
- }
-
- private bool ParseTeraType(ReadOnlySpan value)
- {
- Context = EntityContext.Gen9;
- var types = Strings.types;
- var val = StringUtil.FindIndexIgnoreCase(types, value);
- if (val < 0)
- return false;
- if (val == TeraTypeUtil.StellarTypeDisplayStringIndex)
- val = TeraTypeUtil.Stellar;
- TeraType = (MoveType)val;
- return true;
- }
-
- ///
- /// Gets the standard Text representation of the set details.
- ///
- public string Text => GetText();
-
- ///
- /// Gets the localized Text representation of the set details.
- ///
- /// Language code
- public string LocalizedText(string lang = DefaultLanguage) => LocalizedText(GameLanguage.GetLanguageIndex(lang));
-
- ///
- /// Gets the localized Text representation of the set details.
- ///
- /// Language ID
- private string LocalizedText(int lang)
- {
- var strings = GameInfo.GetStrings(lang);
- return GetText(strings);
- }
-
- private string GetText(GameStrings? strings = null)
- {
- if (Species is 0 or > MAX_SPECIES)
- return string.Empty;
-
- if (strings is not null)
- Strings = strings;
-
- var result = GetSetLines();
- return string.Join(Environment.NewLine, result);
- }
-
- public List GetSetLines()
- {
- var result = new List();
-
- // First Line: Name, Nickname, Gender, Item
- var form = ShowdownParsing.GetShowdownFormName(Species, FormName);
- result.Add(GetStringFirstLine(form));
-
- // IVs
- var maxIV = Context.Generation() < 3 ? 15 : 31;
- var ivs = GetStringStats(IVs, maxIV);
- if (ivs.Length != 0)
- result.Add($"IVs: {string.Join(" / ", ivs)}");
-
- // EVs
- var evs = GetStringStats(EVs, 0);
- if (evs.Length != 0)
- result.Add($"EVs: {string.Join(" / ", evs)}");
-
- // Secondary Stats
- if ((uint)Ability < Strings.Ability.Count)
- result.Add($"Ability: {Strings.Ability[Ability]}");
- if (Context == EntityContext.Gen9 && TeraType != MoveType.Any)
- {
- if ((uint)TeraType <= TeraTypeUtil.MaxType) // Fairy
- result.Add($"Tera Type: {Strings.Types[(int)TeraType]}");
- else if ((uint)TeraType == TeraTypeUtil.Stellar)
- result.Add($"Tera Type: {Strings.Types[TeraTypeUtil.StellarTypeDisplayStringIndex]}");
- }
-
- if (Level != 100)
- result.Add($"Level: {Level}");
- if (Shiny)
- result.Add("Shiny: Yes");
- if (Context == EntityContext.Gen8 && DynamaxLevel != 10)
- result.Add($"Dynamax Level: {DynamaxLevel}");
- if (Context == EntityContext.Gen8 && CanGigantamax)
- result.Add("Gigantamax: Yes");
-
- if ((uint)Nature < Strings.Natures.Count)
- result.Add($"{Strings.Natures[(byte)Nature]} Nature");
-
- // Moves
- result.AddRange(GetStringMoves());
- return result;
- }
-
- private string GetStringFirstLine(string form)
- {
- string specForm = Strings.Species[Species];
- if (form.Length != 0)
- specForm += $"-{form.Replace("Mega ", "Mega-")}";
- else if (Species == (int)NidoranM)
- specForm = specForm.Replace("♂", "-M");
- else if (Species == (int)NidoranF)
- specForm = specForm.Replace("♀", "-F");
-
- string result = GetSpeciesNickname(specForm);
-
- // omit genderless or nonspecific
- if (Gender is 1)
- result += " (F)";
- else if (Gender is 0)
- result += " (M)";
-
- if (HeldItem > 0)
- {
- var items = Strings.GetItemStrings(Context);
- if ((uint)HeldItem < items.Length)
- result += $" @ {items[HeldItem]}";
- }
- return result;
- }
-
- private string GetSpeciesNickname(string specForm)
- {
- if (Nickname.Length == 0 || Nickname == specForm)
- return specForm;
- bool isNicknamed = SpeciesName.IsNicknamedAnyLanguage(Species, Nickname, Context.Generation());
- if (!isNicknamed)
- return specForm;
- return $"{Nickname} ({specForm})";
- }
-
- public static string[] GetStringStats(ReadOnlySpan stats, T ignoreValue) where T : IEquatable
- {
- var count = stats.Length - stats.Count(ignoreValue);
- if (count == 0)
- return [];
-
- var result = new string[count];
- int ctr = 0;
- for (int i = 0; i < stats.Length; i++)
- {
- var statIndex = GetStatIndexStored(i);
- var statValue = stats[statIndex];
- if (statValue.Equals(ignoreValue))
- continue; // ignore unused stats
- var statName = StatNames[statIndex];
- result[ctr++] = $"{statValue} {statName}";
- }
- return result;
- }
-
- private IEnumerable GetStringMoves()
- {
- var moves = Strings.Move;
- foreach (var move in Moves)
- {
- if (move == 0 || move >= moves.Count)
- continue;
-
- if (move != (int)Move.HiddenPower || HiddenPowerType == -1)
- {
- yield return $"- {moves[move]}";
- continue;
- }
-
- var type = 1 + HiddenPowerType; // skip Normal
- var typeName = Strings.Types[type];
- yield return $"- {moves[move]} [{typeName}]";
- }
- }
-
- private static int GetStatIndexStored(int displayIndex) => displayIndex switch
- {
- 3 => 4,
- 4 => 5,
- 5 => 3,
- _ => displayIndex,
- };
-
- ///
- /// Forces some properties to indicate the set for future display values.
- ///
- /// PKM to convert to string
- public void InterpretAsPreview(PKM pk)
- {
- if (pk.Format <= 2) // Nature preview from IVs
- Nature = Experience.GetNatureVC(pk.EXP);
- }
-
- ///
- /// Converts the data into an importable set format for Pokémon Showdown.
- ///
- /// PKM to convert to string
- /// New ShowdownSet object representing the input
- public ShowdownSet(PKM pk)
- {
- if (pk.Species == 0)
- return;
-
- Context = pk.Context;
-
- Nickname = pk.Nickname;
- Species = pk.Species;
- HeldItem = pk.HeldItem;
- Ability = pk.Ability;
- pk.GetEVs(EVs);
- pk.GetIVs(IVs);
-
- var moves = Moves.AsSpan();
- pk.GetMoves(moves);
- if (moves.Contains((ushort)Move.HiddenPower))
- HiddenPowerType = (sbyte)HiddenPower.GetType(IVs, Context);
-
- Nature = pk.StatNature;
- Gender = pk.Gender < 2 ? pk.Gender : (byte)2;
- Friendship = pk.CurrentFriendship;
- Level = pk.CurrentLevel;
- Shiny = pk.IsShiny;
-
- if (pk is PK8 g) // Only set Gigantamax if it is a PK8
- {
- CanGigantamax = g.CanGigantamax;
- DynamaxLevel = g.DynamaxLevel;
- }
-
- if (pk is ITeraType t)
- TeraType = t.TeraType;
- if (pk is IHyperTrain h)
- {
- for (int i = 0; i < 6; i++)
- {
- if (h.IsHyperTrained(i))
- IVs[i] = pk.MaxIV;
- }
- }
-
- FormName = ShowdownParsing.GetStringFromForm(Form = pk.Form, Strings, Species, Context);
- }
-
- private void ParseFirstLine(ReadOnlySpan first)
- {
- int itemSplit = first.IndexOf(ItemSplit, StringComparison.Ordinal);
- if (itemSplit != -1)
- {
- var itemName = first[(itemSplit + ItemSplit.Length)..];
- var speciesName = first[..itemSplit];
-
- ParseItemName(itemName);
- ParseFirstLineNoItem(speciesName);
- }
- else
- {
- ParseFirstLineNoItem(first);
- }
- }
-
- private void ParseItemName(ReadOnlySpan itemName)
- {
- if (TrySetItem(Context, itemName))
- return;
- if (TrySetItem(EntityContext.Gen3, itemName))
- return;
- if (TrySetItem(EntityContext.Gen2, itemName))
- return;
- InvalidLines.Add($"Unknown Item: {itemName}");
-
- bool TrySetItem(EntityContext context, ReadOnlySpan span)
- {
- var items = Strings.GetItemStrings(context);
- int item = StringUtil.FindIndexIgnoreCase(items, span);
- if (item < 0)
- return false;
- HeldItem = item;
- Context = context;
- return true;
- }
- }
-
- private void ParseFirstLineNoItem(ReadOnlySpan line)
- {
- // Gender Detection
- if (line.EndsWith("(M)", StringComparison.Ordinal))
- {
- line = line[..^3].TrimEnd();
- Gender = 0;
- }
- else if (line.EndsWith("(F)", StringComparison.Ordinal))
- {
- line = line[..^3].TrimEnd();
- Gender = 1;
- }
-
- // Nickname Detection
- if (line.IndexOf('(') != -1 && line.IndexOf(')') != -1)
- ParseSpeciesNickname(line);
- else
- ParseSpeciesForm(line);
- }
-
- private const string Gmax = "-Gmax";
-
- private bool ParseSpeciesForm(ReadOnlySpan speciesLine)
- {
- speciesLine = speciesLine.Trim();
- if (speciesLine.Length == 0)
- return false;
-
- if (speciesLine.EndsWith(Gmax, StringComparison.Ordinal))
- {
- CanGigantamax = true;
- speciesLine = speciesLine[..^Gmax.Length];
- }
-
- var speciesIndex = StringUtil.FindIndexIgnoreCase(Strings.specieslist, speciesLine);
- if (speciesIndex > 0)
- {
- // success, nothing else !
- Species = (ushort)speciesIndex;
- return true;
- }
-
- // Form string present.
- int end = speciesLine.IndexOf('-');
- if (end < 0)
- return false;
-
- speciesIndex = StringUtil.FindIndexIgnoreCase(Strings.specieslist, speciesLine[..end]);
- if (speciesIndex > 0)
- {
- Species = (ushort)speciesIndex;
- FormName = speciesLine[(end + 1)..].ToString();
- return true;
- }
-
- // failure to parse, check edge cases
- foreach (var e in DashedSpecies)
- {
- var sn = Strings.Species[e];
- if (!speciesLine.StartsWith(sn.Replace("♂", "-M").Replace("♀", "-F"), StringComparison.Ordinal))
- continue;
- Species = e;
- FormName = speciesLine[sn.Length..].ToString();
- return true;
- }
-
- // Version Megas
- end = speciesLine[Math.Max(0, end - 1)..].LastIndexOf('-');
- if (end < 0)
- return false;
-
- speciesIndex = StringUtil.FindIndexIgnoreCase(Strings.specieslist, speciesLine[..end]);
- if (speciesIndex > 0)
- {
- Species = (ushort)speciesIndex;
- FormName = speciesLine[(end + 1)..].ToString();
- return true;
- }
- return false;
- }
-
- private void ParseSpeciesNickname(ReadOnlySpan line)
- {
- // Entering into this method requires both ( and ) to be present within the input line.
- int index = line.LastIndexOf('(');
- ReadOnlySpan species;
- ReadOnlySpan nickname;
- if (index > 1) // parenthesis value after: Nickname (Species), correct.
- {
- nickname = line[..index].TrimEnd();
- species = line[(index + 1)..];
- if (species.Length != 0 && species[^1] == ')')
- species = species[..^1];
- }
- else // parenthesis value before: (Species) Nickname, incorrect
- {
- int start = index + 1;
- int end = line.LastIndexOf(')');
- var tmp = line[start..end];
- if (end < line.Length - 2)
- {
- nickname = line[(end + 2)..];
- species = tmp;
- }
- else // (Species), or garbage
- {
- species = tmp;
- nickname = [];
- }
- }
-
- if (ParseSpeciesForm(species))
- Nickname = nickname.ToString();
- else if (ParseSpeciesForm(nickname))
- Nickname = species.ToString();
- }
-
- private ReadOnlySpan ParseLineMove(ReadOnlySpan line)
- {
- var startSearch = line[1] == ' ' ? 2 : 1;
- var option = line.IndexOf('/');
- line = option != -1 ? line[startSearch..option] : line[startSearch..];
-
- var moveString = line.Trim();
-
- var hiddenPowerName = Strings.Move[(int)Move.HiddenPower];
- if (!moveString.StartsWith(hiddenPowerName, StringComparison.OrdinalIgnoreCase))
- return moveString; // regular move
-
- if (moveString.Length == hiddenPowerName.Length)
- return hiddenPowerName;
-
- // Defined Hidden Power
- var type = GetHiddenPowerType(moveString[(hiddenPowerName.Length + 1)..]);
- var types = Strings.types.AsSpan(1, HiddenPower.TypeCount);
- int hpVal = StringUtil.FindIndexIgnoreCase(types, type); // Get HP Type
- if (hpVal == -1)
- return hiddenPowerName;
-
- HiddenPowerType = (sbyte)hpVal;
- if (IVs.AsSpan().ContainsAnyExcept(31))
- {
- if (!HiddenPower.SetIVsForType(hpVal, IVs, Context))
- InvalidLines.Add($"Invalid IVs for Hidden Power Type: {type}");
- }
- else if (hpVal >= 0)
- {
- HiddenPower.SetIVs(hpVal, IVs, Context); // Alter IVs
- }
- else
- {
- InvalidLines.Add($"Invalid Hidden Power Type: {type}");
- }
- return hiddenPowerName;
- }
-
- private static ReadOnlySpan GetHiddenPowerType(ReadOnlySpan line)
- {
- var type = line.Trim();
- if (type.Length == 0)
- return type;
-
- if (type[0] == '(' && type[^1] == ')')
- return type[1..^1].Trim();
- if (type[0] == '[' && type[^1] == ']')
- return type[1..^1].Trim();
-
- return type;
- }
-
- private bool ParseLineEVs(ReadOnlySpan line)
- {
- int start = 0;
- while (true)
- {
- var chunk = line[start..];
- var separator = chunk.IndexOf('/');
- var len = separator == -1 ? chunk.Length : separator;
- var tuple = chunk[..len].Trim();
- if (!AbsorbValue(tuple))
- InvalidLines.Add($"Invalid EV tuple: {tuple}");
- if (separator == -1)
- break; // no more stats
- start += separator + 1;
- }
- return true;
-
- bool AbsorbValue(ReadOnlySpan text)
- {
- var space = text.IndexOf(' ');
- if (space == -1)
- return false;
- var stat = text[(space + 1)..].Trim();
- var statIndex = StringUtil.FindIndexIgnoreCase(StatNames, stat);
- if (statIndex == -1)
- return false;
- var value = text[..space].Trim();
- if (!ushort.TryParse(value, out var statValue))
- return false;
- EVs[statIndex] = statValue;
- return true;
- }
- }
-
- private bool ParseLineIVs(ReadOnlySpan line)
- {
- int start = 0;
- while (true)
- {
- var chunk = line[start..];
- var separator = chunk.IndexOf('/');
- var len = separator == -1 ? chunk.Length : separator;
- var tuple = chunk[..len].Trim();
- if (!AbsorbValue(tuple))
- InvalidLines.Add($"Invalid IV tuple: {tuple}");
- if (separator == -1)
- break; // no more stats
- start += separator + 1;
- }
- return true;
-
- bool AbsorbValue(ReadOnlySpan text)
- {
- var space = text.IndexOf(' ');
- if (space == -1)
- return false;
- var stat = text[(space + 1)..].Trim();
- var statIndex = StringUtil.FindIndexIgnoreCase(StatNames, stat);
- if (statIndex == -1)
- return false;
- var value = text[..space].Trim();
- if (!byte.TryParse(value, out var statValue))
- return false;
- IVs[statIndex] = statValue;
- return true;
- }
- }
-}
diff --git a/PKHeX.Core/Game/Enums/LanguageGC.cs b/PKHeX.Core/Game/Enums/LanguageGC.cs
index 3b7c3a825..788edcd77 100644
--- a/PKHeX.Core/Game/Enums/LanguageGC.cs
+++ b/PKHeX.Core/Game/Enums/LanguageGC.cs
@@ -58,7 +58,7 @@ public static class LanguageGCRemap
///
public static LanguageID ToLanguageID(this LanguageGC lang) => lang switch
{
- LanguageGC.Hacked => LanguageID.Hacked,
+ LanguageGC.Hacked => LanguageID.None,
LanguageGC.Japanese => LanguageID.Japanese,
LanguageGC.English => LanguageID.English,
LanguageGC.German => LanguageID.German,
@@ -73,7 +73,7 @@ public static class LanguageGCRemap
///
public static LanguageGC ToLanguageGC(this LanguageID lang) => lang switch
{
- LanguageID.Hacked => LanguageGC.Hacked,
+ LanguageID.None => LanguageGC.Hacked,
LanguageID.Japanese => LanguageGC.Japanese,
LanguageID.English => LanguageGC.English,
LanguageID.German => LanguageGC.German,
diff --git a/PKHeX.Core/Game/Enums/LanguageID.cs b/PKHeX.Core/Game/Enums/LanguageID.cs
index 741b7247b..d40a84e8d 100644
--- a/PKHeX.Core/Game/Enums/LanguageID.cs
+++ b/PKHeX.Core/Game/Enums/LanguageID.cs
@@ -1,4 +1,4 @@
-namespace PKHeX.Core;
+namespace PKHeX.Core;
///
/// Contiguous series Game Language IDs
@@ -9,7 +9,7 @@ public enum LanguageID : byte
/// Undefined Language ID, usually indicative of a value not being set.
///
/// Gen5 Japanese In-game Trades happen to not have their Language value set, and express Language=0.
- Hacked = 0,
+ None = 0,
///
/// Japanese (日本語)
diff --git a/PKHeX.Core/Game/GameStrings/GameLanguage.cs b/PKHeX.Core/Game/GameStrings/GameLanguage.cs
index 3ee29fe61..1d9fb721d 100644
--- a/PKHeX.Core/Game/GameStrings/GameLanguage.cs
+++ b/PKHeX.Core/Game/GameStrings/GameLanguage.cs
@@ -35,6 +35,8 @@ public static int GetLanguageIndex(string lang)
/// Language codes supported for loading string resources
///
///
+ public static ReadOnlySpan AllSupportedLanguages => LanguageCodes;
+
private static readonly string[] LanguageCodes = ["ja", "en", "fr", "it", "de", "es", "ko", "zh-Hans", "zh-Hant"];
///
diff --git a/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterGift1.cs b/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterGift1.cs
index 9dcc4d468..cfc91688f 100644
--- a/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterGift1.cs
+++ b/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterGift1.cs
@@ -196,7 +196,7 @@ private LanguageID GetLanguage(LanguageID request)
if (Language == LanguageRestriction.International && request is not (English or French or Italian or German or Spanish))
return English;
- if (request is Hacked or UNUSED_6 or >= Korean)
+ if (request is None or UNUSED_6 or >= Korean)
return English;
return request;
}
diff --git a/PKHeX.Core/Legality/Encounters/Templates/Gen2/EncounterGift2.cs b/PKHeX.Core/Legality/Encounters/Templates/Gen2/EncounterGift2.cs
index 02269af08..18ac02786 100644
--- a/PKHeX.Core/Legality/Encounters/Templates/Gen2/EncounterGift2.cs
+++ b/PKHeX.Core/Legality/Encounters/Templates/Gen2/EncounterGift2.cs
@@ -162,7 +162,7 @@ private LanguageID GetLanguage(LanguageID request)
if (Language == LanguageRestriction.InternationalNotEnglish && request is not (French or Italian or German or Spanish))
return French;
- if (request is Hacked or UNUSED_6 or >= Korean)
+ if (request is None or UNUSED_6 or >= Korean)
return English;
return request;
}
diff --git a/PKHeX.Core/Legality/Encounters/Templates/Gen3/Gifts/EncounterGift3.cs b/PKHeX.Core/Legality/Encounters/Templates/Gen3/Gifts/EncounterGift3.cs
index 51602f87c..77f483381 100644
--- a/PKHeX.Core/Legality/Encounters/Templates/Gen3/Gifts/EncounterGift3.cs
+++ b/PKHeX.Core/Legality/Encounters/Templates/Gen3/Gifts/EncounterGift3.cs
@@ -332,7 +332,7 @@ private LanguageID GetSafeLanguageNotEgg(LanguageID language)
{
if (Language != 0)
return (LanguageID) Language;
- if (language < LanguageID.Korean && language != LanguageID.Hacked)
+ if (language < LanguageID.Korean && language != LanguageID.None)
{
if (Language == 0 && language is not LanguageID.Japanese)
return language;
diff --git a/PKHeX.Core/Legality/Verifiers/LanguageVerifier.cs b/PKHeX.Core/Legality/Verifiers/LanguageVerifier.cs
index c55f23811..a3c8e187b 100644
--- a/PKHeX.Core/Legality/Verifiers/LanguageVerifier.cs
+++ b/PKHeX.Core/Legality/Verifiers/LanguageVerifier.cs
@@ -54,7 +54,7 @@ public static bool IsValidLanguageID(int currentLanguage, int maxLanguageID, PKM
if (currentLanguage > maxLanguageID)
return false; // Language not available (yet)
- if (currentLanguage <= (int)LanguageID.Hacked && !(enc is EncounterTrade5BW && EncounterTrade5BW.IsValidMissingLanguage(pk)))
+ if (currentLanguage <= (int)LanguageID.None && !(enc is EncounterTrade5BW && EncounterTrade5BW.IsValidMissingLanguage(pk)))
return false; // Missing Language value is not obtainable
return true; // Language is possible
diff --git a/PKHeX.Core/PKM/Util/Language.cs b/PKHeX.Core/PKM/Util/Language.cs
index 36f53347d..3c1132f1d 100644
--- a/PKHeX.Core/PKM/Util/Language.cs
+++ b/PKHeX.Core/PKM/Util/Language.cs
@@ -80,9 +80,29 @@ public static class Language
Korean => "ko",
ChineseS => "zh-Hans",
ChineseT => "zh-Hant",
+ English => "en",
_ => GameLanguage.DefaultLanguage,
};
+ ///
+ /// Gets the value from a language code.
+ ///
+ /// Language code.
+ /// Language ID.
+ public static LanguageID GetLanguageValue(string language) => language switch
+ {
+ "ja" => Japanese,
+ "fr" => French,
+ "it" => Italian,
+ "de" => German,
+ "es" => Spanish,
+ "ko" => Korean,
+ "zh-Hans" => ChineseS,
+ "zh-Hant" => ChineseT,
+ "en" => English,
+ _ => GetLanguageValue(GameLanguage.DefaultLanguage),
+ };
+
///
/// Gets the Main Series language ID from a GameCube (C/XD) language ID.
///
diff --git a/PKHeX.Core/Resources/config/battle_de.json b/PKHeX.Core/Resources/config/battle_de.json
new file mode 100644
index 000000000..c4b0ed792
--- /dev/null
+++ b/PKHeX.Core/Resources/config/battle_de.json
@@ -0,0 +1,35 @@
+{
+ "StatNames": {
+ "Names": ["KP", "Ang", "Vert", "Init", "SpA", "SpV"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "StatNamesFull": {
+ "Names": ["KP", "Angriff", "Verteidigung", "Initiative", "Sp. Angriff", "Sp. Verteidigung"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "Male": "Männlich",
+ "Female": "Weiblich",
+ "Left": [
+ { "Token": "Friendship", "Text": "Freundschaft: " },
+ { "Token": "EVs", "Text": "EVs: " },
+ { "Token": "IVs", "Text": "DVs: " },
+ { "Token": "AVs", "Text": "AVs: " },
+ { "Token": "GVs", "Text": "GVs: " },
+ { "Token": "Level", "Text": "Level: " },
+ { "Token": "Ability", "Text": "Fähigkeit: " },
+ { "Token": "DynamaxLevel", "Text": "Dynamax Level: " },
+ { "Token": "TeraType", "Text": "Tera-Typ: " },
+ { "Token": "Gender", "Text": "Geschlecht: "},
+ { "Token": "Nickname", "Text": "Spitzname: "},
+ { "Token": "HeldItem", "Text": "Item: "}
+ ],
+ "Right": [
+ { "Token": "Nature", "Text": " Wesen" }
+ ],
+ "Center": [
+ { "Token": "Shiny", "Text": "Schillerndes: Ja" },
+ { "Token": "Gigantamax", "Text": "Gigadynamax: Ja" }
+ ]
+}
diff --git a/PKHeX.Core/Resources/config/battle_en.json b/PKHeX.Core/Resources/config/battle_en.json
new file mode 100644
index 000000000..c296291c6
--- /dev/null
+++ b/PKHeX.Core/Resources/config/battle_en.json
@@ -0,0 +1,35 @@
+{
+ "StatNames": {
+ "Names": ["HP", "Atk", "Def", "SpA", "SpD", "Spe"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "StatNamesFull": {
+ "Names": ["HP", "Attack", "Defense", "Sp. Atk", "Sp. Def", "Speed"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "Male": "Male",
+ "Female": "Female",
+ "Left": [
+ { "Token": "Friendship", "Text": "Friendship: " },
+ { "Token": "EVs", "Text": "EVs: " },
+ { "Token": "IVs", "Text": "IVs: " },
+ { "Token": "AVs", "Text": "AVs: " },
+ { "Token": "GVs", "Text": "GVs: " },
+ { "Token": "Level", "Text": "Level: " },
+ { "Token": "Ability", "Text": "Ability: " },
+ { "Token": "DynamaxLevel", "Text": "Dynamax Level: " },
+ { "Token": "TeraType", "Text": "Tera Type: " },
+ { "Token": "Gender", "Text": "Gender: "},
+ { "Token": "Nickname", "Text": "Nickname: "},
+ { "Token": "HeldItem", "Text": "Held Item: "}
+ ],
+ "Right": [
+ { "Token": "Nature", "Text": " Nature" }
+ ],
+ "Center": [
+ { "Token": "Shiny", "Text": "Shiny: Yes" },
+ { "Token": "Gigantamax", "Text": "Gigantamax: Yes" }
+ ]
+}
diff --git a/PKHeX.Core/Resources/config/battle_es.json b/PKHeX.Core/Resources/config/battle_es.json
new file mode 100644
index 000000000..8103efe71
--- /dev/null
+++ b/PKHeX.Core/Resources/config/battle_es.json
@@ -0,0 +1,35 @@
+{
+ "StatNames": {
+ "Names": ["PS", "Atq", "Def", "AtS", "DeS", "Vel"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "StatNamesFull": {
+ "Names": ["PS", "Ataque", "Defensa", "Atq. Esp.", "Def. Esp.", "Velocidad"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "Male": "Masculino",
+ "Female": "Femenina",
+ "Left": [
+ { "Token": "Friendship", "Text": "Felicidad: " },
+ { "Token": "EVs", "Text": "EVs: " },
+ { "Token": "IVs", "Text": "IVs: " },
+ { "Token": "AVs", "Text": "AVs: " },
+ { "Token": "GVs", "Text": "GVs: " },
+ { "Token": "Level", "Text": "Nivel: " },
+ { "Token": "Ability", "Text": "Habilidad: " },
+ { "Token": "DynamaxLevel", "Text": "Nivel Dinamax: " },
+ { "Token": "TeraType", "Text": "Teratipo: " },
+ { "Token": "Nature", "Text": "Naturaleza " },
+ { "Token": "Gender", "Text": "Género: "},
+ { "Token": "Nickname", "Text": "Mote: "},
+ { "Token": "HeldItem", "Text": "Objeto Equipado: "}
+ ],
+ "Right": [
+ ],
+ "Center": [
+ { "Token": "Shiny", "Text": "Shiny: Sí" },
+ { "Token": "Gigantamax", "Text": "Gigamax: Sí" }
+ ]
+}
diff --git a/PKHeX.Core/Resources/config/battle_fr.json b/PKHeX.Core/Resources/config/battle_fr.json
new file mode 100644
index 000000000..6ecc607ad
--- /dev/null
+++ b/PKHeX.Core/Resources/config/battle_fr.json
@@ -0,0 +1,35 @@
+{
+ "StatNames": {
+ "Names": ["PV", "Atq", "Def", "AtS", "DeS", "Vit"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "StatNamesFull": {
+ "Names": ["PV", "Attaque", "Défense", "Atq. Spé.", "Déf. Spé.", "Vitesse"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "Male": "Homme",
+ "Female": "Femme",
+ "Left": [
+ { "Token": "Friendship", "Text": "Bonheur : " },
+ { "Token": "EVs", "Text": "EVs : " },
+ { "Token": "IVs", "Text": "IVs : " },
+ { "Token": "AVs", "Text": "AVs : " },
+ { "Token": "GVs", "Text": "GVs : " },
+ { "Token": "Level", "Text": "Niveau : " },
+ { "Token": "Ability", "Text": "Talent : " },
+ { "Token": "DynamaxLevel", "Text": "Niveau Dynamax : " },
+ { "Token": "TeraType", "Text": "Type Téra : " },
+ { "Token": "Nature", "Text": "Nature " },
+ { "Token": "Gender", "Text": "Gender : "},
+ { "Token": "Nickname", "Text": "Surnom : "},
+ { "Token": "HeldItem", "Text": "Objet : "}
+ ],
+ "Right": [
+ ],
+ "Center": [
+ { "Token": "Shiny", "Text": "Chromatique: Oui" },
+ { "Token": "Gigantamax", "Text": "Gigamax: Oui" }
+ ]
+}
diff --git a/PKHeX.Core/Resources/config/battle_it.json b/PKHeX.Core/Resources/config/battle_it.json
new file mode 100644
index 000000000..ae10c3df5
--- /dev/null
+++ b/PKHeX.Core/Resources/config/battle_it.json
@@ -0,0 +1,35 @@
+{
+ "StatNames": {
+ "Names": ["PS", "Att", "Dif", "AtS", "DiS", "Vel"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "StatNamesFull": {
+ "Names": ["PS", "Attacco", "Difesa", "Attacco Sp.", "Difesa Sp.", "Velocità"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "Male": "Maschio",
+ "Female": "Femmina",
+ "Left": [
+ { "Token": "Friendship", "Text": "Amicizia: " },
+ { "Token": "EVs", "Text": "EVs: " },
+ { "Token": "IVs", "Text": "IVs: " },
+ { "Token": "AVs", "Text": "AVs: " },
+ { "Token": "GVs", "Text": "GVs: " },
+ { "Token": "Level", "Text": "Livello: " },
+ { "Token": "Ability", "Text": "Abilità: " },
+ { "Token": "DynamaxLevel", "Text": "Livello Dynamax: " },
+ { "Token": "TeraType", "Text": "Teratipo: " },
+ { "Token": "Nature", "Text": "Natura " },
+ { "Token": "Gender", "Text": "Genere: "},
+ { "Token": "Nickname", "Text": "Soprannome: "},
+ { "Token": "HeldItem", "Text": "Strumento tenuto: "}
+ ],
+ "Right": [
+ ],
+ "Center": [
+ { "Token": "Shiny", "Text": "Cromatico: Si" },
+ { "Token": "Gigantamax", "Text": "Gigamax: Si" }
+ ]
+}
diff --git a/PKHeX.Core/Resources/config/battle_ja.json b/PKHeX.Core/Resources/config/battle_ja.json
new file mode 100644
index 000000000..392b34ee4
--- /dev/null
+++ b/PKHeX.Core/Resources/config/battle_ja.json
@@ -0,0 +1,35 @@
+{
+ "StatNames": {
+ "Names": ["HP", "攻撃", "防御", "特攻", "特防", "素早さ"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "StatNamesFull": {
+ "Names": ["HP", "攻撃", "防御", "特攻", "特防", "素早さ"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "Male": "男性",
+ "Female": "女性",
+ "Left": [
+ { "Token": "Friendship", "Text": "なつき度 " },
+ { "Token": "EVs", "Text": "努力値 " },
+ { "Token": "IVs", "Text": "個体値 " },
+ { "Token": "AVs", "Text": "AVs " },
+ { "Token": "GVs", "Text": "頑張る " },
+ { "Token": "Level", "Text": "Lv " },
+ { "Token": "Ability", "Text": "特性 " },
+ { "Token": "DynamaxLevel", "Text": "ダイマックスレベル " },
+ { "Token": "TeraType", "Text": "テラスタイプ " },
+ { "Token": "Gender", "Text": "性別 "},
+ { "Token": "Nickname", "Text": "ニックネーム "},
+ { "Token": "HeldItem", "Text": "持ち物 "}
+ ],
+ "Right": [
+ { "Token": "Nature", "Text": "性格" }
+ ],
+ "Center": [
+ { "Token": "Shiny", "Text": "光ひかる: はい" },
+ { "Token": "Gigantamax", "Text": "キョダイマックス: はい" }
+ ]
+}
diff --git a/PKHeX.Core/Resources/config/battle_ko.json b/PKHeX.Core/Resources/config/battle_ko.json
new file mode 100644
index 000000000..6fa539be7
--- /dev/null
+++ b/PKHeX.Core/Resources/config/battle_ko.json
@@ -0,0 +1,35 @@
+{
+ "StatNames": {
+ "Names": ["HP", "공격", "방어", "특공", "특방", "스피드"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "StatNamesFull": {
+ "Names": ["HP", "공격", "방어", "특공", "특방", "스피드"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "Male": "남성",
+ "Female": "여성",
+ "Left": [
+ { "Token": "Friendship", "Text": "친밀도 " },
+ { "Token": "EVs", "Text": "EVs: " },
+ { "Token": "IVs", "Text": "IVs: " },
+ { "Token": "AVs", "Text": "AVs: " },
+ { "Token": "GVs", "Text": "노력 레벨을 " },
+ { "Token": "Level", "Text": "스피드 " },
+ { "Token": "Ability", "Text": "특성 " },
+ { "Token": "DynamaxLevel", "Text": "다이맥스 레벨 " },
+ { "Token": "TeraType", "Text": "테라스탈타입 " },
+ { "Token": "Gender", "Text": "성별 "},
+ { "Token": "Nickname", "Text": "이름 "},
+ { "Token": "HeldItem", "Text": "持ち物 "}
+ ],
+ "Right": [
+ { "Token": "Nature", "Text": "성격" }
+ ],
+ "Center": [
+ { "Token": "Shiny", "Text": "빛나는: 예" },
+ { "Token": "Gigantamax", "Text": "거다이맥스: 예" }
+ ]
+}
diff --git a/PKHeX.Core/Resources/config/battle_zh-hans.json b/PKHeX.Core/Resources/config/battle_zh-hans.json
new file mode 100644
index 000000000..771fee55e
--- /dev/null
+++ b/PKHeX.Core/Resources/config/battle_zh-hans.json
@@ -0,0 +1,35 @@
+{
+ "StatNames": {
+ "Names": ["HP", "攻击", "防御", "特攻", "特防", "速度"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "StatNamesFull": {
+ "Names": ["HP", "攻击", "防御", "特攻", "特防", "速度"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "Male": "男",
+ "Female": "女",
+ "Left": [
+ { "Token": "Friendship", "Text": "亲密度: " },
+ { "Token": "EVs", "Text": "努力值: " },
+ { "Token": "IVs", "Text": "个体值: " },
+ { "Token": "AVs", "Text": "AVs " },
+ { "Token": "GVs", "Text": "奋斗等级 " },
+ { "Token": "Level", "Text": "等级: " },
+ { "Token": "Ability", "Text": "特性: " },
+ { "Token": "DynamaxLevel", "Text": "极巨等级: " },
+ { "Token": "TeraType", "Text": "太晶属性: " },
+ { "Token": "Nature", "Text": "性格: " },
+ { "Token": "Gender", "Text": "性别: "},
+ { "Token": "Nickname", "Text": "昵称: "},
+ { "Token": "HeldItem", "Text": "持有物: "}
+ ],
+ "Right": [
+ ],
+ "Center": [
+ { "Token": "Shiny", "Text": "發光寶: 是的" },
+ { "Token": "Gigantamax", "Text": "超极巨: 是的" }
+ ]
+}
diff --git a/PKHeX.Core/Resources/config/battle_zh-hant.json b/PKHeX.Core/Resources/config/battle_zh-hant.json
new file mode 100644
index 000000000..672b56a5f
--- /dev/null
+++ b/PKHeX.Core/Resources/config/battle_zh-hant.json
@@ -0,0 +1,35 @@
+{
+ "StatNames": {
+ "Names": ["HP", "攻擊", "防禦", "特攻", "特防", "速度"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "StatNamesFull": {
+ "Names": ["HP", "攻擊", "防禦", "特攻", "特防", "速度"],
+ "ValueGap": " ",
+ "Separator": " / "
+ },
+ "Male": "男",
+ "Female": "女",
+ "Left": [
+ { "Token": "Friendship", "Text": "親密度: " },
+ { "Token": "EVs", "Text": "努力值: " },
+ { "Token": "IVs", "Text": "個體值: " },
+ { "Token": "AVs", "Text": "AVs: " },
+ { "Token": "GVs", "Text": "奋斗等级: " },
+ { "Token": "Level", "Text": "等級: " },
+ { "Token": "Ability", "Text": "特性: " },
+ { "Token": "DynamaxLevel", "Text": "極巨化等級: " },
+ { "Token": "TeraType", "Text": "太晶屬性: " },
+ { "Token": "Nature", "Text": "性格: " },
+ { "Token": "Gender", "Text": "性別: "},
+ { "Token": "Nickname", "Text": "昵稱: "},
+ { "Token": "HeldItem", "Text": "携帶物品: "}
+ ],
+ "Right": [
+ ],
+ "Center": [
+ { "Token": "Shiny", "Text": "發光寶: 是的" },
+ { "Token": "Gigantamax", "Text": "超極巨: 是的" }
+ ]
+}
diff --git a/PKHeX.Core/Util/EmbeddedResourceCache.cs b/PKHeX.Core/Util/EmbeddedResourceCache.cs
index d0640c986..a623b3410 100644
--- a/PKHeX.Core/Util/EmbeddedResourceCache.cs
+++ b/PKHeX.Core/Util/EmbeddedResourceCache.cs
@@ -38,7 +38,9 @@ public EmbeddedResourceCache(Assembly assembly)
private static string GetFileName(string resName)
{
- var period = resName.LastIndexOf('.', resName.Length - 5);
+ var period = resName.LastIndexOf('.');
+ if (resName.Length - period <= 5)
+ period = resName.LastIndexOf('.', period - 1);
var start = period + 1;
System.Diagnostics.Debug.Assert(start != 0); // should have a period in the name
diff --git a/PKHeX.WinForms/Controls/PKM Editor/MoveDisplay.cs b/PKHeX.WinForms/Controls/PKM Editor/MoveDisplay.cs
index 2850c49d9..b2484914f 100644
--- a/PKHeX.WinForms/Controls/PKM Editor/MoveDisplay.cs
+++ b/PKHeX.WinForms/Controls/PKM Editor/MoveDisplay.cs
@@ -10,7 +10,7 @@ public partial class MoveDisplay : UserControl
{
public MoveDisplay() => InitializeComponent();
- public int Populate(PKM pk, ushort move, EntityContext context, ReadOnlySpan moves, bool valid = true)
+ public int Populate(PKM pk, GameStrings strings, ushort move, EntityContext context, ReadOnlySpan moves, bool valid = true)
{
if (move == 0 || move >= moves.Length)
{
@@ -24,7 +24,7 @@ public int Populate(PKM pk, ushort move, EntityContext context, ReadOnlySpan> fetch)
{
var list = fetch(sav);
- var result = ShowdownParsing.GetShowdownSets(list, Environment.NewLine + Environment.NewLine);
+ var programLanguage = Language.GetLanguageValue(Main.Settings.Startup.Language);
+ var settings = Main.Settings.BattleTemplate.Export.GetSettings(programLanguage, sav.Context);
+ var result = ShowdownParsing.GetShowdownSets(list, Environment.NewLine + Environment.NewLine, settings);
if (string.IsNullOrWhiteSpace(result))
return;
if (WinFormsUtil.SetClipboardText(result))
diff --git a/PKHeX.WinForms/Controls/Slots/PokePreview.Designer.cs b/PKHeX.WinForms/Controls/Slots/PokePreview.Designer.cs
index 51911e453..c2e2ec998 100644
--- a/PKHeX.WinForms/Controls/Slots/PokePreview.Designer.cs
+++ b/PKHeX.WinForms/Controls/Slots/PokePreview.Designer.cs
@@ -30,13 +30,13 @@ private void InitializeComponent()
{
PAN_All = new System.Windows.Forms.Panel();
FLP_List = new System.Windows.Forms.FlowLayoutPanel();
- L_Stats = new System.Windows.Forms.Label();
+ L_LinesBeforeMoves = new System.Windows.Forms.Label();
FLP_Moves = new System.Windows.Forms.FlowLayoutPanel();
Move1 = new MoveDisplay();
Move2 = new MoveDisplay();
Move3 = new MoveDisplay();
Move4 = new MoveDisplay();
- L_Etc = new System.Windows.Forms.Label();
+ L_LinesAfterMoves = new System.Windows.Forms.Label();
PAN_Top = new System.Windows.Forms.Panel();
FLP_Top = new System.Windows.Forms.FlowLayoutPanel();
PB_Ball = new System.Windows.Forms.PictureBox();
@@ -68,9 +68,9 @@ private void InitializeComponent()
//
FLP_List.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
FLP_List.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
- FLP_List.Controls.Add(L_Stats);
+ FLP_List.Controls.Add(L_LinesBeforeMoves);
FLP_List.Controls.Add(FLP_Moves);
- FLP_List.Controls.Add(L_Etc);
+ FLP_List.Controls.Add(L_LinesAfterMoves);
FLP_List.FlowDirection = System.Windows.Forms.FlowDirection.TopDown;
FLP_List.Location = new System.Drawing.Point(0, 34);
FLP_List.Margin = new System.Windows.Forms.Padding(0);
@@ -79,15 +79,15 @@ private void InitializeComponent()
FLP_List.TabIndex = 1;
FLP_List.WrapContents = false;
//
- // L_Stats
+ // L_LinesBeforeMoves
//
- L_Stats.AutoSize = true;
- L_Stats.Location = new System.Drawing.Point(2, 4);
- L_Stats.Margin = new System.Windows.Forms.Padding(2, 4, 0, 0);
- L_Stats.Name = "L_Stats";
- L_Stats.Size = new System.Drawing.Size(32, 15);
- L_Stats.TabIndex = 5;
- L_Stats.Text = "Stats";
+ L_LinesBeforeMoves.AutoSize = true;
+ L_LinesBeforeMoves.Location = new System.Drawing.Point(2, 4);
+ L_LinesBeforeMoves.Margin = new System.Windows.Forms.Padding(2, 4, 0, 0);
+ L_LinesBeforeMoves.Name = "L_LinesBeforeMoves";
+ L_LinesBeforeMoves.Size = new System.Drawing.Size(36, 17);
+ L_LinesBeforeMoves.TabIndex = 5;
+ L_LinesBeforeMoves.Text = "Stats";
//
// FLP_Moves
//
@@ -100,7 +100,7 @@ private void InitializeComponent()
FLP_Moves.Controls.Add(Move4);
FLP_List.SetFlowBreak(FLP_Moves, true);
FLP_Moves.FlowDirection = System.Windows.Forms.FlowDirection.TopDown;
- FLP_Moves.Location = new System.Drawing.Point(0, 23);
+ FLP_Moves.Location = new System.Drawing.Point(0, 25);
FLP_Moves.Margin = new System.Windows.Forms.Padding(0, 4, 0, 4);
FLP_Moves.Name = "FLP_Moves";
FLP_Moves.Size = new System.Drawing.Size(142, 96);
@@ -150,15 +150,15 @@ private void InitializeComponent()
Move4.Size = new System.Drawing.Size(138, 24);
Move4.TabIndex = 4;
//
- // L_Etc
+ // L_LinesAfterMoves
//
- L_Etc.AutoSize = true;
- L_Etc.Location = new System.Drawing.Point(2, 123);
- L_Etc.Margin = new System.Windows.Forms.Padding(2, 0, 0, 4);
- L_Etc.Name = "L_Etc";
- L_Etc.Size = new System.Drawing.Size(28, 15);
- L_Etc.TabIndex = 6;
- L_Etc.Text = "Info";
+ L_LinesAfterMoves.AutoSize = true;
+ L_LinesAfterMoves.Location = new System.Drawing.Point(2, 125);
+ L_LinesAfterMoves.Margin = new System.Windows.Forms.Padding(2, 0, 0, 4);
+ L_LinesAfterMoves.Name = "L_LinesAfterMoves";
+ L_LinesAfterMoves.Size = new System.Drawing.Size(30, 17);
+ L_LinesAfterMoves.TabIndex = 6;
+ L_LinesAfterMoves.Text = "Info";
//
// PAN_Top
//
@@ -243,7 +243,7 @@ private void InitializeComponent()
private System.Windows.Forms.Panel PAN_All;
private System.Windows.Forms.FlowLayoutPanel FLP_List;
- private System.Windows.Forms.Label L_Stats;
+ private System.Windows.Forms.Label L_LinesBeforeMoves;
private System.Windows.Forms.Panel PAN_Top;
private System.Windows.Forms.Label L_Name;
private System.Windows.Forms.PictureBox PB_Ball;
@@ -252,7 +252,7 @@ private void InitializeComponent()
private MoveDisplay Move2;
private MoveDisplay Move3;
private MoveDisplay Move4;
- private System.Windows.Forms.Label L_Etc;
+ private System.Windows.Forms.Label L_LinesAfterMoves;
private System.Windows.Forms.FlowLayoutPanel FLP_Moves;
private System.Windows.Forms.FlowLayoutPanel FLP_Top;
}
diff --git a/PKHeX.WinForms/Controls/Slots/PokePreview.cs b/PKHeX.WinForms/Controls/Slots/PokePreview.cs
index 35eb32224..f354d98eb 100644
--- a/PKHeX.WinForms/Controls/Slots/PokePreview.cs
+++ b/PKHeX.WinForms/Controls/Slots/PokePreview.cs
@@ -1,7 +1,5 @@
using System;
-using System.Diagnostics.CodeAnalysis;
using System.Drawing;
-using System.Text;
using System.Windows.Forms;
using PKHeX.Core;
@@ -30,17 +28,17 @@ public PokePreview()
Properties.Resources.gender_2,
];
- public void Populate(PKM pk)
+ public void Populate(PKM pk, in BattleTemplateExportSettings settings)
{
var la = new LegalityAnalysis(pk);
- int width = PopulateHeader(pk);
- PopulateMoves(pk, la, ref width);
- PopulateText(pk, la, width);
+ int width = PopulateHeader(pk, settings);
+ PopulateMoves(pk, la, settings, ref width);
+ PopulateText(pk, la, settings, width);
}
- private int PopulateHeader(PKM pk)
+ private int PopulateHeader(PKM pk, in BattleTemplateExportSettings settings)
{
- var name = GetNameTitle(pk);
+ var name = GetNameTitle(pk, settings);
var size = MeasureSize(name, L_Name.Font);
L_Name.Width = Math.Max(InitialNameWidth, size.Width);
L_Name.Text = name;
@@ -52,14 +50,19 @@ private int PopulateHeader(PKM pk)
return Math.Max(InitialWidth, width);
}
- private static string GetNameTitle(PKM pk)
+ private static string GetNameTitle(PKM pk, in BattleTemplateExportSettings settings)
{
+ // Don't care about form; the user will be able to see the sprite next to the preview.
var nick = pk.Nickname;
- var all = GameInfo.Strings.Species;
+ var strings = settings.Localization.Strings;
+ var all = strings.Species;
var species = pk.Species;
if (species >= all.Count)
return nick;
var expect = all[species];
+ if (settings.IsTokenInExport(BattleTemplateToken.Nickname))
+ return expect; // Nickname will be on another line.
+
if (nick.Equals(expect, StringComparison.OrdinalIgnoreCase))
return nick;
return $"{nick} ({expect})";
@@ -87,32 +90,33 @@ private void PopulateGender(PKM pk)
PB_Gender.Image = GenderImages[gender];
}
- private void PopulateMoves(PKM pk, LegalityAnalysis la, ref int width)
+ private void PopulateMoves(PKM pk, LegalityAnalysis la, in BattleTemplateExportSettings settings, ref int width)
{
var context = pk.Context;
- var names = GameInfo.Strings.movelist;
+ var strings = settings.Localization.Strings;
+ var names = strings.movelist;
var check = la.Info.Moves;
- var w1 = Move1.Populate(pk, pk.Move1, context, names, check[0].Valid);
- var w2 = Move2.Populate(pk, pk.Move2, context, names, check[1].Valid);
- var w3 = Move3.Populate(pk, pk.Move3, context, names, check[2].Valid);
- var w4 = Move4.Populate(pk, pk.Move4, context, names, check[3].Valid);
+ var w1 = Move1.Populate(pk, strings, pk.Move1, context, names, check[0].Valid);
+ var w2 = Move2.Populate(pk, strings, pk.Move2, context, names, check[1].Valid);
+ var w3 = Move3.Populate(pk, strings, pk.Move3, context, names, check[2].Valid);
+ var w4 = Move4.Populate(pk, strings, pk.Move4, context, names, check[3].Valid);
var maxWidth = Math.Max(w1, Math.Max(w2, Math.Max(w3, w4)));
width = Math.Max(width, maxWidth + Move1.Margin.Horizontal + interiorMargin);
}
- private void PopulateText(PKM pk, LegalityAnalysis la, int width)
+ private void PopulateText(PKM pk, LegalityAnalysis la, in BattleTemplateExportSettings settings, int width)
{
- var (stats, enc) = GetStatsString(pk, la);
- var settings = Main.Settings.Hover;
+ var (before, after) = GetBeforeAndAfter(pk, la, settings);
+ var hover = Main.Settings.Hover;
bool hasMoves = pk.MoveCount != 0;
FLP_Moves.Visible = hasMoves;
var height = FLP_List.Top + interiorMargin;
if (hasMoves)
height += FLP_Moves.Height + FLP_Moves.Margin.Vertical;
- ToggleLabel(L_Stats, stats, settings.PreviewShowPaste, ref width, ref height);
- ToggleLabel(L_Etc, enc, settings.HoverSlotShowEncounter, ref width, ref height);
+ ToggleLabel(L_LinesBeforeMoves, before, hover.PreviewShowPaste, ref width, ref height);
+ ToggleLabel(L_LinesAfterMoves, after, hover.HoverSlotShowEncounter, ref width, ref height);
Size = new Size(width, height);
}
@@ -137,78 +141,67 @@ public static Size MeasureSize(ReadOnlySpan text, Font font)
return TextRenderer.MeasureText(text, font, new Size(), flags);
}
- private static (string Detail, string Encounter) GetStatsString(PKM pk, LegalityAnalysis la)
+ private static (string Before, string After) GetBeforeAndAfter(PKM pk, LegalityAnalysis la, in BattleTemplateExportSettings settings)
{
- var setText = SummaryPreviewer.GetPreviewText(pk, la);
- var sb = new StringBuilder();
- var lines = setText.AsSpan().EnumerateLines();
- if (!lines.MoveNext())
- throw new ArgumentException("Invalid text format", nameof(pk));
+ var order = settings.Order;
+ // Bifurcate the order into two sections; split via Moves.
+ var moveIndex = settings.GetTokenIndex(BattleTemplateToken.Moves);
+ var before = moveIndex == -1 ? order : order[..moveIndex];
+ var after = moveIndex == -1 ? default : order[(moveIndex + 1)..];
+ if (before.Length > 0 && before[0] == BattleTemplateToken.FirstLine)
+ before = before[1..]; // remove first line token; trust that users don't randomly move it lower in the list.
- var first = lines.Current;
- var itemIndex = first.IndexOf('@');
- if (itemIndex != -1) // Held Item
- {
- var remaining = first[(itemIndex + 2)..];
- if (remaining[^1] == ')')
- remaining = remaining[..^3]; // lop off gender
- var item = remaining.Trim();
- if (item.Length != 0)
- sb.AppendLine($"Held Item: {item}");
- }
+ var start = SummaryPreviewer.GetPreviewText(pk, settings with { Order = before });
+ var end = SummaryPreviewer.GetPreviewText(pk, settings with { Order = after });
+ if (settings.IsTokenInExport(BattleTemplateToken.IVs, before))
+ TryAppendOtherStats(pk, ref start, settings);
+ else if (settings.IsTokenInExport(BattleTemplateToken.IVs, after))
+ TryAppendOtherStats(pk, ref end, settings);
- if (pk is IGanbaru g)
- AddGanbaru(g, sb);
- if (pk is IAwakened a)
- AddAwakening(a, sb);
+ if (Main.Settings.Hover.HoverSlotShowEncounter)
+ end = SummaryPreviewer.AppendEncounterInfo(la, end);
- while (lines.MoveNext())
- {
- var line = lines.Current;
- if (IsMoveLine(line))
- {
- while (lines.MoveNext())
- {
- if (!IsMoveLine(lines.Current))
- break;
- }
- break;
- }
- sb.Append(line).AppendLine();
- }
-
- var detail = sb.ToString();
- sb.Clear();
- while (lines.MoveNext())
- {
- var line = lines.Current;
- sb.Append(line).AppendLine();
- }
- var enc = sb.ToString();
- return (detail.TrimEnd(), enc.TrimEnd());
-
- static bool IsMoveLine(ReadOnlySpan line) => line.Length != 0 && line[0] == '-';
+ return (start, end);
}
- private static void AddGanbaru(IGanbaru g, StringBuilder sb)
+ private static void TryAppendOtherStats(PKM pk, ref string line, in BattleTemplateExportSettings settings)
+ {
+ if (pk is IGanbaru g)
+ AppendGanbaru(g, ref line, settings);
+ if (pk is IAwakened a)
+ AppendAwakening(a, ref line, settings);
+ }
+
+ private static void AppendGanbaru(IGanbaru g, ref string line, in BattleTemplateExportSettings settings)
{
Span gvs = stackalloc byte[6];
g.GetGVs(gvs);
- TryAdd(sb, "GVs", gvs);
+ var statNames = settings.Localization.Config.GetStatDisplay(settings.StatsOther);
+ var value = TryAdd(gvs, statNames);
+ if (value.Length == 0)
+ return;
+ var result = settings.Localization.Config.Push(BattleTemplateToken.GVs, value);
+ line += Environment.NewLine + result;
}
- private static void AddAwakening(IAwakened a, StringBuilder sb)
+ private static void AppendAwakening(IAwakened a, ref string line, in BattleTemplateExportSettings settings)
{
Span avs = stackalloc byte[6];
a.GetAVs(avs);
- TryAdd(sb, "AVs", avs);
+ var statNames = settings.Localization.Config.GetStatDisplay(settings.StatsOther);
+ var value = TryAdd(avs, statNames);
+ if (value.Length == 0)
+ return;
+ var result = settings.Localization.Config.Push(BattleTemplateToken.AVs, value);
+ line += Environment.NewLine + result;
}
- private static void TryAdd(StringBuilder sb, [ConstantExpected] string type, ReadOnlySpan stats, T ignore = default) where T : unmanaged, IEquatable
+ private static string TryAdd(ReadOnlySpan stats, StatDisplayConfig statNames, T ignore = default) where T : unmanaged, IEquatable
{
- var chunks = ShowdownSet.GetStringStats(stats, ignore);
- if (chunks.Length != 0)
- sb.AppendLine($"{type}: {string.Join(" / ", chunks)}");
+ var chunks = ShowdownSet.GetStringStats(stats, ignore, statNames);
+ if (chunks.Length == 0)
+ return string.Empty;
+ return string.Join(" / ", chunks);
}
/// Prevent stealing focus from the form that shows this.
diff --git a/PKHeX.WinForms/Controls/Slots/SlotUtil.cs b/PKHeX.WinForms/Controls/Slots/SlotUtil.cs
index efa507571..8cf46258d 100644
--- a/PKHeX.WinForms/Controls/Slots/SlotUtil.cs
+++ b/PKHeX.WinForms/Controls/Slots/SlotUtil.cs
@@ -66,6 +66,11 @@ public static void UpdateSlot(PictureBox pb, ISlotInfo c, PKM p, SaveFile s, boo
pb.BackColor = Color.Transparent;
pb.Image = img;
- pb.AccessibleDescription = ShowdownParsing.GetLocalizedPreviewText(p, Main.CurrentLanguage);
+
+ var x = Main.Settings;
+ var programLanguage = Language.GetLanguageValue(x.Startup.Language);
+ var cfg = x.BattleTemplate;
+ var settings = cfg.Hover.GetSettings(programLanguage, p.Context);
+ pb.AccessibleDescription = ShowdownParsing.GetLocalizedPreviewText(p, settings);
}
}
diff --git a/PKHeX.WinForms/Controls/Slots/SummaryPreviewer.cs b/PKHeX.WinForms/Controls/Slots/SummaryPreviewer.cs
index 1ce553717..554e754ec 100644
--- a/PKHeX.WinForms/Controls/Slots/SummaryPreviewer.cs
+++ b/PKHeX.WinForms/Controls/Slots/SummaryPreviewer.cs
@@ -24,20 +24,32 @@ public void Show(Control pb, PKM pk)
return;
}
+ var programLanguage = Language.GetLanguageValue(Main.Settings.Startup.Language);
+ var cfg = Main.Settings.BattleTemplate;
+ var settings = cfg.Hover.GetSettings(programLanguage, pk.Context);
+
if (Settings.HoverSlotShowPreview && Control.ModifierKeys != Keys.Alt)
- UpdatePreview(pb, pk);
+ {
+ UpdatePreview(pb, pk, settings);
+ }
else if (Settings.HoverSlotShowText)
- ShowSet.SetToolTip(pb, GetPreviewText(pk, new LegalityAnalysis(pk)));
+ {
+ var text = GetPreviewText(pk, settings);
+ if (Settings.HoverSlotShowEncounter)
+ text = AppendEncounterInfo(new LegalityAnalysis(pk), text);
+ ShowSet.SetToolTip(pb, text);
+ }
+
if (Settings.HoverSlotPlayCry)
Cry.PlayCry(pk, pk.Context);
}
- private void UpdatePreview(Control pb, PKM pk)
+ private void UpdatePreview(Control pb, PKM pk, BattleTemplateExportSettings settings)
{
_source.Cancel();
_source = new();
UpdatePreviewPosition(new());
- Previewer.Populate(pk);
+ Previewer.Populate(pk, settings);
Previewer.Show();
}
@@ -80,12 +92,13 @@ public void Clear()
Cry.Stop();
}
- public static string GetPreviewText(PKM pk, LegalityAnalysis la)
+ public static string GetPreviewText(PKM pk, BattleTemplateExportSettings settings) => ShowdownParsing.GetLocalizedPreviewText(pk, settings);
+
+ public static string AppendEncounterInfo(LegalityAnalysis la, string text)
{
- var text = ShowdownParsing.GetLocalizedPreviewText(pk, Main.Settings.Startup.Language);
- if (!Main.Settings.Hover.HoverSlotShowEncounter)
- return text;
- var result = new List { text, string.Empty };
+ var result = new List(8) { text };
+ if (text.Length != 0) // add a blank line between the set and the encounter info if isn't already a blank line
+ result.Add("");
LegalityFormatting.AddEncounterInfo(la, result);
return string.Join(Environment.NewLine, result);
}
diff --git a/PKHeX.WinForms/MainWindow/Main.cs b/PKHeX.WinForms/MainWindow/Main.cs
index ac3b34863..2d02272eb 100644
--- a/PKHeX.WinForms/MainWindow/Main.cs
+++ b/PKHeX.WinForms/MainWindow/Main.cs
@@ -524,18 +524,15 @@ private void ClickShowdownImportPKM(object sender, EventArgs e)
// Get Simulator Data
var text = Clipboard.GetText();
- ShowdownSet set;
- if (ShowdownTeam.IsURL(text, out var url) && ShowdownTeam.TryGetSets(url, out var content))
- set = ShowdownParsing.GetShowdownSets(content).FirstOrDefault() ?? new(""); // take only first set
- else if (PokepasteTeam.IsURL(text, out url) && PokepasteTeam.TryGetSets(url, out content))
- set = ShowdownParsing.GetShowdownSets(content).FirstOrDefault() ?? new(""); // take only first set
- else
- set = new ShowdownSet(text);
+ var sets = BattleTemplateTeams.TryGetSets(text);
+ var set = sets.FirstOrDefault() ?? new(""); // take only first set
if (set.Species == 0)
{ WinFormsUtil.Alert(MsgSimulatorFailClipboard); return; }
- var reformatted = set.Text;
+ var programLanguage = Language.GetLanguageValue(Settings.Startup.Language);
+ var settings = Settings.BattleTemplate.Export.GetSettings(programLanguage, set.Context);
+ var reformatted = set.GetText(settings);
if (DialogResult.Yes != WinFormsUtil.Prompt(MessageBoxButtons.YesNo, MsgSimulatorLoad, reformatted))
return;
@@ -555,7 +552,9 @@ private void ClickShowdownExportPKM(object sender, EventArgs e)
}
var pk = PreparePKM();
- var text = ShowdownParsing.GetShowdownText(pk);
+ var programLanguage = Language.GetLanguageValue(Settings.Startup.Language);
+ var settings = Settings.BattleTemplate.Export.GetSettings(programLanguage, pk.Context);
+ var text = ShowdownParsing.GetShowdownText(pk, settings);
bool success = WinFormsUtil.SetClipboardText(text);
if (!success || !Clipboard.GetText().Equals(text))
WinFormsUtil.Alert(MsgClipboardFailWrite, MsgSimulatorExportFail);
diff --git a/PKHeX.WinForms/Properties/PKHeXSettings.cs b/PKHeX.WinForms/Properties/PKHeXSettings.cs
index a1f981751..52db0ce8e 100644
--- a/PKHeX.WinForms/Properties/PKHeXSettings.cs
+++ b/PKHeX.WinForms/Properties/PKHeXSettings.cs
@@ -35,6 +35,7 @@ public sealed class PKHeXSettings
public SpriteSettings Sprite { get; set; } = new();
public SoundSettings Sounds { get; set; } = new();
public HoverSettings Hover { get; set; } = new();
+ public BattleTemplateSettings BattleTemplate { get; set; } = new();
// GUI Specific
public DrawConfig Draw { get; set; } = new();
diff --git a/PKHeX.WinForms/Subforms/SAV_Database.cs b/PKHeX.WinForms/Subforms/SAV_Database.cs
index 2fde38cff..ae244bc4d 100644
--- a/PKHeX.WinForms/Subforms/SAV_Database.cs
+++ b/PKHeX.WinForms/Subforms/SAV_Database.cs
@@ -110,7 +110,11 @@ public SAV_Database(PKMEditor f1, SAVEditor saveditor)
return;
var pk = Results[index];
- slot.AccessibleDescription = ShowdownParsing.GetLocalizedPreviewText(pk.Entity, Main.CurrentLanguage);
+
+ var x = Main.Settings;
+ var programLanguage = Language.GetLanguageValue(x.Startup.Language);
+ var settings = x.BattleTemplate.Hover.GetSettings(programLanguage, pk.Entity.Context);
+ slot.AccessibleDescription = ShowdownParsing.GetLocalizedPreviewText(pk.Entity, settings);
};
}
diff --git a/PKHeX.WinForms/Subforms/SettingsEditor.cs b/PKHeX.WinForms/Subforms/SettingsEditor.cs
index c877d4ed8..c14edf300 100644
--- a/PKHeX.WinForms/Subforms/SettingsEditor.cs
+++ b/PKHeX.WinForms/Subforms/SettingsEditor.cs
@@ -64,6 +64,7 @@ private void LoadSettings(object obj)
var tab = new TabPage(p) { Name = $"Tab_{p}" };
var pg = new PropertyGrid { SelectedObject = state, Dock = DockStyle.Fill };
tab.Controls.Add(pg);
+ pg.ExpandAllGridItems();
tabControl1.TabPages.Add(tab);
}
}
diff --git a/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs b/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs
index 4bfc7f4ad..aa8810df3 100644
--- a/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs
+++ b/Tests/PKHeX.Core.Tests/Simulator/ShowdownSetTests.cs
@@ -10,9 +10,11 @@ public class ShowdownSetTests
[Fact]
public void SimulatorGetParse()
{
+ var settings = new BattleTemplateExportSettings(BattleTemplateConfig.CommunityStandard);
+
foreach (ReadOnlySpan setstr in Sets)
{
- var set = new ShowdownSet(setstr).GetSetLines();
+ var set = new ShowdownSet(setstr).GetSetLines(settings);
foreach (var line in set)
setstr.Contains(line, StringComparison.Ordinal).Should().BeTrue($"Line {line} should be in the set {setstr}");
}
@@ -163,6 +165,67 @@ public void SimulatorParseEmpty()
Assert.False(sets.Any());
}
+ [Theory]
+ [InlineData(SetAllTokenExample)]
+ public void SimulatorTranslate(string message, string languageOriginal = "en")
+ {
+ var settingsOriginal = new BattleTemplateExportSettings(BattleTemplateConfig.CommunityStandard, languageOriginal);
+ if (!ShowdownParsing.TryParseAnyLanguage(message, out var set))
+ throw new Exception("Input failed");
+
+ var all = BattleTemplateLocalization.GetAll();
+ foreach (var l in all)
+ {
+ var languageTarget = l.Key;
+ if (languageTarget == languageOriginal)
+ continue;
+
+ var exportSettings = new BattleTemplateExportSettings(languageTarget);
+ var translated = set.GetText(exportSettings);
+ translated.Should().NotBeNullOrEmpty();
+ translated.Should().NotBe(message);
+
+ // Convert back, should be 1:1
+ if (!ShowdownParsing.TryParseAnyLanguage(translated, out var set2))
+ throw new Exception($"{languageTarget} parse failed");
+ set2.InvalidLines.Should().BeEmpty();
+ set2.Species.Should().Be(set.Species);
+ set2.Form.Should().Be(set.Form);
+
+ var result = set2.GetText(settingsOriginal);
+ result.Should().Be(message);
+ }
+ }
+
+ [Theory]
+ [InlineData(SetAllTokenExample)]
+ public void SimulatorTranslateHABCDS(string message, string languageOriginal = "en")
+ {
+ var settingsOriginal = new BattleTemplateExportSettings(BattleTemplateConfig.CommunityStandard, languageOriginal);
+ if (!ShowdownParsing.TryParseAnyLanguage(message, out var set))
+ throw new Exception("Input failed");
+
+ var target = new BattleTemplateExportSettings("ja")
+ {
+ StatsIVs = StatDisplayStyle.HABCDS,
+ StatsEVs = StatDisplayStyle.HABCDS,
+ };
+
+ var translated = set.GetText(target);
+ translated.Should().NotBeNullOrEmpty();
+ translated.Should().NotBe(message);
+
+ // Convert back, should be 1:1
+ if (!ShowdownParsing.TryParseAnyLanguage(translated, out var set2))
+ throw new Exception("ja parse failed");
+ set2.InvalidLines.Should().BeEmpty();
+ set2.Species.Should().Be(set.Species);
+ set2.Form.Should().Be(set.Form);
+
+ var result = set2.GetText(settingsOriginal);
+ result.Should().Be(message);
+ }
+
[Theory]
[InlineData(SetDuplicateMoves, 3)]
public void SimulatorParseDuplicate(string text, int moveCount)
@@ -261,6 +324,24 @@ Modest Nature
- Hyper Voice
""";
+ private const string SetAllTokenExample =
+ """
+ Pikachu (F) @ Oran Berry
+ Ability: Static
+ Level: 69
+ Shiny: Yes
+ Friendship: 42
+ Dynamax Level: 3
+ Gigantamax: Yes
+ EVs: 12 HP / 5 Atk / 6 Def / 17 SpA / 4 SpD / 101 Spe
+ Quirky Nature
+ IVs: 30 HP / 22 Atk / 29 Def / 7 SpA / 1 SpD / 0 Spe
+ - Pound
+ - Sky Attack
+ - Hyperspace Fury
+ - Metronome
+ """;
+
private const string SetSmeargle =
"""
Smeargle @ Focus Sash