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,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);
|
||||
Reference in new issue
Block a user