Files
gen1recomp/tools/save-compat/PkhexProbe/Program.cs
T
2026-10-01 17:30:52 -04:00

224 lines
11 KiB
C#

using System.Buffers.Binary;
using System.Text;
using System.Text.RegularExpressions;
using PKHeX.Core;
static byte[] Trim(byte[] data)
{
int[] chips = [0x20000, 0x10000, 0x8000, 0x2000];
if (Array.IndexOf(chips, data.Length) >= 0) return data;
if (data.Length - 16 is 0x20000 or 0x10000) return data[..^16];
foreach (var size in chips)
{
if (size >= data.Length) continue;
var pad = data.AsSpan(size);
var v = pad[0];
if ((v is 0xFF or 0x00) && pad.IndexOfAnyExcept(v) < 0) return data[..size];
}
return data;
}
static bool Open(byte[] orig, out SaveFile? save, out byte[] payload, out string route)
{
payload = orig.ToArray();
route = "direct";
var ok = SaveUtil.TryGetSaveFile(payload, out save);
if (!ok)
{
var t = Trim(orig);
if (t.Length != orig.Length)
{
payload = t.ToArray();
ok = SaveUtil.TryGetSaveFile(payload, out save);
route = $"trim({orig.Length}->{t.Length})";
}
}
return ok && save != null;
}
static string Diffs(byte[] a, byte[] b, int max = 12)
{
if (a.Length != b.Length) return $"LEN {a.Length}->{b.Length}";
var runs = new List<string>();
int n = 0;
for (int i = 0; i < a.Length;)
{
if (a[i] == b[i]) { i++; continue; }
int s = i;
while (i < a.Length && a[i] != b[i]) { n++; i++; }
runs.Add($"0x{s:X}-0x{i - 1:X}");
}
if (n == 0) return "identical";
var shown = string.Join(",", runs.Take(max)) + (runs.Count > max ? $",...(+{runs.Count - max} runs)" : "");
return $"{n}B/{runs.Count}runs: {shown}";
}
static IEnumerable<(string where, PKM pk)> AllMons(SaveFile s)
{
int pc = Math.Clamp(s.PartyCount, 0, 6);
for (int i = 0; i < pc; i++) yield return ($"party[{i}]", s.GetPartySlotAtIndex(i));
for (int b = 0; b < s.BoxCount; b++)
for (int k = 0; k < s.BoxSlotCount; k++)
{
var pk = s.GetBoxSlotAtIndex(b, k);
bool empty = pk is G3PKM g ? g.SpeciesInternal == 0 : pk.Species == 0;
if (pk is PK1 or PK2 ? pk.Species == 0 : false) empty = true;
if (!empty) yield return ($"box{b + 1}[{k}]", pk);
}
}
var okName = new Regex(@"^[A-Za-z0-9 .,'\-!?:;()/&é♂♀×]*$");
static string Slots(SaveFile s)
{
var sb = new StringBuilder();
int pc = Math.Clamp(s.PartyCount, 0, 6);
for (int i = 0; i < pc; i++) sb.Append(Convert.ToHexString(s.GetPartySlotAtIndex(i).Data)).Append('|');
for (int b = 0; b < s.BoxCount; b++) for (int k = 0; k < s.BoxSlotCount; k++) sb.Append(Convert.ToHexString(s.GetBoxSlotAtIndex(b, k).Data)).Append('|');
return sb.ToString();
}
static string G1BoxChecksums(byte[] d)
{
if (d.Length < 0x8000) return "n/a";
var bad = new List<string>();
for (int bank = 0; bank < 2; bank++)
{
int b0 = 0x4000 + bank * 0x2000;
byte all = 0;
for (int i = 0; i < 0x1A4C; i++) all += d[b0 + i];
all = (byte)~all;
if (all != d[b0 + 0x1A4C]) bad.Add($"bank{bank + 2}all(stored {d[b0 + 0x1A4C]:X2} calc {all:X2})");
for (int bx = 0; bx < 6; bx++)
{
byte c = 0;
for (int i = 0; i < 0x462; i++) c += d[b0 + bx * 0x462 + i];
c = (byte)~c;
if (c != d[b0 + 0x1A4D + bx]) bad.Add($"bank{bank + 2}box{bx}(stored {d[b0 + 0x1A4D + bx]:X2} calc {c:X2})");
}
}
return bad.Count == 0 ? "ok" : "BAD:" + string.Join(";", bad);
}
Console.WriteLine("file\tsize\troute\ttype\tversion\tPKHeXchk\tchkInfo\textraChk\tparty\tboxed\tmonAnomalies\texception\tunchangedWriteDiff\tunchangedWriteSizeSame\tnoopSetSlotsWriteDiff\tnoopSizeSame\tpkforgeReopen\tpkforgeSlotDiff\tpkforgeG3Corrupting\tpkforgeG3Foreign\tpkforgeG3ChkMajority\tpkforgeEditionHint\tnotes");
foreach (var path in args)
{
var cols = new string[23];
cols[0] = path;
try
{
var orig = File.ReadAllBytes(path);
cols[1] = orig.Length.ToString();
if (!Open(orig, out var save, out var pristine, out var route) || save == null)
{
cols[2] = "-"; cols[3] = "NOT RECOGNIZED"; cols[11] = "InvalidDataException: The selected bytes are not a recognized save file.";
Console.WriteLine(string.Join("\t", cols.Select(c => c ?? "")));
continue;
}
pristine = pristine.ToArray();
cols[2] = route;
cols[3] = save.GetType().Name;
cols[4] = save.Version.ToString();
cols[5] = save.ChecksumsValid.ToString();
cols[6] = save.ChecksumInfo.Replace("\n", " / ").Replace("\r", "");
if (save is SAV1) cols[7] = "G1boxbanks=" + G1BoxChecksums(pristine);
else if (save is SAV2 s2c)
{
var o = s2c.Version == GameVersion.C ? 0x1F0D : 0x7E6D;
cols[7] = s2c.Version == GameVersion.C ? "crystal:see chkInfo" : "GS 0x7E6D: see chkInfo";
}
int party = save.PartyCount;
cols[8] = party.ToString();
var anomalies = new List<string>();
int total = 0;
foreach (var (where, pk) in AllMons(save))
{
if (!where.StartsWith("party")) total++;
var issues = new List<string>();
var lv = pk.CurrentLevel;
if (pk.Species < 1 || pk.Species > save.MaxSpeciesID) issues.Add($"species={pk.Species}");
if (lv < 1 || lv > 100) issues.Add($"lvl={lv}");
if (where.StartsWith("party") && pk.Stat_Level != lv) issues.Add($"lvlbyte={pk.Stat_Level}!=exp{lv}");
if (!okName.IsMatch(pk.Nickname) || pk.Nickname.Length == 0) issues.Add($"nick='{pk.Nickname}'");
if (!okName.IsMatch(pk.OriginalTrainerName) || pk.OriginalTrainerName.Length == 0) issues.Add($"OT='{pk.OriginalTrainerName}'");
if (pk is G3PKM g3 && !pk.ChecksumValid) issues.Add("monChkBAD");
if (pk is PK3 p3 && !p3.IsEgg && (p3.Data[0x13] & 2) == 0) issues.Add("noHasSpecies");
if (issues.Count > 0) anomalies.Add($"{where}:{string.Join(",", issues)}");
}
cols[9] = total.ToString();
cols[10] = anomalies.Count == 0 ? "none" : $"{anomalies.Count} mons; " + string.Join(" ; ", anomalies.Take(6)) + (anomalies.Count > 6 ? " ..." : "");
cols[11] = "";
var before = Slots(save);
if (save is SAV3 s3) cols[22] = $"jp={s3.Japanese}";
if (save is SAV1 s1) cols[22] = $"jp={s1.Japanese},boxInit={s1.BoxesInitialized},curBox={s1.CurrentBox}";
if (save is SAV2 s2x) cols[22] = $"jp={s2x.Japanese},kor={s2x.Korean}";
byte[] written;
try { written = save.Write().ToArray(); }
catch (Exception e) { cols[11] = $"WRITE EXC {e.GetType().Name}: {e.Message}"; Console.WriteLine(string.Join("\t", cols.Select(c => c ?? ""))); continue; }
cols[12] = Diffs(pristine, written);
if (Environment.GetEnvironmentVariable("DUMP") is { } dd) { Directory.CreateDirectory(dd); File.WriteAllBytes(Path.Combine(dd, Path.GetFileName(path) + ".written"), written); }
cols[13] = (written.Length == pristine.Length).ToString();
try
{
Open(orig, out var s2, out var p2, out _);
var sv = s2!;
if (sv.PartyCount > 0) sv.SetPartySlotAtIndex(sv.GetPartySlotAtIndex(0), 0, EntityImportSettings.None);
for (int b = 0; b < sv.BoxCount; b++) for (int k = 0; k < sv.BoxSlotCount; k++)
{
var pk = sv.GetBoxSlotAtIndex(b, k);
bool e = pk is G3PKM gg ? gg.SpeciesInternal == 0 : pk.Species == 0;
if (!e) { sv.SetBoxSlotAtIndex(pk, b, k, EntityImportSettings.None); goto done; }
}
done:
var w2 = sv.Write().ToArray();
cols[14] = Diffs(pristine, w2);
cols[15] = (w2.Length == pristine.Length).ToString();
}
catch (Exception e) { cols[14] = $"EXC {e.GetType().Name}: {e.Message}"; }
try
{
var okr = Open(written, out var s3r, out _, out _);
cols[16] = okr.ToString();
if (okr) cols[17] = Slots(s3r!) == before ? "no slot change" : "SLOTS CHANGED";
}
catch (Exception e) { cols[16] = $"EXC {e.Message}"; }
if (save is SAV3 g3s)
{
cols[18] = written.AsSpan().SequenceEqual(pristine) ? "no (untouched Write reproduces file: writes allowed)" : "YES CorruptingLayout: untouched Write != file (writes refused)";
int foreign = 0, readable = 0, party6 = 0, partyBad = 0, boxN = 0, boxBad = 0;
var editions = new HashSet<GameVersion>();
for (int i = 0; i < Math.Clamp(g3s.PartyCount, 0, 6); i++)
{
var pk = (G3PKM)g3s.GetPartySlotAtIndex(i);
if (pk.Data.ToArray().Any(b => b != 0)) { party6++; if (!pk.ChecksumValid) partyBad++; }
if (pk.ChecksumValid && pk.SpeciesInternal != 0) { readable++; var r = pk.SpeciesInternal; if (!(r is >= 1 and <= 251 or >= 277 and <= 412)) foreign++; }
}
for (int b = 0; b < g3s.BoxCount; b++) for (int k = 0; k < g3s.BoxSlotCount; k++)
{
var pk = (G3PKM)g3s.GetBoxSlotAtIndex(b, k);
bool empty = pk.Data.ToArray().All(x => x == 0) || pk.SpeciesInternal == 0 && pk.ChecksumValid;
if (!empty) { boxN++; if (!pk.ChecksumValid) boxBad++; }
if (pk.ChecksumValid && pk.SpeciesInternal != 0) { readable++; var r = pk.SpeciesInternal; if (!(r is >= 1 and <= 251 or >= 277 and <= 412)) foreign++; }
}
cols[19] = foreign == 0 ? "no" : $"YES SuspectedHack: {foreign} of {readable} foreign species ids";
bool maj = (party6 > 0 && partyBad * 2 > party6) || (boxN >= 3 && boxBad * 2 > boxN);
cols[20] = maj ? $"YES CorruptingLayout: partyBad {partyBad}/{party6} boxBad {boxBad}/{boxN}" : $"no (partyBad {partyBad}/{party6}, boxBad {boxBad}/{boxN})";
if (save is SAV3FRLG or SAV3RS)
{
var eds = new HashSet<GameVersion>();
var all = AllMons(save);
var ed = save is SAV3FRLG ? new[] { GameVersion.FR, GameVersion.LG } : new[] { GameVersion.R, GameVersion.S };
foreach (var (_, pk) in all)
if (pk.Species != 0 && pk.ChecksumValid && pk.ID32 == save.ID32 && pk.OriginalTrainerName == save.OT && Array.IndexOf(ed, pk.Version) >= 0) eds.Add(pk.Version);
cols[21] = eds.Count == 1 ? $"{eds.First()}" : $"none/ambiguous ({eds.Count}); stays {save.Version}";
}
}
}
catch (Exception e)
{
cols[11] = $"{e.GetType().FullName}: {e.Message} @ {e.StackTrace?.Split('\n').FirstOrDefault()?.Trim()}";
}
Console.WriteLine(string.Join("\t", cols.Select(c => c ?? "")));
}
return 0;