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,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Ananta.SDK\Ananta.SDK.csproj" />
<ProjectReference Include="..\Ananta.RpcTypes\Ananta.RpcTypes.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Ananta.Gameplay")]
[assembly: InternalsVisibleTo("Ananta.Handlers")]
[assembly: InternalsVisibleTo("Ananta.App")]
@@ -0,0 +1,381 @@
using System.Text.Json;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
namespace Ananta.Server.ClientData.Client4229938;
/// <summary>
/// Reads authored client data directly from the extracted JSON configuration files.
///
/// There is no generated character/switch catalog in the source tree. If you need to understand
/// why a character or animation is available, inspect FightSpiritConfig.json /
/// SwitchSpiritConfig.json in the configured client-data directory.
/// </summary>
internal static class ClientConfigRepository
{
// This exact 4229938 client/VFS capture fails to resolve the streaming animation hashes for
// these generic switch timelines. Prefer another authored row when available; do not globally
// disable streaming animation for every cutscene.
private static readonly HashSet<string> KnownBrokenLocalStreamingSwitchTimelines4229938 = new(StringComparer.Ordinal)
{
"SwitchChar_common_09",
"SwitchChar_common_14",
};
private static ClientData Data => Cache.Value;
internal static IReadOnlyList<CharacterCatalogEntry> Characters() => Data.Characters;
internal static IReadOnlyList<uint> UnlockSystemIds() => Data.UnlockSystemIds;
internal static IReadOnlyList<uint> InstalledPhoneAppIds() => Data.InstalledPhoneAppIds;
internal static IReadOnlyList<uint> SelectableFashionIds() => Data.SelectableFashionIds;
internal static IReadOnlyList<uint> DefaultFashionIds(uint templateId)
=> Data.DefaultFashionIdsBySpirit.TryGetValue(templateId, out var ids) ? ids : Array.Empty<uint>();
internal static (Vec3 Position, float Facing) EntryPlacement(uint switchShowId)
=> Data.SwitchRows.TryGetValue(switchShowId, out var row)
? (row.Position, row.Facing)
: (Profile.WorldSpawn, Profile.WorldFacing);
internal static IReadOnlyList<uint> SwitchAnimations(uint templateId)
=> Data.SwitchIdsBySpirit.TryGetValue(templateId, out var ids) ? ids : Data.CommonSwitchIds;
internal static IReadOnlyList<uint> EntryAnimations(uint templateId)
=> Data.EntryIdsBySpirit.TryGetValue(templateId, out var ids) ? ids : Data.CommonEntryIds;
internal static uint RandomEntryAnimation(uint templateId)
{
var ids = EntryAnimations(templateId);
if (ids.Count == 0)
throw new InvalidOperationException($"No world-entry animations for spirit {templateId} in SwitchSpiritConfig.json.");
// Entry presentation must agree with the configured world spawn. Prefer the nearest authored
// row that does not require external agents and whose streaming animation is present in this
// 4229938 client capture. This replaces the old random cross-map entry selection.
var candidates = ids
.Where(id => Data.SwitchRows.ContainsKey(id))
.Select(id => Data.SwitchRows[id])
.Where(row => row.AgentIds.Count == 0)
.Where(row => !KnownBrokenLocalStreamingSwitchTimelines4229938.Contains(row.Timeline))
.Select(row =>
{
var dx = (double)row.Position.X - Profile.WorldSpawn.X;
var dz = (double)row.Position.Z - Profile.WorldSpawn.Z;
return new { row.Id, Distance = Math.Sqrt(dx * dx + dz * dz), row.Weight };
})
.OrderBy(x => x.Distance)
.ThenByDescending(x => x.Weight)
.ThenBy(x => x.Id)
.ToArray();
return candidates.Length > 0 ? candidates[0].Id : ids[0];
}
internal static SwitchAnimationChoice RandomSwitchAnimation(uint templateId, uint previousSwitchShowId, Vec3 position)
{
var ids = SwitchAnimations(templateId);
if (ids.Count == 0)
throw new InvalidOperationException($"No SwitchSpiritConfig animations for spirit {templateId}.");
// Generic server switching cannot satisfy authored companion-agent rows. Those rows carry
// AgentId entries that stock SwitchTeleport expects the server to spawn before landing.
var candidateRows = ids
.Where(id => Data.SwitchRows.ContainsKey(id))
.Select(id => Data.SwitchRows[id])
.Where(row => row.AgentIds.Count == 0)
.ToArray();
var preferredRows = candidateRows
.Where(row => !KnownBrokenLocalStreamingSwitchTimelines4229938.Contains(row.Timeline))
.ToArray();
if (preferredRows.Length == 0)
preferredRows = candidateRows;
var usableRows = preferredRows
.Select(row =>
{
var dx = (double)row.Position.X - position.X;
var dz = (double)row.Position.Z - position.Z;
return new SwitchAnimationCandidate(
row.Id,
row.Timeline,
row.Weight > 0f && float.IsFinite(row.Weight) ? row.Weight : 1f,
Math.Sqrt(dx * dx + dz * dz));
})
.OrderBy(x => x.AnchorDistance)
.ThenByDescending(x => x.Weight)
.ThenBy(x => x.Id)
.ToArray();
if (usableRows.Length == 0)
throw new InvalidOperationException($"No usable SwitchSpiritConfig rows for spirit {templateId}.");
string? previousTimeline = null;
if (previousSwitchShowId != 0 && Data.SwitchRows.TryGetValue(previousSwitchShowId, out var previousRow))
previousTimeline = previousRow.Timeline;
// SwitchSpiritConfig rows are spatially authored. Randomly choosing a timeline first can bind a
// current position to an animation whose authored anchor is kilometers away. Pick the nearest
// compatible row; only avoid an immediate timeline repeat when an almost-equally-near alternative
// exists, so anti-repeat never wins over spatial validity.
var selected = usableRows[0];
if (PrivateServerConfigStore.Current.Gameplay.SwitchAnimations.AvoidImmediateRepeat &&
previousTimeline is not null)
{
var maxAlternativeDistance = selected.AnchorDistance + 150d;
var alternative = usableRows.FirstOrDefault(x =>
x.AnchorDistance <= maxAlternativeDistance &&
!string.Equals(x.Timeline, previousTimeline, StringComparison.Ordinal));
if (alternative is not null)
selected = alternative;
}
return new SwitchAnimationChoice(
selected.Id,
selected.Timeline,
selected.Weight,
(float)selected.AnchorDistance,
usableRows.Select(x => x.Timeline).Distinct(StringComparer.Ordinal).Count(),
usableRows.Length,
previousTimeline);
}
private static readonly Lazy<ClientData> Cache = new(Load);
private static ClientData Load()
{
var cfg = PrivateServerConfigStore.Current;
var root = PrivateServerConfigStore.ResolveProjectPath(cfg.Paths.ClientConfigs);
var fightSpirits = ReadRecords(Path.Combine(root, "FightSpiritConfig.json"));
var switches = ReadRecords(Path.Combine(root, "SwitchSpiritConfig.json"));
var unlockSystems = ReadRecords(Path.Combine(root, "SystemUnlockConfig.json"));
var fashions = ReadRecords(Path.Combine(root, "FashionConfig.json"));
var mobileApps = ReadRecords(Path.Combine(root, "MobileMenuSGuiConfig.json"));
var roster = cfg.Content.Roster;
var excluded = roster.ExcludedTemplateIds.ToHashSet();
var characters = fightSpirits
.Select(ParseCharacterSource)
.Where(x => x.HasValue)
.Select(x => x.GetValueOrDefault())
.Where(x => x.TemplateId >= roster.TemplateIdMin && x.TemplateId < roster.TemplateIdMaxExclusive)
.Where(x => !excluded.Contains(x.TemplateId))
.Where(x => x.IconId > 0 && x.IconId != roster.GenericPlaceholderIconId)
.OrderBy(x => x.TemplateId)
.Select((x, index) => new CharacterCatalogEntry(
x.TemplateId,
roster.UnitIdBase + (ulong)index,
x.Name,
x.IconId))
.ToArray();
if (characters.Length == 0)
throw new InvalidDataException("FightSpiritConfig.json produced an empty playable roster.");
if (characters[0].TemplateId != cfg.Player.InitialSpiritTemplateId)
throw new InvalidDataException(
$"Playable roster begins with {characters[0].TemplateId}, expected initial spirit {cfg.Player.InitialSpiritTemplateId}.");
var switchRows = switches
.Select(ParseSwitchRow)
.Where(x => x is not null)
.Select(x => x!)
.Where(x => x.SwitchType == cfg.Gameplay.SwitchAnimations.SwitchType)
.Where(x => x.RaidId == cfg.World.RaidId)
.Where(x => !x.Invalid && x.Weight > 0f && !string.IsNullOrWhiteSpace(x.Timeline))
.ToDictionary(x => x.Id);
if (switchRows.Count == 0)
throw new InvalidDataException("SwitchSpiritConfig.json has no usable rows for the configured raid/switch type.");
uint[] IdsFor(uint templateId, bool entryOnly)
{
var prefix = cfg.Gameplay.SwitchAnimations.CommonTimelinePrefix;
var rows = switchRows.Values.Where(x => !entryOnly || !x.LoginInvalid);
var common = rows.Where(x =>
x.Timeline.StartsWith(prefix, StringComparison.Ordinal) &&
!x.ForbidFightSpiritIds.Contains(templateId) &&
(x.FightSpiritIds.Count == 0 && x.FirstSwitchSpiritIds.Count == 0 ||
x.FightSpiritIds.Contains(templateId) || x.FirstSwitchSpiritIds.Contains(templateId)));
var unique = rows.Where(x =>
!x.Timeline.StartsWith(prefix, StringComparison.Ordinal) &&
(x.FightSpiritIds.Contains(templateId) || x.FirstSwitchSpiritIds.Contains(templateId)));
return common.Concat(unique).Select(x => x.Id).Distinct().ToArray();
}
var switchIdsBySpirit = characters.ToDictionary(x => x.TemplateId, x => IdsFor(x.TemplateId, false));
var entryIdsBySpirit = characters.ToDictionary(x => x.TemplateId, x => IdsFor(x.TemplateId, true));
foreach (var character in characters)
{
if (switchIdsBySpirit[character.TemplateId].Length == 0)
throw new InvalidDataException($"No switch animations for character {character.TemplateId} ({character.Name}).");
}
var commonSwitchIds = switchRows.Values
.Where(x => x.Timeline.StartsWith(cfg.Gameplay.SwitchAnimations.CommonTimelinePrefix, StringComparison.Ordinal))
.Select(x => x.Id).ToArray();
var commonEntryIds = switchRows.Values
.Where(x => !x.LoginInvalid && x.Timeline.StartsWith(cfg.Gameplay.SwitchAnimations.CommonTimelinePrefix, StringComparison.Ordinal))
.Select(x => x.Id).ToArray();
var excludedUnlocks = cfg.Content.ExcludedUnlockSystems.ToHashSet();
var unlockSystemIds = unlockSystems
.Select(x => TryUInt32(x, "Id", out var id) ? id : 0u)
.Where(id => id != 0 && !excludedUnlocks.Contains(id))
.Distinct()
.OrderBy(id => id)
.ToArray();
var installedPhoneAppIds = mobileApps
.Where(x => cfg.Content.IncludePhoneBottomApps || !TryBool(x, "IsBottom"))
.Select(x => TryUInt32(x, "Id", out var id) ? id : 0u)
.Where(id => id != 0)
.Distinct()
.OrderBy(id => id)
.ToArray();
var selectableFashionIds = fashions
.Select(x => TryUInt32(x, "Id", out var id) ? id : 0u)
.Where(id => id != 0)
.Distinct()
.OrderBy(id => id)
.ToArray();
var defaultFashionIdsBySpirit = fashions
.Where(x => TryBool(x, "IsDefault"))
.Select(x => new
{
FashionId = TryUInt32(x, "Id", out var fashionId) ? fashionId : 0u,
SpiritId = TryUInt32(x, "BelongSpiritId", out var spiritId) ? spiritId : 0u,
})
.Where(x => x.FashionId != 0 && x.SpiritId != 0)
.GroupBy(x => x.SpiritId)
.ToDictionary(g => g.Key, g => g.Select(x => x.FashionId).Distinct().OrderBy(id => id).ToArray());
Console.WriteLine(
$"[CLIENT-CONFIG] build={cfg.Client.Version} characters={characters.Length} switchRows={switchRows.Count} " +
$"source={root}");
return new ClientData(
characters, switchRows, switchIdsBySpirit, entryIdsBySpirit, commonSwitchIds, commonEntryIds,
unlockSystemIds, installedPhoneAppIds, selectableFashionIds, defaultFashionIdsBySpirit);
}
private static JsonElement[] ReadRecords(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Required client config is missing: {path}");
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
throw new InvalidDataException($"Client config does not contain a 'records' array: {path}");
return records.EnumerateArray().Select(x => x.Clone()).ToArray();
}
private static CharacterSource? ParseCharacterSource(JsonElement value)
{
if (!TryUInt32(value, "Id", out var id)) return null;
var name = TryString(value, "Name") ?? string.Empty;
TryUInt32(value, "SHeadIconID", out var iconId);
return new CharacterSource(id, name, iconId);
}
private static SwitchRow? ParseSwitchRow(JsonElement value)
{
if (!TryUInt32(value, "Id", out var id)) return null;
var timeline = TryString(value, "TimeLine") ?? string.Empty;
TryInt32(value, "SwitchType", out var switchType);
TryUInt32(value, "RaidId", out var raidId);
TrySingle(value, "Weight", out var weight);
var invalid = TryBool(value, "Invalid");
var loginInvalid = TryBool(value, "LoginInvalid");
var position = ReadPosition(value, "Position", Profile.WorldSpawn);
var facing = TrySingle(value, "TimeLineAngleY", out var angle) ? angle : Profile.WorldFacing;
return new SwitchRow(
id, timeline, weight, switchType, raidId, invalid, loginInvalid, position, facing,
ReadUInt32Array(value, "FightSpiritId"),
ReadUInt32Array(value, "FirstSwitchSpiritId"),
ReadUInt32Array(value, "ForbidFightSpiritId"),
ReadUInt32Array(value, "AgentId"));
}
private static Vec3 ReadPosition(JsonElement value, string name, Vec3 fallback)
{
if (!value.TryGetProperty(name, out var item) || item.ValueKind != JsonValueKind.Array)
return fallback;
var values = item.EnumerateArray().Take(3).Select(x => x.TryGetSingle(out var n) ? n : float.NaN).ToArray();
return values.Length == 3 && values.All(float.IsFinite) ? new Vec3(values[0], values[1], values[2]) : fallback;
}
private static HashSet<uint> ReadUInt32Array(JsonElement value, string name)
{
if (!value.TryGetProperty(name, out var item) || item.ValueKind != JsonValueKind.Array)
return new HashSet<uint>();
return item.EnumerateArray().Where(x => x.TryGetUInt32(out _)).Select(x => x.GetUInt32()).ToHashSet();
}
private static bool TryUInt32(JsonElement value, string name, out uint result)
{
result = default;
return value.TryGetProperty(name, out var item) && item.TryGetUInt32(out result);
}
private static bool TryInt32(JsonElement value, string name, out int result)
{
result = default;
return value.TryGetProperty(name, out var item) && item.TryGetInt32(out result);
}
private static bool TrySingle(JsonElement value, string name, out float result)
{
result = default;
return value.TryGetProperty(name, out var item) && item.TryGetSingle(out result);
}
private static string? TryString(JsonElement value, string name)
=> value.TryGetProperty(name, out var item) && item.ValueKind == JsonValueKind.String ? item.GetString() : null;
private static bool TryBool(JsonElement value, string name)
{
if (!value.TryGetProperty(name, out var item)) return false;
return item.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => false,
};
}
private sealed record ClientData(
CharacterCatalogEntry[] Characters,
Dictionary<uint, SwitchRow> SwitchRows,
Dictionary<uint, uint[]> SwitchIdsBySpirit,
Dictionary<uint, uint[]> EntryIdsBySpirit,
uint[] CommonSwitchIds,
uint[] CommonEntryIds,
uint[] UnlockSystemIds,
uint[] InstalledPhoneAppIds,
uint[] SelectableFashionIds,
Dictionary<uint, uint[]> DefaultFashionIdsBySpirit);
private readonly record struct CharacterSource(uint TemplateId, string Name, uint IconId);
private sealed record SwitchRow(
uint Id,
string Timeline,
float Weight,
int SwitchType,
uint RaidId,
bool Invalid,
bool LoginInvalid,
Vec3 Position,
float Facing,
HashSet<uint> FightSpiritIds,
HashSet<uint> FirstSwitchSpiritIds,
HashSet<uint> ForbidFightSpiritIds,
HashSet<uint> AgentIds);
private sealed record SwitchAnimationCandidate(uint Id, string Timeline, float Weight, double AnchorDistance);
}
internal sealed record CharacterCatalogEntry(uint TemplateId, ulong UnitId, string Name, uint IconId);
internal sealed record SwitchAnimationChoice(
uint Id, string Timeline, float Weight, float AnchorDistance, int TimelinePool, int RowPool, string? PreviousTimeline);
@@ -0,0 +1,812 @@
using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
/// <summary>
/// Build-4229938 combat catalog reconstructed from the unmodified extracted client tables.
/// It is the single source of truth for weapon slots, weapon-specific fight styles and skill bindings.
/// </summary>
internal static class CombatCatalogRepository
{
private static CombatCatalog Data => Cache.Value;
private static readonly Lazy<CombatCatalog> Cache = new(Load);
internal static CombatLoadout Loadout(uint spiritTemplateId)
=> Data.Loadouts.TryGetValue(spiritTemplateId, out var value)
? value
: throw new KeyNotFoundException($"No combat loadout for spirit {spiritTemplateId}.");
internal static CombatWeaponDefinition? Weapon(ulong instanceId)
=> Data.AccountWeaponsByInstance.TryGetValue(instanceId, out var value) ? value : null;
internal static CombatWeaponDefinition? Weapon(uint spiritTemplateId, ulong instanceId)
=> Data.WeaponsBySpiritAndInstance.TryGetValue((spiritTemplateId, instanceId), out var value) ? value : null;
internal static IReadOnlyList<CombatWeaponDefinition> AccountWeapons => Data.AccountWeapons;
internal static CombatStyleDefinition? Style(uint styleId)
=> Data.Styles.TryGetValue(styleId, out var value) ? value : null;
internal static IReadOnlyList<uint> UnlockedStyleIds => Data.UnlockedStyleIds;
internal static float ResourceMaximum(uint resourceId, float fallback)
=> Data.ResourceMaximums.TryGetValue(resourceId, out var value) ? value : fallback;
internal static IReadOnlyDictionary<uint, float> ResourceMaximums => Data.ResourceMaximums;
internal static IReadOnlyList<uint> AmmoTemplateIds => Data.AmmoTemplateIds;
internal static uint SkillCastTag(uint skillId)
=> Data.SkillCastTags.GetValueOrDefault(skillId);
internal static bool IsKnownSkill(uint skillId)
=> skillId != 0 && Data.SkillCastTags.ContainsKey(skillId);
internal static bool IsNativeShootSkill(uint skillId)
=> SkillCastTag(skillId) is 10u or 19u;
internal static bool IsStyleCompatible(CombatWeaponDefinition weapon, CombatStyleDefinition style)
{
// Stock WeaponArmoryPanelStore only treats a martial art as adaptive when its
// FightSkillType exactly matches SceneitemConfig.FightSkillType. ActionType is the
// animation/action-set selected BY that martial art; it is not a compatibility gate.
// FIX24J2 incorrectly compared ActionType and therefore accepted one style, then restored
// every other perfectly valid style from the same category.
if (style.SpiritIds.Count > 0 && !style.SpiritIds.Contains(weapon.SpiritTemplateId))
return false;
return weapon.WeaponFightSkillTypeId != 0 &&
style.FightSkillTypeId == weapon.WeaponFightSkillTypeId;
}
internal static bool StyleMatchesType(uint styleId, uint typeId)
=> Data.Styles.TryGetValue(styleId, out var style) &&
(style.FightSkillTypeId == typeId || Data.DefaultStyleByType.GetValueOrDefault(typeId) == styleId);
private static CombatCatalog Load()
{
var config = PrivateServerConfigStore.Current;
var root = PrivateServerConfigStore.ResolveProjectPath(config.Paths.ClientConfigs);
var spiritRows = ReadRecords(Path.Combine(root, "FightSpiritConfig.json"));
var styleRows = ReadRecords(Path.Combine(root, "FightSkillConfig.json"));
var styleTypeRows = ReadRecords(Path.Combine(root, "FightSkillFightSkillTypeConfig.json"));
var weaponRows = ReadRecords(Path.Combine(root, "SceneitemConfig.json"));
var legacyWeaponRows = ReadRecords(Path.Combine(root, "WeaponConfig.json"));
var weaponShootRows = ReadRecords(Path.Combine(root, "WeaponShootConfig.json"));
var skillRows = ReadRecords(Path.Combine(root, "SkillConfig.json"));
var jumpRows = ReadRecords(Path.Combine(root, "SkillJumpConfig.json"));
var jumpGroupRows = ReadRecords(Path.Combine(root, "SkillJumpGroupConfig.json"));
var genericJumpRows = ReadRecords(Path.Combine(root, "SkillJumpGenericConfig.json"));
var resourceRows = ReadRecords(Path.Combine(root, "SkillResourcesConfig.json"));
var skillMetadata = skillRows
.Select(ParseSkillMetadata)
.Where(x => x is not null)
.Select(x => x!)
.ToDictionary(x => x.Id);
var jumpTargets = jumpRows
.Where(x => TryUInt32(x, "Id", out _) && TryUInt32(x, "Skillid", out _))
.ToDictionary(x => x.GetProperty("Id").GetUInt32(), x => x.GetProperty("Skillid").GetUInt32());
var jumpRowsById = jumpRows
.Where(x => TryUInt32(x, "Id", out _))
.ToDictionary(x => x.GetProperty("Id").GetUInt32());
var jumpIdsByGroup = jumpGroupRows
.Where(x => TryUInt32(x, "Id", out _))
.ToDictionary(x => x.GetProperty("Id").GetUInt32(), x => ReadUInt32Array(x, "SkillJumpIds"));
var groupByGenericId = genericJumpRows
.Where(x => TryUInt32(x, "Id", out _) && TryUInt32(x, "SkillJumpGroupId", out _))
.ToDictionary(x => x.GetProperty("Id").GetUInt32(), x => x.GetProperty("SkillJumpGroupId").GetUInt32());
var genericJumpTargets = jumpRowsById.ToDictionary(
x => x.Key,
x =>
{
if (!TryUInt32(x.Value, "SkillGenericId", out var genericId) || genericId == 0 ||
!groupByGenericId.TryGetValue(genericId, out var groupId) ||
!jumpIdsByGroup.TryGetValue(groupId, out var groupJumpIds))
return Array.Empty<uint>();
return groupJumpIds
.Select(jumpId => jumpTargets.GetValueOrDefault(jumpId))
.Where(skillId => skillId != 0)
.Distinct()
.ToArray();
});
var styles = styleRows
.Select(row => ParseStyle(row, skillMetadata, jumpTargets, genericJumpTargets))
.Where(x => x is not null)
.Select(x => x!)
.ToDictionary(x => x.Id);
var defaultStyleByType = styleTypeRows
.Where(x => TryUInt32(x, "Id", out _) && TryUInt32(x, "DefaultFightSkill", out _))
.ToDictionary(x => x.GetProperty("Id").GetUInt32(), x => x.GetProperty("DefaultFightSkill").GetUInt32());
var shootById = weaponShootRows
.Where(x => TryUInt32(x, "Id", out _))
.ToDictionary(
x => x.GetProperty("Id").GetUInt32(),
x => new WeaponShootSource(
TryBool(x, "IsShootWeapon"),
TryBool(x, "SeparateBullets"),
ReadUInt32Array(x, "BulletId")
.Concat(ReadUInt32Array(x, "AppropriateBullets"))
.Where(id => id != 0)
.Distinct()
.ToArray(),
TryUInt32(x, "ReloadSkill", out var reloadSkill) ? reloadSkill : 0u));
var legacyById = legacyWeaponRows
.Where(x => TryUInt32(x, "Id", out _))
.ToDictionary(
x => x.GetProperty("Id").GetUInt32(),
x => new LegacyWeaponSource(
TryUInt32(x, "WeaponBelong", out var owner) ? owner : 0u,
TryUInt32(x, "FightSkillType", out var fightSkillType) ? fightSkillType : 0u,
TryUInt32(x, "FixedFightSkill", out var fixedFightSkill) ? fixedFightSkill : 0u));
var weapons = weaponRows
.Select(row => ParseWeaponSource(row, shootById, legacyById))
.Where(x => x is not null)
.Select(x => x!)
.ToDictionary(x => x.TemplateId);
var spiritCombat = spiritRows
.Where(x => TryUInt32(x, "Id", out _))
.ToDictionary(
x => x.GetProperty("Id").GetUInt32(),
x => new SpiritCombatSource(
ReadUInt32Array(x, "InitFightSkillSetting"),
TryUInt32(x, "FistSceneItem", out var fistId) ? fistId : 0u,
TryUInt32(x, "PrivateSceneItem", out var privateId) ? privateId : 0u,
ReadUInt32Array(x, "SceneItemGeneralSlotInitialization"),
ReadStringArray(x, "WeaponSlot")));
uint EffectiveFightSkillType(WeaponSource weapon)
{
if (weapon.FightSkillTypeId != 0)
return weapon.FightSkillTypeId;
if (weapon.FixedFightStyleId != 0 &&
styles.TryGetValue(weapon.FixedFightStyleId, out var fixedStyle) &&
fixedStyle.FightSkillTypeId != 0)
return fixedStyle.FightSkillTypeId;
// A few signature/prototype rows (notably Bansy's build-4229938 scene item) ship with
// FightSkillType=0 and a hidden prototype FixedFightSkill whose own type is also zero.
// Infer only signature weapons from the character's authored attachment slot; never apply
// this heuristic to generic props in the shared armory.
if (weapon.OwnerSpiritId != 0 && spiritCombat.TryGetValue(weapon.OwnerSpiritId, out var ownerCombat))
{
var slots = ownerCombat.WeaponSlotNames;
if (slots.Any(x => x.Contains("forearm", StringComparison.OrdinalIgnoreCase)))
return 39020004u;
if (slots.Any(x => x.Contains("handr", StringComparison.OrdinalIgnoreCase) ||
x.Contains("weapon_r", StringComparison.OrdinalIgnoreCase)))
return 39020301u;
}
return 0u;
}
bool IsNativeCombatStyle(CombatStyleDefinition? style)
=> style is not null && style.CommonSkill != 0 && style.HeavyCommonSkill != 0 &&
style.ActiveSkill != 0 && style.UniqueSkill != 0;
bool IsArmoryWeapon(WeaponSource weapon)
=> weapon.WheelIconId != 0 &&
(weapon.FightSkillTypeId != 0 || weapon.FixedFightStyleId != 0);
bool IsWheelWeapon(WeaponSource weapon)
=> IsArmoryWeapon(weapon) && !weapon.NotEnterRoulette &&
(weapon.FightSkillTypeId != 0 ||
(weapon.FixedFightStyleId != 0 &&
IsNativeCombatStyle(styles.GetValueOrDefault(weapon.FixedFightStyleId))));
WeaponSource? ByLegacyId(uint legacyId)
=> weapons.Values
.Where(x => x.LegacyWeaponId == legacyId && IsArmoryWeapon(x))
.OrderBy(x => x.TemplateId >= 80300000u)
.ThenBy(x => x.TemplateId)
.FirstOrDefault();
WeaponSource? ResolveConfiguredWeapon(uint id)
=> weapons.TryGetValue(id, out var direct) && IsWheelWeapon(direct) ? direct : ByLegacyId(id);
var configuredWeapon = ResolveConfiguredWeapon(config.Gameplay.Combat.Weapon.TemplateId)
?? throw new InvalidDataException(
$"Configured weapon {config.Gameplay.Combat.Weapon.TemplateId} is absent from SceneitemConfig.json (including OldWeaponId mapping).");
var fallbackFists = weapons.TryGetValue(80003412u, out var fists) ? fists : configuredWeapon;
var characters = ClientConfigRepository.Characters();
var loadouts = new Dictionary<uint, CombatLoadout>();
var weaponsBySpiritAndInstance = new Dictionary<(uint SpiritId, ulong InstanceId), CombatWeaponDefinition>();
var firstInstanceBase = checked(config.Gameplay.Combat.Weapon.InstanceId - 1UL);
var accountWeaponSources = weapons.Values
.Where(IsArmoryWeapon)
.OrderBy(x => x.TemplateId)
.ToArray();
var accountInstanceByTemplate = accountWeaponSources
.Select((source, index) => (source.TemplateId, InstanceId: checked(firstInstanceBase + (ulong)index)))
.ToDictionary(x => x.TemplateId, x => x.InstanceId);
for (var characterIndex = 0; characterIndex < characters.Count; characterIndex++)
{
var character = characters[characterIndex];
var spiritId = character.TemplateId;
spiritCombat.TryGetValue(spiritId, out var authored);
authored ??= new SpiritCombatSource([], 0, 0, [], []);
var initialStyles = authored.InitialStyleIds;
var abilityFallbackStyle = initialStyles
.Select(id => styles.GetValueOrDefault(id))
.FirstOrDefault(IsCompleteAbilityStyle)
?? styles.Values
.Where(x => x.SpiritIds.Contains(spiritId))
.OrderByDescending(AbilityCompleteness)
.ThenBy(x => x.Id)
.FirstOrDefault(IsCompleteAbilityStyle)
?? styles.GetValueOrDefault(39000011u)
?? styles.Values
.Where(x => x.SpiritIds.Count == 0)
.OrderByDescending(AbilityCompleteness)
.ThenBy(x => x.Id)
.First(IsCompleteAbilityStyle);
var fistWeapon = weapons.TryGetValue(authored.FistSceneItemId, out var authoredFists) && IsWheelWeapon(authoredFists)
? authoredFists
: fallbackFists;
// Ownership is account-wide. Build a character-specific runtime view for every shared
// account instance so any armory weapon can be installed in any character's 16 slots.
// The instance id is derived only from the Sceneitem template, never from the character.
foreach (var source in accountWeaponSources)
{
// A handful of private scene items only author a style for their original owner.
// They still belong to the shared account armory; on another character, use that
// character's complete ability style so the weapon remains executable instead of
// producing an empty attack/ability binding.
var effectiveType = EffectiveFightSkillType(source);
var style = ResolveStyle(source, effectiveType, spiritId, initialStyles, styles, defaultStyleByType, config)
?? ResolveAuthoredAccountStyle(source, effectiveType, styles, defaultStyleByType)
?? abilityFallbackStyle;
var instanceId = accountInstanceByTemplate[source.TemplateId];
weaponsBySpiritAndInstance.Add(
(spiritId, instanceId),
new CombatWeaponDefinition(
spiritId,
source.TemplateId,
instanceId,
source.Name,
source.Durability,
source.MagazineAmmo,
source.BulletIds.FirstOrDefault(),
source.BulletIds,
source.IsShootWeapon,
source.SeparateBullets,
source.ReloadSkillId,
source.OwnerSpiritId != 0,
effectiveType != 0 ? effectiveType : style.FightSkillTypeId,
style,
abilityFallbackStyle));
}
// Personal wheel = authored personal equipment only. Slot 0 is fists, then signature
// weapon(s) and authored general initialization. The remaining positions stay nil instead
// of being padded with random account weapons. All 634 authored weapons live in the shared
// armory and can still be installed manually into an empty slot.
var personalSources = new List<WeaponSource> { fistWeapon };
if (weapons.TryGetValue(authored.PrivateSceneItemId, out var privateWeapon) && IsArmoryWeapon(privateWeapon))
personalSources.Add(privateWeapon);
personalSources.AddRange(weapons.Values
.Where(x => x.OwnerSpiritId == spiritId && IsArmoryWeapon(x))
.OrderBy(x => x.TemplateId));
personalSources.AddRange(authored.GeneralSceneItemIds
.Select(id => weapons.GetValueOrDefault(id))
.Where(x => x is not null && IsArmoryWeapon(x))
.Select(x => x!));
var resolvedPersonal = personalSources
.DistinctBy(x => x.TemplateId)
.Select(x => (Weapon: x, Style: ResolveStyle(x, EffectiveFightSkillType(x), spiritId, initialStyles, styles, defaultStyleByType, config)))
.Where(x => x.Style is not null)
.Take(16)
.ToArray();
if (resolvedPersonal.Length == 0)
throw new InvalidDataException($"No personal combat weapon resolved for spirit {spiritId}.");
var personalWeapons = resolvedPersonal
.Select(x => weaponsBySpiritAndInstance[(spiritId, accountInstanceByTemplate[x.Weapon.TemplateId])])
.ToList();
var personalSlots = new CombatWeaponDefinition?[16];
for (var i = 0; i < personalWeapons.Count && i < personalSlots.Length; i++)
personalSlots[i] = personalWeapons[i];
var signatureWeapon = personalWeapons.FirstOrDefault(x => x.IsPrivate && x.TemplateId != fistWeapon.TemplateId);
var authoredGeneral = authored.GeneralSceneItemIds
.Select(id => personalWeapons.FirstOrDefault(x => x.TemplateId == id))
.FirstOrDefault(x => x is not null);
var defaultWeapon = signatureWeapon ?? authoredGeneral ?? personalWeapons[0];
// FightStyleManager indexes the server map by the WEAPON fight-style category.
// Do not key this by FightSkillConfig.FightSkillType: special authored defaults can
// deliberately use a style whose own type differs from the Sceneitem category.
var styleByType = resolvedPersonal
// FixedFightSkill is resolved directly from SceneitemConfig and must not pollute
// the per-spirit category map used by other, non-fixed weapons of the same type.
.Where(x => x.Weapon.FixedFightStyleId == 0)
.Select(x => (TypeId: EffectiveFightSkillType(x.Weapon), Style: x.Style!))
.Where(x => x.TypeId != 0)
.GroupBy(x => x.TypeId)
.ToDictionary(x => x.Key, x => x.First().Style.Id);
loadouts.Add(spiritId, new CombatLoadout(spiritId, personalWeapons, personalSlots, defaultWeapon, styleByType));
}
// Unlock every authored style. IsShowInArmony remains a client-side presentation filter,
// while hidden/private character styles must still be executable by their weapon slots.
var unlockedStyleIds = styles.Values
.Select(x => x.Id)
.OrderBy(x => x)
.ToArray();
var resourceMaximums = resourceRows
.Where(x => TryUInt32(x, "Id", out _) && TrySingle(x, "Max", out _))
.ToDictionary(x => x.GetProperty("Id").GetUInt32(), x => x.GetProperty("Max").GetSingle());
// Shared-armory metadata must be authored from the weapon itself, not from the initial
// protagonist's character-specific view. Otherwise ordinary fist weapons inherit Void Claw
// and signature weapons such as Garm's bat lose their owner-only DK9 style in the inventory UI.
var accountWeapons = accountWeaponSources
.Select(source =>
{
var instanceId = accountInstanceByTemplate[source.TemplateId];
if (source.OwnerSpiritId != 0 &&
weaponsBySpiritAndInstance.TryGetValue((source.OwnerSpiritId, instanceId), out var ownerView))
return ownerView;
var seed = weaponsBySpiritAndInstance.GetValueOrDefault((config.Player.InitialSpiritTemplateId, instanceId))
?? weaponsBySpiritAndInstance.First(x => x.Key.InstanceId == instanceId).Value;
var effectiveType = EffectiveFightSkillType(source);
var authoredStyle = ResolveAuthoredAccountStyle(source, effectiveType, styles, defaultStyleByType);
return authoredStyle is null
? seed
: seed with
{
WeaponFightSkillTypeId = effectiveType != 0 ? effectiveType : authoredStyle.FightSkillTypeId,
Style = authoredStyle
};
})
.ToArray();
var accountWeaponsByInstance = accountWeapons.ToDictionary(x => x.InstanceId);
foreach (var loadout in loadouts.Values.OrderBy(x => x.SpiritTemplateId))
{
foreach (var signature in loadout.Weapons.Where(x => x.IsPrivate && x.TemplateId != 80003412u))
Console.WriteLine($"[SIGNATURE-STYLE] spirit={loadout.SpiritTemplateId} weapon={signature.TemplateId}/{signature.InstanceId} type={signature.WeaponFightSkillTypeId} style={signature.Style.Id}:{signature.Style.Name} action={signature.Style.ActionType}");
}
Console.WriteLine(
$"[COMBAT-CATALOG] build={config.Client.Version} loadouts={loadouts.Count} " +
$"accountWeapons={accountWeapons.Length} personalWeapons={loadouts.Values.Sum(x => x.Weapons.Count)} " +
$"signatureSpirits={loadouts.Values.Count(x => x.Weapons.Any(w => w.IsPrivate && w.TemplateId != 80003412u))} " +
$"perSpiritViews={weaponsBySpiritAndInstance.Count} styles={styles.Count} unlocked={unlockedStyleIds.Length} source={root}");
return new CombatCatalog(
loadouts,
accountWeapons,
accountWeaponsByInstance,
weaponsBySpiritAndInstance,
styles,
defaultStyleByType,
unlockedStyleIds,
resourceMaximums,
skillMetadata.ToDictionary(x => x.Key, x => x.Value.CastTypeTag),
accountWeaponSources.SelectMany(x => x.BulletIds).Where(x => x != 0).Distinct().OrderBy(x => x).ToArray());
}
private static CombatStyleDefinition? ResolveStyle(
WeaponSource weapon,
uint effectiveFightSkillTypeId,
uint spiritId,
IReadOnlyList<uint> initialStyleIds,
IReadOnlyDictionary<uint, CombatStyleDefinition> styles,
IReadOnlyDictionary<uint, uint> defaultStyleByType,
PrivateServerConfig config)
{
bool SpiritCompatible(CombatStyleDefinition style)
=> style.SpiritIds.Count == 0 || style.SpiritIds.Contains(spiritId);
bool TypeCompatible(CombatStyleDefinition style)
=> SpiritCompatible(style) &&
(effectiveFightSkillTypeId == 0 || style.FightSkillTypeId == effectiveFightSkillTypeId);
bool AuthoredTypeCompatible(CombatStyleDefinition style)
=> TypeCompatible(style) ||
(defaultStyleByType.TryGetValue(effectiveFightSkillTypeId, out var authoredDefault) && authoredDefault == style.Id);
// Stock build 4229938 makes SceneitemConfig.FixedFightSkill authoritative: both the Lua
// WeaponManager and FightStyleManager return it before consulting the per-character/default
// category. Keep the exact authored fixed row even when FightSkillType is zero; substituting a
// guessed generic style is what produced the wrong signature/prototype martial arts.
if (weapon.FixedFightStyleId != 0 &&
styles.TryGetValue(weapon.FixedFightStyleId, out var fixedStyle))
return fixedStyle;
foreach (var styleId in initialStyleIds)
{
if (styles.TryGetValue(styleId, out var initialStyle) &&
SpiritCompatible(initialStyle) && AuthoredTypeCompatible(initialStyle))
return initialStyle;
}
if (spiritId == config.Player.InitialSpiritTemplateId &&
styles.TryGetValue(config.Gameplay.Combat.FightStyleId, out var configuredStyle) && TypeCompatible(configuredStyle))
return configuredStyle;
var characterStyle = styles.Values
.Where(x => x.SpiritIds.Contains(spiritId) && AuthoredTypeCompatible(x))
.OrderBy(x => x.Id)
.FirstOrDefault();
if (characterStyle is not null)
return characterStyle;
if (defaultStyleByType.TryGetValue(effectiveFightSkillTypeId, out var defaultStyleId) &&
styles.TryGetValue(defaultStyleId, out var defaultStyle) && SpiritCompatible(defaultStyle))
return defaultStyle;
return styles.Values.Where(TypeCompatible).OrderBy(x => x.Id).FirstOrDefault();
}
private static CombatStyleDefinition? ResolveAuthoredAccountStyle(
WeaponSource weapon,
uint effectiveFightSkillTypeId,
IReadOnlyDictionary<uint, CombatStyleDefinition> styles,
IReadOnlyDictionary<uint, uint> defaultStyleByType)
{
if (weapon.FixedFightStyleId != 0 &&
styles.TryGetValue(weapon.FixedFightStyleId, out var fixedStyle) &&
fixedStyle.FightSkillTypeId != 0 &&
(effectiveFightSkillTypeId == 0 || fixedStyle.FightSkillTypeId == effectiveFightSkillTypeId ||
defaultStyleByType.GetValueOrDefault(effectiveFightSkillTypeId) == fixedStyle.Id))
return fixedStyle;
return defaultStyleByType.TryGetValue(effectiveFightSkillTypeId, out var defaultStyleId) &&
styles.TryGetValue(defaultStyleId, out var defaultStyle)
? defaultStyle
: null;
}
private static bool IsCompleteAbilityStyle(CombatStyleDefinition? style)
=> style is not null && style.ActiveSkill != 0 && style.UniqueSkill != 0;
private static int AbilityCompleteness(CombatStyleDefinition style)
=> (style.ActiveSkill != 0 ? 2 : 0) + (style.UniqueSkill != 0 ? 4 : 0) +
(style.CommonSkill != 0 ? 1 : 0);
private static CombatStyleDefinition? ParseStyle(
JsonElement value,
IReadOnlyDictionary<uint, SkillMetadata> skills,
IReadOnlyDictionary<uint, uint> jumpTargets,
IReadOnlyDictionary<uint, uint[]> genericJumpTargets)
{
if (!TryUInt32(value, "Id", out var id) || !TryUInt32(value, "FightSkillType", out var typeId))
return null;
uint Skill(string name) => TryUInt32(value, name, out var skillId) ? skillId : 0u;
var rootSkills = new uint[]
{
Skill("CommonSkill"), Skill("PressinCommonSkill"), Skill("HeavyCommonSkill"),
Skill("ControlPowerSkill"), Skill("DodgeSkill"), Skill("DodgeAttackSkill"), Skill("GousuoSkill"),
Skill("JumpSkill"), Skill("JumpAttackSkill"), Skill("ActiveSkill"), Skill("UpActiveSkill"),
Skill("LongPressActiveSkill"), Skill("UniqueSkill"), Skill("UpUniqueSkill"),
Skill("LongPressUniqueSkill"), Skill("SwitchSkill")
};
var allowed = rootSkills.Where(x => x != 0).ToHashSet();
var queue = new Queue<uint>(allowed);
while (queue.TryDequeue(out var skillId))
{
if (!skills.TryGetValue(skillId, out var metadata))
continue;
// SkillJumpId contains timed/input transitions while SkillReplace contains conditional
// variants (distance, stance, buffs and several charged-attack stages). Both arrays hold
// SkillJumpConfig ids and therefore must be resolved through SkillJumpConfig.Skillid.
foreach (var next in metadata.RelatedSkills
.Concat(metadata.TransitionIds.Select(transitionId => jumpTargets.GetValueOrDefault(transitionId)))
.Concat(metadata.TransitionIds.SelectMany(
transitionId => genericJumpTargets.GetValueOrDefault(transitionId) ?? [])))
{
if (next != 0 && allowed.Add(next))
queue.Enqueue(next);
}
}
float Cooldown(uint skillId)
=> skills.TryGetValue(skillId, out var metadata) ? metadata.Cooldown : 0f;
uint First(params uint[] values) => values.FirstOrDefault(x => x != 0);
uint FirstTagged(uint tag, params uint[] values)
=> values.FirstOrDefault(x => x != 0 && skills.GetValueOrDefault(x)?.CastTypeTag == tag);
var activeRoots = new[] { Skill("ActiveSkill"), Skill("UpActiveSkill"), Skill("LongPressActiveSkill") };
var uniqueRoots = new[] { Skill("UniqueSkill"), Skill("UpUniqueSkill"), Skill("LongPressUniqueSkill") };
var active = FirstTagged(6u, activeRoots);
if (active == 0) active = First(activeRoots);
var unique = First(uniqueRoots);
// Gun Fu and a few other authored styles store an ultimate in a long-press field.
if (unique == 0) unique = FirstTagged(7u, rootSkills);
return new CombatStyleDefinition(
id,
TryString(value, "Name") ?? $"Style {id}",
typeId,
TryUInt32(value, "ActionType", out var actionType) ? actionType : 0u,
ReadUInt32Array(value, "SpiritId").ToHashSet(),
TryBool(value, "IsShowInArmony"),
Skill("CommonSkill"),
Skill("PressinCommonSkill"),
Skill("HeavyCommonSkill"),
Skill("ControlPowerSkill"),
Skill("DodgeSkill"),
Skill("DodgeAttackSkill"),
Skill("GousuoSkill"),
active,
unique,
Skill("SwitchSkill"),
allowed,
allowed.ToDictionary(x => x, Cooldown),
allowed.ToDictionary(
x => x,
x => skills.TryGetValue(x, out var metadata) ? metadata.MaximumCharges : 0u));
}
private static SkillMetadata? ParseSkillMetadata(JsonElement value)
{
if (!TryUInt32(value, "Id", out var id))
return null;
// ClientConfig declares Cooldown as a float period and CooldownTime as an integer use/stock
// count. Treating CooldownTime as seconds gives charge/combo attacks (Cooldown=0,
// CooldownTime=1) an artificial one-second cooldown and breaks their release transitions.
var cooldown = TrySingle(value, "Cooldown", out var seconds) && seconds > 0f ? seconds : 0f;
var maximumCharges = TryUInt32(value, "CooldownTime", out var configuredCharges)
? configuredCharges
: 0u;
return new SkillMetadata(
id,
cooldown,
cooldown > 0f ? Math.Max(1u, maximumCharges) : 0u,
TryUInt32(value, "SkillCastTypeTag", out var castTypeTag) ? castTypeTag : 0u,
ReadUInt32Array(value, "SkillJumpId")
.Concat(ReadUInt32Array(value, "SkillReplace"))
.Distinct()
.ToArray(),
ReadUInt32Array(value, "ComboSkillId")
.Concat(ReadUInt32Array(value, "ConnectedCdSkills"))
.Distinct()
.ToArray());
}
private static WeaponSource? ParseWeaponSource(
JsonElement value,
IReadOnlyDictionary<uint, WeaponShootSource> shootById,
IReadOnlyDictionary<uint, LegacyWeaponSource> legacyById)
{
if (!TryUInt32(value, "Id", out var id))
return null;
TryUInt32(value, "OldWeaponId", out var legacyWeaponId);
var legacy = legacyWeaponId != 0 ? legacyById.GetValueOrDefault(legacyWeaponId) : null;
TryUInt32(value, "WeaponBelong", out var owner);
if (owner == 0)
owner = legacy?.OwnerSpiritId ?? 0u;
TryUInt32(value, "FightSkillType", out var styleType);
if (styleType == 0)
styleType = legacy?.FightSkillTypeId ?? 0u;
TryUInt32(value, "FixedFightSkill", out var fixedStyle);
if (fixedStyle == 0)
fixedStyle = legacy?.FixedFightStyleId ?? 0u;
TryUInt32(value, "ShootId", out var shootId);
TryUInt32(value, "SWeaponWheelsIconId", out var wheelIconId);
TryInt32(value, "Durability", out var durability);
TryInt32(value, "BulletNum", out var magazineAmmo);
var shoot = shootById.GetValueOrDefault(shootId) ?? new WeaponShootSource(false, false, [], 0);
return new WeaponSource(
id,
TryString(value, "Name") ?? $"Weapon {id}",
legacyWeaponId,
owner,
styleType,
fixedStyle,
durability,
magazineAmmo,
shoot.BulletIds,
shoot.IsShootWeapon,
shoot.SeparateBullets,
shoot.ReloadSkillId,
wheelIconId,
TryBool(value, "NotEnterRoulette"));
}
private static JsonElement[] ReadRecords(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Required combat config is missing: {path}");
using var document = JsonDocument.Parse(File.ReadAllText(path));
if (!document.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
throw new InvalidDataException($"Combat config does not contain a records array: {path}");
return records.EnumerateArray().Select(x => x.Clone()).ToArray();
}
private static uint[] ReadUInt32Array(JsonElement value, string name)
{
if (!value.TryGetProperty(name, out var item) || item.ValueKind != JsonValueKind.Array)
return [];
return item.EnumerateArray().Where(x => x.TryGetUInt32(out _)).Select(x => x.GetUInt32()).ToArray();
}
private static string[] ReadStringArray(JsonElement value, string name)
{
if (!value.TryGetProperty(name, out var item) || item.ValueKind != JsonValueKind.Array)
return [];
return item.EnumerateArray()
.Where(x => x.ValueKind == JsonValueKind.String)
.Select(x => x.GetString())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x!)
.ToArray();
}
private static bool TryUInt32(JsonElement value, string name, out uint result)
{
result = default;
return value.TryGetProperty(name, out var item) && item.TryGetUInt32(out result);
}
private static bool TryInt32(JsonElement value, string name, out int result)
{
result = default;
return value.TryGetProperty(name, out var item) && item.TryGetInt32(out result);
}
private static bool TrySingle(JsonElement value, string name, out float result)
{
result = default;
return value.TryGetProperty(name, out var item) && item.TryGetSingle(out result);
}
private static string? TryString(JsonElement value, string name)
=> value.TryGetProperty(name, out var item) && item.ValueKind == JsonValueKind.String ? item.GetString() : null;
private static bool TryBool(JsonElement value, string name)
=> value.TryGetProperty(name, out var item) && item.ValueKind == JsonValueKind.True;
private sealed record CombatCatalog(
Dictionary<uint, CombatLoadout> Loadouts,
CombatWeaponDefinition[] AccountWeapons,
Dictionary<ulong, CombatWeaponDefinition> AccountWeaponsByInstance,
Dictionary<(uint SpiritId, ulong InstanceId), CombatWeaponDefinition> WeaponsBySpiritAndInstance,
Dictionary<uint, CombatStyleDefinition> Styles,
Dictionary<uint, uint> DefaultStyleByType,
uint[] UnlockedStyleIds,
Dictionary<uint, float> ResourceMaximums,
Dictionary<uint, uint> SkillCastTags,
uint[] AmmoTemplateIds);
private sealed record WeaponSource(
uint TemplateId,
string Name,
uint LegacyWeaponId,
uint OwnerSpiritId,
uint FightSkillTypeId,
uint FixedFightStyleId,
int Durability,
int MagazineAmmo,
uint[] BulletIds,
bool IsShootWeapon,
bool SeparateBullets,
uint ReloadSkillId,
uint WheelIconId,
bool NotEnterRoulette);
private sealed record WeaponShootSource(
bool IsShootWeapon,
bool SeparateBullets,
uint[] BulletIds,
uint ReloadSkillId);
private sealed record LegacyWeaponSource(
uint OwnerSpiritId,
uint FightSkillTypeId,
uint FixedFightStyleId);
private sealed record SpiritCombatSource(
uint[] InitialStyleIds,
uint FistSceneItemId,
uint PrivateSceneItemId,
uint[] GeneralSceneItemIds,
string[] WeaponSlotNames);
private sealed record SkillMetadata(
uint Id,
float Cooldown,
uint MaximumCharges,
uint CastTypeTag,
uint[] TransitionIds,
uint[] RelatedSkills);
}
internal sealed record CombatLoadout(
uint SpiritTemplateId,
IReadOnlyList<CombatWeaponDefinition> Weapons,
IReadOnlyList<CombatWeaponDefinition?> Slots,
CombatWeaponDefinition DefaultWeapon,
IReadOnlyDictionary<uint, uint> StyleByType)
{
internal CombatWeaponDefinition? WeaponAt(int index)
=> index >= 0 && index < Slots.Count ? Slots[index] : null;
internal CombatWeaponDefinition? Weapon(ulong instanceId)
=> Weapons.FirstOrDefault(x => x.InstanceId == instanceId);
}
internal sealed record CombatWeaponDefinition(
uint SpiritTemplateId,
uint TemplateId,
ulong InstanceId,
string Name,
int Durability,
int MagazineAmmo,
uint BulletId,
IReadOnlyList<uint> BulletIds,
bool IsShootWeapon,
bool SeparateBullets,
uint ReloadSkillId,
bool IsPrivate,
uint WeaponFightSkillTypeId,
CombatStyleDefinition Style,
CombatStyleDefinition AbilityFallbackStyle)
{
internal uint ActiveSkill(CombatStyleDefinition selectedStyle)
=> selectedStyle.ActiveSkill != 0 ? selectedStyle.ActiveSkill : AbilityFallbackStyle.ActiveSkill;
internal uint UniqueSkill(CombatStyleDefinition selectedStyle)
=> selectedStyle.UniqueSkill != 0 ? selectedStyle.UniqueSkill : AbilityFallbackStyle.UniqueSkill;
internal bool AllowsSkill(CombatStyleDefinition selectedStyle, uint skillId)
=> selectedStyle.AllowedSkillIds.Contains(skillId) ||
AbilityFallbackStyle.AllowedSkillIds.Contains(skillId) ||
(IsShootWeapon && CombatCatalogRepository.IsNativeShootSkill(skillId)) ||
// Build 4229938 executes many stance/charge/conditional subskills from the native
// SkillJumpGraphAsset. Those graph nodes are not exported in the JSON config set, so an
// exact server-side reconstruction of AllowedSkillIds is necessarily incomplete. Trust
// the unmodified stock client for graph membership and reject only ids absent from the
// build's own SkillConfig. This keeps style selection authoritative while allowing every
// native subskill the selected martial art can actually emit.
CombatCatalogRepository.IsKnownSkill(skillId);
internal int InitialMagazineAmmo
=> !IsShootWeapon || MagazineAmmo < 0 || SeparateBullets || Durability < 0
? MagazineAmmo
: Math.Min(MagazineAmmo, Durability);
// Separate-ammo guns use Durability as the loaded-ammo value in the shared Armory UI,
// while ordinary guns use it as total remaining ammo (magazine + reserve).
internal int InitialClientDurability
=> IsShootWeapon && SeparateBullets ? InitialMagazineAmmo : Durability;
internal bool IsReloadSkill(uint skillId)
=> IsShootWeapon && (skillId == ReloadSkillId || CombatCatalogRepository.SkillCastTag(skillId) == 19u);
internal bool IsShotSkill(uint skillId)
=> IsShootWeapon && CombatCatalogRepository.SkillCastTag(skillId) == 10u;
internal IEnumerable<uint> EffectiveSkillIds(CombatStyleDefinition selectedStyle)
=> selectedStyle.AllowedSkillIds.Concat(AbilityFallbackStyle.AllowedSkillIds).Distinct();
internal float Cooldown(CombatStyleDefinition selectedStyle, uint skillId, float fallback = 1f)
=> selectedStyle.SkillCooldowns.TryGetValue(skillId, out var selected) && selected > 0f
? selected
: AbilityFallbackStyle.Cooldown(skillId, fallback);
internal uint MaximumCharges(CombatStyleDefinition selectedStyle, uint skillId)
=> selectedStyle.SkillMaximumCharges.TryGetValue(skillId, out var selected) && selected > 0
? selected
: AbilityFallbackStyle.MaximumCharges(skillId);
}
internal sealed record CombatStyleDefinition(
uint Id,
string Name,
uint FightSkillTypeId,
uint ActionType,
IReadOnlySet<uint> SpiritIds,
bool ShowInArmory,
uint CommonSkill,
uint PressCommonSkill,
uint HeavyCommonSkill,
uint ControlSkill,
uint DodgeSkill,
uint DodgeAttackSkill,
uint GrappleAttackSkill,
uint ActiveSkill,
uint UniqueSkill,
uint SwitchSkill,
IReadOnlySet<uint> AllowedSkillIds,
IReadOnlyDictionary<uint, float> SkillCooldowns,
IReadOnlyDictionary<uint, uint> SkillMaximumCharges)
{
internal float Cooldown(uint skillId, float fallback = 1f)
=> SkillCooldowns.TryGetValue(skillId, out var value) && value > 0f ? value : fallback;
internal uint MaximumCharges(uint skillId)
=> SkillMaximumCharges.TryGetValue(skillId, out var value) ? value : 0u;
}
@@ -0,0 +1,44 @@
using System.Text.Json;
using Ananta.Server.Configuration;
namespace Ananta.Server.ClientData.Client4229938;
internal sealed record VehicleCatalogEntry4229938(uint Id, string Name, string Model, int SeatCount, string SpoonName);
/// <summary>
/// Build-4229938 vehicle catalog, read from VehicleConfig.json in the configured
/// client-data directory (field names verified against ConfigDump_v3 + lua writers).
/// </summary>
internal static class VehicleCatalog4229938
{
private static readonly Lazy<Dictionary<uint, VehicleCatalogEntry4229938>> Cache = new(Load);
internal static bool TryGet(uint id, out VehicleCatalogEntry4229938 entry)
=> Cache.Value.TryGetValue(id, out entry!);
/// <summary>Every build-local vehicle that can be materialized (prefab + 1..16 seats).</summary>
internal static IReadOnlyList<VehicleCatalogEntry4229938> AllSummonable => Cache.Value.Values
.Where(v => !string.IsNullOrWhiteSpace(v.Model) && v.SeatCount is >= 1 and <= 16)
.OrderBy(v => v.Id)
.ToArray();
private static Dictionary<uint, VehicleCatalogEntry4229938> Load()
{
var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs);
var path = Path.Combine(root, "VehicleConfig.json");
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("records", out var records) || records.ValueKind != JsonValueKind.Array)
throw new InvalidDataException($"VehicleConfig does not contain records: {path}");
var result = new Dictionary<uint, VehicleCatalogEntry4229938>();
foreach (var row in records.EnumerateArray())
{
if (!row.TryGetProperty("Id", out var idNode) || !idNode.TryGetUInt32(out var id) || id == 0)
continue;
static string Str(JsonElement row, string name)
=> row.TryGetProperty(name, out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() ?? string.Empty : string.Empty;
var seats = row.TryGetProperty("VehicleSeatNum", out var s) && s.TryGetInt32(out var seatCount) ? seatCount : 0;
result[id] = new VehicleCatalogEntry4229938(id, Str(row, "VehicleName"), Str(row, "GeneralModel"), seats, Str(row, "VehicleSpoonName"));
}
return result;
}
}
@@ -0,0 +1,568 @@
using System.Text.Json;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
namespace Ananta.Server.Configuration;
/// <summary>
/// Human-editable private-server settings.
///
/// Protocol constants (RPC ids, serializer layouts, client wire types) deliberately do NOT live
/// here. Those belong to Protocol/Client4229938 and should only change when supporting a new client.
/// </summary>
public sealed class PrivateServerConfig
{
public ClientSettings Client { get; init; } = new();
public NetworkSettings Network { get; init; } = new();
public PlayerSettings Player { get; init; } = new();
public WorldSettings World { get; init; } = new();
public ContentSettings Content { get; init; } = new();
public GameplaySettings Gameplay { get; init; } = new();
public UiSettings Ui { get; init; } = new();
public LoggingSettings Logging { get; init; } = new();
public PathSettings Paths { get; init; } = new();
public DebugSettings Debug { get; init; } = new();
internal void Validate()
{
static void Port(int value, string name)
{
if (value is < 1 or > 65535) throw new InvalidDataException($"{name} must be 1..65535, got {value}.");
}
if (Client.Version != ClientBuild.Version)
throw new InvalidDataException($"client.version={Client.Version} is not supported by this source tree; expected {ClientBuild.Version}.");
if (Client.ServerId <= 0) throw new InvalidDataException("client.serverId must be positive.");
if (string.IsNullOrWhiteSpace(Client.RpcMd5)) throw new InvalidDataException("client.rpcMd5 must not be empty.");
if (string.IsNullOrWhiteSpace(Network.BindHost) || string.IsNullOrWhiteSpace(Network.AdvertisedHost))
throw new InvalidDataException("network.bindHost and network.advertisedHost must not be empty.");
if (Player.Pid == 0) throw new InvalidDataException("player.pid must not be zero.");
if (Player.InitialUnitId == 0) throw new InvalidDataException("player.initialUnitId must not be zero.");
if (Player.InitialSpiritTemplateId == 0) throw new InvalidDataException("player.initialSpiritTemplateId must not be zero.");
if (string.IsNullOrWhiteSpace(Player.AccountId) || string.IsNullOrWhiteSpace(Player.UserName) || string.IsNullOrWhiteSpace(Player.DisplayName))
throw new InvalidDataException("player.accountId, player.userName and player.displayName must not be empty.");
if (string.IsNullOrWhiteSpace(Player.LoginToken) || string.IsNullOrWhiteSpace(Player.GameToken) ||
string.IsNullOrWhiteSpace(Player.ShareToken) || string.IsNullOrWhiteSpace(Player.FpPassToken) || string.IsNullOrWhiteSpace(Player.Skey))
throw new InvalidDataException("player compatibility tokens must not be empty.");
if (World.RaidId == 0 || World.SceneInstanceId == 0 || World.UniverseId == 0)
throw new InvalidDataException("world.raidId, sceneInstanceId and universeId must not be zero.");
if (World.Content.Enabled && string.IsNullOrWhiteSpace(Paths.WorldData))
throw new InvalidDataException("paths.worldData must not be empty while world.content.enabled.");
if (World.Content.Enabled && string.IsNullOrWhiteSpace(World.Content.Scene))
throw new InvalidDataException("world.content.scene must not be empty.");
ValidateStreamWindow(World.Content.SceneItems, "world.content.sceneItems");
ValidateStreamWindow(World.Content.Gadgets, "world.content.gadgets");
if (World.Content.DynamicGo.SeedAttempts is < 1 or > 60)
throw new InvalidDataException($"world.content.dynamicGo.seedAttempts must be 1..60, got {World.Content.DynamicGo.SeedAttempts}.");
static void ValidateStreamWindow<T>(T settings, string name) where T : class
{
var radius = (int)(typeof(T).GetProperty("RadiusCells")?.GetValue(settings) ?? 0);
var unload = (int)(typeof(T).GetProperty("UnloadRadiusCells")?.GetValue(settings) ?? 0);
var budget = (int)(typeof(T).GetProperty("LoadBudgetCells")?.GetValue(settings) ?? 0);
if (radius is < 1 or > 8)
throw new InvalidDataException($"{name}.radiusCells must be 1..8, got {radius}.");
if (unload < radius || unload > radius + 8)
throw new InvalidDataException($"{name}.unloadRadiusCells must be within radiusCells..radiusCells+8, got {unload}.");
if (budget is < 1 or > 200)
throw new InvalidDataException($"{name}.loadBudgetCells must be 1..200, got {budget}.");
}
if (Network.LoginPorts is null || Network.LoginPorts.Length != 2)
throw new InvalidDataException("network.loginPorts must contain exactly two ports.");
Port(Network.LoginPorts[0], "network.loginPorts[0]");
Port(Network.LoginPorts[1], "network.loginPorts[1]");
Port(Network.GamePort, "network.gamePort");
Port(Network.Proxy.HttpsPort, "network.proxy.httpsPort");
Port(Network.Proxy.HttpPort, "network.proxy.httpPort");
Port(Network.Proxy.LoginListPort, "network.proxy.loginListPort");
Port(Network.Proxy.LoginTcpPort, "network.proxy.loginTcpPort");
Port(Network.Proxy.GameTcpPort, "network.proxy.gameTcpPort");
Port(Network.Proxy.SceneSubPort, "network.proxy.sceneSubPort");
if (string.IsNullOrWhiteSpace(Network.Proxy.UpdateHost) || string.IsNullOrWhiteSpace(Network.Proxy.CertificatePassphrase))
throw new InvalidDataException("network.proxy.updateHost and certificatePassphrase must not be empty.");
if (Content.Roster.UnitIdBase == 0) throw new InvalidDataException("content.roster.unitIdBase must not be zero.");
if (Content.Roster.TemplateIdMin >= Content.Roster.TemplateIdMaxExclusive)
throw new InvalidDataException("content.roster.templateIdMin must be less than templateIdMaxExclusive.");
if (Gameplay.Combat.MaxHp <= 0) throw new InvalidDataException("gameplay.combat.maxHp must be positive.");
if (Gameplay.Combat.Skills.All().Any(x => x == 0)) throw new InvalidDataException("gameplay.combat.skills contains id 0.");
if (Gameplay.Combat.Charges.Length == 0) throw new InvalidDataException("gameplay.combat.charges must not be empty.");
foreach (var charge in Gameplay.Combat.Charges)
{
_ = Gameplay.Combat.Skills.ByName(charge.Skill);
if (charge.Max == 0 || charge.Current > charge.Max || charge.Period < 0)
throw new InvalidDataException($"Invalid gameplay.combat.charges entry for '{charge.Skill}'.");
}
if (Gameplay.WebTraversal.SharedBuffIds.Length == 0)
throw new InvalidDataException("gameplay.webTraversal.sharedBuffIds must not be empty.");
if (!Gameplay.WebTraversal.SharedBuffIds.Contains(Gameplay.WebTraversal.PersistentGrappleBuffId))
throw new InvalidDataException("persistentGrappleBuffId must also be present in sharedBuffIds.");
if (Gameplay.WebTraversal.GrappleRearmDelayMs < 0)
throw new InvalidDataException("gameplay.webTraversal.grappleRearmDelayMs must be >= 0.");
if (Gameplay.Vehicles.Enabled)
{
if (Gameplay.Vehicles.FleetIds.Length == 0) throw new InvalidDataException("gameplay.vehicles.fleetIds must not be empty when vehicles are enabled.");
if (Gameplay.Vehicles.MinimumApproachMeters <= 0 || Gameplay.Vehicles.DesiredApproachMeters < Gameplay.Vehicles.MinimumApproachMeters)
throw new InvalidDataException("gameplay.vehicles approach distances are invalid.");
if (Gameplay.Vehicles.SearchRadiusMeters < Gameplay.Vehicles.DesiredApproachMeters)
throw new InvalidDataException("gameplay.vehicles.searchRadiusMeters must cover desiredApproachMeters.");
if (string.IsNullOrWhiteSpace(Paths.ZoneGraphLanes)) throw new InvalidDataException("paths.zoneGraphLanes must not be empty while vehicles are enabled.");
}
if (Ui.UidLabel.Enabled && string.IsNullOrWhiteSpace(Ui.UidLabel.Text))
throw new InvalidDataException("ui.uidLabel.text must not be empty when ui.uidLabel.enabled=true.");
if (Ui.CutscenePanel.Enabled)
{
if (string.IsNullOrWhiteSpace(Ui.CutscenePanel.Host))
throw new InvalidDataException("ui.cutscenePanel.host must not be empty when enabled.");
Port(Ui.CutscenePanel.Port, "ui.cutscenePanel.port");
}
if (string.IsNullOrWhiteSpace(Logging.Directory))
throw new InvalidDataException("logging.directory must not be empty.");
if (Logging.Packets.MaxBodyBytes < 0)
throw new InvalidDataException("logging.packets.maxBodyBytes must be >= 0 (0 means unlimited).");
if (string.IsNullOrWhiteSpace(Paths.ClientConfigs))
throw new InvalidDataException("paths.clientConfigs must not be empty.");
if (string.IsNullOrWhiteSpace(Paths.RuntimeFastpatch))
throw new InvalidDataException("paths.runtimeFastpatch must not be empty.");
if (Debug.Enabled)
{
if (string.IsNullOrWhiteSpace(Debug.Host)) throw new InvalidDataException("debug.host must not be empty when debug is enabled.");
Port(Debug.Port, "debug.port");
}
}
}
public sealed class ClientSettings
{
public int Version { get; init; }
public string ArtifactVersion { get; init; } = string.Empty;
public int ServerId { get; init; }
public int Aid { get; init; }
public string RpcMd5 { get; init; } = string.Empty;
public int NoMoreHotfixPatchError { get; init; }
}
public sealed class NetworkSettings
{
public string BindHost { get; init; } = string.Empty;
public string AdvertisedHost { get; init; } = string.Empty;
public int[] LoginPorts { get; init; } = [];
public int GamePort { get; init; }
public ProxyNetworkSettings Proxy { get; init; } = new();
}
public sealed class ProxyNetworkSettings
{
public int HttpsPort { get; init; }
public int HttpPort { get; init; }
public int LoginListPort { get; init; }
public int LoginTcpPort { get; init; }
public int GameTcpPort { get; init; }
public int SceneSubPort { get; init; }
public string UpdateHost { get; init; } = string.Empty;
public string CertificatePassphrase { get; init; } = string.Empty;
}
public sealed class PlayerSettings
{
public ulong Pid { get; init; }
public ulong InitialUnitId { get; init; }
public uint InitialSpiritTemplateId { get; init; }
public string AccountId { get; init; } = string.Empty;
public string UserName { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string LoginToken { get; init; } = string.Empty;
public string GameToken { get; init; } = string.Empty;
public string ShareToken { get; init; } = string.Empty;
public string FpPassToken { get; init; } = string.Empty;
public string Skey { get; init; } = string.Empty;
public uint LoginUniverseId { get; init; }
}
public sealed class WorldSettings
{
public uint RaidId { get; init; }
public ulong SceneInstanceId { get; init; }
public uint UniverseId { get; init; }
public Vector3Settings Spawn { get; init; } = new();
public float Facing { get; init; }
public bool EntryOpeningEnabled { get; init; }
public WorldContentSettings Content { get; init; } = new();
}
/// <summary>
/// World content streaming. The client renders lampposts and street props from
/// its baked SceneItemBlob once the server pushes the destructible grid window;
/// gadgets (lifts, doors, vending machines) use the client build's own baked
/// GadgetBlob through SyncGadgetGridAOIDecrease; DynamicGOs are re-seeded while
/// the client finishes loading its world headers. All data comes from paths.worldData.
/// </summary>
public sealed class WorldContentSettings
{
/// <summary>Master switch for all world-content streaming.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Legacy primary scene name retained for config compatibility.</summary>
public string Scene { get; init; } = "WorldMap_Release";
/// <summary>
/// Build-local scene data allowed to supplement the current free-roam scene.
/// FIX23V uses only WorldMap_Release for this server/raid; SceneItems themselves are
/// resolved from the client SceneItemBlob and do not use this list as a placement whitelist.
/// </summary>
public string[] ProductionScenes { get; init; } = ["WorldMap_Release"];
public SceneItemStreamSettings SceneItems { get; init; } = new();
public GadgetStreamSettings Gadgets { get; init; } = new();
public DynamicGoStreamSettings DynamicGo { get; init; } = new();
}
/// <summary>Grid window for baked scene items (lampposts, props, breakables).</summary>
public sealed class SceneItemStreamSettings
{
public bool Enabled { get; init; } = true;
/// <summary>Window half-size in 40m cells (7x7 window at 3).</summary>
public int RadiusCells { get; init; } = 3;
/// <summary>Cells beyond this distance from the player are unloaded (hysteresis) when retention is disabled.</summary>
public int UnloadRadiusCells { get; init; } = 6;
/// <summary>Keep visited cells resident to avoid destroy/re-create races while streaming quickly.</summary>
public bool RetainVisitedCells { get; init; } = true;
/// <summary>When true, request every authored object in each production cell instead of applying the extracted sector filter.</summary>
public bool LoadAllSectors { get; init; } = false;
/// <summary>Exclude client-authored SceneItem entries whose build-local path is explicitly marked temp/test/debug.</summary>
public bool ExcludeTestItems { get; init; } = true;
/// <summary>Max new cells pushed per stream so the load staggers instead of hitching.</summary>
public int LoadBudgetCells { get; init; } = 49;
}
/// <summary>Grid window for client-baked gadgets (lifts, doors, vending machines).</summary>
public sealed class GadgetStreamSettings
{
public bool Enabled { get; init; } = true;
public int RadiusCells { get; init; } = 3;
public int UnloadRadiusCells { get; init; } = 6;
public bool RetainVisitedCells { get; init; } = true;
/// <summary>When true, request every authored gadget in each production cell instead of applying the extracted sector filter.</summary>
public bool LoadAllSectors { get; init; } = false;
/// <summary>Maximum new production cells sent per update. A 7x7 radius-3 window is 49 cells.</summary>
public int LoadBudgetCells { get; init; } = 49;
}
/// <summary>DynamicGo re-seeding after gameplay-ready.</summary>
public sealed class DynamicGoStreamSettings
{
public bool Enabled { get; init; } = true;
/// <summary>How many times SyncDynamicGoActiveInfo is re-sent (client headers load async).</summary>
public int SeedAttempts { get; init; } = 12;
/// <summary>Activate every DynamicGo id found in the build-4229938 probe.</summary>
public bool ActivateAllKnown { get; init; } = true;
/// <summary>Force explicitly-authored test/temp/debug DynamicGo blockouts inactive.</summary>
public bool ExcludeTestPlaceholders { get; init; } = true;
}
public sealed class Vector3Settings
{
public float X { get; init; }
public float Y { get; init; }
public float Z { get; init; }
internal Vec3 ToVec3() => new(X, Y, Z);
}
public sealed class ContentSettings
{
public RosterSettings Roster { get; init; } = new();
public uint[] ExcludedUnlockSystems { get; init; } = [];
public bool IncludePhoneBottomApps { get; init; }
}
public sealed class RosterSettings
{
public uint TemplateIdMin { get; init; }
public uint TemplateIdMaxExclusive { get; init; }
public uint[] ExcludedTemplateIds { get; init; } = [];
public uint GenericPlaceholderIconId { get; init; }
public ulong UnitIdBase { get; init; }
public int DefaultUrbanJob { get; init; }
}
public sealed class GameplaySettings
{
public SwitchAnimationSettings SwitchAnimations { get; init; } = new();
public CombatSettings Combat { get; init; } = new();
public WebTraversalSettings WebTraversal { get; init; } = new();
public VehicleSettings Vehicles { get; init; } = new();
}
public sealed class VehicleSettings
{
public bool Enabled { get; init; } = true;
public uint[] FleetIds { get; init; } = [];
public float DesiredApproachMeters { get; init; } = 32f;
public float MinimumApproachMeters { get; init; } = 24f;
public float SearchRadiusMeters { get; init; } = 80f;
public int ZoneStorageDataHandle { get; init; } = -1044702623;
}
public sealed class SwitchAnimationSettings
{
public int SwitchType { get; init; }
public string CommonTimelinePrefix { get; init; } = string.Empty;
public bool AvoidImmediateRepeat { get; init; }
}
public sealed class CombatSettings
{
public float MaxHp { get; init; }
public float Attack { get; init; }
public float Defense { get; init; }
public float MoveSpeedMultiplier { get; init; }
public float PanelDamage { get; init; }
public float PanelDefenseDeduct { get; init; }
public uint FightStyleTypeId { get; init; }
public uint FightStyleId { get; init; }
public int[] DefaultUrbanAbilities { get; init; } = [];
public WeaponSettings Weapon { get; init; } = new();
public ResourceSettings Resource { get; init; } = new();
public SkillSettings Skills { get; init; } = new();
public SkillChargeSettings[] Charges { get; init; } = [];
}
public sealed class WeaponSettings
{
public uint TemplateId { get; init; }
public ulong InstanceId { get; init; }
}
public sealed class ResourceSettings
{
public uint Id { get; init; }
public float Max { get; init; }
}
public sealed class SkillSettings
{
public uint Common { get; init; }
public uint PressCommon { get; init; }
public uint HeavyCommon { get; init; }
public uint Dodge { get; init; }
public uint DodgeAttack { get; init; }
public uint GrappleAttack { get; init; }
public uint Active { get; init; }
public uint Unique { get; init; }
internal IEnumerable<uint> All()
{
yield return Common;
yield return PressCommon;
yield return HeavyCommon;
yield return Dodge;
yield return DodgeAttack;
yield return GrappleAttack;
yield return Active;
yield return Unique;
}
internal uint ByName(string name) => name.ToLowerInvariant() switch
{
"common" => Common,
"presscommon" => PressCommon,
"heavycommon" => HeavyCommon,
"dodge" => Dodge,
"dodgeattack" => DodgeAttack,
"grappleattack" => GrappleAttack,
"active" => Active,
"unique" => Unique,
_ => throw new InvalidDataException($"Unknown gameplay.combat.charges skill name '{name}'.")
};
}
public sealed class SkillChargeSettings
{
public string Skill { get; init; } = string.Empty;
public uint Current { get; init; }
public uint Max { get; init; }
public float Period { get; init; }
}
public sealed class WebTraversalSettings
{
public uint MaleProtagonistId { get; init; }
public uint FemaleProtagonistId { get; init; }
public uint PersistentGrappleBuffId { get; init; }
public int GrappleRearmDelayMs { get; init; }
public uint[] SharedBuffIds { get; init; } = [];
public uint[] MaleExtraBuffIds { get; init; } = [];
public uint[] FemaleExtraBuffIds { get; init; } = [];
public uint[] ForbidStateIds { get; init; } = [];
}
public sealed class UiSettings
{
/// <summary>White text rendered next to UID. This is the user-facing watermark setting.</summary>
public UidLabelSettings UidLabel { get; init; } = new();
public bool RemoveStockConfidentialLabel { get; init; }
public CutscenePanelSettings CutscenePanel { get; init; } = new();
}
public sealed class CutscenePanelSettings
{
public bool Enabled { get; init; } = true;
public string Host { get; init; } = "127.0.0.1";
public int Port { get; init; } = 17666;
}
public sealed class UidLabelSettings
{
public bool Enabled { get; init; }
public string Text { get; init; } = string.Empty;
}
public sealed class LoggingSettings
{
/// <summary>Project-relative directory for console and packet logs.</summary>
public string Directory { get; init; } = string.Empty;
public ConsoleLoggingSettings Console { get; init; } = new();
public PacketLoggingSettings Packets { get; init; } = new();
}
public sealed class ConsoleLoggingSettings
{
public bool Enabled { get; init; }
}
public sealed class PacketLoggingSettings
{
public bool Enabled { get; init; }
public bool IncludeHex { get; init; }
public bool IncludeDecoded { get; init; }
/// <summary>Maximum packet body bytes written as hex; 0 means unlimited.</summary>
public int MaxBodyBytes { get; init; }
}
public sealed class PathSettings
{
/// <summary>Directory containing client JSON configs such as FightSpiritConfig.json.</summary>
public string ClientConfigs { get; init; } = string.Empty;
public string ZoneGraphLanes { get; init; } = string.Empty;
/// <summary>Directory with extracted world data (Gadget.json, SceneItem.json,
/// Destructible.json, gadget_cells.json, gadget_placements.json).</summary>
public string WorldData { get; init; } = string.Empty;
/// <summary>Disposable generated fastpatch staging directory.</summary>
public string RuntimeFastpatch { get; init; } = ".runtime/fastpatch";
}
/// <summary>Localhost-only HTTP debug API for the Python debug panel (status, garage resync, teleport).</summary>
public sealed class DebugSettings
{
public bool Enabled { get; init; } = true;
public string Host { get; init; } = "127.0.0.1";
public int Port { get; init; } = 5809;
}
internal static class PrivateServerConfigStore
{
private static PrivateServerConfig? _current;
private static string? _configPath;
internal static PrivateServerConfig Current => _current
?? throw new InvalidOperationException("PrivateServerConfigStore.Initialize must run before protocol/gameplay code.");
internal static string ConfigPath => _configPath
?? throw new InvalidOperationException("PrivateServerConfigStore.Initialize must run before path resolution.");
private static string? _projectRoot;
internal static string ProjectRoot => _projectRoot
?? throw new InvalidOperationException("PrivateServerConfigStore.Initialize must run before path resolution.");
internal static void Initialize(PrivateServerConfig config, string configPath)
{
_current = config;
_configPath = Path.GetFullPath(configPath);
_projectRoot = FindProjectRoot(_configPath);
}
private static string FindProjectRoot(string configPath)
{
var explicitRoot = Environment.GetEnvironmentVariable("Ananta_ROOT");
if (!string.IsNullOrWhiteSpace(explicitRoot))
return Path.GetFullPath(explicitRoot);
static string? SearchUp(string? start)
{
if (string.IsNullOrWhiteSpace(start)) return null;
var first = new DirectoryInfo(Path.GetFullPath(start));
// Prefer the solution root. A copied config may also exist under bin/Debug, but that
// directory is not the project root and must not become the base for ClientData/.runtime.
for (var current = first; current is not null; current = current.Parent)
if (File.Exists(Path.Combine(current.FullName, "AnantaPS.sln")))
return current.FullName;
// Standalone deployments may not contain the solution file. In that case a config
// directory is the best available root marker.
for (var current = first; current is not null; current = current.Parent)
if (File.Exists(Path.Combine(current.FullName, "config", "private-server.json")))
return current.FullName;
return null;
}
var fromConfig = SearchUp(Path.GetDirectoryName(configPath));
if (fromConfig is not null) return fromConfig;
var fromBase = SearchUp(AppContext.BaseDirectory);
if (fromBase is not null) return fromBase;
var fromCurrent = SearchUp(Directory.GetCurrentDirectory());
if (fromCurrent is not null) return fromCurrent;
throw new DirectoryNotFoundException(
"Cannot locate the Ananta project root. Set Ananta_ROOT or launch from the project directory.");
}
internal static string ResolveProjectPath(string configuredPath)
=> Path.IsPathRooted(configuredPath)
? Path.GetFullPath(configuredPath)
: Path.GetFullPath(Path.Combine(ProjectRoot, configuredPath));
}
internal static class PrivateServerConfigLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy.CamelCase) },
};
internal static (PrivateServerConfig Config, string Path) Load()
{
var explicitPath = Environment.GetEnvironmentVariable("Ananta_CONFIG");
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(explicitPath)) candidates.Add(explicitPath);
candidates.Add(Path.Combine(Directory.GetCurrentDirectory(), "config", "private-server.json"));
candidates.Add(Path.Combine(AppContext.BaseDirectory, "config", "private-server.json"));
var path = candidates.Select(Path.GetFullPath).FirstOrDefault(File.Exists)
?? throw new FileNotFoundException("Cannot find config/private-server.json. Set Ananta_CONFIG to an explicit path if needed.");
var json = File.ReadAllText(path);
var config = JsonSerializer.Deserialize<PrivateServerConfig>(json, JsonOptions)
?? throw new InvalidDataException($"Configuration file is empty: {path}");
config.Validate();
return (config, path);
}
}
@@ -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;
}
}
}