diff --git a/PKHeX.Core/Legality/Localization/LegalityCheckLocalization.cs b/PKHeX.Core/Legality/Localization/LegalityCheckLocalization.cs
index 03374be9a..683b95dab 100644
--- a/PKHeX.Core/Legality/Localization/LegalityCheckLocalization.cs
+++ b/PKHeX.Core/Legality/Localization/LegalityCheckLocalization.cs
@@ -94,6 +94,7 @@ public sealed class LegalityCheckLocalization
public string EggLocationTrade { get; init; } = "Able to hatch a traded Egg at Met Location.";
public string EggLocationTradeFail { get; init; } = "Invalid Egg Location, shouldn't be 'traded' while an Egg.";
public string EggMetLocationFail { get; init; } = "Can't obtain Egg from Egg Location.";
+ public string EggMoveCombination { get; init; } = "These Egg Moves cannot be inherited in combination in this generation.";
public string EggNature { get; init; } = "Eggs cannot have their Stat Alignment changed.";
public string EggPP { get; init; } = "Eggs cannot have modified move PP counts.";
public string EggPPUp { get; init; } = "Cannot apply PP Ups to an Egg.";
diff --git a/PKHeX.Core/Legality/Localization/LegalityCheckResultCodeExtensions.cs b/PKHeX.Core/Legality/Localization/LegalityCheckResultCodeExtensions.cs
index b68e867d5..43c1c0b4f 100644
--- a/PKHeX.Core/Legality/Localization/LegalityCheckResultCodeExtensions.cs
+++ b/PKHeX.Core/Legality/Localization/LegalityCheckResultCodeExtensions.cs
@@ -83,6 +83,7 @@ public static class LegalityCheckResultCodeExtensions
EggLocationTrade => localization.EggLocationTrade,
EggLocationTradeFail => localization.EggLocationTradeFail,
EggMetLocationFail => localization.EggMetLocationFail,
+ EggMoveCombination => localization.EggMoveCombination,
EggNature => localization.EggNature,
EggPP => localization.EggPP,
EggPPUp => localization.EggPPUp,
diff --git a/PKHeX.Core/Legality/Moves/Breeding/ChainBreedLegality.cs b/PKHeX.Core/Legality/Moves/Breeding/ChainBreedLegality.cs
new file mode 100644
index 000000000..dc97728cd
--- /dev/null
+++ b/PKHeX.Core/Legality/Moves/Breeding/ChainBreedLegality.cs
@@ -0,0 +1,994 @@
+using System;
+using System.Text;
+using static PKHeX.Core.GameVersion;
+
+namespace PKHeX.Core;
+
+///
+/// Verifies if a Generation 2-5 egg move set can be produced by a single compatible father chain.
+///
+public static class ChainBreedLegality
+{
+ private const byte FlagBase = 1 << 0;
+ private const byte FlagLevelUp = 1 << 1;
+ private const byte FlagGeneral = 1 << 2;
+ private const int MaxMoveCount = 4;
+ // The longest known in-generation chain is five fathers. Leave room for
+ // an evolution/baby-species transition without allowing unbounded search.
+ private const int MaxChainDepth = 8;
+
+ public static bool IsValid(ushort species, GameVersion version, params ReadOnlySpan moves)
+ => TryValidate(species, version, moves, out _);
+
+ public static bool TryValidate(ushort species, GameVersion version, ReadOnlySpan moves, out ChainBreedSummary summary)
+ {
+ summary = default;
+ int count = moves.IndexOf((ushort)0);
+ if (count == 0)
+ return false;
+ if (count == -1)
+ count = moves.Length;
+ if (count > MaxMoveCount)
+ return false;
+
+ var generation = version.Generation;
+ if (generation is < 2 or > 5)
+ return IsValidRelaxed(species, version, moves, out summary);
+
+ var learn = GameData.GetLearnSource(version);
+ var learnset = learn.GetLearnset(species, 0);
+ var baseMoves = learnset.GetBaseEggMoves(GetEggLevel(generation));
+
+ Span flags = stackalloc byte[count];
+ if (!MarkChildMoveFlags(species, version, moves[..count], learnset, flags))
+ return false;
+
+ // For male-only split breed species (Volbeat/Nidoran-M) in Gen 2-5, we need special validation:
+ // All EGG moves must come from a single father.
+ // Level-up moves don't need this restriction (can breed with Ditto).
+ var isMaleSplit = IsMaleOnlySplitBreed(species);
+ if (isMaleSplit)
+ {
+ // Filter to only egg moves (not level-up moves)
+ Span eggMovesOnly = stackalloc ushort[MaxMoveCount];
+ int eggMoveCount = 0;
+ for (int i = 0; i < count; i++)
+ {
+ // Skip if this move can be obtained via level-up
+ if ((flags[i] & FlagLevelUp) != 0)
+ continue;
+ eggMovesOnly[eggMoveCount++] = moves[i];
+ }
+
+ // If there are egg moves, check if a single father can pass them all
+ if (eggMoveCount > 0)
+ {
+ RemapSpeciesToMother(ref species);
+ return CanSingleFatherPassAllMoves(species, version, eggMovesOnly[..eggMoveCount], out summary);
+ }
+ }
+
+ Span visited = stackalloc ChainQueryState[MaxChainDepth];
+ return TryValidateCore(species, version, moves[..count], baseMoves, flags, visited, 0, out summary);
+ }
+
+ private static void RemapSpeciesToMother(ref ushort species)
+ {
+ if (species is (ushort)Species.NidoranM)
+ species = (ushort)Species.NidoranF;
+ if (species is (ushort)Species.Volbeat)
+ species = (ushort)Species.Illumise;
+ }
+
+ private static bool IsMaleOnlySplitBreed(ushort species)
+ {
+ return species is (ushort)Species.NidoranM or (ushort)Species.Volbeat;
+ }
+
+ private static bool IsValidRelaxed(ushort species, GameVersion version, ReadOnlySpan moves, out ChainBreedSummary summary)
+ {
+ // Gen 6+ games have relaxed breeding rules where most chains are valid as Mothers can now pass, allowing for fusing chains.
+ // However, only-Female offspring still have some restrictions:
+ // A Smoochum(no egg group; bred from Jynx, in the Human-like group) can't inherit Powder Snow (learned at level 4)
+ // Jynx is Female only, and there are no other species of the Human-like group that can know Powder Snow.
+ // So, it must breed with a male from the same egg group; that male must know the moves needed.
+
+ // However, Confusion is valid because Alakazam (Human-like) can learn it and breed with Jynx.
+
+ // Additionally, for male-only split breed species (Volbeat/Nidoran-M), in Gen 6-7:
+ // - Mother (Illumise/Nidoran-F) can pass moves she learns as egg moves
+ // - Father must pass moves the mother cannot learn
+ // - In Gen 8+, egg move sharing means this restriction doesn't apply
+ summary = default;
+
+ var generation = version.Generation;
+ var table = GameData.GetPersonal(version);
+ if (!table.IsPresentInGame(species, 0))
+ return true;
+
+ var pi = table[species, 0];
+
+ // Genderless species must breed with Ditto, but since they always must breed with Ditto,
+ // they are already handled by the relaxed rules (no level-up moves can be inherited).
+ if (pi.Genderless)
+ return true;
+
+ var isMaleSplit = IsMaleOnlySplitBreed(species);
+ RemapSpeciesToMother(ref species);
+
+ // For male-only split breed species in Gen 6-7, check if father can pass moves mother can't learn
+ if (isMaleSplit && generation < 8)
+ {
+ return CanMotherAndFatherPassAllMoves(species, version, moves, out summary);
+ }
+
+ // Check if this is a baby Pokemon bred from a female-only species (Gen 8+ restriction)
+ if (generation < 8)
+ return true; // Gen 6-7: fully relaxed for non-male-split species
+
+ var context = version.Context;
+ var tree = EvolutionTree.GetEvolutionTree(context);
+ var evolutions = tree.Forward.GetEvolutions(species, 0);
+
+ foreach (var (evoSpecies, _) in evolutions)
+ {
+ if (!table.IsPresentInGame(evoSpecies, 0))
+ continue;
+
+ var evoPi = table[evoSpecies, 0];
+
+ // If the evolved form is female-only, it must breed with a compatible father
+ if (evoPi.OnlyFemale)
+ {
+ // Check if any of the moves are level-up moves that cannot be inherited
+ var learn = GameData.GetLearnSource(version);
+
+ // Check each move to see if it's a level-up move that has no compatible father
+ foreach (var move in moves)
+ {
+ if (move == 0)
+ break;
+
+ // Check if this is a level-up move for the baby species or evolved species
+ var babyLearnset = learn.GetLearnset(species, 0);
+ var evoLearnset = learn.GetLearnset(evoSpecies, 0);
+
+ bool isLevelUpMove = babyLearnset.TryGetLevelLearnMove(move, out _) ||
+ evoLearnset.TryGetLevelLearnMove(move, out _);
+
+ if (!isLevelUpMove)
+ continue; // Not a level-up move, so it can be inherited normally
+
+ // This is a level-up move. Check if any compatible father can learn it.
+ if (!CanAnyCompatibleFatherLearnMove(evoPi, move, table, learn))
+ return false; // No compatible father can pass this level-up move
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private static bool CanAnyCompatibleFatherLearnMove(IPersonalInfo motherInfo, ushort move, IPersonalTable table, ILearnSource learn)
+ {
+ // Check if any species in the mother's egg groups can learn this move
+ var maxSpecies = table.MaxSpeciesID;
+ for (ushort fatherSpecies = 1; fatherSpecies <= maxSpecies; fatherSpecies++)
+ {
+ if (!table.IsPresentInGame(fatherSpecies, 0))
+ continue;
+
+ var fatherInfo = table[fatherSpecies, 0];
+
+ // Father must be in the same egg group and not be Ditto (or genderless/female-only)
+ if (!IsCompatibleFatherForMove(motherInfo, fatherSpecies, fatherInfo))
+ continue;
+
+ // Check if this father can learn the move via level-up
+ var fatherLearnset = learn.GetLearnset(fatherSpecies, 0);
+ if (fatherLearnset.TryGetLevelLearnMove(move, out _))
+ return true; // Found a compatible father that can learn this move
+ }
+
+ return false; // No compatible father found
+ }
+
+ private static bool IsCompatibleFatherForMove(IPersonalInfo mother, ushort fatherSpecies, IPersonalInfo father)
+ {
+ // Ditto can't pass down level-up moves
+ if (fatherSpecies == (ushort)Species.Ditto)
+ return false;
+
+ // Father can't be genderless or female-only
+ if (father.Genderless || father.OnlyFemale)
+ return false;
+
+ // Father must share an egg group with the mother
+ if (!SharesEggGroup(mother.EggGroup1, mother.EggGroup2, father.EggGroup1))
+ return false;
+ if (!SharesEggGroup(mother.EggGroup1, mother.EggGroup2, father.EggGroup2))
+ return false;
+
+ return true;
+ }
+
+ private static bool CanSingleFatherPassAllMoves(ushort motherSpecies, GameVersion version, ReadOnlySpan moves, out ChainBreedSummary summary)
+ {
+ // For male-only split breed species in Gen 2-5, all egg moves must come from a single father
+ summary = default;
+ var table = GameData.GetPersonal(version);
+ if (!table.IsPresentInGame(motherSpecies, 0))
+ return false;
+
+ var motherInfo = table[motherSpecies, 0];
+ var learn = GameData.GetLearnSource(version);
+ var maxSpecies = table.MaxSpeciesID;
+
+ // Try each potential father species
+ for (ushort fatherSpecies = 1; fatherSpecies <= maxSpecies; fatherSpecies++)
+ {
+ if (!table.IsPresentInGame(fatherSpecies, 0))
+ continue;
+
+ var fatherInfo = table[fatherSpecies, 0];
+
+ // Father must be in the same egg group and not be Ditto (or genderless/female-only)
+ if (!IsCompatibleFatherForBreeding(motherInfo, fatherSpecies, fatherInfo))
+ continue;
+
+ // Check if this father can learn ALL the moves
+ bool canLearnAll = true;
+
+ foreach (var move in moves)
+ {
+ if (move == 0)
+ break;
+
+ // Father must be able to learn this move as an egg move or level-up move
+ if (!CanFatherLearnMoveForEgg(fatherSpecies, move, learn))
+ {
+ canLearnAll = false;
+ break;
+ }
+ }
+
+ if (canLearnAll)
+ {
+ summary = new ChainBreedSummary(motherSpecies, fatherSpecies, 1);
+ return true;
+ }
+ }
+
+ return false; // No single father can pass all moves
+ }
+
+ private static bool CanMotherAndFatherPassAllMoves(ushort motherSpecies, GameVersion version, ReadOnlySpan moves, out ChainBreedSummary summary)
+ {
+ // For male-only split breed species in Gen 6-7:
+ // - Mother can pass moves she learns as egg moves
+ // - Father must pass moves the mother cannot learn
+ summary = default;
+ var table = GameData.GetPersonal(version);
+ if (!table.IsPresentInGame(motherSpecies, 0))
+ return false;
+
+ var motherInfo = table[motherSpecies, 0];
+ var learn = GameData.GetLearnSource(version);
+
+ // Determine which moves the mother can learn as egg moves
+ Span motherCanLearn = stackalloc bool[MaxMoveCount];
+ Span fatherMustPass = stackalloc ushort[MaxMoveCount];
+ int fatherMoveCount = 0;
+
+ for (int i = 0; i < moves.Length; i++)
+ {
+ var move = moves[i];
+ if (move == 0)
+ break;
+
+ // Check if mother can learn this move as an egg move
+ if (CanMotherLearnMoveAsEgg(motherSpecies, move, learn))
+ {
+ motherCanLearn[i] = true;
+ }
+ else
+ {
+ // Father must pass this move
+ fatherMustPass[fatherMoveCount++] = move;
+ }
+ }
+
+ // If mother can learn all moves, it's valid
+ if (fatherMoveCount == 0)
+ {
+ summary = new ChainBreedSummary(motherSpecies, 0, 0);
+ return true;
+ }
+
+ // Check if a single father can pass all the moves the mother cannot learn
+ var maxSpecies = table.MaxSpeciesID;
+ for (ushort fatherSpecies = 1; fatherSpecies <= maxSpecies; fatherSpecies++)
+ {
+ if (!table.IsPresentInGame(fatherSpecies, 0))
+ continue;
+
+ var fatherInfo = table[fatherSpecies, 0];
+
+ // Father must be in the same egg group
+ if (!IsCompatibleFatherForBreeding(motherInfo, fatherSpecies, fatherInfo))
+ continue;
+
+ // Check if this father can learn all the moves mother cannot learn
+ bool canLearnAll = true;
+ for (int i = 0; i < fatherMoveCount; i++)
+ {
+ var move = fatherMustPass[i];
+ if (CanFatherLearnMoveForEgg(fatherSpecies, move, learn))
+ continue;
+ canLearnAll = false;
+ break;
+ }
+
+ if (canLearnAll)
+ {
+ summary = new ChainBreedSummary(motherSpecies, fatherSpecies, 1);
+ return true;
+ }
+ }
+
+ return false; // No father can pass all the moves mother cannot learn
+ }
+
+ private static bool CanFatherLearnMoveForEgg(ushort fatherSpecies, ushort move, ILearnSource learn)
+ {
+ // For male-only split breed validation, check if the father can pass this move to the child
+ // The father can pass a move if he can HAVE it in his moveset via level-up only
+ // (not via egg moves, since that would require another breeding chain)
+ var learnset = learn.GetLearnset(fatherSpecies, 0);
+
+ // Only check level-up
+ return learnset.TryGetLevelLearnMove(move, out _);
+ }
+
+ private static bool CanMotherLearnMoveAsEgg(ushort motherSpecies, ushort move, ILearnSource learn)
+ {
+ // Check if the mother can learn this move as an egg move
+ var eggMoves = learn.GetEggMoves(motherSpecies, 0);
+ return eggMoves.Contains(move);
+ }
+
+ private static bool IsCompatibleFatherForBreeding(IPersonalInfo mother, ushort fatherSpecies, IPersonalInfo father)
+ {
+ // Ditto can't pass down egg moves
+ if (fatherSpecies == (ushort)Species.Ditto)
+ return false;
+
+ // Father can't be genderless or female-only
+ if (father.Genderless || father.OnlyFemale)
+ return false;
+
+ // Father must share at least one egg group with the mother
+ if (SharesEggGroup(mother.EggGroup1, mother.EggGroup2, father.EggGroup1))
+ return true;
+ if (SharesEggGroup(mother.EggGroup1, mother.EggGroup2, father.EggGroup2))
+ return true;
+
+ return false; // No shared egg groups
+ }
+
+ private static bool TryValidateCore(ushort eggSpecies, GameVersion version, ReadOnlySpan moves, ReadOnlySpan baseMoves, ReadOnlySpan flags, Span visited, int depth, out ChainBreedSummary summary)
+ {
+ summary = default;
+ if ((uint)depth >= (uint)visited.Length)
+ return false;
+
+ var state = new ChainQueryState(eggSpecies, moves);
+ // Check against visited[0] through visited[depth-1] for exact duplicates
+ for (int i = 0; i < depth; i++)
+ {
+ if (visited[i].Equals(state))
+ return false;
+ }
+ // Also check if we're about to overwrite the same state at visited[depth]
+ // (happens when recursing at the same depth level via CanFatherKnowAllMoves)
+ if ((uint)depth < (uint)visited.Length && visited[depth].Equals(state))
+ return false;
+
+ visited[depth] = state;
+
+ int maxBase = Math.Min(moves.Length, baseMoves.Length);
+ Span inheritedMoves = stackalloc ushort[MaxMoveCount];
+ return TryValidateBaseCounts(eggSpecies, version, moves, baseMoves, flags, inheritedMoves, maxBase, visited, depth, out summary);
+ }
+
+ private static bool TryValidateBaseCounts(ushort eggSpecies, GameVersion version, ReadOnlySpan moves, ReadOnlySpan baseMoves, ReadOnlySpan flags, Span inheritedMoves, int maxBase, Span visited, int depth, out ChainBreedSummary summary)
+ {
+ summary = default;
+ for (int baseCount = 0; baseCount <= maxBase; baseCount++)
+ {
+ if (!IsValidBaseCount(baseCount, moves, baseMoves, flags))
+ continue;
+
+ int inheritedCount = moves.Length - baseCount;
+ if (inheritedCount == 0)
+ return true;
+ // Note: Even if eggSpecies is in Undiscovered (like baby Pokemon), it can still have
+ // inherited moves if its evolved forms can breed (e.g., Tyrogue from Hitmonlee/Hitmonchan/Hitmontop).
+ // Let TryResolveInheritedSources handle the validation through TryResolveFatherViaEvolution.
+
+ var suffix = moves.Slice(baseCount, inheritedCount);
+ var suffixFlags = flags.Slice(baseCount, inheritedCount);
+ for (int i = 0; i < inheritedCount; i++)
+ inheritedMoves[i] = suffix[i];
+
+ if (TryResolveInheritedSources(eggSpecies, version, inheritedMoves[..inheritedCount], suffixFlags, 0, visited, depth + 1, out summary))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryResolveInheritedSources(ushort eggSpecies, GameVersion version, ReadOnlySpan moves, ReadOnlySpan flags, int index, Span visited, int depth, out ChainBreedSummary summary)
+ {
+ if (index == moves.Length)
+ return TryResolveFather(eggSpecies, version, moves, visited, depth, out summary);
+
+ var flag = flags[index];
+ if ((flag & FlagGeneral) != 0)
+ {
+ if (TryResolveInheritedSources(eggSpecies, version, moves, flags, index + 1, visited, depth, out summary))
+ return true;
+ }
+
+ if ((flag & FlagLevelUp) != 0)
+ {
+ if (TryResolveInheritedSources(eggSpecies, version, moves, flags, index + 1, visited, depth, out summary))
+ return true;
+ }
+
+ summary = default;
+ return false;
+ }
+
+ private static bool TryResolveFather(ushort eggSpecies, GameVersion version, ReadOnlySpan moves, Span visited, int depth, out ChainBreedSummary summary)
+ {
+ summary = default;
+ var table = GameData.GetPersonal(version);
+ if (!table.IsPresentInGame(eggSpecies, 0))
+ return false;
+
+ var mother = table[eggSpecies, 0];
+
+ // If the egg species can't breed (baby Pokemon like Tyrogue), check if its evolutions can act as fathers
+ if (mother.Genderless || mother.OnlyMale || mother.EggGroup1 == (int)EggGroup.Undiscovered)
+ {
+ // Try to find evolved forms that can breed and produce this egg species
+ return TryResolveFatherViaEvolution(eggSpecies, version, moves, visited, depth, out summary);
+ }
+
+ ushort maxSpecies = table.MaxSpeciesID;
+ for (ushort fatherSpecies = 1; fatherSpecies <= maxSpecies; fatherSpecies++)
+ {
+ if (!table.IsPresentInGame(fatherSpecies, 0))
+ continue;
+
+ var father = table[fatherSpecies, 0];
+ if (!IsCompatibleFather(mother, fatherSpecies, father))
+ continue;
+
+ if (!CanFatherKnowAllMoves(fatherSpecies, version, moves, visited, depth, out var chainDepth))
+ continue;
+
+ summary = new ChainBreedSummary(eggSpecies, fatherSpecies, chainDepth);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryResolveFatherViaEvolution(ushort eggSpecies, GameVersion version, ReadOnlySpan moves, Span visited, int depth, out ChainBreedSummary summary)
+ {
+ summary = default;
+ var tree = EvolutionTree.GetEvolutionTree(version.Context);
+ var evos = tree.Forward.GetEvolutions(eggSpecies, 0);
+
+ foreach (var (evoSpecies, _) in evos)
+ {
+ if (!CanFatherKnowAllMoves(evoSpecies, version, moves, visited, depth, out var chainDepth))
+ continue;
+
+ summary = new ChainBreedSummary(eggSpecies, evoSpecies, chainDepth);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool CanFatherKnowAllMoves(ushort fatherSpecies, GameVersion version, ReadOnlySpan moves, Span visited, int depth, out byte chainDepth)
+ {
+ chainDepth = 1;
+ Span pending = stackalloc ushort[MaxMoveCount];
+ int pendingCount = 0;
+ for (int i = 0; i < moves.Length; i++)
+ {
+ var move = moves[i];
+ if (!CanLearnDirectlyInLine(fatherSpecies, version, move))
+ pending[pendingCount++] = move;
+ }
+
+ if (pendingCount == 0)
+ return true;
+
+ Span eggSpecies = stackalloc ushort[2];
+ int eggSpeciesCount = GetEggSpeciesCandidates(fatherSpecies, version, eggSpecies);
+ Span flags = stackalloc byte[MaxMoveCount];
+ for (int i = 0; i < eggSpeciesCount; i++)
+ {
+ var candidate = eggSpecies[i];
+ if (candidate == 0)
+ continue;
+
+ var learn = GameData.GetLearnSource(version);
+ var learnset = learn.GetLearnset(candidate, 0);
+ flags.Clear();
+ if (!MarkChildMoveFlags(candidate, version, pending[..pendingCount], learnset, flags))
+ continue;
+
+ var baseMoves = learnset.GetBaseEggMoves(GetEggLevel(version.Generation));
+ if (!TryValidateCore(candidate, version, pending[..pendingCount], baseMoves, flags, visited, depth, out var nested))
+ continue;
+
+ chainDepth = (byte)(nested.ChainDepth + 1);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool CanLearnDirectlyInLine(ushort species, GameVersion version, ushort move)
+ {
+ if (species == (ushort)Species.Smeargle)
+ return MoveInfo.IsSketchValid(move, version.Context);
+
+ var tree = EvolutionTree.GetEvolutionTree(version.Context);
+
+ // Check backwards through pre-evolutions
+ ushort current = species;
+ while (true)
+ {
+ if (CanLearnDirectly(current, version, move))
+ return true;
+
+ ref readonly var node = ref tree.Reverse.GetReverse(current, 0);
+ var previous = node.First.Species;
+ if (previous == 0)
+ break;
+ current = previous;
+ }
+
+ // Check forward through evolutions (e.g., Tyrogue -> Hitmonlee/Hitmonchan/Hitmontop)
+ var evos = tree.Forward.GetEvolutions(species, 0);
+ foreach (var (evoSpecies, _) in evos)
+ {
+ if (CanLearnDirectly(evoSpecies, version, move))
+ return true;
+ }
+
+ // Check cross-generation and special encounter sources
+ if (CanLearnFromHistoricalSource(species, version, move))
+ return true;
+
+ return false;
+ }
+
+ private static bool CanLearnFromHistoricalSource(ushort species, GameVersion version, ushort move)
+ {
+ var generation = version.Generation;
+
+ // Gen 3: Can use XD/Colo special encounters
+ if (generation == 3)
+ {
+ if (CanLearnFromGen3Special(species, move))
+ return true;
+ }
+
+ // Gen 4: Can transfer from Gen 3 (including XD/Colo), but NOT native Gen4-only moves through XD
+ if (generation == 4)
+ {
+ // Only allow Gen 3 XD/Colo moves (not Gen 4 moves)
+ if (move <= Legal.MaxMoveID_3 && CanLearnFromGen3Special(species, move))
+ return true;
+ }
+
+ // Gen 5: Can transfer from Gen 3/4
+ if (generation == 5)
+ {
+ // Gen 4 TMs (e.g., Shellder + Avalanche via Gen4 TM72)
+ // Check if the move is a Gen4-exclusive move learnable via TM in Gen4
+ if (move is (> Legal.MaxMoveID_3 and <= Legal.MaxMoveID_4))
+ {
+ // Check all Gen4 versions for TM availability
+ if (CanLearnDirectly(species, Pt, move) ||
+ CanLearnDirectly(species, HGSS, move))
+ return true;
+ }
+
+ // Gen 3 special encounters (XD/Colo)
+ if (move <= Legal.MaxMoveID_3 && CanLearnFromGen3Special(species, move))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool CanLearnFromGen3Special(ushort species, ushort move)
+ {
+ // Check XD Shadow Pokemon encounters
+ foreach (var enc in Encounters3XD.Shadow)
+ {
+ if (enc.Species == species)
+ {
+ var moves = enc.Moves.AsSpan();
+ for (int i = 0; i < moves.Length; i++)
+ {
+ if (moves[i] == 0)
+ break;
+ if (moves[i] == move)
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static bool CanLearnDirectly(ushort species, GameVersion version, ushort move) => version switch
+ {
+ GD or SI or GS => CanLearnDirectly2(LearnSource2GS.Instance, species, move, false),
+ C or GSC => CanLearnDirectly2(LearnSource2C.Instance, species, move, true),
+
+ R or S or RS => CanLearnDirectly3(LearnSource3RS.Instance, species, move),
+ E or RSE => CanLearnDirectly3(LearnSource3E.Instance, species, move),
+ FR or FRLG => CanLearnDirectly3(LearnSource3FR.Instance, species, move),
+ LG => CanLearnDirectly3(LearnSource3LG.Instance, species, move),
+
+ D or P or DP => CanLearnDirectly4(LearnSource4DP.Instance, species, move, false),
+ Pt or DPPt => CanLearnDirectly4(LearnSource4Pt.Instance, species, move, false),
+ HG or SS or HGSS => CanLearnDirectly4(LearnSource4HGSS.Instance, species, move, true),
+
+ B or W or BW => CanLearnDirectly5(LearnSource5BW.Instance, species, move),
+ B2 or W2 or B2W2 => CanLearnDirectly5(LearnSource5B2W2.Instance, species, move),
+ _ => false,
+ };
+
+ private static bool CanLearnDirectly2(ILearnSource source, ushort species, ushort move, bool crystal)
+ {
+ if (!source.TryGetPersonal(species, 0, out var pi))
+ return false;
+ if (source.GetLearnset(species, 0).GetIsLearn(move))
+ return true;
+
+ var tmIndex = PersonalInfo2.MachineMoves.IndexOf((byte)move);
+ if (move <= Legal.MaxMoveID_2 && tmIndex >= 0 && pi.GetIsLearnTM(tmIndex))
+ return true;
+
+ var tutorIndex = PersonalInfo2.TutorMoves.IndexOf((byte)move);
+ return crystal && tutorIndex >= 0 && pi.GetIsLearnTutorType(tutorIndex);
+ }
+
+ private static bool CanLearnDirectly3(ILearnSource source, ushort species, ushort move)
+ {
+ if (!source.TryGetPersonal(species, 0, out var pi))
+ return false;
+ if (source.GetLearnset(species, 0).GetIsLearn(move))
+ return true;
+
+ var tmIndex = PersonalInfo3.MachineMovesTechnical.IndexOf(move);
+ if (tmIndex >= 0 && pi.TMHM[tmIndex])
+ return true;
+
+ var hmIndex = PersonalInfo3.MachineMovesHidden.IndexOf(move);
+ return hmIndex >= 0 && pi.TMHM[50 + hmIndex];
+ }
+
+ private static bool CanLearnDirectly4(ILearnSource source, ushort species, ushort move, bool hgss)
+ {
+ if (!source.TryGetPersonal(species, 0, out var pi))
+ return false;
+ if (source.GetLearnset(species, 0).GetIsLearn(move))
+ return true;
+
+ var tmIndex = PersonalInfo4.MachineMovesTechnical.IndexOf(move);
+ if (tmIndex >= 0 && pi.GetIsLearnTM(tmIndex))
+ return true;
+
+ var hms = hgss ? PersonalInfo4.MachineMovesHiddenHGSS : PersonalInfo4.MachineMovesHiddenDPPt;
+ var hmIndex = hms.IndexOf(move);
+ return hmIndex >= 0 && pi.GetIsLearnHM(hmIndex);
+ }
+
+ private static bool CanLearnDirectly5(ILearnSource source, ushort species, ushort move) where T : PersonalInfo
+ {
+ if (!source.TryGetPersonal(species, 0, out var pi))
+ return false;
+ if (source.GetLearnset(species, 0).GetIsLearn(move))
+ return true;
+ if (pi is not IPersonalInfoTM tm)
+ return false;
+
+ var tmIndex = PersonalInfo5BW.MachineMoves.IndexOf(move);
+ return tmIndex >= 0 && tm.GetIsLearnTM(tmIndex);
+ }
+
+ private static bool MarkChildMoveFlags(ushort species, GameVersion version, ReadOnlySpan moves, Learnset learnset, Span flags) => version switch
+ {
+ GD or SI or GS => MarkChildMoveFlags2(species, LearnSource2GS.Instance, PersonalTable.GS[species], version, moves, learnset, flags),
+ C or GSC => MarkChildMoveFlags2(species, LearnSource2C.Instance, PersonalTable.C[species], version, moves, learnset, flags),
+
+ R or S or RS => MarkChildMoveFlags3(species, LearnSource3RS.Instance, PersonalTable.RS[species], moves, learnset, flags),
+ E or RSE or COLO or XD or CXD or EFL => MarkChildMoveFlags3(species, LearnSource3E.Instance, PersonalTable.E[species], moves, learnset, flags),
+ FR or FRLG => MarkChildMoveFlags3(species, LearnSource3FR.Instance, PersonalTable.FR[species], moves, learnset, flags),
+ LG => MarkChildMoveFlags3(species, LearnSource3LG.Instance, PersonalTable.LG[species], moves, learnset, flags),
+
+ D or P or DP => MarkChildMoveFlags4(species, LearnSource4DP.Instance, PersonalTable.DP[species], version, moves, learnset, flags),
+ Pt or DPPt => MarkChildMoveFlags4(species, LearnSource4Pt.Instance, PersonalTable.Pt[species], version, moves, learnset, flags),
+ HG or SS or HGSS => MarkChildMoveFlags4(species, LearnSource4HGSS.Instance, PersonalTable.HGSS[species], version, moves, learnset, flags),
+
+ B or W or BW => MarkChildMoveFlags5(species, LearnSource5BW.Instance, PersonalTable.BW[species], moves, learnset, flags),
+ B2 or W2 or B2W2 => MarkChildMoveFlags5(species, LearnSource5B2W2.Instance, PersonalTable.B2W2[species], moves, learnset, flags),
+ _ => false,
+ };
+
+ private static bool MarkChildMoveFlags2(ushort species, ILearnSource source, PersonalInfo2 info, GameVersion version, ReadOnlySpan moves, Learnset learnset, Span flags)
+ {
+ bool inheritLevelUp = Breeding.GetCanInheritMoves(species);
+ var baseMoves = learnset.GetBaseEggMoves(GetEggLevel(2));
+ var eggMoves = source.GetEggMoves(species, 0);
+ var tmMoves = PersonalInfo2.MachineMoves;
+ var tutorMoves = PersonalInfo2.TutorMoves;
+
+ for (int i = 0; i < moves.Length; i++)
+ {
+ var move = moves[i];
+ byte value = 0;
+ if (baseMoves.Contains(move))
+ value |= FlagBase;
+ if (move <= Legal.MaxMoveID_2)
+ {
+ if (eggMoves.Contains(move))
+ value |= FlagGeneral;
+ if (info.GetIsLearnTM(tmMoves.IndexOf((byte)move)))
+ value |= FlagGeneral;
+ if (inheritLevelUp && learnset.GetIsLearn(move))
+ value |= FlagLevelUp;
+ if (version is C or GSC && info.GetIsLearnTutorType(tutorMoves.IndexOf((byte)move)))
+ value |= FlagGeneral;
+ }
+ if (value == 0)
+ return false;
+ flags[i] = value;
+ }
+ return true;
+ }
+
+ private static bool MarkChildMoveFlags3(ushort species, ILearnSource source, PersonalInfo3 info, ReadOnlySpan moves, Learnset learnset, Span flags)
+ {
+ bool inheritLevelUp = Breeding.GetCanInheritMoves(species);
+ var baseMoves = learnset.GetBaseEggMoves(GetEggLevel(3));
+ var eggMoves = source.GetEggMoves(species, 0);
+ var tms = PersonalInfo3.MachineMovesTechnical;
+ var hms = PersonalInfo3.MachineMovesHidden;
+ var tmhm = info.TMHM;
+
+ for (int i = 0; i < moves.Length; i++)
+ {
+ var move = moves[i];
+ byte value = 0;
+ if (baseMoves.Contains(move))
+ value |= FlagBase;
+ if (eggMoves.Contains(move))
+ value |= FlagGeneral;
+ if (inheritLevelUp && learnset.GetIsLearn(move))
+ value |= FlagLevelUp;
+
+ int tmIndex = tms.IndexOf(move);
+ if (tmIndex != -1 && tmhm[tmIndex])
+ value |= FlagGeneral;
+
+ int hmIndex = hms.IndexOf(move);
+ if (hmIndex != -1 && tmhm[50 + hmIndex])
+ value |= FlagGeneral;
+
+ if (value == 0)
+ return false;
+ flags[i] = value;
+ }
+ return true;
+ }
+
+ private static bool MarkChildMoveFlags4(ushort species, ILearnSource source, PersonalInfo4 info, GameVersion version, ReadOnlySpan moves, Learnset learnset, Span flags)
+ {
+ bool inheritLevelUp = Breeding.GetCanInheritMoves(species);
+ var baseMoves = learnset.GetBaseEggMoves(GetEggLevel(4));
+ var eggMoves = source.GetEggMoves(species, 0);
+ var tms = PersonalInfo4.MachineMovesTechnical;
+ var hms = version is HG or SS or HGSS ? PersonalInfo4.MachineMovesHiddenHGSS : PersonalInfo4.MachineMovesHiddenDPPt;
+
+ for (int i = 0; i < moves.Length; i++)
+ {
+ var move = moves[i];
+ byte value = 0;
+ if (baseMoves.Contains(move))
+ value |= FlagBase;
+ if (eggMoves.Contains(move))
+ value |= FlagGeneral;
+ if (inheritLevelUp && learnset.GetIsLearn(move))
+ value |= FlagLevelUp;
+
+ int tmIndex = tms.IndexOf(move);
+ if (tmIndex != -1 && info.GetIsLearnTM(tmIndex))
+ value |= FlagGeneral;
+
+ int hmIndex = hms.IndexOf(move);
+ if (hmIndex != -1 && info.GetIsLearnHM(hmIndex))
+ value |= FlagGeneral;
+
+ if (value == 0)
+ return false;
+ flags[i] = value;
+ }
+ return true;
+ }
+
+ private static bool MarkChildMoveFlags5(ushort species, ILearnSource source, IPersonalInfoTM info, ReadOnlySpan moves, Learnset learnset, Span flags)
+ {
+ bool inheritLevelUp = Breeding.GetCanInheritMoves(species);
+ var baseMoves = learnset.GetBaseEggMoves(GetEggLevel(5));
+ var eggMoves = source.GetEggMoves(species, 0);
+ var tms = PersonalInfo5BW.MachineMoves;
+
+ for (int i = 0; i < moves.Length; i++)
+ {
+ var move = moves[i];
+ byte value = 0;
+ if (baseMoves.Contains(move))
+ value |= FlagBase;
+ if (eggMoves.Contains(move))
+ value |= FlagGeneral;
+ if (inheritLevelUp && learnset.GetIsLearn(move))
+ value |= FlagLevelUp;
+
+ int tmIndex = tms.IndexOf(move);
+ if (tmIndex != -1 && info.GetIsLearnTM(tmIndex))
+ value |= FlagGeneral;
+
+ if (value == 0)
+ return false;
+ flags[i] = value;
+ }
+ return true;
+ }
+
+ private static bool IsValidBaseCount(int baseCount, ReadOnlySpan moves, ReadOnlySpan baseMoves, ReadOnlySpan flags)
+ {
+ if (baseMoves.Length < baseCount)
+ return false;
+
+ for (int i = 0; i < baseCount; i++)
+ {
+ if ((flags[i] & FlagBase) == 0)
+ return false;
+
+ var expected = baseMoves[baseMoves.Length - baseCount + i];
+ if (moves[i] != expected)
+ return false;
+ }
+
+ for (int i = baseCount; i < moves.Length; i++)
+ {
+ if ((flags[i] & (FlagLevelUp | FlagGeneral)) == 0)
+ return false;
+
+ if ((flags[i] & FlagBase) == 0)
+ continue;
+
+ int baseIndex = baseMoves.IndexOf(moves[i]);
+ if (baseIndex == -1)
+ continue;
+
+ int min = moves.Length - baseMoves.Length + baseIndex;
+ if (i < min + baseCount)
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool IsCompatibleFather(PersonalInfo mother, ushort fatherSpecies, PersonalInfo father)
+ {
+ if (fatherSpecies == (ushort)Species.Ditto)
+ return false;
+ if (father.Genderless || father.OnlyFemale)
+ return false;
+ return SharesEggGroup(mother, father);
+ }
+
+ private static bool SharesEggGroup(PersonalInfo left, PersonalInfo right)
+ {
+ return SharesEggGroup(left.EggGroup1, left.EggGroup2, right.EggGroup1)
+ || SharesEggGroup(left.EggGroup1, left.EggGroup2, right.EggGroup2);
+ }
+
+ private static bool SharesEggGroup(int group1, int group2, int other)
+ {
+ if (!IsBreedGroup(other))
+ return false;
+ return group1 == other || group2 == other;
+ }
+
+ private static bool IsBreedGroup(int group) => group is not ((int)EggGroup.None or (int)EggGroup.Ditto or (int)EggGroup.Undiscovered);
+
+ private static int GetEggSpeciesCandidates(ushort species, GameVersion version, Span result)
+ {
+ var tree = EvolutionTree.GetEvolutionTree(version.Context);
+ ushort current = species;
+ ushort baseSpecies;
+ ushort splitSpecies = 0;
+
+ while (true)
+ {
+ baseSpecies = current;
+ if (splitSpecies == 0 && Breeding.IsSplitBreedNotBabySpecies(current, version.Generation))
+ splitSpecies = current;
+
+ ref readonly var node = ref tree.Reverse.GetReverse(current, 0);
+ var previous = node.First.Species;
+ if (previous == 0)
+ break;
+ current = previous;
+ }
+
+ result[0] = baseSpecies;
+ int count = 1;
+ if (splitSpecies != 0 && splitSpecies != baseSpecies)
+ result[count++] = splitSpecies;
+ return count;
+ }
+
+ private static byte GetEggLevel(byte generation) => EggStateLegality.GetEggLevel(generation);
+
+ private readonly record struct ChainQueryState
+ {
+ private readonly Species Species;
+ private readonly byte Count;
+ private readonly ushort Move1;
+ private readonly ushort Move2;
+ private readonly ushort Move3;
+ private readonly ushort Move4;
+
+ public ChainQueryState(ushort species, ReadOnlySpan moves)
+ {
+ Species = (Species)species;
+ Count = (byte)moves.Length;
+ Move1 = moves.Length > 0 ? moves[0] : (ushort)0;
+ Move2 = moves.Length > 1 ? moves[1] : (ushort)0;
+ Move3 = moves.Length > 2 ? moves[2] : (ushort)0;
+ Move4 = moves.Length > 3 ? moves[3] : (ushort)0;
+ }
+
+ public string Format(ReadOnlySpan species, ReadOnlySpan moves)
+ {
+ var sb = new StringBuilder();
+ sb.Append(species[(int)Species]);
+ sb.Append(" [");
+ if (Count > 0) sb.Append(moves[Move1]);
+ if (Count > 1) { sb.Append(", "); sb.Append(moves[Move2]); }
+ if (Count > 2) { sb.Append(", "); sb.Append(moves[Move3]); }
+ if (Count > 3) { sb.Append(", "); sb.Append(moves[Move4]); }
+ sb.Append(']');
+ return sb.ToString();
+ }
+ }
+}
+
+public readonly record struct ChainBreedSummary(ushort EggSpecies, ushort FatherSpecies, byte ChainDepth);
diff --git a/PKHeX.Core/Legality/Structures/LegalityCheckResultCode.cs b/PKHeX.Core/Legality/Structures/LegalityCheckResultCode.cs
index 3518fa6a6..93ba34a11 100644
--- a/PKHeX.Core/Legality/Structures/LegalityCheckResultCode.cs
+++ b/PKHeX.Core/Legality/Structures/LegalityCheckResultCode.cs
@@ -70,6 +70,7 @@ public enum LegalityCheckResultCode : ushort
EggLocationTrade,
EggLocationTradeFail,
EggMetLocationFail,
+ EggMoveCombination,
EggNature,
EggPP,
EggPPUp,
diff --git a/PKHeX.Core/Legality/Verifiers/Egg/EggVerifier.cs b/PKHeX.Core/Legality/Verifiers/Egg/EggVerifier.cs
index cb60fd444..dd128c2c2 100644
--- a/PKHeX.Core/Legality/Verifiers/Egg/EggVerifier.cs
+++ b/PKHeX.Core/Legality/Verifiers/Egg/EggVerifier.cs
@@ -1,3 +1,4 @@
+using System;
using static PKHeX.Core.LegalityCheckResultCode;
using static PKHeX.Core.CheckIdentifier;
@@ -10,27 +11,30 @@ internal sealed class EggVerifier : Verifier
public override void Verify(LegalityAnalysis data)
{
var pk = data.Entity;
+ if (data.EncounterOriginal is IEncounterEgg egg)
+ VerifyEggBreedingChain(data, pk, egg);
if (pk.IsEgg)
- Verify(data, pk);
+ VerifyWhileEgg(data, pk);
}
- internal void Verify(LegalityAnalysis data, PKM pk)
+ private void VerifyWhileEgg(LegalityAnalysis data, PKM pk)
{
- VerifyCommon(data, pk);
+ // Not hatched yet, must have sane properties while still an egg.
+ var enc = data.EncounterOriginal;
+ VerifyCommon(data, pk, enc);
// No egg have contest stats from the encounter.
if (pk is IContestStatsReadOnly s && s.HasContestStats())
data.AddLine(GetInvalid(Egg, EggContest));
// Cannot transfer eggs across contexts (must be hatched).
- var e = data.EncounterOriginal;
- if (e.Context != pk.Context)
+ if (enc.Context != pk.Context)
data.AddLine(GetInvalid(Egg, TransferEggVersion));
switch (pk)
{
// Side Game: No Eggs
- case SK2 or CK3 or XK3 or BK4 or RK4 when e.Context == pk.Context:
+ case SK2 or CK3 or XK3 or BK4 or RK4 when enc.Context == pk.Context: // same context to not double-flag
data.AddLine(GetInvalid(Egg, TransferEggVersion));
break;
@@ -44,9 +48,8 @@ internal void Verify(LegalityAnalysis data, PKM pk)
data.AddLine(GetInvalid(TransferTrackerShouldBeZero));
}
- internal void VerifyCommon(LegalityAnalysis data, PKM pk)
+ internal void VerifyCommon(LegalityAnalysis data, PKM pk, IEncounterTemplate enc)
{
- var enc = data.EncounterMatch;
if (!EggStateLegality.GetIsEggHatchCyclesValid(pk, enc))
data.AddLine(GetInvalid(Egg, EggHatchCycles));
@@ -74,4 +77,46 @@ private static bool MovesMatchRelearn(PKM pk)
return false;
return true;
}
+
+ private void VerifyEggBreedingChain(LegalityAnalysis data, PKM pk, IEncounterEgg egg)
+ {
+ // Check if we have any moves that are as a result of breeding chain.
+ Span moves = stackalloc ushort[4];
+ pk.GetMoves(moves);
+ var personal = GameData.GetPersonal(egg.Version)[egg.Species, 0];
+ var includeInheritedLevelUp = personal.OnlyFemale
+ && !Breeding.IsGenderSpeciesDetermination(egg.Species);
+ var count = GatherInheritedMoves(moves, data.Info.Moves, includeInheritedLevelUp);
+ if (count == 0 || (count == 1 && !includeInheritedLevelUp))
+ return;
+ moves = moves[..count];
+
+ if (!ChainBreedLegality.IsValid(egg.Species, egg.Version, moves))
+ data.AddLine(GetInvalid(Egg, EggMoveCombination));
+ }
+
+ private static int GatherInheritedMoves(Span moves, ReadOnlySpan parse, bool includeInheritedLevelUp)
+ {
+ // Collapse the list of moves to only those that are relevant for breeding chain verification.
+ int count = 0;
+ for (int i = 0; i < moves.Length; i++)
+ {
+ if (moves[i] == 0)
+ break;
+
+ if (!IsInherited(parse[i], includeInheritedLevelUp))
+ continue;
+
+ moves[count] = moves[i];
+ count++;
+ }
+ return count;
+ }
+
+ private static bool IsInherited(MoveResult parsed, bool includeInheritedLevelUp)
+ {
+ var method = parsed.Info.Method;
+ return method is LearnMethod.EggMove
+ || (includeInheritedLevelUp && method is LearnMethod.InheritLevelUp);
+ }
}
diff --git a/PKHeX.Core/Legality/Verifiers/Misc/MiscG1Verifier.cs b/PKHeX.Core/Legality/Verifiers/Misc/MiscG1Verifier.cs
index 9a96c5244..7cc05da10 100644
--- a/PKHeX.Core/Legality/Verifiers/Misc/MiscG1Verifier.cs
+++ b/PKHeX.Core/Legality/Verifiers/Misc/MiscG1Verifier.cs
@@ -13,30 +13,39 @@ internal sealed class MiscG1Verifier : Verifier
internal void VerifyG1(LegalityAnalysis data, PKM pk)
{
- if (pk.IsEgg)
- Eggs.VerifyCommon(data, pk);
+ Eggs.Verify(data);
- if (pk is not PK1 pk1)
- {
- if (pk is ICaughtData2 { CaughtData: not 0 } t)
- {
- var time = t.MetTimeOfDay;
- bool valid = data.EncounterOriginal switch
- {
- EncounterGift2 g2 when (!g2.IsEgg || pk.IsEgg) => time == 0,
- EncounterTrade2 => time == 0,
- _ => time is 1 or 2 or 3,
- };
- if (!valid)
- data.AddLine(GetInvalid(Encounter, MetDetailTimeOfDay));
- }
- return;
- }
+ if (pk is PK1 pk1)
+ VerifyAsFormat1(data, pk1);
+ else
+ VerifyAsFormat2(data, pk);
+ }
+ private void VerifyAsFormat1(LegalityAnalysis data, PK1 pk1)
+ {
+ // PK1-specific properties: personal Types and Catch Rate (held item).
VerifyMiscG1Types(data, pk1);
VerifyMiscG1CatchRate(data, pk1);
}
+ private static void VerifyAsFormat2(LegalityAnalysis data, PKM pk)
+ {
+ // The only additional property in Gen2 is the Caught u16 with Time of Day and OT gender.
+ // OT Gender is checked separately in the Trainer verifier; still need to verify Time of Day.
+ if (pk is not ICaughtData2 { CaughtData: not 0 } t)
+ return;
+
+ var time = t.MetTimeOfDay;
+ bool valid = data.EncounterOriginal switch
+ {
+ EncounterGift2 g2 when (!g2.IsEgg || pk.IsEgg) => time == 0,
+ EncounterTrade2 => time == 0,
+ _ => time is 1 or 2 or 3,
+ };
+ if (!valid)
+ data.AddLine(GetInvalid(Encounter, MetDetailTimeOfDay));
+ }
+
private void VerifyMiscG1Types(LegalityAnalysis data, PK1 pk1)
{
var species = pk1.Species;
diff --git a/PKHeX.Core/Legality/Verifiers/MiscVerifier.cs b/PKHeX.Core/Legality/Verifiers/MiscVerifier.cs
index eb444d982..1260e2a79 100644
--- a/PKHeX.Core/Legality/Verifiers/MiscVerifier.cs
+++ b/PKHeX.Core/Legality/Verifiers/MiscVerifier.cs
@@ -36,8 +36,8 @@ public sealed class MiscVerifier : Verifier
public override void Verify(LegalityAnalysis data)
{
var pk = data.Entity;
- if (pk.IsEgg)
- Eggs.Verify(data, pk);
+ // If it originated from an egg, check various egg rules.
+ Eggs.Verify(data);
// Verify gimmick data
switch (pk)
diff --git a/PKHeX.Core/Resources/byte/eggmove/eggmove_gs.pkl b/PKHeX.Core/Resources/byte/eggmove/eggmove_gs.pkl
index 5150d5a92..c658243e4 100644
Binary files a/PKHeX.Core/Resources/byte/eggmove/eggmove_gs.pkl and b/PKHeX.Core/Resources/byte/eggmove/eggmove_gs.pkl differ
diff --git a/PKHeX.Core/Resources/localize/legality/legality_de.json b/PKHeX.Core/Resources/localize/legality/legality_de.json
index de01faca5..95961ebf5 100644
--- a/PKHeX.Core/Resources/localize/legality/legality_de.json
+++ b/PKHeX.Core/Resources/localize/legality/legality_de.json
@@ -53,6 +53,7 @@
"EggLocationTrade": "Ein getauschtes Ei kann am Fundort ausgebrütet werden.",
"EggLocationTradeFail": "Ungültiger Ei-Ort, sollte im Ei-Status nicht 'getauscht' sein.",
"EggMetLocationFail": "Ei kann an diesem Ei-Ort nicht erhalten werden.",
+ "EggMoveCombination": "Diese Kombination von Ei-Attacken kann in dieser Generation nicht vererbt werden.",
"EggNature": "Statuswertanpassung eines Ei kann nicht geändert werden.",
"EggPP": "Eier können keine modifizierten AP-Werte haben.",
"EggPPUp": "AP-Plus kann nicht auf ein Ei angewendet werden.",
diff --git a/PKHeX.Core/Resources/localize/legality/legality_en.json b/PKHeX.Core/Resources/localize/legality/legality_en.json
index cb95d66c8..8d5a0461f 100644
--- a/PKHeX.Core/Resources/localize/legality/legality_en.json
+++ b/PKHeX.Core/Resources/localize/legality/legality_en.json
@@ -53,6 +53,7 @@
"EggLocationTrade": "Able to hatch a traded Egg at Met Location.",
"EggLocationTradeFail": "Invalid Egg Location, shouldn't be 'traded' while an Egg.",
"EggMetLocationFail": "Can't obtain Egg from Egg Location.",
+ "EggMoveCombination": "These Egg Moves cannot be inherited in combination in this generation.",
"EggNature": "Eggs cannot have their Stat Alignment changed.",
"EggPP": "Eggs cannot have modified move PP counts.",
"EggPPUp": "Cannot apply PP Ups to an Egg.",
diff --git a/PKHeX.Core/Resources/localize/legality/legality_es-419.json b/PKHeX.Core/Resources/localize/legality/legality_es-419.json
index 6d395fdac..2694aa128 100644
--- a/PKHeX.Core/Resources/localize/legality/legality_es-419.json
+++ b/PKHeX.Core/Resources/localize/legality/legality_es-419.json
@@ -53,6 +53,7 @@
"EggLocationTrade": "Se puede eclosionar el Huevo intercambiado en la localización.",
"EggLocationTradeFail": "Localización del Huevo inválida, no debería haber sido intercambiado mientras era un Huevo.",
"EggMetLocationFail": "No se puede obtener el Huevo en la localización.",
+ "EggMoveCombination": "Esta combinación de movimientos huevo no puede heredarse en esta generación.",
"EggNature": "Los Huevos no pueden tener ajustes de estadísticas.",
"EggPP": "Los Huevos no pueden tener los contadores de PP modificados.",
"EggPPUp": "No se puede aplicar Más PP a un huevo.",
diff --git a/PKHeX.Core/Resources/localize/legality/legality_es.json b/PKHeX.Core/Resources/localize/legality/legality_es.json
index 00d739b56..042dc2f8a 100644
--- a/PKHeX.Core/Resources/localize/legality/legality_es.json
+++ b/PKHeX.Core/Resources/localize/legality/legality_es.json
@@ -53,6 +53,7 @@
"EggLocationTrade": "Se puede eclosionar el Huevo intercambiado en la localización.",
"EggLocationTradeFail": "Localización del Huevo inválida, no debería haber sido intercambiado mientras era un Huevo.",
"EggMetLocationFail": "No se puede obtener el Huevo en la localización.",
+ "EggMoveCombination": "Esta combinación de movimientos huevo no puede heredarse en esta generación.",
"EggNature": "Los Huevos no pueden tener ajustes de estadísticas.",
"EggPP": "Los Huevos no pueden tener los contadores de PP modificados.",
"EggPPUp": "No se puede aplicar Más PP a un huevo.",
diff --git a/PKHeX.Core/Resources/localize/legality/legality_fr.json b/PKHeX.Core/Resources/localize/legality/legality_fr.json
index 7e721ea8a..3b5002b6a 100644
--- a/PKHeX.Core/Resources/localize/legality/legality_fr.json
+++ b/PKHeX.Core/Resources/localize/legality/legality_fr.json
@@ -53,6 +53,7 @@
"EggLocationTrade": "Œuf échangé peut éclore au Lieu de Rencontre.",
"EggLocationTradeFail": "Lieu de Rencontre de l'Œuf non valide, ne devrait pas être « échangé » en Œuf.",
"EggMetLocationFail": "L'Œuf ne peut pas être obtenu au Lieu de Rencontre.",
+ "EggMoveCombination": "Cette combinaison de capacités Repro ne peut pas être transmise dans cette génération.",
"EggNature": "Impossible de modifier l'ajustement des statistiques d'un Œuf.",
"EggPP": "Impossible de modifier les PP d'un Œuf.",
"EggPPUp": "Impossible d'appliquer des PP Plus à un Œuf.",
diff --git a/PKHeX.Core/Resources/localize/legality/legality_it.json b/PKHeX.Core/Resources/localize/legality/legality_it.json
index fdc9926ac..08a76b65a 100644
--- a/PKHeX.Core/Resources/localize/legality/legality_it.json
+++ b/PKHeX.Core/Resources/localize/legality/legality_it.json
@@ -53,6 +53,7 @@
"EggLocationTrade": "È possibile schiudere un Uovo scambiato nel luogo di incontro.",
"EggLocationTradeFail": "Il luogo di incontro non dovrebbe essere 'Scambio', se è un Uovo.",
"EggMetLocationFail": "Impossibile ottenre l'Uovo nel Luogo dell'Uovo.",
+ "EggMoveCombination": "Questa combinazione di mosse Uovo non può essere ereditata in questa generazione.",
"EggNature": "Le Uova non possono avere l'aggiustamento delle statistiche cambiato.",
"EggPP": "Le Uova non possono avere modifiche ai PP.",
"EggPPUp": "Non si possono applicare PP Up alle Uova.",
diff --git a/PKHeX.Core/Resources/localize/legality/legality_ja.json b/PKHeX.Core/Resources/localize/legality/legality_ja.json
index df91f4af7..38690221c 100644
--- a/PKHeX.Core/Resources/localize/legality/legality_ja.json
+++ b/PKHeX.Core/Resources/localize/legality/legality_ja.json
@@ -31,16 +31,16 @@
"BallSpecies": "このボールでは捕獲できません。",
"BallSpeciesPass": "ポケモンによっては捕獲可能なボール。",
"BallUnavailable": "元の世代では入手できないボール。",
- "BallG4Sinnoh": "Ball value for D/P/Pt (0x83) is not within range.",
- "BallG4Johto": "Extended Ball value for HG/SS (0x86) is not within range.",
+ "BallG4Sinnoh": "D/P/Pt (0x83) のボール値が範囲外です。",
+ "BallG4Johto": "HG/SS (0x86) の拡張ボール値が範囲外です。",
"ContestZero": "コンディションは0でなければなりません。",
"ContestZeroSheen": "けづやは0でなければなりません。",
"ContestSheenGEQ_0": "けづやは >= {0} でなければなりません。",
"ContestSheenLEQ_0": "けづやは <= {0} でなければなりません。",
- "DateCalendarInvalidMet": "Met Date is not a valid calendar date.",
- "DateCalendarInvalidEgg": "Egg Met Date is not a valid calendar date.",
- "DateLocalInvalidDate": "Local Date is outside of console's local time window.",
- "DateLocalInvalidTime": "Local Time is not a valid timestamp.",
+ "DateCalendarInvalidMet": "出会った日が有効なカレンダー日付ではありません。",
+ "DateCalendarInvalidEgg": "タマゴの出会った日が有効なカレンダー日付ではありません。",
+ "DateLocalInvalidDate": "ローカル日付がコンソールのローカル時間の範囲外です。",
+ "DateLocalInvalidTime": "ローカル時間が有効なタイムスタンプではありません。",
"DateOutsideDistributionWindow": "出会った日が配布期間外です。",
"EggContest": "タマゴはコンディションを上げることはできません。",
"EggEXP": "タマゴは経験値を得ることはできません。",
@@ -53,6 +53,7 @@
"EggLocationTrade": "出会った場所で交換したタマゴを孵化させることができます。",
"EggLocationTradeFail": "もらった場所が無効です。タマゴの状態で交換してはいけません。",
"EggMetLocationFail": "そのもらった場所では、タマゴを入手できません。",
+ "EggMoveCombination": "このタマゴわざの組み合わせは、今の世代では遺伝できません。",
"EggNature": "タマゴは能力調整を変更できません。",
"EggPP": "タマゴは技のPPを変更することはできません。",
"EggPPUp": "タマゴにポイントアップは使用できません",
diff --git a/PKHeX.Core/Resources/localize/legality/legality_ko.json b/PKHeX.Core/Resources/localize/legality/legality_ko.json
index 33d8f17b8..1282eeae8 100644
--- a/PKHeX.Core/Resources/localize/legality/legality_ko.json
+++ b/PKHeX.Core/Resources/localize/legality/legality_ko.json
@@ -53,6 +53,7 @@
"EggLocationTrade": "만난 장소에서 교환한 알을 부화시킬 수 있습니다.",
"EggLocationTradeFail": "알을 만난 장소가 잘못되었습니다. 부화 전 알을 교환할 수 없습니다.",
"EggMetLocationFail": "알을 만난 장소에서 알을 얻을 수 없습니다.",
+ "EggMoveCombination": "이 알기술 조합은 현재 세대에서 유전될 수 없습니다.",
"EggNature": "알은 능력치 조정을 변경할 수 없습니다.",
"EggPP": "알은 PP를 수정할 수 없습니다.",
"EggPPUp": "알에는 PP업을 적용할 수 없습니다.",
diff --git a/PKHeX.Core/Resources/localize/legality/legality_zh-hans.json b/PKHeX.Core/Resources/localize/legality/legality_zh-hans.json
index f90358312..44f6cd759 100644
--- a/PKHeX.Core/Resources/localize/legality/legality_zh-hans.json
+++ b/PKHeX.Core/Resources/localize/legality/legality_zh-hans.json
@@ -53,6 +53,7 @@
"EggLocationTrade": "能在相遇地点孵化交易的蛋。",
"EggLocationTradeFail": "非法蛋取得场所, 不能在还是蛋时“交换”",
"EggMetLocationFail": "不能在蛋取得场所获得蛋。",
+ "EggMoveCombination": "这一组蛋招式在本世代中无法遗传。",
"EggNature": "不能改变蛋的能力调整(薄荷)。",
"EggPP": "蛋不能有PP数变动。",
"EggPPUp": "不能对蛋使用PP提升剂。",
diff --git a/PKHeX.Core/Resources/localize/legality/legality_zh-hant.json b/PKHeX.Core/Resources/localize/legality/legality_zh-hant.json
index 559c50ebb..d546cc1b4 100644
--- a/PKHeX.Core/Resources/localize/legality/legality_zh-hant.json
+++ b/PKHeX.Core/Resources/localize/legality/legality_zh-hant.json
@@ -53,6 +53,7 @@
"EggLocationTrade": "可於遇見地點孵化「連線交換」所得蛋。",
"EggLocationTradeFail": "不合法之蛋取得場所, 無法於寶可夢仍是蛋時「連線交換」。",
"EggMetLocationFail": "無法於設定之蛋取得場所獲得該蛋。",
+ "EggMoveCombination": "這組蛋招式在本世代中無法遺傳。",
"EggNature": "無法使用「薄荷」修正蛋之能力調整。",
"EggPP": "蛋不能修改 PP 值。",
"EggPPUp": "不能對蛋使用 PP 提升劑。",
diff --git a/Tests/PKHeX.Core.Tests/Legality/ChainBreedLegalityTests.cs b/Tests/PKHeX.Core.Tests/Legality/ChainBreedLegalityTests.cs
new file mode 100644
index 000000000..b7b712272
--- /dev/null
+++ b/Tests/PKHeX.Core.Tests/Legality/ChainBreedLegalityTests.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Runtime.InteropServices;
+using FluentAssertions;
+using Xunit;
+using static PKHeX.Core.GameVersion;
+using static PKHeX.Core.Move;
+using static PKHeX.Core.Species;
+
+namespace PKHeX.Core.Tests.Legality;
+
+public class ChainBreedLegalityTests
+{
+ private static ReadOnlySpan GetMoves(ReadOnlySpan moves)
+ => MemoryMarshal.Cast(moves);
+
+ [Theory]
+ [InlineData(BW, Marill, BellyDrum, AquaJet)]
+ [InlineData(B2W2, Azurill, BellyDrum, AquaJet)]
+ [InlineData(FR, Squirtle, Haze, Flail)]
+ [InlineData(B2W2, Chansey, EggBomb)]
+ [InlineData(GS, Oddish, Flail, RazorLeaf, SwordsDance, Synthesis)]
+ [InlineData(GS, Smoochum, LovelyKiss)] // egg move removed from table (no parents)
+ public void DetectsInvalidChains(GameVersion version, Species species, params Move[] movelist)
+ {
+ var moves = GetMoves(movelist);
+ ChainBreedLegality.IsValid((ushort)species, version, moves).Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData(HGSS, Slugma, Smokescreen, HeatWave)] // Heat Wave is a Tutor in HG/SS.
+ [InlineData(GS, Paras, Counter, Flail, LightScreen)]
+ [InlineData(HGSS, Mankey, Encore, Meditate, SmellingSalts)]
+ [InlineData(GS, Chansey, DoubleEdge)] // via Jigglypuff (Level 39)
+ [InlineData(Pt, Shellder, RapidSpin, IcicleSpear)]
+ public void DetectsValidChains(GameVersion version, Species species, params Move[] movelist)
+ => ValidateSimple(version, species, movelist);
+
+ [Theory]
+ // Avalanche learned in Gen4 TM, TakeDown learned via *special encounter* move in Gen3 XD.
+ [InlineData(B2W2, Shellder, TakeDown)] // Gen3 encounter move (in XD)
+ [InlineData(B2W2, Shellder, Avalanche)] // Gen4 TM move
+ [InlineData(B2W2, Shellder, Avalanche, TakeDown)] // Valid Gen5 parent from a Gen3 encounter=>Gen4=>Gen5 transfer route.
+ public void DetectValidChainPastFather(GameVersion version, Species species, params Move[] movelist)
+ => ValidateSimple(version, species, movelist);
+
+ [Theory]
+ // evolve=>pass chain with same species lineage: multiple Tyrogue evolutions (hitmonlee, hitmonchan, hitmontop), all providing one move.
+ [InlineData(GS, Tyrogue, HighJumpKick, MachPunch, RapidSpin)]
+ public void DetectValidChainCyclic(GameVersion version, Species species, params Move[] movelist)
+ => ValidateSimple(version, species, movelist);
+
+ private static void ValidateSimple(GameVersion version, Species species, ReadOnlySpan movelist)
+ {
+ var moves = GetMoves(movelist);
+ ChainBreedLegality.TryValidate((ushort)species, version, moves, out var summary).Should().BeTrue();
+ summary.EggSpecies.Should().Be((ushort)species);
+ summary.FatherSpecies.Should().NotBe(0);
+ summary.ChainDepth.Should().BeGreaterThan(0);
+ }
+
+ [Theory]
+ [InlineData(HGSS, Mankey, Smeargle, Encore, Meditate, SmellingSalts)]
+ public void DetectsValidChainSmeargle(GameVersion version, Species species, Species father, params Move[] movelist)
+ {
+ var moves = GetMoves(movelist);
+ ChainBreedLegality.TryValidate((ushort)species, version, moves, out var summary).Should().BeTrue();
+ summary.EggSpecies.Should().Be((ushort)species);
+ summary.FatherSpecies.Should().Be((ushort)father);
+ summary.ChainDepth.Should().BeGreaterThan(0);
+ }
+
+ [Theory]
+ [InlineData(BD, Smoochum, false, PowderSnow)] // none in Human-like
+ [InlineData(BD, Smoochum, true, Confusion)] // via Alakazam
+ // Female only species: Male must be able to pass inheritable level up move.
+ public void DetectsInvalidInheritedLevelUpMove(GameVersion version, Species species, bool expect, params Move[] movelist)
+ {
+ var moves = GetMoves(movelist);
+ ChainBreedLegality.IsValid((ushort)species, version, moves).Should().Be(expect);
+ }
+
+ [Theory]
+ // For breeding purposes of Volbeat and Nidoran-M, the father must be able to pass all egg moves.
+ // If Gen6+, the mother can aggregate egg moves, so mother egg moves can be ignored.
+ // Volbeat can get Lunge from Mothim and Dewpider, but Dizzy Punch is only from Spinda and Lopunny.
+ // Level up moves are not considered, as Volbeat/etc can breed with Ditto to pass level-up and acquired egg chain moves.
+ // This check is only relevant up to Gen7, as egg move sharing became a thing.
+ [InlineData(US, Volbeat, false, DizzyPunch, Lunge)] // mother can't learn either, father must pass both (none can do both).
+ [InlineData(B2, Volbeat, false, DizzyPunch, SeismicToss)] // father must pass both in Gen5
+ [InlineData(B2, Volbeat, true, DizzyPunch)] // father must pass both in Gen5
+ [InlineData(B2, Volbeat, true, SeismicToss)] // father must pass both in Gen5
+ // Amnesia via Psyduck, Head Smash via Rampardos. No father can pass both when breeding with Nidoran-F.
+ [InlineData(X, NidoranM, false, Amnesia, HeadSmash)] // mother can't learn either, father must pass both (none can do both).
+ [InlineData(Y, NidoranM, true, BeatUp, HeadSmash)] // mother can pass Beat Up, father can pass Head Smash.
+ [InlineData(B2, NidoranM, false, BeatUp, HeadSmash)] // father must pass both in Gen5
+ public void DetectInvalidSpeciesMaleSplit(GameVersion version, Species species, bool expect, params Move[] movelist)
+ {
+ var moves = GetMoves(movelist);
+ ChainBreedLegality.IsValid((ushort)species, version, moves).Should().Be(expect);
+ }
+}
diff --git a/Tests/PKHeX.Core.Tests/Legality/Legal/Generation 2/043 - ODDISH - 2DCE.pk2 b/Tests/PKHeX.Core.Tests/Legality/Illegal/Gen2/043 - ODDISH - 2DCE noChain.pk2
similarity index 100%
rename from Tests/PKHeX.Core.Tests/Legality/Legal/Generation 2/043 - ODDISH - 2DCE.pk2
rename to Tests/PKHeX.Core.Tests/Legality/Illegal/Gen2/043 - ODDISH - 2DCE noChain.pk2