From c7a5ab29be02250c6db7db06cbd58ab01c5b08e5 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sat, 22 Aug 2026 13:23:53 -0500 Subject: [PATCH] Stad2: add slot compression, ignore ghost slots Uses the slot-present count in the box data header, same logic as Stadium 1. Closes #4861 I assume Japanese-Stadium1 has the same issue, but I cannot find a reference save file with any box slots present. The count might be at offset 0x14 based on previous documentation threads on pporg. add ampersand escape for display, and add some suppression for roslynator error that isn't needed. --- .../Moves/Breeding/Chain/ChainBreedUtil.cs | 7 +- .../Specialized/GameSpecificSettings.cs | 4 +- PKHeX.Core/Saves/SAV2Stadium.cs | 91 ++++++++++++++++--- PKHeX.WinForms/Resources/text/lang_de.txt | 2 +- PKHeX.WinForms/Resources/text/lang_en.txt | 2 +- 5 files changed, 89 insertions(+), 17 deletions(-) diff --git a/PKHeX.Core/Legality/Moves/Breeding/Chain/ChainBreedUtil.cs b/PKHeX.Core/Legality/Moves/Breeding/Chain/ChainBreedUtil.cs index 217436f25..49c0d6a5f 100644 --- a/PKHeX.Core/Legality/Moves/Breeding/Chain/ChainBreedUtil.cs +++ b/PKHeX.Core/Legality/Moves/Breeding/Chain/ChainBreedUtil.cs @@ -43,6 +43,7 @@ public static bool Summarize(GameStrings strings, ushort species, byte form, Gam return true; } + #pragma warning disable RCS1242 /// public static string Summarize(GameStrings strings, in ChainBreedTrace trace) { @@ -55,9 +56,10 @@ public static string Summarize(GameStrings strings, in ChainBreedTrace trace) public static void Summarize(GameStrings strings, in ChainBreedTrace trace, StringBuilder sb) { WriteHeader(trace, sb); - for (var i = trace.Steps.Length - 1; i >= 0; i--) + var steps = trace.Steps; + for (var i = steps.Length - 1; i >= 0; i--) { - var entry = trace.Steps[i]; + var entry = steps[i]; WriteEntry(entry, sb, strings, i); } } @@ -72,6 +74,7 @@ private static void WriteHeader(in ChainBreedTrace trace, StringBuilder sb) else sb.AppendLine($"{count} = {breed} 🥚 & {transfer} ♻️."); } + #pragma warning restore RCS1242 private static void WriteEntry(in ChainBreedStep entry, StringBuilder sb, GameStrings strings, int index) { diff --git a/PKHeX.Core/Legality/Settings/Specialized/GameSpecificSettings.cs b/PKHeX.Core/Legality/Settings/Specialized/GameSpecificSettings.cs index 9d4ae367f..556c16683 100644 --- a/PKHeX.Core/Legality/Settings/Specialized/GameSpecificSettings.cs +++ b/PKHeX.Core/Legality/Settings/Specialized/GameSpecificSettings.cs @@ -14,12 +14,12 @@ public sealed class GameSpecificSettings public sealed class GameSpecificSettings3 { /// - /// Pokemon Box: Ruby & Sapphire allows swapping of teams at a precise time, which can put Mythicals/Legends/Over-leveled into the team slots before the ribbon award sequence. + /// Pokemon Box: Ruby & Sapphire allows swapping of teams at a precise time, which can put Mythicals/Legends/Over-leveled into the team slots before the ribbon award sequence. /// /// /// https://projectpokemon.org/home/forums/topic/68163-how-to-give-ribbons-to-legendary-pok%C3%A9mon-using-pok%C3%A9mon-box-ruby-sapphire/ /// - [LocalizedDescription("Rule-tweak to allow the Pokemon Box: Ruby & Sapphire exploit to obtain Battle Tower ribbons illegitimately.")] + [LocalizedDescription("Rule-tweak to allow the Pokemon Box: Ruby && Sapphire exploit to obtain Battle Tower ribbons illegitimately.")] public bool AllowBattleTowerTeamSwap { get; set; } } diff --git a/PKHeX.Core/Saves/SAV2Stadium.cs b/PKHeX.Core/Saves/SAV2Stadium.cs index 68c9c35be..6cac0a25b 100644 --- a/PKHeX.Core/Saves/SAV2Stadium.cs +++ b/PKHeX.Core/Saves/SAV2Stadium.cs @@ -6,7 +6,7 @@ namespace PKHeX.Core; /// /// Pokémon Stadium 2 (Pokémon Stadium GS in Japan) /// -public sealed class SAV2Stadium : SAV_STADIUM, IBoxDetailName +public sealed class SAV2Stadium : SAV_STADIUM, IBoxDetailName, IStorageCleanup { public override int SaveRevision => Japanese ? 0 : 1; public override string SaveRevisionString => Japanese ? "-J" : "-U"; @@ -62,6 +62,7 @@ public sealed class SAV2Stadium : SAV_STADIUM, IBoxDetailName public SAV2Stadium(Memory data, bool japanese) : base(data, japanese, GetIsSwap(data.Span, japanese)) { Box = BoxStart; + ConditionBoxes(); } public SAV2Stadium(bool japanese = false) : base(japanese, SaveUtil.SIZE_G2STAD) @@ -85,6 +86,15 @@ protected override bool GetIsBoxChecksumValid(int box) return chk == actual; } + // Box Structure: + // 0x00: 1 byte: initialized (1) or not (0) + // 0x01: 1 byte: count of present slots + // 0x02: 2 bytes: reserved + // 0x04: 0x50 (String?) + // 0x10: 16 bytes: box name (0x50-terminated) + // 0x20: SK2[n] slots + // ....: 0x00, 0x00, u32 Magic, u16 Checksum + protected override void SetBoxMetadata(int box) { var bdata = GetBoxOffset(box); @@ -99,21 +109,23 @@ protected override void SetBoxMetadata(int box) } var boxOfs = bdata - ListHeaderSizeBox; - var slice = Data.Slice(boxOfs, ListHeaderSizeBox); - if (slice[0] == 0) + var header = Data.Slice(boxOfs, ListHeaderSizeBox); + if (header[0] == 0) { - slice[0] = 1; - slice[1] = (byte)count; - slice[4] = StringConverter2.TerminatorCode; + // Initialize with current count and a fake box name. This is only done for uninitialized boxes. + header[0] = 1; + header[4] = StringConverter2.TerminatorCode; + + // Write a fake box name for (int i = 0; i < 4; i++) - slice[0x10 + i] = (byte)(0xF6 + i); // 1234 - } - else - { - slice[1] = (byte)count; + header[0x10 + i] = (byte)(0xF6 + i); // 1234 + header[0x14] = StringConverter2.TerminatorCode; } + header[1] = (byte)count; } + private byte GetBoxSlotCount(int boxDataStart) => Data[boxDataStart - ListHeaderSizeBox + 1]; + protected override void SetBoxChecksum(int box) { var boxOfs = GetBoxOffset(box) - ListHeaderSizeBox; @@ -238,6 +250,63 @@ private static bool GetIsSwap(ReadOnlySpan data, bool japanese) return StadiumUtil.IsMagicPresentSwap(boxSpan, BoxSizeJ, MAGIC_FOOTER, 1); return StadiumUtil.IsMagicPresentSwap(boxSpan, BoxSizeU, MAGIC_FOOTER, 1); } + + private void ConditionBoxes() + { + var blank = BlankPKM; + for (int i = 0; i < BoxCount; i++) + { + // If the box is uninitialized, reset it to the right state. + var ofs = GetBoxOffset(i); + + // Wipe empty slots after the count; don't display ghost slots. + var count = GetBoxSlotCount(ofs); + if (count >= BoxSlotCount) + continue; // already full + + // Fill empty slots with blank PKM so that arbitrary reads are correct + // If you want to see the ghost slots, add your own code to `continue` instead of doing the loop. + for (int s = count; s < BoxSlotCount; s++) + { + var rel = ofs + (s * SIZE_STORED); + var slice = Data.Slice(rel, SIZE_STORED); + var species = slice[0]; + if (species == 0) // don't bother converting from internal->national + continue; // don't bother wiping already-empty slots. + WriteSlotBox(blank, slice); + } + } + } + + public bool FixStoragePreWrite() + { + // Compress the storage. + bool anyShifted = false; + // For each box, move present slots to the front. + for (int i = 0; i < BoxCount; i++) + { + int present = 0; + var ofs = GetBoxOffset(i); + for (int s = 0; s < BoxSlotCount; s++) + { + var rel = ofs + (s * SIZE_STORED); + var species = Data[rel]; + if (species == 0) + continue; + if (present != s) + { + anyShifted = true; + var upSlot = Data[(ofs + (present * SIZE_STORED))..]; + var src = Data.Slice(rel, SIZE_STORED); + src.CopyTo(upSlot); + // wipe the old slot + src.Clear(); + } + present++; + } + } + return anyShifted; + } } public enum Stadium2TeamType diff --git a/PKHeX.WinForms/Resources/text/lang_de.txt b/PKHeX.WinForms/Resources/text/lang_de.txt index 9c2d0e4b4..fff52f34b 100644 --- a/PKHeX.WinForms/Resources/text/lang_de.txt +++ b/PKHeX.WinForms/Resources/text/lang_de.txt @@ -377,7 +377,7 @@ KChart.DGV_SpecName=Name KChart.DGV_Sprite=Sprite KChart.DGV_Type1=Typ1 KChart.DGV_Type2=Typ2 -LocalizedDescription.AllowBattleTowerTeamSwap=Regeländerung, um den Pokémon Box: Rubin & Saphir-Exploit zu erlauben, mit dem Battle Tower-Bänder unrechtmäßig erhalten werden können. +LocalizedDescription.AllowBattleTowerTeamSwap=Regeländerung, um den Pokémon Box: Rubin && Saphir-Exploit zu erlauben, mit dem Battle Tower-Bänder unrechtmäßig erhalten werden können. LocalizedDescription.AllowBoxDataDrop=Ermöglicht das Drag-and-Drop von Box-Binärdateien aus der Benutzeroberfläche über den Box-Tab. LocalizedDescription.AllowGen1Tradeback=Erlaube Gen 1 Rücktausch LocalizedDescription.AllowGuessRejuvenateHOME=Erlaube der PKM Konvertierung, legale Begegnungs Daten, welche nicht im vorherigen Format gespeichert sind, zu erraten. diff --git a/PKHeX.WinForms/Resources/text/lang_en.txt b/PKHeX.WinForms/Resources/text/lang_en.txt index 5f6451fd7..2f64fd9a1 100644 --- a/PKHeX.WinForms/Resources/text/lang_en.txt +++ b/PKHeX.WinForms/Resources/text/lang_en.txt @@ -377,7 +377,7 @@ KChart.DGV_SpecName=Name KChart.DGV_Sprite=Sprite KChart.DGV_Type1=Type1 KChart.DGV_Type2=Type2 -LocalizedDescription.AllowBattleTowerTeamSwap=Allow the Pokemon Box: Ruby & Sapphire exploit to obtain Battle Tower ribbons illegitimately. +LocalizedDescription.AllowBattleTowerTeamSwap=Allow the Pokemon Box: Ruby && Sapphire exploit to obtain Battle Tower ribbons illegitimately. LocalizedDescription.AllowBoxDataDrop=Allow drag and drop of boxdata binary files from the GUI via the Box tab. LocalizedDescription.AllowGen1Tradeback=GB: Allow Generation 2 tradeback learnsets LocalizedDescription.AllowGuessRejuvenateHOME=Allow PKM file conversion paths to guess the legal original encounter data that is not stored in the format that it was converted from.