Files
Ananta-ps/Ananta.Server/Ananta.Core/Protocol/Client4229938/RuntimePayloadFactory.cs
T

2431 lines
120 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.RpcTypes.Client4229938;
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>
/// Builds the large build-4229938 RPC payloads directly from human config + extracted client JSON.
/// Nothing here reads generated .bin files: every packet is a normal RpcTypes object serialized by UxSerializer.
/// </summary>
internal static class RuntimePayloadFactory
{
/// <summary>
/// LT.ConfigGen.UrbanJobConfig.Jobless in the matching 4229938 client dump.
/// Zero is not a valid UrbanJobConfig id: the client's SpiritJobManager dereferences a missing
/// config as soon as that character later receives an authored job through SyncSpiritJobInfo.
/// </summary>
internal const uint JoblessUrbanJob4229938 = 100;
/// <summary>
/// ImageNewAvatarConfig ids used as PersonalZoneHeadInfo.SystemHeadId. The field is an
/// *ImageNewAvatarConfig* id (1..15 in this build), not an image resource id — see the note on
/// the login payload below. Id 1 and 2 are the two "&lt;player&gt;" rows (male / female); the rest
/// are named character avatars (Taffy / Garm / Bansy / ...).
/// </summary>
internal const uint MaleAvatarSystemHeadId4229938 = 1;
internal const uint FemaleAvatarSystemHeadId4229938 = 2;
private static PrivateServerConfig Config => PrivateServerConfigStore.Current;
internal static Auto.PlayerClientInfo MinimalPlayerInfo4229938(
WorldEntryState? state = null, ExtractionShooterSessionState? extraction = null)
{
// Keep only identity plus the actor-facing roster required to materialize the controlled
// spirit. Economy/social/vehicles/apps/tasks and the unrelated account armory stay absent.
var root = new Auto.PlayerClientInfo();
root.InfoLogin.Aid = Config.Client.Aid;
root.InfoLogin.Pid = Config.Player.Pid;
root.InfoLogin.AccountId = Config.Player.AccountId;
root.InfoLogin.Name = Config.Player.DisplayName;
root.InfoLogin.Level = 1;
root.InfoLogin.Sex = 1;
root.InfoLogin.UseSystemName = false;
root.InfoLogin.UniverseId = Config.Player.LoginUniverseId;
// Personal-zone head (头像). PlayerInfoLoginData.lua:14 copies InfoLogin.PzHeadInfo into
// bindData.infoPzHeadInfo and every avatar consumer reads SystemHeadId straight off it:
// ComputerMainPanelStore.lua:341 — the lock-screen avatar of any computer whose
// ComputerConfig.UserName is "<player>" (Taffy apartment 12130033/34, Uncle Hong's
// 12130049, the test PC 12130000). Other machines use their own ComputerConfig.UserHeadId
// and never touch this field, which is why an unpatched server still shows avatars there.
// HunLunManager.lua:322 GetHeadIconAndName -> ImageNewAvatarConfig.GetConfig(id).SguiImageId
// CallPhoneUtils.lua:424, ChatManager.lua:100, FriendManager.lua:336 — phone/chat/friends.
//
// ⛑️ SystemHeadId is an ImageNewAvatarConfig **row id**, not an image resource id: the table
// only holds 15 rows, so GetConfig(0) is nil and GetHeadIconAndName returns nil. Left at its
// default the lock screen renders no avatar at all — nothing to click, so the desktop behind
// it (and the file/mail apps on its task bar) is unreachable. HeadType 0 = System.
var avatarId = root.InfoLogin.Sex == 1
? MaleAvatarSystemHeadId4229938
: FemaleAvatarSystemHeadId4229938;
root.InfoLogin.PzHeadInfo.HeadType = 0;
root.InfoLogin.PzHeadInfo.SystemHeadId = avatarId;
root.InfoLogin.LinkPzHeadInfo.HeadType = 0;
root.InfoLogin.LinkPzHeadInfo.SystemHeadId = avatarId;
// World-entry cannot advertise a concrete WeaponTemplateId in SyncEnterScene while the
// login roster contains zero weapon instances. 4229938 preloads the local actor from the
// PlayerInfo spirit/armory stores before AskLoadSceneCompleted; an empty wheel makes that
// actor profile internally inconsistent and leaves the client waiting on presentation data.
// Keep only the weapon instances actually referenced by the 32 owned spirit wheels.
// A roster row the combat catalog does not know (hand-added template id, row removed from the
// catalog) has no weapon wheel to build, so it is dropped here rather than failing the whole
// login payload.
var spirits = ClientConfigRepository.Characters()
.Where(character => CombatCatalogRepository.TryLoadout(character.TemplateId) is not null)
.Select(character => MinimalActorCharacterSpirit4229938(character, state))
.ToList();
var actorArmory = spirits
.SelectMany(spirit => spirit.WeaponSlots)
.OfType<Auto.WeaponData>()
.Where(weapon => weapon.InstanceId != 0)
.GroupBy(weapon => weapon.InstanceId)
.Select(group => group.First())
.OrderBy(weapon => weapon.InstanceId)
.ToList();
root.InfoSpirit.Spirits = spirits;
root.InfoSpirit.ActiveSpirit = Config.Player.InitialSpiritTemplateId;
root.InfoSpirit.InfoPokemon.FastFightSquad = [Config.Player.InitialUnitId];
root.InfoSpirit.InfoArmory.Weapons = actorArmory;
// Phone apps: the client only renders an app icon when its id is present in
// PlayerClientInfoSpirit.InstalledApps (otherwise the home screen is empty and the app
// store shows everything as "not downloaded"). The set is already computed from
// MobileMenuSGuiConfig.json — it was never written into the login payload before.
root.InfoSpirit.InstalledApps = ClientConfigRepository.InstalledPhoneAppIds().ToList();
// T0-4: PlayerInfoSpiritData.lua:75-76 copies this single int into
// gPoliceJobManager.todayMissionCnt ("今日完成 N 单"). The contract already ships the field,
// so this only seeds its value — see GameplaySettings.PoliceTodayCompleteMissionCnt.
if (Config.Gameplay.PoliceTodayCompleteMissionCnt != 0)
{
root.InfoSpirit.InfoPolice.TodayCompleteMissionCnt = Config.Gameplay.PoliceTodayCompleteMissionCnt;
}
// Unlock only the styles actually referenced by the owned actor loadouts. This is actor
// presentation metadata, not the old server's global combat/unlock economy.
foreach (var styleId in ClientConfigRepository.Characters()
.Select(character => CombatCatalogRepository.TryLoadout(character.TemplateId)?.StyleByType.Values)
.Where(styles => styles is not null)
.SelectMany(styles => styles!)
.Where(styleId => styleId != 0)
.Distinct())
root.InfoSpirit.InfoFightStyle.FightStyleIsUnLocked[styleId] = true;
root.InfoMinor.Level = 1;
root.InfoMinor.MatchInfo.DeviceLevel = 1;
root.InfoMinor.MatchInfo.CurLinkDeviceLevel = 1;
// Provide the full summonable fleet so the client-side DriveManager recognises every
// drivable vehicle and the phone F1 car-call (142857) lists them all (AskSummonVehicle).
// User request: seed EVERY summonable vehicle from VehicleConfig (AllSummonable, 237
// entries — prefab + 1..16 seats) as owned. The sibling 08.05 build proved this
// PlayerInfo seeding is what activates the client F1 summon.
if (Config.Gameplay.Vehicles.Enabled)
{
var summonable = VehicleCatalog4229938.AllSummonable;
if (summonable.Count > 0)
{
root.InfoMinor.VehicleInfo = new Auto.PlayerVehicleInfo
{
UnlockedVehicles = summonable.Select(v => new Auto.PlayerVehicleDetail
{
Id = v.Id,
UnlockTime = 1,
SuitId = 0,
Parts = [],
}).ToList(),
RequisitionVehicleCount = summonable.Count,
ParkingVehicleId = 0,
};
}
}
// Seed phone contacts so the dialer has something to call without playing the story.
//
// The client matches contacts to its local PhoneContactConfig by phone number
// (CallPhoneUtils.GetConfigIdByPhoneNumber), so the server only owns the number: the name,
// avatar, remark and the whole dialogue body all resolve client-side. What used to be two
// hardcoded quick-call numbers (142857 叫车 / 206337 快递车) is now the full catalogue in
// Configs/PhoneContacts.json — same two rows are still in it, so nothing regresses.
//
// ⛑ 光有号码不够:拨号时 CallPhoneUtils.GetDialogArgsByPhoneNumber 会先问
// CheckConfigContactHasUnlock,未解锁播的是 LockDialogId("号码忙"),既不报错也不打日志。
// 解锁开关在下面 PhoneContactsUnlockAll 那段(module 3 的 FinishedTemplateIdList)。
var summonContacts = BuildPhoneContacts(Config.Gameplay.PhoneContactsEnabled);
foreach (var character in ClientConfigRepository.Characters())
{
root.InfoMinor.PlayerPhoneInfo.SpiritPhoneInfos[character.TemplateId] = new Auto.PhoneInfos
{
ContactList = summonContacts.Select(c => new Auto.PhoneContact
{
Remark = c.Remark,
PhoneNumber = c.Number,
}).ToList(),
ContactGroupList = [],
CallRecordList = [],
ContactOutgoingCallTimesDict = new Dictionary<string, uint>(),
};
}
// 趴卜 NPC 主页的「日程」标签。BubbleFriendHomePanelStore.RefreshContent 取
// NpcDaliyManager:GetTodayScheduleList(agentType) -> NpcTimeTableLists[agentType],而那份表
// 只有两个来源:SyncFavorNpcTimeTableInfos(64219939) 的推送,或登录帧里的
// FavorNpcDailyScheduleInfos。走登录帧就不用再单独推一条 notify,客户端进游戏就有。
// 数据来自 NpcDailySchedule.json(LifeScheduleTimetableConfig 的四个时段 × AgentDataSetsActivityConfig
// 的活动),见 _gen_npc_schedule.py。
if (Config.Gameplay.NpcDailyScheduleEnabled)
{
var scheduleEntries = NpcScheduleCatalog4229938.Entries;
if (NpcScheduleCatalog4229938.LoadError is not null)
{
// Never fatal: an unusable catalogue simply means the 日程 tab stays empty.
Console.Error.WriteLine($"[NPC-SCHEDULE] catalogue unavailable: {NpcScheduleCatalog4229938.LoadError}");
}
if (scheduleEntries.Count > 0)
{
var todayStart = new DateTimeOffset(DateTimeOffset.UtcNow.Date, TimeSpan.Zero).ToUnixTimeSeconds();
var tables = new Dictionary<uint, Auto.NpcTimeTableInfo>(scheduleEntries.Count);
foreach (var entry in scheduleEntries)
{
// ★ 坐标不能留空:NpcDaliyManager:GetNpcPosition 直接把 currentSchedule.Position
// 当 NPC 的所在位置,MapSubSystem_SpiritAcquisition 也拿它(配合 RaidId)落大地图
// 的「角色在哪」图钉。值来自 AgentDataSetsActivityConfig.MapPosition —— 官方给
// 这条活动编好的世界坐标(Richie 的跑步机就是 1191,0,2052),见 _gen_npc_life_cfg.py。
static Auto.NpcScheduleInfo Slice(NpcScheduleSlice4229938 slice) => new()
{
ActivityId = slice.ActivityId,
StartDaySecond = slice.StartDaySecond,
Position = NpcActivityCatalog4229938.TryGet(slice.ActivityId, out var at)
? new Auto.UXVector3 { X = at.X, Y = at.Y, Z = at.Z }
: new Auto.UXVector3(),
SpoonAgentId = 0,
EndDaySecond = slice.EndDaySecond,
RaidId = slice.RaidId,
};
tables[entry.AgentTag] = new Auto.NpcTimeTableInfo
{
Schedule0 = Slice(entry.Slices[0]),
Schedule1 = Slice(entry.Slices[1]),
Schedule2 = Slice(entry.Slices[2]),
Schedule3 = Slice(entry.Slices[3]),
Schedule4 = Slice(entry.Slices[4]),
CurrentSpoonAgentId = 0,
SpoonPosition = new Auto.UXVector3(),
TempSchedule = null,
IsTempScheduleOnly = false,
RefreshDay = todayStart,
};
}
root.InfoMinor.FavorNpcDailyScheduleInfos = tables;
}
}
// 眼界 (Tuite) 的内容列表。SocialNetWorkUtils.lua 里六个列表函数(GetRecommendList:143 /
// GetExploreTuiteList:400 / GetFollowList:431 / GetTuiteListByPubisher:445 /
// GetCollectionList:1687 / GetLocalTuiteList:3046)全部只读 playerTuiteInfo.TuiteList,
// GetTuiteInfo 再用 v.CfgId 去查客户端本地的 TuiteConfig 取正文与作者,所以服务端只要给出
// 「这条存在 + 计数 + 时间 + 关注收藏标记」即可,正文图文评论全在客户端。
// 数据来自 TuitePosts.json(_gen_tuite_posts.py 从 ConfigDump 的 TuiteConfig 204 行生成)。
if (Config.Gameplay.TuiteEnabled)
{
if (TuiteCatalog4229938.LoadError is not null)
{
// Never fatal: an unusable catalogue simply means 眼界 stays empty.
Console.Error.WriteLine($"[TUITE] catalogue unavailable: {TuiteCatalog4229938.LoadError}");
}
var tuitePosts = TuiteCatalog4229938.Posts;
if (tuitePosts.Count > 0)
{
var followAll = Config.Gameplay.TuiteFollowAll;
var publishedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var tuiteList = new List<Auto.ClientTuiteInfo>(tuitePosts.Count);
for (var i = 0; i < tuitePosts.Count; i++)
{
var post = tuitePosts[i];
tuiteList.Add(new Auto.ClientTuiteInfo
{
// TuiteConfig.Id — the only field that unlocks the client's local text.
CfgId = post.Id,
// Rendered as "多久以前", so it must be in the past and strictly decreasing.
// Same helper the detail handlers use, so list and detail agree.
PublishTime = TuiteCatalog4229938.PublishTimeFor(post.Id, publishedAt),
IsCollect = false,
IsLike = false,
// GetFollowList keeps only IsFollow rows; see Gameplay.TuiteFollowAll.
IsFollow = followAll,
// Never true: the timeline blob is authored client-side (see TuiteCatalog4229938).
HasTimeline = false,
TimelineData = null,
LikeCount = post.Likes,
ViewCount = post.Views,
CommentCount = post.Comments.Count,
// Non-zero would light up the 领取 reward button (AskTuiteTakeReward).
DropId = 0,
OssKey = null,
});
}
root.InfoMinor.PlayerTuiteInfo = new Auto.PlayerTuiteClientInfo { TuiteList = tuiteList };
}
}
// Event-condition progress so the phone contacts count as unlocked.
// Module 3 = UX.Game.EventConditionImplModule.PhoneContact (IL2CPP metadata
// declaration order). EventConditionUtils.CheckHasUnlocked walks this list and returns
// unlocked as soon as it contains the PhoneContactUnlockConfig row's own Id:
// 之前只标 [1, 2](= 111111111 叫车 / 111111112 快递车),所以只有那两个号拨得通、
// 其余号码一律播 LockDialogId —— 表现为"号码忙",既不报错也不打日志,很容易误判成
// 服务端没发通讯录。现在整表标完成(79 行),任何角色都能拨。
// ⛑ 这条与上面的 ContactList 是两件事:前者决定"列表里有没有这个号",这条决定"拨不拨得通"。
var phoneProgress = new Auto.ModuleEventProgressInfoBySpirit
{
EventProgressInfoDict = new Dictionary<uint, Auto.EventProgressInfo>(),
FinishedTemplateIdList = BuildPhoneUnlockIds(Config.Gameplay.PhoneContactsUnlockAll),
};
var phoneModule = new Auto.ModuleEventProgressInfo
{
ProgressInfoDict = new Dictionary<uint, Auto.ModuleEventProgressInfoBySpirit>
{
[0] = phoneProgress,
},
};
foreach (var character in ClientConfigRepository.Characters())
phoneModule.ProgressInfoDict[character.TemplateId] = phoneProgress;
root.InfoMinor.ModuleEventProgressInfoDict[3] = phoneModule;
// -----------------------------------------------------------------------------------
// 派对解锁(2026-09-25 第 24 轮)
//
// ★ 症状:客户端里的「主题派对」点开是**锁上**的。
// ★ 根因(逐行可查,非推断):派对解锁判定走
// lua\LX6\Utils\EventConditionUtils.lua:6 M.CheckHasUnlocked(config, module, ...)
// :9 CheckHasUnlockedCondition(config, "MaxProgress")
// → :48 返回 config.MaxProgress > 0 ;PartyConfig.MaxProgress = 1 ⇒ **条件成立**
// :10 GetModuleFinishTemplateIdList(Party)
// → :63 moduleEventProgressInfo.FinishedTemplateIdList
// → :69 gPlayerManager.infoMinor.bindData.ModuleEventProgressInfoDict[module]
// :27 列表里有 `finishedTemplateId == config.Id`(= PartyConfig.Id 13030000…)⇒ 解锁
// 而服务端登录 payload **只填了 module 3(PhoneContact)**,没有 Party
// ⇒ FinishedTemplateIdList 为空 ⇒ 恒 false ⇒ 面板一律显示锁。
// (另:SystemUnlockConfig.Id=160 的 PopupName 就是「主题派对」,VersionCondition=[0,1],
// 它只管主菜单入口,管不了「派对面板里具体哪几个派对解锁」——那是本段的事。)
//
// ★ 模块号铁证(frida 反射 System.Enum.GetValues 读 UX.Game.EventConditionImplModule,
// 非按声明顺序猜):**Party = 30**;对照组 PhoneContact = 3,与上面既有注释完全吻合
// ⇒ 读值方法可靠。
//
// ⚠ 这只是「解锁」。派对真正开局还需要 StartSingleParty(63731129) 等,
// 那些是另一层(见 gym_tools\派对Party_研究报告_第24轮.md),本段不涉及。
// -----------------------------------------------------------------------------------
var partyProgress = new Auto.ModuleEventProgressInfoBySpirit
{
EventProgressInfoDict = new Dictionary<uint, Auto.EventProgressInfo>(),
// PartyConfig 全表 7 条:主城(新启) 13030000/13030002/13030003、联机 13030004/13030005、
// 凌云 13030001/13030006 —— 后两条按「凌云不用管」不解锁,需要时加进来即可。
FinishedTemplateIdList = [13030000, 13030002, 13030003, 13030004, 13030005],
};
var partyModule = new Auto.ModuleEventProgressInfo
{
ProgressInfoDict = new Dictionary<uint, Auto.ModuleEventProgressInfoBySpirit>
{
[0] = partyProgress,
},
};
foreach (var character in ClientConfigRepository.Characters())
partyModule.ProgressInfoDict[character.TemplateId] = partyProgress;
root.InfoMinor.ModuleEventProgressInfoDict[30] = partyModule;
// The v7 guide only constrains ActiveSpirit/actor identity; it does not require the rest of
// PlayerInfo to be zero. Keep build-local SystemUnlock metadata so the stock 4229938 HUD,
// map and input UI do not treat the account as a pre-tutorial profile. No semantic handlers
// are re-enabled by publishing these client-owned unlock ids.
root.InfoMinor.PlayerInfoGuide.UnlockSystems = ClientConfigRepository.UnlockSystemIds().ToList();
root.InfoMinor.PlayerFashionsInfo = MinimalPlayerFashions4229938(state);
SeedOwnedHouses4229938(root, state);
SeedWeaponSkins4229938(root, state);
// UnlockedQuestList is deliberately left empty. It was filled with all 4342 authored ids
// for a while, but the client only reads it from the favourites / ambient-event code — the
// task panel never looks at it, and every S2C route to that panel failed too
// (see GameRouter.Tasks.cs). Dropping it saves ~17 KB of login payload for no loss.
// NPC profiles (character compendium). Unlike the quest list, this one IS read:
// lua/LX6/Data/Player/PlayerData/PlayerInfoMinorNpcProfileData.lua InitPlayerInfo walks
// InfoNpcProfile.NpcProfiles and builds npcTrustInfo[ProfileId] from it.
//
// Deliberately NOT using SyncPlayerNpcProfileActivate(64753383) for this: its client impl
// calls gAgentTrustManager:PopUpAgentProfile, which pushes the profile onto a popup queue
// and then shows a "you met N people" dialog. 61 profiles means 61 queued popups. Filling
// the login payload gets the same data in with no popups at all.
var activateTime = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
foreach (var profile in NpcProfileCatalog4229938.Profiles)
{
root.InfoMinor.InfoNpcProfile.NpcProfiles[profile.Id] = new Auto.TrustNpcInfo
{
ProfileId = profile.Id,
// MaxTrust is the per-profile ceiling from ProfileAgentProfileConfig (1..4 here).
TrustValue = profile.MaxTrust,
ActivateTime = activateTime,
// IsNew=false keeps the red dot / "new" badge off.
IsNew = false,
// Reward and target lists are left empty on purpose: filling GotRewardList would
// mark rewards as claimed and quietly eat them, and we have no state machine that
// can actually grant them.
IsMaxTrustReward = false,
};
}
// Radio songs. PlayerRadioSongsData.SongInfoDict is read directly by the radio UI, and
// GameToClientImpl.SyncAddRadioSong does nothing at all when it is missing — so this
// dictionary is the unlock, and no follow-up S2C is needed.
foreach (var song in NpcProfileCatalog4229938.Songs)
root.InfoMinor.PlayerRadioSongsData.SongInfoDict[song.Id] = true;
// City compendium (城市图鉴). Same shape as the profiles above: the client stores the whole
// struct off the login payload (PlayerInfoMinorData.lua) and BaiKeArchiveManager reads
// CityPediaStatusDict out of it, so filling it here is the unlock — no S2C needed.
//
// SyncNewCityPediaInfo(64303542) would be the incremental route but its client impl pushes
// CityPediaUnlocked onto the popup manager, so 295 entries means 295 queued popups.
var pedia = root.InfoMinor.PlayerCityPediaInfos;
foreach (var entry in CityPediaCatalog4229938.Entries)
pedia.CityPediaStatusDict[entry.Id] = CityPediaCatalog4229938.StatusUnlockedAndRead;
// Credit is the collection score shown per category. Level is the id from
// CityPediaCollectionLevelConfig (7 = Legend); CreditByType is keyed by the Type column of
// CityPediaFirstClassConfig, NOT by class id.
//
// ClaimedLevelRewards stays empty on purpose — marking levels claimed would swallow the
// ClaimCityPediaLevelReward grants without ever paying them out.
pedia.CreditInfo.Level = CityPediaCatalog4229938.MaxLevel;
foreach (var creditType in CityPediaCatalog4229938.CreditTypes)
pedia.CreditInfo.CreditByType[creditType] = CityPediaCatalog4229938.MaxLevelValue;
// Achievements (成就). Also a login-payload unlock: PlayerInfoAchievementData.lua:21 copies
// InfoAchievement.AchievementInfos into the player data, and NewAchievementMgr replays the
// whole dictionary through OnAchievementFinished in its ctor, on panel open and after every
// scene switch. There is no server-side progress machine behind these ids, so every authored
// achievement is stamped as settled at login.
//
// Status is a bit mask, not a plain enum: 1 = Completed, 2 = Rewarded (UX.Game.AchievementStatus
// from the IL2CPP dump — the Lua enum names are obfuscated in the decompile). Stamping 3 keeps
// both "finished" and "already paid" true; NewAchievementMgr.CalcuateHasRedDotNum only counts
// entries with the Completed bit clear and the Rewarded bit set, so nothing raises a red dot.
//
// AchieveTime is a double unix timestamp and must be non-zero: getPriority reads ~= 0 as "has a
// record", and SAchievementDetailStore formats it as the completion date.
foreach (var achievement in AchievementCatalog4229938.Entries)
{
root.InfoAchievement.AchievementInfos[achievement.Id] = new Auto.AchievementDetail
{
AchieveTime = activateTime,
Status = AchievementCatalog4229938.StatusSettled,
};
}
// Challenges (挑战记录). PlayerInfoAchievementData.lua:12-13 copies both dictionaries
// straight into bindData, so this fill is the unlock — no S2C needed.
//
// ⛑⛑ Shape is settled by lua/LuaGen/AutoGen/RPCDeserializeAuto.lua (Auto.Reader[1305]):
// ChallengeRecordInfo = ReadDict7Bit(u32 key -> Complex(Reader[381] ChallengeRecord))
// NewChallengeRecordInfo = ReadDict7Bit(u32 key -> Complex(Reader[594] NewChallengeRecord))
// => dictionaries keyed by challenge id, NOT lists. The only other reference in the client is
// GameToClientImpl.lua:1099 (SyncCompletedChallenge) doing table.insert on ChallengeRecordInfo,
// which appends an *integer* key after login; that is a separate S2C channel and a client-side
// quirk, it does not change what the login payload has to emit.
//
// Every authored challenge gets a row so the panel has entries, while HighestLevel /
// ReceivedRewardLevel start at 0 so nothing reads as completed or claimable. Real progress
// arrives through SetNewChallengeData (checkpoint flags + score) and lives in
// ChallengeRecordStore4229938.
foreach (var challenge in ChallengeCatalog4229938.Entries)
{
var (highest, received, isNew, best, param) = ChallengeRecordStore4229938.Snapshot(challenge.Id);
root.InfoAchievement.ChallengeRecordInfo[challenge.Id] = new Auto.ChallengeRecord
{
ChallengeId = challenge.Id,
HighestLevel = highest,
ReceivedRewardLevel = received,
CurrentIsNewRewardLevel = isNew,
BestScore = (int)best,
};
root.InfoAchievement.NewChallengeRecordInfo[challenge.Id] = new Auto.NewChallengeRecord
{
ChallengeId = challenge.Id,
HighestLevel = highest,
ReceivedRewardLevel = received,
CurrentIsNewRewardLevel = isNew,
BestScore = best,
ParamData = param,
};
}
// Urban badges (徽章). PlayerInfoMinorData.lua:94 pipes InfoMinor.Badges through
// SpiritManager.FilterBadge, which drops rows whose UrbanBadgeConfig is missing or OnlyServer
// and re-keys the table by BadgeInfo.TemplateId. Consumers then look the badge up by
// UrbanBadgeConfig id and additionally require BadgeInfo.Active before treating it as owned
// (UrbanAbilityBadgePanelStore:325, UrbanAbilityBadgeV2PanelStore:228, TalentTreeMgr:1528,
// AkxFuxiExtraArgFuncs:28) — so the key, TemplateId and Active all have to agree.
//
// Deliberately NOT using SyncUrbanBadgeInfo per badge for this: its client impl calls
// gSpiritManager:PushPopWait for every id that is not already present, i.e. 148 queued
// popups. DropSend is write-only on the client side and stays false.
foreach (var badge in BadgeCatalog4229938.Entries)
{
root.InfoMinor.Badges[badge.Id] = new Auto.BadgeInfo
{
TemplateId = badge.Id,
Active = true,
DropSend = false,
};
}
// NPC cultivation (好感度 / NPC 卡片). PlayerInfoMinorNpcCultivationData.lua:9 copies
// InfoMinor.InfoNpcCultivation into the player data, then turns every NpcCardInfos row into a
// card via gNpcInteracsUtils:CreateNpcCardInfo; NpcChatManager_Story.lua:28 reads the same
// struct for story gating.
//
// NpcCardInfo.TemplateId is the *NpcCultivationConfig* id (910500xx) — not an agent id and
// not a FightSpiritConfig id. CreateNpcCardInfo is null-tolerant on every field except
// TemplateId, so a minimal card is safe; Favor is the only field with real gameplay weight.
// The favour ladder (NpcCultivationNpcFavorLevelConfig.UnlockFavor) tops out at 1000, so
// stamping 1000 opens every favour level. Set NpcCultivationCatalog4229938.MaxFavor to 0 to
// make progression start from scratch instead.
//
// LockedCardInfos stays empty, and the voice/story unlock lists are left empty rather than
// null — they are read by the voice and story panels, which index them directly.
foreach (var cultivation in NpcCultivationCatalog4229938.Cultivations)
{
root.InfoMinor.InfoNpcCultivation.NpcCardInfos.Add(new Auto.NpcCardInfo
{
TemplateId = cultivation.Id,
Favor = NpcCultivationCatalog4229938.MaxFavor,
MaxFavorInHistory = NpcCultivationCatalog4229938.MaxFavor,
ActivateTimestamp = activateTime,
UnlockedVoice = new List<uint>(),
UnlockedStoryDict = new Dictionary<uint, uint>(),
});
}
// InteractPoint and AvailableGiftSendCount are plain counters the client reads straight off
// this struct. InteractPointMaxLimit is 5 in the client's own config, and the client re-syncs
// the gift count itself whenever a gift is sent.
root.InfoMinor.InfoNpcCultivation.InteractPoint = NpcCultivationCatalog4229938.MaxInteractPoint;
root.InfoMinor.InfoNpcCultivation.AvailableGiftSendCount = NpcCultivationCatalog4229938.GiftSendCount;
FillNpcChats(root, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
// Computers (电脑解锁). PlayerInfoMinorData.lua:18 copies InfoMinor.ComputerUnlockInfo into
// bindData wholesale. The three dictionaries are presence checks, not lists of state:
// ComputerIndexerPanelStore walks the computer's own FileList and hides any id missing from
// UnlockFiles, so every configured file/email/computer id is stamped here. Compare with
// the S2C route instead: GameToClientImpl.SyncUnlockComputerFile exists, but it is written
// for one id at a time and here nothing is supposed to be locked in the first place.
//
// IsRead stays false on purpose — ComputerUtils.lua:122 uses it as the unread badge.
// DeleteFiles / DeleteEmails stay empty: they list rows the player removed from a machine,
// and the desktop reproduces the full FileList until something is actually deleted.
var computer = root.InfoMinor.ComputerUnlockInfo;
computer.ComputerInfos ??= new Dictionary<uint, Auto.ComputerDetailInfo>();
computer.UnlockFiles ??= new Dictionary<uint, Auto.ComputerFile>();
computer.UnlockEmails ??= new Dictionary<uint, Auto.ComputerEmail>();
foreach (var id in ComputerCatalog4229938.Computers)
{
computer.ComputerInfos[id] = new Auto.ComputerDetailInfo
{
CfgId = id,
FirstOpenTime = activateTime,
DeleteFiles = new List<uint>(),
DeleteEmails = new List<uint>(),
};
}
foreach (var id in ComputerCatalog4229938.Files)
{
computer.UnlockFiles[id] = new Auto.ComputerFile
{
CfgId = id,
UnlockTime = activateTime,
IsRead = false,
};
}
foreach (var id in ComputerCatalog4229938.Emails)
{
computer.UnlockEmails[id] = new Auto.ComputerEmail
{
CfgId = id,
UnlockTime = activateTime,
IsRead = false,
};
}
// Factions (阵营声望). PlayerInfoAchievementData.lua:17 copies
// InfoAchievement.FactionInfoDic straight into bindData.FactionInfoDic, and every consumer
// then reads exactly one field off those rows — DispositionLevel — with higher meaning
// friendlier:
// NPCShopPanelStore.lua:473/479 DispositionLevel -> shop attitude and FactionCtrl
// DeliverGiftsPanelStore.lua:512 level > 6 -> favour x1.4, level > 5 -> x1.2
// SwapCharacterListPanelStore.lua:543 level below the cap locks the character out
// ClientUtils.lua:614 GetFameLevel returns it, falling back to 999 when the row is absent
// So each faction is stamped with its own ceiling from FactionConfig; Disposition carries the
// matching raw value because the map/tooltip overlays read that one instead.
//
// Influence, GreetCount and InteractionCount stay at zero: they are progression counters for
// territory control and repeated NPC contact, and fabricating them would misreport play time.
// CounterAttackData must stay non-null (a null complex marker breaks deserialization).
//
// CountryReputationInfo is deliberately left empty: the only client code that touches it is
// GameToClientImpl.SyncCountryReputation, which merely writes dict[country] = reputation.
// Nothing ever reads it, so filling it would invent numbers with no consumer.
foreach (var faction in FactionCatalog4229938.Entries)
{
root.InfoAchievement.FactionInfoDic[faction.Id] = new Auto.FactionInfo
{
Disposition = faction.MaxDisposition,
DispositionLevel = faction.MaxDispositionLevel,
IsUnlock = true,
CounterAttackData = new Auto.FactionCounterAttackData(),
};
}
// Phone menu skin parts (手机皮肤). PlayerInfoSpiritData.lua:18 copies
// InfoSpirit.AvailableSkinParts into the player data. MainPhoneUtils.CheckSkinPartAvailable
// then calls LTConfig.MobileMenuSkinPartConfig.GetConfig(id) for EVERY entry and dereferences
// .Type, so an id missing from that table would throw — which is why this catalog is built
// from MobileMenuSkinPartConfig itself (112 parts) rather than from the suit table.
//
// Note the whole list is replaced whenever SyncSpiritMobileSkinPartInfo arrives; the server
// does not send it, so the login-payload fill is what the phone panel sees.
foreach (var part in NpcCultivationCatalog4229938.SkinParts)
root.InfoSpirit.AvailableSkinParts.Add(part.Id);
// Map fog is deliberately NOT published here. Publishing SceneFogMaps (one All=true entry
// per raid) was tried and verified useless in-game: the map stayed fully fogged. The
// client's fog view never reads this field — MapSystem_FogMap asks MapFogDataMgr, which is
// fed only by SyncSceneFogMapAllUnlock(sceneId, unlocked), and that key is a SCENE id
// (RaidConfig.SceneId) rather than a raid id. See GameRouter.PublishMapFogUnlockAsync.
Console.WriteLine(
$"[LOGIN-UNLOCK] apps={root.InfoSpirit.InstalledApps.Count} " +
$"systems={root.InfoMinor.PlayerInfoGuide.UnlockSystems.Count} " +
$"houses={root.InfoMinor.housesInfo.HouseInfoList.Count} " +
$"profiles={root.InfoMinor.InfoNpcProfile.NpcProfiles.Count} " +
$"radioSongs={root.InfoMinor.PlayerRadioSongsData.SongInfoDict.Count} " +
$"cityPedia={root.InfoMinor.PlayerCityPediaInfos.CityPediaStatusDict.Count} " +
$"pediaLevel={root.InfoMinor.PlayerCityPediaInfos.CreditInfo.Level} " +
$"achievements={root.InfoAchievement.AchievementInfos.Count} " +
$"challenges={root.InfoAchievement.NewChallengeRecordInfo.Count} " +
$"badges={root.InfoMinor.Badges.Count} " +
$"npcCards={root.InfoMinor.InfoNpcCultivation.NpcCardInfos.Count} " +
$"computers={(root.InfoMinor.ComputerUnlockInfo.ComputerInfos?.Count ?? 0)} " +
$"factions={root.InfoAchievement.FactionInfoDic.Count} " +
// The first few "<id>:<layer>" pairs are the fast way to confirm the off-by-one below:
// CheckTalentIsLocked needs Layer > LayerNum, so a LayerNum-1 node must read ":2".
$"careerTalents={CareerTalentNodes4229938().Count}" +
$"[{string.Join(" ", CareerTalentNodes4229938().Take(3).Select(n => $"{n.Id}:{n.Layer}"))}] " +
$"seenTrees={root.InfoMinor.GameplayTalentInfos.Values.Sum(x => x.TalentTreeRecordDict.Count)} " +
$"gameplayNodes={string.Join("/", TalentTreeRepository.GameplayIds.Select(g => $"{g}:{GameplayTalentNodes4229938(g).Count}"))} " +
$"skinParts={root.InfoSpirit.AvailableSkinParts.Count}");
SeedPlayerEconomy4229938(root, state);
SeedPlayerTalents4229938(root, state);
SeedFishing4229938(root, state);
// 搜打撤的四个包(背包 / 安全箱 / 仓库 / 武器轮盘)。客户端在这一帧里就把
// PlayerExtractionShooterInfo 读成自己的 bag store,之后再改要靠 SetItemInfo 增量推 ——
// 所以这里必须是 session 里那份状态,不能现造一张空表。
// ⛑ Bags 与 SlotGroups 互斥(Type ∈ {4,5,6} 走 SlotGroups),分流在 FillLoginInfo 里做。
// ⛑ 挂载点是 InfoMinor,不是 root —— PlayerExtractionShooterInfo 在 PlayerClientInfoMinor 里。
if (extraction is not null)
ExtractionShooterRuntime4229938.FillLoginInfo(extraction, root.InfoMinor.PlayerExtractionShooterInfo);
// Poses/emotes: the client keeps ActionItems LOCKED until they appear in
// UnlockActionItemDict (or arrive via SyncUnlockInteractionActionItems), so seed
// every authored action id from ActionItemConfig.json at login.
foreach (var actionId in ClientConfigRepository.ActionItemIds())
root.InfoMinor.PlayerInteractionActionInfo.UnlockActionItemDict[actionId] =
new Auto.PlayerInteractionActionItem { CfgId = actionId, UnlockTime = 1, ShowRedPoint = false };
return root;
}
/// <summary>
/// BBChat (AppId 27 "Message") — NPC conversation history. See
/// <see cref="NpcChatCatalog4229938"/> for why this has to be part of the login frame: every
/// conversation branch already exists in the client's own NPCChatConfig, and the server only
/// says which rows are unlocked, under which dictionary key, in which order.
/// </summary>
private static void FillNpcChats(Auto.PlayerClientInfo root, long now)
{
if (!Config.Gameplay.NpcChatEnabled)
return;
// A missing catalogue is not fatal — the app simply has nothing to show, same as before this
// existed. The reason surfaces through GameRouter.NpcChat instead, which has a logger.
if (NpcChatCatalog4229938.LoadError is not null)
return;
// ChatType of the thread's head row (dump.cs:1326930). 1 Normal / 2 Invite are idle-app
// content; 3 Dialog / 4 Fake are handed over by world interactions and only ride along when
// gameplay.npcChatStoryThreads is on.
var includeStory = Config.Gameplay.NpcChatStoryThreads;
FillNpcChatList(root.InfoMinor.InfoNpcCultivation.NpcChats, NpcChatCatalog4229938.Npcs, includeStory, now);
foreach (var group in GroupsOf(NpcChatCatalog4229938.Groups, includeStory, now))
root.InfoMinor.InfoNpcCultivation.NpcGroupChats.Add(group);
}
/// subset — which rows are unlocked, under which dictionary key, in which order.
///
/// <c>InviteChatList</c> / <c>DialogChatListDict</c> stay empty on purpose: they are what the
/// player can answer right now, so filling them would auto-open every conversation on login.
/// </summary>
private static void FillNpcChatList(
List<Auto.ClientNpcChatData> destination,
IReadOnlyList<NpcChatOwner4229938> owners,
bool includeStory,
long now)
{
var threadIndex = 0;
foreach (var owner in owners)
{
Auto.ClientNpcChatData? current = null;
foreach (var thread in owner.Threads)
{
if (thread.Type >= 3 && !includeStory)
continue;
var data = current ??= EnsureOwner(destination, owner.Id);
// Key is the row's AsNpcCultivation; GetNpcChatList looks up both {0, currentNpcId},
// so a row keyed 0 is readable no matter who the player is playing as.
if (!data.NpcChatListDict.TryGetValue(thread.Key, out var list))
{
list = new Auto.ChatInfoList();
data.NpcChatListDict[thread.Key] = list;
}
AppendThread(list.ChatList, thread, owner.Id, threadIndex, now);
threadIndex++;
}
}
static Auto.ClientNpcChatData EnsureOwner(List<Auto.ClientNpcChatData> destination, uint templateId)
{
foreach (var existing in destination)
if (existing.TemplateId == templateId)
return existing;
var created = new Auto.ClientNpcChatData { TemplateId = templateId };
destination.Add(created);
return created;
}
}
private static IEnumerable<Auto.ClientNpcGroupChatData> GroupsOf(
IReadOnlyList<NpcChatOwner4229938> owners,
bool includeStory,
long now)
{
var threadIndex = 0;
foreach (var owner in owners)
{
var data = new Auto.ClientNpcGroupChatData { TemplateId = owner.Id };
foreach (var member in owner.Members)
data.Members.Add(member);
var moved = false;
foreach (var thread in owner.Threads)
{
if (thread.Type >= 3 && !includeStory)
continue;
if (!data.NpcChatListDict.TryGetValue(thread.Key, out var list))
{
list = new Auto.ChatInfoList();
data.NpcChatListDict[thread.Key] = list;
}
AppendThread(list.ChatList, thread, owner.Id, threadIndex, now);
moved = true;
threadIndex++;
}
if (moved)
yield return data;
}
}
/// <summary>
/// Appends one thread as <c>NpcChatItem</c> rows. Timestamps run forward inside the thread and
/// end a few minutes ago, so a conversation reads oldest→newest and its last message is never
/// in the future; every thread additionally gets an hour-wide offset so two NPCs do not look
/// like they wrote at the same second.
/// </summary>
private static void AppendThread(
List<Auto.NpcChatItem> destination,
NpcChatThread4229938 thread,
uint belongNpc,
int threadIndex,
long now)
{
const long gapSeconds = 120;
var start = now - threadIndex * 3600L - (long)thread.Items.Count * gapSeconds - gapSeconds;
for (var i = 0; i < thread.Items.Count; i++)
{
var node = thread.Items[i];
destination.Add(new Auto.NpcChatItem
{
Timestamp = (uint)Math.Clamp(start + i * gapSeconds, 0, uint.MaxValue),
ChatId = node.Id,
NextChatId = node.NextId,
// Emojis / hyperlinks ride in here; every published row is plain text from the client
// table, so null is honest and keeps the row at 22 bytes.
ChatContext = null,
// Published history is already read — otherwise every thread lights the app's red dot.
IsRead = true,
BelongNpc = belongNpc,
ChatActionTriggeredTime = 0,
});
}
}
internal static Auto.EnterSceneInfo EnterScene(uint switchShowId)
{
var placement = ClientConfigRepository.EntryPlacement(switchShowId);
return EnterScene(
Config.World.RaidId,
Config.World.SceneInstanceId,
Config.World.UniverseId,
Config.Player.InitialUnitId,
Config.Player.InitialSpiritTemplateId,
placement.Position,
placement.Facing,
switchShowId,
isSwitchSpiritShow: true);
}
internal static Auto.EnterSceneInfo EnterScene(
uint raidId,
ulong instanceId,
uint universeId,
ulong unitId,
uint templateId,
Vec3 position,
float facing,
uint switchShowId = 0,
bool isSwitchSpiritShow = false,
uint sectorControlId = 0)
=> new()
{
PlayerSessionId = Config.Player.Pid,
RaidId = raidId,
InstanceId = instanceId,
Position = Vector(position),
Facing = facing,
SceneSpoonNames = [],
SceneSpoonMd5s = [],
Spirits =
[
new Auto.SpiritInitData
{
Id = unitId,
TemplateId = templateId,
IsActive = true,
// Proven v7 single-CreateHero entry contract: initial actor identity only.
// Weapon/fashion/runtime presentation is hydrated only after world-entry finalization.
WeaponTemplateId = 0,
WeaponSkinId = 0,
}
],
GridInfo = new Auto.ServerSimpleGridInfo
{
MinX = 0,
MinZ = 0,
MaxX = 300000,
MaxZ = 300000,
},
MatchGameId = 0,
SwitchShowId = switchShowId,
IsSwitchSpiritShow = isSwitchSpiritShow,
UniverseId = universeId,
SectorControlId = sectorControlId,
LoadingType = new Auto.LoadingTypeInfo
{
Type = 0,
Members = [],
Ripple = false,
},
LinkSimpleInfo = new Auto.LinkSimpleInfo
{
Id = 0,
Mode = 0,
DeviceLevel = 1,
Tag = 0,
PublicEventId = 0,
LastPublicEventTime = 0,
HostPid = 0,
},
DataLayerIndices = [],
Seamless = false,
TombIslandIds = [],
};
internal static Auto.RaidBattleUnitSpirit ExistingUnitProjection(uint switchShowId)
{
var placement = ClientConfigRepository.EntryPlacement(switchShowId);
return ExistingUnitProjection(
Config.Player.InitialUnitId,
Config.Player.InitialSpiritTemplateId,
placement.Position,
placement.Facing);
}
internal static Auto.RaidBattleUnitSpirit ExistingUnitProjection(
ulong unitId,
uint templateId,
Vec3 position,
float facing,
WorldEntryState? state = null)
{
// Initial world-entry uses the same BaseUnit created by native CreateHero. ManagedPid is
// presentation/remote-management metadata on this AOI projection and must stay zero; the
// actual local authority is established by SyncManagedLogicAgent(unitId, playerPid, 0).
// The worn outfit travels with the projection because this is what materializes the actor —
// sending the authored defaults here dress the model before any later sync can correct it.
var projection = CharacterUnitProjection(
unitId, templateId, position, facing, WornFashionIds4229938(templateId, state), state);
projection.ManagedPid = 0;
return projection;
}
internal static Auto.RaidBattleUnitSpirit CharacterUnitProjection(
uint templateId,
Vec3 position,
float facing,
WorldEntryState? state = null)
{
// ⛑ 必须用 ControlCharacter 而不是 Characters():科斯机甲(15022030)不在花名册里
// (隐藏可控原型),但切人主流程建单位这一步照样要能解析它 —— 2026-09-26 04:39 实测
// 就是这里抛 "Character 15022030 is not present in FightSpiritConfig roster." 把整个
// 接管打断在发 SyncRaidBattleUnitSpirit 之前。
var character = ClientConfigRepository.ControlCharacter(templateId)
?? throw new InvalidOperationException($"Character {templateId} is not present in FightSpiritConfig roster.");
return CharacterUnitProjection(
character.UnitId,
character.TemplateId,
position,
facing,
ClientConfigRepository.DefaultFashionIds(templateId),
state);
}
/// <summary>
/// The outfit one character should be wearing. When the player picked an outfit in an earlier
/// session it is replayed from the progress snapshot; otherwise the authored defaults apply.
///
/// This is the authoritative write for "what the model wears": the wardrobe panel never edits
/// the value locally, it only sends AskSetSpiritFashionsWithSource and waits for this packet.
/// Publishing defaults after a restore therefore silently undresses the character.
/// </summary>
internal static GameMethods.SyncSetSpiritFashions CharacterFashions(uint templateId, WorldEntryState? state = null)
{
var worn = state?.FashionBySpirit.GetValueOrDefault(templateId);
var wear = worn is { FashionIds.Count: > 0 }
? WearFashionsForGameMethods(
worn.FashionIds,
functionSuitId: worn.FunctionSuitId,
source: worn.Source,
sourceId: worn.SourceId,
isTryWear: worn.IsTryWear,
hiddenParts: worn.HiddenParts)
: WearFashionsForGameMethods(ClientConfigRepository.DefaultFashionIds(templateId));
return new GameMethods.SyncSetSpiritFashions
{
spiritId = templateId,
source = worn?.Source ?? 0,
spiritWearFashionsInfo = wear,
};
}
internal static GameMethods.AskAllSpiritPanelDataResult MinimalAllSpiritPanelData4229938()
=> new()
{
spirits = ClientConfigRepository.Characters().Select(character => new Auto.SpiritPanelData
{
FightSpiritId = character.TemplateId,
MaxHp = 1f,
Dam = 0f,
DefDeduct = 0f,
}).ToList()
};
/// <summary>
/// ⛑⛑⛑ 主角「换皮」:把 <b>已存在的主角单位</b>(默认 <c>Config.Player.InitialUnitId</c>)
/// 直接换成另一个 templateId,不新建单位。
///
/// 2026-09-25 02:0x 实测:客户端 <c>IsMe=True</c> 只在登录时 native <c>CreateHero</c>
/// 建出来的那个单位上出现过(<c>InitModules 100000000000 IsMe True</c> + <c>[LB] 进入场景 创建主角</c>);
/// 之后 5 次切换,无论 <c>ExistingUnitProjection</c> 还是 <c>CharacterUnitProjection</c>、
/// 无论 ManagedPid 填 0 还是 PlayerPid,客户端一律 <c>IsMe False</c> + <c>[LB] 创建角色</c>。
/// ⇒ 结论:服务端推 projection 建不出主角,只能让客户端已有的主角单位换 template。
/// </summary>
internal static Auto.RaidBattleUnitSpirit CharacterUnitProjection(
ulong unitId,
uint templateId,
Vec3 position,
float facing,
WorldEntryState? state = null)
=> CharacterUnitProjection(unitId, templateId, position, facing,
ClientConfigRepository.DefaultFashionIds(templateId), state);
internal static Auto.RaidBattleUnitSpirit CharacterUnitProjection(
ulong unitId,
uint templateId,
Vec3 position,
float facing,
IReadOnlyList<uint> fashionIds,
WorldEntryState? state = null)
{
return new Auto.RaidBattleUnitSpirit
{
SpiritWearFashionsInfo = new Auto.OtherPlayerSpiritWearFashionsInfo
{
// Applied dye per worn fashion. A spirit switch re-materializes the actor from this
// projection, so without the replay the model comes back in its authored colours.
// Empty for fashions the dye handlers never saw.
WearFashionColoringInfoDict = FashionColoringStore.GetAppliedColorings(state, fashionIds)
.ToDictionary(kv => kv.Key, kv => new Auto.FashionColoringInfo
{
ColoringType2ColorIdDict = kv.Value,
}),
WearFashionInfoList = fashionIds.Select(id => new Auto.WearFashionInfo { FashionId = id }).ToList(),
WearFashionEditInfoList = [],
HiddenParts = 0,
EditedHiddenParts = 0,
},
Begging = false,
IsBot = false,
Id = unitId,
TemplateId = templateId,
Position = Vector(position),
FacingDirection = facing,
OwnerId = Config.Player.Pid,
// Build 4229938 uses ManagedPid while constructing the BaseUnit to decide whether
// the projected spirit belongs to the local player. Leaving this at zero creates
// switch targets as IsMe=false and the stock handoff never completes.
ManagedPid = Config.Player.Pid,
MoveId = 0,
NavTags = 0,
GroundData = new Auto.MoveActionGroundData
{
MoveGroundType = 0,
MoveGroundId = 0,
MetaInfo = 0,
LocalPos = new Auto.UXVector3(),
},
};
}
private static Auto.SpiritInfo MinimalActorCharacterSpirit4229938(
CharacterCatalogEntry character,
WorldEntryState? state)
{
var loadout = CombatCatalogRepository.Loadout(character.TemplateId);
var spirit = new Auto.SpiritInfo
{
Id = character.UnitId,
TemplateId = character.TemplateId,
PossessTime = 1,
HpRate = 1f,
// ⛑️ ROLLBACK BASELINE — round 1 publishes Jobless here. See
// SpiritJobManager.lua:38 GetCurSpiritJobId() / :51 GetCurSpiritJobClassId(), which both
// read this very struct; that is what the character menu renders and what
// MainPhoneUtils.CheckJobCanShow() classifies the phone apps against.
//
// Publishing the real top job (TopUrbanJob4229938) did not unlock the hacker entry or the
// EonBug app, so it is parked. Two known obstacles if this is revisited:
// 1. CheckJobCanShow requires #JobClassIdList > 0 AND the job class to be present in
// GetCurSpiritAvailableJobIdList(), and that list only collects Jobless rows unless
// the caller passes containJobless — so route is dead independent of what we send.
// 2. The runtime top-up (GameToClientImpl.SyncSpiritJobInfo) can never land: it routes
// through SpiritManager.lua:637 `local tid = self.Id_to_Tid(self, spiritId)` and
// Id_to_Tid (SpiritManager.lua:513) returns 0 when the spirit IS found
// (`if spirit ~= nil then return 0 end`), so tid is always 0, spiritViewDatas[0] is
// nil, and the packet is dropped silently with no client-side error.
// The roster therefore stays the only source the client trusts — and stays Jobless until
// those two are solved.
SpiritJobInfo = new Auto.SpiritJobInfo { CurrentJob = JoblessUrbanJob4229938 },
EverSwitched = character.TemplateId != Config.Player.InitialSpiritTemplateId,
Blocked = false,
LastUsedWeaponInstanceId = loadout.DefaultWeapon.InstanceId,
// The phone wallpaper the character is wearing. MainPhoneUtils.GetCurrentSpiritSkinInfo()
// reads exactly this struct, and the phone panel believes whatever it finds here — a
// zeroed struct makes every "apply" look successful and change nothing.
MobileSkinInfo = WornPhoneSkin4229938(character.TemplateId, state),
// Career talents (职业天赋) published as already-activated nodes. Reusing the same
// SpiritTalentInfo shape as GameplayTalentInfos, which is the one talent dictionary the
// 4229938 login frame demonstrably parses. See TalentTreeSettings.SeedCareerTalents.
TalentInfo = CareerTalentInfo4229938(character.TemplateId),
// The persisted wheel, not the authored loadout: SeedPlayerEconomy-level armory and the
// character's own slot view must agree with the restored LastUsedWeaponInstanceId below,
// otherwise the client's preload cannot resolve the equipped weapon.
WeaponSlots = RestoredWheel4229938(character.TemplateId, state)
.Select(weapon => weapon is null ? null! : WeaponData(weapon))
.ToList(),
};
// Replay the weapon this account last equipped. PublishMinimalActorPresentation4229938
// republishes it via SyncSpiritLastUsedWeapon, but seeding the login payload keeps the
// character from briefly showing the authored default first.
var savedWeapon = state?.LastWeaponBySpirit.GetValueOrDefault(character.TemplateId) ?? 0;
if (savedWeapon != 0 &&
(CombatCatalogRepository.Weapon(character.TemplateId, savedWeapon) is not null ||
CombatCatalogRepository.Weapon(savedWeapon) is not null))
{
spirit.LastUsedWeaponInstanceId = savedWeapon;
}
// Fight-style metadata is required to interpret the equipped weapon's authored action set.
// Do not seed the old server's synthetic SpiritAbilities/urban job/combat graph here.
foreach (var (typeId, styleId) in loadout.StyleByType)
spirit.SpiritFightStyle.FightStyleInfo[typeId] = styleId;
return spirit;
}
/// <summary>
/// 科斯机甲的 <c>SyncPlayerAddNewSpirit</c>(64691088) 载荷(移植捐赠端 7k-v0.5 RuntimePayloadFactory.cs:343-344)。
///
/// 科斯不在登录花名册里 —— FightSpiritConfig 15022030 = "特摄战原型-科斯",IsPublish=false /
/// CanJoin=false / Invisible=true,所以客户端 SpiritManager 手上没有它的 SpiritViewData。
/// 不先补这条 entry,切过去时 <c>Id_to_Tid</c> 查不到对应 tid,包会被静默丢掉(这也是
/// GameRouter.Profiles.cs 里 ROLLBACK BASELINE 注释记的同一个坑)。
///
/// ⛑ 刻意复用本服已在用的 <see cref="MinimalActorCharacterSpirit4229938"/>(每个 roster 角色都靠它
/// 下发),而不是另写一份精简结构:它的 WeaponSlots 走 <see cref="RestoredWheel4229938"/>,
/// 科斯没有存档轮盘 ⇒ 回落到 <c>CombatCatalogRepository.TryLoadout</c> 那份孤立装配(slot0 = 科斯冲锋枪)。
/// </summary>
internal static GameMethods.SyncPlayerAddNewSpirit KesiSpirit4229938(WorldEntryState? state)
=> new()
{
spirit = new() { SpiritInfo = MinimalActorCharacterSpirit4229938(ClientConfigRepository.Kesi, state) },
reason = 0,
};
/// <summary>
/// The urban-job record for one controllable character, read from
/// <c>FightSpiritConfig.DefaultUrbanJob</c>. Data-driven on purpose: no spirit id is
/// hard-coded here, so an authored job on any character shows up without touching this file.
/// </summary>
internal static Auto.SpiritJobInfo SpiritJobInfo4229938(uint templateId)
{
// Characters() only holds the playable roster; an unknown template falls back to Jobless.
// 科斯机甲(15022030)不在花名册里,走 ControlCharacter 才能拿到它的 DefaultUrbanJob=100。
var character = ClientConfigRepository.ControlCharacter(templateId);
return DefaultSpiritJobInfo4229938(templateId, character?.DefaultUrbanJob ?? 0);
}
/// <summary>
/// Every urban job the character will actually be published with — authored rank, its promotion
/// chain, any per-character override and (in "multi") every app-gated class representative. This
/// is the server's own view of "which careers does this character own", so it is the input for
/// anything else that keys off a career (badge-derived capability buffs, chiefly) rather than a
/// second, divergent job lookup.
/// </summary>
internal static IReadOnlyList<uint> PublishedUrbanJobIds4229938(uint templateId)
=> SpiritJobInfo4229938(templateId).AvailableJobs.Keys.OrderBy(x => x).ToArray();
/// <summary>
/// <c>UrbanJobJobClassConfig</c> 11300003 "NCCA Officer" — the police career class. The client's
/// <c>PoliceJobManager.CheckIsPoliceJob</c> compares <c>UrbanJobConfig.JobClass</c> against
/// <c>UrbanJobJobClassConfig.Police</c>; the career has three ranks, 301 / 302 / 303.
/// </summary>
internal const uint PoliceJobClassId4229938 = 11_300_003;
/// <summary>
/// True when any career the character is published with belongs to the police class. Data-driven
/// on <see cref="PublishedUrbanJobIds4229938"/> (so <c>urbanJobOverrides</c> works too) rather
/// than on a hard-coded spirit or job id — Richie (15021021) is the only authored one today.
/// </summary>
internal static bool IsPoliceCharacter4229938(uint templateId)
=> PublishedUrbanJobIds4229938(templateId)
.Any(jobId => ClientConfigRepository.UrbanJob(jobId)?.JobClass == PoliceJobClassId4229938);
/// <summary>
/// 层 2a / T4-9: the payload behind <c>SyncSpiritPoliceJobInfo</c> (64349458). Everything is
/// derived from authored tables via <see cref="PoliceCatalog4229938"/> — no dispatch id, fine id
/// or suspect id is spelled out here.
///
/// The panel is a pure consumer, so "the panel is empty" always means "the payload was empty".
/// Three independent lists feed it (all read by PolicePanelManager.lua:156
/// OnSpiritPoliceJobInfoSync):
///
/// DispatchInfos -> the chips on the Duty Terminal homepage. GetPoliceDispatchItemList (:848)
/// walks dispatchInfo[spiritId] and keeps entries whose id survives
/// CheckDispatchEnable (config exists AND ShowInApp). An empty dict is a
/// blank homepage. NextAvailableTime=0 makes GetDispatchCD negative => the
/// chips render clickable instead of counting down.
///
/// CaseInfos -> the case tab. A case row is not a config table (the real client builds it
/// from people it arrests), so rows are composed from real tables:
/// NpcId <- PoliceSuspectCatalog (must exist in AgentConfig, else
/// OnRenderCaseItem returns early and draws nothing),
/// Fines <- PoliceFineCatalog (IsShow rows; GetFineList/GetResultList
/// render their Title and Factor),
/// Drops <- PoliceConfig.static.ArrestRewardDrop.
/// State=CanInterrogate(0) so the panel shows the interrogation entry rather
/// than pretending the case is settled.
///
/// ServiceData -> the homepage tally (RenderSummaryTemplate :906 reads
/// DutyBasicInfo.ServiceData / WeeklyServiceData; a missing one degrades to
/// zeros rather than crashing).
///
/// Left empty on purpose: <c>ViolationInfos</c> (a live violation arms the suspension countdown
/// and the force-quit popup — not something a private server wants to fake). It is legal as an
/// empty container.
///
/// <c>PoliceFakeFileInfo</c> used to be empty for the same "too much state machine" reason, but
/// that is why the Mimic Database archive renders 27 locked rows: nothing needs to be simulated,
/// they only need a <c>State</c> (= <see cref="PoliceFakeFileStateUnlock"/>). See step 4 below,
/// gated by <c>gameplay.policeFakeFilesUnlock</c>.
/// </summary>
internal static GameMethods.SpiritPoliceJobInfo4229938 PoliceJobInfo4229938(uint templateId)
{
var gameplay = Config.Gameplay;
var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var result = new GameMethods.SpiritPoliceJobInfo4229938();
// Known-good skeleton fallback — byte-identical to the frame the empty wire test pins.
if (string.Equals(gameplay.PolicePayloadMode, "empty", StringComparison.OrdinalIgnoreCase))
return result;
// ---- 1. duty-terminal dispatch chips ------------------------------------------------
if (gameplay.PoliceDispatchEnabled)
{
foreach (var dispatch in PoliceCatalog4229938.InAppDispatches)
{
result.DispatchInfos[dispatch.Id] = new GameMethods.PoliceDispatchInfo4229938
{
Id = dispatch.Id,
NextAvailableTime = 0,
IsTemp = false,
TempEventId = 0,
TodayArrestSupportTimes = 0,
};
}
}
// ---- 2. fabricated case history -----------------------------------------------------
var suspects = PoliceCatalog4229938.Suspects;
var fines = PoliceCatalog4229938.RenderableFines;
var arrestDrop = PoliceCatalog4229938.ArrestRewardDrop;
var wanted = Math.Min(gameplay.PoliceCaseCount, PoliceCatalog4229938.CaseUpperLimit);
var caseCount = (uint)Math.Min(wanted, (uint)suspects.Count);
for (var i = 0u; i < caseCount; i++)
{
// Rotate through the charge table rather than repeating one row, so the list does not
// look like the same crime N times.
var charges = new List<uint>();
if (fines.Count > 0)
{
var take = 1 + (int)(i % 3);
for (var k = 0; k < take; k++)
charges.Add(fines[(int)((i * 3 + k) % fines.Count)].Id);
}
var drops = arrestDrop == 0 ? new List<uint>() : new List<uint> { arrestDrop };
result.CaseInfos.Add(new GameMethods.PoliceCaseInfo4229938
{
Id = 9_000_000ul + i,
NpcId = suspects[(int)i].Id,
Time = now - (i + 1) * 86_400u,
Fines = charges,
Sentence = 0,
Drops = drops,
BonusDrops = new List<uint>(drops),
RewardTaken = false,
IsFakePerson = false,
NpcImprisonStatus = 0,
SourceType = 0,
HasUnlockClue = false,
InterrogationInfo = new GameMethods.PoliceInterrogationInfo4229938
{
// CanInterrogate (UX.Game.PoliceCaseInterrogationState 0).
State = 0,
NpcInfo = new GameMethods.PoliceInterrogationNpcInfo4229938
{
RPSInfo = new GameMethods.PoliceInterrogationRpsInfo4229938
{
Cards = new GameMethods.PoliceInterrogationCards4229938(),
},
},
},
});
}
// ---- 3. homepage tally --------------------------------------------------------------
var tally = gameplay.PoliceServiceTally != 0
? gameplay.PoliceServiceTally
: (uint)(result.DispatchInfos.Count + result.CaseInfos.Count);
result.DutyBasicInfo = new GameMethods.PoliceDutyBasicInfo4229938
{
ServiceData = new GameMethods.PoliceServiceData4229938
{
DispatchTimes = tally,
PatrolTimes = tally,
ArrestTimes = tally,
FineCount = tally,
CarFineCount = 0,
LastUpdateTime = now,
},
WeeklyServiceData = new GameMethods.PoliceServiceData4229938
{
DispatchTimes = tally,
PatrolTimes = tally,
ArrestTimes = tally,
FineCount = tally,
CarFineCount = 0,
LastUpdateTime = now,
},
};
// ---- 4. Mimic Database archive (伪人档案) ------------------------------------------
// Every entry stays locked until the server hands it a state: PolicePanelManager only ever
// sees what SyncSpiritPoliceJobInfo carries, and an empty UnlockFileInfoDict means
// CheckFakeInfoState(id, Unlock) answers None for all 27 rows. State 8 is
// PoliceFakeFileState.Unlock — the only value the archive renders as "unlocked"; the rows
// are authored per character (all 27 belong to Richie 15021021).
if (gameplay.PoliceFakeFilesUnlock)
{
foreach (var file in PoliceCatalog4229938.FakeFilesFor(templateId))
{
result.PoliceFakeFileInfo.UnlockFileInfoDict[file.Id] =
new GameMethods.PoliceFakeFileState4229938
{
State = PoliceFakeFileStateUnlock,
InterrogationTime = 0,
};
}
}
return result;
}
/// <summary>PoliceFakeFileState.Unlock — the archive entry is revealed ( PoliceFakeFileState enum).</summary>
internal const byte PoliceFakeFileStateUnlock = 8;
/// Hacker chain), i.e. the rank a character stands on once its promotion chain is finished.
/// Returns <paramref name="jobId"/> itself when the id is unknown or has no authored chain.
/// </summary>
private static uint TopUrbanJob4229938(uint jobId)
{
if (jobId == 0)
return JoblessUrbanJob4229938;
var chain = ClientConfigRepository.UrbanJobChain(jobId);
return chain.Count == 0 ? jobId : chain[^1].Id;
}
/// <summary>
/// The ranks to publish for one job id: either just that rank, or — when promotion is fast-
/// forwarded (<c>urbanJobRank = "top"</c>) — every rank of the career up to the top one, which is
/// what the dictionary looks like after the player has walked the whole PreJob chain.
/// Ordered low-to-high exactly like <see cref="ClientConfigRepository.UrbanJobChain"/>, so the
/// serialized frame stays deterministic.
/// ⛑️ Only app gating and the career UI read this dictionary, and both key off the shared
/// <c>JobClass</c>, so extra ranks add nothing a single rank would not already unlock — they just
/// make "已晋升到最高段位" true.
/// </summary>
private static IReadOnlyList<uint> PublishedRanks4229938(uint jobId, bool promoteTop)
{
if (!promoteTop)
return new[] { jobId };
var chain = ClientConfigRepository.UrbanJobChain(jobId);
return chain.Count == 0 ? new[] { jobId } : chain.Select(x => x.Id).ToArray();
}
/// <summary>
/// One representative rank per job <b>class</b> that gates a phone app — the whole point of the
/// "multi" grant mode. A 2026-09-20 sweep of <c>MobileMenuSGuiConfig</c> (57 phone apps) found
/// only 9 with a non-empty <c>JobClassIdList</c>; the ids below cover all of them:
/// 4=VWT(11300002) · 301=Duty Terminal <i>and</i> Mimic Database(11300003) · 401=EonBug(11300004) ·
/// 501=Cat Express(11300005) · 801=Chaos Tide(11300009) · 1201=Top Cleaning(11300014) ·
/// 1901=茶农(11300019) · 2000=重霄食谱(11300020).
/// Sorted on purpose so the serialized frame is deterministic. The last three ship as
/// <c>StartVersion V99.0.0</c> and may be filtered by the native config loader; registering them
/// costs nothing either way.
/// </summary>
private static readonly uint[] AppGatedUrbanJobIds4229938 = { 4, 301, 401, 501, 801, 1201, 1901, 2000 };
/// <summary>
/// 单个角色的「当前职业」覆盖(<c>gameplay.urbanJobCurrentOverrides</c>,key 支持逗号分隔多个
/// template id)。返回 0 = 没配。
///
/// 用途见 <see cref="GameplaySettings.UrbanJobCurrentOverrides"/>:白靖(15021039)作者给的是
/// 100「无业」,但原生斯安威斯坦属于赛车手职业 ⇒ 需要把 CurrentJob 顶成 4(初级赛车手)。
/// </summary>
private static uint CurrentUrbanJobOverride4229938(GameplaySettings gameplay, uint templateId)
{
var map = gameplay.UrbanJobCurrentOverrides;
if (map is null || map.Count == 0)
return 0;
foreach (var (key, jobId) in map)
{
if (string.IsNullOrWhiteSpace(key) || jobId == 0)
continue;
foreach (var part in key.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
if (uint.TryParse(part, out var id) && id == templateId)
return jobId;
}
return 0;
}
private static Auto.SpiritJobInfo DefaultSpiritJobInfo4229938(uint templateId, uint jobId)
{
// 0 is not a valid UrbanJobConfig id — never hand it to the client.
if (jobId == 0)
jobId = JoblessUrbanJob4229938;
var gameplay = PrivateServerConfigStore.Current.Gameplay;
var multiJob = string.Equals(gameplay.UrbanJobGrantMode, "multi", StringComparison.OrdinalIgnoreCase);
var promoteTop = string.Equals(gameplay.UrbanJobRank, "top", StringComparison.OrdinalIgnoreCase);
var overrides = OverriddenUrbanJobs4229938(gameplay, templateId);
// ★ per-character 当前职业覆盖:顺带把它塞进 overrides,这样下面「Jobless 且无 override
// 就早退」的那条短路不会把当前职业吃掉(白靖那条路正是 Jobless + 只配了当前职业)。
var currentOverride = CurrentUrbanJobOverride4229938(gameplay, templateId);
if (currentOverride != 0 && !overrides.Contains(currentOverride))
overrides.Add(currentOverride);
// Fast-forwarding promotion also moves the character onto the top rank, so CurrentJob agrees
// with the highest id we publish. Jobless stays Jobless — there is no chain above it.
var effectiveJob = jobId == JoblessUrbanJob4229938 || !promoteTop ? jobId : TopUrbanJob4229938(jobId);
var result = new Auto.SpiritJobInfo { CurrentJob = effectiveJob };
// Jobless carries no record of its own: the dictionary stays empty, which is also the only
// shape the login payload may use. Per-character overrides are the one thing that can still
// fill it, so the app exclusivity stays intact without them.
if (jobId == JoblessUrbanJob4229938 && !multiJob && overrides.Count == 0)
return result;
void Register(uint id)
{
foreach (var rank in PublishedRanks4229938(id, promoteTop))
{
if (result.AvailableJobs.ContainsKey(rank))
continue;
// Shape note lives on NewSpiritJobRecord4229938. Every rank carries the class's top
// job level, so promoting does not silently hand out a weaker level anywhere.
result.AvailableJobs[rank] = NewSpiritJobRecord4229938(rank);
}
}
// Multi: every app-gated class first (sorted), then the authored rank if its class is not
// already covered. MainPhoneUtils.CheckJobCanShow walks the whole dictionary, so nothing
// here is exclusive — the extra ranks simply add more apps.
if (multiJob)
foreach (var id in AppGatedUrbanJobIds4229938)
Register(id);
// Per-character grants first so an authored rank never shadows one the operator asked for.
foreach (var id in overrides)
Register(id);
if (jobId != JoblessUrbanJob4229938)
Register(jobId);
// ★ 当前职业覆盖。只在目标职业确实已注册时生效 —— 否则会发一个 AvailableJobs 里不存在的
// CurrentJob,客户端 SpiritJobManager 取不到 lastCfg(Jobless=100 那条注释同一个坑)。
if (currentOverride != 0 && result.AvailableJobs.ContainsKey(currentOverride))
{
result.CurrentJob = currentOverride;
}
return result;
}
/// <summary>
/// One <c>AvailableJobs</c> entry — the 7k-verified shape (donor 47-byte capture, 2026-09-20):
/// register the rank and stand on it. Career-locked phone apps gate on the job <b>class</b>, which
/// every rank of a career shares, so one rank per class carries the unlock.
/// </summary>
private static Auto.SpiritJob NewSpiritJobRecord4229938(uint id)
{
// Level is a single byte on the wire; job authoring never goes past 24, but clamp anyway.
byte level = 1;
var exp = 0u;
// Level is not free-form: SpiritJobManager.GetLevelCfg(jobClass, level) looks the pair up in
// UrbanJobLevelConfig, so only levels that table authors may be published (see the catalog).
if (string.Equals(PrivateServerConfigStore.Current.Gameplay.UrbanJobLevel,
"max", StringComparison.OrdinalIgnoreCase))
{
var jobClass = ClientConfigRepository.UrbanJob(id)?.JobClass ?? 0;
if (UrbanJobLevelCatalog4229938.TryGetTopLevel(jobClass, out var topLevel, out var topExp))
{
level = topLevel > byte.MaxValue ? byte.MaxValue : (byte)topLevel;
exp = topExp;
}
}
return new Auto.SpiritJob
{
Job = id,
Exp = exp,
Level = level,
RegisterTime = 1,
UnregisterTime = 0,
// Career talents seeded per job record too: the client's talent lock check is called with
// a job class (11300004 for Hacker), so the job dictionaries carry the same nodes as
// SpiritInfo.TalentInfo. See CareerJobTalentInfo4229938.
// every job class gates app visibility, so the talents have to follow the class too.
TalentInfo = CareerJobTalentInfo4229938(ClientConfigRepository.UrbanJob(id)?.JobClass ?? 0),
};
}
/// <summary>
/// GM 控制台(<c>GmJob*</c>)用的职业记录 —— 与 <see cref="NewSpiritJobRecord4229938"/> 完全同一
/// 形状,只是等级由命令指定而不是配置推导。
///
/// ⛑⛑ 必须走这一条路而不是自己 new 一个 <c>Auto.SpiritJob</c>:捐赠端因为没有职业天赋表,
/// 它发的记录是 <c>RegisterTime = 0</c> + <c>TalentInfo = null</c>(全零 17 字节 + null 标记)。
/// 本服有 <see cref="CareerJobTalentInfo4229938"/>,照捐赠端那样填 null 会让玩家用 GM 命令
/// 换完职业后职业天赋整片消失 —— 那是回归,不是移植。wire 形状以本服 7k 验证过的那份为准。
/// </summary>
internal static Auto.SpiritJob GmJobRecord4229938(uint jobId, byte level)
{
var record = NewSpiritJobRecord4229938(jobId);
record.Level = Math.Max((byte)1, level);
return record;
}
/// <summary>
/// Per-character jobs from <see cref="GameplaySettings.UrbanJobOverrides"/>, i.e. the escape hatch
/// that hands one character a career-locked app without switching the whole server to "multi".
/// A key may list several template ids (<c>"15020967,15021023"</c>); the values are urban job ids.
/// De-duplicated and sorted so the serialized frame stays deterministic.
/// </summary>
private static List<uint> OverriddenUrbanJobs4229938(GameplaySettings gameplay, uint templateId)
{
var result = new List<uint>();
var map = gameplay.UrbanJobOverrides;
if (map is null || map.Count == 0)
return result;
foreach (var (key, jobs) in map)
{
if (string.IsNullOrWhiteSpace(key) || jobs is null || jobs.Length == 0)
continue;
var hit = false;
foreach (var part in key.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (uint.TryParse(part, out var id) && id == templateId)
{
hit = true;
break;
}
}
if (!hit)
continue;
foreach (var job in jobs)
if (job != 0 && !result.Contains(job))
result.Add(job);
}
result.Sort();
return result;
}
/// <summary>
/// The weapon wheel one character owns: the wheel restored from the progress snapshot when the
/// player ever edited it, otherwise the authored loadout. Instances that no longer resolve in
/// the catalog degrade to an empty slot rather than throwing during login payload construction.
/// </summary>
private static IReadOnlyList<CombatWeaponDefinition?> RestoredWheel4229938(
uint templateId,
WorldEntryState? state)
{
if (state is not null &&
state.WeaponSlotsBySpirit.TryGetValue(templateId, out var saved) &&
saved.Count > 0)
{
return saved
.Select(instanceId => instanceId == 0
? null
: CombatCatalogRepository.Weapon(templateId, instanceId))
.ToArray();
}
return CombatCatalogRepository.TryLoadout(templateId)?.Slots
?? Array.Empty<CombatWeaponDefinition?>();
}
/// <summary>
/// 拨号用的通讯录条目。默认取 Configs/PhoneContacts.json 里 AutoAddFlag 的那批(49 条),
/// phoneContacts=false 时退回原来硬编码的两个叫车快拨号 —— 这两个号本来就在表里,
/// 所以开与不开都不会让叫车失效。
///
/// ⛑ 只发号码不发名字是刻意的:PhoneContact 契约只有 Remark + PhoneNumber,客户端拿号码回查
/// 本地 PhoneContactConfig 取名字头像(CallPhoneUtils.GetConfigIdByPhoneNumber),
/// 服务端填 Remark 反而会盖掉本地备注(GetContactRemark 优先用 contactInfo.Remark)。
/// </summary>
private static List<(string Remark, string Number)> BuildPhoneContacts(bool catalogueEnabled)
{
if (!catalogueEnabled)
return [(string.Empty, "142857"), (string.Empty, "206337")];
if (PhoneContactCatalog4229938.LoadError is not null)
{
Console.Error.WriteLine($"[PHONE] contact catalogue unavailable: {PhoneContactCatalog4229938.LoadError}");
return [(string.Empty, "142857"), (string.Empty, "206337")];
}
var includeHidden = PrivateServerConfigStore.Current.Gameplay.PhoneContactsIncludeHidden;
var contacts = includeHidden ? PhoneContactCatalog4229938.Contacts : PhoneContactCatalog4229938.AutoAddContacts();
if (contacts.Count == 0)
return [(string.Empty, "142857"), (string.Empty, "206337")];
var list = new List<(string, string)>(contacts.Count);
foreach (var contact in contacts)
list.Add((string.Empty, contact.PhoneNumber));
Console.WriteLine($"[LOGIN-PHONE] contacts={list.Count} hidden={includeHidden}");
return list;
}
/// <summary>
/// 拨号解锁进度:PhoneContactUnlockConfig 的行 id 列表(module 3 的 FinishedTemplateIdList)。
/// 关掉时只留 [1, 2](叫车 / 快递车),用于二分"是号码没发还是解锁没生效"。
/// </summary>
private static List<uint> BuildPhoneUnlockIds(bool unlockAll)
{
if (!unlockAll)
return [1, 2];
if (PhoneContactCatalog4229938.LoadError is null && PhoneContactCatalog4229938.UnlockIds.Count > 0)
{
Console.WriteLine($"[LOGIN-PHONE] unlockIds={PhoneContactCatalog4229938.UnlockIds.Count}");
return PhoneContactCatalog4229938.UnlockIds.ToList();
}
return [1, 2];
}
/// <summary>
/// Phone-skin suit worn by one character (SpiritInfo.MobileSkinInfo).
///
/// Falls back to the suit MobileMenuSkinPartConfig itself declares as default
/// (DefaultWallPaperCfg/DefaultDecorationCfg/DefaultPendantCfg), which is also what the client
/// substitutes when the struct is zeroed — so seeding it explicitly keeps the login payload and
/// the panel in agreement instead of leaving MainPhoneUtils on its silent fallback path.
///
/// The stored choice is replayed by GameRouter.AskSetMobileSkinPart and persisted via
/// PlayerProgressStore, so it survives a re-login.
/// </summary>
private static Auto.SpiritMobileSkinInfo WornPhoneSkin4229938(uint templateId, WorldEntryState? state)
{
if (state is not null)
{
lock (state.SyncRoot)
{
if (state.PhoneSkinBySpirit.TryGetValue(templateId, out var worn) && worn is not null)
return new Auto.SpiritMobileSkinInfo
{
Wallpaper = worn.Wallpaper,
Decoration = worn.Decoration,
Pendant = worn.Pendant,
};
}
}
return new Auto.SpiritMobileSkinInfo
{
Wallpaper = NpcCultivationCatalog4229938.DefaultWallpaperPart,
Decoration = NpcCultivationCatalog4229938.DefaultDecorationPart,
Pendant = NpcCultivationCatalog4229938.DefaultPendantPart,
};
}
internal const uint InitialAmmoReserve4229938 = 9999u;
internal static Auto.PlayerPackItem AmmoPackItem(uint templateId, uint count = InitialAmmoReserve4229938)
=> new()
{
UniqueId = 910000000000UL + templateId,
TemplateId = templateId,
Count = count,
IsNew = false,
ExpiryTime = 0,
RemindState = 0,
Quality = 0,
Tags = 0,
IsBind = false,
Components = null,
CDFinishTime = 0,
};
/// <summary>Backpack stack for an item granted through the in-game GM console (ALT+F1).</summary>
internal static Auto.PlayerPackItem GmPackItem4229938(uint templateId, uint count, bool isBind, ulong uniqueId)
=> new()
{
UniqueId = uniqueId,
TemplateId = templateId,
Count = count,
IsNew = false,
ExpiryTime = 0,
RemindState = 0,
Quality = 0,
Tags = 0,
IsBind = isBind,
Components = null,
CDFinishTime = 0,
};
/// <summary>
/// Restores the saved wallet and backpack. Login is the only place the client adopts server
/// economy state (PlayerInfoItemData.InitPlayerInfo copies Money/Gold/BindingGold into the HUD
/// wallet and CommonItemManager_Package.SetPlayerInfoItem inserts InfoItem.PackItems), so a
/// purchase/draw survives a re-login only if it is published here.
/// </summary>
private static void SeedPlayerEconomy4229938(Auto.PlayerClientInfo root, WorldEntryState? state)
{
if (state is null)
return;
List<Auto.PlayerPackItem> items;
lock (state.SyncRoot)
{
if (!state.ProgressRestored && !state.HasSessionEconomy4229938())
return;
root.InfoItem.Money = state.WalletMoney;
root.InfoItem.Gold = state.WalletGold;
root.InfoItem.BindingGold = state.WalletBindingGold;
items = state.GmBackpackItems.Values.ToList();
}
root.InfoItem.PackItems = items;
}
/// <summary>
/// 播种钓鱼(FishingGame)的玩家侧数据 —— <c>InfoMinor.FishingInfo</c>。
///
/// <para><b>为什么要有这一段</b>:捐赠文档 §7.1 把「登录帧播种 34 件渔具」列在已落地改动的
/// 第一条,而本项目此前<b>完全没有</b> —— Core 里连 <c>FishingInfo</c> 都没碰过,
/// 只做了 <c>AskFishingSpotFullSync</c> / <c>AskFishingSuccess</c> 两个 handler。
/// 结果就是客户端进钓鱼时初始化链三连 nil(<c>FishMgr.container</c> /
/// <c>EffectMgr.objPool</c> / <c>Rod.animator</c>),状态机停在 0,人物呆站着。</para>
///
/// <para><b>注意别被文档 §8 误导</b>:那里说 <c>GearDict</c> 在客户端 lua / C# 都无人读取、
/// 「塞数据零可见效果」。这条结论说的是<b>字典内容不被玩法消费</b>,
/// 不等于 <c>PlayerFishingInfo</c> 空着无所谓 —— 两者不是一回事。</para>
///
/// <para>⛑ <c>GearDict</c> 的 key 用 <b>UniqueId</b>(不是 TemplateId):结构里专门带了
/// <c>UniqueId</c> 字段,按"字典 of 实例"理解更合理,而且同一模板可能有多件(耐久不同)。
/// 这是推断,没有官方 reader 佐证 —— 若客户端按 TemplateId 查,改用 TemplateId 作 key 即可,
/// 播种数量不变。</para>
///
/// <para>只在 <c>GearDict</c> 为空时才播 ⇒ 已经从官方服拿过渔具的存档不会被覆盖。
/// 关掉开关 <c>gameplay.fishingSeedAllGear</c> 即回到原状。</para>
/// </summary>
private static void SeedFishing4229938(Auto.PlayerClientInfo root, WorldEntryState? state)
{
var fishing = state?.Fishing ?? new Auto.PlayerFishingInfo();
if (state is not null && fishing.GearDict.Count == 0)
{
var dur = (uint)Math.Max(0, Config.Gameplay.FishingGearDurability);
var ids = Config.Gameplay.FishingSeedAllGear
? FishingGearCatalog4229938.GearIds
: FishingGearCatalog4229938.FallbackGearIds;
if (ids.Count == 0) ids = FishingGearCatalog4229938.FallbackGearIds;
foreach (var gearConfigId in ids)
{
var uid = fishing.NextUniqueId++;
fishing.GearDict[uid] = new Auto.FishingGearInfo
{
UniqueId = uid,
TemplateId = gearConfigId,
RemainingDurability = dur,
};
}
Console.WriteLine($"[FISHING] seeded gears={ids.Count} durability={dur}");
}
root.InfoMinor.FishingInfo = fishing;
}
/// <summary>
/// Publishes the server-owned talent trees (天赋, T1-6) into PlayerClientInfo.GameplayTalentInfos.
///
/// This dictionary is the only talent wire with a proven shape: it rides the login frame, which
/// the client already parses today. The eight talent S2Cs (SyncGameplayTalentPoint 64859579,
/// SyncActiveGameplayTalentLayer 64743495, ...) have no contract and no verifiable client reader,
/// so they are NOT used — instead GameRouter.Talent re-pushes this whole frame through
/// SyncPlayerInfo (64682159) after every accepted activation.
///
/// ⛑️ gameplayId must be a real TalentTreeGamePlayConfig id (1..5 in stock 4229938). A donor build
/// once invented 1020 and the panel then showed no points at all, because the client keys the
/// whole view by that id and never falls back.
/// </summary>
private static void SeedPlayerTalents4229938(Auto.PlayerClientInfo root, WorldEntryState? state)
{
var settings = Config.Gameplay.Talents;
if (!settings.Enabled)
return;
var gameplayIds = TalentTreeRepository.GameplayIds;
if (gameplayIds.Count == 0)
return;
if (state is null)
{
// No session state (payload built outside world entry): publish the seed only, so the
// panel at least has points to spend instead of showing an empty tree.
foreach (var gameplayId in gameplayIds)
{
if (gameplayId != 0)
root.InfoMinor.GameplayTalentInfos[gameplayId] = new Auto.SpiritTalentInfo
{
TalentPoint = settings.PointSeed,
};
}
return;
}
lock (state.SyncRoot)
{
foreach (var gameplayId in gameplayIds)
{
if (gameplayId == 0)
continue;
var info = new Auto.SpiritTalentInfo();
if (state.GameplayTalents.TryGetValue(gameplayId, out var live))
{
info.Exp = live.Exp;
info.Level = live.Level;
info.TalentPoint = live.Point;
}
else
{
info.TalentPoint = settings.PointSeed;
}
// Whole-tree seeding underneath whatever the player activated themselves: the seed is
// the floor, live rows win on conflict because that is a real Ask activation.
if (settings.SeedAllTrees && settings.SeedTreesOnGameplay)
{
var layers = new Dictionary<uint, uint>();
foreach (var (id, layer, _) in GameplayTalentNodes4229938(gameplayId))
{
if (id != 0)
layers[id] = layer;
}
var owned = TalentTreeRepository.Trees
.Where(tree => tree.GameplayId == gameplayId)
.ToList();
if (live is not null)
{
foreach (var (talentId, layer) in live.Nodes)
{
if (talentId != 0 && layer != 0)
layers[talentId] = layer;
}
}
FillTalentNodes4229938(
info.UnlockTalentInfoDict,
layers.OrderBy(x => x.Key)
.Select(x => (x.Key, x.Value, 0u))
.ToList());
foreach (var (treeId, spent, count) in TalentTreeRecords4229938(owned))
{
info.TalentTreeRecordDict[treeId] = new Auto.TalentTreeRecord
{
SpentTalentPoint = spent,
ActivatedNodeCount = count,
};
}
}
else if (live is not null)
{
foreach (var (talentId, layer) in live.Nodes)
{
if (talentId == 0 || layer == 0)
continue;
info.UnlockTalentInfoDict[talentId] = new Auto.SpiritOrJobTalentNodeInfo
{
TalentId = talentId,
Layer = layer,
};
}
info.TalentTreeRecordDict = BuildTalentTreeRecords4229938(live);
}
root.InfoMinor.GameplayTalentInfos[gameplayId] = info;
}
}
}
/// <summary>
/// The career talent nodes (职业天赋) this server publishes as already activated.
///
/// Keyed TalentTreeTalentConfig id -&gt; layers. Defaults to the whole <c>careerTalentTreeId</c>
/// tree (99900010 = 17 nodes in stock 4229938) because nodes have prerequisites: publishing only
/// 99906204 leaves its parents 99906100 / 99906001 unmet, and anything that checks the chain
/// would then still read the node as locked. <c>careerTalentIds</c> narrows it when some nodes
/// should stay buyable in the panel.
/// </summary>
/// <returns>
/// Nodes as <c>(Id, Layer, Charge)</c>:
/// <list type="bullet">
/// <item><c>Layer</c> — what we send. The client lock test (<c>TalentTreeMgr.lua:1031</c>) is
/// <c>return talentInfo.Layer &lt;= talentCfg.LayerNum</c>, i.e. a node whose <c>LayerNum</c> is 1
/// needs <c>Layer</c> 2 to count as unlocked. With <c>careerTalentLayers = 0</c> we therefore
/// publish <c>LayerNum + 1</c> (2026-09-24: seeding the raw <c>LayerNum</c> left every vending
/// machine grey, which is exactly the off-by-one the client asks for). An explicit
/// <c>careerTalentLayers</c> is used verbatim — it must itself exceed the node's <c>LayerNum</c>.</item>
/// <item><c>Charge</c> — the number of layers actually paid for, for the per-tree accounting
/// records. One less than <c>Layer</c> for the same off-by-one, never below 1.</item>
/// </list>
/// </returns>
/// <summary>
/// Turns talent-table rows into the activation tuples the client reads. Shared by all three
/// containers; see <see cref="CareerTalentNodes4229938"/> for the Layer/Charge off-by-one.
/// Duplicate ids are dropped — 33 rows belong to several trees, and feeding one node twice would
/// only inflate the per-tree accounting records.
/// </summary>
private static List<(uint Id, uint Layer, uint Charge)> BuildTalentNodes4229938(
IEnumerable<TalentTreeRepository.TalentNode> nodes)
{
static (uint, uint, uint) Build(uint id, uint layerNum, uint overrideLayers)
{
// layerNum 0 happens in rows that omit it; treat it as one buyable layer.
var real = layerNum == 0 ? 1u : layerNum;
if (overrideLayers > 0)
return (id, overrideLayers, overrideLayers);
var layers = real + 1;
return (id, layers, real);
}
var result = new List<(uint, uint, uint)>();
var seen = new HashSet<uint>();
foreach (var node in nodes)
{
if (node.Id == 0 || !seen.Add(node.Id))
continue;
result.Add(Build(node.Id, node.LayerNum, Config.Gameplay.Talents.CareerTalentLayers));
}
return result;
}
/// <summary>
/// Nodes published into <c>GameplayTalentInfos[gameplayId]</c>: every tree whose
/// <c>TalentTreeConfig.GameplayId</c> is that id.
/// </summary>
internal static List<(uint Id, uint Layer, uint Charge)> GameplayTalentNodes4229938(uint gameplayId)
=> BuildTalentNodes4229938(TalentTreeRepository.NodesOfTrees(tree => tree.GameplayId == gameplayId));
/// <summary>
/// Nodes published into one career's <c>SpiritJob.TalentInfo</c>: every tree authored for that
/// <c>JobClassId</c>. A tree only ever shows up for characters that own the career, because the
/// client builds the dictionary from the AvailableJobs it received.
/// </summary>
internal static List<(uint Id, uint Layer, uint Charge)> JobClassTalentNodes4229938(uint jobClassId)
=> BuildTalentNodes4229938(TalentTreeRepository.NodesOfTrees(tree => tree.JobClassId == jobClassId));
/// <summary>
/// Nodes published into a character's <c>SpiritInfo.TalentInfo</c>: every tree keyed by neither
/// gameplay nor job class, further narrowed by the tree's own <c>SpiritId</c> list (Kingpin lists
/// the two protagonists; a few rows pin exactly one spirit). An empty list means "everybody".
/// </summary>
internal static List<(uint Id, uint Layer, uint Charge)> SpiritTalentNodes4229938(uint templateId)
{
var allowSpiritKeyed = Config.Gameplay.Talents.SeedSpiritIdTrees;
return BuildTalentNodes4229938(TalentTreeRepository.NodesOfTrees(tree =>
tree.GameplayId == 0
&& tree.JobClassId == 0
&& (allowSpiritKeyed || tree.SpiritIds.Count == 0)
&& (tree.SpiritIds.Count == 0 || tree.SpiritIds.Contains(templateId))));
}
internal static List<(uint Id, uint Layer, uint Charge)> CareerTalentNodes4229938()
{
var settings = Config.Gameplay.Talents;
if (!settings.Enabled || !settings.SeedCareerTalents)
return [];
// Explicit list wins so an operator can pin exactly one node without importing the tree.
if (settings.CareerTalentIds is { Length: > 0 } ids)
{
return BuildTalentNodes4229938(ids
.Where(id => id != 0)
.Select(id => TalentTreeRepository.TryTalent(id, out var node)
? node
: new TalentTreeRepository.TalentNode(id, 0, 1, 1)));
}
return BuildTalentNodes4229938(TalentTreeRepository.NodesOfTree(settings.CareerTalentTreeId));
}
/// <summary>
/// Writes one activation per node. Layer/Charge already carry the client's off-by-one, see
/// <see cref="CareerTalentNodes4229938"/>.
/// </summary>
private static void FillTalentNodes4229938(
IDictionary<uint, Auto.SpiritOrJobTalentNodeInfo> target,
IReadOnlyList<(uint Id, uint Layer, uint Charge)> nodes)
{
foreach (var (id, layer, _) in nodes)
{
target[id] = new Auto.SpiritOrJobTalentNodeInfo
{
TalentId = id,
Layer = layer,
};
}
}
/// <summary>
/// Per-tree accounting records (<c>TalentTreeRecordDict</c>), computed tree by tree.
///
/// ⛑️ Never derive the grouping from <c>TalentNode.TreeId</c>: that field is only the first row of
/// a node's <c>TalentTreeid</c> array, so the 33 shared rows would all be billed to whichever tree
/// lists them first and every other tree would under-report.
/// </summary>
private static List<(uint TreeId, uint Spent, uint Count)> TalentTreeRecords4229938(
IEnumerable<TalentTreeRepository.TalentTree> trees)
{
var records = new List<(uint, uint, uint)>();
foreach (var tree in trees)
{
var spent = 0u;
var count = 0u;
foreach (var node in TalentTreeRepository.NodesOfTree(tree.Id))
{
var charge = node.LayerNum == 0 ? 1u : node.LayerNum;
spent += node.CostPoint * charge;
count += charge;
}
if (count > 0)
records.Add((tree.Id, spent, count));
}
return records;
}
/// <summary>
/// Publishes the career talents into one character's <c>SpiritInfo.TalentInfo</c>.
///
/// This is the gate behind the greyed-out scene hack prompts — see
/// <see cref="TalentTreeSettings.SeedCareerTalents"/> for the evidence chain. The client computes
/// the lock state from this data at registration time, so it has to be in the login frame:
/// patching the lua table afterwards does nothing (verified 2026-09-24, the machines stayed grey
/// even after <c>talentLocked</c> was cleared by hand).
/// </summary>
private static Auto.SpiritTalentInfo CareerTalentInfo4229938(uint templateId)
{
var settings = Config.Gameplay.Talents;
var info = new Auto.SpiritTalentInfo { TalentPoint = settings.CareerTalentPoint };
if (!settings.Enabled || !settings.SeedCareerTalents || !settings.SeedCareerTalentsOnSpirit)
return info;
// Tree-seeding mode: publish every tree the client reads out of SpiritInfo.TalentInfo, i.e. the
// trees keyed by neither gameplay nor job class, narrowed by their own SpiritId list (Kingpin =
// the two protagonists, some rows pin exactly one spirit). Otherwise one configured tree only.
if (settings.SeedAllTrees)
{
var allowSpiritKeyed = settings.SeedSpiritIdTrees;
var owned = TalentTreeRepository.Trees
.Where(tree => tree.GameplayId == 0
&& tree.JobClassId == 0
&& (allowSpiritKeyed || tree.SpiritIds.Count == 0)
&& (tree.SpiritIds.Count == 0 || tree.SpiritIds.Contains(templateId)))
.ToList();
FillTalentNodes4229938(info.UnlockTalentInfoDict, SpiritTalentNodes4229938(templateId));
foreach (var (treeId, spent, count) in TalentTreeRecords4229938(owned))
{
info.TalentTreeRecordDict[treeId] = new Auto.TalentTreeRecord
{
SpentTalentPoint = spent,
ActivatedNodeCount = count,
};
}
return info;
}
FillTalentNodes4229938(info.UnlockTalentInfoDict, CareerTalentNodes4229938());
var legacyTreeId = settings.CareerTalentTreeId;
if (legacyTreeId != 0 && info.UnlockTalentInfoDict.Count > 0)
{
foreach (var (treeId, spent, count) in TalentTreeRecords4229938(
TalentTreeRepository.Trees.Where(tree => tree.Id == legacyTreeId)))
{
info.TalentTreeRecordDict[treeId] = new Auto.TalentTreeRecord
{
SpentTalentPoint = spent,
ActivatedNodeCount = count,
};
}
}
return info;
}
/// <summary>
/// Same nodes as <see cref="CareerTalentInfo4229938"/>, for one <c>SpiritJob.TalentInfo</c>
/// record — the career talent view is keyed by job class, and the client method that answers
/// <c>CheckTalentIsLocked(talentId, jobClassId)</c> is passed a job class (11300004 for Hacker),
/// so the job records carry the very same activation set.
/// </summary>
private static Auto.SpiritJobTalentInfo CareerJobTalentInfo4229938(uint jobClassId)
{
var settings = Config.Gameplay.Talents;
if (!settings.Enabled || !settings.SeedCareerTalents || !settings.SeedCareerTalentsOnEveryJob)
return new Auto.SpiritJobTalentInfo();
var info = new Auto.SpiritJobTalentInfo { TalentPoint = settings.CareerTalentPoint };
// Tree-seeding mode: every tree authored for this career's JobClassId, instead of one global
// tree copy. The client's dictionary is built per AvailableJobs record, so a tree only becomes
// visible for characters that actually own the career.
if (settings.SeedAllTrees)
{
if (jobClassId == 0)
return info;
var owned = TalentTreeRepository.Trees.Where(tree => tree.JobClassId == jobClassId).ToList();
FillTalentNodes4229938(info.UnlockTalentInfoDict, JobClassTalentNodes4229938(jobClassId));
foreach (var (treeId, spent, count) in TalentTreeRecords4229938(owned))
{
info.TalentTreeRecordDict[treeId] = new Auto.TalentTreeRecord
{
SpentTalentPoint = spent,
ActivatedNodeCount = count,
};
}
return info;
}
FillTalentNodes4229938(info.UnlockTalentInfoDict, CareerTalentNodes4229938());
var legacyTreeId = settings.CareerTalentTreeId;
if (legacyTreeId != 0 && info.UnlockTalentInfoDict.Count > 0)
{
foreach (var (treeId, spent, count) in TalentTreeRecords4229938(
TalentTreeRepository.Trees.Where(tree => tree.Id == legacyTreeId)))
{
info.TalentTreeRecordDict[treeId] = new Auto.TalentTreeRecord
{
SpentTalentPoint = spent,
ActivatedNodeCount = count,
};
}
}
return info;
}
/// <summary>
/// Per-tree "how much of this tree has been spent" record the panel shows next to each tree tab.
/// Derived from the activated nodes so it can never disagree with them.
/// </summary>
private static Dictionary<uint, Auto.TalentTreeRecord> BuildTalentTreeRecords4229938(
GameplayTalentState4229938 live)
{
var records = new Dictionary<uint, Auto.TalentTreeRecord>();
foreach (var (talentId, layer) in live.Nodes)
{
if (!TalentTreeRepository.TryTalent(talentId, out var node) || node.TreeId == 0)
continue;
if (!records.TryGetValue(node.TreeId, out var record))
{
record = new Auto.TalentTreeRecord();
records[node.TreeId] = record;
}
record.SpentTalentPoint += node.CostPoint * layer;
record.ActivatedNodeCount += layer;
}
return records;
}
/// <summary>
/// Ensures every configured gameplay talent tree exists in the session state, seeding the point
/// balance for trees the player has never touched. Called at login before the payload is built,
/// and lazily by the talent handlers so a request for an unseen tree still works.
/// </summary>
internal static GameplayTalentState4229938 EnsureGameplayTalentTree4229938(
WorldEntryState state, uint gameplayId)
{
var settings = Config.Gameplay.Talents;
lock (state.SyncRoot)
{
if (!state.GameplayTalents.TryGetValue(gameplayId, out var tree))
{
tree = new GameplayTalentState4229938 { Point = settings.PointSeed };
state.GameplayTalents[gameplayId] = tree;
}
else if (settings.RenewSeedOnLogin && tree.Point < settings.PointSeed)
{
tree.Point = settings.PointSeed;
}
return tree;
}
}
internal static Auto.WeaponData WeaponData(CombatWeaponDefinition weapon)
=> new()
{
TemplateId = weapon.TemplateId,
Durability = weapon.InitialClientDurability,
InstanceId = weapon.InstanceId,
EventId = 0,
ReceivedTimeStamp = 0,
// Byte 1 = discardable. See CombatCodec.WeaponOperatorFlagsDiscardable — leaving this at
// 0 makes the client treat every login-payload weapon as CantDiscard.
OperatorFlags = CombatCodec.WeaponOperatorFlagsDiscardable,
SpecialLabel = null,
SceneItemHp = 1f,
StackCount = 1,
FightStyleId = weapon.Style.Id,
MagazineAmmo = weapon.InitialMagazineAmmo,
BulletDatas = new Auto.WeaponBulletDatas { BulletId = weapon.BulletId },
BindPid = Config.Player.Pid,
IsPlayerLocked = false,
};
/// <summary>
/// The outfit one character is wearing, honouring a restored choice when there is one.
/// The wardrobe authority in the login payload is PlayerFashionsInfo.SpiritFashionsInfoDict;
/// writing the authored defaults there undresses the character on every re-login.
/// </summary>
private static IReadOnlyList<uint> WornFashionIds4229938(uint templateId, WorldEntryState? state)
{
var worn = state?.FashionBySpirit.GetValueOrDefault(templateId);
return worn is { FashionIds.Count: > 0 }
? worn.FashionIds
: ClientConfigRepository.DefaultFashionIds(templateId);
}
/// <summary>
/// 房产(House)所有权 —— 2026-09-30。
///
/// <para><b>为什么所有权只能走登录包</b>:客户端判断「这套房是不是我的」只有一条路径 ——
/// <c>gBuyHouseUtils.CheckHasBuyTheHouse(houseId)</c>
/// → <c>gPlayerManager.infoMinor.bindData.housesInfo.HouseInfoList</c>
/// → <c>PlayerInfoMinorData.lua:54</c> 的 <c>t.housesInfo = info.InfoMinor.housesInfo</c>。
/// 也就是说这份数据**完全由登录包决定**,没有任何 S2C 通道能补它
/// (<c>SyncAddHouse</c> 只能往列表里追加,读不到列表本身)。</para>
///
/// <para>以前这里一个字都没填 ⇒ 客户端认为一套房都没买。手机 app 里买完会放动画、
/// 显示「已购买」,那纯粹是 <c>BuyHouseUtils.BuyTheHouse</c> 拿到 error=0 之后的**本地乐观行为**
/// (自己 <c>SendMessage(ON_BUY_HOUSE_SUCCESS)</c> + 开 <c>HOUSE_REWARD_PANEL</c>),
/// 服务端当时既不记账也不推包,所以一走出那个面板 / 一重登 / 一开大地图,
/// 客户端重新算一次 <c>CheckHasBuyTheHouse</c> 就翻回「未购买」。</para>
///
/// <para>线格式见 <see cref="HouseOwnershipWire4229938"/>(同一份结构也用于
/// <c>SyncAddHouse</c> 推送)。</para>
/// </summary>
private static void SeedOwnedHouses4229938(Auto.PlayerClientInfo root, WorldEntryState? state)
{
if (state is null)
return;
var list = root.InfoMinor.housesInfo.HouseInfoList;
list.Clear();
// 排序只为让登录包字节稳定(便于和 packets 日志逐包对照)。
foreach (var houseId in state.Progress.OwnedHouseIds.Where(id => id != 0).OrderBy(id => id))
{
// 配置里已经没有的 houseId(手改存档 / 换 build)照样发 —— 客户端只拿它跟
// HouseConfig 对 id,多余的条目不会让它崩,但少一条就等于玩家丢一套房。
// 2026-09-30:连同该房的车库车位指派一起回填(ParkingSpaceVehicleIdDict),
// 否则重登后车库全空、按 F 驶离车库会因 ParkingVehicleUid 为空而卡死。
state.Progress.GarageParking.TryGetValue(houseId, out var parking);
list.Add(HouseOwnershipWire4229938.ToWire(houseId, parking));
}
}
private static Auto.PlayerFashionsInfo MinimalPlayerFashions4229938(WorldEntryState? state)
{
var result = new Auto.PlayerFashionsInfo
{
DefaultSpiritIsInitDefaultFashion = true,
ColoringCollectionScore = 0,
};
// Grant all selectable fashions so the wardrobe/character panel shows every skin.
// Default wear remains the character's authored defaults; players can swap in the UI.
foreach (var fashionId in ClientConfigRepository.SelectableFashionIds())
{
if (!result.FashionInfoDict.ContainsKey(fashionId))
result.FashionInfoDict[fashionId] = FashionInfo4229938(fashionId, state);
}
foreach (var character in ClientConfigRepository.Characters())
{
var worn = state?.FashionBySpirit.GetValueOrDefault(character.TemplateId);
var wornIds = WornFashionIds4229938(character.TemplateId, state);
var isRestored = worn is { FashionIds.Count: > 0 };
foreach (var fashionId in wornIds)
{
if (!result.FashionInfoDict.ContainsKey(fashionId))
result.FashionInfoDict[fashionId] = FashionInfo4229938(fashionId, state);
}
result.SpiritFashionsInfoDict[character.TemplateId] = new Auto.SpiritFashionsInfo
{
SpiritId = character.TemplateId,
SpiritWearFashionsInfo = isRestored
? WearFashions(
worn!.FashionIds,
functionSuitId: worn.FunctionSuitId,
source: worn.Source,
sourceId: worn.SourceId,
isTryWear: worn.IsTryWear,
hiddenParts: worn.HiddenParts)
: WearFashions(wornIds),
SpiritPrevWearFashionsInfo = null,
FirstGainSuitIdList = [],
ActiveClientTryWearSource = 0,
UnlockSuitSlotCount = 0,
};
}
return result;
}
/// <summary>
/// One owned fashion as the login seed publishes it. The dye half is what unlocks the wardrobe's
/// colour page: <c>UnlockColoringSlotCount = 0</c> meant every plan slot was sealed, so the page
/// had nothing to offer and the apply button did nothing.
///
/// <c>FashionColoringStore</c> replays whatever the dye handlers remembered; a fashion nobody
/// dyed gets <see cref="FashionColoringStore.DefaultSlots"/> (all eight) and no saved scheme.
/// </summary>
private static Auto.FashionInfo FashionInfo4229938(uint fashionId, WorldEntryState? state)
{
var info = new Auto.FashionInfo
{
FashionId = fashionId,
ExpiredTime = 0,
GainTime = 1,
Status = 0,
ApplyColoringSchemeId = FashionColoringStore.GetAppliedScheme(state, fashionId),
UnlockColoringSlotCount = FashionColoringStore.GetUnlockedSlots(state, fashionId),
ColoringSchemeTopNMaxCollectionScore = 0,
};
foreach (var (planId, colors) in FashionColoringStore.GetSchemes(state, fashionId))
{
info.ColoringSchemeInfoDict[planId] = new Auto.FashionColoringInfo
{
ColoringType2ColorIdDict = new Dictionary<byte, uint>(colors),
};
}
return info;
}
/// <summary>
/// Weapon skins (武器皮肤): seed every authored skin as owned, then replay whatever the player
/// actually equipped earlier.
///
/// <c>OwnedSkinIds</c> is the gate — the client's skin picker lists nothing without it, the same
/// way <c>UnlockColoringSlotCount = 0</c> sealed every dye slot. <c>SpiritWeaponSkinDict</c> is
/// the per-character "which skin is on which weapon" half; it starts empty for a fresh account
/// and the client renders the authored default skins until AskSetWeaponSkins assigns one.
/// </summary>
private static void SeedWeaponSkins4229938(Auto.PlayerClientInfo root, WorldEntryState? state)
{
// A missing/corrupt WeaponSkinConfig.json must not fail LoginGame — it degrades to "no skins
// owned", the same state the build has always shipped with.
if (WeaponSkinCatalog4229938.LoadError is not null)
return;
foreach (var skinId in WeaponSkinCatalog4229938.SkinIds)
root.InfoMinor.PlayerWeaponSkinInfo.OwnedSkinIds[skinId] = 1;
foreach (var (spiritId, skinBySceneItem) in WeaponSkinStore.Snapshot(state))
{
root.InfoMinor.PlayerWeaponSkinInfo.SpiritWeaponSkinDict[spiritId] = new Auto.SpiritWeaponSkinInfo
{
SkinDict = new Dictionary<uint, uint>(skinBySceneItem),
};
}
}
private static Auto.SpiritWearFashionsInfo WearFashions(
IReadOnlyList<uint> ids,
uint functionSuitId = 0,
short source = 0,
uint sourceId = 0,
bool isTryWear = false,
byte hiddenParts = 0)
=> new()
{
FunctionSuitId = functionSuitId,
WearSourceInfo = new Auto.WearSourceInfo { Source = source, SourceId = sourceId },
IsTryWear = isTryWear,
WearFashionInfoList = ids.Select(id => new Auto.WearFashionInfo { FashionId = id }).ToList(),
WearFashionEditInfoList = [],
HiddenParts = hiddenParts,
EditedHiddenParts = 0,
};
private static GameMethods.SpiritWearFashionsInfo WearFashionsForGameMethods(
IReadOnlyList<uint> ids,
uint functionSuitId = 0,
short source = 0,
uint sourceId = 0,
bool isTryWear = false,
byte hiddenParts = 0)
=> new()
{
FunctionSuitId = functionSuitId,
WearSourceInfo = new GameMethods.WearSourceInfo { Source = source, SourceId = sourceId },
IsTryWear = isTryWear,
WearFashionInfoList = ids.Select(id => new GameMethods.WearFashionInfo { FashionId = id }).ToList(),
WearFashionEditInfoList = [],
HiddenParts = hiddenParts,
EditedHiddenParts = 0,
};
private static Auto.UXVector3 Vector(Vec3 value)
=> new() { X = value.X, Y = value.Y, Z = value.Z };
}