Initial commit: Ananta CBT 4229938 private server with all fixes
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user