Initial commit: Ananta CBT 4229938 private server with all fixes

This commit is contained in:
CAtlas0784 committed 2026-09-15 18:13:46 +07:00
commit c84135c357
286 files changed
+2214315

No files matched your search

@@ -0,0 +1,12 @@
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>
/// Identifies the client ABI implemented by this protocol folder.
/// This is not a private-server setting: supporting another build requires another protocol implementation.
/// Trunk 4229938/4221361/4226719 keeps the 4229938 RPC ids verbatim (verified against the
/// 4229938 ServerMessageProcId dump), so the protocol folder continues to serve it.
/// </summary>
internal static class ClientBuild
{
internal const int Version = 4229938;
}
@@ -0,0 +1,236 @@
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>
/// Typed factories for combat RPCs. Weapon/style/skill values come from the extracted build-4229938
/// client tables; private-server.json only supplies private-server balance and identity values.
/// </summary>
internal static class CombatCodec
{
private static CombatSettings Settings => PrivateServerConfigStore.Current.Gameplay.Combat;
internal static float MaxHp => Settings.MaxHp;
internal static uint UltimateResourceId => Settings.Resource.Id;
internal static float UltimateResourceMax
=> CombatCatalogRepository.ResourceMaximum(UltimateResourceId, Settings.Resource.Max);
internal static IReadOnlyDictionary<uint, float> AllResourceMaximums
=> CombatCatalogRepository.ResourceMaximums;
internal static CombatLoadout Loadout(uint templateId)
=> CombatCatalogRepository.Loadout(templateId);
internal static CombatWeaponDefinition DefaultWeapon(uint templateId)
=> Loadout(templateId).DefaultWeapon;
internal static CombatWeaponDefinition? Weapon(uint templateId, ulong instanceId)
=> CombatCatalogRepository.Weapon(templateId, instanceId);
internal static CombatWeaponDefinition? WeaponAt(uint templateId, int index)
=> Loadout(templateId).WeaponAt(index);
internal static SceneMethods.PlayerFightStyleUnlockChangeInfo FightStyleUnlock()
{
var values = CombatCatalogRepository.UnlockedStyleIds.ToDictionary(x => x, _ => true);
return new SceneMethods.PlayerFightStyleUnlockChangeInfo
{
playerInfoFightStyle = new SceneMethods.PlayerInfoFightStyle
{
fightStyles = values.ToDictionary(x => x.Key, x => x.Value)
},
addOrUpdateUnlockInfo = values
};
}
internal static SceneMethods.SpiritFightTypeChangeAction FightStyleAction(uint templateId)
=> FightStyleAction(templateId, Array.Empty<KeyValuePair<uint, uint>>());
internal static SceneMethods.SpiritFightTypeChangeAction FightStyleAction(
uint templateId,
IEnumerable<KeyValuePair<uint, uint>> overrides)
{
var styleByType = Loadout(templateId).StyleByType.ToDictionary(x => x.Key, x => x.Value);
foreach (var (typeId, styleId) in overrides)
styleByType[typeId] = styleId;
return new SceneMethods.SpiritFightTypeChangeAction
{
templateId = templateId,
fullInfo = new SceneMethods.SpiritFightStyleInfo
{
fightStyleInfo = styleByType.ToDictionary(x => x.Key, x => x.Value)
},
addOrUpdateInfo = styleByType
};
}
internal static SceneMethods.SyncChangeSkill CommonBinding(ulong unitId, CombatStyleDefinition style)
=> WorldCodec.SkillBinding(unitId, style.CommonSkill);
internal static SceneMethods.SyncChangeSkill HeavyAttackBinding(ulong unitId, CombatStyleDefinition style)
=> WorldCodec.SkillBinding(unitId, style.HeavyCommonSkill);
internal static SceneMethods.SyncChangeSkill DodgeBinding(ulong unitId, CombatStyleDefinition style)
=> WorldCodec.SkillBinding(unitId, style.DodgeSkill);
internal static SceneMethods.SyncChangeSkill ControlBinding(ulong unitId, CombatStyleDefinition style)
=> WorldCodec.SkillBinding(unitId, style.ControlSkill);
internal static SceneMethods.SyncChangeSkill ActiveBinding(
ulong unitId,
CombatWeaponDefinition weapon,
CombatStyleDefinition style)
=> WorldCodec.SkillBinding(unitId, weapon.ActiveSkill(style));
internal static SceneMethods.SyncChangeSkill UniqueBinding(
ulong unitId,
CombatWeaponDefinition weapon,
CombatStyleDefinition style)
=> WorldCodec.SkillBinding(unitId, weapon.UniqueSkill(style));
internal static SceneMethods.SyncPlayerAllSkillChargeData AllSkillCharges(
ulong unitId,
CombatWeaponDefinition weapon,
CombatStyleDefinition style)
{
var body = new SceneMethods.SyncPlayerAllSkillChargeData { unitId = unitId };
void Full(uint skillId)
{
if (skillId == 0 || body.charges.ContainsKey(skillId))
return;
var period = weapon.Cooldown(style, skillId, 0f);
var maximum = weapon.MaximumCharges(style, skillId);
// A zero Cooldown means the skill is input/animation driven, not charge-count driven.
// Publishing ChargeData for such skills makes the client consume an invented stock and
// interferes with click/hold/release transitions in SkillJumpConfig.
if (period <= 0f || maximum == 0)
return;
body.charges[skillId] = new SceneMethods.ChargeData
{
current = maximum,
readyRatio = 1f,
period = period,
max = maximum,
timestamp = 0d
};
}
foreach (var skillId in weapon.EffectiveSkillIds(style))
Full(skillId);
Full(weapon.ActiveSkill(style));
Full(weapon.UniqueSkill(style));
return body;
}
internal static SceneMethods.SyncAttachBattleModule AttachBattleModule(ulong unitId)
=> new() { unitId = unitId };
internal static SceneMethods.SyncFightResource FightResource(ulong unitId, uint resourceId, float value)
=> new() { unitId = unitId, resourceId = resourceId, value = value };
internal static SceneMethods.SyncFightResourceFreeState FightResourceFreeState(ulong unitId, uint resourceId, bool isFree)
=> new() { unitId = unitId, resourceId = resourceId, isFree = isFree };
internal static SceneMethods.SyncUnitHp UnitHp(ulong unitId, float hp)
=> new() { unitId = unitId, hp = hp };
internal static SceneMethods.SyncSpiritUnitUrbanAttrs UrbanAttrs(ulong unitId)
=> new() { unitId = unitId, abilities = Settings.DefaultUrbanAbilities.ToList() };
internal static SceneMethods.SyncUnitAttrs UnitAttrs(ulong unitId, float maxHp)
=> new()
{
unitId = unitId,
attrs = new Dictionary<uint, float>
{
[1] = maxHp,
[2] = Settings.Attack,
[7] = Settings.Defense,
[10] = Settings.MoveSpeedMultiplier
}
};
internal static SceneMethods.SyncSpiritLastUsedWeapon SpiritLastUsedWeapon(uint templateId, ulong weaponInstanceId)
=> new() { templateId = templateId, weaponInstanceId = weaponInstanceId };
internal static SceneMethods.SpiritSwitchWeaponAction SpiritSwitchWeapon(ulong unitId, ulong weaponInstanceId)
=> new() { spiritUid = unitId, weaponInstanceId = weaponInstanceId, reason = 0 };
internal static IReadOnlyList<Auto.WeaponData> ArmoryWeapons()
=> CombatCatalogRepository.AccountWeapons.Select(RuntimePayloadFactory.WeaponData).ToArray();
internal static SceneMethods.SpiritWeaponDetail SpiritWeaponSnapshot(
ulong unitId,
uint templateId,
ulong currentWeaponInstanceId,
IReadOnlyList<CombatWeaponDefinition?>? slotWeapons = null)
{
var loadout = Loadout(templateId);
slotWeapons ??= loadout.Slots;
if (!slotWeapons.Any(x => x?.InstanceId == currentWeaponInstanceId))
currentWeaponInstanceId = slotWeapons.FirstOrDefault(x => x is not null)?.InstanceId ?? loadout.DefaultWeapon.InstanceId;
return new SceneMethods.SpiritWeaponDetail
{
SpiritTid = templateId,
SpiritUid = unitId,
CurrentWeaponUid = currentWeaponInstanceId,
WeaponSlots = slotWeapons.Select(weapon => weapon is null ? null : WeaponDetail(weapon)).ToList(),
CurrentTempWeapon = null,
TempWeaponSlots = null,
VirtualWeaponSlots = []
};
}
private static SceneMethods.WeaponDetail WeaponDetail(CombatWeaponDefinition weapon)
=> new()
{
SourceAgentSpoonId = 0,
SourceAgentId = 0,
SourceSceneItemId = 0,
IsLocked = false,
SourceSceneItemTaskUId = 0,
TemplateId = weapon.TemplateId,
Durability = weapon.InitialClientDurability,
InstanceId = weapon.InstanceId,
EventId = 0,
ReceivedTimeStamp = 0d,
OperatorFlags = 0,
SpecialLabel = null,
SceneItemHp = 1f,
StackCount = 1,
FightStyleId = weapon.Style.Id,
MagazineAmmo = weapon.InitialMagazineAmmo,
BulletDatas = new Auto.WeaponBulletDatas { BulletId = weapon.BulletId },
BindPid = PrivateServerConfigStore.Current.Player.Pid,
IsPlayerLocked = false
};
internal static SceneMethods.SpiritWeaponDurabilityChangedAction WeaponDurabilityChanged(
uint spiritTemplateId, ulong spiritUnitId, CombatWeaponDefinition weapon, int durability, int magazineAmmo, uint bulletId)
=> new()
{
SpiritTid = spiritTemplateId,
SpiritUid = spiritUnitId,
WeaponInstanceId = weapon.InstanceId,
Durability = durability,
MagazineAmmo = magazineAmmo,
CurrentBulletId = bulletId,
StackCount = 1,
};
internal static bool IsKnownSkill(
uint templateId,
ulong weaponInstanceId,
uint skillId)
{
var weapon = Weapon(templateId, weaponInstanceId) ?? DefaultWeapon(templateId);
return weapon.AllowsSkill(weapon.Style, skillId);
}
internal static SceneMethods.SyncBreakSkill BreakSkill(ulong unitId)
=> new() { unitId = unitId };
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,106 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using LoginMethods = Ananta.Server.RpcTypes.Client4229938.Methods.LoginGate;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>
/// Typed login/gate payload factories. No serializer calls live here: handlers pass these DTOs to ReturnAsync/NotifyAsync.
/// </summary>
internal static class LoginCodec
{
internal static LoginMethods.PatchCheckResult PatchCheck()
// NOTE (2026-09-09): advertising patchVersion=1 loops the client
// (it polls RequestPatchesCheckDataFromLogin until patch DATA arrives
// via RequestPatchesFromLogin, whose payload format is still unknown).
// Keep 0 until the real patch-data response is reverse-engineered.
=> new();
internal static LoginMethods.CheckAccountResult CheckAccount()
{
var deviceId = Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(Profile.AccountId)));
// Do not use an anonymous object here for device-id aliases.
// System.Text.Json treats property names case-insensitively while building
// anonymous-type metadata, so having both `deviceid` and `deviceId` makes
// CheckAccount throw before a response body can be serialized.
// A dictionary keeps the exact wire keys and avoids that collision.
var loginJson = JsonSerializer.Serialize(new Dictionary<string, object?>
{
["deviceid"] = deviceId,
["device_id"] = deviceId,
["udid"] = deviceId,
["unisdk_device_id"] = deviceId,
["code"] = 200,
["subcode"] = 0,
["msg"] = "ok",
["uid"] = Profile.AccountId,
["sdkuid"] = Profile.AccountId,
["user_id"] = Profile.AccountId,
["aid"] = Profile.Aid,
["pid"] = Profile.PlayerPid,
["player_id"] = Profile.PlayerPid.ToString(),
["role_id"] = Profile.PlayerPid.ToString(),
["role_name"] = Profile.DisplayName,
["server_id"] = Profile.ServerId.ToString(),
["host_id"] = Profile.ServerId,
["username"] = Profile.UserName,
["account"] = Profile.AccountId,
["platform"] = "pc",
["token"] = Profile.LoginToken,
["access_token"] = Profile.LoginToken,
["ext_access_token"] = Profile.LoginToken,
["sessionid"] = Profile.LoginToken,
["login_ticket"] = Profile.LoginToken,
["realname_status"] = 1,
["realname_verify_status"] = 1,
["mobile_bind_status"] = 1
});
return new LoginMethods.CheckAccountResult
{
unisdk_login_json = loginJson,
Token = Profile.LoginToken,
UserName = Profile.UserName,
Aid = Profile.Aid,
NeedRealNameTip = false,
NeedRoleEnter = false,
RealNameVerified = true,
HostId = Profile.ServerId,
code = 200,
subcode = 0,
msg = "ok"
};
}
internal static LoginMethods.EnterGameResult EnterGame(string gateHost, int gatePort)
=> new()
{
aid = Profile.Aid,
playerPid = Profile.PlayerPid,
gate = new LoginMethods.GateServerInfo
{
aid = Profile.Aid,
playerPid = Profile.PlayerPid,
host = gateHost,
port = gatePort,
loginToken = Profile.LoginToken,
serverId = Profile.ServerId,
accountId = Profile.AccountId
}
};
internal static GameMethods.ServerTime ServerTime(double? clientUnixTime = null)
{
var serverUnixTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
return new GameMethods.ServerTime
{
clientUnixTime = clientUnixTime ?? serverUnixTime,
serverUnixTime = serverUnixTime
};
}
internal static LoginMethods.GameServerInfo GameServerInfo(string host, int port)
=> new() { host = host, port = port, gameToken = Profile.GameToken };
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,34 @@
using Ananta.Server.Configuration;
using Ananta.Server.RpcTypes.Client4229938;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>
/// Compatibility facade used by the build-4229938 codecs.
/// Editable values come from config/private-server.json; do not add new gameplay settings here.
/// </summary>
internal static class Profile
{
private static PrivateServerConfig Config => PrivateServerConfigStore.Current;
internal static int ClientVersion => Config.Client.Version;
internal static int Aid => Config.Client.Aid;
internal static ulong PlayerPid => Config.Player.Pid;
internal static ulong InitialUnitId => Config.Player.InitialUnitId;
internal static uint InitialSpiritTemplateId => Config.Player.InitialSpiritTemplateId;
internal static uint RaidId => Config.World.RaidId;
internal static ulong SceneInstanceId => Config.World.SceneInstanceId;
internal static uint UniverseId => Config.World.UniverseId;
internal static int ServerId => Config.Client.ServerId;
internal static string AccountId => Config.Player.AccountId;
internal static string UserName => Config.Player.UserName;
internal static string DisplayName => Config.Player.DisplayName;
internal static string LoginToken => Config.Player.LoginToken;
internal static string GameToken => Config.Player.GameToken;
internal static bool WorldEntryOpeningEnabled => Config.World.EntryOpeningEnabled;
internal static Vec3 WorldSpawn => Config.World.Spawn.ToVec3();
internal static float WorldFacing => Config.World.Facing;
}
@@ -0,0 +1,450 @@
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.RpcTypes.Client4229938;
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>
/// Builds the large build-4229938 RPC payloads directly from human config + extracted client JSON.
/// Nothing here reads generated .bin files: every packet is a normal RpcTypes object serialized by UxSerializer.
/// </summary>
internal static class RuntimePayloadFactory
{
private static PrivateServerConfig Config => PrivateServerConfigStore.Current;
internal static Auto.PlayerClientInfo MinimalPlayerInfo4229938()
{
// Keep only identity plus the actor-facing roster required to materialize the controlled
// spirit. Economy/social/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;
// 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.
var spirits = ClientConfigRepository.Characters().Select(MinimalActorCharacterSpirit4229938).ToList();
root.InfoSpirit.Spirits = spirits;
root.InfoSpirit.ActiveSpirit = Config.Player.InitialSpiritTemplateId;
root.InfoSpirit.InfoPokemon.FastFightSquad = [Config.Player.InitialUnitId];
// Unlock all 670 weapons across the entire armory for all characters
root.InfoSpirit.InfoArmory.Weapons = CombatCodec.ArmoryWeapons().ToList();
// Unlock all fight styles and martial arts
foreach (var styleId in CombatCatalogRepository.UnlockedStyleIds)
root.InfoSpirit.InfoFightStyle.FightStyleIsUnLocked[styleId] = true;
// Install all phone apps (Camera, Time, Settings, etc.)
root.InfoSpirit.InstalledApps = ClientConfigRepository.InstalledPhoneAppIds().ToList();
// Unlock all phone customizations / styles (wallpapers, decorations, pendants)
root.InfoSpirit.AvailableSkinParts = Enumerable.Range(1, 200).Select(i => (uint)i).ToList();
root.InfoMinor.Level = 1;
root.InfoMinor.MatchInfo.DeviceLevel = 1;
root.InfoMinor.MatchInfo.CurLinkDeviceLevel = 1;
// Provide the vehicle fleet so the client-side DriveManager recognises available vehicles.
if (Config.Gameplay.Vehicles.Enabled && Config.Gameplay.Vehicles.FleetIds.Length > 0)
{
root.InfoMinor.VehicleInfo = new Auto.PlayerVehicleInfo
{
UnlockedVehicles = Config.Gameplay.Vehicles.FleetIds.Select(fleetId => new Auto.PlayerVehicleDetail
{
Id = fleetId,
UnlockTime = 1,
SuitId = 0,
Parts = [],
}).ToList(),
RequisitionVehicleCount = Config.Gameplay.Vehicles.FleetIds.Length,
ParkingVehicleId = 0,
};
}
// Seed phone contacts so the quick-call summon contacts exist without progression.
// The client matches contacts to PhoneContactConfig by phone number:
// 142857 = "Call vehicle" (owned-car list -> AskSummonVehicle),
// 206337 = "Express Depot" (milk/taxi car).
var summonContacts = new (string Remark, string Number)[]
{
(string.Empty, "142857"),
(string.Empty, "206337"),
};
foreach (var character in ClientConfigRepository.Characters())
{
root.InfoMinor.PlayerPhoneInfo.SpiritPhoneInfos[character.TemplateId] = new Auto.PhoneInfos
{
ContactList = summonContacts.Select(c => new Auto.PhoneContact
{
Remark = c.Remark,
PhoneNumber = c.Number,
}).ToList(),
ContactGroupList = [],
CallRecordList = [],
ContactOutgoingCallTimesDict = new Dictionary<string, uint>(),
};
}
// Event-condition progress so the quick-call contacts count as unlocked.
// Module 3 = UX.Game.EventConditionImplModule.PhoneContact (IL2CPP metadata
// declaration order); finished ids 1/2 = PhoneContactUnlockConfig rows for
// contacts 111111111 (Call vehicle) / 111111112 (Express Depot).
// Without them the client plays LockDialogId (busy) instead of the summon dialog.
var phoneProgress = new Auto.ModuleEventProgressInfoBySpirit
{
EventProgressInfoDict = new Dictionary<uint, Auto.EventProgressInfo>(),
FinishedTemplateIdList = [1, 2],
};
var phoneModule = new Auto.ModuleEventProgressInfo
{
ProgressInfoDict = new Dictionary<uint, Auto.ModuleEventProgressInfoBySpirit>
{
[0] = phoneProgress,
},
};
foreach (var character in ClientConfigRepository.Characters())
phoneModule.ProgressInfoDict[character.TemplateId] = phoneProgress;
root.InfoMinor.ModuleEventProgressInfoDict[3] = phoneModule;
// 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();
// Unlock all character poses and interaction actions
for (uint i = 1; i <= 200; i++)
{
root.InfoMinor.PlayerInteractionActionInfo.UnlockActionItemDict[i] = new Auto.PlayerInteractionActionItem
{
CfgId = i,
UnlockTime = 1,
ShowRedPoint = false,
};
}
return root;
}
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)
{
// 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).
var projection = CharacterUnitProjection(
unitId, templateId, position, facing, ClientConfigRepository.DefaultFashionIds(templateId));
projection.ManagedPid = 0;
return projection;
}
internal static Auto.RaidBattleUnitSpirit CharacterUnitProjection(
uint templateId,
Vec3 position,
float facing)
{
var character = ClientConfigRepository.Characters().FirstOrDefault(x => x.TemplateId == templateId)
?? throw new InvalidOperationException($"Character {templateId} is not present in FightSpiritConfig roster.");
return CharacterUnitProjection(
character.UnitId,
character.TemplateId,
position,
facing,
ClientConfigRepository.DefaultFashionIds(templateId));
}
internal static GameMethods.SyncSetSpiritFashions CharacterFashions(uint templateId)
{
var wear = WearFashionsForGameMethods(ClientConfigRepository.DefaultFashionIds(templateId));
return new GameMethods.SyncSetSpiritFashions
{
spiritId = templateId,
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()
};
private static Auto.RaidBattleUnitSpirit CharacterUnitProjection(
ulong unitId,
uint templateId,
Vec3 position,
float facing,
IReadOnlyList<uint> fashionIds)
{
return new Auto.RaidBattleUnitSpirit
{
SpiritWearFashionsInfo = new Auto.OtherPlayerSpiritWearFashionsInfo
{
WearFashionColoringInfoDict = new Dictionary<uint, Auto.FashionColoringInfo>(),
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)
{
var loadout = CombatCatalogRepository.Loadout(character.TemplateId);
var spirit = new Auto.SpiritInfo
{
Id = character.UnitId,
TemplateId = character.TemplateId,
PossessTime = 1,
HpRate = 1f,
EverSwitched = character.TemplateId != Config.Player.InitialSpiritTemplateId,
Blocked = false,
LastUsedWeaponInstanceId = loadout.DefaultWeapon.InstanceId,
WeaponSlots = loadout.Slots
.Select(weapon => weapon is null ? null! : WeaponData(weapon))
.ToList(),
};
// 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;
}
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,
};
internal static Auto.WeaponData WeaponData(CombatWeaponDefinition weapon)
=> new()
{
TemplateId = weapon.TemplateId,
Durability = weapon.InitialClientDurability,
InstanceId = weapon.InstanceId,
EventId = 0,
ReceivedTimeStamp = 0,
OperatorFlags = 0,
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,
};
private static Auto.PlayerFashionsInfo MinimalPlayerFashions4229938()
{
var result = new Auto.PlayerFashionsInfo
{
DefaultSpiritIsInitDefaultFashion = true,
ColoringCollectionScore = 100,
};
foreach (var fashionId in ClientConfigRepository.SelectableFashionIds())
{
result.FashionInfoDict[fashionId] = new Auto.FashionInfo
{
FashionId = fashionId,
ExpiredTime = 0,
GainTime = 1,
Status = 1,
ApplyColoringSchemeId = 0,
UnlockColoringSlotCount = 10,
ColoringSchemeTopNMaxCollectionScore = 100,
OwnedCount = 1,
};
}
foreach (var character in ClientConfigRepository.Characters())
{
var defaultIds = ClientConfigRepository.DefaultFashionIds(character.TemplateId);
result.SpiritFashionsInfoDict[character.TemplateId] = new Auto.SpiritFashionsInfo
{
SpiritId = character.TemplateId,
SpiritWearFashionsInfo = WearFashions(defaultIds),
SpiritPrevWearFashionsInfo = null,
FirstGainSuitIdList = [],
ActiveClientTryWearSource = 0,
UnlockSuitSlotCount = 10,
};
}
return result;
}
private static Auto.SpiritWearFashionsInfo WearFashions(IReadOnlyList<uint> ids)
=> new()
{
FunctionSuitId = 0,
WearSourceInfo = new Auto.WearSourceInfo { Source = 0, SourceId = 0 },
IsTryWear = false,
WearFashionInfoList = ids.Select(id => new Auto.WearFashionInfo { FashionId = id }).ToList(),
WearFashionEditInfoList = [],
HiddenParts = 0,
EditedHiddenParts = 0,
};
internal static GameMethods.SpiritWearFashionsInfo WearFashionsForGameMethods(IReadOnlyList<uint> ids)
=> new()
{
FunctionSuitId = 0,
WearSourceInfo = new GameMethods.WearSourceInfo { Source = 0, SourceId = 0 },
IsTryWear = false,
WearFashionInfoList = ids.Select(id => new GameMethods.WearFashionInfo { FashionId = id }).ToList(),
WearFashionEditInfoList = [],
HiddenParts = 0,
EditedHiddenParts = 0,
};
private static Auto.UXVector3 Vector(Vec3 value)
=> new() { X = value.X, Y = value.Y, Z = value.Z };
}
@@ -0,0 +1,187 @@
using Ananta.SDK.Serialization;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>
/// Writer for the 4229938 story channel (SyncStoryCoreServerInfo, 68629349) used by
/// the vehicle ride story (S011). Ported from the proven 4091149 codec; command framing
/// (Create=3, Delete=4, ConfirmRpc=2, Enable=6, Validate=9) and payload tags
/// (Bool=5, Int=12, ULong=44) verified against the current dump.cs story classes.
/// 4229938 delta applied: CreateClientNodeServerCommand gained StoryboardGuid
/// (dump TypeDefIndex 69592) — sent as EMPTY string: the Lua writer marks the
/// field non-nullable, so a null marker (0x00) corrupts the whole Create parse
/// client-side and the S011 node is never built (F-enter: 缺少S011PlayerNet节点).
/// Base fields (RpcId, HasRpcId) trail each command, matching the proven layout.
/// </summary>
internal static class StoryS011Codec4229938
{
internal const string RootType = "StoryServer.Story.World.S011.S011PlayerNet";
internal const string EnterChildType = "StoryServer.Story.World.S011.SVehicleEnterBoardingNet";
internal const string ExitChildType = "StoryServer.Story.World.S011.SVehicleExitBoardingNet";
private static readonly string[] RootMessengers =
[
"S011PlayerNetEnterVehicleRequest",
"S011PlayerNetExitVehicleRequest",
"S011PlayerNetForceEnterVehicleRequest",
"S011PlayerNetForceExitVehicleRequest",
"S011PlayerNetEnterVehicleIndoorRequest",
"S011PlayerNetExitVehicleIndoorRequest",
"S011PlayerNetForceShiftSeatRequest",
];
/// <summary>Atomic S011 root bootstrap: Create + Validate + Enable + ConfirmRpc.</summary>
internal static byte[] BuildRootBootstrap(uint rootNid, long confirmRpcId)
{
var w = new UxWriter();
ListHeader(w, 4);
Create(w, rootNid, RootType);
Validate(w, rootNid, messengers: RootMessengers);
EnableEmpty(w, rootNid);
ConfirmRpc(w, confirmRpcId);
return w.ToArray();
}
/// <summary>Enter child bootstrap with its three replicables and phase messenger.</summary>
internal static byte[] BuildEnterChildBootstrap(uint childNid, ulong vehicleId, byte seat)
{
var w = new UxWriter();
ListHeader(w, 3);
Create(w, childNid, EnterChildType);
Validate(w, childNid,
replicables: ["vehicleEntityId", "seatIndex", "requisitionFailed"],
messengers: ["MsgrChangePhaseRequest"]);
var enable = new UxWriter();
ListHeader(enable, 3);
NamedPayload(enable, "vehicleEntityId", b => { PayloadTag(b, 44); b.U64(vehicleId); });
NamedPayload(enable, "seatIndex", b => { PayloadTag(b, 12); b.I32(seat); });
NamedPayload(enable, "requisitionFailed", b => { PayloadTag(b, 5); b.Bool(false); });
Enable(w, childNid, enable.ToArray());
return w.ToArray();
}
/// <summary>Exit child bootstrap with its four replicables and phase messenger.</summary>
internal static byte[] BuildExitChildBootstrap(uint childNid, ulong vehicleId, byte seat, bool stopBeforeLeave)
{
var w = new UxWriter();
ListHeader(w, 3);
Create(w, childNid, ExitChildType);
Validate(w, childNid,
replicables: ["vehicleEntityId", "seatIndex", "StopBeforeLeave", "IsEscortExit"],
messengers: ["MsgrChangePhaseRequest"]);
var enable = new UxWriter();
ListHeader(enable, 4);
NamedPayload(enable, "vehicleEntityId", b => { PayloadTag(b, 44); b.U64(vehicleId); });
NamedPayload(enable, "seatIndex", b => { PayloadTag(b, 12); b.I32(seat); });
NamedPayload(enable, "StopBeforeLeave", b => { PayloadTag(b, 5); b.Bool(stopBeforeLeave); });
NamedPayload(enable, "IsEscortExit", b => { PayloadTag(b, 5); b.Bool(false); });
Enable(w, childNid, enable.ToArray());
return w.ToArray();
}
/// <summary>ConfirmRpc(tag 2) with the cumulative committed id.</summary>
internal static byte[] BuildConfirmRpc(long confirmRpcId)
{
var w = new UxWriter();
ListHeader(w, 1);
ConfirmRpc(w, confirmRpcId);
return w.ToArray();
}
/// <summary>DeleteClientNode(tag 4) for a finished boarding child.</summary>
internal static byte[] BuildDeleteNode(uint nid)
{
var w = new UxWriter();
ListHeader(w, 1);
w.U8(4); // DeleteClientNodeCommand
w.U32(nid);
w.I64(0); // RpcId
w.Bool(false);
return w.ToArray();
}
private static void Create(UxWriter w, uint nid, string type)
{
w.U8(3); // CreateClientNodeServerCommand
w.U32(nid);
w.UxString(type);
w.UxString(string.Empty); // StoryboardGuid: none for server-made nodes (empty, NOT null)
w.Bool(false); // Sync
w.I64(0); // RpcId
w.Bool(false); // HasRpcId
}
private static void Validate(UxWriter w, uint nid, string[]? observables = null, string[]? replicables = null, string[]? messengers = null, string[]? syncReplicables = null)
{
w.U8(9); // ValidateClientNodeServerCommand
w.U32(nid);
StringList(w, observables);
StringList(w, replicables);
StringList(w, messengers);
StringList(w, syncReplicables);
w.I64(0);
w.Bool(false);
}
private static void EnableEmpty(UxWriter w, uint nid)
{
w.U8(6); // EnableClientNodeCommand
w.U32(nid);
w.U8(0); // Observables: null
w.U8(0); // Replicables: null
w.U8(0); // SyncReplicables: null
w.I64(0);
w.Bool(false);
}
private static void Enable(UxWriter w, uint nid, byte[] replicablesBody)
{
w.U8(6);
w.U32(nid);
w.U8(0); // Observables: null
w.Raw(replicablesBody);
w.U8(0); // SyncReplicables: null
w.I64(0);
w.Bool(false);
}
private static void ConfirmRpc(UxWriter w, long confirmRpcId)
{
w.U8(2); // ConfirmRpcServerCommand
w.I64(confirmRpcId);
w.I64(0); // RpcId
w.Bool(false);
}
private static void PayloadTag(UxWriter w, byte tag) => w.U8(tag);
/// <summary>NamedPayload complex: FF + Name + complex payload (tag byte + fields).</summary>
private static void NamedPayload(UxWriter w, string name, Action<UxWriter> writePayload)
{
w.U8(0xFF);
w.UxString(name);
writePayload(w);
}
private static void StringList(UxWriter w, string[]? values)
{
if (values is null) { w.U8(0); return; }
w.U8(0xFF);
WriteCount(w, values.Length);
foreach (var v in values) w.UxString(v);
}
private static void ListHeader(UxWriter w, int count)
{
w.U8(0xFF);
WriteCount(w, count);
}
// UxWriter.Int7 already applies the +1 wire bias (reader returns v-1).
private static void WriteCount(UxWriter w, int count) => w.Int7(count);
}
@@ -0,0 +1,146 @@
using Ananta.SDK.Serialization;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>One parsed StoryClientCommand from the C2S story channel.</summary>
internal sealed record StoryClientCommand4229938(
byte TypeMark,
uint Nid,
string Name,
long RpcId,
bool HasRpcId,
string? PayloadKind,
ulong? PayloadVehicleId,
List<byte>? PayloadSeatIndices,
bool? PayloadBool,
long? PayloadInt,
ulong? PayloadULong);
/// <summary>
/// Parser for the 4229938 story channel (SyncStoryCoreClientInfo, 67130745):
/// an inline List7Bit of complex StoryClientCommand entries.
/// Command marks: ConfirmRpc 2, Message 4, SetObservable-shaped 5, reliable RpcId-only 3/6.
/// Payload marks: Bool 5, Byte 6, Int 12, S011EnterVehicleRequest 35 (NOT 36 — V2's value
/// is from another build; proven live 2026-09-09: hex ...-23-<u64>-FF-03-<seats>),
/// S011VehicleAndSeat 37, ULong 44. Shapes verified against dump.cs (StoryClientCommand base,
/// payload V fields, S011EnterVehicleRequestPayload{VehicleId, SeatIndices}, S011VehicleAndSeatPayload).
/// An unknown command or payload aborts that message's parse; the prefix is still returned.
/// </summary>
internal static class StoryVehicleCodec4229938
{
internal static IReadOnlyList<StoryClientCommand4229938> Parse(ReadOnlySpan<byte> body, out bool fullyConsumed)
{
var commands = new List<StoryClientCommand4229938>();
var reader = new UxReader(body.ToArray());
fullyConsumed = false;
try
{
// Nullable list framing: 0x00 = null, 0xFF + Int7 count + items.
if (reader.U8() == 0x00)
{
fullyConsumed = true;
return commands;
}
int count = reader.Int7();
for (int i = 0; i < count; i++)
{
byte mark = reader.U8();
if (mark == 0x00)
continue;
switch (mark)
{
case 2: // ConfirmRpcCommand { ConfirmRpcId i64, RpcId i64, HasRpcId bool }
{
long confirmRpcId = reader.I64();
(long rpcId, bool hasRpcId) = ParseCommandBase(reader);
commands.Add(new StoryClientCommand4229938(2, 0, "ConfirmRpc", rpcId, hasRpcId, "Confirm", (ulong)confirmRpcId, null, null, null, null));
break;
}
case 4: // MessageCommand { Nid u32, Name string, Value StoryNetPayload, RpcId, HasRpcId }
{
uint nid = reader.U32();
string name = reader.UxStringNullable() ?? string.Empty;
var payload = ParsePayload(reader);
(long rpcId, bool hasRpcId) = ParseCommandBase(reader);
commands.Add(new StoryClientCommand4229938(4, nid, name, rpcId, hasRpcId,
payload.Kind, payload.VehicleId, payload.SeatIndices, payload.Bool, payload.Int, payload.ULong));
break;
}
case 5: // { Nid u32, Name string, Value StoryNetPayload, RpcId, HasRpcId }
{
uint nid = reader.U32();
string name = reader.UxStringNullable() ?? string.Empty;
var payload = ParsePayload(reader);
(long rpcId, bool hasRpcId) = ParseCommandBase(reader);
commands.Add(new StoryClientCommand4229938(5, nid, name, rpcId, hasRpcId,
payload.Kind, payload.VehicleId, payload.SeatIndices, payload.Bool, payload.Int, payload.ULong));
break;
}
case 3:
case 6: // { RpcId i64, HasRpcId bool }
{
(long rpcId, bool hasRpcId) = ParseCommandBase(reader);
commands.Add(new StoryClientCommand4229938(mark, 0, mark == 3 ? "Rpc3" : "Rpc6", rpcId, hasRpcId, null, null, null, null, null, null));
break;
}
default:
return commands;
}
}
fullyConsumed = true;
return commands;
}
catch (Exception)
{
return commands;
}
}
private static (long RpcId, bool HasRpcId) ParseCommandBase(UxReader reader)
=> (reader.I64(), reader.Bool());
private static (string Kind, ulong? VehicleId, List<byte>? SeatIndices, bool? Bool, long? Int, ulong? ULong) ParsePayload(UxReader reader)
{
byte mark = reader.U8();
switch (mark)
{
case 5: // BoolPayload { V bool }
return ("Bool", null, null, reader.Bool(), null, null);
case 6: // BytePayload { V byte } - S011 boarding phases arrive in this shape
return ("Byte", null, null, null, reader.U8(), null);
case 12: // IntPayload { V i32 }
return ("Int", null, null, null, reader.I32(), null);
case 44: // ULongPayload { V u64 }
return ("ULong", null, null, null, null, reader.U64());
case 35: // S011EnterVehicleRequestPayload { VehicleId u64, SeatIndices byte[]? }
case 36: // same shape under V2's build; kept for tolerance
{
ulong vehicleId = reader.U64();
List<byte>? seats = null;
if (reader.U8() != 0x00)
{
int n = reader.Int7();
seats = new List<byte>(n);
for (int i = 0; i < n; i++) seats.Add(reader.U8());
}
return ("S011EnterVehicleRequest", vehicleId, seats, null, null, null);
}
case 37: // S011VehicleAndSeatPayload { VehicleId u64, SeatIndex byte }
{
ulong vehicleId = reader.U64();
byte seat = reader.U8();
return ("S011VehicleAndSeat", vehicleId, [seat], null, null, null);
}
default:
throw new InvalidDataException($"unknown StoryNetPayload type mark {mark}");
}
}
}
@@ -0,0 +1,164 @@
using Ananta.Server.RpcTypes.Client4229938;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>
/// Typed factories for world/game-scene messages. The wire layout itself lives in Ananta.RpcTypes.
/// Large world/profile packets are built as RpcTypes DTOs by RuntimePayloadFactory and serialized on the fly.
/// </summary>
internal static class WorldCodec
{
internal static SceneMethods.SyncLogicAgentEnter LogicAgentEnter(ulong agentId)
=> new() { agentId = agentId };
internal static SceneMethods.SyncLogicAgentLeave LogicAgentLeave(ulong agentId)
=> new() { agentId = agentId };
internal static SceneMethods.SyncManagedLogicAgent ManagedLogicAgent(ulong agentId, ulong pid, byte moveId = 0)
=> new() { agentId = agentId, pid = pid, moveId = moveId };
internal static SceneMethods.SyncPlayerCurrentSpirit CurrentSpirit()
=> CurrentSpirit(Profile.PlayerPid, Profile.InitialSpiritTemplateId, Profile.InitialUnitId);
internal static SceneMethods.SyncPlayerCurrentSpirit CurrentSpirit(ulong playerPid, uint templateId, ulong spiritId, bool isAgentSwitch = false)
=> new()
{
playerPid = playerPid,
templateId = templateId,
spiritId = spiritId,
isAgentSwitch = isAgentSwitch
};
internal static SceneMethods.SyncSwitchSpiritConfigId SwitchSpiritConfigId(
uint configId, Vec3 position, IReadOnlyCollection<ulong>? spawnedAgentIds = null)
=> new()
{
configId = configId,
position = new SceneMethods.UxVector3(position.X, position.Y, position.Z),
spawnedAgentIds = spawnedAgentIds?.ToList() ?? []
};
internal static SceneMethods.SyncPreSwitchSpirit PreSwitchSpirit(uint configId, uint newTemplateId, Vec3 position)
=> new()
{
configId = configId,
newTemplateId = newTemplateId,
position = new SceneMethods.UxVector3(position.X, position.Y, position.Z)
};
internal static SceneMethods.SyncPlayerLoadRate PlayerLoadRate(double rate = 1.0)
=> new() { playerPid = Profile.PlayerPid, rate = rate };
internal static SceneMethods.SyncSceneLoadCompleted SceneLoadCompleted(ulong sceneId)
=> new() { sceneId = sceneId };
internal static SceneMethods.SyncUnitPositionAndFacing PositionAndFacing(ulong unitId, Vec3 position, float facing, byte setPositionType = 3)
=> new()
{
unitId = unitId,
position = new SceneMethods.UxVector3(position.X, position.Y, position.Z),
facing = facing,
moveId = 0,
continueMove = false,
setPositionType = setPositionType,
moveGroundInfo = null,
loadingInfo = null,
};
internal static SceneMethods.SyncUnitPositionAndFacing PositionAndFacing(Vec3 position, float facing, byte setPositionType = 3)
=> PositionAndFacing(Profile.InitialUnitId, position, facing, setPositionType);
internal static SceneMethods.SyncUnitPositionP PositionP(ulong unitId, Vec3 position, float facing, byte setPositionType = 3)
=> new()
{
unitId = unitId,
position = new SceneMethods.UxVector3(position.X, position.Y, position.Z),
facing = facing,
moveId = 0,
setPositionType = setPositionType
};
internal static SceneMethods.SyncUnitPositionP PositionP(Vec3 position, float facing, byte setPositionType = 3)
=> PositionP(Profile.InitialUnitId, position, facing, setPositionType);
internal static SceneMethods.SyncGamePause GamePause(bool pause)
=> new() { pause = pause };
internal static SceneMethods.SyncEntityActionGroup EntityActionGroup(ulong unitId, uint actionGroupId = 1u)
=> new() { unitId = unitId, actionGroupId = actionGroupId };
internal static SceneMethods.SyncAllSpiritCombatPower AllSpiritCombatPower(uint templateId, float combatPower = 1000f)
=> new() { combatPower = new Dictionary<uint, float> { [templateId] = combatPower } };
internal static SceneMethods.SyncAllSpiritCombatPower AllSpiritCombatPower(params uint[] templateIds)
{
var body = new SceneMethods.SyncAllSpiritCombatPower();
foreach (var templateId in templateIds)
body.combatPower[templateId] = 1000f;
return body;
}
internal static SceneMethods.SyncChangeSkill SkillBinding(ulong spiritId, uint skillId)
=> new()
{
spiritId = spiritId,
skillId = skillId,
duration = skillId == 0 ? 0f : 99999f
};
internal static SceneMethods.SyncUnitBuffList UnitBuffList(ulong unitId, IReadOnlyList<uint> buffIds, uint firstInstanceId)
{
var body = new SceneMethods.SyncUnitBuffList { entityId = unitId };
// 4229938 still evaluates ExpireTime inside several client-side BuffActions even when
// Permanent=true. ExpireTime=0 becomes a huge negative remaining time and can execute
// teardown paths immediately. Use a real far-future unix timestamp for persistent state.
var persistentExpiry = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 315_360_000d;
var instanceId = firstInstanceId;
foreach (var buffId in buffIds)
{
body.buffs.Add(new SceneMethods.BuffViewData
{
InstanceId = instanceId++,
Id = buffId,
ReleaserId = unitId,
ExpireTime = persistentExpiry,
Tier = 1,
Permanent = true,
DestructibleId = 0
});
}
return body;
}
internal static SceneMethods.SyncUnitAddBuff UnitAddBuff(ulong unitId, uint buffId, uint instanceId)
=> new()
{
entityId = unitId,
buff = new SceneMethods.BuffViewData
{
InstanceId = instanceId,
Id = buffId,
ReleaserId = unitId,
ExpireTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 315_360_000d,
Tier = 1,
Permanent = true,
DestructibleId = 0
}
};
internal static SceneMethods.SyncUnitRemoveBuff UnitRemoveBuff(ulong unitId, uint buffInstanceId)
=> new() { entityId = unitId, buffInstanceId = buffInstanceId };
internal static SceneMethods.SyncUnitStates UnitStates(ulong unitId, params uint[] states)
=> new() { unitId = unitId, states = states?.ToList(), effectFreezeState = 0 };
internal static SceneMethods.SyncUnitStates UnitStates(params uint[] states)
=> UnitStates(Profile.InitialUnitId, states);
internal static SceneMethods.SyncRemoveUnitState RemoveUnitState(ulong unitId, uint state)
=> new() { unitId = unitId, state = state, reason = 1, sourceId = 0 };
internal static SceneMethods.SyncRemoveUnitState RemoveUnitState(uint state)
=> RemoveUnitState(Profile.InitialUnitId, state);
}
@@ -0,0 +1,239 @@
using Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Protocol.Client4229938;
internal sealed class WorldEntryState
{
internal object SyncRoot { get; } = new();
internal Dictionary<uint, GameMethods.SpiritWearFashionsInfo> CustomWornFashionsBySpirit { get; } = [];
internal bool InitialStateSent { get; set; }
internal bool EnterSceneSent { get; set; }
internal bool SceneLoadAckSeen { get; set; }
internal bool GameResourcesReadySeen { get; set; }
internal bool ControlPublished { get; set; }
internal bool SceneCompletionSent { get; set; }
internal bool CurrentSpiritStateSent { get; set; }
internal bool Ready { get; set; }
internal bool LivePlayerProfilePublished { get; set; }
internal bool SameSceneAckSeen { get; set; }
internal bool MovementCapabilityPublished { get; set; }
internal bool FreeRoamReleased { get; set; }
internal bool FirstMovementSeen { get; set; }
internal bool GaragePublished { get; set; }
internal bool AetherVehicleInitSent { get; set; }
internal bool InitialCapabilityBuffsPublished { get; set; }
internal bool AllBuildBuffsPublished { get; set; }
internal bool InitialActorPresentationPublished { get; set; }
internal bool CombatProfilePublished { get; set; }
internal bool AccountArmoryPublished { get; set; }
// Current public-world destination. Initial values come from private-server.json; airport
// travel updates these per scene generation without mutating global configuration.
internal uint ActiveRaidId { get; set; } = Profile.RaidId;
internal ulong ActiveInstanceId { get; set; } = Profile.SceneInstanceId;
internal uint ActiveUniverseId { get; set; } = Profile.UniverseId;
internal string ActiveContentScene { get; set; } = "WorldMap_Release";
internal bool WorldEntryIsAirportTravel { get; set; }
// Native LoadingManager common-teleport transaction. Airport travel reuses the retail
// AskTeleport -> SyncPreTeleportOption -> ReportPreTeleportFinish -> domain RPC ->
// SyncTeleport -> ReportPostTeleportFinish sequence so SwitchTeleportManager cannot retain
// a stale AcrossRaid flow after landing.
internal ulong PendingTeleportId { get; set; }
internal string PendingAirportRouteKey { get; set; } = string.Empty;
internal bool PendingTeleportPreFinished { get; set; }
internal bool PendingTeleportSyncSent { get; set; }
// Build 4229938: one generation-bound initial-player handoff.
// 0 = edge not claimed; -generation = claimed/in-flight; +generation = published.
internal bool WorldEntryControlPending { get; set; }
internal bool WorldEntryControlFinalized { get; set; }
internal ulong WorldEntryControlUnit { get; set; }
internal uint WorldEntryControlTemplate { get; set; }
internal int WorldEntryControlGeneration { get; set; }
internal int WorldEntryLoadingCompletedGeneration { get; set; }
internal int WorldEntryOpeningEndedGeneration { get; set; }
internal int WorldEntryControlFinalizingGeneration { get; set; }
internal int WorldEntryLogicProjectionPublishedGeneration { get; set; }
internal int WorldEntryCurrentMetadataPublishedGeneration { get; set; }
internal ulong WorldEntrySceneId4229938 { get; set; }
internal ulong WorldEntrySessionId4229938 { get; set; }
internal Vec3 WorldEntryCreateHeroPosition { get; set; } = Profile.WorldSpawn;
internal float WorldEntryCreateHeroFacing { get; set; } = Profile.WorldFacing;
internal Vec3 LastReportedPlayerPosition { get; set; } = Profile.WorldSpawn;
internal Vec3 LastReportedPlayerRotation { get; set; } = new(0f, Profile.WorldFacing, 0f);
internal bool HasLastReportedPlayerTransform { get; set; }
internal Vec3 PendingTeleportPosition { get; set; } = new(0f, 0f, 0f);
internal float PendingTeleportFacing { get; set; } = 0f;
internal uint ActiveSkillId { get; set; }
internal bool RestoreResourcesAfterActiveSkill { get; set; }
internal int ActiveClientSkillInstanceId { get; set; }
internal long ActiveSkillStartedTicks { get; set; }
internal ulong ActiveWeaponInstanceId { get; set; }
internal uint ActiveFightStyleId { get; set; }
internal Dictionary<uint, ulong> LastWeaponBySpirit { get; } = [];
internal Dictionary<uint, List<ulong>> WeaponSlotsBySpirit { get; } = [];
internal Dictionary<ulong, uint> WeaponStyleOverrides { get; } = [];
// Persistent weapon-instance ammunition. These dictionaries intentionally survive public-scene
// generations and character switches; a weapon switch must never refill its magazine.
internal Dictionary<ulong, int> WeaponMagazineAmmo { get; } = [];
// For firearms without separate backpack bullets, Durability is the remaining total ammo
// (loaded magazine + reserve). Keep it instance-persistent for the same reason as the magazine.
internal Dictionary<ulong, int> WeaponDurabilityAmmo { get; } = [];
internal Dictionary<ulong, uint> WeaponBulletByInstance { get; } = [];
internal Dictionary<uint, uint> BackpackItemCounts { get; } = [];
internal Dictionary<(uint SpiritId, uint FightStyleTypeId), uint> SpiritStyleOverrides { get; } = [];
internal int CombatUseCount { get; set; }
internal int CombatEndCount { get; set; }
internal int CombatHitCount { get; set; }
internal uint NextBuffInstanceId { get; set; } = 1200000u;
internal uint ActiveFeiSuoBuffInstanceId { get; set; }
internal ulong ActiveFeiSuoBuffUnitId { get; set; }
// Exact instance ids for client-owned 4229938 traversal states (SwingBuff/WallRushBuff).
// They are created/removed on demand and are intentionally not resident login buffs.
internal Dictionary<(ulong UnitId, uint BuffId), uint> ActiveClientWebBuffInstances { get; } = [];
internal ulong SceneId { get; set; }
internal ulong ActiveSpiritUnitId { get; set; } = Profile.InitialUnitId;
internal uint ActiveSpiritTemplateId { get; set; } = Profile.InitialSpiritTemplateId;
internal uint WorldEntrySwitchShowId { get; set; }
internal uint LastSwitchShowId { get; set; }
internal int SwitchCount { get; set; }
// Native 4229938 SwitchTeleport is a three-phase transaction. Only the current character
// exists as the controlled actor. Every target is materialized by the same generic AOI path
// after the client reports that the source close-up has finished.
internal uint PendingSwitchTemplateId { get; set; }
internal ulong PendingSwitchUnitId { get; set; }
internal ulong PendingSwitchOldUnitId { get; set; }
internal uint PendingSwitchShowId { get; set; }
internal Vec3 PendingSwitchPosition { get; set; } = Profile.WorldSpawn;
internal float PendingSwitchFacing { get; set; } = Profile.WorldFacing;
internal bool PendingSwitchControlTransferred { get; set; }
internal bool PendingSwitchLandingStarted { get; set; }
// Time-of-day state (persists across scene switches; pushed on load when set).
internal uint TimeHour { get; set; }
internal uint TimeMinute { get; set; }
internal bool TimeFixed { get; set; }
internal uint TimeTransitionSeconds { get; set; }
internal bool HasExplicitTime { get; set; }
internal uint WeatherId { get; set; }
internal uint WeatherTransitionSeconds { get; set; }
internal bool HasExplicitWeather { get; set; }
// Personal time settings and mobile customization
internal List<GameMethods.PersonalTimeSetting4229938> PersonalTimeSettings { get; } = [];
internal uint MobileWallpaperId { get; set; }
internal uint MobileDecorationId { get; set; }
internal uint MobilePendantId { get; set; }
// 10-Second Position History Rollback Buffer (holds last 30s locally, 60s globally across reconnects)
internal sealed record PositionCheckpoint(DateTime Timestamp, Vec3 Position, float Facing);
private readonly LinkedList<PositionCheckpoint> _positionHistory = new();
private DateTime _lastCheckpointTime = DateTime.MinValue;
private static readonly LinkedList<PositionCheckpoint> s_globalPositionHistory = new();
private static readonly object s_globalSyncRoot = new();
internal static void RecordGlobalCheckpoint(Vec3 pos, float facing)
{
lock (s_globalSyncRoot)
{
var now = DateTime.UtcNow;
s_globalPositionHistory.AddLast(new PositionCheckpoint(now, pos, facing));
var cutoff = now.AddSeconds(-60);
while (s_globalPositionHistory.Count > 0 && s_globalPositionHistory.First!.Value.Timestamp < cutoff)
{
s_globalPositionHistory.RemoveFirst();
}
}
}
internal static PositionCheckpoint? TryGetGlobalCheckpointSecondsAgo(double seconds = 10.0)
{
lock (s_globalSyncRoot)
{
if (s_globalPositionHistory.Count == 0)
return null;
var targetTime = DateTime.UtcNow.AddSeconds(-seconds);
PositionCheckpoint? best = null;
double bestDiff = double.MaxValue;
foreach (var cp in s_globalPositionHistory)
{
var diff = Math.Abs((cp.Timestamp - targetTime).TotalSeconds);
if (diff < bestDiff)
{
bestDiff = diff;
best = cp;
}
}
return best ?? s_globalPositionHistory.First?.Value;
}
}
internal void SeedHistoryFromGlobal()
{
lock (SyncRoot)
{
lock (s_globalSyncRoot)
{
foreach (var cp in s_globalPositionHistory)
{
_positionHistory.AddLast(cp);
}
}
}
}
internal void RecordPositionCheckpoint(Vec3 pos, float facing)
{
lock (SyncRoot)
{
var now = DateTime.UtcNow;
if ((now - _lastCheckpointTime).TotalMilliseconds < 500)
return;
_lastCheckpointTime = now;
_positionHistory.AddLast(new PositionCheckpoint(now, pos, facing));
var cutoff = now.AddSeconds(-30);
while (_positionHistory.Count > 0 && _positionHistory.First!.Value.Timestamp < cutoff)
{
_positionHistory.RemoveFirst();
}
}
RecordGlobalCheckpoint(pos, facing);
}
internal PositionCheckpoint? TryGetCheckpointSecondsAgo(double seconds = 10.0)
{
lock (SyncRoot)
{
if (_positionHistory.Count == 0)
return TryGetGlobalCheckpointSecondsAgo(seconds);
var targetTime = DateTime.UtcNow.AddSeconds(-seconds);
PositionCheckpoint? best = null;
double bestDiff = double.MaxValue;
foreach (var cp in _positionHistory)
{
var diff = Math.Abs((cp.Timestamp - targetTime).TotalSeconds);
if (diff < bestDiff)
{
bestDiff = diff;
best = cp;
}
}
return best ?? _positionHistory.First?.Value;
}
}
}