Save endgame progress, team presets, technique support and camera controls

This commit is contained in:
CAtlas0784 committed 2026-10-17 23:44:36 +07:00
1 parent a681affdb3
commit 639cef7d19
52 files changed
+11940 -766

No files matched your search

+44 -10
View File
@@ -331,17 +331,41 @@ func DefaultScene() Scene {
return Scene{PlaneID: 20411, FloorID: 20411001, EntryID: 2041101}
}
// Tierce teams and best measured node results survive leaving a scene/login.
type TierceNodeProgress struct {
Score uint32 `json:"score"`
Won bool `json:"won"`
Result []byte `json:"result,omitempty"`
}
type TierceProgress struct {
Teams [][]uint32 `json:"teams"`
Buffs []uint32 `json:"buffs"`
Nodes map[uint32]TierceNodeProgress `json:"nodes"`
}
type TeamPreset struct {
Name string `json:"name"`
Avatars map[uint32]uint32 `json:"avatars"`
LeaderSlot uint32 `json:"leader_slot"`
Favourite bool `json:"favourite"`
}
type Persistent struct {
Lineups map[uint32]uint32 `json:"lineups"`
Position Position `json:"position"`
Scene Scene `json:"scene"`
MainCharacter MultiPathAvatar `json:"main_character"`
MarchType MultiPathAvatar `json:"march_type"`
EnableSWGlobal *bool `json:"enable_sw_global"`
EnableCastoriceGlobal *bool `json:"enable_castorice_global"`
EnableAhaGlobal *bool `json:"enable_aha_global"`
DressedSkins map[uint32]uint32 `json:"dressed_skins"`
ExRanksUnlocked map[uint32]bool `json:"ex_ranks_unlocked"`
TeamPresets map[uint32]*TeamPreset `json:"team_presets"`
ActiveTeamPreset uint32 `json:"active_team_preset"`
TierceProgress map[uint32]*TierceProgress `json:"tierce_progress"`
TechniqueFoodStock map[uint32]uint32 `json:"technique_food_stock"`
AutoTechniqueFood *bool `json:"auto_technique_food"`
Lineups map[uint32]uint32 `json:"lineups"`
Position Position `json:"position"`
Scene Scene `json:"scene"`
MainCharacter MultiPathAvatar `json:"main_character"`
MarchType MultiPathAvatar `json:"march_type"`
EnableSWGlobal *bool `json:"enable_sw_global"`
EnableCastoriceGlobal *bool `json:"enable_castorice_global"`
EnableAhaGlobal *bool `json:"enable_aha_global"`
DressedSkins map[uint32]uint32 `json:"dressed_skins"`
ExRanksUnlocked map[uint32]bool `json:"ex_ranks_unlocked"`
}
// DefaultPersistent mirrors the Rust Default impl.
@@ -368,6 +392,11 @@ func DefaultPersistent() Persistent {
// ---------------------------------------------------------------------------
type FreesrData struct {
TeamPresets map[uint32]*TeamPreset `json:"-"`
ActiveTeamPreset uint32 `json:"-"`
TierceProgress map[uint32]*TierceProgress `json:"-"`
TechniqueFoodStock map[uint32]uint32 `json:"-"`
AutoTechniqueFood *bool `json:"-"`
persistentBaseline map[string]any
Lightcones []Lightcone `json:"lightcones"`
@@ -413,6 +442,11 @@ func LoadFreesrData() (*FreesrData, error) {
var p Persistent
if err := json.Unmarshal(pers, &p); err == nil {
data.Lineups = p.Lineups
data.TeamPresets = p.TeamPresets
data.ActiveTeamPreset = p.ActiveTeamPreset
data.TierceProgress = p.TierceProgress
data.TechniqueFoodStock = p.TechniqueFoodStock
data.AutoTechniqueFood = p.AutoTechniqueFood
data.Position = p.Position
data.Scene = p.Scene
data.MainCharacter = p.MainCharacter
+1 -1
View File
@@ -39,7 +39,7 @@ func (f *FreesrData) persistentSnapshot() Persistent {
ex[id] = avatar.ExRankUnlocked
}
}
return Persistent{Lineups: f.Lineups, Position: f.Position, Scene: f.Scene,
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}
+22 -31
View File
@@ -162,34 +162,24 @@ func (a *AvatarJson) ToAvatarPathDataProto(lightcone *Lightcone, relics []*Relic
out.EquipRelicList = append(out.EquipRelicList, r.ToEquipRelic())
}
if len(a.Data.Skills) > 0 {
for pointID, level := range a.Data.Skills {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pointID,
Level: level,
})
}
} else if len(a.Data.SkillsByAnchorType) > 0 {
res, _ := GameRes()
for anchorType, level := range a.Data.SkillsByAnchorType {
pointID := anchorType
if res != nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
if pid, ok := res.AvatarSkillTreePoints[a.AvatarID][anchorType]; ok && pid != 0 {
pointID = pid
}
// AvatarPathData uses AnchorType numbers, whereas BattleAvatar uses full
// PointIDs. PearlSR avatar_mgr.createSkillTree and battle_mgr intentionally
// serialize different IDs for these two protocol messages.
if res, err := GameRes(); err == nil {
points := res.AvatarSkillTreePoints[a.AvatarID]
for anchor, pointID := range points {
var level uint32
if len(a.Data.Skills) > 0 {
level = a.Data.Skills[pointID]
} else if len(a.Data.SkillsByAnchorType) > 0 {
level = a.Data.SkillsByAnchorType[anchor]
} else {
// Keep the existing no-import level behavior, using valid anchors.
level = 1
}
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pointID,
Level: level,
})
}
} else {
if res, err := GameRes(); err == nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
for _, pid := range res.AvatarSkillTreePoints[a.AvatarID] {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pid,
Level: 1,
})
if level > 0 {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree,
&goproto.AvatarPathSkillTree{PointId: anchor, Level: level})
}
}
}
@@ -314,15 +304,16 @@ func ToLineupAvatars(f *FreesrData) []*goproto.LineupAvatar {
// ToLineupInfo builds LineupInfo from a lineup map (order: ascending slot).
func ToLineupInfo(lineups map[uint32]uint32) *goproto.LineupInfo {
maxMp := uint32(5)
if _, ok := lineups[1408]; ok {
maxMp = 8
var maxMp, currentMP uint32
if rules, err := LoadTechniqueRules(); err == nil {
maxMp = rules.MaxMP(lineups)
currentMP = rules.InitialMP
}
lineup := &goproto.LineupInfo{
ExtraLineupType: goproto.ExtraLineupType_ExtraLineupType_LineupNone,
Name: "Squad 1",
Mp: maxMp,
Mp: currentMP,
MaxMp: maxMp,
}
+5 -5
View File
@@ -36,7 +36,7 @@ func TestStandaloneBattleRelicWire(t *testing.T) {
}
func TestAvatarPathSkillTreePointIds(t *testing.T) {
// 1. avatar with explicit Skills map uses real PointIDs directly
// AvatarPathData uses resource AnchorType numbers; BattleAvatar uses PointIDs.
av := AvatarJson{
AvatarID: 1213,
Data: AvatarData{
@@ -55,8 +55,8 @@ func TestAvatarPathSkillTreePointIds(t *testing.T) {
if len(proto.AvatarPathSkillTree) != 2 {
t.Fatalf("expected 2 skill tree nodes, got %d", len(proto.AvatarPathSkillTree))
}
if proto.AvatarPathSkillTree[0].PointId != 1213001 || proto.AvatarPathSkillTree[1].PointId != 1213002 {
t.Fatalf("point ids must be real config point IDs, got %v", proto.AvatarPathSkillTree)
if proto.AvatarPathSkillTree[0].PointId != 1 || proto.AvatarPathSkillTree[1].PointId != 2 {
t.Fatalf("path point ids must be resource anchor numbers, got %v", proto.AvatarPathSkillTree)
}
// 2. avatar with only SkillsByAnchorType resolves from AvatarSkillTreeConfig.json
@@ -73,8 +73,8 @@ func TestAvatarPathSkillTreePointIds(t *testing.T) {
if len(protoAnchor.AvatarPathSkillTree) != 1 {
t.Fatalf("expected 1 skill tree node, got %d", len(protoAnchor.AvatarPathSkillTree))
}
if protoAnchor.AvatarPathSkillTree[0].PointId != 1001001 {
t.Fatalf("expected resolved point ID 1001001 for March Point01, got %d", protoAnchor.AvatarPathSkillTree[0].PointId)
if protoAnchor.AvatarPathSkillTree[0].PointId != 1 {
t.Fatalf("expected anchor 1 for March Point01, got %d", protoAnchor.AvatarPathSkillTree[0].PointId)
}
}
+73
View File
@@ -81,6 +81,8 @@ type AvatarConfig struct {
}
type JsonConfig struct {
MapEntrances map[uint32]MapEntranceEntry `json:"-"`
SceneAnchors map[uint32][]AnchorItem `json:"-"`
// entryId -> "P<plane>_F<floor>" -> config
LevelOutputConfigs map[uint32]map[string]*LevelOutputConfig `json:"levelOutputConfigs"`
AvatarConfigs map[uint32]AvatarConfig `json:"avatarConfigs"`
@@ -192,6 +194,7 @@ func GameRes() (*JsonConfig, error) {
_ = Teleports() // ensure teleportDB is initialized
loadSupplementaryResources(gameRes)
applySceneMetadata(gameRes)
})
return gameRes, gameResErr
}
@@ -266,6 +269,8 @@ func loadSupplementaryResources(res *JsonConfig) {
}
planesByID := map[uint32]MazePlaneEntry{}
res.MapEntrances = entrancesByEntry
res.SceneAnchors = map[uint32][]AnchorItem{}
if raw, err := readResourceFile("resources/MazePlane.json", "MazePlane.json"); err == nil {
var list []MazePlaneEntry
if json.Unmarshal(raw, &list) != nil {
@@ -366,6 +371,7 @@ func loadSupplementaryResources(res *JsonConfig) {
name: {
IsEnteredSceneInfo: true,
Scenes: groups,
WorldID: planesByID[sc.PlaneID].WorldID,
},
}
}
@@ -377,6 +383,7 @@ func loadSupplementaryResources(res *JsonConfig) {
var aFile AnchorConfigFile
if json.Unmarshal(raw, &aFile) == nil && len(aFile.AnchorConfig) > 0 {
for _, item := range aFile.AnchorConfig {
res.SceneAnchors[item.EntryID] = item.Anchor
floorID := uint32(0)
if ent, ok := entrancesByEntry[item.EntryID]; ok && ent.FloorID != 0 {
floorID = ent.FloorID
@@ -415,6 +422,72 @@ func loadSupplementaryResources(res *JsonConfig) {
}
}
}
// Supplementary scenes do not carry world metadata. Resolve it from the
// installed entrance/plane tables, including entries supplied by Anchor.json.
floorSavedValues := map[uint32]map[string]int32{}
if raw, err := readResourceFile("resources/FloorSavedValuesConfig.json"); err == nil {
var table struct {
Floors []struct {
FloorID uint32 `json:"FloorID"`
Values []struct {
Name string `json:"Name"`
MaxValue int32 `json:"MaxValue"`
} `json:"SavedValues"`
} `json:"saved_values"`
}
if err := json.Unmarshal(raw, &table); err == nil {
// Same unlocked scene defaults as PearlSR 005d2c12 scene_mgr.zig.
for _, floor := range table.Floors {
values := map[string]int32{}
for _, value := range floor.Values {
if value.Name != "" {
values[value.Name] = value.MaxValue
}
}
floorSavedValues[floor.FloorID] = values
}
} else {
LogInfo("[SCENE] Invalid FloorSavedValuesConfig: %v", err)
}
}
for entryID, configs := range res.LevelOutputConfigs {
entrance, ok := entrancesByEntry[entryID]
if !ok {
continue
}
// Legacy dumps label every entrance in a plane with its first floor.
// An entrance has an explicit floor in MapEntrance; do not derive it.
if len(configs) == 1 {
canonical := fmt.Sprintf("P%d_F%d", entrance.PlaneID, entrance.FloorID)
for name, config := range configs {
if name != canonical {
delete(configs, name)
configs[canonical] = config
}
break
}
}
plane, ok := planesByID[entrance.PlaneID]
if !ok {
continue
}
for name, config := range configs {
config.WorldID = plane.WorldID
var configPlane, configFloor uint32
if n, _ := fmt.Sscanf(name, "P%d_F%d", &configPlane, &configFloor); n != 2 {
continue
}
if config.SavedValues == nil {
config.SavedValues = map[string]int32{}
}
for name, value := range floorSavedValues[configFloor] {
if _, exists := config.SavedValues[name]; !exists {
config.SavedValues[name] = value
}
}
}
}
}
// Teleports lazily parses teleports.json ({floor_id: {teleport_id: info}}).
+11
View File
@@ -15,6 +15,7 @@ import (
// RuntimeConfig is shared by both server processes. Deployment values belong
// in configuration rather than in handlers or executable entry points.
type RuntimeConfig struct {
PlayerUID uint32 `json:"player_uid"`
BattleDiagnostics BattleDiagnosticsConfig `json:"battle_diagnostics"`
ClientVersion string `json:"client_version"`
GameHost string `json:"game_host"`
@@ -67,6 +68,16 @@ func LoadRuntimeConfig() (RuntimeConfig, error) {
*dest = uint16(n)
}
}
if value, ok := os.LookupEnv("GAC_PLAYER_UID"); ok {
n, err := strconv.ParseUint(value, 10, 32)
if err != nil || n == 0 {
return cfg, fmt.Errorf("GAC_PLAYER_UID must be an integer between 1 and 4294967295")
}
cfg.PlayerUID = uint32(n)
}
if cfg.PlayerUID == 0 {
return cfg, fmt.Errorf("runtime config requires a nonzero player_uid")
}
if cfg.GamePort == 0 || cfg.SDKPort == 0 || strings.TrimSpace(cfg.ClientVersion) == "" {
return cfg, fmt.Errorf("runtime config requires client_version, game_port and sdk_port")
}
+74
View File
@@ -0,0 +1,74 @@
package common
import (
"encoding/json"
"fmt"
)
// Scene metadata is exported from the installed Client's RuntimeFloor tables.
// A complete group list allows us to exclude deleted/client-owned groups
// without maintaining special cases for individual maps or group IDs.
type sceneMetadata struct {
ClientVersion string `json:"client_version"`
Floors map[uint32]struct {
StartAnchor *AnchorItem `json:"start_anchor"`
Monsters map[uint32]map[uint32]TeleportInfo `json:"monsters"`
GroupCount int `json:"group_count"`
Groups map[uint32]struct {
LoadSide string `json:"load_side"`
Deleted bool `json:"is_deleted"`
} `json:"groups"`
} `json:"floors"`
}
func applySceneMetadata(res *JsonConfig) {
raw, err := ReadResourceFile("resources/SceneMetadata.json")
if err != nil {
return
}
var metadata sceneMetadata
if err := json.Unmarshal(raw, &metadata); err != nil {
LogInfo("[SCENE] Invalid SceneMetadata: %v", err)
return
}
config, err := LoadRuntimeConfig()
if err != nil || metadata.ClientVersion != config.ClientVersion {
LogInfo("[SCENE] SceneMetadata Client revision does not match runtime config; not applied")
return
}
for entryID, configs := range res.LevelOutputConfigs {
for name, scene := range configs {
var planeID, floorID uint32
if n, _ := fmt.Sscanf(name, "P%d_F%d", &planeID, &floorID); n != 2 {
continue
}
floor, ok := metadata.Floors[floorID]
if !ok {
continue
}
if floor.GroupCount == 0 || len(floor.Groups) != floor.GroupCount {
LogInfo("[SCENE] Incomplete group metadata for floor %d; not applied", floorID)
continue
}
if floor.StartAnchor != nil {
res.SceneAnchors[entryID] = []AnchorItem{*floor.StartAnchor}
}
for groupID := range scene.Scenes {
group, exists := floor.Groups[groupID]
if !exists || group.Deleted || group.LoadSide == "Client" {
delete(scene.Scenes, groupID)
LogInfo("[SCENE] Excluded group entry=%d floor=%d group=%d present=%t deleted=%t side=%s", entryID, floorID, groupID, exists, group.Deleted, group.LoadSide)
continue
}
data := scene.Scenes[groupID]
for index := range data.Monsters {
monster := &data.Monsters[index]
if placement, ok := floor.Monsters[groupID][monster.InstID]; ok {
monster.Pos, monster.Rot = placement.Pos, placement.Rot
}
}
scene.Scenes[groupID] = data
}
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package common
import (
"encoding/json"
"fmt"
"sync"
)
type TeamPresetRules struct {
Count uint32 `json:"preset_count"`
MaxMembers uint32 `json:"max_members"`
MaxNameLength int `json:"max_name_length"`
}
var teamPresetOnce sync.Once
var teamPresetRules TeamPresetRules
var teamPresetError error
func LoadTeamPresetRules() (*TeamPresetRules, error) {
teamPresetOnce.Do(func() {
raw, err := ReadResourceFile("resources/TeamPresetConfig.json")
if err != nil {
teamPresetError = err
return
}
if err := json.Unmarshal(raw, &teamPresetRules); err != nil {
teamPresetError = err
return
}
if teamPresetRules.Count == 0 || teamPresetRules.MaxMembers == 0 || teamPresetRules.MaxNameLength < 1 {
teamPresetError = fmt.Errorf("invalid TeamPresetConfig")
}
})
return &teamPresetRules, teamPresetError
}
func (f *FreesrData) TeamPreset(index uint32) *TeamPreset {
original := f.TeamPresets[index]
p := &TeamPreset{Name: fmt.Sprintf("Team %d", index+1), Avatars: map[uint32]uint32{}, Favourite: true}
if original != nil {
p.Name = original.Name
p.LeaderSlot = original.LeaderSlot
p.Favourite = original.Favourite
for slot, id := range original.Avatars {
p.Avatars[slot] = id
}
}
// Preserve edits made through the existing /api/persistent lineups field.
if index == f.ActiveTeamPreset {
p.Avatars = map[uint32]uint32{}
for slot, id := range f.Lineups {
p.Avatars[slot] = id
}
}
return p
}
func (f *FreesrData) SaveTeamPreset(index uint32, preset *TeamPreset) error {
if f.TeamPresets == nil {
f.TeamPresets = map[uint32]*TeamPreset{}
}
if preset.Avatars[preset.LeaderSlot] == 0 {
found := false
for slot, id := range preset.Avatars {
if id != 0 && (!found || slot < preset.LeaderSlot) {
preset.LeaderSlot = slot
found = true
}
}
}
old, oldLineup := f.TeamPresets[index], f.Lineups
f.TeamPresets[index] = preset
if index == f.ActiveTeamPreset {
f.Lineups = map[uint32]uint32{}
for slot, id := range preset.Avatars {
f.Lineups[slot] = id
}
}
if err := f.SavePersistentChecked(); err != nil {
if old == nil {
delete(f.TeamPresets, index)
} else {
f.TeamPresets[index] = old
}
f.Lineups = oldLineup
return err
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package common
import (
"encoding/json"
"fmt"
"sync"
)
type TechniqueFood struct {
RecoverMP uint32 `json:"recover_mp"`
MaxUse uint32 `json:"max_use"`
PileLimit uint32 `json:"pile_limit"`
}
type MazeSkillRule struct {
ID uint32 `json:"MazeSkillId"`
MPCost uint32 `json:"MPCost"`
Trigger string `json:"SkillTriggerKey"`
}
type TechniqueRules struct {
ClientVersion string `json:"client_version"`
BaseMaxMP uint32 `json:"base_max_mp"`
InitialMP uint32 `json:"initial_mp"`
MaxMPByAvatar map[uint32]uint32 `json:"max_mp_by_avatar"`
NonAttackTechniques []uint32 `json:"non_attack_techniques"`
Avatars map[uint32][]MazeSkillRule `json:"avatars"`
Foods map[uint32]TechniqueFood `json:"foods"`
InitialFoodCount uint32 `json:"initial_food_count"`
AutoFoodDefault bool `json:"auto_food_default"`
}
var techniqueOnce sync.Once
var techniqueRules TechniqueRules
var techniqueErr error
func LoadTechniqueRules() (*TechniqueRules, error) {
techniqueOnce.Do(func() {
raw, err := ReadResourceFile("resources/TechniqueRules.json")
if err != nil {
techniqueErr = err
return
}
if err = json.Unmarshal(raw, &techniqueRules); err != nil {
techniqueErr = err
return
}
cfg, err := LoadRuntimeConfig()
if err != nil {
techniqueErr = err
return
}
if techniqueRules.ClientVersion != cfg.ClientVersion || techniqueRules.BaseMaxMP == 0 || techniqueRules.InitialMP > techniqueRules.BaseMaxMP {
techniqueErr = fmt.Errorf("invalid or mismatched TechniqueRules client resource")
return
}
for id, f := range techniqueRules.Foods {
if id == 0 || f.RecoverMP == 0 || f.MaxUse == 0 || techniqueRules.InitialFoodCount > f.PileLimit {
techniqueErr = fmt.Errorf("invalid technique food %d", id)
return
}
}
})
return &techniqueRules, techniqueErr
}
func (r *TechniqueRules) MaxMP(avatars map[uint32]uint32) uint32 {
maximum := r.BaseMaxMP
// PearlSR buildLineup selects the largest team cap; it does not add both caps.
for _, id := range avatars {
if value := r.MaxMPByAvatar[id]; value > maximum {
maximum = value
}
}
return maximum
}
// Called only by an explicit inventory/cast operation, never by save readers.
func (f *FreesrData) EnsureTechniqueFoods() error {
if f.TechniqueFoodStock != nil && f.AutoTechniqueFood != nil {
return nil
}
r, err := LoadTechniqueRules()
if err != nil {
return err
}
if f.TechniqueFoodStock == nil {
f.TechniqueFoodStock = map[uint32]uint32{}
for id := range r.Foods {
f.TechniqueFoodStock[id] = r.InitialFoodCount
}
}
if f.AutoTechniqueFood == nil {
value := r.AutoFoodDefault
f.AutoTechniqueFood = &value
}
return f.SavePersistentChecked()
}
// Refresh just the setting, and advance its merge baseline so a subsequent
// session position save cannot undo an external API toggle.
func (f *FreesrData) RefreshTechniqueSettings(p *Persistent) {
f.AutoTechniqueFood = p.AutoTechniqueFood
if snapshot, err := jsonObject(f.persistentSnapshot()); err == nil {
if f.persistentBaseline == nil {
f.persistentBaseline = map[string]any{}
}
f.persistentBaseline["auto_technique_food"] = snapshot["auto_technique_food"]
}
}