diff --git a/.gitignore b/.gitignore index 26213c355..1cca1a975 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ deploy.sh # Test/build artifacts coverage.out +# Local save blob dumps (PII) +tmp/ + # Claude Code local config .claude/ CLAUDE.local.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3806b3589..540284d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Parse zenny, gzenny and caravan points (CP) from the ZZ character save blob (offsets 0xB0, 0x1FF64, 0x212E4 — sourced from Chakratos/mhf-save-manager, validated against a live HR999 save). Exposed as `CharacterSaveData.Zenny/GZenny/CP` alongside the existing `current_equip` pointer; read-only for now. Pre-ZZ modes remain unmapped to avoid corrupting unverified layouts. - Chinese (`zh`) language strings for chat commands, guild mails, cafe/timer broadcasts and prayer beads. Note: Shift-JIS wire encoding only covers characters shared with Japanese — simplified-only glyphs may fail to encode. - Server-side multi-language support ([#188](https://github.com/Mezeporta/Erupe/issues/188)): each player picks their own language with `!lang `, persisted per user (migration `0022_user_language`) and loaded on login. Chat replies, guild invite mails, and cafe/timer broadcasts are served in that language via `Session.I18n()`. Quest and scenario JSON text fields now accept either a plain string (unchanged) or a `{"jp":"...","en":"...","fr":"..."}` map; the compiler resolves per session and the quest cache is keyed by `(questID, lang)`. Existing single-language JSONs and `.bin` round-trips remain byte-identical. Shift-JIS wire encoding still applies (ASCII/kana/CJK only). Raviente world-wide broadcasts stay on the server default since they have no single session. diff --git a/server/channelserver/model_character.go b/server/channelserver/model_character.go index 832796d13..e348ddbb4 100644 --- a/server/channelserver/model_character.go +++ b/server/channelserver/model_character.go @@ -28,6 +28,13 @@ const ( pGRP pKQF lBookshelfData + // Offsets sourced from Chakratos/mhf-save-manager (ZZ layout), validated + // against live G6-ZZ blobs. F5 / G1-G5.2 values from that project have + // not been verified and are intentionally left unmapped here. + pZenny + pGZenny + pCP + pCurrentEquip ) // CharacterSaveData holds a character's save data and its parsed fields. @@ -52,6 +59,9 @@ type CharacterSaveData struct { HR uint16 GR uint16 KQF []byte + Zenny uint32 + GZenny uint32 + CP uint32 compSave []byte decompSave []byte @@ -74,6 +84,11 @@ func getPointers(mode cfg.Mode) map[SavePointer]int { pointers[pGardenData] = 142424 pointers[pRP] = 142614 pointers[pKQF] = 146720 + // Validated against a live HR999 ZZ save blob (see tests). + pointers[pZenny] = 0xB0 + pointers[pGZenny] = 0x1FF64 + pointers[pCP] = 0x212E4 + pointers[pCurrentEquip] = 0x1F604 case 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: @@ -174,6 +189,12 @@ const ( saveFieldKQF = 8 saveFieldNameOffset = 88 saveFieldNameLen = 12 + saveFieldZenny = 4 + saveFieldGZenny = 4 + saveFieldCP = 4 + // current_equip is a ~2.4KB equipment record; we expose the offset but do + // not extract a fixed-size slice until its exact length is reverse- + // engineered. Leave extraction as a follow-up. ) func (save *CharacterSaveData) updateStructWithSaveData() { @@ -213,6 +234,20 @@ func (save *CharacterSaveData) updateStructWithSaveData() { if save.Mode >= cfg.G10 { save.KQF = save.decompSave[save.Pointers[pKQF] : save.Pointers[pKQF]+saveFieldKQF] } + // Read zenny / gzenny / CP only when a pointer is configured for + // the current mode. Unmapped versions (e.g. S6, F4/F5, G1-G5.2) + // leave the pointer at zero; we guard with the ok check and an + // additional offset != 0 check so a bare default map cannot cause + // bogus reads from the blob header. + if off, ok := save.Pointers[pZenny]; ok && off > 0 && off+saveFieldZenny <= len(save.decompSave) { + save.Zenny = binary.LittleEndian.Uint32(save.decompSave[off : off+saveFieldZenny]) + } + if off, ok := save.Pointers[pGZenny]; ok && off > 0 && off+saveFieldGZenny <= len(save.decompSave) { + save.GZenny = binary.LittleEndian.Uint32(save.decompSave[off : off+saveFieldGZenny]) + } + if off, ok := save.Pointers[pCP]; ok && off > 0 && off+saveFieldCP <= len(save.decompSave) { + save.CP = binary.LittleEndian.Uint32(save.decompSave[off : off+saveFieldCP]) + } } } } diff --git a/server/channelserver/model_character_test.go b/server/channelserver/model_character_test.go new file mode 100644 index 000000000..964158055 --- /dev/null +++ b/server/channelserver/model_character_test.go @@ -0,0 +1,273 @@ +package channelserver + +import ( + "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) + } +} + +// 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() + }() + } +}