Initial commit: Ananta CBT 4229938 private server with all fixes
This commit is contained in:
commit
c84135c357
286 files changed
+2214315
No files matched your search
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user