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; /// /// 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. /// internal static class RuntimePayloadFactory { /// /// 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. /// internal const uint JoblessUrbanJob4229938 = 100; /// /// 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 "<player>" rows (male / female); the rest /// are named character avatars (Taffy / Garm / Bansy / ...). /// 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 "" (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() .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(), }; } // 趴卜 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(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(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(), FinishedTemplateIdList = BuildPhoneUnlockIds(Config.Gameplay.PhoneContactsUnlockAll), }; var phoneModule = new Auto.ModuleEventProgressInfo { ProgressInfoDict = new Dictionary { [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(), // PartyConfig 全表 7 条:主城(新启) 13030000/13030002/13030003、联机 13030004/13030005、 // 凌云 13030001/13030006 —— 后两条按「凌云不用管」不解锁,需要时加进来即可。 FinishedTemplateIdList = [13030000, 13030002, 13030003, 13030004, 13030005], }; var partyModule = new Auto.ModuleEventProgressInfo { ProgressInfoDict = new Dictionary { [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(), UnlockedStoryDict = new Dictionary(), }); } // 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(); computer.UnlockFiles ??= new Dictionary(); computer.UnlockEmails ??= new Dictionary(); foreach (var id in ComputerCatalog4229938.Computers) { computer.ComputerInfos[id] = new Auto.ComputerDetailInfo { CfgId = id, FirstOpenTime = activateTime, DeleteFiles = new List(), DeleteEmails = new List(), }; } 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 ":" 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; } /// /// BBChat (AppId 27 "Message") — NPC conversation history. See /// 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. /// 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. /// /// InviteChatList / DialogChatListDict stay empty on purpose: they are what the /// player can answer right now, so filling them would auto-open every conversation on login. /// private static void FillNpcChatList( List destination, IReadOnlyList 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 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 GroupsOf( IReadOnlyList 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; } } /// /// Appends one thread as NpcChatItem 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. /// private static void AppendThread( List 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); } /// /// 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. /// 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() }; /// /// ⛑⛑⛑ 主角「换皮」:把 已存在的主角单位(默认 Config.Player.InitialUnitId) /// 直接换成另一个 templateId,不新建单位。 /// /// 2026-09-25 02:0x 实测:客户端 IsMe=True 只在登录时 native CreateHero /// 建出来的那个单位上出现过(InitModules 100000000000 IsMe True + [LB] 进入场景 创建主角); /// 之后 5 次切换,无论 ExistingUnitProjection 还是 CharacterUnitProjection、 /// 无论 ManagedPid 填 0 还是 PlayerPid,客户端一律 IsMe False + [LB] 创建角色。 /// ⇒ 结论:服务端推 projection 建不出主角,只能让客户端已有的主角单位换 template。 /// 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 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; } /// /// 科斯机甲的 SyncPlayerAddNewSpirit(64691088) 载荷(移植捐赠端 7k-v0.5 RuntimePayloadFactory.cs:343-344)。 /// /// 科斯不在登录花名册里 —— FightSpiritConfig 15022030 = "特摄战原型-科斯",IsPublish=false / /// CanJoin=false / Invisible=true,所以客户端 SpiritManager 手上没有它的 SpiritViewData。 /// 不先补这条 entry,切过去时 Id_to_Tid 查不到对应 tid,包会被静默丢掉(这也是 /// GameRouter.Profiles.cs 里 ROLLBACK BASELINE 注释记的同一个坑)。 /// /// ⛑ 刻意复用本服已在用的 (每个 roster 角色都靠它 /// 下发),而不是另写一份精简结构:它的 WeaponSlots 走 , /// 科斯没有存档轮盘 ⇒ 回落到 CombatCatalogRepository.TryLoadout 那份孤立装配(slot0 = 科斯冲锋枪)。 /// internal static GameMethods.SyncPlayerAddNewSpirit KesiSpirit4229938(WorldEntryState? state) => new() { spirit = new() { SpiritInfo = MinimalActorCharacterSpirit4229938(ClientConfigRepository.Kesi, state) }, reason = 0, }; /// /// The urban-job record for one controllable character, read from /// FightSpiritConfig.DefaultUrbanJob. Data-driven on purpose: no spirit id is /// hard-coded here, so an authored job on any character shows up without touching this file. /// 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); } /// /// 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. /// internal static IReadOnlyList PublishedUrbanJobIds4229938(uint templateId) => SpiritJobInfo4229938(templateId).AvailableJobs.Keys.OrderBy(x => x).ToArray(); /// /// UrbanJobJobClassConfig 11300003 "NCCA Officer" — the police career class. The client's /// PoliceJobManager.CheckIsPoliceJob compares UrbanJobConfig.JobClass against /// UrbanJobJobClassConfig.Police; the career has three ranks, 301 / 302 / 303. /// internal const uint PoliceJobClassId4229938 = 11_300_003; /// /// True when any career the character is published with belongs to the police class. Data-driven /// on (so urbanJobOverrides works too) rather /// than on a hard-coded spirit or job id — Richie (15021021) is the only authored one today. /// internal static bool IsPoliceCharacter4229938(uint templateId) => PublishedUrbanJobIds4229938(templateId) .Any(jobId => ClientConfigRepository.UrbanJob(jobId)?.JobClass == PoliceJobClassId4229938); /// /// 层 2a / T4-9: the payload behind SyncSpiritPoliceJobInfo (64349458). Everything is /// derived from authored tables via — 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: ViolationInfos (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. /// /// PoliceFakeFileInfo 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 State (= ). See step 4 below, /// gated by gameplay.policeFakeFilesUnlock. /// 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(); 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() : new List { 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(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; } /// PoliceFakeFileState.Unlock — the archive entry is revealed ( PoliceFakeFileState enum). internal const byte PoliceFakeFileStateUnlock = 8; /// Hacker chain), i.e. the rank a character stands on once its promotion chain is finished. /// Returns itself when the id is unknown or has no authored chain. /// private static uint TopUrbanJob4229938(uint jobId) { if (jobId == 0) return JoblessUrbanJob4229938; var chain = ClientConfigRepository.UrbanJobChain(jobId); return chain.Count == 0 ? jobId : chain[^1].Id; } /// /// The ranks to publish for one job id: either just that rank, or — when promotion is fast- /// forwarded (urbanJobRank = "top") — 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 , so the /// serialized frame stays deterministic. /// ⛑️ Only app gating and the career UI read this dictionary, and both key off the shared /// JobClass, so extra ranks add nothing a single rank would not already unlock — they just /// make "已晋升到最高段位" true. /// private static IReadOnlyList 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(); } /// /// One representative rank per job class that gates a phone app — the whole point of the /// "multi" grant mode. A 2026-09-20 sweep of MobileMenuSGuiConfig (57 phone apps) found /// only 9 with a non-empty JobClassIdList; the ids below cover all of them: /// 4=VWT(11300002) · 301=Duty Terminal and 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 /// StartVersion V99.0.0 and may be filtered by the native config loader; registering them /// costs nothing either way. /// private static readonly uint[] AppGatedUrbanJobIds4229938 = { 4, 301, 401, 501, 801, 1201, 1901, 2000 }; /// /// 单个角色的「当前职业」覆盖(gameplay.urbanJobCurrentOverrides,key 支持逗号分隔多个 /// template id)。返回 0 = 没配。 /// /// 用途见 :白靖(15021039)作者给的是 /// 100「无业」,但原生斯安威斯坦属于赛车手职业 ⇒ 需要把 CurrentJob 顶成 4(初级赛车手)。 /// 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; } /// /// One AvailableJobs 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 class, which /// every rank of a career shares, so one rank per class carries the unlock. /// 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), }; } /// /// GM 控制台(GmJob*)用的职业记录 —— 与 完全同一 /// 形状,只是等级由命令指定而不是配置推导。 /// /// ⛑⛑ 必须走这一条路而不是自己 new 一个 Auto.SpiritJob:捐赠端因为没有职业天赋表, /// 它发的记录是 RegisterTime = 0 + TalentInfo = null(全零 17 字节 + null 标记)。 /// 本服有 ,照捐赠端那样填 null 会让玩家用 GM 命令 /// 换完职业后职业天赋整片消失 —— 那是回归,不是移植。wire 形状以本服 7k 验证过的那份为准。 /// internal static Auto.SpiritJob GmJobRecord4229938(uint jobId, byte level) { var record = NewSpiritJobRecord4229938(jobId); record.Level = Math.Max((byte)1, level); return record; } /// /// Per-character jobs from , 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 ("15020967,15021023"); the values are urban job ids. /// De-duplicated and sorted so the serialized frame stays deterministic. /// private static List OverriddenUrbanJobs4229938(GameplaySettings gameplay, uint templateId) { var result = new List(); 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; } /// /// 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. /// private static IReadOnlyList 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(); } /// /// 拨号用的通讯录条目。默认取 Configs/PhoneContacts.json 里 AutoAddFlag 的那批(49 条), /// phoneContacts=false 时退回原来硬编码的两个叫车快拨号 —— 这两个号本来就在表里, /// 所以开与不开都不会让叫车失效。 /// /// ⛑ 只发号码不发名字是刻意的:PhoneContact 契约只有 Remark + PhoneNumber,客户端拿号码回查 /// 本地 PhoneContactConfig 取名字头像(CallPhoneUtils.GetConfigIdByPhoneNumber), /// 服务端填 Remark 反而会盖掉本地备注(GetContactRemark 优先用 contactInfo.Remark)。 /// 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; } /// /// 拨号解锁进度:PhoneContactUnlockConfig 的行 id 列表(module 3 的 FinishedTemplateIdList)。 /// 关掉时只留 [1, 2](叫车 / 快递车),用于二分"是号码没发还是解锁没生效"。 /// private static List 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]; } /// /// 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. /// 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, }; /// Backpack stack for an item granted through the in-game GM console (ALT+F1). 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, }; /// /// 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. /// private static void SeedPlayerEconomy4229938(Auto.PlayerClientInfo root, WorldEntryState? state) { if (state is null) return; List 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; } /// /// 播种钓鱼(FishingGame)的玩家侧数据 —— InfoMinor.FishingInfo。 /// /// 为什么要有这一段:捐赠文档 §7.1 把「登录帧播种 34 件渔具」列在已落地改动的 /// 第一条,而本项目此前完全没有 —— Core 里连 FishingInfo 都没碰过, /// 只做了 AskFishingSpotFullSync / AskFishingSuccess 两个 handler。 /// 结果就是客户端进钓鱼时初始化链三连 nil(FishMgr.container / /// EffectMgr.objPool / Rod.animator),状态机停在 0,人物呆站着。 /// /// 注意别被文档 §8 误导:那里说 GearDict 在客户端 lua / C# 都无人读取、 /// 「塞数据零可见效果」。这条结论说的是字典内容不被玩法消费, /// 不等于 PlayerFishingInfo 空着无所谓 —— 两者不是一回事。 /// /// ⛑ GearDict 的 key 用 UniqueId(不是 TemplateId):结构里专门带了 /// UniqueId 字段,按"字典 of 实例"理解更合理,而且同一模板可能有多件(耐久不同)。 /// 这是推断,没有官方 reader 佐证 —— 若客户端按 TemplateId 查,改用 TemplateId 作 key 即可, /// 播种数量不变。 /// /// 只在 GearDict 为空时才播 ⇒ 已经从官方服拿过渔具的存档不会被覆盖。 /// 关掉开关 gameplay.fishingSeedAllGear 即回到原状。 /// 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; } /// /// 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. /// 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(); 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; } } } /// /// The career talent nodes (职业天赋) this server publishes as already activated. /// /// Keyed TalentTreeTalentConfig id -> layers. Defaults to the whole careerTalentTreeId /// 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. careerTalentIds narrows it when some nodes /// should stay buyable in the panel. /// /// /// Nodes as (Id, Layer, Charge): /// /// Layer — what we send. The client lock test (TalentTreeMgr.lua:1031) is /// return talentInfo.Layer <= talentCfg.LayerNum, i.e. a node whose LayerNum is 1 /// needs Layer 2 to count as unlocked. With careerTalentLayers = 0 we therefore /// publish LayerNum + 1 (2026-09-24: seeding the raw LayerNum left every vending /// machine grey, which is exactly the off-by-one the client asks for). An explicit /// careerTalentLayers is used verbatim — it must itself exceed the node's LayerNum. /// Charge — the number of layers actually paid for, for the per-tree accounting /// records. One less than Layer for the same off-by-one, never below 1. /// /// /// /// Turns talent-table rows into the activation tuples the client reads. Shared by all three /// containers; see 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. /// private static List<(uint Id, uint Layer, uint Charge)> BuildTalentNodes4229938( IEnumerable 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(); 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; } /// /// Nodes published into GameplayTalentInfos[gameplayId]: every tree whose /// TalentTreeConfig.GameplayId is that id. /// internal static List<(uint Id, uint Layer, uint Charge)> GameplayTalentNodes4229938(uint gameplayId) => BuildTalentNodes4229938(TalentTreeRepository.NodesOfTrees(tree => tree.GameplayId == gameplayId)); /// /// Nodes published into one career's SpiritJob.TalentInfo: every tree authored for that /// JobClassId. A tree only ever shows up for characters that own the career, because the /// client builds the dictionary from the AvailableJobs it received. /// internal static List<(uint Id, uint Layer, uint Charge)> JobClassTalentNodes4229938(uint jobClassId) => BuildTalentNodes4229938(TalentTreeRepository.NodesOfTrees(tree => tree.JobClassId == jobClassId)); /// /// Nodes published into a character's SpiritInfo.TalentInfo: every tree keyed by neither /// gameplay nor job class, further narrowed by the tree's own SpiritId list (Kingpin lists /// the two protagonists; a few rows pin exactly one spirit). An empty list means "everybody". /// 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)); } /// /// Writes one activation per node. Layer/Charge already carry the client's off-by-one, see /// . /// private static void FillTalentNodes4229938( IDictionary target, IReadOnlyList<(uint Id, uint Layer, uint Charge)> nodes) { foreach (var (id, layer, _) in nodes) { target[id] = new Auto.SpiritOrJobTalentNodeInfo { TalentId = id, Layer = layer, }; } } /// /// Per-tree accounting records (TalentTreeRecordDict), computed tree by tree. /// /// ⛑️ Never derive the grouping from TalentNode.TreeId: that field is only the first row of /// a node's TalentTreeid array, so the 33 shared rows would all be billed to whichever tree /// lists them first and every other tree would under-report. /// private static List<(uint TreeId, uint Spent, uint Count)> TalentTreeRecords4229938( IEnumerable 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; } /// /// Publishes the career talents into one character's SpiritInfo.TalentInfo. /// /// This is the gate behind the greyed-out scene hack prompts — see /// 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 talentLocked was cleared by hand). /// 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; } /// /// Same nodes as , for one SpiritJob.TalentInfo /// record — the career talent view is keyed by job class, and the client method that answers /// CheckTalentIsLocked(talentId, jobClassId) is passed a job class (11300004 for Hacker), /// so the job records carry the very same activation set. /// 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; } /// /// 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. /// private static Dictionary BuildTalentTreeRecords4229938( GameplayTalentState4229938 live) { var records = new Dictionary(); 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; } /// /// 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. /// 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, }; /// /// 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. /// private static IReadOnlyList WornFashionIds4229938(uint templateId, WorldEntryState? state) { var worn = state?.FashionBySpirit.GetValueOrDefault(templateId); return worn is { FashionIds.Count: > 0 } ? worn.FashionIds : ClientConfigRepository.DefaultFashionIds(templateId); } /// /// 房产(House)所有权 —— 2026-09-30。 /// /// 为什么所有权只能走登录包:客户端判断「这套房是不是我的」只有一条路径 —— /// gBuyHouseUtils.CheckHasBuyTheHouse(houseId) /// → gPlayerManager.infoMinor.bindData.housesInfo.HouseInfoList /// → PlayerInfoMinorData.lua:54 的 t.housesInfo = info.InfoMinor.housesInfo。 /// 也就是说这份数据**完全由登录包决定**,没有任何 S2C 通道能补它 /// (SyncAddHouse 只能往列表里追加,读不到列表本身)。 /// /// 以前这里一个字都没填 ⇒ 客户端认为一套房都没买。手机 app 里买完会放动画、 /// 显示「已购买」,那纯粹是 BuyHouseUtils.BuyTheHouse 拿到 error=0 之后的**本地乐观行为** /// (自己 SendMessage(ON_BUY_HOUSE_SUCCESS) + 开 HOUSE_REWARD_PANEL), /// 服务端当时既不记账也不推包,所以一走出那个面板 / 一重登 / 一开大地图, /// 客户端重新算一次 CheckHasBuyTheHouse 就翻回「未购买」。 /// /// 线格式见 (同一份结构也用于 /// SyncAddHouse 推送)。 /// 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; } /// /// One owned fashion as the login seed publishes it. The dye half is what unlocks the wardrobe's /// colour page: UnlockColoringSlotCount = 0 meant every plan slot was sealed, so the page /// had nothing to offer and the apply button did nothing. /// /// FashionColoringStore replays whatever the dye handlers remembered; a fashion nobody /// dyed gets (all eight) and no saved scheme. /// 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(colors), }; } return info; } /// /// Weapon skins (武器皮肤): seed every authored skin as owned, then replay whatever the player /// actually equipped earlier. /// /// OwnedSkinIds is the gate — the client's skin picker lists nothing without it, the same /// way UnlockColoringSlotCount = 0 sealed every dye slot. SpiritWeaponSkinDict 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. /// 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(skinBySceneItem), }; } } private static Auto.SpiritWearFashionsInfo WearFashions( IReadOnlyList 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 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 }; }