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.
This commit is contained in:
Kurt
2026-08-22 13:23:53 -05:00
parent ad69eae1a3
commit c7a5ab29be
5 changed files with 89 additions and 17 deletions

View File

@@ -43,6 +43,7 @@ public static bool Summarize(GameStrings strings, ushort species, byte form, Gam
return true;
}
#pragma warning disable RCS1242
/// <inheritdoc cref="Summarize(GameStrings, ushort, byte, GameVersion, ReadOnlySpan{ushort})"/>
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)
{

View File

@@ -14,12 +14,12 @@ public sealed class GameSpecificSettings
public sealed class GameSpecificSettings3
{
/// <summary>
/// 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 &amp; 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.
/// </summary>
/// <remarks>
/// https://projectpokemon.org/home/forums/topic/68163-how-to-give-ribbons-to-legendary-pok%C3%A9mon-using-pok%C3%A9mon-box-ruby-sapphire/
/// </remarks>
[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; }
}

View File

@@ -6,7 +6,7 @@ namespace PKHeX.Core;
/// <summary>
/// Pokémon Stadium 2 (Pokémon Stadium GS in Japan)
/// </summary>
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<byte> 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<byte> 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

View File

@@ -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.

View File

@@ -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.