diff --git a/PKHeX.Core/Editing/Saves/Slots/Extensions.cs b/PKHeX.Core/Editing/Saves/Slots/Extensions.cs index 7fabcfe67..4ced2cca6 100644 --- a/PKHeX.Core/Editing/Saves/Slots/Extensions.cs +++ b/PKHeX.Core/Editing/Saves/Slots/Extensions.cs @@ -237,7 +237,7 @@ private static List GetExtraSlots9(SAV9SV sav) if (sav.Blocks.TryGetBlock(SaveBlockAccessor9SV.KSurpriseTrade, out var surprise)) { - var st = surprise.Data; + var st = surprise.Data.AsMemory(); list.Add(new(st[0x198..], 0) { Type = StorageSlotType.Misc }); // my upload list.Add(new(st[0x2C..], 1) { Type = StorageSlotType.Misc }); // received from others } diff --git a/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterTrade1.cs b/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterTrade1.cs index 9844ff1a8..9bb50026b 100644 --- a/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterTrade1.cs +++ b/PKHeX.Core/Legality/Encounters/Templates/Gen1/EncounterTrade1.cs @@ -72,7 +72,7 @@ private bool IsNicknameValid(PKM pk, ReadOnlySpan nick) private static bool IsTrainerNameValid(PKM pk) { if (pk.Format <= 2) - return pk.OriginalTrainerTrash is [StringConverter12.G1TradeOTCode, StringConverter12.G1TerminatorCode, ..]; + return pk.OriginalTrainerTrash is [StringConverter1.TradeOTCode, StringConverter1.TerminatorCode, ..]; var lang = pk.Language; var expect = StringConverter12Transporter.GetTradeNameGen1(lang); return pk.OriginalTrainerName == expect; @@ -125,7 +125,7 @@ public PK1 ConvertToPKM(ITrainerInfo tr, EncounterCriteria criteria) Type1 = pi.Type1, Type2 = pi.Type2, }; - pk.OriginalTrainerTrash[0] = StringConverter12.G1TradeOTCode; + pk.OriginalTrainerTrash[0] = StringConverter1.TradeOTCode; EncounterUtil.SetEncounterMoves(pk, Version, level); if (EvolveOnTrade) diff --git a/PKHeX.Core/Legality/Verifiers/NicknameVerifier.cs b/PKHeX.Core/Legality/Verifiers/NicknameVerifier.cs index ab600835f..337368d90 100644 --- a/PKHeX.Core/Legality/Verifiers/NicknameVerifier.cs +++ b/PKHeX.Core/Legality/Verifiers/NicknameVerifier.cs @@ -305,17 +305,17 @@ private static void VerifyNicknameTrade(LegalityAnalysis data, IEncounterTemplat private void VerifyG1NicknameWithinBounds(LegalityAnalysis data, ReadOnlySpan str) { var pk = data.Entity; - if (StringConverter12.GetIsG1English(str)) + if (StringConverter1.GetIsEnglish(str)) { if (str.Length > 10) data.AddLine(GetInvalid(LNickLengthLong)); } - else if (StringConverter12.GetIsG1Japanese(str)) + else if (StringConverter1.GetIsJapanese(str)) { if (str.Length > 5) data.AddLine(GetInvalid(LNickLengthLong)); } - else if (pk.Korean && StringConverter2KOR.GetIsG2Korean(str)) + else if (pk.Korean && StringConverter2KOR.GetIsKorean(str)) { if (str.Length > 5) data.AddLine(GetInvalid(LNickLengthLong)); diff --git a/PKHeX.Core/Legality/Verifiers/TrainerNameVerifier.cs b/PKHeX.Core/Legality/Verifiers/TrainerNameVerifier.cs index da55d1ee2..aa7fa0958 100644 --- a/PKHeX.Core/Legality/Verifiers/TrainerNameVerifier.cs +++ b/PKHeX.Core/Legality/Verifiers/TrainerNameVerifier.cs @@ -134,21 +134,21 @@ private void VerifyGBOTWithinBounds(LegalityAnalysis data, ReadOnlySpan st { if (str.Length > 5) data.AddLine(GetInvalid(LOTLong)); - if (!StringConverter12.GetIsG1Japanese(str)) + if (!StringConverter1.GetIsJapanese(str)) data.AddLine(GetInvalid(LG1CharOT)); } else if (pk.Korean) { if (str.Length > 5) data.AddLine(GetInvalid(LOTLong)); - if (!StringConverter2KOR.GetIsG2Korean(str)) + if (!StringConverter2KOR.GetIsKorean(str)) data.AddLine(GetInvalid(LG1CharOT)); } else { if (str.Length > 7) data.AddLine(GetInvalid(LOTLong)); - if (!StringConverter12.GetIsG1English(str)) + if (!StringConverter1.GetIsEnglish(str)) data.AddLine(GetInvalid(LG1CharOT)); } } diff --git a/PKHeX.Core/PKM/PK1.cs b/PKHeX.Core/PKM/PK1.cs index d60d07c19..13eb509c5 100644 --- a/PKHeX.Core/PKM/PK1.cs +++ b/PKHeX.Core/PKM/PK1.cs @@ -19,6 +19,14 @@ public sealed class PK1 : GBPKML, IPersonalType public PK1(bool jp = false) : base(PokeCrypto.SIZE_1PARTY, jp) { } public PK1(byte[] decryptedData, bool jp = false) : base(EnsurePartySize(decryptedData), jp) { } + public PK1(ReadOnlySpan data, ReadOnlySpan ot, ReadOnlySpan nick) + : this(ot.Length == StringLengthJapanese) + { + data.CopyTo(Data); + ot.CopyTo(OriginalTrainerTrash); + nick.CopyTo(NicknameTrash); + } + private static byte[] EnsurePartySize(byte[] data) { if (data.Length != PokeCrypto.SIZE_1PARTY) @@ -34,7 +42,7 @@ public override PK1 Clone() return clone; } - protected override byte[] Encrypt() => new PokeList1(this).Write(); + protected override byte[] Encrypt() => PokeList1.WrapSingle(this); #region Stored Attributes public byte SpeciesInternal { get => Data[0]; set => Data[0] = value; } // raw access @@ -235,8 +243,29 @@ public PK7 ConvertToPK7() private string GetTransferTrainerName(int lang) { - if (OriginalTrainerTrash[0] == StringConverter12.G1TradeOTCode) // In-game Trade + if (OriginalTrainerTrash[0] == StringConverter1.TradeOTCode) // In-game Trade return StringConverter12Transporter.GetTradeNameGen1(lang); return StringConverter12Transporter.GetString(OriginalTrainerTrash, Japanese); } + + protected override string GetString(ReadOnlySpan data) + { + return StringConverter1.GetString(data, Japanese); + } + + protected override int SetString(Span destBuffer, ReadOnlySpan value, int maxLength, + StringConverterOption option = StringConverterOption.None) + { + return StringConverter1.SetString(destBuffer, value, maxLength, Japanese, option); + } + + /// + /// Gets a checksum over all the entity's data using a single list to wrap all components. + /// + public ushort GetSingleListChecksum() + { + Span tmp = stackalloc byte[PokeList1.GetListLengthSingle(Japanese)]; + PokeList1.WrapSingle(this, tmp); + return Checksums.CRC16_CCITT(tmp); + } } diff --git a/PKHeX.Core/PKM/PK2.cs b/PKHeX.Core/PKM/PK2.cs index 3f573fc98..0b907bad1 100644 --- a/PKHeX.Core/PKM/PK2.cs +++ b/PKHeX.Core/PKM/PK2.cs @@ -8,8 +8,7 @@ public sealed class PK2 : GBPKML, ICaughtData2 { public override PersonalInfo2 PersonalInfo => PersonalTable.C[Species]; - internal const byte EggSpeciesValue = 0xFD; - public override bool Valid => Species is <= Legal.MaxSpeciesID_2 or EggSpeciesValue; // egg + public override bool Valid => Species <= Legal.MaxSpeciesID_2; public override int SIZE_PARTY => PokeCrypto.SIZE_2PARTY; public override int SIZE_STORED => PokeCrypto.SIZE_2STORED; @@ -20,6 +19,14 @@ public sealed class PK2 : GBPKML, ICaughtData2 public PK2(bool jp = false) : base(PokeCrypto.SIZE_2PARTY, jp) { } public PK2(byte[] decryptedData, bool jp = false) : base(EnsurePartySize(decryptedData), jp) { } + public PK2(ReadOnlySpan data, ReadOnlySpan ot, ReadOnlySpan nick) + : this(ot.Length == StringLengthJapanese) + { + data.CopyTo(Data); + ot.CopyTo(OriginalTrainerTrash); + nick.CopyTo(NicknameTrash); + } + private static byte[] EnsurePartySize(byte[] data) { if (data.Length != PokeCrypto.SIZE_2PARTY) @@ -35,10 +42,11 @@ public override PK2 Clone() return clone; } - protected override byte[] Encrypt() => new PokeList2(this).Write(); + protected override byte[] Encrypt() => PokeList2.WrapSingle(this); #region Stored Attributes public override ushort Species { get => Data[0]; set => Data[0] = (byte)value; } + public byte SpeciesInternal { get => Data[0]; set => Data[0] = value; } // Alias with a different type. public override int SpriteItem => ItemConverter.GetItemFuture2((byte)HeldItem); public override int HeldItem { get => Data[0x1]; set => Data[0x1] = (byte)value; } public override ushort Move1 { get => Data[2]; set => Data[2] = (byte)value; } @@ -209,7 +217,7 @@ public PK7 ConvertToPK7() private string GetTransferTrainerName(int lang) { - if (OriginalTrainerTrash[0] == StringConverter12.G1TradeOTCode) // In-game Trade + if (OriginalTrainerTrash[0] == StringConverter1.TradeOTCode) // In-game Trade return StringConverter12Transporter.GetTradeNameGen1(lang); if (Korean) return OriginalTrainerName; @@ -250,4 +258,29 @@ public SK2 ConvertToSK2() => new(Japanese) Nickname = Nickname, OriginalTrainerName = OriginalTrainerName, }; + + protected override string GetString(ReadOnlySpan data) + { + if (Korean) + return StringConverter2KOR.GetString(data); + return StringConverter2.GetString(data, Language); + } + + protected override int SetString(Span destBuffer, ReadOnlySpan value, int maxLength, + StringConverterOption option = StringConverterOption.None) + { + if (Korean) + return StringConverter2KOR.SetString(destBuffer, value, maxLength, option); + return StringConverter2.SetString(destBuffer, value, maxLength, Language, option); + } + + /// + /// Gets a checksum over all the entity's data using a single list to wrap all components. + /// + public ushort GetSingleListChecksum() + { + Span tmp = stackalloc byte[PokeList2.GetListLengthSingle(Japanese)]; + PokeList2.WrapSingle(this, tmp); + return Checksums.CRC16_CCITT(tmp); + } } diff --git a/PKHeX.Core/PKM/SK2.cs b/PKHeX.Core/PKM/SK2.cs index 011c50afb..7e169ec7e 100644 --- a/PKHeX.Core/PKM/SK2.cs +++ b/PKHeX.Core/PKM/SK2.cs @@ -8,7 +8,7 @@ public sealed class SK2 : GBPKM, ICaughtData2 { public override PersonalInfo2 PersonalInfo => PersonalTable.C[Species]; - public override bool Valid => Species <= 252; + public override bool Valid => Species <= Legal.MaxSpeciesID_2; public override int SIZE_PARTY => PokeCrypto.SIZE_2STADIUM; public override int SIZE_STORED => PokeCrypto.SIZE_2STADIUM; @@ -94,13 +94,13 @@ public bool IsRental public override string Nickname { - get => StringConverter12.GetString(NicknameTrash, Japanese); - set => StringConverter12.SetString(NicknameTrash, value, StringLength, Japanese, StringConverterOption.None); + get => StringConverter2.GetString(NicknameTrash, Language); + set => StringConverter2.SetString(NicknameTrash, value, StringLength, Language, StringConverterOption.None); } public override string OriginalTrainerName { - get => StringConverter12.GetString(OriginalTrainerTrash, Japanese); + get => StringConverter2.GetString(OriginalTrainerTrash, Language); set { if (IsRental) @@ -108,7 +108,7 @@ public override string OriginalTrainerName OriginalTrainerTrash.Clear(); return; } - StringConverter12.SetString(OriginalTrainerTrash, value, StringLength, Japanese, StringConverterOption.None); + StringConverter2.SetString(OriginalTrainerTrash, value, StringLength, Language, StringConverterOption.None); } } @@ -135,7 +135,7 @@ public override string OriginalTrainerName protected override void GetNonNickname(int language, Span data) { var name = SpeciesName.GetSpeciesNameGeneration(Species, language, 2); - StringConverter12.SetString(data, name, data.Length, Japanese, StringConverterOption.Clear50); + StringConverter2.SetString(data, name, data.Length, Language, StringConverterOption.Clear50); } public override void SetNotNicknamed(int language) => GetNonNickname(language, NicknameTrash); @@ -184,18 +184,18 @@ public PK2 ConvertToPK2() => new(Japanese) private static bool IsJapanese(ReadOnlySpan data) { const byte empty = 0; - const byte terminator = StringConverter12.G1TerminatorCode; + const byte terminator = StringConverter2.TerminatorCode; var ot = data.Slice(0x30, StringLength); if (ot[6..].ContainsAnyExcept(empty, terminator)) return false; - if (!StringConverter12.GetIsG1Japanese(ot)) + if (!StringConverter2.GetIsJapanese(ot)) return false; var nick = data.Slice(0x24, StringLength); if (nick[6..].ContainsAnyExcept(empty, terminator)) return false; - if (!StringConverter12.GetIsG1Japanese(nick)) + if (!StringConverter2.GetIsJapanese(nick)) return false; return true; @@ -203,9 +203,9 @@ private static bool IsJapanese(ReadOnlySpan data) private static bool IsEnglish(ReadOnlySpan data) { - if (!StringConverter12.GetIsG1English(data.Slice(0x30, StringLength))) + if (!StringConverter2.GetIsEnglish(data.Slice(0x30, StringLength))) return false; - if (!StringConverter12.GetIsG1English(data.Slice(0x24, StringLength))) + if (!StringConverter2.GetIsEnglish(data.Slice(0x24, StringLength))) return false; return true; } diff --git a/PKHeX.Core/PKM/Shared/GBPKM.cs b/PKHeX.Core/PKM/Shared/GBPKM.cs index ba520a7ff..869067b7d 100644 --- a/PKHeX.Core/PKM/Shared/GBPKM.cs +++ b/PKHeX.Core/PKM/Shared/GBPKM.cs @@ -74,9 +74,12 @@ public sealed override int Language return (int)LanguageID.Japanese; if (Korean) return (int)LanguageID.Korean; - if (StringConverter12.IsG12German(OriginalTrainerTrash)) + if (StringConverter1.IsG12German(OriginalTrainerTrash)) return (int)LanguageID.German; // german - int lang = SpeciesName.GetSpeciesNameLanguage(Species, Nickname, Format); + + Span nick = stackalloc char[11]; + int len = StringConverter1.LoadString(NicknameTrash, nick, false); + int lang = SpeciesName.GetSpeciesNameLanguage(Species, nick[..len], Format); if (lang > 0) return lang; return 0; diff --git a/PKHeX.Core/PKM/Shared/GBPKML.cs b/PKHeX.Core/PKM/Shared/GBPKML.cs index 56b52974a..6a4cd8187 100644 --- a/PKHeX.Core/PKM/Shared/GBPKML.cs +++ b/PKHeX.Core/PKM/Shared/GBPKML.cs @@ -29,8 +29,8 @@ protected GBPKML([ConstantExpected] int size, bool jp = false) : base(size) // initialize string buffers RawOT = new byte[strLen]; RawNickname = new byte[strLen]; - OriginalTrainerTrash.Fill(StringConverter12.G1TerminatorCode); - NicknameTrash.Fill(StringConverter12.G1TerminatorCode); + OriginalTrainerTrash.Fill(StringConverter1.TerminatorCode); + NicknameTrash.Fill(StringConverter1.TerminatorCode); } protected GBPKML(byte[] data, bool jp = false) : base(data) @@ -40,8 +40,8 @@ protected GBPKML(byte[] data, bool jp = false) : base(data) // initialize string buffers RawOT = new byte[strLen]; RawNickname = new byte[strLen]; - OriginalTrainerTrash.Fill(StringConverter12.G1TerminatorCode); - NicknameTrash.Fill(StringConverter12.G1TerminatorCode); + OriginalTrainerTrash.Fill(StringConverter1.TerminatorCode); + NicknameTrash.Fill(StringConverter1.TerminatorCode); } public override void SetNotNicknamed(int language) => GetNonNickname(language, RawNickname); @@ -49,7 +49,7 @@ protected GBPKML(byte[] data, bool jp = false) : base(data) protected override void GetNonNickname(int language, Span data) { var name = SpeciesName.GetSpeciesNameGeneration(Species, language, Format); - SetString(name, data, data.Length, StringConverterOption.Clear50); + SetString(data, name, data.Length, StringConverterOption.Clear50); if (Korean) return; @@ -61,19 +61,10 @@ protected override void GetNonNickname(int language, Span data) } } - private string GetString(ReadOnlySpan span) - { - if (Korean) - return StringConverter2KOR.GetString(span); - return StringConverter12.GetString(span, Japanese); - } + protected abstract string GetString(ReadOnlySpan data); - private int SetString(ReadOnlySpan value, Span destBuffer, int maxLength, StringConverterOption option = StringConverterOption.None) - { - if (Korean) - return StringConverter2KOR.SetString(destBuffer, value, maxLength, option); - return StringConverter12.SetString(destBuffer, value, maxLength, Japanese, option); - } + protected abstract int SetString(Span destBuffer, ReadOnlySpan value, int maxLength, + StringConverterOption option = StringConverterOption.None); public sealed override string Nickname { @@ -103,6 +94,6 @@ private void SetStringKeepTerminatorStyle(ReadOnlySpan value, Span e // Reset the destination buffer based on the termination style of the existing string. bool zeroed = exist.Contains(0); StringConverterOption converterOption = (zeroed) ? StringConverterOption.ClearZero : StringConverterOption.Clear50; - SetString(value, exist, value.Length, converterOption); + SetString(exist, value, value.Length, converterOption); } } diff --git a/PKHeX.Core/PKM/Shared/PokeList1.cs b/PKHeX.Core/PKM/Shared/PokeList1.cs index 6b1b75b1d..f5d01ff1d 100644 --- a/PKHeX.Core/PKM/Shared/PokeList1.cs +++ b/PKHeX.Core/PKM/Shared/PokeList1.cs @@ -5,24 +5,266 @@ namespace PKHeX.Core; /// /// List of prefixed by a count. /// -public sealed class PokeList1 : PokeListGB +public static class PokeList1 { - protected override byte GetSpeciesBoxIdentifier(PK1 pk) => SpeciesConverter.GetInternal1(pk.Species); - protected override PK1 GetEntry(byte[] dat, ReadOnlySpan otname, ReadOnlySpan nick, bool egg) + // Structure: + // u8 Count of slots filled + // s8[capacity+1] GB Species ID in Slot (-1 = no species) + // pkx[capacity] GB PKM data (no strings) + // str[capacity] Trainer Name + // str[capacity] Nickname + // + // where, + // - str has variable size (jp/int) + // - pkx is different size for Gen1/Gen2 + + public const byte SlotEmpty = 0xFF; + + /// + /// Gets the species identifier for the slot. + /// + public static byte GetHeaderIdentifierMark(PK1 pk) { - var result = new PK1(dat, Japanese); // no eggs - otname.CopyTo(result.OriginalTrainerTrash); - nick.CopyTo(result.NicknameTrash); - return result; + var species = pk.SpeciesInternal; + if (species == 0) + return SlotEmpty; + // Let out-of-bounds species fall through. + return species; } - protected override int GetEntrySize() => GetEntrySize(IsFormatParty); + /// + /// Checks if the slot has content present. + /// + /// Identifying summary mark from the list header. + /// True if present. + private static bool IsPresent(byte marker) => marker is not (0 or SlotEmpty); - public PokeList1(byte[] d, PokeListType c = PokeListType.Single, bool jp = false) : base(d, c, jp) { } - public PokeList1(PokeListType c = PokeListType.Single, bool jp = false) : base(c, jp) { } - public PokeList1(byte[] d) : base(d, PokeListType.Single, d.Length == PokeCrypto.SIZE_1JLIST) { } - public PokeList1(PK1 pk) : base(pk) { } + /// + /// Counts the number of present slots in the list. + /// + /// List header + /// Count of slots allowed in the list + /// Offset jump between slot indexes; used to parse multi-single lists instead of a multi-list. + private static int CountPresent(ReadOnlySpan input, int capacity, int lerp = 0) + { + var count = 0; + for (int i = 0; i < capacity; i++) + { + var mark = input[(i * lerp) + 1]; + if (IsPresent(mark)) + count++; + } + return count; + } - private static int GetEntrySize(bool party) => party ? PokeCrypto.SIZE_1PARTY : PokeCrypto.SIZE_1STORED; - public static int GetDataLength(PokeListType c, bool jp) => GetDataSize(c, jp, GetEntrySize(IsCapacityPartyFormat(c))); + private static bool IsJapaneseList(int length) => length == PokeCrypto.SIZE_1JLIST; + private static bool IsJapaneseString(int length) => length == GBPKML.StringLengthJapanese; + public static int GetListLengthSingle(bool jp) => jp ? PokeCrypto.SIZE_1JLIST : PokeCrypto.SIZE_1ULIST; + private static int GetBodyLength(bool party) => party ? PokeCrypto.SIZE_1PARTY : PokeCrypto.SIZE_1STORED; + private static int GetStringLength(bool jp) => jp ? GBPKML.StringLengthJapanese : GBPKML.StringLengthNotJapan; + + /// + /// Checks if the header is sane. + /// + /// List header + /// Count of slots allowed in the list + public static bool IsValidHeader(ReadOnlySpan input, int capacity) + { + if (input.Length < 2) + return false; + + var count = input[0]; + if (count > capacity) + return false; + + var expectLength = 1 + (capacity + 1); + if (input.Length < expectLength) + return false; + + for (int i = 0; i < capacity; i++) + { + var mark = input[1 + i]; + bool present = IsPresent(mark); + if (present != (i < count)) + return false; + } + return true; + } + + /// + /// Reads an entity from a single-slot list. + /// + public static PK1 ReadFromSingle(ReadOnlySpan input) + { + bool jp = IsJapaneseList(input.Length); + var stringLength = GetStringLength(jp); + return ReadFromList(input, stringLength); + } + + /// + /// Reads an entity from a list. + /// + /// List data + /// Trainer and Nickname string length + /// Count of slots allowed in the list + /// List stores party stats for each entity + /// Entity index to read + public static PK1 ReadFromList(ReadOnlySpan input, int stringLength, int capacity = 1, bool isParty = true, int index = 0) + { + var start = 1 + (capacity + 1); + int sizeBody = GetBodyLength(isParty); + + var ofsBody = start + (sizeBody * index); + var ofsStr1 = start + (sizeBody * capacity) + (stringLength * index); + var ofsStr2 = ofsStr1 + (capacity * stringLength); + + var body = input.Slice(ofsBody, sizeBody); + var stringSection1 = input.Slice(ofsStr1, stringLength); + var stringSection2 = input.Slice(ofsStr2, stringLength); + + return new PK1(body, stringSection1, stringSection2); + } + + /// + /// Writes an entity to a list. + /// + /// List data + /// Entity to write + /// Count of slots allowed in the list + /// List stores party stats for each entity + /// Entity index to write + public static void WriteToList(Span output, PK1 pk, int capacity = 1, bool isParty = true, int index = 0) + { + var start = 1 + (capacity + 1); + var sizeBody = GetBodyLength(isParty); + var stringLength = pk.OriginalTrainerTrash.Length; + + var ofsBody = start + (sizeBody * index); + var ofsStr1 = start + (sizeBody * capacity) + (stringLength * index); + var ofsStr2 = ofsStr1 + (capacity * stringLength); + + var body = output.Slice(ofsBody, sizeBody); + var stringSection1 = output.Slice(ofsStr1, stringLength); + var stringSection2 = output.Slice(ofsStr2, stringLength); + + pk.Data.AsSpan()[..sizeBody].CopyTo(body); + pk.OriginalTrainerTrash.CopyTo(stringSection1); + pk.NicknameTrash.CopyTo(stringSection2); + + output[1 + index] = GetHeaderIdentifierMark(pk); + output[0] = (byte)CountPresent(output, capacity); + output[1 + capacity] = SlotEmpty; // cap off the list + } + + /// + /// Unpacks a multi-slot list into many single-slot lists. + /// + /// Multi-slot list + /// Destination to write each single-slot list + /// Count of slots allowed in the list + /// Trainer and Nickname string length + /// List stores party stats for each entity + public static void Unpack(ReadOnlySpan input, Span output, int stringLength, int capacity, bool isParty) + { + var lengthBody = GetBodyLength(isParty); + var lengthParty = GetBodyLength(true); + + var ofsBody = 1 + (capacity + 1); + var ofsStr1 = ofsBody + (capacity * lengthBody); + var ofsStr2 = ofsStr1 + (capacity * stringLength); + + for (int i = 0; i < capacity; i++) + { + var species = input[1 + i]; + var body = input.Slice(ofsBody, lengthBody); + var stringSection1 = input.Slice(ofsStr1, stringLength); + var stringSection2 = input.Slice(ofsStr2, stringLength); + + output[0] = 1; + output[1] = species; + output = output[3..]; + body.CopyTo(output); + output = output[lengthParty..]; + stringSection1.CopyTo(output); + output = output[stringLength..]; + stringSection2.CopyTo(output); + output = output[stringLength..]; + + ofsBody += lengthBody; + ofsStr1 += stringLength; + ofsStr2 += stringLength; + } + } + + /// + /// Packs many single-slot lists into a multi-slot list. + /// + /// Source single-slot lists + /// Destination multi-slot list + /// Trainer and Nickname string length + /// Count of slots allowed in the list + /// List stores party stats for each entity + public static bool MergeSingles(ReadOnlySpan input, Span output, int stringLength, int capacity, bool isParty) + { + // Collect the count of set slots + var jp = IsJapaneseString(stringLength); + var size = GetListLengthSingle(jp); + + int count = CountPresent(input, capacity, size); + if (count == 0 && !output.ContainsAnyExcept(0)) + return false; // No need to merge if all empty and dest is not initialized. + + output[0] = (byte)count; // ensure written list is valid + // Put non-zero species first, then empty slots + var ctr = 0; + var emptyIndex = count; + + var lengthBody = GetBodyLength(isParty); + var lengthParty = GetBodyLength(true); + for (int i = 0; i < capacity; i++) + { + var single = input.Slice(i * size, size); + var marker = single[1]; // assume correct, don't look in body data. + + var index = IsPresent(marker) ? ctr++ : emptyIndex++; + output[1 + index] = marker; + + // Source Properties + var body = single.Slice(3, lengthBody); + var str1 = single.Slice(3 + lengthParty, stringLength); + var str2 = single.Slice(3 + lengthParty + stringLength, stringLength); + + // Destination Properties + var ofsStart = 1 + (capacity + 1); + var ofsBody = ofsStart + (index * lengthBody); + var ofsStr1 = ofsStart + (capacity * lengthBody) + (index * stringLength); + var ofsStr2 = ofsStr1 + (capacity * stringLength); + var destBody = output.Slice(ofsBody, lengthBody); + var destStr1 = output.Slice(ofsStr1, stringLength); + var destStr2 = output.Slice(ofsStr2, stringLength); + + body.CopyTo(destBody); + str1.CopyTo(destStr1); + str2.CopyTo(destStr2); + } + + output[1 + capacity] = SlotEmpty; // cap off the list + return true; + } + + /// + public static byte[] WrapSingle(PK1 pk) + { + var length = GetListLengthSingle(pk.Japanese); + var data = new byte[length]; + WrapSingle(pk, data); + return data; + } + + /// + /// Wraps a single entity into a single-slot list. + /// + /// Entity to wrap + /// Destination to write the single-slot list + public static void WrapSingle(PK1 pk, Span output) => WriteToList(output, pk); } diff --git a/PKHeX.Core/PKM/Shared/PokeList2.cs b/PKHeX.Core/PKM/Shared/PokeList2.cs index 4ac866df0..d886d0d65 100644 --- a/PKHeX.Core/PKM/Shared/PokeList2.cs +++ b/PKHeX.Core/PKM/Shared/PokeList2.cs @@ -3,26 +3,272 @@ namespace PKHeX.Core; /// -/// List of prefixed by a count. +/// List of prefixed by a count. /// -public sealed class PokeList2 : PokeListGB +public static class PokeList2 { - protected override byte GetSpeciesBoxIdentifier(PK2 pk) => pk.IsEgg ? PK2.EggSpeciesValue : (byte)pk.Species; - protected override PK2 GetEntry(byte[] dat, ReadOnlySpan otname, ReadOnlySpan nick, bool egg) + // Structure: + // u8 Count of slots filled + // s8[capacity+1] GB Species ID in Slot (-1 = no species) + // pkx[capacity] GB PKM data (no strings) + // str[capacity] Trainer Name + // str[capacity] Nickname + // + // where, + // - str has variable size (jp/int) + // - pkx is different size for Gen1/Gen2 + + public const byte SlotEmpty = 0xFF; + public const byte SlotEgg = 0xFD; + + /// + /// Gets the species identifier for the slot. + /// + public static byte GetHeaderIdentifierMark(PK2 pk) { - var result = new PK2(dat, Japanese) { IsEgg = egg }; - otname.CopyTo(result.OriginalTrainerTrash); - nick.CopyTo(result.NicknameTrash); - return result; + var species = pk.SpeciesInternal; + if (species == 0) + return SlotEmpty; + if (pk.IsEgg) + return SlotEgg; + // Let out-of-bounds species fall through. + return species; } - protected override int GetEntrySize() => GetEntrySize(IsFormatParty); + /// + /// Checks if the slot has content present. + /// + /// Identifying summary mark from the list header. + /// True if present. + private static bool IsPresent(byte marker) => marker is not (0 or SlotEmpty); + private static bool IsEgg(byte marker) => marker == SlotEgg; - public PokeList2(byte[] d, PokeListType c = PokeListType.Single, bool jp = false) : base(d, c, jp) { } - public PokeList2(PokeListType c = PokeListType.Single, bool jp = false) : base(c, jp) { } - public PokeList2(byte[] d) : base(d, PokeListType.Single, d.Length == PokeCrypto.SIZE_2JLIST) { } - public PokeList2(PK2 pk) : base(pk) { } + /// + /// Counts the number of present slots in the list. + /// + /// List header + /// Count of slots allowed in the list + /// Offset jump between slot indexes; used to parse multi-single lists instead of a multi-list. + private static int CountPresent(ReadOnlySpan input, int capacity, int lerp = 0) + { + var nonZeroSpecies = 0; + for (int i = 0; i < capacity; i++) + { + var mark = input[(i * lerp) + 1]; + if (IsPresent(mark)) + nonZeroSpecies++; + } + return nonZeroSpecies; + } - private static int GetEntrySize(bool party) => party ? PokeCrypto.SIZE_2PARTY : PokeCrypto.SIZE_2STORED; - public static int GetDataLength(PokeListType c, bool jp) => GetDataSize(c, jp, GetEntrySize(IsCapacityPartyFormat(c))); + private static bool IsJapaneseList(int length) => length == PokeCrypto.SIZE_2JLIST; + private static bool IsJapaneseString(int length) => length == GBPKML.StringLengthJapanese; + public static int GetListLengthSingle(bool jp) => jp ? PokeCrypto.SIZE_2JLIST : PokeCrypto.SIZE_2ULIST; + private static int GetBodyLength(bool party) => party ? PokeCrypto.SIZE_2PARTY : PokeCrypto.SIZE_2STORED; + private static int GetStringLength(bool jp) => jp ? GBPKML.StringLengthJapanese : GBPKML.StringLengthNotJapan; + + /// + /// Checks if the header is sane. + /// + /// List header + /// Count of slots allowed in the list + public static bool IsValidHeader(ReadOnlySpan input, int capacity) + { + if (input.Length < 2) + return false; + + var count = input[0]; + if (count > capacity) + return false; + + var expectLength = 1 + (capacity + 1); + if (input.Length < expectLength) + return false; + + for (int i = 0; i < capacity; i++) + { + var mark = input[1 + i]; + bool present = IsPresent(mark); + if (present != (i < count)) + return false; + } + return true; + } + + /// + /// Reads an entity from a single-slot list. + /// + public static PK2 ReadFromSingle(ReadOnlySpan input) + { + var jp = IsJapaneseList(input.Length); + var stringLength = GetStringLength(jp); + return ReadFromList(input, stringLength); + } + + /// + /// Reads an entity from a list. + /// + /// List data + /// Trainer and Nickname string length + /// Count of slots allowed in the list + /// List stores party stats for each entity + /// Entity index to read + public static PK2 ReadFromList(ReadOnlySpan input, int stringLength, int capacity = 1, bool isParty = true, int index = 0) + { + var start = 1 + (capacity + 1); + var sizeBody = GetBodyLength(isParty); + + var ofsBody = start + (sizeBody * index); + var ofsStr1 = start + (sizeBody * capacity) + (stringLength * index); + var ofsStr2 = ofsStr1 + (capacity * stringLength); + + var body = input.Slice(ofsBody, sizeBody); + var stringSection1 = input.Slice(ofsStr1, stringLength); + var stringSection2 = input.Slice(ofsStr2, stringLength); + + return new PK2(body, stringSection1, stringSection2) { IsEgg = IsEgg(input[1 + index]) }; + } + + /// + /// Writes an entity to a list. + /// + /// List data + /// Entity to write + /// Count of slots allowed in the list + /// List stores party stats for each entity + /// Entity index to write + public static void WriteToList(Span output, PK2 pk, int capacity = 1, bool isParty = true, int index = 0) + { + var start = 1 + (capacity + 1); + var sizeBody = GetBodyLength(isParty); + var stringLength = pk.OriginalTrainerTrash.Length; + + var ofsBody = start + (sizeBody * index); + var ofsStr1 = start + (sizeBody * capacity) + (stringLength * index); + var ofsStr2 = ofsStr1 + (capacity * stringLength); + + var body = output.Slice(ofsBody, sizeBody); + var stringSection1 = output.Slice(ofsStr1, stringLength); + var stringSection2 = output.Slice(ofsStr2, stringLength); + + pk.Data.AsSpan()[..sizeBody].CopyTo(body); + pk.OriginalTrainerTrash.CopyTo(stringSection1); + pk.NicknameTrash.CopyTo(stringSection2); + + output[1 + index] = GetHeaderIdentifierMark(pk); + output[0] = (byte)CountPresent(output, capacity); + output[1 + capacity] = SlotEmpty; // cap off the list + } + + /// + /// Unpacks a multi-slot list into many single-slot lists. + /// + /// Multi-slot list + /// Destination to write each single-slot list + /// Trainer and Nickname string length + /// Count of slots allowed in the list + /// List stores party stats for each entity + public static void Unpack(ReadOnlySpan input, Span output, int stringLength, int capacity, bool isParty) + { + var lengthBody = GetBodyLength(isParty); + var lengthParty = GetBodyLength(true); + + var ofsBody = 1 + (capacity + 1); + var ofsStr1 = ofsBody + (capacity * lengthBody); + var ofsStr2 = ofsStr1 + (capacity * stringLength); + + for (int i = 0; i < capacity; i++) + { + var species = input[1 + i]; + var body = input.Slice(ofsBody, lengthBody); + var stringSection1 = input.Slice(ofsStr1, stringLength); + var stringSection2 = input.Slice(ofsStr2, stringLength); + + output[0] = 1; + output[1] = species; + output = output[3..]; + body.CopyTo(output); + output = output[lengthParty..]; + stringSection1.CopyTo(output); + output = output[stringLength..]; + stringSection2.CopyTo(output); + output = output[stringLength..]; + + ofsBody += lengthBody; + ofsStr1 += stringLength; + ofsStr2 += stringLength; + } + } + + /// + /// Packs many single-slot lists into a multi-slot list. + /// + /// Source single-slot lists + /// Destination multi-slot list + /// Trainer and Nickname string length + /// Count of slots allowed in the list + /// List stores party stats for each entity + public static bool MergeSingles(ReadOnlySpan input, Span output, int stringLength, int capacity, bool isParty) + { + // Collect the count of set slots + var jp = IsJapaneseString(stringLength); + var size = GetListLengthSingle(jp); + + int count = CountPresent(input, capacity, size); + if (count == 0 && !output.ContainsAnyExcept(0)) + return false; // No need to merge if all empty and dest is not initialized. + + output[0] = (byte)count; // ensure written list is valid + // Put non-zero species first, then empty slots + var ctr = 0; + var emptyIndex = count; + + var lengthBody = GetBodyLength(isParty); + var lengthParty = GetBodyLength(true); + for (int i = 0; i < capacity; i++) + { + var single = input.Slice(i * size, size); + var marker = single[1]; // assume correct, don't look in body data. + + var index = IsPresent(marker) ? ctr++ : emptyIndex++; + output[1 + index] = marker; + + // Source Properties + var body = single.Slice(3, lengthBody); + var str1 = single.Slice(3 + lengthParty, stringLength); + var str2 = single.Slice(3 + lengthParty + stringLength, stringLength); + + // Destination Properties + var ofsStart = 1 + (capacity + 1); + var ofsBody = ofsStart + (index * lengthBody); + var ofsStr1 = ofsStart + (capacity * lengthBody) + (index * stringLength); + var ofsStr2 = ofsStr1 + (capacity * stringLength); + var destBody = output.Slice(ofsBody, lengthBody); + var destStr1 = output.Slice(ofsStr1, stringLength); + var destStr2 = output.Slice(ofsStr2, stringLength); + + body.CopyTo(destBody); + str1.CopyTo(destStr1); + str2.CopyTo(destStr2); + } + + output[1 + capacity] = SlotEmpty; // cap off the list + return true; + } + + /// + public static byte[] WrapSingle(PK2 pk) + { + var length = GetListLengthSingle(pk.Japanese); + var data = new byte[length]; + WrapSingle(pk, data); + return data; + } + + /// + /// Wraps a single entity into a single-slot list. + /// + /// Entity to wrap + /// Destination to write the single-slot list + public static void WrapSingle(PK2 pk, Span output) => WriteToList(output, pk); } diff --git a/PKHeX.Core/PKM/Shared/PokeListGB.cs b/PKHeX.Core/PKM/Shared/PokeListGB.cs deleted file mode 100644 index 69d75e913..000000000 --- a/PKHeX.Core/PKM/Shared/PokeListGB.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System; - -namespace PKHeX.Core; - -/// -/// List of prefixed by a count. -/// -/// type that inherits from . -public abstract class PokeListGB where T : GBPKML -{ - // Structure: - // u8 Count of slots filled - // s8[capacity+1] GB Species ID in Slot (-1 = no species) - // pkx[capacity] GB PKM data (no strings) - // str[capacity] Trainer Name - // str[capacity] Nickname - // - // where, - // - str has variable size (jp/int) - // - pkx is different size for Gen1/Gen2 - - private readonly int StringLength; - private readonly byte[] Data; - private readonly byte Capacity; - private readonly int EntrySize; - protected readonly bool Japanese; - - public readonly T[] Pokemon; - - /// - /// Count of slots filled in the list - /// - public byte Count { get => Data[0]; private set => Data[0] = value > Capacity ? Capacity : value; } - - private const int SLOT_NONE = byte.MaxValue; - - protected PokeListGB(byte[]? d, PokeListType c = PokeListType.Single, bool jp = false) - { - Japanese = jp; - Capacity = (byte)c; - EntrySize = GetEntrySize(); - StringLength = GetStringLength(jp); - byte[] data = d ?? GetEmptyList(c, jp); - var dataSize = 1 + 1 + (Capacity * (EntrySize + 1 + (2 * StringLength))); - - Array.Resize(ref data, dataSize); - Data = data; - - Pokemon = Read(); - } - - protected PokeListGB(PokeListType c = PokeListType.Single, bool jp = false) - : this(null, c, jp) => Count = 1; - - protected PokeListGB(T pk) - : this(PokeListType.Single, pk.Japanese) - { - this[0] = pk; - Count = 1; - } - - private byte[] GetEmptyList(PokeListType c, bool jp = false) - { - int capacity = (byte)c; - - int size_intro = capacity + 1; - int size_pkm = GetEntrySize() * capacity; - int size_str = 2 * GetStringLength(jp) * capacity; - - // first byte: count (0) - var result = new byte[1 + size_intro + size_pkm + size_str]; - - // species present in slot: none - for (int i = 1; i <= size_intro; i++) - result[i] = SLOT_NONE; - - // fill string buffers with terminators - for (int i = 1 + size_intro + size_pkm; i < result.Length; i++) - result[i] = StringConverter12.G1TerminatorCode; // terminator - - return result; - } - - private int GetOffsetPKMData(int base_ofs, int index) => base_ofs + (EntrySize * index); - private int GetOffsetPKMOT(int base_ofs, int index) => GetOffsetPKMData(base_ofs, Capacity) + (StringLength * index); - private int GetOffsetPKMNickname(int base_ofs, int index) => GetOffsetPKMOT(base_ofs, Capacity) + (StringLength * index); - - private static int GetStringLength(bool jp) => jp ? GBPKML.StringLengthJapanese : GBPKML.StringLengthNotJapan; - protected bool IsFormatParty => IsCapacityPartyFormat((PokeListType)Capacity); - protected static bool IsCapacityPartyFormat(PokeListType Capacity) => Capacity is PokeListType.Single or PokeListType.Party; - - protected static int GetDataSize(PokeListType c, bool jp, int entrySize) - { - var entryLength = 1 + entrySize + (2 * GetStringLength(jp)); - return 2 + ((byte)c * entryLength); - } - - protected abstract int GetEntrySize(); - protected abstract byte GetSpeciesBoxIdentifier(T pk); - protected abstract T GetEntry(byte[] dat, ReadOnlySpan otname, ReadOnlySpan nick, bool egg); - - public T this[int i] - { - get - { - if ((uint)i > Capacity) - throw new ArgumentOutOfRangeException($"Invalid {nameof(PokeListGB)} Access: {i}"); - return Pokemon[i]; - } - set => Pokemon[i] = (T)value.Clone(); - } - - private T[] Read() - { - var arr = new T[Capacity]; - int base_ofs = 2 + Capacity; - for (int i = 0; i < Capacity; i++) - arr[i] = GetEntry(base_ofs, i); - return arr; - } - - public byte[] Write() - { - // Rebuild GB Species ID table - int count = Array.FindIndex(Pokemon, static pk => pk.Species == 0); - Count = count < 0 ? Capacity : (byte)count; - int base_ofs = 1 + 1 + Capacity; - for (int i = 0; i < Count; i++) - { - Data[1 + i] = GetSpeciesBoxIdentifier(Pokemon[i]); - SetEntry(base_ofs, i); - } - Data[1 + Count] = SLOT_NONE; - return Data; - } - - private T GetEntry(int base_ofs, int index) - { - int pkOfs = GetOffsetPKMData(base_ofs, index); - int otOfs = GetOffsetPKMOT(base_ofs, index); - int nkOfs = GetOffsetPKMNickname(base_ofs, index); - - var dat = Data.AsSpan(pkOfs, EntrySize).ToArray(); - var otname = Data.AsSpan(otOfs, StringLength); - var nick = Data.AsSpan(nkOfs, StringLength); - - return GetEntry(dat, otname, nick, Data[1 + index] == 0xFD); - } - - private void SetEntry(int base_ofs, int index) - { - int pkOfs = GetOffsetPKMData(base_ofs, index); - int otOfs = GetOffsetPKMOT(base_ofs, index); - int nkOfs = GetOffsetPKMNickname(base_ofs, index); - - var pk = Pokemon[index]; - Array.Copy(pk.Data, 0, Data, pkOfs, EntrySize); - pk.OriginalTrainerTrash.CopyTo(Data.AsSpan(otOfs)); - pk.NicknameTrash.CopyTo(Data.AsSpan(nkOfs)); - } -} diff --git a/PKHeX.Core/PKM/Shared/PokeListType.cs b/PKHeX.Core/PKM/Shared/PokeListType.cs deleted file mode 100644 index 56b2f9276..000000000 --- a/PKHeX.Core/PKM/Shared/PokeListType.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace PKHeX.Core; - -/// -/// Type of that is stored. -/// -/// Acts as a magic capacity value; there aren't any other List sizes used by the games. -public enum PokeListType : byte -{ - /// Invalid type. - None = 0, - - /// A single is contained in this list. - Single = 1, - - /// A full party of is contained in this list. - Party = 6, - - /// A full box of (International format) is contained in this list. - Stored = 20, - - /// A full box of (Japanese) is contained in this list. - StoredJP = 30, -} diff --git a/PKHeX.Core/PKM/Strings/StringConverter.cs b/PKHeX.Core/PKM/Strings/StringConverter.cs index 9d16ceab4..797f8ca92 100644 --- a/PKHeX.Core/PKM/Strings/StringConverter.cs +++ b/PKHeX.Core/PKM/Strings/StringConverter.cs @@ -21,7 +21,8 @@ public static class StringConverter 3 when isBigEndian => StringConverter3GC.GetString(data), 4 when isBigEndian => StringConverter4GC.GetString(data), - 1 or 2 => StringConverter12.GetString(data, jp), + 1 => StringConverter1.GetString(data, jp), + 2 => StringConverter2.GetString(data, language), 3 => StringConverter3.GetString(data, language), 4 => StringConverter4.GetString(data), 5 => StringConverter5.GetString(data), @@ -47,7 +48,8 @@ public static class StringConverter 3 when isBigEndian => StringConverter3GC.LoadString(data, result), 4 when isBigEndian => StringConverter4GC.LoadString(data, result), - 1 or 2 => StringConverter12.LoadString(data, result, jp), + 1 => StringConverter1.LoadString(data, result, jp), + 2 => StringConverter2.LoadString(data, result, language), 3 => StringConverter3.LoadString(data, result, language), 4 => StringConverter4.LoadString(data, result), 5 => StringConverter5.LoadString(data, result), @@ -76,7 +78,8 @@ public static class StringConverter 3 when isBigEndian => StringConverter3GC.SetString(destBuffer, value, maxLength, option), 4 when isBigEndian => StringConverter4GC.SetString(destBuffer, value, maxLength, option), - 1 or 2 => StringConverter12.SetString(destBuffer, value, maxLength, jp, option), + 1 => StringConverter1.SetString(destBuffer, value, maxLength, jp, option), + 2 => StringConverter2.SetString(destBuffer, value, maxLength, language, option), 3 => StringConverter3.SetString(destBuffer, value, maxLength, language, option), 4 => StringConverter4.SetString(destBuffer, value, maxLength, option), 5 => StringConverter5.SetString(destBuffer, value, maxLength, option), diff --git a/PKHeX.Core/PKM/Strings/StringConverter12.cs b/PKHeX.Core/PKM/Strings/StringConverter1.cs similarity index 84% rename from PKHeX.Core/PKM/Strings/StringConverter12.cs rename to PKHeX.Core/PKM/Strings/StringConverter1.cs index a799b45b1..b5ea3466a 100644 --- a/PKHeX.Core/PKM/Strings/StringConverter12.cs +++ b/PKHeX.Core/PKM/Strings/StringConverter1.cs @@ -3,11 +3,20 @@ namespace PKHeX.Core; /// -/// Logic for converting a for Generation 1 & 2 games. +/// Logic for converting a for Generation 1. /// -public static class StringConverter12 +/// Slight differences when compared to . +public static class StringConverter1 { - public static bool GetIsG1Japanese(ReadOnlySpan str) => AllJapanese(str); + public const byte TerminatorCode = 0x50; + public const byte TerminatorZero = 0x00; + public const byte TradeOTCode = 0x5D; + public const byte SpaceCode = 0x7F; + + public const char Terminator = '\0'; + public const char TradeOT = '*'; + + public static bool GetIsJapanese(ReadOnlySpan str) => AllJapanese(str); private static bool AllJapanese(ReadOnlySpan str) { @@ -20,28 +29,21 @@ private static bool AllJapanese(ReadOnlySpan str) static bool IsJapanese(char c) => c is >= '\u3000' and <= '\u30FC'; } - public static bool GetIsG1English(ReadOnlySpan str) => !GetIsG1Japanese(str); - public static bool GetIsG1Japanese(ReadOnlySpan raw) => AllCharsInTable(raw, TableJP); - public static bool GetIsG1English(ReadOnlySpan raw) => AllCharsInTable(raw, TableEN); + public static bool GetIsEnglish(ReadOnlySpan str) => !GetIsJapanese(str); + public static bool GetIsJapanese(ReadOnlySpan raw) => AllCharsInTable(raw, TableJP); + public static bool GetIsEnglish(ReadOnlySpan raw) => AllCharsInTable(raw, TableEN); private static bool AllCharsInTable(ReadOnlySpan data, ReadOnlySpan table) { foreach (var c in data) { var b = table[c]; - if (b == G1Terminator && c is not (G1TerminatorCode or G1TerminatorZero)) + if (b == Terminator && c is not (TerminatorCode or TerminatorZero)) return false; } return true; } - public const byte G1TerminatorCode = 0x50; - public const byte G1TerminatorZero = 0x00; - public const char G1Terminator = '\0'; - public const byte G1TradeOTCode = 0x5D; - public const char G1TradeOT = '*'; - public const byte G1SpaceCode = 0x7F; - /// /// Checks if the input byte array is definitely of German origin (any ÄÖÜäöü) /// @@ -104,9 +106,9 @@ public static int LoadString(ReadOnlySpan data, Span result, bool jp { if (data.Length == 0) return 0; - if (data[0] == G1TradeOTCode) // In-game Trade + if (data[0] == TradeOTCode) // In-game Trade { - result[0] = G1TradeOT; + result[0] = TradeOT; return 1; } @@ -116,7 +118,7 @@ public static int LoadString(ReadOnlySpan data, Span result, bool jp { var value = data[i]; var c = dict[value]; - if (c == G1Terminator) // Stop if Terminator + if (c == Terminator) // Stop if Terminator break; result[i] = c; } @@ -138,16 +140,16 @@ public static int LoadString(ReadOnlySpan data, Span result, bool jp if (option is StringConverterOption.ClearZero) destBuffer.Clear(); else if (option is StringConverterOption.Clear50) - destBuffer.Fill(G1TerminatorCode); + destBuffer.Fill(TerminatorCode); else if (option is StringConverterOption.Clear7F) - destBuffer.Fill(G1SpaceCode); + destBuffer.Fill(SpaceCode); if (value.Length == 0) return 0; - if (value[0] == G1TradeOT) // Handle "[TRAINER]" + if (value[0] == TradeOT) // Handle "[TRAINER]" { - destBuffer[0] = G1TradeOTCode; - destBuffer[1] = G1TerminatorCode; + destBuffer[0] = TradeOTCode; + destBuffer[1] = TerminatorCode; return 2; } @@ -166,7 +168,7 @@ public static int LoadString(ReadOnlySpan data, Span result, bool jp int count = i; if (count == destBuffer.Length) return count; - destBuffer[count] = G1TerminatorCode; + destBuffer[count] = TerminatorCode; return count + 1; } @@ -181,7 +183,7 @@ private static bool TryGetIndex(in ReadOnlySpan dict, char c, out byte res } // べ (U+3079), ぺ (U+307A), へ (U+3078), and り (U+308A) - private const string Hiragana = "べぺへり"; + internal const string Hiragana = "べぺへり"; /// /// Tries to remap the user input to a valid character. @@ -198,18 +200,18 @@ private static bool TryGetUserFriendlyRemap(in ReadOnlySpan dict, char c, return false; } - #region Gen 1/2 Character Tables + #region Gen 1 Character Tables - private const char NUL = G1Terminator; - private const char TOT = G1TradeOT; - private const char LPK = '{'; // Pk - private const char LMN = '}'; // Mn - private const char MNY = '¥'; // Yen - private const char LPO = '@'; // Po - private const char LKE = '#'; // Ke - private const char LEA = '%'; // é for Box + internal const char NUL = Terminator; + internal const char TOT = TradeOT; + internal const char LPK = '{'; // Pk + internal const char LMN = '}'; // Mn + internal const char MNY = '¥'; // Yen + internal const char LPO = '@'; // Po + internal const char LKE = '#'; // Ke + internal const char LEA = '%'; // é for Box public const char DOT = '․'; // . for MR.MIME (U+2024, not U+002E) - private const char SPF = ' '; // Full-width space (U+3000) + internal const char SPF = ' '; // Full-width space (U+3000) public const char SPH = ' '; // Half-width space public static ReadOnlySpan TableEN => diff --git a/PKHeX.Core/PKM/Strings/StringConverter2.cs b/PKHeX.Core/PKM/Strings/StringConverter2.cs new file mode 100644 index 000000000..0b9ca1af4 --- /dev/null +++ b/PKHeX.Core/PKM/Strings/StringConverter2.cs @@ -0,0 +1,387 @@ +using System; + +namespace PKHeX.Core; + +public static class StringConverter2 +{ + public const byte TerminatorCode = StringConverter1.TerminatorCode; + public const byte TerminatorZero = StringConverter1.TerminatorZero; + public const byte TradeOTCode = StringConverter1.TradeOTCode; + public const byte SpaceCode = StringConverter1.SpaceCode; + + public const char Terminator = StringConverter1.Terminator; + public const char TradeOT = StringConverter1.TradeOT; + + public static bool GetIsJapanese(ReadOnlySpan str) => AllJapanese(str); + + private static bool AllJapanese(ReadOnlySpan str) + { + foreach (var x in str) + { + if (!IsJapanese(x)) + return false; + } + return true; + static bool IsJapanese(char c) => c is >= '\u3000' and <= '\u30FC'; + } + + public static bool GetIsEnglish(ReadOnlySpan str) => !GetIsJapanese(str); + public static bool GetIsJapanese(ReadOnlySpan raw) => AllCharsInTable(raw, TableJP); + public static bool GetIsEnglish(ReadOnlySpan raw) => AllCharsInTable(raw, TableEN); + + private static bool AllCharsInTable(ReadOnlySpan data, ReadOnlySpan table) + { + foreach (var c in data) + { + var b = table[c]; + if (b == Terminator && c is not (TerminatorCode or TerminatorZero)) + return false; + } + return true; + } + + /// + /// Converts Generation 1 encoded data into a string. + /// + /// Encoded data. + /// Data source language. + /// Decoded string. + public static string GetString(ReadOnlySpan data, int language) + { + Span result = stackalloc char[data.Length]; + int length = LoadString(data, result, language); + return new string(result[..length]); + } + + /// + /// Encoded data + /// Decoded character result buffer + /// Data source language. + /// Character count loaded. + public static int LoadString(ReadOnlySpan data, Span result, int language) + { + if (data.Length == 0) + return 0; + if (data[0] == TradeOTCode) // In-game Trade + { + result[0] = TradeOT; + return 1; + } + + int i = 0; + var dict = GetDict(language); + for (; i < data.Length; i++) + { + var value = data[i]; + var c = dict[value]; + if (c == Terminator) // Stop if Terminator + break; + result[i] = c; + } + return i; + } + + private static ReadOnlySpan GetDict(int language) => language switch + { + 1 => TableJP, + 3 or 5 => TableFRE, + 4 or 7 => TableITA, + _ => TableEN, + }; + + /// + /// Converts a string to Generation 1 encoded data. + /// + /// Span of bytes to write encoded string data + /// Decoded string. + /// Maximum length of the input + /// Data source language. + /// Buffer pre-formatting option + /// Encoded data. + public static int SetString(Span destBuffer, ReadOnlySpan value, int maxLength, int language, + StringConverterOption option = StringConverterOption.Clear50) + { + ConditionBuffer(destBuffer, option); + if (value.Length == 0) + return 0; + if (value[0] == TradeOT) // Handle "[TRAINER]" + { + destBuffer[0] = TradeOTCode; + destBuffer[1] = TerminatorCode; + return 2; + } + + if (value.Length > maxLength) + value = value[..maxLength]; // Hard cap + + var dict = GetDict(language); + int i = 0; + for (; i < value.Length; i++) + { + if (!TryGetIndex(dict, value[i], out var index)) + break; + destBuffer[i] = index; + } + + int count = i; + if (count == destBuffer.Length) + return count; + destBuffer[count] = TerminatorCode; + return count + 1; + } + + private static void ConditionBuffer(Span destBuffer, StringConverterOption option) + { + if (option is StringConverterOption.ClearZero) + destBuffer.Clear(); + else if (option is StringConverterOption.Clear50) + destBuffer.Fill(TerminatorCode); + else if (option is StringConverterOption.Clear7F) + destBuffer.Fill(SpaceCode); + } + + private static bool TryGetIndex(in ReadOnlySpan dict, char c, out byte result) + { + var index = dict.IndexOf(c); + if (index == -1) + return TryGetUserFriendlyRemap(dict, c, out result); + // \0 shouldn't really be user-entered, but just in case + result = (byte)index; + return index != default; + } + + /// + /// Tries to remap the user input to a valid character. + /// + /// + private static bool TryGetUserFriendlyRemap(in ReadOnlySpan dict, char c, out byte result) + { + if (StringConverter1.Hiragana.Contains(c)) + { + int index = dict.IndexOf((char)(c + (char)0x60)); + result = (byte)index; + return true; // Valid Hiragana will always be found if it's in the table + } + result = default; + return false; + } + + #region Gen 2 Character Tables + + private const char NUL = StringConverter1.NUL; + private const char TOT = StringConverter1.TOT; + private const char LPK = StringConverter1.LPK; // Pk + private const char LMN = StringConverter1.LMN; // Mn + private const char MNY = StringConverter1.MNY; // Yen + private const char LPO = StringConverter1.LPO; // Po + private const char LKE = StringConverter1.LKE; // Ke + private const char LEA = StringConverter1.LEA; // é for Box + private const char DOT = StringConverter1.DOT; // . for MR.MIME (U+2024, not U+002E) + private const char SPF = StringConverter1.SPF; // Full-width space (U+3000) + private const char SPH = StringConverter1.SPH; // Half-width space + + private const char LAP = '’'; // Apostrophe + + // 'd 'l 'm 'r 's 't 'v + // All are apostrophe-before. + private const string LigatureENG = "dlmrstv"; + + // c' d' j' l' m' n' p' s' 's t' u' y' + // All are apostrophe-after besides index 8 ('s) + private const string LigatureFRE = "cdjlmnpsstuy"; + + private const char LI0 = '0'; // 'd + private const char LI1 = '1'; // 'l + private const char LI2 = '2'; // 'm + private const char LI3 = '3'; // 'r + private const char LI4 = '4'; // 's + private const char LI5 = '5'; // 't + private const char LI6 = '6'; // 'v + private const char LI7 = '7'; // 'd + private const char LI8 = '8'; // 'l + private const char LI9 = '9'; // 'm + private const char LIA = 'A'; // 'r + private const char LIB = 'B'; // 's + + /// + /// English encoding table with unused indexes merged in from other languages that use them. + /// + /// + public static ReadOnlySpan TableEN => + [ + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 00-0F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 10-1F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 20-2F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 30-3F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 40-4F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, TOT, NUL, NUL, // 50-5F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 60-6F + LPO, LKE, '“', '”', NUL, '…', NUL, NUL, NUL, '┌', '─', '┐', '│', '└', '┘', SPH, // 70-7F + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 80-8F + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '(', ')', ':', ';', '[', ']', // 90-9F + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', // A0-AF + 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'à', 'è', 'é', 'ù', 'À', 'Á', // B0-BF + 'Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'È', 'É', 'Ì', 'Í', 'Ñ', 'Ò', 'Ó', 'Ù', 'Ú', 'á', // C0-CF + LI0, LI1, LI2, LI3, LI4, LI5, LI6, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, '←', // D0-DF + LAP, LPK, LMN, '-', '+', NUL, '?', '!', DOT, '&', LEA, '→', '▷', '▶', '▼', '♂', // E0-EF + MNY, '×', '.', '/', ',', '♀', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', // F0-FF + ]; + + public static ReadOnlySpan TableFRE => // Also German + [ + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 00-0F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 10-1F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 20-2F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 30-3F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 40-4F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, TOT, NUL, NUL, // 50-5F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 60-6F + LPO, LKE, '“', '”', NUL, '…', NUL, NUL, NUL, '┌', '─', '┐', '│', '└', '┘', SPH, // 70-7F + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 80-8F + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '(', ')', ':', ';', '[', ']', // 90-9F + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', // A0-AF + 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'à', 'è', 'é', 'ù', 'ß', 'ç', // B0-BF + 'Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'È', 'É', 'Ì', 'Í', 'Ñ', 'Ò', 'Ó', 'Ù', 'Ú', 'á', // C0-CF + NUL, NUL, NUL, NUL, LI0, LI1, LI2, LI3, LI4, LI5, LI6, LI7, LI8, LI9, LIA, LIB, // D0-DF + LAP, LPK, LMN, '-', '+', NUL, '?', '!', DOT, '&', LEA, '→', '▷', '▶', '▼', '♂', // E0-EF + MNY, '×', '.', '/', ',', '♀', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', // F0-FF + ]; + + public static ReadOnlySpan TableITA => // Also Spanish + [ + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 00-0F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 10-1F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 20-2F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 30-3F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 40-4F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, TOT, NUL, NUL, // 50-5F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 60-6F + LPO, LKE, '“', '”', NUL, '…', NUL, NUL, NUL, '┌', '─', '┐', '│', '└', '┘', SPH, // 70-7F + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 80-8F + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '(', ')', ':', ';', '[', ']', // 90-9F + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', // A0-AF + 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'à', 'è', 'é', 'ù', 'À', 'Á', // B0-BF + 'Ä', 'Ö', 'Ü', 'ä', 'ö', 'ü', 'È', 'É', 'Ì', 'Í', 'Ñ', 'Ò', 'Ó', 'Ù', 'Ú', 'á', // C0-CF + 'ì', 'í', 'ñ', 'ò', 'ó', 'ú', NUL, NUL, LI0, LI1, LI2, LI3, LI4, LI5, LI6, NUL, // D0-DF + LAP, LPK, LMN, '-', '¿', '¡', '?', '!', DOT, '&', LEA, '→', '▷', '▶', '▼', '♂', // E0-EF + MNY, '×', '.', '/', ',', '♀', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', // F0-FF + ]; + + private static void InsertLigature(Span result, char c, bool isAfter) + { + if (isAfter) + { + result[0] = c; + result[1] = LAP; + } + else + { + result[0] = LAP; + result[1] = c; + } + } + + public static ReadOnlySpan TableJP => + [ + NUL, NUL, NUL, NUL, NUL, 'ガ', 'ギ', 'グ', 'ゲ', 'ゴ', 'ザ', 'ジ', 'ズ', 'ゼ', 'ゾ', 'ダ', // 00-0F + 'ヂ', 'ヅ', 'デ', 'ド', NUL, NUL, NUL, NUL, NUL, 'バ', 'ビ', 'ブ', 'ボ', NUL, NUL, NUL, // 10-1F + NUL, NUL, NUL, NUL, NUL, NUL, 'が', 'ぎ', 'ぐ', 'げ', 'ご', 'ざ', 'じ', 'ず', 'ぜ', 'ぞ', // 20-2F + 'だ', 'ぢ', 'づ', 'で', 'ど', NUL, NUL, NUL, NUL, NUL, 'ば', 'び', 'ぶ', 'ベ', 'ぼ', NUL, // 30-3F + 'パ', 'ピ', 'プ', 'ポ', 'ぱ', 'ぴ', 'ぷ', 'ペ', 'ぽ', NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 40-4F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, TOT, NUL, NUL, // 50-5F + NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, // 60-6F + '「', '」', '『', '』', '・', '⋯', NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, NUL, SPF, // 70-7F + 'ア', 'イ', 'ウ', 'エ', 'オ', 'カ', 'キ', 'ク', 'ケ', 'コ', 'サ', 'シ', 'ス', 'セ', 'ソ', 'タ', // 80-8F + 'チ', 'ツ', 'テ', 'ト', 'ナ', 'ニ', 'ヌ', 'ネ', 'ノ', 'ハ', 'ヒ', 'フ', 'ホ', 'マ', 'ミ', 'ム', // 90-9F + 'メ', 'モ', 'ヤ', 'ユ', 'ヨ', 'ラ', 'ル', 'レ', 'ロ', 'ワ', 'ヲ', 'ン', 'ッ', 'ャ', 'ュ', 'ョ', // A0-AF + 'ィ', 'あ', 'い', 'う', 'え', 'お', 'か', 'き', 'く', 'け', 'こ', 'さ', 'し', 'す', 'せ', 'そ', // B0-BF + 'た', 'ち', 'つ', 'て', 'と', 'な', 'に', 'ぬ', 'ね', 'の', 'は', 'ひ', 'ふ', 'ヘ', 'ほ', 'ま', // C0-CF + 'み', 'む', 'め', 'も', 'や', 'ゆ', 'よ', 'ら', 'リ', 'る', 'れ', 'ろ', 'わ', 'を', 'ん', 'っ', // D0-DF + 'ゃ', 'ゅ', 'ょ', 'ー', '゚', '゙', '?', '!', '。', 'ァ', 'ゥ', 'ェ', NUL, NUL, NUL, '♂', // E0-EF + MNY, NUL, '.', '/', 'ォ', '♀', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', // F0-FF + ]; + + #endregion + + public static string InflateLigatures(string result, int language) + { + if (language is (int)LanguageID.Japanese or (int)LanguageID.Korean) + return result; // No ligatures in JPN/KOR + + bool after = language is (int)LanguageID.French or (int)LanguageID.German; + var ligatures = after ? LigatureFRE : LigatureENG; + Span inflated = stackalloc char[result.Length * 2]; // worst case is double length + int index = 0; + foreach (var c in result) + { + if (!TryGetLigatureIndex(c, out var i) || i >= ligatures.Length) + { + inflated[index++] = c; + continue; + } + + var ligature = ligatures[i]; + InsertLigature(inflated[index..], ligature, after && i != 8); + index += 2; + } + if (index == result.Length) + return result; // Nothing changed. + return new string(inflated[..index]); + } + + private static bool TryGetLigatureIndex(char c, out int index) => -1 != (index = LigatureList.IndexOf(c)); + private static ReadOnlySpan LigatureList => [LI0, LI1, LI2, LI3, LI4, LI5, LI6, LI7, LI8, LI8, LI9, LIA, LIB]; + private static char GetLigature(int ligatureIndex) => LigatureList[ligatureIndex]; + + public static int DeflateLigatures(ReadOnlySpan value, Span result, int language) + { + if (language is (int)LanguageID.Japanese or (int)LanguageID.Korean) + { + value.CopyTo(result); + return value.Length; // No ligatures in JPN/KOR + } + + bool after = language is (int)LanguageID.French or (int)LanguageID.German; + var ligatures = after ? LigatureFRE : LigatureENG; + int index = 0; + for (var i = 0; i < value.Length; i++) + { + char c = value[i]; + if (c is not (LAP or '\'')) + { + if (index == result.Length) + return index; // Overflow (shouldn't happen for correctly-written strings) + result[index++] = c; + continue; + } + + if (after && index != 0) + { + ref var prev = ref result[index - 1]; + var ligatureIndex = ligatures.IndexOf(prev); + if (ligatureIndex != -1) + { + prev = ligatures[ligatureIndex]; + continue; + } + } + + if (index == result.Length) + return index; // Overflow (shouldn't happen for correctly-written strings) + if (i < value.Length - 1) + { + var next = value[i + 1]; + var ligatureIndex = ligatures.IndexOf(next); + if (ligatureIndex != -1) + { + result[index++] = GetLigature(ligatureIndex); + i++; + continue; + } + } + + result[index++] = c; + } + return index; + } +} diff --git a/PKHeX.Core/PKM/Strings/StringConverter2KOR.cs b/PKHeX.Core/PKM/Strings/StringConverter2KOR.cs index cea3501a7..05bc2221b 100644 --- a/PKHeX.Core/PKM/Strings/StringConverter2KOR.cs +++ b/PKHeX.Core/PKM/Strings/StringConverter2KOR.cs @@ -1,6 +1,5 @@ using System; - -using static PKHeX.Core.StringConverter12; +using static PKHeX.Core.StringConverter1; namespace PKHeX.Core; @@ -12,7 +11,7 @@ public static class StringConverter2KOR /// /// Checks if any of the characters inside are from the special Korean codepoint pages. /// - public static bool GetIsG2Korean(ReadOnlySpan str) + public static bool GetIsKorean(ReadOnlySpan str) { foreach (var c in str) { @@ -42,9 +41,9 @@ public static int LoadString(ReadOnlySpan data, Span result) { if (data.Length == 0) return 0; - if (data[0] == G1TradeOTCode) // In-game Trade + if (data[0] == TradeOTCode) // In-game Trade { - result[0] = G1TradeOT; + result[0] = TradeOT; return 1; } @@ -70,7 +69,7 @@ public static int LoadString(ReadOnlySpan data, Span result) } var c = table[value]; - if (c == G1Terminator) // Stop if Terminator + if (c == Terminator) // Stop if Terminator break; result[ctr++] = c; } @@ -91,15 +90,15 @@ public static int LoadString(ReadOnlySpan data, Span result) if (option is StringConverterOption.ClearZero) destBuffer.Clear(); else if (option is StringConverterOption.Clear50) - destBuffer.Fill(G1TerminatorCode); + destBuffer.Fill(TerminatorCode); if (value.Length == 0) return 0; - // Can't get Gen1 In-Game trades with this char, but handle it anyway since the game can handle it. - if (value[0] == G1TradeOT) // Handle "[TRAINER]" + // Korean games can't trade for Gen1 In-Game trades with this char, but handle it anyway since the game can handle it. + if (value[0] == TradeOT) // Handle "[TRAINER]" { - destBuffer[0] = G1TradeOTCode; - destBuffer[1] = G1TerminatorCode; + destBuffer[0] = TradeOTCode; + destBuffer[1] = TerminatorCode; return 2; } @@ -108,7 +107,7 @@ public static int LoadString(ReadOnlySpan data, Span result) int ctr = LoadCharacters(destBuffer, value); if (ctr < value.Length) - destBuffer[ctr++] = G1TerminatorCode; + destBuffer[ctr++] = TerminatorCode; return ctr; } @@ -128,7 +127,7 @@ private static int LoadCharacters(Span destBuffer, ReadOnlySpan valu else { var index = Table0.IndexOf(c); - if (index is -1 or G1TerminatorCode) + if (index is -1 or TerminatorCode) break; if (ctr + 1 > destBuffer.Length) break; // adding 1 character will overflow requested buffer cap @@ -177,7 +176,7 @@ public static void LocalizeKOR2(ushort species, ref string nick) } #region Gen 2 Korean Character Tables - private const char NULL = G1Terminator; + private const char NULL = Terminator; private const byte TableInvalid = 0; private const byte TableMin = 1; private const byte TableMax = 11; diff --git a/PKHeX.Core/PKM/Strings/Trash/TrashBytesGB.cs b/PKHeX.Core/PKM/Strings/Trash/TrashBytesGB.cs index 37830b292..f2c8b1b77 100644 --- a/PKHeX.Core/PKM/Strings/Trash/TrashBytesGB.cs +++ b/PKHeX.Core/PKM/Strings/Trash/TrashBytesGB.cs @@ -1,5 +1,5 @@ using System; -using static PKHeX.Core.StringConverter12; +using static PKHeX.Core.StringConverter1; namespace PKHeX.Core; @@ -21,5 +21,5 @@ public static int GetStringLength(ReadOnlySpan buffer) /// Backing buffer of the string. /// Index of the terminator, or -1 if not found. public static int FindTerminatorIndex(ReadOnlySpan buffer) - => buffer.IndexOfAny(G1TerminatorZero, G1TerminatorCode); + => buffer.IndexOfAny(TerminatorZero, TerminatorCode); } diff --git a/PKHeX.Core/PKM/Util/EntityFileNamer.cs b/PKHeX.Core/PKM/Util/EntityFileNamer.cs index 4775def24..caa71d5d2 100644 --- a/PKHeX.Core/PKM/Util/EntityFileNamer.cs +++ b/PKHeX.Core/PKM/Util/EntityFileNamer.cs @@ -49,13 +49,13 @@ private static string GetRegular(PKM pk) private static string GetGBPKM(GBPKM gb) { - ReadOnlySpan raw = gb switch + var checksum = gb switch { - PK1 pk1 => new PokeList1(pk1).Write(), - PK2 pk2 => new PokeList2(pk2).Write(), - _ => gb.Data, + PK1 pk1 => pk1.GetSingleListChecksum(), + PK2 pk2 => pk2.GetSingleListChecksum(), + _ => Checksums.CRC16_CCITT(gb.Data), }; - var checksum = Checksums.CRC16_CCITT(raw); + var form = gb.Form != 0 ? $"-{gb.Form:00}" : string.Empty; var star = gb.IsShiny ? " ★" : string.Empty; return $"{gb.Species:0000}{form}{star} - {gb.Nickname} - {checksum:X4}"; diff --git a/PKHeX.Core/PKM/Util/EntityFormat.cs b/PKHeX.Core/PKM/Util/EntityFormat.cs index 6238fd3f0..3e1dc09c6 100644 --- a/PKHeX.Core/PKM/Util/EntityFormat.cs +++ b/PKHeX.Core/PKM/Util/EntityFormat.cs @@ -117,8 +117,8 @@ private static bool IsFormatReally9(PK8 pk) private static PKM? GetFromBytes(byte[] data, EntityFormatDetected format, EntityContext prefer) => format switch { - FormatPK1 => new PokeList1(data)[0], - FormatPK2 => new PokeList2(data)[0], + FormatPK1 => PokeList1.ReadFromSingle(data), + FormatPK2 => PokeList2.ReadFromSingle(data), FormatSK2 => new SK2(data), FormatPK3 => new PK3(data), FormatCK3 => new CK3(data), diff --git a/PKHeX.Core/PKM/Util/SpeciesName.cs b/PKHeX.Core/PKM/Util/SpeciesName.cs index 7ac56a830..495b95514 100644 --- a/PKHeX.Core/PKM/Util/SpeciesName.cs +++ b/PKHeX.Core/PKM/Util/SpeciesName.cs @@ -154,11 +154,11 @@ private static string GetSpeciesName1234(ushort species, int language, byte gene // The only Gen1/2 species with a space is Mr. Mime; different period and no space. if (species == (int)Species.MrMime) { - int indexSpace = result.IndexOf(StringConverter12.SPH); + int indexSpace = result.IndexOf(StringConverter1.SPH); if (indexSpace > 0) { // Gen1/2 uses a different period for MR.MIME than user input. - result[indexSpace - 1] = StringConverter12.DOT; + result[indexSpace - 1] = StringConverter1.DOT; // Shift down. Strings have at most 1 occurrence of a space. result[(indexSpace + 1)..].CopyTo(result[indexSpace..]); diff --git a/PKHeX.Core/Saves/SAV1.cs b/PKHeX.Core/Saves/SAV1.cs index 481a97ebc..3cad08301 100644 --- a/PKHeX.Core/Saves/SAV1.cs +++ b/PKHeX.Core/Saves/SAV1.cs @@ -7,7 +7,7 @@ namespace PKHeX.Core; /// /// Generation 1 object. /// -public sealed class SAV1 : SaveFile, ILangDeviantSave, IEventFlagArray, IEventWorkArray, IBoxDetailName, IDaycareStorage +public sealed class SAV1 : SaveFile, ILangDeviantSave, IEventFlagArray, IEventWorkArray, IBoxDetailNameRead, IDaycareStorage { protected internal override string ShortSummary => $"{OT} ({Version}) - {PlayTimeString}"; public override string Extension => ".sav"; @@ -17,7 +17,14 @@ public sealed class SAV1 : SaveFile, ILangDeviantSave, IEventFlagArray, IEventWo public string SaveRevisionString => (Japanese ? "J" : "U") + (IsVirtualConsole ? "VC" : "GB"); public bool Japanese { get; } public bool Korean => false; - public override int Language => Japanese ? 1 : -1; + public override int Language { get; set; } + + private readonly byte[] Reserved = new byte[SIZE_RESERVED]; + + private const int SIZE_RESERVED = 0x8000; // unpacked box data + protected override Span BoxBuffer => Reserved; + protected override Span PartyBuffer => Reserved; + private readonly SAV1Offsets Offsets; public override PersonalTable1 Personal { get; } @@ -29,19 +36,21 @@ public sealed class SAV1 : SaveFile, ILangDeviantSave, IEventFlagArray, IEventWo return gen is 1 or 2; }); - public SAV1(GameVersion version = GameVersion.RBY, bool japanese = false) : base(SaveUtil.SIZE_G1RAW) + public SAV1(GameVersion version = GameVersion.RBY, LanguageID language = LanguageID.English) : base(SaveUtil.SIZE_G1RAW) { Version = version; - Japanese = japanese; + Japanese = language == LanguageID.Japanese; + Language = (int)language; Offsets = Japanese ? SAV1Offsets.JPN : SAV1Offsets.INT; Personal = version == GameVersion.YW ? PersonalTable.Y : PersonalTable.RB; Initialize(version); ClearBoxes(); } - public SAV1(byte[] data, GameVersion versionOverride = GameVersion.Any) : base(data) + public SAV1(byte[] data, GameVersion versionOverride = GameVersion.Any, LanguageID language = LanguageID.English) : base(data) { Japanese = SaveUtil.GetIsG1SAVJ(Data); + Language = (int)language; Offsets = Japanese ? SAV1Offsets.JPN : SAV1Offsets.INT; Version = versionOverride != GameVersion.Any ? versionOverride : SaveUtil.GetIsG1SAV(data); @@ -63,98 +72,48 @@ private void Initialize(GameVersion versionOverride) Version = Data[Offsets.PikaFriendship] != 0 ? GameVersion.YW : GameVersion.RB; } - Box = Data.Length; - Array.Resize(ref Data, Data.Length + SIZE_RESERVED); + Box = 0; Party = GetPartyOffset(0); // Stash boxes after the save file's end. - int stored = SIZE_STOREDBOX; - var capacity = Japanese ? PokeListType.StoredJP : PokeListType.Stored; - int baseDest = Data.Length - SIZE_RESERVED; - for (int box = 0; box < BoxCount; box++) + int stored = SIZE_BOX_LIST; + var capacity = BoxSlotCount; + for (int i = 0; i < BoxCount; i++) { - int boxOfs = GetBoxRawDataOffset(box); - UnpackBox(boxOfs, baseDest, stored, box, capacity); + int ofs = GetBoxRawDataOffset(i); + var src = Data.AsSpan(ofs, stored); + var dest = BoxBuffer[(i * SIZE_BOX_AS_SINGLES)..]; + PokeList1.Unpack(src, dest, StringLength, capacity, false); } - if ((uint)CurrentBox < BoxCount) + // Don't treat the CurrentBox segment as valid; Stadium ignores it and will de-synchronize it. + // The main box data segment is the truth, the CurrentBox copy is not always up-to-date. + // When exporting an updated save file in this program, be nice and re-synchronize. + + // Stash party immediately after. { - // overwrite previously cached box data. - UnpackBox(Offsets.CurrentBox, baseDest, stored, CurrentBox, capacity); + var ofs = Offsets.Party; + var src = Data.AsSpan(ofs, SIZE_PARTY_LIST); + var dest = PartyBuffer[GetPartyOffset(0)..]; + PokeList1.Unpack(src, dest, StringLength, 6, false); } - var party = Data.AsSpan(Offsets.Party, SIZE_STOREDPARTY).ToArray(); - var partyPL = new PokeList1(party, PokeListType.Party, Japanese); - for (int i = 0; i < partyPL.Pokemon.Length; i++) - { - var dest = GetPartyOffset(i); - var pkDat = i < partyPL.Count - ? new PokeList1(partyPL[i]).Write() - : new byte[PokeList1.GetDataLength(PokeListType.Single, Japanese)]; - pkDat.CopyTo(Data, dest); - } - - Span rawDC = stackalloc byte[0x38]; - Data.AsSpan(Offsets.Daycare, rawDC.Length).CopyTo(rawDC); - byte[] TempDaycare = new byte[PokeList1.GetDataLength(PokeListType.Single, Japanese)]; + ReadOnlySpan rawDC = Data.AsSpan(Offsets.Daycare, 0x38); + var daycareOffset = DaycareOffset = GetPartyOffset(6); + var TempDaycare = BoxBuffer[daycareOffset..]; TempDaycare[0] = rawDC[0]; - rawDC.Slice(1, StringLength).CopyTo(TempDaycare.AsSpan(2 + 1 + PokeCrypto.SIZE_1PARTY + StringLength)); - rawDC.Slice(1 + StringLength, StringLength).CopyTo(TempDaycare.AsSpan(2 + 1 + PokeCrypto.SIZE_1PARTY)); - rawDC.Slice(1 + (2 * StringLength), PokeCrypto.SIZE_1STORED).CopyTo(TempDaycare.AsSpan(2 + 1)); + var dcDest = TempDaycare[3..]; - PokeList1 daycareList = new(TempDaycare, PokeListType.Single, Japanese); - daycareList.Write().CopyTo(Data, GetPartyOffset(7)); - DaycareOffset = GetPartyOffset(7); - - // Enable Pokedex editing + // OT, Nickname, and Core data (rather than the reverse...) + rawDC.Slice(1, StringLength).CopyTo(dcDest[(PokeCrypto.SIZE_1PARTY + StringLength)..]); + rawDC.Slice(1 + StringLength, StringLength).CopyTo(dcDest[PokeCrypto.SIZE_1PARTY..]); + rawDC.Slice(1 + (2 * StringLength), PokeCrypto.SIZE_1STORED).CopyTo(dcDest); } private int DaycareOffset = -1; public override bool HasPokeDex => true; - private void UnpackBox(int srcOfs, int destOfs, int boxSize, int boxIndex, PokeListType boxCapacity) - { - var boxData = Data.AsSpan(srcOfs, boxSize).ToArray(); - var boxDest = destOfs + (boxIndex * SIZE_BOX); - var boxPL = new PokeList1(boxData, boxCapacity, Japanese); - for (int i = 0; i < boxPL.Pokemon.Length; i++) - { - var slotOfs = boxDest + (i * SIZE_STORED); - var slotData = Data.AsSpan(slotOfs, SIZE_STORED); - if (i < boxPL.Count) - new PokeList1(boxPL[i]).Write().CopyTo(slotData); - else - slotData.Clear(); - } - } - - private void PackBox(int boxDest, int boxIndex, PokeListType boxCapacity) - { - var boxPL = new PokeList1(boxCapacity, Japanese); - int slot = 0; - for (int i = 0; i < boxPL.Pokemon.Length; i++) - { - var slotOfs = boxDest + (i * SIZE_STORED); - var slotData = Data.AsSpan(slotOfs, SIZE_STORED); - PK1 pk = GetPKM(slotData.ToArray()); - if (pk.Species > 0) - boxPL[slot++] = pk; - } - - // copy to box location - var boxData = boxPL.Write(); - int boxSrc = GetBoxRawDataOffset(boxIndex); - SetData(Data.AsSpan(boxSrc), boxData); - - // copy to active loc if current box - if (boxIndex == CurrentBox) - SetData(Data.AsSpan(Offsets.CurrentBox), boxData); - } - - private const int SIZE_RESERVED = 0x8000; // unpacked box data - private readonly SAV1Offsets Offsets; - // Event Flags public int EventFlagCount => 0xA00; // 320 * 8 public bool GetEventFlag(int flagNumber) @@ -178,24 +137,27 @@ public void SetEventFlag(int flagNumber, bool value) protected override byte[] GetFinalData() { - var capacity = Japanese ? PokeListType.StoredJP : PokeListType.Stored; - for (int box = 0; box < BoxCount; box++) + int boxListLength = SIZE_BOX_LIST; + var boxSlotCount = BoxSlotCount; + for (int i = 0; i < BoxCount; i++) { - var boxOfs = GetBoxOffset(box); - PackBox(boxOfs, box, capacity); + int ofs = GetBoxRawDataOffset(i); + var dest = Data.AsSpan(ofs, boxListLength); + var src = BoxBuffer.Slice(i * SIZE_BOX_AS_SINGLES, SIZE_BOX_AS_SINGLES); + + bool written = PokeList1.MergeSingles(src, dest, StringLength, boxSlotCount, false); + if (written && i == CurrentBox) + dest.CopyTo(Data.AsSpan(Offsets.CurrentBox)); } - var partyPL = new PokeList1(PokeListType.Party, Japanese); - int pSlot = 0; - for (int i = 0; i < 6; i++) + // Write Party { - var ofs = GetPartyOffset(i); - var data = Data.AsSpan(ofs, SIZE_STORED).ToArray(); - PK1 partyPK = GetPKM(data); - if (partyPK.Species > 0) - partyPL[pSlot++] = partyPK; + int ofs = Offsets.Party; + var dest = Data.AsSpan(ofs, SIZE_PARTY_LIST); + var src = PartyBuffer[GetPartyOffset(0)..]; + + PokeList1.MergeSingles(src, dest, StringLength, 6, true); } - partyPL.Write().CopyTo(Data, Offsets.Party); // Daycare is read-only, but in case it ever becomes editable, copy it back in. Span rawDC = Data.AsSpan(GetDaycareSlotOffset(index: 0), SIZE_STORED); @@ -207,24 +169,24 @@ protected override byte[] GetFinalData() dc.CopyTo(Data.AsSpan(Offsets.Daycare)); SetChecksums(); - return Data[..^SIZE_RESERVED]; + return Data; } private int GetBoxRawDataOffset(int box) { if (box < BoxCount / 2) - return 0x4000 + (box * SIZE_STOREDBOX); - return 0x6000 + ((box - (BoxCount / 2)) * SIZE_STOREDBOX); + return 0x4000 + (box * SIZE_BOX_LIST); + return 0x6000 + ((box - (BoxCount / 2)) * SIZE_BOX_LIST); } // Configuration - protected override SAV1 CloneInternal() => new(Write(), Version); + protected override SAV1 CloneInternal() => new(Write(), Version) { Language = Language }; protected override int SIZE_STORED => Japanese ? PokeCrypto.SIZE_1JLIST : PokeCrypto.SIZE_1ULIST; protected override int SIZE_PARTY => Japanese ? PokeCrypto.SIZE_1JLIST : PokeCrypto.SIZE_1ULIST; - private int SIZE_BOX => BoxSlotCount*SIZE_STORED; - private int SIZE_STOREDBOX => PokeList1.GetDataLength(Japanese ? PokeListType.StoredJP : PokeListType.Stored, Japanese); - private int SIZE_STOREDPARTY => PokeList1.GetDataLength(PokeListType.Party, Japanese); + private int SIZE_BOX_AS_SINGLES => BoxSlotCount * SIZE_STORED; + private int SIZE_BOX_LIST => (((StringLength * 2) + PokeCrypto.SIZE_1STORED + 1) * BoxSlotCount) + 2; + private int SIZE_PARTY_LIST => (((StringLength * 2) + PokeCrypto.SIZE_1PARTY + 1) * 6) + 2; public override PK1 BlankPKM => new(Japanese); public override Type PKMType => typeof(PK1); @@ -473,8 +435,8 @@ public override int PartyCount protected set => Data[Offsets.Party] = (byte)value; } - public override int GetBoxOffset(int box) => Data.Length - SIZE_RESERVED + (box * SIZE_BOX); - public override int GetPartyOffset(int slot) => Data.Length - SIZE_RESERVED + (BoxCount * SIZE_BOX) + (slot * SIZE_STORED); + public override int GetBoxOffset(int box) => box * SIZE_BOX_AS_SINGLES; + public override int GetPartyOffset(int slot) => (BoxCount * SIZE_BOX_AS_SINGLES) + (slot * SIZE_STORED); public override int CurrentBox { @@ -495,15 +457,10 @@ public string GetBoxName(int box) return BoxDetailNameExtensions.GetDefaultBoxName(box); } - public void SetBoxName(int box, ReadOnlySpan value) - { - // Don't allow for custom box names - } - protected override PK1 GetPKM(byte[] data) { if (data.Length == SIZE_STORED) - return new PokeList1(data, PokeListType.Single, Japanese)[0]; + return PokeList1.ReadFromList(data, StringLength); return new(data); } @@ -590,10 +547,10 @@ public bool[] EventSpawnFlags } } - public override string GetString(ReadOnlySpan data) => StringConverter12.GetString(data, Japanese); + public override string GetString(ReadOnlySpan data) => StringConverter1.GetString(data, Japanese); public override int SetString(Span destBuffer, ReadOnlySpan value, int maxLength, StringConverterOption option) { - return StringConverter12.SetString(destBuffer, value, maxLength, Japanese, option); + return StringConverter1.SetString(destBuffer, value, maxLength, Japanese, option); } } diff --git a/PKHeX.Core/Saves/SAV2.cs b/PKHeX.Core/Saves/SAV2.cs index 1b81ee7e4..e6ab9b708 100644 --- a/PKHeX.Core/Saves/SAV2.cs +++ b/PKHeX.Core/Saves/SAV2.cs @@ -17,7 +17,14 @@ public sealed class SAV2 : SaveFile, ILangDeviantSave, IEventFlagArray, IEventWo public string SaveRevisionString => (Japanese ? "J" : !Korean ? "U" : "K") + (IsVirtualConsole ? "VC" : "GB"); public bool Japanese { get; } public bool Korean { get; } - public override int Language => Japanese ? 1 : Korean ? (int)LanguageID.Korean : -1; + public override int Language { get; set; } + + private readonly byte[] Reserved = new byte[0x8000]; + + protected override Span BoxBuffer => Reserved; + protected override Span PartyBuffer => Reserved; + + private readonly SAV2Offsets Offsets; public override PersonalTable2 Personal { get; } public override ReadOnlySpan HeldItems => Legal.HeldItems_GSC; @@ -30,18 +37,22 @@ public sealed class SAV2 : SaveFile, ILangDeviantSave, IEventFlagArray, IEventWo return gen is 1 or 2; }); - public SAV2(GameVersion version = GameVersion.C, LanguageID lang = LanguageID.English) : base(SaveUtil.SIZE_G2RAW_J) + public SAV2(GameVersion version = GameVersion.C, LanguageID language = LanguageID.English) : base(SaveUtil.SIZE_G2RAW_J) { Version = version; - switch (lang) + switch (language) { case LanguageID.Japanese: Japanese = true; + Language = 1; break; case LanguageID.Korean: Korean = true; + Language = (int)LanguageID.Korean; + break; + default: // otherwise, both false + Language = -1; break; - // otherwise, both false } Offsets = new SAV2Offsets(this); Personal = Version == GameVersion.GS ? PersonalTable.GS : PersonalTable.C; @@ -55,6 +66,7 @@ public SAV2(byte[] data, GameVersion versionOverride = GameVersion.Any) : base(d Japanese = SaveUtil.GetIsG2SAVJ(Data) != GameVersion.Invalid; if (Version != GameVersion.C && !Japanese) Korean = SaveUtil.GetIsG2SAVK(Data) != GameVersion.Invalid; + Language = Japanese ? 1 : Korean ? (int)LanguageID.Korean : -1; Offsets = new SAV2Offsets(this); Personal = Version == GameVersion.GS ? PersonalTable.GS : PersonalTable.C; @@ -64,51 +76,30 @@ public SAV2(byte[] data, GameVersion versionOverride = GameVersion.Any) : base(d private void Initialize() { Box = Data.Length; - Array.Resize(ref Data, Data.Length + SIZE_RESERVED); Party = GetPartyOffset(0); // Stash boxes after the save file's end. int splitAtIndex = (Japanese ? 6 : 7); - int stored = SIZE_STOREDBOX; - int baseDest = Data.Length - SIZE_RESERVED; - var capacity = Japanese ? PokeListType.StoredJP : PokeListType.Stored; + int stored = SIZE_BOX_LIST; + var capacity = BoxSlotCount; for (int i = 0; i < BoxCount; i++) { int ofs = GetBoxRawDataOffset(i, splitAtIndex); - var box = Data.AsSpan(ofs, stored).ToArray(); - var boxDest = baseDest + (i * SIZE_BOX); - var boxPL = new PokeList2(box, capacity, Japanese); - for (int j = 0; j < boxPL.Pokemon.Length; j++) - { - var dest = boxDest + (j * SIZE_STORED); - var pkDat = (j < boxPL.Count) - ? new PokeList2(boxPL[j]).Write() - : new byte[PokeList2.GetDataLength(PokeListType.Single, Japanese)]; - pkDat.CopyTo(Data, dest); - } + var src = Data.AsSpan(ofs, stored); + var dest = BoxBuffer[(i * SIZE_BOX_AS_SINGLES)..]; + PokeList2.Unpack(src, dest, StringLength, capacity, false); } - var current = Data.AsSpan(Offsets.CurrentBox, stored).ToArray(); - var curBoxPL = new PokeList2(current, capacity, Japanese); - var curDest = baseDest + (CurrentBox * SIZE_BOX); - for (int i = 0; i < curBoxPL.Pokemon.Length; i++) - { - var dest = curDest + (i * SIZE_STORED); - var pkDat = i < curBoxPL.Count - ? new PokeList2(curBoxPL[i]).Write() - : new byte[PokeList2.GetDataLength(PokeListType.Single, Japanese)]; - pkDat.CopyTo(Data, dest); - } + // Don't treat the CurrentBox segment as valid; Stadium ignores it and will de-synchronize it. + // The main box data segment is the truth, the CurrentBox copy is not always up-to-date. + // When exporting an updated save file in this program, be nice and re-synchronize. - var party = Data.AsSpan(Offsets.Party, SIZE_STOREDPARTY).ToArray(); - var partyPL = new PokeList2(party, PokeListType.Party, Japanese); - for (int i = 0; i < partyPL.Pokemon.Length; i++) + // Stash party immediately after. { - var dest = GetPartyOffset(i); - var pkDat = i < partyPL.Count - ? new PokeList2(partyPL[i]).Write() - : new byte[PokeList2.GetDataLength(PokeListType.Single, Japanese)]; - pkDat.CopyTo(Data, dest); + var ofs = Offsets.Party; + var src = Data.AsSpan(ofs, SIZE_PARTY_LIST); + var dest = PartyBuffer[GetPartyOffset(0)..]; + PokeList2.Unpack(src, dest, StringLength, 6, false); } if (Offsets.Daycare >= 0) @@ -117,7 +108,6 @@ private void Initialize() offset++; var pk1 = ReadPKMFromOffset(offset); // parent 1 - var daycare1 = new PokeList2(pk1); offset += (StringLength * 2) + 0x20; // nick/ot/pk offset++; //byte steps = Data[offset]; @@ -125,15 +115,13 @@ private void Initialize() //byte BreedMotherOrNonDitto = Data[offset]; offset++; var pk2 = ReadPKMFromOffset(offset); // parent 2 - var daycare2 = new PokeList2(pk2); offset += (StringLength * 2) + PokeCrypto.SIZE_2STORED; // nick/ot/pk var pk3 = ReadPKMFromOffset(offset); // egg! pk3.IsEgg = true; - var daycare3 = new PokeList2(pk3); - daycare1.Write().CopyTo(Data, GetPartyOffset(7 + (0 * 2))); - daycare2.Write().CopyTo(Data, GetPartyOffset(7 + (1 * 2))); - daycare3.Write().CopyTo(Data, GetPartyOffset(7 + (2 * 2))); + PokeList2.WriteToList(PartyBuffer[GetPartyOffset(6 + (0 * 2))..], pk1, 1, true); + PokeList2.WriteToList(PartyBuffer[GetPartyOffset(6 + (1 * 2))..], pk2, 1, true); + PokeList2.WriteToList(PartyBuffer[GetPartyOffset(6 + (2 * 2))..], pk3, 1, true); } } @@ -158,49 +146,37 @@ private PK2 ReadPKMFromOffset(int offset) return pk; } - private const int SIZE_RESERVED = 0x8000; // unpacked box data - private readonly SAV2Offsets Offsets; - private int GetBoxRawDataOffset(int i, int splitAtIndex) { if (i < splitAtIndex) - return 0x4000 + (i * (SIZE_STOREDBOX + 2)); - return 0x6000 + ((i - splitAtIndex) * (SIZE_STOREDBOX + 2)); + return 0x4000 + (i * (SIZE_BOX_LIST + 2)); + return 0x6000 + ((i - splitAtIndex) * (SIZE_BOX_LIST + 2)); } protected override byte[] GetFinalData() { int splitAtIndex = (Japanese ? 6 : 7); + int boxListLength = SIZE_BOX_LIST; + var boxSlotCount = BoxSlotCount; for (int i = 0; i < BoxCount; i++) { - var boxPL = new PokeList2(Japanese ? PokeListType.StoredJP : PokeListType.Stored, Japanese); - int slot = 0; - for (int j = 0; j < boxPL.Pokemon.Length; j++) - { - var ofs = GetBoxOffset(i) + (j * SIZE_STORED); - var data = Data.AsSpan(ofs, SIZE_STORED).ToArray(); - PK2 boxPK = GetPKM(data); - if (boxPK.Species > 0) - boxPL[slot++] = boxPK; - } + int ofs = GetBoxRawDataOffset(i, splitAtIndex); + var dest = Data.AsSpan(ofs, boxListLength); + var src = BoxBuffer.Slice(i * SIZE_BOX_AS_SINGLES, SIZE_BOX_AS_SINGLES); - int src = GetBoxRawDataOffset(i, splitAtIndex); - boxPL.Write().CopyTo(Data, src); - if (i == CurrentBox) - boxPL.Write().CopyTo(Data, Offsets.CurrentBox); + bool written = PokeList2.MergeSingles(src, dest, StringLength, boxSlotCount, false); + if (written && i == CurrentBox) + dest.CopyTo(Data.AsSpan(Offsets.CurrentBox)); } - var partyPL = new PokeList2(PokeListType.Party, Japanese); - int pSlot = 0; - for (int i = 0; i < 6; i++) + // Write Party { - var ofs = GetPartyOffset(i); - var data = Data.AsSpan(ofs, SIZE_STORED).ToArray(); - PK2 partyPK = GetPKM(data); - if (partyPK.Species > 0) - partyPL[pSlot++] = partyPK; + int ofs = Offsets.Party; + var dest = Data.AsSpan(ofs, SIZE_PARTY_LIST); + var src = PartyBuffer[GetPartyOffset(0)..]; + + PokeList2.MergeSingles(src, dest, StringLength, 6, true); } - partyPL.Write().CopyTo(Data, Offsets.Party); SetChecksums(); if (Japanese) @@ -246,20 +222,20 @@ protected override byte[] GetFinalData() break; } } - return Data[..^SIZE_RESERVED]; + return Data; } // Configuration - protected override SAV2 CloneInternal() => new(Write(), Version); + protected override SAV2 CloneInternal() => new(Write(), Version) { Language = Language }; protected override int SIZE_STORED => Japanese ? PokeCrypto.SIZE_2JLIST : PokeCrypto.SIZE_2ULIST; protected override int SIZE_PARTY => Japanese ? PokeCrypto.SIZE_2JLIST : PokeCrypto.SIZE_2ULIST; public override PK2 BlankPKM => new(jp: Japanese); public override Type PKMType => typeof(PK2); - private int SIZE_BOX => BoxSlotCount*SIZE_STORED; - private int SIZE_STOREDBOX => PokeList2.GetDataLength(Japanese ? PokeListType.StoredJP : PokeListType.Stored, Japanese); - private int SIZE_STOREDPARTY => PokeList2.GetDataLength(PokeListType.Party, Japanese); + private int SIZE_BOX_AS_SINGLES => BoxSlotCount*SIZE_STORED; + private int SIZE_BOX_LIST => (((StringLength * 2) + PokeCrypto.SIZE_2STORED + 1) * BoxSlotCount) + 2; + private int SIZE_PARTY_LIST => (((StringLength * 2) + PokeCrypto.SIZE_2PARTY + 1) * 6) + 2; public override ushort MaxMoveID => Legal.MaxMoveID_2; public override ushort MaxSpeciesID => Legal.MaxSpeciesID_2; @@ -583,8 +559,8 @@ private int GetDaycareOffset(int index) public int DaycareSlotCount => 2; private int GetDaycareSlotOffset(int slot) => GetPartyOffset(7 + (slot * 2)); - public Memory GetDaycareSlot(int slot) => Data.AsMemory(GetDaycareSlotOffset(slot), SIZE_STORED); - public Memory GetDaycareEgg() => Data.AsMemory(GetDaycareSlotOffset(2), SIZE_STORED); + public Memory GetDaycareSlot(int slot) => Reserved.AsMemory(GetDaycareSlotOffset(slot), SIZE_STORED); + public Memory GetDaycareEgg() => Reserved.AsMemory(GetDaycareSlotOffset(2), SIZE_STORED); public bool IsDaycareOccupied(int slot) => (DaycareFlagByte(slot) & 1) != 0; public void SetDaycareOccupied(int slot, bool occupied) @@ -606,12 +582,12 @@ public override int PartyCount public override int GetBoxOffset(int box) { - return Data.Length - SIZE_RESERVED + (box * SIZE_BOX); + return (box * SIZE_BOX_AS_SINGLES); } public override int GetPartyOffset(int slot) { - return Data.Length - SIZE_RESERVED + (BoxCount * SIZE_BOX) + (slot * SIZE_STORED); + return (BoxCount * SIZE_BOX_AS_SINGLES) + (slot * SIZE_STORED); } public override int CurrentBox @@ -626,7 +602,13 @@ public bool CurrentBoxChanged set => Data[Offsets.CurrentBoxIndex] = (byte)((Data[Offsets.CurrentBoxIndex] & 0x7F) | (byte)(value ? 0x80 : 0)); } - public string GetBoxName(int box) => GetString(GetBoxNameSpan(box)); + public string GetBoxName(int box) + { + var result = GetString(GetBoxNameSpan(box)); + if (!Korean) + result = StringConverter2.InflateLigatures(result, Language); + return result; + } private Span GetBoxNameSpan(int box) { @@ -637,13 +619,16 @@ private Span GetBoxNameSpan(int box) public void SetBoxName(int box, ReadOnlySpan value) { var span = GetBoxNameSpan(box); - SetString(span, value, 8, StringConverterOption.Clear50); + const int maxLen = 8; + Span deflated = stackalloc char[maxLen]; + int len = StringConverter2.DeflateLigatures(value, deflated, Language); + SetString(span, deflated[..len], maxLen, StringConverterOption.Clear50); } protected override PK2 GetPKM(byte[] data) { if (data.Length == SIZE_STORED) - return new PokeList2(data, PokeListType.Single, Japanese)[0]; + return PokeList2.ReadFromList(data, StringLength); return new(data); } @@ -790,7 +775,7 @@ private ushort GetResetKey() foreach (var b in Data.AsSpan(Offsets.Money, 3)) result += b; var tr = Data.AsSpan(Offsets.Trainer1, 7); - var end = tr[2..].IndexOf(StringConverter12.G1TerminatorCode); + var end = tr[2..].IndexOf(StringConverter1.TerminatorCode); if (end >= 0) tr = tr[..(end + 2)]; foreach (var b in tr) @@ -813,14 +798,14 @@ public override string GetString(ReadOnlySpan data) { if (Korean) return StringConverter2KOR.GetString(data); - return StringConverter12.GetString(data, Japanese); + return StringConverter2.GetString(data, Language); } public override int SetString(Span destBuffer, ReadOnlySpan value, int maxLength, StringConverterOption option) { if (Korean) return StringConverter2KOR.SetString(destBuffer, value, maxLength, option); - return StringConverter12.SetString(destBuffer, value, maxLength, Japanese, option); + return StringConverter2.SetString(destBuffer, value, maxLength, Language, option); } public bool IsGBMobileAvailable => Japanese && Version == GameVersion.C; diff --git a/PKHeX.Core/Saves/SAV2Stadium.cs b/PKHeX.Core/Saves/SAV2Stadium.cs index 00d3d1aa0..7c74bf1ea 100644 --- a/PKHeX.Core/Saves/SAV2Stadium.cs +++ b/PKHeX.Core/Saves/SAV2Stadium.cs @@ -93,16 +93,18 @@ protected override void SetBoxMetadata(int box) } var boxOfs = bdata - ListHeaderSizeBox; - if (Data[boxOfs] == 0) + var slice = Data.AsSpan(boxOfs, ListHeaderSizeBox); + if (slice[0] == 0) { - Data[boxOfs] = 1; - Data[boxOfs + 1] = (byte)count; - Data[boxOfs + 4] = StringConverter12.G1TerminatorCode; - StringConverter12.SetString(Data.AsSpan(boxOfs + 0x10, 4), "1234", 4, Japanese, StringConverterOption.None); + slice[0] = 1; + slice[1] = (byte)count; + slice[4] = StringConverter2.TerminatorCode; + for (int i = 0; i < 4; i++) + slice[0x10 + i] = (byte)(0xF6 + i); // 1234 } else { - Data[boxOfs + 1] = (byte)count; + slice[1] = (byte)count; } } diff --git a/PKHeX.Core/Saves/SAV3.cs b/PKHeX.Core/Saves/SAV3.cs index 38a129ad1..4e94078e9 100644 --- a/PKHeX.Core/Saves/SAV3.cs +++ b/PKHeX.Core/Saves/SAV3.cs @@ -40,7 +40,7 @@ public abstract class SAV3 : SaveFile, ILangDeviantSave, IEventFlag37, IBoxDetai public readonly byte[] Storage = new byte[9 * SIZE_SECTOR_USED]; // [0x83D0] private readonly int ActiveSlot; - public sealed override int Language { get => Japanese ? (int)LanguageID.Japanese : (int)LanguageID.English; set { } } + public sealed override int Language { get; set; } protected SAV3(bool japanese) => Japanese = japanese; diff --git a/PKHeX.Core/Saves/SAV3E.cs b/PKHeX.Core/Saves/SAV3E.cs index 260ecc78e..0a7f5cbfa 100644 --- a/PKHeX.Core/Saves/SAV3E.cs +++ b/PKHeX.Core/Saves/SAV3E.cs @@ -11,7 +11,7 @@ namespace PKHeX.Core; public sealed class SAV3E : SAV3, IGen3Hoenn, IGen3Joyful, IGen3Wonder, IDaycareRandomState { // Configuration - protected override SAV3E CloneInternal() => new(Write()); + protected override SAV3E CloneInternal() => new(Write()) { Language = Language }; public override GameVersion Version { get => GameVersion.E; set { } } public override PersonalTable3 Personal => PersonalTable.E; diff --git a/PKHeX.Core/Saves/SAV3FRLG.cs b/PKHeX.Core/Saves/SAV3FRLG.cs index 5d208cc4c..866c26c8b 100644 --- a/PKHeX.Core/Saves/SAV3FRLG.cs +++ b/PKHeX.Core/Saves/SAV3FRLG.cs @@ -10,7 +10,7 @@ namespace PKHeX.Core; public sealed class SAV3FRLG : SAV3, IGen3Joyful, IGen3Wonder, IDaycareRandomState { // Configuration - protected override SAV3FRLG CloneInternal() => new(Write()); + protected override SAV3FRLG CloneInternal() => new(Write()) { Language = Language }; public override GameVersion Version { get; set; } = GameVersion.FR; // allow mutation private PersonalTable3 _personal = PersonalTable.FR; public override PersonalTable3 Personal => _personal; diff --git a/PKHeX.Core/Saves/SAV3RS.cs b/PKHeX.Core/Saves/SAV3RS.cs index f1f5965cd..67ca7c73a 100644 --- a/PKHeX.Core/Saves/SAV3RS.cs +++ b/PKHeX.Core/Saves/SAV3RS.cs @@ -11,8 +11,8 @@ namespace PKHeX.Core; public sealed class SAV3RS : SAV3, IGen3Hoenn, IDaycareRandomState { // Configuration - protected override SAV3RS CloneInternal() => new(Write()); - public override GameVersion Version { get => GameVersion.RS; set { } } + protected override SAV3RS CloneInternal() => new(Write()) { Language = Language }; + public override GameVersion Version { get; set; } = GameVersion.RS; // allow mutation public override PersonalTable3 Personal => PersonalTable.RS; public override int EventFlagCount => 8 * 288; diff --git a/PKHeX.Core/Saves/SAV_STADIUM.cs b/PKHeX.Core/Saves/SAV_STADIUM.cs index 92d4e391b..fda26a424 100644 --- a/PKHeX.Core/Saves/SAV_STADIUM.cs +++ b/PKHeX.Core/Saves/SAV_STADIUM.cs @@ -93,11 +93,11 @@ public virtual SlotGroup[] GetRegisteredTeams() return result; } - public sealed override string GetString(ReadOnlySpan data) => StringConverter12.GetString(data, Japanese); + public sealed override string GetString(ReadOnlySpan data) => StringConverter1.GetString(data, Japanese); public sealed override int SetString(Span destBuffer, ReadOnlySpan value, int maxLength, StringConverterOption option) { - return StringConverter12.SetString(destBuffer, value, maxLength, Japanese, option); + return StringConverter1.SetString(destBuffer, value, maxLength, Japanese, option); } /// diff --git a/PKHeX.Core/Saves/Substructures/Mail/Mail2.cs b/PKHeX.Core/Saves/Substructures/Mail/Mail2.cs index ed4e0aa10..018e5e241 100644 --- a/PKHeX.Core/Saves/Substructures/Mail/Mail2.cs +++ b/PKHeX.Core/Saves/Substructures/Mail/Mail2.cs @@ -6,9 +6,9 @@ namespace PKHeX.Core; // warning: international only public sealed class Mail2 : MailDetail { - private readonly bool US; - private bool Japanese => !US; - private bool Korean => !US && !Japanese; + private readonly int Language; + private bool Japanese => Language == 1; + private bool Korean => Language == (int)LanguageID.Korean; // structure: private const int LINE_LENGTH = 0x10; @@ -27,7 +27,7 @@ public sealed class Mail2 : MailDetail public Mail2(SAV2 sav, int index) : base(sav.Data.AsSpan(GetMailOffset(index), 0x2F).ToArray(), GetMailOffset(index)) { - US = sav is { Japanese: false, Korean: false }; + Language = sav.Language; } private static int GetMailOffset(int index) @@ -51,19 +51,26 @@ private static int GetMailboxMailOffset(int index) return (index * SIZE) + 0x835; } - private string GetString(Span asSpan) + private string GetString(Span span) { if (Korean) - return StringConverter2KOR.GetString(asSpan); - return StringConverter12.GetString(asSpan, Japanese); + return StringConverter2KOR.GetString(span); + var result = StringConverter2.GetString(span, Language); + if (!Korean) + result = StringConverter2.InflateLigatures(result, Language); + return result; } - private void SetString(Span asSpan, ReadOnlySpan value, int maxLength) + private void SetString(Span span, ReadOnlySpan value, int maxLength) { if (Korean) - StringConverter2KOR.SetString(asSpan, value, maxLength); - else - StringConverter12.SetString(asSpan, value, maxLength, Japanese); + { + StringConverter2KOR.SetString(span, value, maxLength); + return; + } + Span deflated = stackalloc char[maxLength]; + int len = StringConverter2.DeflateLigatures(value, deflated, Language); + StringConverter2.SetString(span, deflated[..len], maxLength, Language); } public string Line1 diff --git a/PKHeX.Core/Saves/Util/SaveLanguage.cs b/PKHeX.Core/Saves/Util/SaveLanguage.cs new file mode 100644 index 000000000..405de6eaa --- /dev/null +++ b/PKHeX.Core/Saves/Util/SaveLanguage.cs @@ -0,0 +1,279 @@ +using System; +using static PKHeX.Core.LanguageID; +using static PKHeX.Core.GameVersion; + +namespace PKHeX.Core; + +/// +/// Logic to detect and update the language and version of a save file. +/// +/// Useful for save files that do not have a language or version saved in the data, or impossible to infer without file names. +public static class SaveLanguage +{ + public static LanguageID OverrideLanguageGen1 { get; set; } = English; + public static GameVersion OverrideVersionGen1 { get; set; } = RB; + public static LanguageID OverrideLanguageGen2 { get; set; } = English; + public static GameVersion OverrideVersionGen2 { get; set; } = GS; + public static LanguageID OverrideLanguageGen3RS { get; set; } = English; + public static GameVersion OverrideVersionGen3RS { get; set; } = RS; + public static LanguageID OverrideLanguageGen3FRLG { get; set; } = English; + public static GameVersion OverrideVersionGen3FRLG { get; set; } = FRLG; + + /// + /// Tries to infer the language and version of the save file. + /// + /// Save file to infer from + /// Result of the inference. Only use if the result is true. + /// True if the language and version could be inferred, false otherwise + public static bool TryGetResult(SaveFile sav, out SaveLanguageResult result) => result = sav switch + { + SAV1 s1 => s1.InferFrom(), + SAV2 s2 => s2.InferFrom(), + SAV3 s3 => s3.InferFrom(), + _ => default, + }; + + /// + /// Updates the language and version of the save file if possible. + /// + /// Save file to update + /// True if the language and version were updated, false otherwise + public static bool TryRevise(SaveFile sav) + { + if (!TryGetResult(sav, out var result)) + return false; + + sav.Language = (int)result.Language; + sav.Version = result.Version; + + if (sav is SAV3FRLG s3) + s3.ResetPersonal(result.Version); + + if (sav.Metadata.FilePath is { } path) + sav.Metadata.SetExtraInfo(path); + + return true; + } + + /// + /// Infer the language and version of the save file based on the file name. + /// + public static SaveLanguageResult InferFrom(this SAV1 sav) + { + if (sav.Metadata.FileName is not { } x) + return GetFallback(sav); + + var result = InferFrom1(x); + if (result != default && sav.Japanese == (result.Language == Japanese)) + return result; + + return GetFallback(sav); + } + + /// + public static SaveLanguageResult InferFrom(this SAV2 sav) + { + if (sav.Metadata.FileName is not { } x) + return GetFallback(sav); + + var result = InferFrom2(x); + if (result != default && (sav.Japanese == (result.Language == Japanese)) && (sav.Korean == (result.Language == Korean))) + return result; + + return GetFallback(sav); + } + + /// + public static SaveLanguageResult InferFrom(this SAV3 sav) + { + if (sav.Metadata.FileName is not { } x) + return GetFallback(sav); + + var result = InferFrom3(x); + if (result != default && sav.Japanese == (result.Language == Japanese)) + return result; + + return GetFallback(sav); + } + + private static bool Contains(ReadOnlySpan span, ReadOnlySpan value) + => span.Contains(value, StringComparison.OrdinalIgnoreCase); + + /// + public static SaveLanguageResult InferFrom1(ReadOnlySpan name) + { + // Red + if (Contains(name, "red")) return (English, RD); + if (Contains(name, "roug")) return (French, RD); + if (Contains(name, "rot")) return (German, RD); + if (Contains(name, "ross")) return (Italian, RD); + if (Contains(name, "rojo")) return (Spanish, RD); + + // Blue + if (Contains(name, "blue")) return (English, GN); + if (Contains(name, "bleu")) return (French, GN); + if (Contains(name, "blau")) return (German, GN); + if (Contains(name, "blu")) return (Italian, GN); + if (Contains(name, "azul")) return (Spanish, GN); + + // Yellow + if (Contains(name, "yell")) return (English, YW); + if (Contains(name, "jaun")) return (French, YW); + if (Contains(name, "gelb")) return (German, YW); + if (Contains(name, "gial")) return (Italian, YW); + if (Contains(name, "amar")) return (Spanish, YW); + + // Japanese + if (Contains(name, "赤")) return (Japanese, RD); + if (Contains(name, "緑")) return (Japanese, GN); + if (Contains(name, "青")) return (Japanese, BU); + if (Contains(name, "ピ")) return (Japanese, YW); + if (Contains(name, "黄")) return (Japanese, YW); + + if (Contains(name, "aka")) return (Japanese, RD); + if (Contains(name, "ao")) return (Japanese, BU); + if (Contains(name, "mid")) return (Japanese, GN); + if (Contains(name, "pika")) return (Japanese, YW); + + return default; + } + + /// + public static SaveLanguageResult InferFrom2(ReadOnlySpan name) + { + // Gold + if (Contains(name, "golde")) return (German, GD); + if (Contains(name, "gold")) return (English, GD); + if (Contains(name, "e oro")) return (Italian, GD); + if (Contains(name, "oro")) return (Spanish, GD); + if (Contains(name, "n or")) return (French, GD); + if (Contains(name, "金")) return (Japanese, GD); + if (Contains(name, "금")) return (Korean, GD); + + // Silver + if (Contains(name, "silv")) return (English, SI); + if (Contains(name, "silb")) return (German, SI); + if (Contains(name, "plat")) return (Spanish, SI); + if (Contains(name, "e arg")) return (Italian, SI); + if (Contains(name, "arge")) return (French, SI); + if (Contains(name, "銀")) return (Japanese, SI); + if (Contains(name, "은")) return (Korean, SI); + + // Crystal + if (Contains(name, "cry")) return (English, C); + if (Contains(name, "kri")) return (German, C); + if (Contains(name, "cristall")) return (Italian, C); + if (Contains(name, "on cri")) return (French, C); + if (Contains(name, "ón crist")) return (Spanish, C); + if (Contains(name, "クリ")) return (Japanese, C); + + if (Contains(name, "kin")) return (Japanese, GD); + if (Contains(name, "gin")) return (Japanese, SI); + if (Contains(name, "geum")) return (Korean, GD); + if (Contains(name, "eun")) return (Korean, SI); + + return default; + } + + /// + public static SaveLanguageResult InferFrom3(ReadOnlySpan name) + { + // FireRed + if (Contains(name, "fir")) return (English, FR); + if (Contains(name, "feu")) return (French, FR); + if (Contains(name, "feuer")) return (German, FR); + if (Contains(name, "fuoco")) return (Italian, FR); + if (Contains(name, "fueg")) return (Spanish, FR); + if (Contains(name, "ファイアレッド")) return (Japanese, FR); + + // LeafGreen + if (Contains(name, "lea")) return (English, LG); + if (Contains(name, "vert")) return (French, LG); + if (Contains(name, "blatt")) return (German, LG); + if (Contains(name, "fogli")) return (Italian, LG); + if (Contains(name, "hoja")) return (Spanish, LG); + if (Contains(name, "リーフグリーン")) return (Japanese, LG); + + // Ruby + if (Contains(name, "ruby")) return (English, R); + if (Contains(name, "rubí")) return (Spanish, R); + if (Contains(name, "rubino")) return (Italian, R); + if (Contains(name, "rubis")) return (French, R); + if (Contains(name, "rubin")) return (German, R); + if (Contains(name, "ルビー")) return (Japanese, R); + + // Sapphire + if (Contains(name, "sapp")) return (English, S); + if (Contains(name, "saphir")) return (French, S); + if (Contains(name, "safir")) return (German, S); + if (Contains(name, "zafir")) return (Spanish, S); + if (Contains(name, "zaffiro")) return (Italian, S); + if (Contains(name, "サファ")) return (Japanese, S); + + // Emerald + if (Contains(name, "esm")) return (Spanish, E); + if (Contains(name, "smar")) return (German, E); + if (Contains(name, "smer")) return (Italian, E); + if (Contains(name, "éme") || Contains(name, "n eme")) return (French, E); + if (Contains(name, "emer")) return (English, E); + if (Contains(name, "エメ")) return (Japanese, E); + + // Codes + if (Contains(name, "axpj")) return (Japanese, S); + if (Contains(name, "axp")) return (OverrideLanguageGen3RS, S); + if (Contains(name, "axvj")) return (Japanese, R); + if (Contains(name, "axv")) return (OverrideLanguageGen3RS, R); + if (Contains(name, "bpej")) return (Japanese, E); + if (Contains(name, "bpe")) return (OverrideLanguageGen3RS, E); + if (Contains(name, "bprj")) return (Japanese, FR); + if (Contains(name, "bpr")) return (OverrideLanguageGen3FRLG, FR); + if (Contains(name, "bpgj")) return (Japanese, LG); + if (Contains(name, "bpg")) return (OverrideLanguageGen3FRLG, LG); + + return default; + } + + /// + /// Gets a safe fallback language and version for the save file. + /// + private static SaveLanguageResult GetFallback(SAV1 sav) + { + bool jp = sav.Japanese; + bool yw = sav.Yellow; + var lang = jp ? Japanese : OverrideLanguageGen1; + var ver = yw ? YW : OverrideVersionGen1; + return (lang, ver); + } + + /// "/> + private static SaveLanguageResult GetFallback(SAV2 sav) + { + bool jp = sav.Japanese; + bool kor = sav.Korean; + var lang = jp ? Japanese : kor ? Korean : OverrideLanguageGen2; + var ver = sav.Version == C ? C : OverrideVersionGen2; + return (lang, ver); + } + + /// "/> + private static SaveLanguageResult GetFallback(SAV3 sav) + { + bool jp = sav.Japanese; + if (sav is SAV3FRLG) + return (jp ? Japanese : OverrideLanguageGen3FRLG, OverrideVersionGen3FRLG); + var lang = jp ? Japanese : OverrideLanguageGen3RS; + var ver = sav.Version == E ? E : OverrideVersionGen3RS; + return (lang, ver); + } +} + +/// +/// Result of the language and version inference. +/// +/// Language ID for the save file. +/// Version ID for the save file. +public readonly record struct SaveLanguageResult(LanguageID Language, GameVersion Version) +{ + public static implicit operator bool(SaveLanguageResult r) => r != default; + public static implicit operator SaveLanguageResult((LanguageID Language, GameVersion Version) r) => new(r.Language, r.Version); +} diff --git a/PKHeX.Core/Saves/Util/SaveUtil.cs b/PKHeX.Core/Saves/Util/SaveUtil.cs index 18710fb2a..21e1d091a 100644 --- a/PKHeX.Core/Saves/Util/SaveUtil.cs +++ b/PKHeX.Core/Saves/Util/SaveUtil.cs @@ -653,7 +653,12 @@ private static GameVersion GetIsG9SAV(ReadOnlySpan data) { var data = File.ReadAllBytes(path); var sav = GetVariantSAV(data, path); - sav?.Metadata.SetExtraInfo(path); + if (sav is null) + return null; + + sav.Metadata.SetExtraInfo(path); + if (sav.Generation <= 3) + SaveLanguage.TryRevise(sav); return sav; } catch (Exception ex) @@ -846,12 +851,12 @@ public static SaveFile GetBlankSAV(GameVersion game, string trainerName, Languag /// Blank save file from the requested game, null if no game exists for that . private static SaveFile GetBlankSAV(GameVersion game, LanguageID language) => game switch { - RD or BU or GN or YW or RBY => new SAV1(version: game, japanese: language == LanguageID.Japanese || game == BU), + RD or BU or GN or YW or RBY => new SAV1(version: game, game == BU ? LanguageID.Japanese : language), StadiumJ => new SAV1StadiumJ(), Stadium => new SAV1Stadium(language == LanguageID.Japanese), - GD or SI or GS => new SAV2(version: GS, lang: language), - C or GSC => new SAV2(version: C, lang: language), + GD or SI or GS => new SAV2(version: GS, language: language), + C or GSC => new SAV2(version: C, language: language), Stadium2 => new SAV2Stadium(language == LanguageID.Japanese), R or S or RS => new SAV3RS(language == LanguageID.Japanese), diff --git a/PKHeX.WinForms/MainWindow/Main.cs b/PKHeX.WinForms/MainWindow/Main.cs index 27bce3652..8dd29292d 100644 --- a/PKHeX.WinForms/MainWindow/Main.cs +++ b/PKHeX.WinForms/MainWindow/Main.cs @@ -426,6 +426,7 @@ private void ReloadProgramSettings(PKHeXSettings settings) PKME_Tabs.HideSecretValues = C_SAV.HideSecretDetails = settings.Privacy.HideSecretDetails; WinFormsUtil.DetectSaveFileOnFileOpen = settings.Startup.TryDetectRecentSave; SelectablePictureBox.FocusBorderDeflate = GenderToggle.FocusBorderDeflate = settings.Display.FocusBorderDeflate; + settings.SaveLanguage.Apply(); var converter = settings.Converter; EntityConverter.AllowIncompatibleConversion = converter.AllowIncompatibleConversion; @@ -912,6 +913,9 @@ private static bool CheckLoadPath(string path) private static bool SanityCheckSAV(ref SaveFile sav) { + if (sav.Generation <= 3) + SaveLanguage.TryRevise(sav); + if (sav.State.Exportable && sav is SAV3 s3) { if (ModifierKeys == Keys.Control || s3.IsCorruptPokedexFF()) @@ -935,7 +939,7 @@ private static bool SanityCheckSAV(ref SaveFile sav) s.Metadata.SetExtraInfo(origin); sav = s; } - else if (s3 is SAV3FRLG frlg) // IndeterminateSubVersion + else if (s3 is SAV3FRLG frlg && !frlg.Version.IsValidSavedVersion()) // IndeterminateSubVersion { string fr = GameInfo.GetVersionName(GameVersion.FR); string lg = GameInfo.GetVersionName(GameVersion.LG); diff --git a/PKHeX.WinForms/Properties/PKHeXSettings.cs b/PKHeX.WinForms/Properties/PKHeXSettings.cs index afdff12e7..237ead679 100644 --- a/PKHeX.WinForms/Properties/PKHeXSettings.cs +++ b/PKHeX.WinForms/Properties/PKHeXSettings.cs @@ -28,6 +28,7 @@ public sealed class PKHeXSettings public SetImportSettings Import { get; set; } = new(); public SlotWriteSettings SlotWrite { get; set; } = new(); public PrivacySettings Privacy { get; set; } = new(); + public SaveLanguageSettings SaveLanguage { get; set; } = new(); // UI Tweaks public DisplaySettings Display { get; set; } = new(); @@ -463,6 +464,47 @@ public sealed class PrivacySettings public bool HideSecretDetails { get; set; } } +public sealed class SaveLanguageSettings +{ + [LocalizedDescription("Gen1: If unable to detect a language or version for a save file, use these instead.")] + public LangVersion OverrideGen1 { get; set; } = new(); + + [LocalizedDescription("Gen2: If unable to detect a language or version for a save file, use these instead.")] + public LangVersion OverrideGen2 { get; set; } = new(); + + [LocalizedDescription("Gen3 R/S: If unable to detect a language or version for a save file, use these instead.")] + public LangVersion OverrideGen3RS { get; set; } = new(); + + [LocalizedDescription("Gen3 FR/LG: If unable to detect a language or version for a save file, use these instead.")] + public LangVersion OverrideGen3FRLG { get; set; } = new(); + + [TypeConverter(typeof(ExpandableObjectConverter))] + public sealed record LangVersion + { + public LanguageID Language { get; set; } = LanguageID.English; + public GameVersion Version { get; set; } + } + + public void Apply() + { + SaveLanguage.OverrideLanguageGen1 = OverrideGen1.Language; + if (GameVersion.RBY.Contains(OverrideGen1.Version)) + SaveLanguage.OverrideVersionGen1 = OverrideGen1.Version; + + SaveLanguage.OverrideLanguageGen2 = OverrideGen2.Language; + if (GameVersion.GS.Contains(OverrideGen2.Version)) + SaveLanguage.OverrideVersionGen2 = OverrideGen2.Version; + + SaveLanguage.OverrideLanguageGen3RS = OverrideGen3RS.Language; + if (GameVersion.RS.Contains(OverrideGen3RS.Version)) + SaveLanguage.OverrideVersionGen3RS = OverrideGen3RS.Version; + + SaveLanguage.OverrideLanguageGen3FRLG = OverrideGen3FRLG.Language; + if (GameVersion.FRLG.Contains(OverrideGen3FRLG.Version)) + SaveLanguage.OverrideVersionGen3FRLG = OverrideGen3FRLG.Version; + } +} + public sealed class BulkAnalysisSettings : IBulkAnalysisSettings { [LocalizedDescription("Checks the save file data and Current Handler state to determine if the Pokémon's Current Handler does not match the expected value.")] diff --git a/PKHeX.WinForms/Subforms/Save Editors/Gen6/SAV_BoxLayout.cs b/PKHeX.WinForms/Subforms/Save Editors/Gen6/SAV_BoxLayout.cs index 9a1543803..31a9fe6e3 100644 --- a/PKHeX.WinForms/Subforms/Save Editors/Gen6/SAV_BoxLayout.cs +++ b/PKHeX.WinForms/Subforms/Save Editors/Gen6/SAV_BoxLayout.cs @@ -27,6 +27,7 @@ public SAV_BoxLayout(SaveFile sav, int box) LB_BoxSelect.SelectedIndex = box; TB_BoxName.MaxLength = SAV.Generation switch { + 2 when SAV is SAV2 { Japanese: false, Korean: false } => 8 * 2, 6 or 7 => 14, >= 8 => 16, _ => 8, diff --git a/Tests/PKHeX.Core.Tests/PKM/StringTests.cs b/Tests/PKHeX.Core.Tests/PKM/StringTests.cs index c634418ab..93608e0f9 100644 --- a/Tests/PKHeX.Core.Tests/PKM/StringTests.cs +++ b/Tests/PKHeX.Core.Tests/PKM/StringTests.cs @@ -79,7 +79,7 @@ public static void Encode45(ushort g4, char g5) public static void ConvertStringVC(string g12, string g7) { Span b12 = stackalloc byte[g12.Length]; - var len = StringConverter12.SetString(b12, g12, g12.Length, true); + var len = StringConverter1.SetString(b12, g12, g12.Length, true); var result = StringConverter12Transporter.GetString(b12[..len], true); result.Should().Be(g7); } @@ -96,9 +96,9 @@ public static void ConvertStringG1(Species species, string expect) // Ensure the API converts it back and forth correctly. Span convert = stackalloc byte[expect.Length + 1]; - var len = StringConverter12.SetString(convert, name, name.Length, jp); + var len = StringConverter1.SetString(convert, name, name.Length, jp); len.Should().Be(expect.Length + 1); - var gen1Name = StringConverter12.GetString(convert, jp); + var gen1Name = StringConverter1.GetString(convert, jp); gen1Name.Should().Be(expect); // Truncated name transferred with Virtual Console rules isn't the same as the Generation 7 name.