D297 M-150: eeprom-shim migration of pre-D284 save CRCs

v0.3.0 silently wiped all v0.2.x saves on first launch: stored slot CRCs
are fingerprints of the PRNG that wrote them, and D284 changed the PRNG.
One-time migration in the eeprom shim's read path (block 4 only): a slot
that fails the current-CRC check but validates under the legacy PRNG is
re-stamped with current-PRNG checksums before fileValidateSaves sees it,
then persisted once. Runs before geEepromPatchAllCheats so cheat patches
never reach the file. Corrupt/fresh slots untouched (game's own wipe path).

- port/src/legacycrc.c: isolated file carrying the deliberately-wrong
  pre-D284 PRNG (verbatim from 0de99b4d^, reviewer-verified bit-exact) +
  lockstep copy of the crc.c loop; header says when it may be deleted.
- tools_pc/d295_crc.py: reference CRC model, bit-exact vs compiled C for
  both PRNGs (used to verify migrated files).

Verified: user's #87 artifact migrates 5/5 slots with progress intact and
is idempotent on relaunch; native post-fix saves, corrupt slots, and fresh
eeps all behave per design; 21/21 sweep PASS.
This commit is contained in:
James Dansereau committed 2026-09-16 15:28:37 -04:00
1 parent 4fc54293f9
commit f1e2c7cbb8
3 files changed
+209

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
/*
* port/src/legacycrc.c — D297: one-time migration of pre-D284 save-slot CRCs.
*
* WHY A DELIBERATELY-WRONG PRNG LIVES IN THIS TREE (read before "fixing" it):
*
* v0.2.x shipped with a port-side bug in randomGetNextFrom's dsll32/dsrl32
* semantics (modelled as 32-bit shifts; real MIPS64 shifts by n+32 over the
* full register). D284 (commit 0de99b4d) fixed it. But fileGenerateCRC
* (src/game/crc.c) runs that PRNG off a LOCAL constant seed, so every slot
* checksum stored on disk is a fingerprint of the PRNG implementation that
* wrote it: saves written by v0.2.x validate under the OLD stream and fail
* the NEW one, and fileValidateSaves wipes failing slots (D295/M-148, D297).
*
* This file carries the pre-D284 transform so the eeprom shim can recognize
* a legacy save (old-CRC match) and re-stamp its two checksum words with the
* current-PRNG values before the game validates. No game files are touched;
* the new CRCs come from the game's own fileGenerateCRC — only the OLD PRNG
* is duplicated here, and it must stay bit-exact-WRONG. Do not "fix" the
* shifts below; do not share helpers with port/src/random.c.
*
* When this can be deleted: once no pre-D284 saves are expected in the wild
* (the v0.3.0 release is the last build that meets them un-migrated). Keep
* it at least until then; the cost is ~60 lines of dead-but-harmless code.
*
* Verification: the legacy stream below was re-derived from
* `git show 0de99b4d^:port/src/random.c` and matches tools_pc/d295_crc.py,
* which is bit-exact against compiled C for both PRNGs (see D295 M-148).
*/
#include <ultra64.h>
#include "file.h" /* save_data */
extern void fileGenerateCRC(u8 *addressA, u8 *addressB, save_data *retval);
/* Pre-D284 randomGetNextFrom: verbatim body from 0de99b4d^:port/src/random.c
* with the OLD helper semantics inlined ((x << n) & 0xFFFFFFFF etc.). */
static u32 geLegacyRandomGetNextFrom(u64 *seed)
{
u64 a3 = *seed;
u64 a1, a2;
a2 = (a3 << 0x1f) & 0xFFFFFFFFULL; /* dsll32, pre-D284 semantics */
a1 = a3 << 0x1f; /* dsll */
a2 = a2 >> 0x1f; /* dsrl */
a1 = (a1 >> 0) & 0xFFFFFFFFULL; /* dsrl32, pre-D284 semantics */
a3 = (a3 << 0xc) & 0xFFFFFFFFULL; /* dsll32, pre-D284 semantics */
a2 = a2 | a1;
a3 = (a3 >> 0) & 0xFFFFFFFFULL; /* dsrl32, pre-D284 semantics */
a2 = a2 ^ a3;
a3 = a2 >> 0x14; /* dsrl */
a3 = a3 & 0xfff;
a3 = a3 ^ a2;
*seed = a3;
return (u32)a3;
}
/* Same loop as fileGenerateCRC (src/game/crc.c) stepping the legacy PRNG.
* Keep in lockstep with crc.c if that loop ever changes. */
static void geLegacyGenerateCRC(u8 *addressA, u8 *addressB, save_data *retval)
{
u8 *byte;
s32 shift = 0;
s64 polynormal = 0x8F809F473108B3C1; /* same local constant seed as crc.c */
s32 checksum1 = 0;
s32 checksum2 = 0;
for (byte = addressA; byte < addressB; byte++, shift += 7) {
polynormal += *byte << (shift & 0xF);
checksum1 ^= geLegacyRandomGetNextFrom(&polynormal);
}
for (byte = addressB - 1; byte >= addressA; byte--, shift += 3) {
polynormal += *byte << (shift & 0xF);
checksum2 ^= geLegacyRandomGetNextFrom(&polynormal);
}
retval->chksum1 = checksum1;
retval->chksum2 = checksum2;
}
/* Migrate up to five consecutive 96-byte save slots in place. A slot is
* migrated iff it FAILS the current-PRNG check but PASSES the legacy one;
* re-stamped via the game's own fileGenerateCRC. Corrupt slots (fail both)
* are left alone for the game's own wipe path. Returns the count migrated. */
int geLegacyCrcMaybeMigrateSlots(u8 *slots5)
{
save_data *slots = (save_data *)slots5;
int migrated = 0;
for (int i = 0; i < 5; i++) {
save_data calc = {0}; /* only chksum1/chksum2 are read back */
u8 *a = &slots[i].completion_bitflags; /* slot bytes [8..96) */
u8 *b = (u8 *)&slots[i + 1]; /* end boundary, never dereferenced */
fileGenerateCRC(a, b, &calc);
if (calc.chksum1 == slots[i].chksum1 && calc.chksum2 == slots[i].chksum2)
continue; /* current-PRNG save (or already migrated) — idempotent no-op */
geLegacyGenerateCRC(a, b, &calc);
if (calc.chksum1 != slots[i].chksum1 || calc.chksum2 != slots[i].chksum2)
continue; /* corrupt: game's fileValidateSaves handles it */
fileGenerateCRC(a, b, &slots[i]); /* re-stamp chksum1/chksum2 in place */
migrated++;
}
return migrated;
}
+19
View File
@@ -1225,6 +1225,8 @@ typedef char ge_save_slot_size_check[(sizeof(ge_save_slot) == 96) ? 1 : -1];
* re-checks after the block read. */
extern void fileGenerateCRC(u8 *addressA, u8 *addressB, void *retval);
extern int geLegacyCrcMaybeMigrateSlots(u8 *slots5); /* port/src/legacycrc.c (D297) */
extern s32 portAllUnlocked; /* port/src/video.c */
/* Bit math mirrors fileGetSaveStageDifficultyTime / fileSetDifficultyStageTime
@@ -1337,6 +1339,23 @@ static s32 geEepromRW(u8 block, u8 *buf, int nbytes, int write)
geEepromStore();
} else {
memcpy(buf, s_eeprom + off, nbytes);
/* D297: one-time migration of pre-D284 slot CRCs (port/src/legacycrc.c).
* This IS the read fileValidateSaves performs (block 4, five slots), so
* the re-stamped checksums are exactly what the game validates — no
* ordering hazard. Runs BEFORE geEepromPatchAllCheats so that only
* pristine+migrated bytes are ever persisted; AllUnlocked patches stay
* read-time-only as before. Block-0 smallSave is deliberately untouched
* (factory seal, see D297). */
if (block == 4 && nbytes == (int)(sizeof(ge_save_slot) * 5)) {
int migrated = geLegacyCrcMaybeMigrateSlots(buf);
if (migrated) {
memcpy(s_eeprom + off, buf, nbytes);
geEepromStore();
sysLogPrintf(LOG_INFO,
"eeprom: D297-migrated %d pre-D284 save slot(s) to current CRC",
migrated);
}
}
/* fileValidateSaves' block read: five save slots from block 4. */
if (portAllUnlocked && block == 4 &&
nbytes == (int)(sizeof(ge_save_slot) * 5)) {
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""D295/M-148: reproduce fileGenerateCRC (src/game/crc.c) with both the pre-D284
(buggy, shipped in v0.2.x) and post-D284 (HEAD) PRNG semantics, verify against the
user's saved slot CRCs, and emit a patched eep whose slots carry POST-FIX CRCs so
a HEAD build accepts them (proves the difficultytext overflow is still live).
Save layout in ge007.eep (verified M-148):
bytes 0..31 : smallSave header (block 0)
slot i : file offset 32 + i*96, i = 0..5 (sizeof(save_data)=96, padded)
slot fields : c1(s32) c2(s32) bitflags(1) flag_007(1) music(1) sfx(1)
options(2) cheats(3) pad(1) times[76]
CRC region : &completion_bitflags .. save+1 => slot bytes [8..96), 88 bytes.
"""
import struct, sys
M64 = (1 << 64) - 1
def make_rng(buggy: bool):
def dsll32(x, n):
if buggy: return (x << n) & 0xFFFFFFFF
return (x << (n + 32)) & M64
def dsrl32(x, n):
if buggy: return (x >> n) & 0xFFFFFFFF
return x >> (n + 32)
def step(seed: int) -> int:
# Mirrors randomGetNextFrom line-by-line. NOTE the plain dsrl/dsll lines
# (a2 >>= 31, a1 = a3 << 31, a2 >>= 20) are NOT the *32 helpers.
a3 = seed & M64
a2 = dsll32(a3, 0x1F) >> 0x1F # dsll32 then plain dsrl 31
a1 = dsrl32((a3 << 0x1F) & M64, 0) # plain dsll 31 then dsrl32 0
a3 = dsrl32(dsll32(a3, 0xC), 0) # dsll32 then dsrl32 0
a2 = (a2 | a1) ^ a3
a3 = ((a2 >> 0x14) & 0xFFF) ^ a2 # plain dsrl 20, andi, xor
return a3 & M64
return step
def file_generate_crc(data: bytes, buggy: bool):
"""Mirror crc.c exactly: shift is NOT reset between the two passes."""
step = make_rng(buggy)
poly = 0x8F809F473108B3C1 & M64
c1 = 0; c2 = 0; shift = 0
for b in data: # forward pass, +7/byte
poly = (poly + ((b << (shift & 0xF)) & M64)) & M64
shift += 7
poly = step(poly) # randomGetNextFrom updates the seed IN PLACE
c1 ^= poly & 0xFFFFFFFF
for b in reversed(data): # backward pass, +3/byte, shift continues
poly = (poly + ((b << (shift & 0xF)) & M64)) & M64
shift += 3
poly = step(poly)
c2 ^= poly & 0xFFFFFFFF
return c1 & 0xFFFFFFFF, c2 & 0xFFFFFFFF
def main():
src = sys.argv[1] if len(sys.argv) > 1 else r"C:\Users\james\Downloads\ge-issue87-artifacts\ge007.eep"
dst = sys.argv[2] if len(sys.argv) > 2 else None
eep = bytearray(open(src, "rb").read())
assert len(eep) == 2048, len(eep)
print("slot | stored c1,c2 | oldPRNG c1,c2 | newPRNG c1,c2 | old? new?")
ok_old = ok_new = True
for i in range(5):
off = 32 + i * 96
sc1, sc2 = struct.unpack_from("<II", eep, off)
region = bytes(eep[off + 8: off + 96])
oc1, oc2 = file_generate_crc(region, buggy=True)
nc1, nc2 = file_generate_crc(region, buggy=False)
o = (oc1, oc2) == (sc1, sc2); n = (nc1, nc2) == (sc1, sc2)
ok_old &= o; ok_new &= n
print(f" {i} | {sc1:08x},{sc2:08x} | {oc1:08x},{oc2:08x} | {nc1:08x},{nc2:08x} | {o=} {n=}")
print(f"old PRNG matches all 5 stored CRCs: {ok_old}")
print(f"new PRNG matches all 5 stored CRCs: {ok_new}")
if dst:
for i in range(5):
off = 32 + i * 96
region = bytes(eep[off + 8: off + 96])
nc1, nc2 = file_generate_crc(region, buggy=False)
struct.pack_into("<II", eep, off, nc1, nc2)
open(dst, "wb").write(eep)
print(f"patched (post-fix CRCs, slots 0-4) -> {dst}")
if __name__ == "__main__":
main()