382 lines
18 KiB
C#
382 lines
18 KiB
C#
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);
|