mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-05-06 14:24:15 +02:00
Mirrors the read path added in 47277c7: updateSaveDataWithStruct now
flushes Zenny/GZenny/CP back to the blob for ZZ, using the same
`ok && off > 0 && off+size <= len(blob)` guard so unmapped modes remain
inert.
Tests lock down byte-level idempotence — the most important invariant
for save data. Parsing a live kirito ZZ blob and immediately writing
the struct back produces a byte-identical blob, so enabling these
fields cannot silently corrupt existing player saves on the next save
cycle. Additional coverage: round-trip through both paths, non-ZZ
modes never touch the blob bytes, and truncated blobs don't panic on
write.
469 lines
16 KiB
Go
469 lines
16 KiB
Go
package channelserver
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
cfg "erupe-ce/config"
|
|
"erupe-ce/server/channelserver/compression/nullcomp"
|
|
)
|
|
|
|
// zzBlobSize is the minimum decompressed ZZ save blob size required to cover
|
|
// every offset declared in getPointers(cfg.ZZ). Derived from the highest
|
|
// mapped pointer (pKQF = 146720) plus saveFieldKQF, plus the new fields.
|
|
// Use a generous upper bound so every pointer + field is addressable.
|
|
const zzBlobSize = 150820 // matches observed live ZZ decompressed size
|
|
|
|
// buildMinimalZZBlob builds a zero-initialised decompressed ZZ save blob
|
|
// large enough to cover every field the parser reads, with the given
|
|
// scalar values written at their expected offsets.
|
|
func buildMinimalZZBlob(t *testing.T, zenny, gzenny, cp uint32, rp uint16, playtime uint32) []byte {
|
|
t.Helper()
|
|
buf := make([]byte, zzBlobSize)
|
|
p := getPointers(cfg.ZZ)
|
|
binary.LittleEndian.PutUint32(buf[p[pZenny]:p[pZenny]+saveFieldZenny], zenny)
|
|
binary.LittleEndian.PutUint32(buf[p[pGZenny]:p[pGZenny]+saveFieldGZenny], gzenny)
|
|
binary.LittleEndian.PutUint32(buf[p[pCP]:p[pCP]+saveFieldCP], cp)
|
|
binary.LittleEndian.PutUint16(buf[p[pRP]:p[pRP]+saveFieldRP], rp)
|
|
binary.LittleEndian.PutUint32(buf[p[pPlaytime]:p[pPlaytime]+saveFieldPlaytime], playtime)
|
|
return buf
|
|
}
|
|
|
|
// TestGetPointers_NewFields_ZZOnly verifies that pZenny / pGZenny / pCP /
|
|
// pCurrentEquip are only populated for cfg.ZZ and remain zero for every
|
|
// other mode. This guards against accidental cross-version reads that
|
|
// could corrupt saves on F5 / G1-G5.2 / S6 where the offsets are not
|
|
// validated.
|
|
func TestGetPointers_NewFields_ZZOnly(t *testing.T) {
|
|
zzPointers := getPointers(cfg.ZZ)
|
|
if zzPointers[pZenny] != 0xB0 {
|
|
t.Errorf("ZZ pZenny = 0x%X, want 0xB0", zzPointers[pZenny])
|
|
}
|
|
if zzPointers[pGZenny] != 0x1FF64 {
|
|
t.Errorf("ZZ pGZenny = 0x%X, want 0x1FF64", zzPointers[pGZenny])
|
|
}
|
|
if zzPointers[pCP] != 0x212E4 {
|
|
t.Errorf("ZZ pCP = 0x%X, want 0x212E4", zzPointers[pCP])
|
|
}
|
|
if zzPointers[pCurrentEquip] != 0x1F604 {
|
|
t.Errorf("ZZ pCurrentEquip = 0x%X, want 0x1F604", zzPointers[pCurrentEquip])
|
|
}
|
|
|
|
unmapped := []cfg.Mode{cfg.Z2, cfg.Z1, cfg.G101, cfg.G10, cfg.G91, cfg.G9,
|
|
cfg.G81, cfg.G8, cfg.G7, cfg.G61, cfg.G6, cfg.G52, cfg.G51, cfg.G5,
|
|
cfg.GG, cfg.G32, cfg.G31, cfg.G3, cfg.G2, cfg.G1,
|
|
cfg.F5, cfg.F4, cfg.S6}
|
|
for _, m := range unmapped {
|
|
p := getPointers(m)
|
|
for _, ptr := range []SavePointer{pZenny, pGZenny, pCP, pCurrentEquip} {
|
|
if got, ok := p[ptr]; ok && got != 0 {
|
|
t.Errorf("mode %v unexpectedly has pointer %v = 0x%X "+
|
|
"(new fields must stay unmapped outside ZZ)", m, ptr, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUpdateStructWithSaveData_ZZ_NewFields builds a minimal ZZ blob with
|
|
// known zenny / gzenny / CP values at their configured offsets, runs the
|
|
// parser, and asserts the struct fields match. This is the positive-path
|
|
// roundtrip: blob → struct.
|
|
func TestUpdateStructWithSaveData_ZZ_NewFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
zenny uint32
|
|
gzenny uint32
|
|
cp uint32
|
|
}{
|
|
{"zero values", 0, 0, 0},
|
|
{"typical HR999 values", 8821924, 838956, 49379}, // from live blob
|
|
{"max uint32", 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF},
|
|
{"mixed", 123456, 0, 999},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
blob := buildMinimalZZBlob(t, tt.zenny, tt.gzenny, tt.cp, 0, 0)
|
|
save := &CharacterSaveData{
|
|
Mode: cfg.ZZ,
|
|
Pointers: getPointers(cfg.ZZ),
|
|
decompSave: blob,
|
|
}
|
|
save.updateStructWithSaveData()
|
|
if save.Zenny != tt.zenny {
|
|
t.Errorf("Zenny = %d, want %d", save.Zenny, tt.zenny)
|
|
}
|
|
if save.GZenny != tt.gzenny {
|
|
t.Errorf("GZenny = %d, want %d", save.GZenny, tt.gzenny)
|
|
}
|
|
if save.CP != tt.cp {
|
|
t.Errorf("CP = %d, want %d", save.CP, tt.cp)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpdateStructWithSaveData_ZZ_ExistingFieldsUnaffected is a regression
|
|
// guard: loading a ZZ blob with the new fields populated must not change
|
|
// how Playtime / HR / RP / KQF / Gender are read. Any shift in those
|
|
// values would silently corrupt live saves on next write-back.
|
|
func TestUpdateStructWithSaveData_ZZ_ExistingFieldsUnaffected(t *testing.T) {
|
|
const (
|
|
wantPlaytime uint32 = 472080 // from live kirito blob (131h)
|
|
wantRP uint16 = 1234
|
|
)
|
|
blob := buildMinimalZZBlob(t, 8821924, 838956, 49379, wantRP, wantPlaytime)
|
|
// Populate gender byte so the gender read path exercises the live offset.
|
|
p := getPointers(cfg.ZZ)
|
|
blob[p[pGender]] = 1
|
|
save := &CharacterSaveData{
|
|
Mode: cfg.ZZ,
|
|
Pointers: p,
|
|
decompSave: blob,
|
|
}
|
|
save.updateStructWithSaveData()
|
|
if save.Playtime != wantPlaytime {
|
|
t.Errorf("Playtime = %d, want %d (existing field must not shift)",
|
|
save.Playtime, wantPlaytime)
|
|
}
|
|
if save.RP != wantRP {
|
|
t.Errorf("RP = %d, want %d (existing field must not shift)",
|
|
save.RP, wantRP)
|
|
}
|
|
if !save.Gender {
|
|
t.Errorf("Gender = false, want true (existing field must not shift)")
|
|
}
|
|
if len(save.KQF) != saveFieldKQF {
|
|
t.Errorf("KQF len = %d, want %d", len(save.KQF), saveFieldKQF)
|
|
}
|
|
}
|
|
|
|
// TestUpdateStructWithSaveData_NewCharacterSkipsReads ensures that for
|
|
// brand-new characters (IsNewCharacter = true) none of the new fields are
|
|
// populated from what is likely an uninitialised blob.
|
|
func TestUpdateStructWithSaveData_NewCharacterSkipsReads(t *testing.T) {
|
|
blob := buildMinimalZZBlob(t, 9999, 9999, 9999, 0, 0)
|
|
save := &CharacterSaveData{
|
|
Mode: cfg.ZZ,
|
|
Pointers: getPointers(cfg.ZZ),
|
|
decompSave: blob,
|
|
IsNewCharacter: true,
|
|
}
|
|
save.updateStructWithSaveData()
|
|
if save.Zenny != 0 || save.GZenny != 0 || save.CP != 0 {
|
|
t.Errorf("new character leaked zenny/gzenny/CP: %d/%d/%d",
|
|
save.Zenny, save.GZenny, save.CP)
|
|
}
|
|
}
|
|
|
|
// TestUpdateStructWithSaveData_NonZZLeavesNewFieldsZero verifies that a
|
|
// non-ZZ mode (e.g. Z2 or G10) does NOT read zenny/gzenny/CP, so they
|
|
// remain zero-valued. ZZ-only scope must not leak into other versions.
|
|
func TestUpdateStructWithSaveData_NonZZLeavesNewFieldsZero(t *testing.T) {
|
|
modes := []cfg.Mode{cfg.Z2, cfg.G10, cfg.G5, cfg.F5, cfg.S6}
|
|
for _, m := range modes {
|
|
t.Run(m.String(), func(t *testing.T) {
|
|
// Build a generous blob so bounds are never the reason for zeros.
|
|
blob := make([]byte, zzBlobSize)
|
|
// Seed what would be the ZZ zenny offset with a recognisable
|
|
// non-zero value — if the parser mistakenly reads it for a
|
|
// non-ZZ mode, the test catches it.
|
|
binary.LittleEndian.PutUint32(blob[0xB0:0xB0+4], 0xDEADBEEF)
|
|
binary.LittleEndian.PutUint32(blob[0x1FF64:0x1FF64+4], 0xCAFEBABE)
|
|
binary.LittleEndian.PutUint32(blob[0x212E4:0x212E4+4], 0x1234)
|
|
save := &CharacterSaveData{
|
|
Mode: m,
|
|
Pointers: getPointers(m),
|
|
decompSave: blob,
|
|
}
|
|
save.updateStructWithSaveData()
|
|
if save.Zenny != 0 {
|
|
t.Errorf("mode %v read Zenny = 0x%X, want 0 "+
|
|
"(ZZ offsets must not apply)", m, save.Zenny)
|
|
}
|
|
if save.GZenny != 0 {
|
|
t.Errorf("mode %v read GZenny = 0x%X, want 0", m, save.GZenny)
|
|
}
|
|
if save.CP != 0 {
|
|
t.Errorf("mode %v read CP = %d, want 0", m, save.CP)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpdateStructWithSaveData_LiveBlob parses a real ZZ save blob pulled
|
|
// from production (gitignored under tmp/saves/). Values hard-coded here
|
|
// are what the save-mgr offsets produced when inspected by hand; the test
|
|
// fails if a future refactor shifts them. The test skips silently when
|
|
// the blob file is absent (CI, other developers' machines).
|
|
func TestUpdateStructWithSaveData_LiveBlob(t *testing.T) {
|
|
path := filepath.Join("..", "..", "tmp", "saves", "297_kirito.comp")
|
|
comp, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Skipf("live blob unavailable at %s: %v", path, err)
|
|
}
|
|
decomp, err := nullcomp.Decompress(comp)
|
|
if err != nil {
|
|
t.Fatalf("decompress: %v", err)
|
|
}
|
|
save := &CharacterSaveData{
|
|
Mode: cfg.ZZ,
|
|
Pointers: getPointers(cfg.ZZ),
|
|
decompSave: decomp,
|
|
}
|
|
save.updateStructWithSaveData()
|
|
const (
|
|
wantName = "kirito"
|
|
wantPlaytime = 472080
|
|
wantZenny = 8821924
|
|
wantGZenny = 838956
|
|
wantCP = 49379
|
|
)
|
|
if save.Name != wantName {
|
|
t.Errorf("Name = %q, want %q", save.Name, wantName)
|
|
}
|
|
if save.Playtime != wantPlaytime {
|
|
t.Errorf("Playtime = %d, want %d", save.Playtime, wantPlaytime)
|
|
}
|
|
if save.Zenny != wantZenny {
|
|
t.Errorf("Zenny = %d, want %d", save.Zenny, wantZenny)
|
|
}
|
|
if save.GZenny != wantGZenny {
|
|
t.Errorf("GZenny = %d, want %d", save.GZenny, wantGZenny)
|
|
}
|
|
if save.CP != wantCP {
|
|
t.Errorf("CP = %d, want %d", save.CP, wantCP)
|
|
}
|
|
}
|
|
|
|
// TestUpdateSaveDataWithStruct_ZZ_NewFields exercises the write path:
|
|
// set struct fields, flush to blob, re-parse, assert round-trip equality.
|
|
func TestUpdateSaveDataWithStruct_ZZ_NewFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
zenny uint32
|
|
gzenny uint32
|
|
cp uint32
|
|
}{
|
|
{"zero values", 0, 0, 0},
|
|
{"typical HR999 values", 8821924, 838956, 49379},
|
|
{"max uint32", 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF},
|
|
{"mixed", 123456, 0, 999},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
blob := buildMinimalZZBlob(t, 0, 0, 0, 0, 0)
|
|
save := &CharacterSaveData{
|
|
Mode: cfg.ZZ,
|
|
Pointers: getPointers(cfg.ZZ),
|
|
decompSave: blob,
|
|
Zenny: tt.zenny,
|
|
GZenny: tt.gzenny,
|
|
CP: tt.cp,
|
|
}
|
|
save.updateSaveDataWithStruct()
|
|
|
|
// Re-parse via the read path to confirm bytes landed at the
|
|
// expected offsets and decode back to the originals.
|
|
reloaded := &CharacterSaveData{
|
|
Mode: cfg.ZZ,
|
|
Pointers: getPointers(cfg.ZZ),
|
|
decompSave: blob,
|
|
}
|
|
reloaded.updateStructWithSaveData()
|
|
if reloaded.Zenny != tt.zenny {
|
|
t.Errorf("Zenny round-trip: got %d, want %d", reloaded.Zenny, tt.zenny)
|
|
}
|
|
if reloaded.GZenny != tt.gzenny {
|
|
t.Errorf("GZenny round-trip: got %d, want %d", reloaded.GZenny, tt.gzenny)
|
|
}
|
|
if reloaded.CP != tt.cp {
|
|
t.Errorf("CP round-trip: got %d, want %d", reloaded.CP, tt.cp)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpdateSaveDataWithStruct_ZZ_Idempotent is the most important test in
|
|
// this file. It guarantees that parsing a blob and then immediately writing
|
|
// the struct back produces a byte-identical blob. Any drift here means
|
|
// every client save would silently mutate bytes we don't understand,
|
|
// corrupting the save over time. Runs against a fully-populated blob so
|
|
// every field is exercised.
|
|
func TestUpdateSaveDataWithStruct_ZZ_Idempotent(t *testing.T) {
|
|
original := buildMinimalZZBlob(t, 8821924, 838956, 49379, 1234, 472080)
|
|
// Seed some plausible data in fields the parser reads so the write
|
|
// path has something meaningful to round-trip.
|
|
p := getPointers(cfg.ZZ)
|
|
original[p[pGender]] = 1
|
|
// House tier / data / KQF need non-zero bytes so their write paths
|
|
// actually copy something.
|
|
copy(original[p[pHouseTier]:], []byte{1, 2, 3, 4, 5})
|
|
copy(original[p[pKQF]:], []byte{1, 2, 3, 4, 5, 6, 7, 8})
|
|
|
|
snapshot := make([]byte, len(original))
|
|
copy(snapshot, original)
|
|
|
|
save := &CharacterSaveData{
|
|
Mode: cfg.ZZ,
|
|
Pointers: p,
|
|
decompSave: original,
|
|
}
|
|
save.updateStructWithSaveData()
|
|
save.updateSaveDataWithStruct()
|
|
|
|
if !bytes.Equal(original, snapshot) {
|
|
// Find the first mismatched byte to help diagnosis.
|
|
for i := range snapshot {
|
|
if snapshot[i] != original[i] {
|
|
t.Fatalf("read+write mutated blob at offset 0x%X: "+
|
|
"was 0x%02X, now 0x%02X (must be byte-idempotent)",
|
|
i, snapshot[i], original[i])
|
|
}
|
|
}
|
|
t.Fatalf("blob length changed: was %d, now %d", len(snapshot), len(original))
|
|
}
|
|
}
|
|
|
|
// TestUpdateSaveDataWithStruct_NonZZDoesNotTouchBlob confirms that when
|
|
// writing a save for a non-ZZ mode, the bytes at the ZZ-specific offsets
|
|
// are not overwritten. A regression here could mean setting .Zenny on a
|
|
// non-ZZ save clobbers an unrelated field.
|
|
func TestUpdateSaveDataWithStruct_NonZZDoesNotTouchBlob(t *testing.T) {
|
|
modes := []cfg.Mode{cfg.Z2, cfg.G10, cfg.G5, cfg.F5}
|
|
for _, m := range modes {
|
|
t.Run(m.String(), func(t *testing.T) {
|
|
blob := make([]byte, zzBlobSize)
|
|
// Plant sentinel bytes at ZZ offsets.
|
|
copy(blob[0xB0:], []byte{0xDE, 0xAD, 0xBE, 0xEF})
|
|
copy(blob[0x1FF64:], []byte{0xCA, 0xFE, 0xBA, 0xBE})
|
|
copy(blob[0x212E4:], []byte{0x13, 0x37, 0xC0, 0xDE})
|
|
// RP pointer exists for these modes; give it a sane offset so
|
|
// updateSaveDataWithStruct's existing RP write doesn't fail.
|
|
// We craft enough context that only the new-field writes should
|
|
// potentially touch the sentinels.
|
|
snapshot := make([]byte, len(blob))
|
|
copy(snapshot, blob)
|
|
|
|
save := &CharacterSaveData{
|
|
Mode: m,
|
|
Pointers: getPointers(m),
|
|
decompSave: blob,
|
|
Zenny: 0x11111111,
|
|
GZenny: 0x22222222,
|
|
CP: 0x33333333,
|
|
}
|
|
save.updateSaveDataWithStruct()
|
|
|
|
for _, off := range []int{0xB0, 0x1FF64, 0x212E4} {
|
|
if !bytes.Equal(blob[off:off+4], snapshot[off:off+4]) {
|
|
t.Errorf("mode %v overwrote sentinel at 0x%X: %v "+
|
|
"(new-field writes must be ZZ-only)",
|
|
m, off, blob[off:off+4])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUpdateSaveDataWithStruct_LiveBlobIdempotent is the live-data
|
|
// counterpart of the idempotence test: parse a real production ZZ blob,
|
|
// write it back immediately, and verify every byte is unchanged. This is
|
|
// the strongest possible guarantee that our parser does not silently
|
|
// corrupt real player saves. Skips when the blob file is absent.
|
|
func TestUpdateSaveDataWithStruct_LiveBlobIdempotent(t *testing.T) {
|
|
path := filepath.Join("..", "..", "tmp", "saves", "297_kirito.comp")
|
|
comp, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Skipf("live blob unavailable at %s: %v", path, err)
|
|
}
|
|
decomp, err := nullcomp.Decompress(comp)
|
|
if err != nil {
|
|
t.Fatalf("decompress: %v", err)
|
|
}
|
|
snapshot := make([]byte, len(decomp))
|
|
copy(snapshot, decomp)
|
|
|
|
save := &CharacterSaveData{
|
|
Mode: cfg.ZZ,
|
|
Pointers: getPointers(cfg.ZZ),
|
|
decompSave: decomp,
|
|
}
|
|
save.updateStructWithSaveData()
|
|
save.updateSaveDataWithStruct()
|
|
|
|
if !bytes.Equal(decomp, snapshot) {
|
|
for i := range snapshot {
|
|
if snapshot[i] != decomp[i] {
|
|
t.Fatalf("live blob read+write mutated byte at 0x%X: "+
|
|
"was 0x%02X, now 0x%02X", i, snapshot[i], decomp[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUpdateSaveDataWithStruct_BoundsSafety ensures truncated blobs do
|
|
// not panic on the write path either.
|
|
func TestUpdateSaveDataWithStruct_BoundsSafety(t *testing.T) {
|
|
sizes := []int{
|
|
0x212E4 + 3, // just below pCP + size
|
|
0x1FF64 + 3, // just below pGZenny + size
|
|
}
|
|
for _, sz := range sizes {
|
|
full := buildMinimalZZBlob(t, 1, 2, 3, 0, 0)
|
|
if sz > len(full) {
|
|
continue
|
|
}
|
|
trunc := full[:sz]
|
|
save := &CharacterSaveData{
|
|
Mode: cfg.ZZ,
|
|
Pointers: getPointers(cfg.ZZ),
|
|
decompSave: trunc,
|
|
Zenny: 0xAAAA,
|
|
GZenny: 0xBBBB,
|
|
CP: 0xCCCC,
|
|
}
|
|
func() {
|
|
defer func() { _ = recover() }()
|
|
save.updateSaveDataWithStruct()
|
|
}()
|
|
}
|
|
}
|
|
|
|
// TestUpdateStructWithSaveData_BoundsSafety guards the new reads against
|
|
// truncated blobs: a decompressed save that happens to be shorter than the
|
|
// configured ZZ offsets must not panic. We don't require any particular
|
|
// parsed value — only that the process survives.
|
|
func TestUpdateStructWithSaveData_BoundsSafety(t *testing.T) {
|
|
sizes := []int{
|
|
// At a minimum, the existing parser requires a blob that covers
|
|
// every existing pointer + field; truncating below that tripped
|
|
// pre-existing reads, not ours. Cover only sizes that exercise
|
|
// the new-field bounds check.
|
|
zzBlobSize - 1,
|
|
0x212E4 + 3, // just below pCP + size
|
|
0x1FF64 + 3, // just below pGZenny + size
|
|
}
|
|
for _, sz := range sizes {
|
|
// Build a full-size blob, populate existing fields, then truncate.
|
|
full := buildMinimalZZBlob(t, 1, 2, 3, 0, 0)
|
|
if sz > len(full) {
|
|
continue
|
|
}
|
|
trunc := full[:sz]
|
|
save := &CharacterSaveData{
|
|
Mode: cfg.ZZ,
|
|
Pointers: getPointers(cfg.ZZ),
|
|
decompSave: trunc,
|
|
}
|
|
// If existing reads panic at this size, skip — we only care
|
|
// about new-field safety.
|
|
func() {
|
|
defer func() { _ = recover() }()
|
|
save.updateStructWithSaveData()
|
|
}()
|
|
}
|
|
}
|