External Legality Checkers
PKHeX allows you to register custom validation logic that runs after the standard legality checks. This is done through theIExternalLegalityChecker interface.
Creating a Custom Checker
Step 1: Implement the Interface
using PKHeX.Core;
public class MyCustomChecker : IExternalLegalityChecker
{
// Unique identifier for this checker
public ushort Identity => 1000; // Use a unique number
// Display name
public string Name => "My Custom Validator";
// Perform validation
public void Verify(LegalityAnalysis analysis)
{
// Your custom validation logic here
var pk = analysis.Entity;
// Example: Check if nickname contains profanity
if (ContainsProfanity(pk.Nickname))
{
var result = new CheckResult
{
Judgement = Severity.Invalid,
Identifier = CheckIdentifier.Nickname,
Result = LegalityCheckResultCode.External,
Value = Identity // Store our identity for localization
};
analysis.AddLine(result);
}
}
// Provide localized messages
public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
{
// Return custom message for your results
return "Nickname contains inappropriate content.";
}
private bool ContainsProfanity(string text)
{
// Your implementation
return false;
}
}
Step 2: Register the Checker
using PKHeX.Core;
public class Program
{
static Program()
{
// Register at application startup
var checker = new MyCustomChecker();
ExternalLegalityCheck.RegisterChecker(checker);
}
static void Main()
{
// Now all legality checks will include your custom validator
var pk = LoadPokemon();
var analysis = new LegalityAnalysis(pk);
// Your checker ran automatically
if (!analysis.Valid)
{
Console.WriteLine("Validation failed (including custom checks)");
}
}
}
Example: Competitive Battle Validator
Validate Pokémon for competitive battle format rules:using PKHeX.Core;
using System.Collections.Generic;
using System.Linq;
public class CompetitiveBattleChecker : IExternalLegalityChecker
{
public ushort Identity => 2000;
public string Name => "Competitive Battle Validator";
private static readonly HashSet<ushort> BannedSpecies = new()
{
150, // Mewtwo
382, // Kyogre
383, // Groudon
384, // Rayquaza
// ... etc
};
private static readonly HashSet<ushort> BannedAbilities = new()
{
95, // Moody
// ... etc
};
private static readonly HashSet<ushort> BannedMoves = new()
{
560, // Dark Void
// ... etc
};
public void Verify(LegalityAnalysis analysis)
{
var pk = analysis.Entity;
// Check for banned species
if (BannedSpecies.Contains(pk.Species))
{
AddInvalid(analysis, CheckIdentifier.Encounter,
"Species is banned in competitive play.");
}
// Check for banned abilities
if (BannedAbilities.Contains((ushort)pk.Ability))
{
AddInvalid(analysis, CheckIdentifier.Ability,
"Ability is banned in competitive play.");
}
// Check for banned moves
var moves = new[] { pk.Move1, pk.Move2, pk.Move3, pk.Move4 };
foreach (var move in moves)
{
if (BannedMoves.Contains(move))
{
AddInvalid(analysis, CheckIdentifier.CurrentMove,
$"Move {move} is banned in competitive play.");
}
}
// Check level restriction (e.g., Level 50 for VGC)
if (pk.CurrentLevel > 50)
{
AddInvalid(analysis, CheckIdentifier.Level,
"Level must be 50 or lower for VGC.");
}
}
private void AddInvalid(LegalityAnalysis analysis, CheckIdentifier identifier, string message)
{
var result = new CheckResult
{
Judgement = Severity.Invalid,
Identifier = identifier,
Result = LegalityCheckResultCode.External,
Value = Identity
};
analysis.AddLine(result);
// Store message for localization
_messages[identifier] = message;
}
private Dictionary<CheckIdentifier, string> _messages = new();
public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
{
if (_messages.TryGetValue(chk.Identifier, out var message))
return message;
return "Failed competitive battle validation.";
}
}
Example: Event Distribution Validator
Validate event Pokémon against distribution dates:using PKHeX.Core;
using System;
public class EventDateChecker : IExternalLegalityChecker
{
public ushort Identity => 3000;
public string Name => "Event Date Validator";
public void Verify(LegalityAnalysis analysis)
{
var pk = analysis.Entity;
var encounter = analysis.EncounterMatch;
// Only check Mystery Gifts
if (encounter is not MysteryGift gift)
return;
// Check if the met date is within distribution window
if (pk is IEncounterDate dated)
{
var metDate = new DateTime(dated.MetYear + 2000, dated.MetMonth, dated.MetDay);
// Example: Check against known distribution dates
// You would load this from a database
var distributionStart = new DateTime(2023, 1, 1);
var distributionEnd = new DateTime(2023, 12, 31);
if (metDate < distributionStart || metDate > distributionEnd)
{
var result = new CheckResult
{
Judgement = Severity.Invalid,
Identifier = CheckIdentifier.Encounter,
Result = LegalityCheckResultCode.DateOutsideDistributionWindow,
Value = Identity
};
analysis.AddLine(result);
}
}
}
public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
{
return "Met date is outside the event distribution window.";
}
}
Example: Shiny Lock Validator
Add additional shiny lock validation:using PKHeX.Core;
using System.Collections.Generic;
public class ShinyLockChecker : IExternalLegalityChecker
{
public ushort Identity => 4000;
public string Name => "Shiny Lock Validator";
// Species that are shiny locked in certain games
private static readonly Dictionary<ushort, EntityContext> ShinyLocks = new()
{
{ 249, EntityContext.Gen7 }, // Lugia in USUM
{ 250, EntityContext.Gen7 }, // Ho-Oh in USUM
{ 716, EntityContext.Gen7 }, // Xerneas in USUM
{ 717, EntityContext.Gen7 }, // Yveltal in USUM
// ... etc
};
public void Verify(LegalityAnalysis analysis)
{
var pk = analysis.Entity;
if (!pk.IsShiny)
return; // Not shiny, no problem
var encounter = analysis.EncounterMatch;
// Check if this species has a shiny lock
if (ShinyLocks.TryGetValue(pk.Species, out var lockedContext))
{
if (encounter.Context == lockedContext)
{
var result = new CheckResult
{
Judgement = Severity.Invalid,
Identifier = CheckIdentifier.Shiny,
Result = LegalityCheckResultCode.External,
Value = Identity
};
analysis.AddLine(result);
}
}
}
public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
{
return "This Pokémon is shiny locked in its origin game.";
}
}
Example: Ribbon Validator
Validate that ribbon combinations are possible:using PKHeX.Core;
public class RibbonLogicChecker : IExternalLegalityChecker
{
public ushort Identity => 5000;
public string Name => "Ribbon Logic Validator";
public void Verify(LegalityAnalysis analysis)
{
var pk = analysis.Entity;
// Example: Champion Ribbon requires beating Elite Four
// but Partner Ribbon is only for starter Pokémon
if (pk is IRibbonSetCommon6 ribbons)
{
if (ribbons.RibbonChampionKalos && ribbons.RibbonPartner)
{
// This combination might be suspicious
var result = new CheckResult
{
Judgement = Severity.Fishy,
Identifier = CheckIdentifier.Ribbon,
Result = LegalityCheckResultCode.External,
Value = Identity
};
analysis.AddLine(result);
}
}
// Check for impossible ribbon counts
if (pk is IRibbonSetCommon8 ribbons8)
{
int ribbonCount = CountRibbons(ribbons8);
// Example: No single Pokémon should have every ribbon
if (ribbonCount > 50) // Arbitrary threshold
{
var result = new CheckResult
{
Judgement = Severity.Fishy,
Identifier = CheckIdentifier.Ribbon,
Result = LegalityCheckResultCode.External,
Value = Identity
};
analysis.AddLine(result);
}
}
}
private int CountRibbons(object ribbons)
{
// Count non-zero ribbon properties
int count = 0;
var type = ribbons.GetType();
foreach (var prop in type.GetProperties())
{
if (prop.PropertyType == typeof(bool))
{
if ((bool)prop.GetValue(ribbons))
count++;
}
}
return count;
}
public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
{
return "Ribbon combination is unusual or impossible.";
}
}
Accessing Analysis Data
Your checker has full access to the analysis:public void Verify(LegalityAnalysis analysis)
{
// The Pokémon being checked
var pk = analysis.Entity;
// Personal info (species data)
var personal = analysis.PersonalInfo;
// Matched encounter
var encounter = analysis.EncounterMatch;
var original = analysis.EncounterOriginal;
// Where it came from
var origin = analysis.SlotOrigin;
// Detailed info
var info = analysis.Info;
var generation = info.Generation;
var moves = info.Moves;
var relearn = info.Relearn;
var evolutions = info.EvoChainsAllGens;
var pidiv = info.PIDIV;
// Existing check results
var results = analysis.Results;
// You can check what other verifiers found
var abilityCheck = results.FirstOrDefault(r =>
r.Identifier == CheckIdentifier.Ability);
if (abilityCheck.Valid)
{
// Ability passed standard checks
}
}
Creating Check Results
public void Verify(LegalityAnalysis analysis)
{
// Invalid result
var invalid = new CheckResult
{
Judgement = Severity.Invalid,
Identifier = CheckIdentifier.Misc,
Result = LegalityCheckResultCode.External,
Value = Identity // Your checker ID
};
// Fishy/suspicious result
var fishy = new CheckResult
{
Judgement = Severity.Fishy,
Identifier = CheckIdentifier.Trainer,
Result = LegalityCheckResultCode.External,
Value = Identity
};
// Valid with custom message
var valid = new CheckResult
{
Judgement = Severity.Valid,
Identifier = CheckIdentifier.Encounter,
Result = LegalityCheckResultCode.External,
Value = Identity
};
// Add results
analysis.AddLine(invalid);
analysis.AddLine(fishy);
analysis.AddLine(valid);
}
Including Numeric Arguments
public void Verify(LegalityAnalysis analysis)
{
var pk = analysis.Entity;
// Single 32-bit argument
var result1 = new CheckResult
{
Judgement = Severity.Invalid,
Identifier = CheckIdentifier.Level,
Result = LegalityCheckResultCode.External,
Value = 50 // Max level allowed
};
// Two 16-bit arguments
var result2 = new CheckResult
{
Judgement = Severity.Invalid,
Identifier = CheckIdentifier.CurrentMove,
Result = LegalityCheckResultCode.External,
Argument = 2, // Move slot
Argument2 = 123 // Move ID
};
analysis.AddLine(result1);
analysis.AddLine(result2);
}
Localization
Provide messages in multiple languages:using System.Collections.Generic;
public class MultilingualChecker : IExternalLegalityChecker
{
public ushort Identity => 6000;
public string Name => "Multilingual Validator";
private Dictionary<string, Dictionary<string, string>> _messages = new()
{
["en"] = new()
{
["nickname_invalid"] = "Nickname contains invalid characters.",
["trainer_suspicious"] = "Trainer name is suspicious."
},
["es"] = new()
{
["nickname_invalid"] = "El apodo contiene caracteres no válidos.",
["trainer_suspicious"] = "El nombre del entrenador es sospechoso."
},
["ja"] = new()
{
["nickname_invalid"] = "ニックネームに無効な文字が含まれています。",
["trainer_suspicious"] = "トレーナー名が疑わしいです。"
}
};
public void Verify(LegalityAnalysis analysis)
{
// Your validation logic
}
public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
{
// Get current language from localization settings
var language = localization.Language.ToString().ToLower();
// Determine which message to return based on context
var messageKey = chk.Identifier switch
{
CheckIdentifier.Nickname => "nickname_invalid",
CheckIdentifier.Trainer => "trainer_suspicious",
_ => "unknown"
};
// Return localized message
if (_messages.TryGetValue(language, out var messages))
{
if (messages.TryGetValue(messageKey, out var message))
return message;
}
// Fallback to English
return _messages["en"][messageKey];
}
}
Unregistering Checkers
var checker = new MyCustomChecker();
// Register
ExternalLegalityCheck.RegisterChecker(checker);
// Later, if needed, unregister
ExternalLegalityCheck.UnregisterChecker(checker);
Best Practices
Use Unique Identity Numbers
Use Unique Identity Numbers
Choose identity numbers that won’t conflict with other checkers. Use a range like 1000-9999 for custom checkers.
public ushort Identity => 1234; // Pick a unique number
Don't Duplicate Standard Checks
Don't Duplicate Standard Checks
PKHeX already validates most standard legality. Focus on:
- Game-specific rules not in PKHeX
- Competitive format restrictions
- Community-specific rules
- Additional sanity checks
Use Appropriate Severity
Use Appropriate Severity
Severity.Invalid: Definitively illegalSeverity.Fishy: Suspicious but technically validSeverity.Valid: Passed your custom check (rare)
Provide Good Error Messages
Provide Good Error Messages
Your
Localize method should return clear, actionable messages.public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
{
return "Move is banned in VGC 2024 Series 1.";
}
Complete Example
using PKHeX.Core;
using System;
using System.Collections.Generic;
public class CustomValidator : IExternalLegalityChecker
{
public ushort Identity => 7000;
public string Name => "Custom Validator";
private List<string> _errors = new();
public void Verify(LegalityAnalysis analysis)
{
_errors.Clear();
var pk = analysis.Entity;
// Check 1: Validate nickname length
if (pk.Nickname.Length > 12)
{
_errors.Add("Nickname too long");
AddInvalid(analysis, CheckIdentifier.Nickname);
}
// Check 2: Validate level
if (pk.CurrentLevel > 100)
{
_errors.Add("Level exceeds maximum");
AddInvalid(analysis, CheckIdentifier.Level);
}
// Check 3: Validate moves
var moves = new[] { pk.Move1, pk.Move2, pk.Move3, pk.Move4 };
var duplicates = moves.Where(m => m != 0)
.GroupBy(m => m)
.Where(g => g.Count() > 1);
if (duplicates.Any())
{
_errors.Add("Duplicate moves detected");
AddInvalid(analysis, CheckIdentifier.CurrentMove);
}
}
private void AddInvalid(LegalityAnalysis analysis, CheckIdentifier identifier)
{
var result = new CheckResult
{
Judgement = Severity.Invalid,
Identifier = identifier,
Result = LegalityCheckResultCode.External,
Value = Identity
};
analysis.AddLine(result);
}
public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
{
if (_errors.Count > 0)
return string.Join("; ", _errors);
return "Custom validation failed.";
}
}
// Usage
public class Program
{
static void Main()
{
// Register at startup
ExternalLegalityCheck.RegisterChecker(new CustomValidator());
// Now use LegalityAnalysis normally
var pk = LoadPokemon();
var analysis = new LegalityAnalysis(pk);
if (!analysis.Valid)
{
foreach (var result in analysis.Results.Where(r => !r.Valid))
{
Console.WriteLine($"{result.Identifier}: {result.Result}");
}
}
}
}
Next Steps
Overview
Return to legality system overview
Running Checks
Learn how to perform legality checks