package common import ( "bytes" "encoding/json" "fmt" "os" "reflect" "sync" ) var persistentWriteMu sync.Mutex func ReadPersistent() (*Persistent, error) { raw, err := os.ReadFile("persistent") if err != nil { return nil, err } var p Persistent if err := json.Unmarshal(raw, &p); err != nil { return nil, fmt.Errorf("parse persistent: %w", err) } return &p, nil } func jsonObject(value any) (map[string]any, error) { raw, err := json.Marshal(value) if err != nil { return nil, err } var object map[string]any err = json.Unmarshal(raw, &object) return object, err } func (f *FreesrData) persistentSnapshot() Persistent { ex := make(map[uint32]bool) for id := range AvatarExRankMap { if avatar := f.Avatars[id]; avatar != nil { ex[id] = avatar.ExRankUnlocked } } return Persistent{Profile: f.Profile, TeamPresets: f.TeamPresets, ActiveTeamPreset: f.ActiveTeamPreset, TierceProgress: f.TierceProgress, TechniqueFoodStock: f.TechniqueFoodStock, AutoTechniqueFood: f.AutoTechniqueFood, Lineups: f.Lineups, Position: f.Position, Scene: f.Scene, MainCharacter: f.MainCharacter, MarchType: f.MarchType, EnableSWGlobal: f.EnableSWGlobal, EnableCastoriceGlobal: f.EnableCastoriceGlobal, EnableAhaGlobal: f.EnableAhaGlobal, DressedSkins: f.DressedSkins, PlayerOutfits: f.PlayerOutfits, SceneGroupProperties: f.SceneGroupProperties, ExRanksUnlocked: ex} } // Apply only locally changed fields. A stale game session saving its position // must not undo an EX switch written by the SDK process. Nested maps are merged // per key so different avatars' switches do not overwrite one another. func mergePersistentChanges(existing, baseline, next map[string]any) { for key, value := range next { old, known := baseline[key] if known && reflect.DeepEqual(old, value) { continue } newMap, newOK := value.(map[string]any) oldMap, oldOK := old.(map[string]any) if newOK && oldOK { dest, ok := existing[key].(map[string]any) if !ok { dest = make(map[string]any) } mergePersistentChanges(dest, oldMap, newMap) for removed := range oldMap { if _, present := newMap[removed]; !present { delete(dest, removed) } } existing[key] = dest } else { existing[key] = value } } } func writeJSONAtomic(path string, value any) error { raw, err := json.MarshalIndent(value, "", " ") if err != nil { return err } file, err := os.CreateTemp(".", ".gac-save-*.tmp") if err != nil { return err } name := file.Name() defer os.Remove(name) if _, err := file.Write(raw); err != nil { file.Close() return err } if err := file.Close(); err != nil { return err } return os.Rename(name, path) } func (f *FreesrData) SavePersistentChecked() error { persistentWriteMu.Lock() defer persistentWriteMu.Unlock() unlock, err := lockPersistentFile() if err != nil { return err } defer unlock() next, err := jsonObject(f.persistentSnapshot()) if err != nil { return err } existing := make(map[string]any) if raw, err := os.ReadFile("persistent"); err == nil { if err := json.Unmarshal(raw, &existing); err != nil { return fmt.Errorf("refuse to overwrite invalid persistent: %w", err) } } else if !os.IsNotExist(err) { return err } mergePersistentChanges(existing, f.persistentBaseline, next) if err := writeJSONAtomic("persistent", existing); err != nil { return err } // Track our own observed snapshot, not external changes we have not loaded. f.persistentBaseline = next return nil } // RefreshExRanks imports persisted switches without replacing inventory, // lineup, scene or the configured battle. Call under the session state lock. func (f *FreesrData) RefreshExRanks(p *Persistent) []uint32 { var changed []uint32 for id := range AvatarExRankMap { avatar := f.Avatars[id] if avatar == nil { continue } unlocked := p.ExRanksUnlocked[id] if avatar.ExRankUnlocked != unlocked { avatar.ExRankUnlocked = unlocked changed = append(changed, id) } } if current, err := jsonObject(f.persistentSnapshot()); err == nil { if f.persistentBaseline == nil { f.persistentBaseline = make(map[string]any) } f.persistentBaseline["ex_ranks_unlocked"] = current["ex_ranks_unlocked"] } return changed } // Patch only the selected avatar's rank in the latest import. Unknown FreeSR // fields and all inventory IDs/affixes retain their original JSON values. func SaveAvatarRank(id, rank uint32) error { persistentWriteMu.Lock() defer persistentWriteMu.Unlock() unlock, err := lockPersistentFile() if err != nil { return err } defer unlock() raw, err := os.ReadFile("freesr-data.json") if err != nil { return err } var root map[string]json.RawMessage if err := json.Unmarshal(raw, &root); err != nil { return err } var avatars map[uint32]json.RawMessage if err := json.Unmarshal(root["avatars"], &avatars); err != nil { return err } var avatar map[string]json.RawMessage if err := json.Unmarshal(avatars[id], &avatar); err != nil { return err } var data map[string]json.RawMessage if err := json.Unmarshal(avatar["data"], &data); err != nil { return err } data["rank"], err = json.Marshal(rank) if err != nil { return err } avatar["data"], err = json.Marshal(data) if err != nil { return err } avatars[id], err = json.Marshal(avatar) if err != nil { return err } root["avatars"], err = json.Marshal(avatars) if err != nil { return err } return writeJSONAtomic("freesr-data.json", root) } // SaveEquipmentAssignments patches only equip_avatar on the explicitly changed // inventory entries. It preserves all other equipment, relic stats and unknown // FreeSR fields, so an in-game equip action never replaces the full inventory. func SaveEquipmentAssignments(lightcones, relics map[uint32]uint32) error { if len(lightcones) == 0 && len(relics) == 0 { return nil } persistentWriteMu.Lock() defer persistentWriteMu.Unlock() unlock, err := lockPersistentFile() if err != nil { return err } defer unlock() raw, err := os.ReadFile("freesr-data.json") if err != nil { return err } var root map[string]json.RawMessage if err := json.Unmarshal(raw, &root); err != nil { return err } for _, collection := range []struct { field string owners map[uint32]uint32 }{{"lightcones", lightcones}, {"relics", relics}} { if len(collection.owners) == 0 { continue } var entries []json.RawMessage if err := json.Unmarshal(root[collection.field], &entries); err != nil { return fmt.Errorf("read %s: %w", collection.field, err) } found := make(map[uint32]bool, len(collection.owners)) for i, entry := range entries { var item map[string]json.RawMessage if err := json.Unmarshal(entry, &item); err != nil { return fmt.Errorf("decode %s entry: %w", collection.field, err) } var uid uint32 if err := json.Unmarshal(item["internal_uid"], &uid); err != nil { return fmt.Errorf("read %s internal_uid: %w", collection.field, err) } owner, update := collection.owners[uid] if !update { continue } item["equip_avatar"], err = json.Marshal(owner) if err != nil { return err } entries[i], err = json.Marshal(item) if err != nil { return err } found[uid] = true } for uid := range collection.owners { if !found[uid] { return fmt.Errorf("%s item internal_uid %d not found", collection.field, uid) } } root[collection.field], err = json.Marshal(entries) if err != nil { return err } } return writeJSONAtomic("freesr-data.json", root) } // SaveNewLightcones appends catalog entries that are absent from the latest // import while preserving existing inventory records and unknown fields. func SaveNewLightcones(additions []Lightcone) error { if len(additions) == 0 { return nil } persistentWriteMu.Lock() defer persistentWriteMu.Unlock() unlock, err := lockPersistentFile() if err != nil { return err } defer unlock() raw, err := os.ReadFile("freesr-data.json") if err != nil { return err } var root map[string]json.RawMessage if err := json.Unmarshal(raw, &root); err != nil { return err } entries, err := readInventoryEntries(root, "lightcones") if err != nil { return fmt.Errorf("read lightcones: %w", err) } var existing []Lightcone for _, entry := range entries { var item Lightcone if err := json.Unmarshal(entry, &item); err != nil { return fmt.Errorf("decode lightcone: %w", err) } existing = append(existing, item) } usedIDs := make(map[uint32]bool, len(existing)) usedUIDs := make(map[uint32]bool, len(existing)) for _, item := range existing { usedIDs[item.ItemID] = true usedUIDs[item.InternalUID] = true } for _, item := range additions { if usedIDs[item.ItemID] || usedUIDs[item.InternalUID] { continue } encoded, err := json.Marshal(item) if err != nil { return err } entries = append(entries, encoded) usedIDs[item.ItemID] = true usedUIDs[item.InternalUID] = true } root["lightcones"], err = json.Marshal(entries) if err != nil { return err } return writeJSONAtomic("freesr-data.json", root) } // SaveNewRelics appends catalog instances absent from the latest import. func SaveNewRelics(additions []Relic) error { if len(additions) == 0 { return nil } persistentWriteMu.Lock() defer persistentWriteMu.Unlock() unlock, err := lockPersistentFile() if err != nil { return err } defer unlock() raw, err := os.ReadFile("freesr-data.json") if err != nil { return err } var root map[string]json.RawMessage if err := json.Unmarshal(raw, &root); err != nil { return err } entries, err := readInventoryEntries(root, "relics") if err != nil { return fmt.Errorf("read relics: %w", err) } var existing []Relic for _, entry := range entries { var item Relic if err := json.Unmarshal(entry, &item); err != nil { return fmt.Errorf("decode relic: %w", err) } existing = append(existing, item) } usedIDs := make(map[uint32]bool, len(existing)) usedUIDs := make(map[uint32]bool, len(existing)) for _, item := range existing { usedIDs[item.RelicID] = true usedUIDs[item.InternalUID] = true } for _, item := range additions { if usedIDs[item.RelicID] || usedUIDs[item.InternalUID] { continue } encoded, err := json.Marshal(item) if err != nil { return err } entries = append(entries, encoded) usedIDs[item.RelicID] = true usedUIDs[item.InternalUID] = true } root["relics"], err = json.Marshal(entries) if err != nil { return err } return writeJSONAtomic("freesr-data.json", root) } // Empty initial saves may contain account settings without inventory arrays. // Treat absent or null collections as empty, while still rejecting malformed // collections so a bad import cannot be silently replaced. func readInventoryEntries(root map[string]json.RawMessage, field string) ([]json.RawMessage, error) { if root == nil { return nil, fmt.Errorf("inventory data must be a JSON object") } raw, ok := root[field] if !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return []json.RawMessage{}, nil } var entries []json.RawMessage if err := json.Unmarshal(raw, &entries); err != nil { return nil, err } if entries == nil { entries = []json.RawMessage{} } return entries, nil }