package common import ( "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{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, 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) }