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 };
}