Initial commit: Ananta CBT 4229938 private server with all fixes

This commit is contained in:
CAtlas0784 committed 2026-09-15 18:13:46 +07:00
commit c84135c357
286 files changed
+2214315

No files matched your search

@@ -0,0 +1,13 @@
<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.Core\Ananta.Core.csproj" />
<ProjectReference Include="..\Ananta.Gameplay\Ananta.Gameplay.csproj" />
<ProjectReference Include="..\Ananta.RpcTypes\Ananta.RpcTypes.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,2 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Ananta.App")]
@@ -0,0 +1,605 @@
using Ananta.SDK.Rpc;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Handlers;
using Ananta.Server.Protocol.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>Authoritative weapon, fight-style and skill lifecycle for the current local unit.</summary>
internal sealed partial class GameRouter
{
async Task OnClientUseSkill(RpcContext ctx, SceneMethods.SkillUseData req)
{
var state = GetWorldState(ctx);
await ctx.ReturnEmptyOkAsync();
if (!state.CombatProfilePublished)
{
ctx.Session.Log.Warn("[COMBAT] skill request arrived before initial combat profile");
return;
}
if (req.Releaser != 0 && req.Releaser != state.ActiveSpiritUnitId)
{
ctx.Session.Log.Warn($"[COMBAT] reject releaser={req.Releaser} active={state.ActiveSpiritUnitId} skill={req.SkillId}");
return;
}
var style = ActiveStyle(state);
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
if (weapon.IsReloadSkill(req.SkillId))
await ReloadWeapon4229938(ctx, state, weapon);
if (!weapon.AllowsSkill(style, req.SkillId))
{
ctx.Session.Log.Warn($"[COMBAT] reject unknown-skill style={style.Id} weapon={state.ActiveWeaponInstanceId} skill={req.SkillId} inst={req.SkillInstanceId}");
return;
}
state.CombatUseCount++;
state.RestoreResourcesAfterActiveSkill |=
req.SkillId == weapon.ActiveSkill(style) ||
req.SkillId == weapon.UniqueSkill(style) ||
weapon.Cooldown(style, req.SkillId, 0f) > 0f ||
weapon.MaximumCharges(style, req.SkillId) > 0;
state.ActiveSkillId = req.SkillId;
state.ActiveClientSkillInstanceId = req.SkillInstanceId;
state.ActiveSkillStartedTicks = Environment.TickCount64;
if (state.CombatUseCount <= 20 || state.CombatUseCount % 50 == 0)
ctx.Session.Log.Info($"[COMBAT] use unit={state.ActiveSpiritUnitId} weapon={state.ActiveWeaponInstanceId} style={style.Id} skill={req.SkillId} inst={req.SkillInstanceId} target={req.TargetId} nativeAction=true count={state.CombatUseCount}");
}
async Task OnReportSkillEnd(RpcContext ctx, SceneMethods.ReportSkillEnd report)
{
var state = GetWorldState(ctx);
state.CombatEndCount++;
var elapsed = state.ActiveSkillStartedTicks > 0
? Math.Max(0, Environment.TickCount64 - state.ActiveSkillStartedTicks)
: -1;
if (state.CombatEndCount <= 20 || state.CombatEndCount % 50 == 0)
ctx.Session.Log.Info($"[COMBAT] end unit={report.unitId} token={report.skillId} next={report.newSkillId} break={report.isBreak} elapsedMs={elapsed} count={state.CombatEndCount}");
var style = ActiveStyle(state);
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
if (report.newSkillId != 0 && weapon.AllowsSkill(style, report.newSkillId))
{
// isBreak describes how the previous native action ended, not whether the authored chain
// ended. The client reports isBreak=true + a non-zero newSkillId for ordinary rapid-click
// combo transitions. Keep every known next skill alive and never inject cooldown/resource
// snapshots between click, hold and release stages.
state.ActiveSkillId = report.newSkillId;
state.ActiveClientSkillInstanceId = 0;
state.ActiveSkillStartedTicks = Environment.TickCount64;
return;
}
var restoreResources = state.RestoreResourcesAfterActiveSkill;
state.ActiveSkillId = 0;
state.RestoreResourcesAfterActiveSkill = false;
state.ActiveClientSkillInstanceId = 0;
state.ActiveSkillStartedTicks = 0;
// Ordinary attacks must finish without any server-side skill-state rewrite. Re-publishing the
// whole charge/resource snapshot after each common attack can make the client save a new action
// while the previous trigger graph is still iterating, which restarts the first animation on
// every click. Only cooldown/charge abilities and ultimates need sandbox restoration.
if (!restoreResources)
return;
// The private server runs an unrestricted combat sandbox: restore charges and ultimate energy
// only after the complete authored ability chain has ended, never between combo/hold stages.
await ctx.NotifyAsync(MethodId.SyncPlayerAllSkillChargeData,
CombatCodec.AllSkillCharges(state.ActiveSpiritUnitId, weapon, style));
await ctx.NotifyAsync(MethodId.SyncFightResource,
CombatCodec.FightResource(state.ActiveSpiritUnitId, CombatCodec.UltimateResourceId, CombatCodec.UltimateResourceMax));
await ctx.NotifyAsync(MethodId.SyncFightResourceFreeState,
CombatCodec.FightResourceFreeState(state.ActiveSpiritUnitId, CombatCodec.UltimateResourceId, true));
}
Task OnSkillHit(RpcContext ctx, SceneMethods.SkillHitData hit)
{
var state = GetWorldState(ctx);
if (hit.ReleaserId != 0 && hit.ReleaserId != state.ActiveSpiritUnitId)
{
ctx.Session.Log.Warn($"[HIT] reject releaser={hit.ReleaserId} active={state.ActiveSpiritUnitId} skill={hit.SkillId}");
return Task.CompletedTask;
}
var style = ActiveStyle(state);
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
if (!weapon.AllowsSkill(style, hit.SkillId))
{
ctx.Session.Log.Warn($"[HIT] reject unknown-skill style={style.Id} skill={hit.SkillId} target={hit.HitTarget}");
return Task.CompletedTask;
}
state.CombatHitCount++;
if (state.CombatHitCount <= 20 || state.CombatHitCount % 100 == 0)
ctx.Session.Log.Info($"[HIT] accepted unit={state.ActiveSpiritUnitId} skill={hit.SkillId} target={hit.HitTarget} stage={hit.Stage} trigger={hit.TriggerIndex}/{hit.TriggerInstanceId} material={hit.HitMaterial} reflected={hit.IsReflected} count={state.CombatHitCount}");
return Task.CompletedTask;
}
async Task OnSwitchWeapon(RpcContext ctx, int index)
{
var state = GetWorldState(ctx);
if (!state.Ready || state.PendingSwitchTemplateId != 0)
{
ctx.Session.Log.Warn($"[WEAPON] ignored index={index} ready={state.Ready} pendingSpirit={state.PendingSwitchTemplateId}");
return;
}
var slotWeapons = WeaponSlotDefinitions(state, state.ActiveSpiritTemplateId);
var weapon = index >= 0 && index < slotWeapons.Count ? slotWeapons[index] : null;
if (weapon is null)
{
ctx.Session.Log.Warn($"[WEAPON] invalid index={index} template={state.ActiveSpiritTemplateId} slots={slotWeapons.Count}");
return;
}
var style = ResolveWeaponStyle(state, weapon);
if (state.ActiveSkillId != 0)
await ctx.NotifyAsync(MethodId.SyncBreakSkill, CombatCodec.BreakSkill(state.ActiveSpiritUnitId));
state.ActiveSkillId = 0;
state.RestoreResourcesAfterActiveSkill = false;
state.ActiveClientSkillInstanceId = 0;
state.ActiveSkillStartedTicks = 0;
state.ActiveWeaponInstanceId = weapon.InstanceId;
state.ActiveFightStyleId = style.Id;
state.LastWeaponBySpirit[state.ActiveSpiritTemplateId] = weapon.InstanceId;
// Re-send the authoritative slot snapshot as a cheap self-heal if the client rebuilt its
// WeaponManager during a scene/UI transition.
await PublishWeaponSnapshot(ctx, state.ActiveSpiritUnitId, state.ActiveSpiritTemplateId, weapon.InstanceId);
await ctx.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction,
CombatCodec.SpiritSwitchWeapon(state.ActiveSpiritUnitId, weapon.InstanceId));
await PublishSelectedWeaponProfile(ctx, state.ActiveSpiritUnitId, state.ActiveSpiritTemplateId, weapon, style);
ctx.Session.Log.Info($"[WEAPON] switched template={state.ActiveSpiritTemplateId} unit={state.ActiveSpiritUnitId} slot={index} page={index / 8 + 1} weapon={weapon.TemplateId}/{weapon.InstanceId} name={weapon.Name} style={style.FightSkillTypeId}:{style.Id} skills={style.CommonSkill},{style.HeavyCommonSkill},{weapon.ActiveSkill(style)},{weapon.UniqueSkill(style)}");
}
async Task OnSwitchFightStyle(RpcContext ctx, uint spiritId, uint fightStyleTypeId, uint fightStyleId)
{
var state = GetWorldState(ctx);
var style = CombatCatalogRepository.Style(fightStyleId);
if (style is null || !CombatCatalogRepository.StyleMatchesType(fightStyleId, fightStyleTypeId) ||
(style.SpiritIds.Count > 0 && !style.SpiritIds.Contains(spiritId)))
{
ctx.Session.Log.Warn($"[STYLE] rejected spirit={spiritId} type={fightStyleTypeId} style={fightStyleId}");
return;
}
if (spiritId == state.ActiveSpiritTemplateId &&
CombatCodec.Weapon(spiritId, state.ActiveWeaponInstanceId) is { } activeWeapon &&
(activeWeapon.WeaponFightSkillTypeId == fightStyleTypeId || activeWeapon.Style.FightSkillTypeId == fightStyleTypeId) &&
!CombatCatalogRepository.IsStyleCompatible(activeWeapon, style))
{
var safeStyle = ResolveWeaponStyle(state, activeWeapon);
await ctx.NotifyAsync(MethodId.SyncSpiritFightStyleChangeAction,
CombatCodec.FightStyleAction(spiritId, SpiritFightStyleOverrides(state, spiritId)));
ctx.Session.Log.Warn($"[STYLE] rejected+restored model-incompatible spirit={spiritId} weapon={activeWeapon.TemplateId}/{activeWeapon.InstanceId} requested={fightStyleId} restored={safeStyle.Id} actionType={style.ActionType} baseActionType={activeWeapon.Style.ActionType}");
return;
}
// The RPC carries the category being replaced. Keep exactly that key; duplicating the
// style under FightSkillConfig.FightSkillType can overwrite an unrelated special category.
state.SpiritStyleOverrides[(spiritId, fightStyleTypeId)] = fightStyleId;
await ctx.NotifyAsync(MethodId.SyncSpiritFightStyleChangeAction,
CombatCodec.FightStyleAction(spiritId, SpiritFightStyleOverrides(state, spiritId)));
if (spiritId == state.ActiveSpiritTemplateId)
{
// FightStyleManager also keeps a weapon-instance style cache. Refresh the equipped
// instances in this category so the explicit live FightStyleId follows Replace All.
foreach (var instanceId in WeaponSlotIds(state, spiritId).Where(x => x != 0).Distinct())
{
if (CombatCodec.Weapon(spiritId, instanceId) is not { } slotWeapon)
continue;
if (slotWeapon.WeaponFightSkillTypeId != fightStyleTypeId &&
slotWeapon.Style.FightSkillTypeId != fightStyleTypeId)
continue;
var slotStyle = ResolveWeaponStyle(state, slotWeapon);
await ctx.NotifyAsync(MethodId.SyncWeaponFightStyleChange,
new GameMethods.SyncWeaponFightStyleChange
{
weaponInstanceId = instanceId,
fightStyleId = slotStyle.Id
});
}
var weapon = CombatCodec.Weapon(spiritId, state.ActiveWeaponInstanceId);
if (weapon is not null &&
(weapon.WeaponFightSkillTypeId == fightStyleTypeId || weapon.Style.FightSkillTypeId == fightStyleTypeId))
{
if (state.ActiveSkillId != 0)
await ctx.NotifyAsync(MethodId.SyncBreakSkill, CombatCodec.BreakSkill(state.ActiveSpiritUnitId));
state.ActiveSkillId = 0;
state.RestoreResourcesAfterActiveSkill = false;
state.ActiveClientSkillInstanceId = 0;
state.ActiveSkillStartedTicks = 0;
var effectiveStyle = ResolveWeaponStyle(state, weapon);
state.ActiveFightStyleId = effectiveStyle.Id;
await PublishSelectedWeaponProfile(ctx, state.ActiveSpiritUnitId, spiritId, weapon, effectiveStyle);
}
}
ctx.Session.Log.Info($"[STYLE] switched spirit={spiritId} type={fightStyleTypeId} style={fightStyleId} name={style.Name}");
}
async Task OnSetWeaponFightStyle(RpcContext ctx, ulong weaponInstanceId, uint fightStyleId)
{
var state = GetWorldState(ctx);
var weapon = CombatCatalogRepository.Weapon(state.ActiveSpiritTemplateId, weaponInstanceId)
?? CombatCatalogRepository.Weapon(weaponInstanceId);
var style = CombatCatalogRepository.Style(fightStyleId);
if (weapon is null || style is null || !CombatCatalogRepository.IsStyleCompatible(weapon, style))
{
if (weapon is not null)
{
var safeStyle = ResolveWeaponStyle(state, weapon);
await ctx.NotifyAsync(MethodId.SyncWeaponFightStyleChange,
new GameMethods.SyncWeaponFightStyleChange
{
weaponInstanceId = weaponInstanceId,
fightStyleId = safeStyle.Id
});
if (weaponInstanceId == state.ActiveWeaponInstanceId)
await PublishSelectedWeaponProfile(ctx, state.ActiveSpiritUnitId, state.ActiveSpiritTemplateId, weapon, safeStyle);
ctx.Session.Log.Warn($"[STYLE] rejected+restored weapon={weapon.TemplateId}/{weaponInstanceId} requested={fightStyleId} restored={safeStyle.Id} requestedAction={style?.ActionType ?? 0} baseAction={weapon.Style.ActionType}");
}
else
{
ctx.Session.Log.Warn($"[STYLE] rejected unknown weapon={weaponInstanceId} style={fightStyleId}");
}
return;
}
state.WeaponStyleOverrides[weaponInstanceId] = fightStyleId;
await ctx.NotifyAsync(MethodId.SyncWeaponFightStyleChange,
new GameMethods.SyncWeaponFightStyleChange
{
weaponInstanceId = weaponInstanceId,
fightStyleId = fightStyleId
});
if (weaponInstanceId == state.ActiveWeaponInstanceId)
{
if (state.ActiveSkillId != 0)
await ctx.NotifyAsync(MethodId.SyncBreakSkill, CombatCodec.BreakSkill(state.ActiveSpiritUnitId));
state.ActiveSkillId = 0;
state.RestoreResourcesAfterActiveSkill = false;
state.ActiveClientSkillInstanceId = 0;
state.ActiveSkillStartedTicks = 0;
state.ActiveFightStyleId = style.Id;
await PublishSelectedWeaponProfile(ctx, state.ActiveSpiritUnitId, state.ActiveSpiritTemplateId, weapon, style);
}
ctx.Session.Log.Info($"[STYLE] weapon-style weapon={weaponInstanceId} style={fightStyleId} name={style.Name}");
}
async Task OnLoadWeaponToSlot(RpcContext ctx, uint spiritId, ulong weaponInstanceId, int slotIndex)
{
var state = GetWorldState(ctx);
var slots = WeaponSlotIds(state, spiritId);
if (!IsEditableWeaponSlot(slotIndex) ||
CombatCatalogRepository.Weapon(weaponInstanceId) is null ||
CombatCatalogRepository.Weapon(spiritId, weaponInstanceId) is null)
{
ctx.Session.Log.Warn($"[ARMORY] load rejected spirit={spiritId} weapon={weaponInstanceId} slot={slotIndex}");
return;
}
var existingIndex = slots.IndexOf(weaponInstanceId);
if (existingIndex >= 0 && existingIndex != slotIndex)
(slots[existingIndex], slots[slotIndex]) = (slots[slotIndex], slots[existingIndex]);
else
slots[slotIndex] = weaponInstanceId;
await PublishWeaponSlotsAfterMutation(ctx, spiritId, $"load:{weaponInstanceId}@{slotIndex}");
}
async Task OnDepositSpiritWeapon(RpcContext ctx, uint spiritId, int slotIndex)
{
var state = GetWorldState(ctx);
var slots = WeaponSlotIds(state, spiritId);
if (!IsEditableWeaponSlot(slotIndex))
{
ctx.Session.Log.Warn($"[ARMORY] deposit rejected spirit={spiritId} slot={slotIndex}");
return;
}
// Empty wheel positions are represented by a normal complex-object null marker. Keep the
// shared armory intact while removing the weapon only from this character's personal wheel.
slots[slotIndex] = 0;
await PublishWeaponSlotsAfterMutation(ctx, spiritId, $"deposit:{slotIndex}");
}
async Task OnExchangeWeaponSlot(
RpcContext ctx,
uint fromSpirit,
int fromIndex,
uint toSpirit,
int toIndex)
{
var state = GetWorldState(ctx);
var fromSlots = WeaponSlotIds(state, fromSpirit);
var toSlots = WeaponSlotIds(state, toSpirit);
if (!IsEditableWeaponSlot(fromIndex) || !IsEditableWeaponSlot(toIndex))
{
ctx.Session.Log.Warn($"[ARMORY] exchange rejected from={fromSpirit}:{fromIndex} to={toSpirit}:{toIndex}");
return;
}
(fromSlots[fromIndex], toSlots[toIndex]) = (toSlots[toIndex], fromSlots[fromIndex]);
await PublishWeaponSlotsAfterMutation(ctx, fromSpirit, $"exchange:{fromIndex}->{toSpirit}:{toIndex}");
if (toSpirit != fromSpirit)
await PublishWeaponSlotsAfterMutation(ctx, toSpirit, $"exchange:{toIndex}<-{fromSpirit}:{fromIndex}");
}
private async Task PublishWeaponSlotsAfterMutation(RpcContext ctx, uint spiritId, string operation)
{
var state = GetWorldState(ctx);
var slots = WeaponSlotIds(state, spiritId);
var isActive = spiritId == state.ActiveSpiritTemplateId;
var currentWeaponId = isActive
? state.ActiveWeaponInstanceId
: state.LastWeaponBySpirit.GetValueOrDefault(spiritId);
if (currentWeaponId == 0 || !slots.Contains(currentWeaponId))
currentWeaponId = slots.FirstOrDefault(x => x != 0);
if (currentWeaponId == 0)
currentWeaponId = CombatCodec.Loadout(spiritId).DefaultWeapon.InstanceId;
var unitId = isActive
? state.ActiveSpiritUnitId
: ClientConfigRepository.Characters().First(x => x.TemplateId == spiritId).UnitId;
var currentWeapon = CombatCatalogRepository.Weapon(spiritId, currentWeaponId)
?? throw new InvalidDataException($"Missing runtime view for spirit {spiritId}, weapon {currentWeaponId}.");
var currentChanged = isActive && state.ActiveWeaponInstanceId != currentWeaponId;
if (currentChanged && state.ActiveSkillId != 0)
await ctx.NotifyAsync(MethodId.SyncBreakSkill, CombatCodec.BreakSkill(state.ActiveSpiritUnitId));
state.LastWeaponBySpirit[spiritId] = currentWeaponId;
if (isActive)
{
state.ActiveSkillId = 0;
state.RestoreResourcesAfterActiveSkill = false;
state.ActiveClientSkillInstanceId = 0;
state.ActiveSkillStartedTicks = 0;
state.ActiveWeaponInstanceId = currentWeaponId;
state.ActiveFightStyleId = ResolveWeaponStyle(state, currentWeapon).Id;
}
await PublishWeaponSnapshot(ctx, unitId, spiritId, currentWeaponId);
await ctx.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon,
CombatCodec.SpiritLastUsedWeapon(spiritId, currentWeaponId));
if (currentChanged)
{
var style = ResolveWeaponStyle(state, currentWeapon);
await ctx.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction,
CombatCodec.SpiritSwitchWeapon(unitId, currentWeaponId));
await PublishSelectedWeaponProfile(ctx, unitId, spiritId, currentWeapon, style);
}
ctx.Session.Log.Info($"[ARMORY] slots committed operation={operation} spirit={spiritId} active={isActive} current={currentWeaponId} unique={slots.Where(x => x != 0).Distinct().Count()} slots={slots.Count}");
}
private static bool IsEditableWeaponSlot(int index) => index >= 0 && index < 16;
private static List<ulong> WeaponSlotIds(WorldEntryState state, uint spiritId)
{
if (state.WeaponSlotsBySpirit.TryGetValue(spiritId, out var slots))
return slots;
var loadout = CombatCodec.Loadout(spiritId);
slots = loadout.Slots.Select(x => x?.InstanceId ?? 0UL).ToList();
state.WeaponSlotsBySpirit.Add(spiritId, slots);
return slots;
}
private static IReadOnlyList<CombatWeaponDefinition?> WeaponSlotDefinitions(WorldEntryState state, uint spiritId)
=> WeaponSlotIds(state, spiritId)
.Select(instanceId => instanceId == 0 ? null :
CombatCatalogRepository.Weapon(spiritId, instanceId)
?? throw new InvalidDataException($"Missing runtime view for spirit {spiritId}, weapon {instanceId}."))
.ToArray();
Task OnSkillUseWeaponDurability(RpcContext ctx, int skillInstanceId, int triggerIndex)
{
var state = GetWorldState(ctx);
if (!state.Ready)
return Task.CompletedTask;
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
if (!weapon.IsShootWeapon || weapon.MagazineAmmo == 0)
return Task.CompletedTask;
var current = EnsureMagazine4229938(state, weapon);
if (current > 0)
state.WeaponMagazineAmmo[weapon.InstanceId] = --current;
// Separate-bullet firearms consume only the loaded magazine; their reserve is an ordinary
// backpack item. Other firearms use Durability as total remaining ammunition, so decrement
// total and magazine together. Infinite (-1) values stay infinite.
var durability = CurrentDurability4229938(state, weapon, current);
if (!weapon.SeparateBullets && durability > 0)
{
durability--;
state.WeaponDurabilityAmmo[weapon.InstanceId] = durability;
}
else if (weapon.SeparateBullets)
{
durability = current;
}
var bulletId = EnsureBullet4229938(state, weapon);
var reserve = weapon.SeparateBullets && bulletId != 0
? EnsureReserve4229938(state, bulletId)
: NonSeparateReserve4229938(durability, current);
ctx.Session.Log.Info($"[AMMO] shot weapon={weapon.TemplateId}/{weapon.InstanceId} token={skillInstanceId} trigger={triggerIndex} mag={current}/{weapon.MagazineAmmo} reserve={reserve} bullet={bulletId} total={durability}");
return ctx.NotifyAsync(MethodId.SyncSpiritWeaponDurabilityChangedAction,
CombatCodec.WeaponDurabilityChanged(state.ActiveSpiritTemplateId, state.ActiveSpiritUnitId, weapon, durability, current, bulletId));
}
async Task OnWeaponEquipBullets(RpcContext ctx, ulong weaponInstanceId, uint bulletId)
{
var state = GetWorldState(ctx);
var weapon = CombatCatalogRepository.Weapon(state.ActiveSpiritTemplateId, weaponInstanceId)
?? CombatCatalogRepository.Weapon(weaponInstanceId);
if (weapon is null || !weapon.SeparateBullets || bulletId == 0 || !weapon.BulletIds.Contains(bulletId))
{
ctx.Session.Log.Warn($"[AMMO] equip rejected weapon={weaponInstanceId} bullet={bulletId}");
return;
}
state.WeaponBulletByInstance[weaponInstanceId] = bulletId;
var mag = EnsureMagazine4229938(state, weapon);
var reserve = EnsureReserve4229938(state, bulletId);
await ctx.NotifyAsync(MethodId.SyncSpiritWeaponDurabilityChangedAction,
CombatCodec.WeaponDurabilityChanged(state.ActiveSpiritTemplateId, state.ActiveSpiritUnitId, weapon, mag, mag, bulletId));
ctx.Session.Log.Info($"[AMMO] bullet-selected weapon={weapon.TemplateId}/{weapon.InstanceId} bullet={bulletId} mag={mag}/{weapon.MagazineAmmo} reserve={reserve}");
}
private async Task ReloadWeapon4229938(RpcContext ctx, WorldEntryState state, CombatWeaponDefinition weapon)
{
if (!weapon.IsShootWeapon || weapon.MagazineAmmo <= 0)
return;
var mag = EnsureMagazine4229938(state, weapon);
var capacity = Math.Max(0, weapon.MagazineAmmo);
if (mag < 0 || mag >= capacity)
return;
var bulletId = EnsureBullet4229938(state, weapon);
if (weapon.SeparateBullets && bulletId != 0)
{
var reserve = EnsureReserve4229938(state, bulletId);
var need = (uint)Math.Max(0, capacity - mag);
var loaded = Math.Min(need, reserve);
if (loaded == 0)
return;
mag += (int)loaded;
reserve -= loaded;
state.WeaponMagazineAmmo[weapon.InstanceId] = mag;
state.BackpackItemCounts[bulletId] = reserve;
await ctx.NotifyAsync(MethodId.SyncBackpackItemChanged,
new GameMethods.SyncBackpackItemChanged4229938
{
updateItemList = [RuntimePayloadFactory.AmmoPackItem(bulletId, reserve)]
});
await ctx.NotifyAsync(MethodId.SyncSpiritWeaponDurabilityChangedAction,
CombatCodec.WeaponDurabilityChanged(state.ActiveSpiritTemplateId, state.ActiveSpiritUnitId, weapon, mag, mag, bulletId));
ctx.Session.Log.Info($"[AMMO] reload weapon={weapon.TemplateId}/{weapon.InstanceId} loaded={loaded} mag={mag}/{capacity} reserve={reserve} bullet={bulletId}");
return;
}
// Non-separate firearms use Durability as total remaining ammunition. Reload moves rounds
// from reserve into the magazine without increasing total ammo. A total of -1 is infinite.
var durability = EnsureDurability4229938(state, weapon);
var reserveNonSeparate = NonSeparateReserve4229938(durability, mag);
var needed = Math.Max(0, capacity - mag);
var loadedNonSeparate = durability < 0
? needed
: Math.Min(needed, (int)reserveNonSeparate);
if (loadedNonSeparate <= 0)
return;
mag += loadedNonSeparate;
state.WeaponMagazineAmmo[weapon.InstanceId] = mag;
await ctx.NotifyAsync(MethodId.SyncSpiritWeaponDurabilityChangedAction,
CombatCodec.WeaponDurabilityChanged(state.ActiveSpiritTemplateId, state.ActiveSpiritUnitId, weapon, durability, mag, bulletId));
ctx.Session.Log.Info($"[AMMO] reload weapon={weapon.TemplateId}/{weapon.InstanceId} loaded={loadedNonSeparate} mag={mag}/{capacity} reserve={NonSeparateReserve4229938(durability, mag)} bullet=inline total={durability}");
}
private static int EnsureMagazine4229938(WorldEntryState state, CombatWeaponDefinition weapon)
{
if (!state.WeaponMagazineAmmo.TryGetValue(weapon.InstanceId, out var value))
{
value = weapon.InitialMagazineAmmo;
state.WeaponMagazineAmmo[weapon.InstanceId] = value;
}
return value;
}
private static int EnsureDurability4229938(WorldEntryState state, CombatWeaponDefinition weapon)
{
if (!weapon.IsShootWeapon)
return weapon.Durability;
if (weapon.SeparateBullets)
return EnsureMagazine4229938(state, weapon);
if (!state.WeaponDurabilityAmmo.TryGetValue(weapon.InstanceId, out var value))
{
value = weapon.Durability;
state.WeaponDurabilityAmmo[weapon.InstanceId] = value;
}
return value;
}
private static int CurrentDurability4229938(WorldEntryState state, CombatWeaponDefinition weapon, int magazineAmmo)
=> weapon.IsShootWeapon && weapon.SeparateBullets
? magazineAmmo
: EnsureDurability4229938(state, weapon);
private static uint NonSeparateReserve4229938(int durability, int magazineAmmo)
=> durability < 0
? uint.MaxValue
: (uint)Math.Max(0, durability - Math.Max(0, magazineAmmo));
private static uint EnsureBullet4229938(WorldEntryState state, CombatWeaponDefinition weapon)
{
if (!state.WeaponBulletByInstance.TryGetValue(weapon.InstanceId, out var bulletId))
{
bulletId = weapon.BulletId;
state.WeaponBulletByInstance[weapon.InstanceId] = bulletId;
}
return bulletId;
}
private static uint EnsureReserve4229938(WorldEntryState state, uint bulletId)
{
if (bulletId == 0)
return 0;
if (!state.BackpackItemCounts.TryGetValue(bulletId, out var count))
{
count = RuntimePayloadFactory.InitialAmmoReserve4229938;
state.BackpackItemCounts[bulletId] = count;
}
return count;
}
async Task PublishSelectedWeaponProfile(
RpcContext ctx,
ulong unitId,
uint templateId,
CombatWeaponDefinition weapon,
CombatStyleDefinition style)
{
// Switching/individually styling one weapon must never overwrite the character-wide
// FightStyleInfo map. OnSwitchFightStyle publishes that map explicitly.
await ctx.NotifyAsync(MethodId.SyncPlayerAllSkillChargeData,
CombatCodec.AllSkillCharges(unitId, weapon, style));
await PublishCombatResources(ctx, unitId);
await PublishSkillBindings(ctx, unitId, weapon, style);
await ctx.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon,
CombatCodec.SpiritLastUsedWeapon(templateId, weapon.InstanceId));
}
private static CombatStyleDefinition ResolveWeaponStyle(WorldEntryState state, CombatWeaponDefinition weapon)
{
if (state.WeaponStyleOverrides.TryGetValue(weapon.InstanceId, out var weaponStyleId) &&
CombatCatalogRepository.Style(weaponStyleId) is { } weaponStyle &&
CombatCatalogRepository.IsStyleCompatible(weapon, weaponStyle))
return weaponStyle;
if (state.SpiritStyleOverrides.TryGetValue((weapon.SpiritTemplateId, weapon.WeaponFightSkillTypeId), out var spiritStyleId) &&
CombatCatalogRepository.Style(spiritStyleId) is { } spiritStyle &&
CombatCatalogRepository.IsStyleCompatible(weapon, spiritStyle))
return spiritStyle;
return weapon.Style;
}
private static CombatStyleDefinition ActiveStyle(WorldEntryState state)
{
var weapon = CombatCodec.Weapon(state.ActiveSpiritTemplateId, state.ActiveWeaponInstanceId)
?? CombatCodec.DefaultWeapon(state.ActiveSpiritTemplateId);
return ResolveWeaponStyle(state, weapon);
}
}
@@ -0,0 +1,62 @@
using Ananta.SDK.Rpc;
using Ananta.Server.Protocol.Client4229938;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Build-4229938 RPC-surface compatibility. Semantic handlers still win. Any other Invoke uses
/// the exact generated return reader shape from the matching client Lua so callbacks receive a
/// valid default value (empty list/dict, zero primitive, empty string, or non-null empty object)
/// instead of a zero-byte body.
/// </summary>
internal sealed partial class GameRouter
{
private Task DefaultUnknownInvoke4229938(RpcContext ctx)
{
if (DefaultReturnCatalog4229938.TryGet(ctx.MethodId, out var body, out var shape))
{
ctx.Session.Log.Warn($"[RPC4229938] unimplemented {Ananta.SDK.Logging.RpcMethodNames.Display(ctx.MethodId)} -> typed-default {shape} {body.Length}b");
return ctx.ReturnAsync(body);
}
ctx.Session.Log.Warn($"[RPC4229938] no generated return reader for {Ananta.SDK.Logging.RpcMethodNames.Display(ctx.MethodId)} -> empty OK");
return ctx.ReturnEmptyOkAsync();
}
private Task DefaultUnknownNotify4229938(RpcContext ctx)
{
// Notifies have no callback body. Accepting an unimplemented generated 4229938 notify is
// safer than treating it as a protocol error; stateful methods get semantic handlers as
// they are implemented, while telemetry/client-local signals remain harmless no-ops.
ctx.Session.Log.Warn($"[RPC4229938] unimplemented notify {Ananta.SDK.Logging.RpcMethodNames.Display(ctx.MethodId)} -> accepted");
return Task.CompletedTask;
}
// High-frequency/UI RPCs seen in stock 4229938 are made explicit so they no longer appear as
// unknown traffic. The server does not emulate production economy/social services here; it
// returns the exact neutral value expected by the generated client callback.
[Handler(MethodId.AskTradeGetMarketList, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskTradeGetHistoryPage, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskPlayerRankingSummary, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskPopularityPhoneFirstOpened, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskPopularityUIOpened, HandlerPacketKind.Invoke)]
[Handler(MethodId.SyncOpenInspireHub, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskQueryInspireHubAllGamePlayRecommendData, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskQueryInspireHubAllGamePlayRankData, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskLinkInfos, HandlerPacketKind.Invoke)]
[Handler(MethodId.GetLastMode, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskSwitchLinkMode, HandlerPacketKind.Invoke)]
private Task KnownNeutralInvoke4229938(Connection conn, UxRpcMessage msg)
{
if (DefaultReturnCatalog4229938.TryGet(msg.MethodId, out var body, out var shape))
{
conn.Log.Info($"[RPC4229938] neutral {Ananta.SDK.Logging.RpcMethodNames.Display(msg.MethodId)} -> {shape} {body.Length}b");
return msg.Context.ReturnAsync(body);
}
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.OnParkourStateChange, HandlerPacketKind.Invoke)]
private Task OnParkourStateChange4229938(Connection conn, UxRpcMessage msg)
=> conn.ReturnEmptyOkAsync(msg);
}
@@ -0,0 +1,419 @@
using Ananta.SDK.Rpc;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>Only RPC endpoints used by the maintained 4229938 private-server feature set.</summary>
internal sealed partial class GameRouter
{
[Handler(MethodId.LoginGame, HandlerPacketKind.Notify)]
private Task LoginGame(Connection conn, UxRpcMessage msg)
=> SendInitialGameState(msg.Context);
[Handler(MethodId.RequestGameSceneData, HandlerPacketKind.Notify)]
private Task RequestGameSceneData(Connection conn, UxRpcMessage msg)
=> SendEnterSceneIfNeeded(msg.Context);
[Handler(MethodId.GetServerTimeGame)]
private async Task GetServerTime(Connection conn, UxRpcMessage msg)
{
if (msg.IsInvoke)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var args = msg.GetArgs<GameMethods.GetServerTime>();
await conn.NotifyAsync(MethodId.SendServerTimeGame, LoginCodec.ServerTime(args.clientUnixTime));
}
[Handler(MethodId.AskAllSpiritPanelData, HandlerPacketKind.Invoke)]
private Task AskAllSpiritPanelData(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, RuntimePayloadFactory.MinimalAllSpiritPanelData4229938());
[Handler(MethodId.GetSpriteToken, HandlerPacketKind.Invoke)]
private Task GetSpriteToken4229938(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, new GameMethods.SpriteToken4229938
{
Token = string.Empty,
ExpireTimeStamp = 0
});
[Handler(MethodId.CheckPSOPermissions, HandlerPacketKind.Invoke)]
private Task CheckPsoPermissions(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, false);
[Handler(MethodId.AskSetGamePause, HandlerPacketKind.Invoke)]
private async Task AskSetGamePause(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskSetGamePause>();
await conn.ReturnEmptyOkAsync(msg);
await conn.NotifyAsync(MethodId.SyncGamePause, new SceneMethods.SyncGamePause { pause = args.value });
}
[Handler(MethodId.AskLoadSceneCompleted, HandlerPacketKind.Notify)]
private Task AskLoadSceneCompleted(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskLoadSceneCompleted>();
return OnLoadSceneCompleted(msg.Context, args.sceneId, args.sessionId);
}
[Handler(MethodId.AskLoadGameResCompleted, HandlerPacketKind.Notify)]
private Task AskLoadGameResCompleted(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskLoadGameResCompleted>();
return OnLoadGameResourcesCompleted(msg.Context, args.sceneId);
}
[Handler(MethodId.AskLoadingFinished, HandlerPacketKind.Invoke)]
private async Task AskLoadingFinished(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskLoadingFinished>();
await conn.ReturnEmptyOkAsync(msg);
await OnLoadingFinished(msg.Context, args.sceneId, args.sessionId);
}
[Handler(MethodId.AskLoadedInSameScene, HandlerPacketKind.Notify)]
private Task AskLoadedInSameScene(Connection conn, UxRpcMessage msg)
=> OnLoadedInSameScene(msg.Context);
[Handler(MethodId.AskTeleport, HandlerPacketKind.Invoke)]
private async Task AskTeleport(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskTeleportArgs>();
if (conn.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState state)
{
lock (state.SyncRoot)
{
state.PendingTeleportPosition = new Vec3(args.option.position.X, args.option.position.Y, args.option.position.Z);
state.PendingTeleportFacing = args.option.facing;
state.PendingTeleportId = args.option.teleportId;
state.LastReportedPlayerPosition = state.PendingTeleportPosition;
state.LastReportedPlayerRotation = new Vec3(0f, args.option.facing, 0f);
state.HasLastReportedPlayerTransform = true;
}
}
await conn.ReturnEmptyOkAsync(msg);
var sync = new SceneMethods.SyncPreTeleportOption { option = args.option };
await conn.NotifyAsync(MethodId.SyncPreTeleportOption, sync);
}
[Handler(MethodId.ReportPreTeleportFinish, HandlerPacketKind.Invoke)]
private async Task ReportPreTeleportFinish(Connection conn, UxRpcMessage msg)
{
await conn.ReturnEmptyOkAsync(msg);
if (conn.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState state)
{
Vec3 targetPos;
float targetFacing;
ulong teleportId;
ulong unitId;
lock (state.SyncRoot)
{
targetPos = state.PendingTeleportPosition;
targetFacing = state.PendingTeleportFacing;
teleportId = state.PendingTeleportId;
unitId = state.ActiveSpiritUnitId;
}
var sync = new SceneMethods.SyncTeleport
{
option = new SceneMethods.TeleportOption
{
teleportId = teleportId,
Position = new SceneMethods.UxVector3(targetPos.X, targetPos.Y, targetPos.Z),
Facing = targetFacing,
IsSwitchScene = false,
WaitTaskResource = false,
MapEntranceId = 0
}
};
await conn.NotifyAsync(MethodId.SyncTeleport, sync);
if (unitId != 0)
{
await conn.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(unitId, targetPos, targetFacing));
}
}
}
[Handler(MethodId.ReportPostTeleportFinish, HandlerPacketKind.Notify)]
private Task ReportPostTeleportFinish(Connection conn, UxRpcMessage msg)
=> Task.CompletedTask;
[Handler(MethodId.AskPlayerChangePositionByScenePortal, HandlerPacketKind.Invoke)]
private Task AskPlayerChangePositionByScenePortal(Connection conn, UxRpcMessage msg)
=> conn.ReturnEmptyOkAsync(msg);
[Handler(MethodId.AskPublicSwitchToPublicScene, HandlerPacketKind.Invoke)]
private async Task AskPublicSwitchToPublicScene(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskPublicSwitchToPublicSceneArgs>();
await conn.ReturnEmptyOkAsync(msg);
uint raidId = args.raidid;
ulong instanceId = raidId == 23300999 ? 20001223UL : 20001222UL;
uint universeId = 76000888;
Vec3 pos = raidId == 23300999 ? new Vec3(-4719.8f, 168.5f, -2844.7f) : new Vec3(2588.66f, 269.0f, -637.28f);
await SwitchSceneDirectAsync(conn.Session, raidId, instanceId, universeId, pos, 0f, isAirport: true);
}
[Handler(MethodId.AskGadgetDoorTransfer, HandlerPacketKind.Notify)]
private Task AskGadgetDoorTransfer(Connection conn, UxRpcMessage msg)
=> HandleAirportOrTeleportAsync(conn.Session);
[Handler(MethodId.AskSwitchSpirit)]
private async Task AskSwitchSpirit(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskSwitchSpirit>();
await conn.ReturnEmptyOkAsync(msg);
await OnSwitchSpirit(msg.Context, args.spiritId);
}
[Handler(MethodId.AskSwitchWeapon, HandlerPacketKind.Notify)]
private Task AskSwitchWeapon(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskSwitchWeapon>();
return OnSwitchWeapon(msg.Context, args.index);
}
[Handler(MethodId.AskSwitchFightStyle, HandlerPacketKind.Invoke)]
private async Task AskSwitchFightStyle(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskSwitchFightStyle>();
await conn.ReturnEmptyOkAsync(msg);
await OnSwitchFightStyle(msg.Context, args.spiritId, args.fightStyleTypeId, args.fightStyleId);
}
[Handler(MethodId.AskSetWeaponFightStyle, HandlerPacketKind.Invoke)]
private async Task AskSetWeaponFightStyle(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskSetWeaponFightStyle>();
await conn.ReturnEmptyOkAsync(msg);
await OnSetWeaponFightStyle(msg.Context, args.weaponInstanceId, args.fightStyleId);
}
[Handler(MethodId.AskLoadWeaponToSlot, HandlerPacketKind.Invoke)]
private async Task AskLoadWeaponToSlot(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskLoadWeaponToSlot>();
await conn.ReturnEmptyOkAsync(msg);
await OnLoadWeaponToSlot(msg.Context, args.spiritId, args.weaponId, args.slotIndex);
}
[Handler(MethodId.AskDepositSpiritWeapon, HandlerPacketKind.Invoke)]
private async Task AskDepositSpiritWeapon(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskDepositSpiritWeapon>();
await conn.ReturnEmptyOkAsync(msg);
await OnDepositSpiritWeapon(msg.Context, args.spiritId, args.slotIndex);
}
[Handler(MethodId.AskExchangeWeaponSlot, HandlerPacketKind.Invoke)]
private async Task AskExchangeWeaponSlot(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskExchangeWeaponSlot>();
await conn.ReturnEmptyOkAsync(msg);
await OnExchangeWeaponSlot(msg.Context, args.fromSpirit, args.fromIndex, args.toSpirit, args.toIndex);
}
[Handler(MethodId.AskSetWeaponSkins)]
private Task AskSetWeaponSkins(Connection conn, UxRpcMessage msg)
=> conn.ReturnEmptyOkAsync(msg);
[Handler(MethodId.AskSyncWeaponSkinToSpirits)]
private Task AskSyncWeaponSkinToSpirits(Connection conn, UxRpcMessage msg)
=> conn.ReturnEmptyOkAsync(msg);
[Handler(MethodId.AskAddClientBuff)]
private async Task AskAddClientBuff(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskAddClientBuff>();
await conn.ReturnEmptyOkAsync(msg);
await OnClientBuffAdd(msg.Context, args.unitId, args.buffId);
}
[Handler(MethodId.AskRemoveClientBuff)]
private async Task AskRemoveClientBuff(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskRemoveClientBuff>();
await conn.ReturnEmptyOkAsync(msg);
await OnClientBuffRemove(msg.Context, args.unitId, args.buffId);
}
[Handler(MethodId.AskEnterFeiSuoCrouch, HandlerPacketKind.Notify)]
private Task AskEnterFeiSuoCrouch(Connection conn, UxRpcMessage msg)
=> OnEnterFeiSuoCrouch(msg.Context);
[Handler(MethodId.AskFeiSuoSuccess, HandlerPacketKind.Invoke)]
private async Task AskFeiSuoSuccess(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskFeiSuoSuccess>();
await OnFeiSuoSuccess(msg.Context, args.feiSuoId);
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskLeaveFeiSuoCrouch, HandlerPacketKind.Notify)]
private Task AskLeaveFeiSuoCrouch(Connection conn, UxRpcMessage msg)
=> OnLeaveFeiSuoCrouch(msg.Context);
[Handler(MethodId.AskClientUseCommonSkill, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskUseSkill, HandlerPacketKind.Invoke)]
private Task AskUseSkill(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskUseSkill>();
return OnClientUseSkill(msg.Context, args.data);
}
[Handler(MethodId.AskSkillUseWeaponDurability, HandlerPacketKind.Notify)]
private Task AskSkillUseWeaponDurability(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskSkillUseWeaponDurability>();
return OnSkillUseWeaponDurability(msg.Context, args.skillid, args.triggerindex);
}
[Handler(MethodId.AskWeaponEquipBullets, HandlerPacketKind.Invoke)]
private async Task AskWeaponEquipBullets(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskWeaponEquipBullets>();
await conn.ReturnEmptyOkAsync(msg);
await OnWeaponEquipBullets(msg.Context, args.weaponinstanceid, args.bulletid);
}
[Handler(MethodId.ReportSkillEnd, HandlerPacketKind.Notify)]
private Task ReportSkillEnd(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.ReportSkillEnd>();
return OnReportSkillEnd(msg.Context, args);
}
[Handler(MethodId.AskMultipleSkillHit2, HandlerPacketKind.Notify)]
private Task AskMultipleSkillHit2(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.AskMultipleSkillHit2>();
return OnSkillHit(msg.Context, args.skillHitData);
}
[Handler(MethodId.AskInterruptSkillExecute, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskInterruptSkillExecuteStiff, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskSkillExecuteEnd, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskSkillExecute, HandlerPacketKind.Invoke)]
private static Task AcceptCombatInvoke(Connection conn, UxRpcMessage msg)
=> conn.ReturnEmptyOkAsync(msg);
[Handler(MethodId.AskSkillDestructibleCreate, HandlerPacketKind.Invoke)]
private static Task AskSkillDestructibleCreate(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, 0UL);
[Handler(MethodId.AskReportLogicAgentSyncData)]
[Handler(MethodId.AskUnitMoveActionSimple, HandlerPacketKind.Notify)]
[Handler(MethodId.AskUnitMoveActionSimpleWithGround, HandlerPacketKind.Notify)]
[Handler(MethodId.AskUnitMoveActionWithGround, HandlerPacketKind.Notify)]
[Handler(MethodId.AskUnitMoveAction, HandlerPacketKind.Notify)]
private async Task MovementReport(Connection conn, UxRpcMessage msg)
{
IEnumerable<SceneMethods.LogicAgentSyncData>? samples = msg.MethodId switch
{
MethodId.AskReportLogicAgentSyncData => msg.GetArgs<SceneMethods.AskReportLogicAgentSyncData>().list,
MethodId.AskUnitMoveActionSimple => msg.GetArgs<SceneMethods.AskUnitMoveActionSimple>().actions.Select(x =>
new SceneMethods.LogicAgentSyncData { AgentId = x.UnitId, Position = x.Pos, Rotation = x.Rot }),
MethodId.AskUnitMoveActionSimpleWithGround => msg.GetArgs<SceneMethods.AskUnitMoveActionSimpleWithGround>().actions.Select(x =>
new SceneMethods.LogicAgentSyncData { AgentId = x.UnitId, Position = x.Pos, Rotation = x.Rot }),
MethodId.AskUnitMoveAction => msg.GetArgs<SceneMethods.AskUnitMoveAction>().actions.Select(x =>
new SceneMethods.LogicAgentSyncData { AgentId = x.UnitId, Position = x.Pos, Rotation = x.Rot }),
MethodId.AskUnitMoveActionWithGround => msg.GetArgs<SceneMethods.AskUnitMoveActionWithGround>().actions.Select(x =>
new SceneMethods.LogicAgentSyncData { AgentId = x.UnitId, Position = x.Pos, Rotation = x.Rot }),
_ => null
};
await conn.ReturnEmptyOkAsync(msg);
await OnMovementReport(msg.Context, samples);
}
[Handler(MethodId.AskSetSpiritFashionsWithSource, HandlerPacketKind.Invoke)]
private Task AskSetSpiritFashionsWithSource(Connection conn, UxRpcMessage msg)
=> HandleAskSetSpiritFashionsWithSourceAsync(conn, msg);
[Handler(MethodId.AskSetSpiritFashions, HandlerPacketKind.Invoke)]
private Task AskSetSpiritFashions(Connection conn, UxRpcMessage msg)
=> HandleAskSetSpiritFashionsAsync(conn, msg);
[Handler(MethodId.AskModifySpiritWearFashionsOnlyWearWithSource, HandlerPacketKind.Invoke)]
private Task AskModifySpiritWearFashionsOnlyWearWithSource(Connection conn, UxRpcMessage msg)
=> HandleAskModifySpiritWearFashionsOnlyWearWithSourceAsync(conn, msg);
[Handler(MethodId.AskModifySpiritWearFashionsOnlyWear, HandlerPacketKind.Invoke)]
private Task AskModifySpiritWearFashionsOnlyWear(Connection conn, UxRpcMessage msg)
=> HandleAskModifySpiritWearFashionsOnlyWearAsync(conn, msg);
[Handler(MethodId.AskModifySpiritWearFashionsWithSource, HandlerPacketKind.Invoke)]
private Task AskModifySpiritWearFashionsWithSource(Connection conn, UxRpcMessage msg)
=> HandleAskModifySpiritWearFashionsWithSourceAsync(conn, msg);
[Handler(MethodId.AskModifySpiritWearFashionEditInfosWithSource, HandlerPacketKind.Invoke)]
private Task AskModifySpiritWearFashionEditInfosWithSource(Connection conn, UxRpcMessage msg)
=> HandleAskModifySpiritWearFashionEditInfosWithSourceAsync(conn, msg);
[Handler(MethodId.AskModifySpiritWearFashionEditInfos, HandlerPacketKind.Invoke)]
private Task AskModifySpiritWearFashionEditInfos(Connection conn, UxRpcMessage msg)
=> HandleAskModifySpiritWearFashionEditInfosAsync(conn, msg);
[Handler(MethodId.AskSetSpiritWearFashionHiddenPartsWithSource, HandlerPacketKind.Invoke)]
private Task AskSetSpiritWearFashionHiddenPartsWithSource(Connection conn, UxRpcMessage msg)
=> HandleAskSetSpiritWearFashionHiddenPartsWithSourceAsync(conn, msg);
[Handler(MethodId.AskSetSpiritWearFashionHiddenParts, HandlerPacketKind.Invoke)]
private Task AskSetSpiritWearFashionHiddenParts(Connection conn, UxRpcMessage msg)
=> HandleAskSetSpiritWearFashionHiddenPartsAsync(conn, msg);
[Handler(MethodId.AskOpenOrCloseFashionPanel, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskUnlockFashionColoringSlot, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskUnlockFashionSuitSlot, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskApplyFashionColoringSchemeInf, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskSetFashionColoringSchemeInfos, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskFavoriteFashions, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskFavoriteFashionSuits, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskReadFashions, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskReadFashionSuits, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskSetSpiritFashionVariantPreference, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskTradeRecycleFashion, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskClearSwitchInFashionCache, HandlerPacketKind.Invoke)]
private static Task AcceptFashionInvoke(Connection conn, UxRpcMessage msg)
=> conn.ReturnEmptyOkAsync(msg);
[Handler(MethodId.AskRecommendGetTopWearFashionTag, HandlerPacketKind.Invoke)]
private static Task AskRecommendGetTopWearFashionTag(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, 0u);
[Handler(MethodId.AskActivityCancelRedPoint, HandlerPacketKind.Invoke)]
private static Task AskActivityCancelRedPoint(Connection conn, UxRpcMessage msg)
=> conn.ReturnEmptyOkAsync(msg);
// Story & Quest Task Handlers
[Handler(MethodId.AskAcceptTask, HandlerPacketKind.Invoke)]
private static Task AskAcceptTask(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[TASK] AskAcceptTask accepted");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskAcceptAndSetCurrentTask, HandlerPacketKind.Invoke)]
private static Task AskAcceptAndSetCurrentTask(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[TASK] AskAcceptAndSetCurrentTask accepted");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskSubmitTask, HandlerPacketKind.Invoke)]
private static Task AskSubmitTask(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[TASK] AskSubmitTask accepted");
return conn.ReturnEmptyOkAsync(msg);
}
}
@@ -0,0 +1,291 @@
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Wardrobe & Fashion dressing engine for client 4229938.
/// Handles dressing, customizing, and equipping fashion items and persists worn state per spirit.
/// </summary>
internal sealed partial class GameRouter
{
private async Task HandleAskSetSpiritFashionsWithSourceAsync(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskSetSpiritFashionsWithSource>();
var state = GetWorldState(msg.Context);
var spiritId = args.spiritOrInstanceId;
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
state.CustomWornFashionsBySpirit[spiritId] = args.spiritWearFashionsInfo;
await conn.ReturnEmptyOkAsync(msg);
// Notify client of updated worn fashion
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
{
spiritId = spiritId,
source = args.source,
spiritWearFashionsInfo = args.spiritWearFashionsInfo,
});
conn.Session.Log.Info($"[FASHION] AskSetSpiritFashionsWithSource: spirit={spiritId} source={args.source} items={args.spiritWearFashionsInfo.WearFashionInfoList.Count}");
}
private async Task HandleAskSetSpiritFashionsAsync(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskSetSpiritFashions>();
var state = GetWorldState(msg.Context);
var spiritId = args.spiritId;
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
state.CustomWornFashionsBySpirit[spiritId] = args.spiritWearFashionsInfo;
await conn.ReturnEmptyOkAsync(msg);
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
{
spiritId = spiritId,
source = 0,
spiritWearFashionsInfo = args.spiritWearFashionsInfo,
});
conn.Session.Log.Info($"[FASHION] AskSetSpiritFashions: spirit={spiritId} items={args.spiritWearFashionsInfo.WearFashionInfoList.Count}");
}
private async Task HandleAskModifySpiritWearFashionsOnlyWearWithSourceAsync(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionsOnlyWearWithSource>();
var state = GetWorldState(msg.Context);
var spiritId = args.spiritOrInstanceId;
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
var currentWear = GetOrCreateSpiritWear(state, spiritId);
ApplyFashionWearDiff(currentWear, args.unwearFashionIdList, args.wearFashionInfoList);
var response = new GameMethods.ModifySpiritWearFashionsOnlyWearResponse
{
unwearFashionIdList = args.unwearFashionIdList,
wearFashionInfoList = args.wearFashionInfoList,
};
await conn.ReturnAsync(msg, response);
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
{
spiritId = spiritId,
source = args.source,
spiritWearFashionsInfo = currentWear,
});
conn.Session.Log.Info($"[FASHION] ModifySpiritWearFashionsOnlyWearWithSource: spirit={spiritId} unwear={args.unwearFashionIdList.Count} wear={args.wearFashionInfoList.Count}");
}
private async Task HandleAskModifySpiritWearFashionsOnlyWearAsync(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionsOnlyWear>();
var state = GetWorldState(msg.Context);
var spiritId = args.spiritId;
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
var currentWear = GetOrCreateSpiritWear(state, spiritId);
ApplyFashionWearDiff(currentWear, args.unwearFashionIdList, args.wearFashionInfoList);
var response = new GameMethods.ModifySpiritWearFashionsOnlyWearResponse
{
unwearFashionIdList = args.unwearFashionIdList,
wearFashionInfoList = args.wearFashionInfoList,
};
await conn.ReturnAsync(msg, response);
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
{
spiritId = spiritId,
source = 0,
spiritWearFashionsInfo = currentWear,
});
conn.Session.Log.Info($"[FASHION] ModifySpiritWearFashionsOnlyWear: spirit={spiritId} unwear={args.unwearFashionIdList.Count} wear={args.wearFashionInfoList.Count}");
}
private async Task HandleAskModifySpiritWearFashionsWithSourceAsync(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionsWithSource>();
var state = GetWorldState(msg.Context);
var spiritId = args.spiritOrInstanceId;
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
var currentWear = GetOrCreateSpiritWear(state, spiritId);
ApplyFashionWearDiff(currentWear, args.unwearFashionIdList, args.wearFashionInfoList);
ApplyFashionEditDiff(currentWear, args.uneditWearFashionIdList, args.editWearFashionEditInfoList);
var result = new GameMethods.ModifySpiritWearFashionResult
{
R0 = args.unwearFashionIdList,
R1 = args.wearFashionInfoList,
R2 = args.uneditWearFashionIdList,
R3 = args.editWearFashionEditInfoList,
};
await conn.ReturnAsync(msg, result);
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
{
spiritId = spiritId,
source = args.source,
spiritWearFashionsInfo = currentWear,
});
conn.Session.Log.Info($"[FASHION] ModifySpiritWearFashionsWithSource: spirit={spiritId}");
}
private async Task HandleAskModifySpiritWearFashionEditInfosWithSourceAsync(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionEditInfosWithSource>();
var state = GetWorldState(msg.Context);
var spiritId = args.spiritOrInstanceId;
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
var currentWear = GetOrCreateSpiritWear(state, spiritId);
ApplyFashionEditDiff(currentWear, args.uneditWearFashionIdList, args.editWearFashionEditInfoList);
var response = new GameMethods.ModifySpiritWearFashionEditInfosResponse
{
uneditWearFashionIdList = args.uneditWearFashionIdList,
editWearFashionEditInfoList = args.editWearFashionEditInfoList,
};
await conn.ReturnAsync(msg, response);
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
{
spiritId = spiritId,
source = args.source,
spiritWearFashionsInfo = currentWear,
});
}
private async Task HandleAskModifySpiritWearFashionEditInfosAsync(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskModifySpiritWearFashionEditInfos>();
var state = GetWorldState(msg.Context);
var spiritId = args.spiritId;
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
var currentWear = GetOrCreateSpiritWear(state, spiritId);
ApplyFashionEditDiff(currentWear, args.uneditWearFashionIdList, args.editWearFashionEditInfoList);
var response = new GameMethods.ModifySpiritWearFashionEditInfosResponse
{
uneditWearFashionIdList = args.uneditWearFashionIdList,
editWearFashionEditInfoList = args.editWearFashionEditInfoList,
};
await conn.ReturnAsync(msg, response);
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
{
spiritId = spiritId,
source = 0,
spiritWearFashionsInfo = currentWear,
});
}
private async Task HandleAskSetSpiritWearFashionHiddenPartsWithSourceAsync(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskSetSpiritWearFashionHiddenPartsWithSource>();
var state = GetWorldState(msg.Context);
var spiritId = args.spiritOrInstanceId;
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
var currentWear = GetOrCreateSpiritWear(state, spiritId);
currentWear.HiddenParts = args.hiddenParts;
await conn.ReturnEmptyOkAsync(msg);
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
{
spiritId = spiritId,
source = args.source,
spiritWearFashionsInfo = currentWear,
});
}
private async Task HandleAskSetSpiritWearFashionHiddenPartsAsync(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<GameMethods.AskSetSpiritWearFashionHiddenParts>();
var state = GetWorldState(msg.Context);
var spiritId = args.spiritId;
if (spiritId == 0) spiritId = state.ActiveSpiritTemplateId;
var currentWear = GetOrCreateSpiritWear(state, spiritId);
currentWear.HiddenParts = args.hiddenParts;
await conn.ReturnEmptyOkAsync(msg);
await conn.NotifyAsync(MethodId.SyncSetSpiritFashions, new GameMethods.SyncSetSpiritFashions
{
spiritId = spiritId,
source = 0,
spiritWearFashionsInfo = currentWear,
});
}
private static GameMethods.SpiritWearFashionsInfo GetOrCreateSpiritWear(WorldEntryState state, uint spiritId)
{
if (!state.CustomWornFashionsBySpirit.TryGetValue(spiritId, out var currentWear))
{
var defaultIds = ClientConfigRepository.DefaultFashionIds(spiritId);
currentWear = RuntimePayloadFactory.WearFashionsForGameMethods(defaultIds);
state.CustomWornFashionsBySpirit[spiritId] = currentWear;
}
return currentWear;
}
private static void ApplyFashionWearDiff(
GameMethods.SpiritWearFashionsInfo wear,
List<uint> unwearIds,
List<GameMethods.WearFashionInfo> newWear)
{
if (unwearIds.Count > 0)
{
var unwearSet = unwearIds.ToHashSet();
wear.WearFashionInfoList.RemoveAll(x => unwearSet.Contains(x.FashionId));
}
foreach (var item in newWear)
{
if (!wear.WearFashionInfoList.Any(x => x.FashionId == item.FashionId))
{
wear.WearFashionInfoList.Add(item);
}
}
}
private static void ApplyFashionEditDiff(
GameMethods.SpiritWearFashionsInfo wear,
List<uint> uneditIds,
List<GameMethods.WearFashionEditInfo> newEdits)
{
wear.WearFashionEditInfoList ??= [];
if (uneditIds.Count > 0)
{
var uneditSet = uneditIds.ToHashSet();
wear.WearFashionEditInfoList.RemoveAll(x => uneditSet.Contains(x.FashionId));
}
foreach (var item in newEdits)
{
var existingIndex = wear.WearFashionEditInfoList.FindIndex(x => x.FashionId == item.FashionId);
if (existingIndex >= 0)
{
wear.WearFashionEditInfoList[existingIndex] = item;
}
else
{
wear.WearFashionEditInfoList.Add(item);
}
}
}
}
@@ -0,0 +1,71 @@
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938.Auto;
using Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
internal sealed partial class GameRouter
{
[Handler(MethodId.AskDrawGacha, HandlerPacketKind.Invoke)]
private async Task AskDrawGacha(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<AskDrawGachaArgs>();
uint poolId = args.gachaPoolId == 0 ? 1001u : args.gachaPoolId;
uint count = args.drawCount == 0 ? 1u : args.drawCount;
conn.Session.Log.Info($"[GACHA] AskDrawGacha: poolId={poolId} count={count} autoEx={args.isAutoExchange}");
var sync = new SyncGachaDrawInfo
{
isGrandPrizeWithAllFillers = false,
drawDetails = new List<GachaDrawDetail>()
};
for (uint i = 0; i < count; i++)
{
sync.drawDetails.Add(new GachaDrawDetail
{
PoolContentId = poolId,
IsGrandPrize = (i == count - 1),
IsConverted = false,
IsNew = true
});
}
await conn.NotifyAsync(MethodId.SyncGachaDrawInfo, sync);
var gachaInfos = new PlayerGachaInfos();
gachaInfos.PoolInfos[poolId] = new PlayerGachaPoolInfo
{
DrawCount = count
};
gachaInfos.GroupInfos[poolId] = new PlayerGachaGroupInfo
{
TotalDrawCount = count
};
gachaInfos.PityInfos[poolId] = new PlayerGachaPityInfo
{
TotalDrawCount = count,
DrawsSinceLastReset = count % 80
};
await conn.ReturnAsync(msg, gachaInfos);
}
[Handler(MethodId.AskClaimGachaMilestone, HandlerPacketKind.Invoke)]
private Task AskClaimGachaMilestone(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<AskClaimGachaMilestoneArgs>();
conn.Session.Log.Info($"[GACHA] AskClaimGachaMilestone: groupId={args.groupId} count={args.milestoneCount}");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskChaosMasterGacha, HandlerPacketKind.Invoke)]
private Task AskChaosMasterGacha(Connection conn, UxRpcMessage msg)
{
conn.Session.Log.Info("[GACHA] AskChaosMasterGacha invoke");
return conn.ReturnAsync(msg, new List<uint>());
}
}
@@ -0,0 +1,69 @@
using Ananta.SDK.Rpc;
using Ananta.Server.Protocol.Client4229938;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Private-server GM surface (build 4229938): the in-game GM console (ALT+F1) sends these
/// C2S invokes. Strategy is return-real-ids-first: the client spawns GM entities locally
/// (GM invoke call sites ignore the return), the server records the ids so the debug
/// panel can manage them. If a command proves to wait for a server broadcast, the push
/// (SyncLogicVehicleEnter etc.) gets added for that command.
/// </summary>
internal sealed partial class GameRouter
{
private static long _gmEntitySeq = 320000000000L;
private static long _gmEnemySeq = 330000000000L;
[Handler(MethodId.GmSpawnVehicle, HandlerPacketKind.Invoke)]
private Task GmSpawnVehicle(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.GmSpawnVehicle>();
var entityId = (ulong)Interlocked.Increment(ref _gmEntitySeq);
RegisterSummoned(entityId, args.TemplateId, args.Position.X, args.Position.Y, args.Position.Z, args.Facing);
conn.Log.Info($"[GM] spawn vehicle cfg={args.TemplateId} suit={args.SuitId} own={args.LoadOwnVehicle} spoon='{args.SpoonName}' at=({args.Position.X:F1},{args.Position.Y:F1},{args.Position.Z:F1}) -> entity={entityId}");
return conn.ReturnAsync(msg, entityId);
}
[Handler(MethodId.GmAddEnemyWithPosition, HandlerPacketKind.Invoke)]
private Task GmAddEnemyWithPosition(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.GmAddEnemyWithPosition>();
var entityId = (ulong)Interlocked.Increment(ref _gmEnemySeq);
conn.Log.Info($"[GM] add enemy id={args.EnemyId} camp={args.Camp} nav={args.NavTagType} at=({args.Position.X:F1},{args.Position.Y:F1},{args.Position.Z:F1}) -> entity={entityId}");
return conn.ReturnAsync(msg, entityId);
}
[Handler(MethodId.GmAddEnemy, HandlerPacketKind.Invoke)]
private Task GmAddEnemy(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.GmAddEnemy>();
var entityId = (ulong)Interlocked.Increment(ref _gmEnemySeq);
conn.Log.Info($"[GM] add enemy id={args.EnemyId} camp={args.Camp} tree='{args.TreeName}' -> entity={entityId}");
return conn.ReturnAsync(msg, entityId);
}
[Handler(MethodId.GmAddEnemyByPlayer, HandlerPacketKind.Invoke)]
private Task GmAddEnemyByPlayer(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.GmAddEnemyByPlayer>();
var entityId = (ulong)Interlocked.Increment(ref _gmEnemySeq);
conn.Log.Info($"[GM] add enemy by player id={args.EnemyId} camp={args.Camp} -> entity={entityId}");
return conn.ReturnAsync(msg, entityId);
}
[Handler(MethodId.GmTeleportXYZ, HandlerPacketKind.Invoke)]
private Task GmTeleportXYZ(Connection conn, UxRpcMessage msg)
{
var args = msg.GetArgs<SceneMethods.GmTeleportXYZ>();
conn.Log.Info($"[GM] teleport to=({args.X:F1},{args.Y:F1},{args.Z:F1}) facing={args.Facing:F1} (client-side, accepted)");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.DavinciCode, HandlerPacketKind.Notify)]
private static void DavinciCode(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[SECURITY] DavinciCode notify absorbed");
}
}
@@ -0,0 +1,83 @@
using Ananta.SDK.Rpc;
using Ananta.Server.RpcTypes.Client4229938;
using Ananta.Server.Protocol.Client4229938;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>Client movement reports and live transform tracking.</summary>
internal sealed partial class GameRouter
{
async Task OnMovementReport(RpcContext ctx, IEnumerable<SceneMethods.LogicAgentSyncData>? samples)
{
var state = GetWorldState(ctx);
// All build-4229938 movement RPCs are projected into the same transform shape by the endpoint.
// Walk the whole batch so the newest action wins instead of keeping the oldest position.
if (samples is not null)
{
foreach (var item in samples)
{
var isActive = item.AgentId == state.ActiveSpiritUnitId;
var isPendingTarget = state.PendingSwitchUnitId != 0 && item.AgentId == state.PendingSwitchUnitId;
var isPendingOld = state.PendingSwitchOldUnitId != 0 && item.AgentId == state.PendingSwitchOldUnitId;
if (!isActive && !isPendingTarget && !isPendingOld)
continue;
var p = item.Position;
var r = item.Rotation;
if (float.IsFinite(p.X) && float.IsFinite(p.Y) && float.IsFinite(p.Z) &&
float.IsFinite(r.X) && float.IsFinite(r.Y) && float.IsFinite(r.Z))
{
state.LastReportedPlayerPosition = new Vec3(p.X, p.Y, p.Z);
state.LastReportedPlayerRotation = new Vec3(r.X, r.Y, r.Z);
state.HasLastReportedPlayerTransform = true;
state.RecordPositionCheckpoint(state.LastReportedPlayerPosition, state.LastReportedPlayerRotation.Y);
// V5.7 safe-entry mode: movement is a pure client->server transform report.
// Do not use the first movement edge to reintroduce the same AOI/DynamicGo/social
// notification burst that was removed from AskLoadingFinished. The V3 4229938
// stable capture remained playable with no post-load server world-content push.
// World-content streaming will be re-enabled incrementally only after the crash
// boundary is proven stable.
}
}
}
if (state.FirstMovementSeen)
return;
// Do not consume the first-movement edge before AskLoadingFinished has committed Ready.
// Build 4229938 can report a transform while the loading overlay is still closing; a later
// real gameplay movement must still be able to trigger the pause-only stability release.
if (!state.Ready)
{
ctx.Session.Log.Warn("[MOVE] movement arrived before world ready; pause-only stability release remains armed");
return;
}
state.FirstMovementSeen = true;
ctx.Session.Log.Info($"[MOVE] client-agent-traffic rpc={ctx.MethodId} bytes={ctx.Body.Length} liveTransform={state.HasLastReportedPlayerTransform} logicProjection={state.WorldEntryLogicProjectionPublishedGeneration} currentMetadata={state.WorldEntryCurrentMetadataPublishedGeneration}");
// The supplied v7 guide keeps actor creation/presentation client-owned on initial entry.
// PlayerInfo + the same-ID projection already contain the build-local roster/fashion data,
// so do NOT replay SyncSetSpiritFashions/weapon snapshots for the initial actor. The only
// server feature intentionally layered on after GameplayReady is the requested safe web kit.
var unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
var templateId = state.ActiveSpiritTemplateId != 0 ? state.ActiveSpiritTemplateId : Profile.InitialSpiritTemplateId;
if (!state.AllBuildBuffsPublished)
{
await PublishSafeRuntimeBuffSnapshot4229938(ctx, unitId, templateId, "first-gameplay-movement");
state.AllBuildBuffsPublished = true;
}
// Same edge: populate the phone/garage vehicle list automatically.
await PublishGarageAsync(ctx);
await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false));
state.MovementCapabilityPublished = true;
state.FreeRoamReleased = true;
ctx.Session.Log.Info($"[GAMEPLAY-V7] first-movement post-load-buffs unit={unitId} template={templateId} actorPresentation=client-owned safeBuffs=true pause=false catalogBulk=false");
}
}
@@ -0,0 +1,185 @@
using Ananta.SDK.Rpc;
using Ananta.Server.Handlers;
using Ananta.Server.Gameplay;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.ClientData.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
/// <summary>Runtime player/combat hydration shared by world entry and character switching.</summary>
internal sealed partial class GameRouter
{
async Task PublishSkillBindings(
RpcContext ctx,
ulong unitId,
CombatWeaponDefinition weapon,
CombatStyleDefinition style)
{
await ctx.NotifyAsync(MethodId.SyncChangeCommonSkill, CombatCodec.CommonBinding(unitId, style));
await ctx.NotifyAsync(MethodId.SyncChangeHeavyAttack, CombatCodec.HeavyAttackBinding(unitId, style));
await ctx.NotifyAsync(MethodId.SyncChangeDodgeSkill, CombatCodec.DodgeBinding(unitId, style));
await ctx.NotifyAsync(MethodId.SyncChangeControlSkill, CombatCodec.ControlBinding(unitId, style));
await ctx.NotifyAsync(MethodId.SyncChangeActiveSkill, CombatCodec.ActiveBinding(unitId, weapon, style));
await ctx.NotifyAsync(MethodId.SyncChangeUniqueSkill, CombatCodec.UniqueBinding(unitId, weapon, style));
}
async Task PublishCombatResources(RpcContext ctx, ulong unitId)
{
foreach (var (resourceId, maximum) in CombatCodec.AllResourceMaximums)
{
await ctx.NotifyAsync(MethodId.SyncFightResource,
CombatCodec.FightResource(unitId, resourceId, maximum));
await ctx.NotifyAsync(MethodId.SyncFightResourceFreeState,
CombatCodec.FightResourceFreeState(unitId, resourceId, true));
}
}
private static IEnumerable<KeyValuePair<uint, uint>> SpiritFightStyleOverrides(
WorldEntryState state,
uint templateId)
=> state.SpiritStyleOverrides
.Where(x => x.Key.SpiritId == templateId)
.Select(x => new KeyValuePair<uint, uint>(x.Key.FightStyleTypeId, x.Value));
private static uint PublishedWeaponFightStyleId(WorldEntryState state, CombatWeaponDefinition weapon)
{
// The armory can store category-wide and per-instance choices separately, but the live
// combat runtime also caches FightStyleId by weapon instance. Publishing the EFFECTIVE
// style keeps FightStyleManager.GetUnitFightStyleByTemplateAndWeapon, ActionType and the
// native SkillJumpGraph on the same martial art. Category changes refresh this cache.
return ResolveWeaponStyle(state, weapon).Id;
}
async Task PublishMinimalActorPresentation4229938(
RpcContext ctx,
ulong unitId,
uint templateId,
string reason)
{
var state = GetWorldState(ctx);
var weapon = CombatCodec.DefaultWeapon(templateId);
var style = ResolveWeaponStyle(state, weapon);
state.ActiveWeaponInstanceId = weapon.InstanceId;
state.ActiveFightStyleId = style.Id;
state.LastWeaponBySpirit[templateId] = weapon.InstanceId;
// Actor presentation is part of the ownership contract, not the legacy combat server.
// The client already received the small referenced armory in SyncPlayerInfo; these packets
// only bind the current model to its authored fashion/wheel/current weapon.
var wearFashions = state.CustomWornFashionsBySpirit.TryGetValue(templateId, out var customWear)
? new GameMethods.SyncSetSpiritFashions
{
spiritId = templateId,
source = 0,
spiritWearFashionsInfo = customWear,
}
: RuntimePayloadFactory.CharacterFashions(templateId);
await ctx.NotifyAsync(MethodId.SyncSetSpiritFashions, wearFashions);
await PublishWeaponSnapshot(ctx, unitId, templateId, weapon.InstanceId);
await ctx.NotifyAsync(MethodId.SyncSpiritLastUsedWeapon,
CombatCodec.SpiritLastUsedWeapon(templateId, weapon.InstanceId));
await ctx.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction,
CombatCodec.SpiritSwitchWeapon(unitId, weapon.InstanceId));
ctx.Session.Log.Info($"[ACTOR-MIN] presentation unit={unitId} template={templateId} weapon={weapon.TemplateId}/{weapon.InstanceId} style={style.Id} reason={reason} combatModules=false skills=false");
}
async Task PublishWeaponSnapshot(
RpcContext ctx,
ulong unitId,
uint templateId,
ulong currentWeaponInstanceId)
{
// Armory and character wheels are separate client stores. Publish the shared account armory
// once, refresh this character's mutable 16-slot view, then select the current instance.
// Sending only SyncSpiritSwitchWeaponAction leaves CurrWeaponSlots nil and produces
// "Not Find Current Weapon" in WeaponManager.lua.
var state = GetWorldState(ctx);
if (!state.AccountArmoryPublished)
{
// Minimal V4.1 puts only the weapon instances referenced by owned spirit wheels into
// SyncPlayerInfo.InfoSpirit.InfoArmory.Weapons. PlayerInfoSpiritData feeds that compact subset
// directly to gWeaponManager, so no incremental account-armory replay is needed here.
state.AccountArmoryPublished = true;
var actorArmoryCount = ClientConfigRepository.Characters()
.SelectMany(character => CombatCodec.Loadout(character.TemplateId).Slots)
.Where(weapon => weapon is not null && weapon.InstanceId != 0)
.Select(weapon => weapon!.InstanceId)
.Distinct()
.Count();
ctx.Session.Log.Info($"[ARMORY-MIN] retained login actor-reference subset count={actorArmoryCount} fullAccount={CombatCatalogRepository.AccountWeapons.Count} incrementalAddReplay=false");
}
var snapshot = CombatCodec.SpiritWeaponSnapshot(
unitId,
templateId,
currentWeaponInstanceId,
WeaponSlotDefinitions(state, templateId));
foreach (var weapon in snapshot.WeaponSlots)
{
if (weapon is null)
continue;
if (CombatCodec.Weapon(templateId, weapon.InstanceId) is { } definition)
{
weapon.FightStyleId = PublishedWeaponFightStyleId(state, definition);
weapon.MagazineAmmo = EnsureMagazine4229938(state, definition);
weapon.Durability = CurrentDurability4229938(state, definition, weapon.MagazineAmmo);
weapon.BulletDatas.BulletId = EnsureBullet4229938(state, definition);
if (definition.SeparateBullets && weapon.BulletDatas.BulletId != 0)
_ = EnsureReserve4229938(state, weapon.BulletDatas.BulletId);
}
}
await ctx.NotifyAsync(MethodId.SyncSpiritWeaponDetail, snapshot);
// FightStyleId is also cached in the account armory object. Reconcile each equipped weapon
// when a character becomes current so a style that was valid for the previous model cannot
// leak into this model's attack ActionSet.
foreach (var detail in snapshot.WeaponSlots.Where(x => x is not null).Select(x => x!))
{
await ctx.NotifyAsync(MethodId.SyncWeaponFightStyleChange,
new GameMethods.SyncWeaponFightStyleChange
{
weaponInstanceId = detail.InstanceId,
fightStyleId = detail.FightStyleId
});
}
}
async Task PublishSafeRuntimeBuffSnapshot4229938(RpcContext ctx, ulong unitId, uint templateId, string phase)
{
var state = GetWorldState(ctx);
var buffs = WebTraversal.CapabilityBuffIds(templateId);
var firstInstanceId = state.NextBuffInstanceId;
await ctx.NotifyAsync(MethodId.SyncUnitBuffList,
WorldCodec.UnitBuffList(unitId, buffs, firstInstanceId));
state.NextBuffInstanceId += (uint)buffs.Count;
// Do NOT bulk-activate the extracted buff catalogue. It contains 4k+ records for
// bosses, NPCs, timelines, skin replacement, ControlList and ActionGroup switches. The client
// applies those as executable behavior, not as an "unlocked buff catalogue". In the failing
// capture this forced actionGroup=33 on main_A104001 and later crashed ReplaceSkinAction.End.
ctx.Session.Log.Info($"[BUFFS-MIN] {phase} unit={unitId} template={templateId} count={buffs.Count} firstInstance={firstInstanceId} bulk=false safeWebOnly=true legacyCatalogLoaded=false");
}
async Task PublishInitialCapabilityBuffs(RpcContext ctx)
{
var state = GetWorldState(ctx);
if (state.InitialCapabilityBuffsPublished)
return;
state.InitialCapabilityBuffsPublished = true;
// Keep the opening/loading barrier on the proven small traversal capability set.
// Never activate the entire BuffConfig as one player snapshot: it contains NPC/timeline actions.
var firstInstanceId = state.NextBuffInstanceId;
await ctx.NotifyAsync(MethodId.SyncUnitBuffList,
WorldCodec.UnitBuffList(Profile.InitialUnitId, WebTraversal.InitialCapabilityBuffIds, firstInstanceId));
state.NextBuffInstanceId += (uint)WebTraversal.InitialCapabilityBuffIds.Count;
ctx.Session.Log.Info($"[CAPABILITY] initial unit={Profile.InitialUnitId} count={WebTraversal.InitialCapabilityBuffIds.Count} loadingSafe=true allBuffsDeferred=true");
}
}
@@ -0,0 +1,131 @@
using Ananta.SDK.Rpc;
using Ananta.Server.Handlers;
using Ananta.Server.Gameplay;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
namespace Ananta.Server.Handlers.Game;
/// <summary>Generic character switching. No protagonist-specific switch path lives here.</summary>
internal sealed partial class GameRouter
{
async Task OnSwitchSpirit(RpcContext ctx, uint requestedSpiritId)
{
var state = GetWorldState(ctx);
if (!state.Ready)
{
ctx.Session.Log.Warn($"[SWITCH-MIN] ignored before world ready spirit={requestedSpiritId}");
return;
}
if (state.PendingSwitchTemplateId != 0)
{
ctx.Session.Log.Warn($"[SWITCH-MIN] ignored while pending target={state.PendingSwitchTemplateId}");
return;
}
var target = ClientConfigRepository.Characters().FirstOrDefault(x => x.TemplateId == requestedSpiritId);
if (target is null)
{
ctx.Session.Log.Warn($"[SWITCH-MIN] unknown requested spirit={requestedSpiritId}");
return;
}
if (target.TemplateId == state.ActiveSpiritTemplateId)
return;
var hasLiveTransform = state.HasLastReportedPlayerTransform;
var position = hasLiveTransform
? state.LastReportedPlayerPosition
: state.WorldEntryCreateHeroPosition;
var facing = hasLiveTransform && float.IsFinite(state.LastReportedPlayerRotation.Y)
? state.LastReportedPlayerRotation.Y
: state.WorldEntryCreateHeroFacing;
// Freeze the old actor's last authoritative transform before CurrentSpirit changes ownership.
// Delayed movement packets from the old unit are ignored after the handoff, so every target
// starts exactly where the player stood when the switch was requested.
state.LastReportedPlayerPosition = position;
state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f);
state.HasLastReportedPlayerTransform = true;
// Minimal mode never starts SyncPreSwitchSpirit/SyncSwitchSpiritConfigId Timeline flows.
// It performs only the identity/unit handoff needed to keep the requested character controllable.
await SwitchSpiritDirectSameScene4229938(ctx, state, target.TemplateId, target.UnitId, position, facing);
}
async Task SwitchSpiritDirectSameScene4229938(
RpcContext ctx, WorldEntryState state, uint templateId, ulong unitId, Vec3 switchPosition, float switchFacing)
{
var oldUnitId = state.ActiveSpiritUnitId;
state.PendingSwitchTemplateId = templateId;
state.PendingSwitchUnitId = unitId;
state.PendingSwitchOldUnitId = oldUnitId;
state.PendingSwitchShowId = 0;
state.PendingSwitchPosition = switchPosition;
state.PendingSwitchFacing = switchFacing;
state.PendingSwitchControlTransferred = false;
state.PendingSwitchLandingStarted = false;
// Materialize exactly one target character. No authored switch Timeline, weapons, combat graph,
// fashion inventory replay, vehicle cleanup or old-build gameplay hydration is published.
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(unitId));
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent, WorldCodec.ManagedLogicAgent(unitId, Profile.PlayerPid, 0));
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.CharacterUnitProjection(templateId, switchPosition, switchFacing));
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(unitId, switchPosition, switchFacing));
await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit,
WorldCodec.CurrentSpirit(Profile.PlayerPid, templateId, unitId, isAgentSwitch: true));
// CurrentSpirit can rebuild the controlled actor on the client. Re-assert the frozen transform
// after ownership transfer so the new character cannot fall back to its template/default spawn.
await ctx.NotifyAsync(MethodId.SyncUnitPositionAndFacing,
WorldCodec.PositionAndFacing(unitId, switchPosition, switchFacing));
state.LastReportedPlayerPosition = switchPosition;
state.LastReportedPlayerRotation = new Vec3(0f, switchFacing, 0f);
state.HasLastReportedPlayerTransform = true;
state.ActiveSpiritTemplateId = templateId;
state.ActiveSpiritUnitId = unitId;
state.PendingSwitchControlTransferred = true;
state.ActiveSkillId = 0;
state.RestoreResourcesAfterActiveSkill = false;
state.ActiveClientSkillInstanceId = 0;
state.ActiveSkillStartedTicks = 0;
// Direct switch still needs a complete actor presentation even though combat gameplay is
// disabled. Bind the stock fashion/wheel/current weapon so the target does not become a
// half-initialized BaseUnit after CurrentSpirit changes ownership.
await PublishMinimalActorPresentation4229938(ctx, unitId, templateId, "direct-switch");
if (state.ActiveFeiSuoBuffInstanceId != 0)
{
var transientUnitId = state.ActiveFeiSuoBuffUnitId != 0 ? state.ActiveFeiSuoBuffUnitId : oldUnitId;
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
WorldCodec.UnitRemoveBuff(transientUnitId, state.ActiveFeiSuoBuffInstanceId));
state.ActiveFeiSuoBuffInstanceId = 0;
state.ActiveFeiSuoBuffUnitId = 0;
}
await ClearClientWebLifecycleBuffs(ctx, oldUnitId, "minimal-direct-switch");
await PublishSafeRuntimeBuffSnapshot4229938(ctx, unitId, templateId, "minimal-direct-switch");
state.AllBuildBuffsPublished = true;
if (oldUnitId != 0 && oldUnitId != unitId)
await ctx.NotifyAsync(MethodId.SyncLogicAgentLeave, WorldCodec.LogicAgentLeave(oldUnitId));
await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false));
state.SwitchCount++;
state.LastSwitchShowId = 0;
state.PendingSwitchTemplateId = 0;
state.PendingSwitchUnitId = 0;
state.PendingSwitchOldUnitId = 0;
state.PendingSwitchShowId = 0;
state.PendingSwitchFacing = state.WorldEntryCreateHeroFacing;
state.PendingSwitchControlTransferred = false;
state.PendingSwitchLandingStarted = false;
ctx.Session.Log.Info($"[SWITCH-MIN] direct count={state.SwitchCount} oldUnit={oldUnitId} target={templateId}/{unitId} authoredTimeline=false combatModules=false actorPresentation=true vehicles=false buffs=safe-web-only pos=({switchPosition.X:0.##},{switchPosition.Y:0.##},{switchPosition.Z:0.##}) facing={switchFacing:0.##}");
}
}
@@ -0,0 +1,282 @@
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.Protocol.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Time + weather. The client runs its clock/atmosphere locally; these handlers make
/// the in-game time UI work (accept + remember) and the debug panel can force it:
/// time via SyncPlayerCurrentTime (proto-verified shape). Stored per session,
/// re-pushed after every load.
/// </summary>
internal sealed partial class GameRouter
{
internal static WorldEntryState? GetStateIfExists(TcpSession session)
=> session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing
? existing
: null;
private static void SetSessionTime(WorldEntryState state, uint hour, uint minute, bool fix, uint transition)
{
lock (state.SyncRoot)
{
state.TimeHour = Math.Min(hour, 23);
state.TimeMinute = Math.Min(minute, 59);
state.TimeFixed = fix;
state.TimeTransitionSeconds = transition;
state.HasExplicitTime = true;
}
}
internal static async Task PushSessionTimeAsync(TcpSession session)
{
var state = GetStateIfExists(session);
if (state is null)
return;
uint hour, minute, transition;
bool fix;
lock (state.SyncRoot)
{
if (!state.HasExplicitTime)
return;
hour = state.TimeHour;
minute = state.TimeMinute;
fix = state.TimeFixed;
transition = state.TimeTransitionSeconds;
}
await session.NotifyAsync(MethodId.SyncPlayerCurrentTime, UxSerializer.Serialize(
new GameMethods.SyncPlayerCurrentTime4229938
{
realTime = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
raidDaySeconds = hour * 3600 + minute * 60,
fix = fix,
isPause = false,
transitionSecond = transition,
reason = 0, // RaidTimeAndWeatherChangeReason.Gm
nodeId = 0,
}), CancellationToken.None);
session.Log.Info($"[TIME] push {hour:D2}:{minute:D2} fix={fix} transition={transition}s");
}
internal static async Task PushSessionWeatherAsync(TcpSession session)
{
var state = GetStateIfExists(session);
if (state is null)
return;
uint weatherId, transition;
lock (state.SyncRoot)
{
if (!state.HasExplicitWeather)
return;
weatherId = state.WeatherId;
transition = state.WeatherTransitionSeconds;
}
await session.NotifyAsync(MethodId.SyncPlayerWeather, UxSerializer.Serialize(
new GameMethods.SyncPlayerWeather4229938
{
weatherTypeId = weatherId,
nextWeatherTypeId = weatherId,
transitionSecond = transition,
}), CancellationToken.None);
session.Log.Info($"[WEATHER] push id={weatherId} transition={transition}s");
}
[Handler(MethodId.AskPassingTime)]
private async Task AskPassingTime(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.PassingTimeArgs4229938>(out var args) && args is not null)
{
SetSessionTime(GetWorldState(msg.Context), args.hour, args.minute, true, 5);
conn.Log.Info($"[TIME] AskPassingTime {args.hour:D2}:{args.minute:D2}");
await PushSessionTimeAsync(msg.Context.Session);
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.GmPassingTime)]
private async Task GmPassingTime(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.PassingTimeArgs4229938>(out var args) && args is not null)
{
SetSessionTime(GetWorldState(msg.Context), args.hour, args.minute, true, 5);
conn.Log.Info($"[TIME] GmPassingTime {args.hour:D2}:{args.minute:D2}");
await PushSessionTimeAsync(msg.Context.Session);
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.GmSetTime)]
private async Task GmSetTime(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.GmSetTimeArgs4229938>(out var args) && args is not null)
{
SetSessionTime(GetWorldState(msg.Context), args.hour, args.minute, true, args.transition);
conn.Log.Info($"[TIME] GmSetTime {args.hour:D2}:{args.minute:D2} transition={args.transition}s");
await PushSessionTimeAsync(msg.Context.Session);
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.GmFixRaidTime)]
private async Task GmFixRaidTime(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.GmFixRaidTimeArgs4229938>(out var args) && args is not null)
{
var state = GetWorldState(msg.Context);
if (args.clear)
{
lock (state.SyncRoot)
state.HasExplicitTime = false;
conn.Log.Info("[TIME] GmFixRaidTime clear");
}
else
{
SetSessionTime(state, args.hour, args.minute, true, 5);
conn.Log.Info($"[TIME] GmFixRaidTime {args.hour:D2}:{args.minute:D2}");
await PushSessionTimeAsync(msg.Context.Session);
}
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskTimePanelInfo)]
private async Task AskTimePanelInfo(Connection conn, UxRpcMessage msg)
{
var state = GetWorldState(msg.Context);
GameMethods.TimePanelInfo4229938 response;
lock (state.SyncRoot)
{
response = new GameMethods.TimePanelInfo4229938
{
PersonalTimeSettings = new List<GameMethods.PersonalTimeSetting4229938>(state.PersonalTimeSettings)
};
}
conn.Log.Info($"[TIME] AskTimePanelInfo: returning {response.PersonalTimeSettings.Count} personal time slots");
await conn.ReturnAsync(msg, response);
}
[Handler(MethodId.ChangePersonalTimeSetting)]
private async Task ChangePersonalTimeSetting(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.ChangePersonalTimeSettingArgs4229938>(out var args) && args is not null)
{
var state = GetWorldState(msg.Context);
lock (state.SyncRoot)
{
if (args.info is null)
{
if (args.index >= 0 && args.index < state.PersonalTimeSettings.Count)
{
state.PersonalTimeSettings.RemoveAt(args.index);
conn.Log.Info($"[TIME] personal delete slot={args.index}");
}
}
else
{
if (args.index >= 0 && args.index < state.PersonalTimeSettings.Count)
{
state.PersonalTimeSettings[args.index] = args.info;
conn.Log.Info($"[TIME] personal update slot={args.index} label={args.info.Label} {args.info.Hour:D2}:{args.info.Minute:D2}");
}
else
{
state.PersonalTimeSettings.Add(args.info);
conn.Log.Info($"[TIME] personal append slot={args.index} label={args.info.Label} {args.info.Hour:D2}:{args.info.Minute:D2}");
}
}
}
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AddPersonalTimeSetting)]
private async Task AddPersonalTimeSetting(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.AddPersonalTimeSettingArgs4229938>(out var args) && args?.info is not null)
{
var state = GetWorldState(msg.Context);
lock (state.SyncRoot)
{
state.PersonalTimeSettings.Add(args.info);
}
conn.Log.Info($"[TIME] personal add label={args.info.Label} {args.info.Hour:D2}:{args.info.Minute:D2}");
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskSetMobileSkinPart)]
private async Task AskSetMobileSkinPart(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.AskSetMobileSkinPartArgs4229938>(out var args) && args is not null)
{
var state = GetWorldState(msg.Context);
lock (state.SyncRoot)
{
state.MobileWallpaperId = args.wallpaper;
state.MobileDecorationId = args.decoration;
state.MobilePendantId = args.pendant;
}
conn.Log.Info($"[PHONE] AskSetMobileSkinPart wallpaper={args.wallpaper} decoration={args.decoration} pendant={args.pendant}");
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskResetMobileSkinPart)]
private async Task AskResetMobileSkinPart(Connection conn, UxRpcMessage msg)
{
var state = GetWorldState(msg.Context);
lock (state.SyncRoot)
{
state.MobileWallpaperId = 0;
state.MobileDecorationId = 0;
state.MobilePendantId = 0;
}
conn.Log.Info("[PHONE] AskResetMobileSkinPart reset to defaults");
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskInstallMobileApp)]
private async Task AskInstallMobileApp(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.AskInstallMobileAppArgs4229938>(out var args) && args is not null)
conn.Log.Info($"[PHONE] AskInstallMobileApp appId={args.appId}");
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskCancelInteractionActionRedPoint)]
private async Task AskCancelInteractionActionRedPoint(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.AskCancelInteractionActionRedPointArgs4229938>(out var args) && args is not null)
conn.Log.Info($"[ACTION] AskCancelInteractionActionRedPoint actionId={args.actionId}");
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.GmSetWeather)]
private async Task GmSetWeather(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.GmSetWeatherArgs4229938>(out var args) && args is not null)
{
var state = GetWorldState(msg.Context);
lock (state.SyncRoot)
{
state.WeatherId = args.weatherId;
state.WeatherTransitionSeconds = 15;
state.HasExplicitWeather = true;
}
conn.Log.Info($"[WEATHER] GmSetWeather id={args.weatherId}");
await PushSessionWeatherAsync(msg.Context.Session);
}
await conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.GmSetWeatherParam)]
private async Task GmSetWeatherParam(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.GmSetWeatherParamArgs4229938>(out var args) && args is not null)
conn.Log.Info($"[WEATHER] GmSetWeatherParam id={args.weatherParamId}");
await conn.ReturnEmptyOkAsync(msg);
}
}
@@ -0,0 +1,180 @@
using Ananta.SDK.Rpc;
using Ananta.Server.Handlers;
using Ananta.Server.Gameplay;
using Ananta.Server.Protocol.Client4229938;
namespace Ananta.Server.Handlers.Game;
/// <summary>Protagonist traversal/web buff handling. Buff ids come from private-server.json.</summary>
internal sealed partial class GameRouter
{
static bool IsActiveProtagonist(WorldEntryState state, ulong unitId = 0)
=> WebTraversal.IsProtagonist(state.ActiveSpiritTemplateId)
&& (unitId == 0 || unitId == state.ActiveSpiritUnitId);
async Task EnsureFeiSuoActionBuff(RpcContext ctx)
{
var state = GetWorldState(ctx);
if (!IsActiveProtagonist(state))
return;
if (state.ActiveFeiSuoBuffInstanceId != 0)
return;
var instanceId = state.NextBuffInstanceId++;
state.ActiveFeiSuoBuffInstanceId = instanceId;
state.ActiveFeiSuoBuffUnitId = state.ActiveSpiritUnitId;
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff,
WorldCodec.UnitAddBuff(state.ActiveSpiritUnitId, WebTraversal.FeiSuoBuff, instanceId));
}
async Task OnEnterFeiSuoCrouch(RpcContext ctx)
{
var state = GetWorldState(ctx);
if (!IsActiveProtagonist(state))
{
ctx.Session.Log.Info($"[WEB] feisuo-enter ignored template={state.ActiveSpiritTemplateId} protagonist=false");
return;
}
await PublishInitialCapabilityBuffs(ctx);
await EnsureFeiSuoActionBuff(ctx);
ctx.Session.Log.Info($"[WEB] feisuo-enter ack=true transient={WebTraversal.FeiSuoBuff}");
}
async Task OnFeiSuoSuccess(RpcContext ctx, int feiSuoId)
{
var state = GetWorldState(ctx);
if (!IsActiveProtagonist(state))
{
ctx.Session.Log.Info($"[WEB] feisuo-success ignored template={state.ActiveSpiritTemplateId} protagonist=false");
return;
}
await EnsureFeiSuoActionBuff(ctx);
ctx.Session.Log.Info($"[WEB] feisuo-success id={feiSuoId} transient={WebTraversal.FeiSuoBuff}");
}
async Task OnLeaveFeiSuoCrouch(RpcContext ctx)
{
var state = GetWorldState(ctx);
if (state.ActiveFeiSuoBuffInstanceId == 0)
return;
var instanceId = state.ActiveFeiSuoBuffInstanceId;
var unitId = state.ActiveFeiSuoBuffUnitId != 0
? state.ActiveFeiSuoBuffUnitId
: state.ActiveSpiritUnitId;
state.ActiveFeiSuoBuffInstanceId = 0;
state.ActiveFeiSuoBuffUnitId = 0;
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
WorldCodec.UnitRemoveBuff(unitId, instanceId));
ctx.Session.Log.Info($"[WEB] feisuo-leave transient-removed instance={instanceId}");
}
async Task OnClientBuffAdd(RpcContext ctx, ulong unitId, uint buffId)
{
var state = GetWorldState(ctx);
if (WebTraversal.IsWebBuff(buffId) && !IsActiveProtagonist(state, unitId))
{
ctx.Session.Log.Info($"[WEB] client-buff-add denied unit={unitId} template={state.ActiveSpiritTemplateId} buff={buffId} protagonist=false");
return;
}
// SwingBuff/WallRushBuff are action states, not unlocks. The 4229938 client requests them
// when an action starts and removes them when it ends. Track one exact instance per unit/buff
// so repeated client requests refresh the state instead of stacking permanent super-armor.
if (WebTraversal.IsClientLifecycleBuff(buffId))
{
var key = (unitId, buffId);
if (state.ActiveClientWebBuffInstances.Remove(key, out var previousInstanceId))
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff, WorldCodec.UnitRemoveBuff(unitId, previousInstanceId));
var lifecycleInstanceId = state.NextBuffInstanceId++;
state.ActiveClientWebBuffInstances[key] = lifecycleInstanceId;
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff,
WorldCodec.UnitAddBuff(unitId, buffId, lifecycleInstanceId));
ctx.Session.Log.Info($"[WEB] lifecycle-add unit={unitId} buff={buffId} instance={lifecycleInstanceId} refreshed={previousInstanceId != 0}");
return;
}
var instanceId = state.NextBuffInstanceId++;
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff, WorldCodec.UnitAddBuff(unitId, buffId, instanceId));
if (buffId == WebTraversal.FeiSuoBuff)
{
state.ActiveFeiSuoBuffInstanceId = instanceId;
state.ActiveFeiSuoBuffUnitId = unitId;
}
if (buffId == WebTraversal.FeiSuoBuff || WebTraversal.IsProtected(buffId))
ctx.Session.Log.Info($"[WEB] client-buff-add unit={unitId} buff={buffId} instance={instanceId}");
}
async Task OnClientBuffRemove(RpcContext ctx, ulong unitId, uint buffId)
{
var state = GetWorldState(ctx);
if (WebTraversal.IsWebBuff(buffId) && !IsActiveProtagonist(state, unitId))
return;
if (WebTraversal.IsClientLifecycleBuff(buffId))
{
var key = (unitId, buffId);
if (state.ActiveClientWebBuffInstances.Remove(key, out var lifecycleInstanceId))
{
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
WorldCodec.UnitRemoveBuff(unitId, lifecycleInstanceId));
ctx.Session.Log.Info($"[WEB] lifecycle-remove unit={unitId} buff={buffId} instance={lifecycleInstanceId}");
}
return;
}
if (buffId == WebTraversal.FeiSuoBuff)
{
if (state.ActiveFeiSuoBuffInstanceId != 0)
{
var instanceId = state.ActiveFeiSuoBuffInstanceId;
var transientUnitId = state.ActiveFeiSuoBuffUnitId != 0
? state.ActiveFeiSuoBuffUnitId
: unitId;
state.ActiveFeiSuoBuffInstanceId = 0;
state.ActiveFeiSuoBuffUnitId = 0;
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
WorldCodec.UnitRemoveBuff(transientUnitId, instanceId));
ctx.Session.Log.Info($"[WEB] client-buff-remove transient={buffId} instance={instanceId}");
}
// Client 4229938 reports grapple teardown by buff *id*, not by instance.
// Locally that teardown can consume the configured resident grapple gate as well as the short-lived
// action copy. This is especially visible on the female protagonist: the first grapple
// succeeds, then no second client-buff-add is emitted. Re-arm one untracked resident gate
// after teardown for either protagonist. It is deliberately NOT stored as
// ActiveFeiSuoBuffInstanceId; that slot is reserved for the next action copy only.
await Task.Delay(WebTraversal.GrappleRearmDelayMs);
if (IsActiveProtagonist(state, unitId))
{
var residentInstanceId = state.NextBuffInstanceId++;
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff,
WorldCodec.UnitAddBuff(unitId, WebTraversal.FeiSuoBuff, residentInstanceId));
ctx.Session.Log.Info($"[WEB] persistent-gate-reassert unit={unitId} buff={WebTraversal.FeiSuoBuff} instance={residentInstanceId}");
}
return;
}
if (!WebTraversal.IsProtected(buffId))
return;
await ctx.NotifyAsync(MethodId.SyncUnitAddBuff, WorldCodec.UnitAddBuff(unitId, buffId, state.NextBuffInstanceId++));
ctx.Session.Log.Info($"[WEB] protected-buff-reassert unit={unitId} buff={buffId}");
}
async Task ClearClientWebLifecycleBuffs(RpcContext ctx, ulong unitId, string phase)
{
var state = GetWorldState(ctx);
var active = state.ActiveClientWebBuffInstances
.Where(x => x.Key.UnitId == unitId)
.ToArray();
foreach (var item in active)
{
state.ActiveClientWebBuffInstances.Remove(item.Key);
await ctx.NotifyAsync(MethodId.SyncUnitRemoveBuff,
WorldCodec.UnitRemoveBuff(unitId, item.Value));
}
if (active.Length > 0)
ctx.Session.Log.Info($"[WEB] lifecycle-clear phase={phase} unit={unitId} count={active.Length}");
}
}
@@ -0,0 +1,600 @@
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// S011 vehicle boarding story channel (build 4229938). Ported from the proven
/// Server_V2 flow: the client only offers the F-enter prompt and drives the
/// enter/exit animation phases once the S011 root node exists; without the root
/// bootstrap, pressing F near a spawned vehicle sends nothing.
/// Flow: root bootstrap (once per session) → client S011PlayerNetEnterVehicleRequest →
/// enter child + controller + status 2 → phase 2 (status 3) → phase 3 (status 4,
/// seated) → exit request → exit child + status 5 → phase 1 (status 0, done).
/// Vehicle records live in the shared static SummonedVehicles registry (extended
/// with seat state); the boarding transaction itself is per-session.
/// Lock order everywhere: story state, then SummonedVehiclesSync.
/// </summary>
internal sealed partial class GameRouter
{
private const string VehicleStoryKey = "client4229938-vehicle-story";
private const uint VehicleStoryRootNid = 1;
private const uint VehicleStoryEnterNid = 2;
private const uint VehicleStoryExitNid = 3;
internal sealed class VehicleStoryState
{
internal object SyncRoot { get; } = new();
internal bool RootSent;
internal bool SeenAnyRpc;
internal long HighestConfirmedRpcId = -1;
internal SortedSet<long> PendingRpcIds { get; } = new();
internal HashSet<long> CommittedRpcIds { get; } = new();
internal ulong EnterVehicleId;
internal byte EnterSeat;
internal bool ExitInProgress;
internal ulong CurrentVehicleId;
internal int CurrentSeat = -1;
internal ulong BoardingUnitId;
}
internal static VehicleStoryState GetVehicleStoryState(TcpSession session)
{
if (session.Items.TryGetValue(VehicleStoryKey, out var raw) && raw is VehicleStoryState existing)
return existing;
var created = new VehicleStoryState();
session.Items[VehicleStoryKey] = created;
return created;
}
internal static WorldEntryState? GetVehicleWorldState(TcpSession session)
=> session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing
? existing
: null;
internal static (ulong VehicleId, int Seat) VehicleSeatSnapshot(TcpSession session)
{
var story = GetVehicleStoryState(session);
lock (story.SyncRoot)
return (story.CurrentVehicleId, story.CurrentSeat);
}
internal static async Task EnsureVehicleStoryRoot(RpcContext ctx)
{
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
return;
var story = GetVehicleStoryState(ctx.Session);
long confirm;
lock (story.SyncRoot)
{
if (story.RootSent)
return;
story.RootSent = true;
story.BoardingUnitId = GetVehicleWorldState(ctx.Session)?.ActiveSpiritUnitId
?? Profile.InitialUnitId;
confirm = story.SeenAnyRpc ? Math.Max(0, story.HighestConfirmedRpcId) : -1;
}
await ctx.NotifyAsync(MethodId.SyncStoryCoreServerInfo,
StoryS011Codec4229938.BuildRootBootstrap(VehicleStoryRootNid, confirm));
ctx.Session.Log.Info($"[VEHICLE-STORY] S011 root ready nid={VehicleStoryRootNid} confirm={confirm}");
}
internal static void ResetVehicleStoryForEntity(TcpSession session, ulong entityId)
{
var story = GetVehicleStoryState(session);
lock (story.SyncRoot)
{
if (story.CurrentVehicleId == entityId)
{
story.CurrentVehicleId = 0;
story.CurrentSeat = -1;
}
if (story.EnterVehicleId == entityId)
story.EnterVehicleId = 0;
story.ExitInProgress = false;
}
}
/// <summary>
/// Fresh scene load (login / airport travel): the client builds a new story
/// instance and drops all server-made nodes, so the S011 root must be sent
/// again by the post-load finalization. Called from OnLoadingFinished for each
/// accepted generation, next to the garage/aether one-shot resets. The early
/// root pushed on the pre-load story hello (if any) is harmless — the client
/// discards it with the old story instance.
/// </summary>
internal static void ResetVehicleStory(TcpSession session)
{
var story = GetVehicleStoryState(session);
lock (story.SyncRoot)
{
story.RootSent = false;
story.SeenAnyRpc = false;
story.HighestConfirmedRpcId = -1;
story.PendingRpcIds.Clear();
story.CommittedRpcIds.Clear();
story.EnterVehicleId = 0;
story.EnterSeat = 0;
story.ExitInProgress = false;
story.CurrentVehicleId = 0;
story.CurrentSeat = -1;
}
}
[Handler(MethodId.SyncStoryCoreClientInfo, HandlerPacketKind.Notify)]
private async Task SyncStoryCoreClientInfo(Connection conn, UxRpcMessage msg)
{
await EnsureVehicleStoryRoot(msg.Context);
var story = GetVehicleStoryState(msg.Context.Session);
lock (story.SyncRoot)
story.BoardingUnitId = GetVehicleWorldState(msg.Context.Session)?.ActiveSpiritUnitId
?? Profile.InitialUnitId;
var commands = StoryVehicleCodec4229938.Parse(msg.Body, out var full);
if (!full)
conn.Log.Warn($"[VEHICLE-STORY] partial parse commands={commands.Count} bytes={msg.Body.Length} hex={BitConverter.ToString(msg.Body)}");
foreach (var summary in commands)
{
// Non-heartbeat story traffic is rare; log it so new flows are visible.
if (summary.Name != "Rpc3" || summary.RpcId < 0)
conn.Log.Info($"[VEHICLE-STORY] cmd mark={summary.TypeMark} nid={summary.Nid} name={summary.Name} rpc={summary.RpcId} payload={summary.PayloadKind} vehicle={summary.PayloadVehicleId} seats=[{(summary.PayloadSeatIndices is null ? "" : string.Join(",", summary.PayloadSeatIndices))}] bool={summary.PayloadBool} int={summary.PayloadInt}");
}
foreach (var cmd in commands)
{
var duplicateCommitted = false;
long duplicateConfirm = -1;
if (cmd.HasRpcId && cmd.RpcId >= 0)
{
lock (story.SyncRoot)
{
story.SeenAnyRpc = true;
if (cmd.RpcId <= story.HighestConfirmedRpcId)
{
duplicateCommitted = true;
duplicateConfirm = story.HighestConfirmedRpcId;
}
else
{
story.PendingRpcIds.Add(cmd.RpcId);
}
}
}
if (duplicateCommitted)
{
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildConfirmRpc(duplicateConfirm));
continue;
}
if (cmd.TypeMark is 3 or 6)
{
await ConfirmVehicleStoryRpc(conn, story, cmd.HasRpcId ? cmd.RpcId : null);
continue;
}
if (cmd.Name == "S011PlayerNetEnterVehicleRequest" && cmd.PayloadKind == "S011EnterVehicleRequest")
await BeginVehicleEnter(conn, story, cmd);
else if (cmd.Name == "S011PlayerNetExitVehicleRequest" && cmd.PayloadKind == "Bool")
await BeginVehicleExit(conn, story, cmd.PayloadBool ?? true);
else if (cmd.Nid == VehicleStoryEnterNid && cmd.Name == "MsgrChangePhaseRequest" && cmd.PayloadKind is "Byte" or "Int")
await AdvanceVehicleEnter(conn, story, (int)(cmd.PayloadInt ?? 0));
else if (cmd.Nid == VehicleStoryExitNid && cmd.Name == "MsgrChangePhaseRequest" && cmd.PayloadKind is "Byte" or "Int")
await AdvanceVehicleExit(conn, story, (int)(cmd.PayloadInt ?? 0));
await ConfirmVehicleStoryRpc(conn, story, cmd.HasRpcId ? cmd.RpcId : null);
}
}
private async Task BeginVehicleEnter(Connection conn, VehicleStoryState story, StoryClientCommand4229938 cmd)
{
var vehicleId = cmd.PayloadVehicleId ?? 0;
var seat = cmd.PayloadSeatIndices is { Count: > 0 } ? cmd.PayloadSeatIndices[0] : (byte)0;
var pid = Profile.PlayerPid;
ulong unitId;
var ok = false;
var lazyRegistered = false;
lock (story.SyncRoot)
{
unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId;
lock (SummonedVehiclesSync)
{
// GM-console cars are spawned client-locally with client-side ids the
// server has never seen: adopt them on first enter attempt instead of
// leaving the client stuck with a hidden prompt. Seat layout is grown
// from the requested index; real counts arrive via SyncSpawnVehicle.
if (vehicleId != 0 && !SummonedVehicles.ContainsKey(vehicleId))
{
SummonedVehicles[vehicleId] = new SummonedVehicle
{
SeatCount = (int)Math.Min(16, Math.Max(2, (int)seat + 1)),
Interactable = true,
};
lazyRegistered = true;
}
if (vehicleId != 0 && story.CurrentVehicleId == 0
&& story.EnterVehicleId == 0 && !story.ExitInProgress
&& SummonedVehicles.TryGetValue(vehicleId, out var vehicle) && vehicle.Interactable
&& seat < vehicle.SeatCount
&& !vehicle.SeatReservations.ContainsKey(seat) && !vehicle.SeatOccupants.ContainsKey(seat))
{
vehicle.SeatReservations[seat] = pid;
story.EnterVehicleId = vehicleId;
story.EnterSeat = seat;
vehicle.ControllerPid = pid;
ok = true;
}
}
}
if (!ok)
{
conn.Log.Warn($"[VEHICLE-STORY] enter rejected vehicle={vehicleId} seat={seat}");
return;
}
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildEnterChildBootstrap(VehicleStoryEnterNid, vehicleId, seat));
await conn.NotifyAsync(MethodId.SyncChangeVehicleController, new SceneMethods.SyncChangeVehicleController
{ VehicleInstanceId = vehicleId, ControllerPid = pid });
await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 2);
conn.Log.Info($"[VEHICLE-STORY] enter begin vehicle={vehicleId} seat={seat} unit={unitId} status=2 lazy={lazyRegistered}");
}
private async Task AdvanceVehicleEnter(Connection conn, VehicleStoryState story, int phase)
{
ulong vehicleId;
byte seat;
ulong unitId;
lock (story.SyncRoot)
{
vehicleId = story.EnterVehicleId;
seat = story.EnterSeat;
unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId;
}
if (vehicleId == 0)
return;
switch (phase)
{
case 0:
case 1:
return;
case 2:
{
var committed = false;
lock (SummonedVehiclesSync)
{
if (SummonedVehicles.TryGetValue(vehicleId, out var vehicle)
&& vehicle.SeatReservations.TryGetValue(seat, out var owner) && owner == Profile.PlayerPid
&& !vehicle.SeatOccupants.ContainsKey(seat))
{
vehicle.SeatReservations.Remove(seat);
vehicle.SeatOccupants[seat] = Profile.PlayerPid;
committed = true;
}
}
if (!committed)
{
conn.Log.Warn($"[VEHICLE-STORY] enter phase=2 commit failed vehicle={vehicleId} seat={seat}");
return;
}
await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 3);
conn.Log.Info($"[VEHICLE-STORY] enter phase=2 vehicle={vehicleId} seat={seat} status=3");
return;
}
case 3:
await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 4);
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryEnterNid));
lock (story.SyncRoot)
{
story.CurrentVehicleId = vehicleId;
story.CurrentSeat = seat;
story.EnterVehicleId = 0;
lock (SummonedVehiclesSync)
{
if (SummonedVehicles.TryGetValue(vehicleId, out var v))
v.ControllerPid = Profile.PlayerPid;
}
}
conn.Log.Info($"[VEHICLE-STORY] enter complete vehicle={vehicleId} seat={seat} status=4");
return;
case 4:
await SendVehicleBoardingStatus(conn, unitId, vehicleId, 0, 0);
await conn.NotifyAsync(MethodId.SyncChangeVehicleController, new SceneMethods.SyncChangeVehicleController
{ VehicleInstanceId = vehicleId, ControllerPid = 0 });
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryEnterNid));
lock (story.SyncRoot)
{
story.EnterVehicleId = 0;
lock (SummonedVehiclesSync)
{
if (SummonedVehicles.TryGetValue(vehicleId, out var v))
{
if (v.SeatReservations.TryGetValue(seat, out var reservedBy) && reservedBy == Profile.PlayerPid)
v.SeatReservations.Remove(seat);
if (v.SeatOccupants.TryGetValue(seat, out var occupiedBy) && occupiedBy == Profile.PlayerPid)
v.SeatOccupants.Remove(seat);
v.ControllerPid = 0;
}
}
}
conn.Log.Info($"[VEHICLE-STORY] enter cancelled vehicle={vehicleId}");
return;
}
}
private async Task BeginVehicleExit(Connection conn, VehicleStoryState story, bool stopBeforeLeave)
{
ulong vehicleId;
byte seat;
ulong unitId;
lock (story.SyncRoot)
{
vehicleId = story.CurrentVehicleId;
seat = (byte)Math.Max(0, story.CurrentSeat);
unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId;
if (vehicleId == 0 || story.ExitInProgress)
return;
story.ExitInProgress = true;
}
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildExitChildBootstrap(VehicleStoryExitNid, vehicleId, seat, stopBeforeLeave));
await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 5);
conn.Log.Info($"[VEHICLE-STORY] exit begin vehicle={vehicleId} seat={seat} status=5");
}
private async Task AdvanceVehicleExit(Connection conn, VehicleStoryState story, int phase)
{
ulong vehicleId;
byte seat;
ulong unitId;
lock (story.SyncRoot)
{
vehicleId = story.CurrentVehicleId;
seat = (byte)Math.Max(0, story.CurrentSeat);
unitId = story.BoardingUnitId != 0 ? story.BoardingUnitId : Profile.InitialUnitId;
}
if (vehicleId == 0)
return;
if (phase == 0)
return;
if (phase == 1)
{
await SendVehicleBoardingStatus(conn, unitId, vehicleId, 0, 0);
await conn.NotifyAsync(MethodId.SyncChangeVehicleController, new SceneMethods.SyncChangeVehicleController
{ VehicleInstanceId = vehicleId, ControllerPid = 0 });
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryExitNid));
lock (story.SyncRoot)
{
story.CurrentVehicleId = 0;
story.CurrentSeat = -1;
story.ExitInProgress = false;
lock (SummonedVehiclesSync)
{
if (SummonedVehicles.TryGetValue(vehicleId, out var v))
{
if (v.SeatOccupants.TryGetValue(seat, out var occupiedBy) && occupiedBy == Profile.PlayerPid)
v.SeatOccupants.Remove(seat);
if (v.SeatReservations.TryGetValue(seat, out var reservedBy) && reservedBy == Profile.PlayerPid)
v.SeatReservations.Remove(seat);
v.ControllerPid = 0;
v.Interactable = true;
}
}
}
conn.Log.Info($"[VEHICLE-STORY] exit complete vehicle={vehicleId} status=0");
}
else if (phase == 2)
{
await SendVehicleBoardingStatus(conn, unitId, vehicleId, seat, 4);
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryExitNid));
lock (story.SyncRoot)
story.ExitInProgress = false;
conn.Log.Info($"[VEHICLE-STORY] exit cancelled vehicle={vehicleId} status=4");
}
}
[Handler(MethodId.AskClaimVehicleSeat, HandlerPacketKind.Invoke)]
private async Task AskClaimVehicleSeatStory(Connection conn, UxRpcMessage msg)
{
if (!msg.TryGetArgs<SceneMethods.AskClaimVehicleSeatArgs>(out var args) || args is null)
{
conn.Log.Warn($"[VEHICLE] AskClaimVehicleSeat undecodable bytes={msg.Body.Length}");
await conn.ReturnEmptyOkAsync(msg);
return;
}
var story = GetVehicleStoryState(msg.Context.Session);
byte selected = 0;
var valid = false;
lock (story.SyncRoot)
{
lock (SummonedVehiclesSync)
{
if (story.EnterVehicleId == args.VehicleEntityId
&& SummonedVehicles.TryGetValue(args.VehicleEntityId, out var vehicle))
{
foreach (var seat in args.SeatIndices)
{
var reservedByOther = vehicle.SeatReservations.TryGetValue(seat, out var reservedBy) && reservedBy != Profile.PlayerPid;
var occupiedByOther = vehicle.SeatOccupants.TryGetValue(seat, out var occupiedBy) && occupiedBy != Profile.PlayerPid;
if (seat < vehicle.SeatCount && !reservedByOther && !occupiedByOther)
{
selected = seat;
valid = true;
break;
}
}
if (valid)
{
var oldSeat = story.EnterSeat;
if (vehicle.SeatReservations.TryGetValue(oldSeat, out var oldOwner) && oldOwner == Profile.PlayerPid)
vehicle.SeatReservations.Remove(oldSeat);
vehicle.SeatReservations[selected] = Profile.PlayerPid;
story.EnterSeat = selected;
}
}
}
}
if (!valid)
{
await conn.ReturnEmptyAsync(msg, 1);
return;
}
await conn.ReturnAsync(msg, selected);
conn.Log.Info($"[VEHICLE-STORY] seat claim vehicle={args.VehicleEntityId} seat={selected}");
}
private Task SendVehicleBoardingStatus(Connection conn, ulong unitId, ulong vehicleId, byte seat, byte status)
=> conn.NotifyAsync(MethodId.SyncUnitVehicleStatus, new SceneMethods.NewClientBoardingInfo
{
EntityId = unitId,
Status = status,
VehicleUId = vehicleId,
SeatIndex = seat,
ExtInfo = null,
});
/// <summary>
/// Server-forced enter (debug panel path): seats the player without any client
/// F-press. Mirrors the enter-complete leg — move-to-seat, controller, boarding
/// status seated, start/state-change/finish drive-state notifies — plus an S011
/// enter child so the story side agrees, deleted right after like a phase-3 commit.
/// </summary>
internal static async Task<(bool Ok, string Message)> ForceEnterVehicleAsync(TcpSession session, ulong entityId = 0)
{
var story = GetVehicleStoryState(session);
var unitId = GetVehicleWorldState(session)?.ActiveSpiritUnitId ?? Profile.InitialUnitId;
var pid = Profile.PlayerPid;
ulong vehicleId = entityId != 0 ? entityId : LastSummonedEntity();
const byte seat = 0;
lock (story.SyncRoot)
{
lock (SummonedVehiclesSync)
{
if (vehicleId == 0 || !SummonedVehicles.TryGetValue(vehicleId, out var vehicle))
return (false, "no spawned vehicle (spawn one first)");
if (story.CurrentVehicleId != 0)
return (false, $"already in vehicle {story.CurrentVehicleId} (exit first)");
if (!vehicle.Interactable || vehicle.SeatOccupants.ContainsKey(seat))
return (false, "vehicle is not enterable right now");
vehicle.SeatReservations.Remove(seat);
vehicle.SeatOccupants[seat] = pid;
vehicle.ControllerPid = pid;
story.CurrentVehicleId = vehicleId;
story.CurrentSeat = seat;
story.EnterVehicleId = 0;
}
}
var drive = new SceneMethods.PlayerVehicleDriveStateInfo
{
Pid = pid, EnterOrLeave = true, VehicleEntityId = vehicleId, SeatIndex = seat,
IfForce = true, OpenDoorTypeId = 0, OpenDoorActionSpeed = 0, OpenDoorActionClipLength = 0,
};
try
{
var ct = CancellationToken.None;
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize(
new SceneMethods.SyncChangeVehicleInteractable { VehicleInstanceId = vehicleId, Interactable = false }), ct);
await session.NotifyAsync(MethodId.SyncPlayerMoveToDriveSeat, UxSerializer.Serialize(
new SceneMethods.SyncPlayerMoveToDriveSeat { Pid = pid, VehicleEntityId = vehicleId }), ct);
await session.NotifyAsync(MethodId.SyncUnitVehicleStatus, UxSerializer.Serialize(
new SceneMethods.NewClientBoardingInfo { EntityId = unitId, Status = 4, VehicleUId = vehicleId, SeatIndex = seat, ExtInfo = null }), ct);
await session.NotifyAsync(MethodId.SyncPlayerStartEnterOrExitVehicl, UxSerializer.Serialize(drive), ct);
await session.NotifyAsync(MethodId.SyncPlayerVehicleStateChange, UxSerializer.Serialize(drive), ct);
await session.NotifyAsync(MethodId.SyncChangeVehicleController, UxSerializer.Serialize(
new SceneMethods.SyncChangeVehicleController { VehicleInstanceId = vehicleId, ControllerPid = pid }), ct);
await session.NotifyAsync(MethodId.SyncPlayerFinishEnterOrExitVehic, UxSerializer.Serialize(drive), ct);
await SendStoryAsync(session, StoryS011Codec4229938.BuildEnterChildBootstrap(VehicleStoryEnterNid, vehicleId, seat));
await SendStoryAsync(session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryEnterNid));
session.Log.Info($"[VEHICLE-STORY] force enter vehicle={vehicleId} seat={seat} unit={unitId} status=4");
return (true, $"seated in vehicle {vehicleId} (seat 0) — drive with normal keys, exit with F or the Exit button");
}
catch (Exception ex)
{
return (false, $"enter failed: {ex.Message}");
}
}
/// <summary>Server-forced exit (debug panel path).</summary>
internal static async Task<(bool Ok, string Message)> ForceExitVehicleAsync(TcpSession session)
{
var story = GetVehicleStoryState(session);
var unitId = GetVehicleWorldState(session)?.ActiveSpiritUnitId ?? Profile.InitialUnitId;
ulong vehicleId;
lock (story.SyncRoot)
{
vehicleId = story.CurrentVehicleId;
if (vehicleId == 0)
return (false, "not in a vehicle");
}
try
{
var ct = CancellationToken.None;
await session.NotifyAsync(MethodId.SyncPlayerExitVehicle, UxSerializer.Serialize(
new SceneMethods.SyncPlayerExitVehicle { VehicleEntityId = vehicleId, Force = true, StopBeforeLeave = true }), ct);
await session.NotifyAsync(MethodId.SyncUnitVehicleStatus, UxSerializer.Serialize(
new SceneMethods.NewClientBoardingInfo { EntityId = unitId, Status = 0, VehicleUId = vehicleId, SeatIndex = 0, ExtInfo = null }), ct);
await session.NotifyAsync(MethodId.SyncChangeVehicleController, UxSerializer.Serialize(
new SceneMethods.SyncChangeVehicleController { VehicleInstanceId = vehicleId, ControllerPid = 0 }), ct);
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize(
new SceneMethods.SyncChangeVehicleInteractable { VehicleInstanceId = vehicleId, Interactable = true }), ct);
await SendStoryAsync(session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryEnterNid));
await SendStoryAsync(session, StoryS011Codec4229938.BuildDeleteNode(VehicleStoryExitNid));
lock (story.SyncRoot)
{
story.CurrentVehicleId = 0;
story.CurrentSeat = -1;
story.EnterVehicleId = 0;
story.ExitInProgress = false;
lock (SummonedVehiclesSync)
{
if (SummonedVehicles.TryGetValue(vehicleId, out var v))
{
v.SeatOccupants.Clear();
v.SeatReservations.Clear();
v.ControllerPid = 0;
v.Interactable = true;
}
}
}
session.Log.Info($"[VEHICLE-STORY] force exit vehicle={vehicleId}");
return (true, $"exited vehicle {vehicleId}");
}
catch (Exception ex)
{
return (false, $"exit failed: {ex.Message}");
}
}
private async Task ConfirmVehicleStoryRpc(Connection conn, VehicleStoryState story, long? commitRpcId)
{
long highest;
lock (story.SyncRoot)
{
if (commitRpcId is long id && id >= 0)
story.CommittedRpcIds.Add(id);
highest = story.HighestConfirmedRpcId;
while (story.PendingRpcIds.Count > 0)
{
var next = highest + 1;
if (next >= 0 && story.CommittedRpcIds.Contains(next))
{
highest = next;
story.PendingRpcIds.Remove(next);
story.CommittedRpcIds.Remove(next);
}
else break;
}
if (highest <= story.HighestConfirmedRpcId)
return;
story.HighestConfirmedRpcId = highest;
}
await SendStoryAsync(conn.Session, StoryS011Codec4229938.BuildConfirmRpc(highest));
}
private static Task SendStoryAsync(TcpSession session, byte[] payload)
=> session.NotifyAsync(MethodId.SyncStoryCoreServerInfo, payload, CancellationToken.None);
}
@@ -0,0 +1,596 @@
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Private-server vehicle surface (build 4229938): summon + owned fleet + drive-loop accepts.
/// Spawn uses the proven 3-notify sequence (SyncLogicVehicleEnter + SyncSpawnVehicle +
/// SyncChangeVehicleInteractable) with 5.5m-right/+0.15m offsets; driving itself is
/// simulated client-side.
/// </summary>
internal sealed partial class GameRouter
{
private static long _vehicleEntitySeq = 300000000000L;
private static long _vehicleTaskSeq;
private static readonly Dictionary<ulong, SummonedVehicle> SummonedVehicles = new();
private static readonly object SummonedVehiclesSync = new();
internal sealed class SummonedVehicle
{
internal uint ConfigId;
internal float X;
internal float Y;
internal float Z;
internal float Yaw;
internal bool HasFix;
// Boarding state (S011 story flow): seat layout + occupancy.
internal int SeatCount = 2;
internal bool Interactable = true;
internal ulong ControllerPid;
internal readonly Dictionary<byte, ulong> SeatReservations = new();
internal readonly Dictionary<byte, ulong> SeatOccupants = new();
}
private static ulong _lastSummonedEntity;
internal static ulong LastSummonedEntity()
{
lock (SummonedVehiclesSync)
return _lastSummonedEntity;
}
internal static bool TryGetSummoned(ulong entityId, out SummonedVehicle? vehicle)
{
lock (SummonedVehiclesSync)
return SummonedVehicles.TryGetValue(entityId, out vehicle);
}
internal static List<(ulong EntityId, uint ConfigId, float X, float Y, float Z, float Yaw, bool HasFix)> SummonedSnapshot()
{
lock (SummonedVehiclesSync)
return SummonedVehicles
.Select(kv => (kv.Key, kv.Value.ConfigId, kv.Value.X, kv.Value.Y, kv.Value.Z, kv.Value.Yaw, kv.Value.HasFix))
.ToList();
}
internal static void RegisterSummoned(ulong entityId, uint configId)
{
lock (SummonedVehiclesSync)
{
if (!SummonedVehicles.TryGetValue(entityId, out var existing))
SummonedVehicles[entityId] = existing = new SummonedVehicle { ConfigId = configId };
else
existing.ConfigId = configId;
existing.SeatCount = LookupSeatCount(configId);
existing.Interactable = true;
_lastSummonedEntity = entityId;
}
}
internal static void RegisterSummoned(ulong entityId, uint configId, float x, float y, float z, float yaw)
{
lock (SummonedVehiclesSync)
{
SummonedVehicles[entityId] = new SummonedVehicle
{
ConfigId = configId,
X = x,
Y = y,
Z = z,
Yaw = yaw,
HasFix = float.IsFinite(x) && float.IsFinite(y) && float.IsFinite(z),
SeatCount = LookupSeatCount(configId),
Interactable = true,
};
_lastSummonedEntity = entityId;
}
}
private static int LookupSeatCount(uint configId)
{
try
{
if (VehicleCatalog4229938.TryGet(configId, out var config)
&& config.SeatCount is >= 1 and <= 16)
return config.SeatCount;
}
catch
{
}
return 2;
}
internal static void ForgetSummoned(ulong entityId)
{
lock (SummonedVehiclesSync)
SummonedVehicles.Remove(entityId);
}
internal static void TrackVehicleMove(ulong entityId, float x, float y, float z, float yaw)
{
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z) || !float.IsFinite(yaw))
return;
lock (SummonedVehiclesSync)
{
// Only track server-summoned entities; traffic/AI vehicles stay untracked
// so the registry cannot grow unbounded.
if (SummonedVehicles.TryGetValue(entityId, out var existing))
{
existing.X = x;
existing.Y = y;
existing.Z = z;
existing.Yaw = yaw;
existing.HasFix = true;
}
}
}
[Handler(MethodId.AskSummonVehicle, HandlerPacketKind.Invoke)]
private async Task AskSummonVehicle(Connection conn, UxRpcMessage msg)
{
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
{
await conn.ReturnEmptyAsync(msg, 1);
return;
}
if (!msg.TryGetArgs<SceneMethods.AskSummonVehicle>(out var args) || args is null
|| msg.Body.Length != 20 || args.VehicleConfigId == 0
|| !float.IsFinite(args.Position.X) || !float.IsFinite(args.Position.Y) || !float.IsFinite(args.Position.Z)
|| !float.IsFinite(args.FacingDirection))
{
conn.Log.Warn($"[VEHICLE] AskSummonVehicle bad args bytes={msg.Body.Length}");
await conn.ReturnEmptyAsync(msg, 1);
return;
}
var result = await SpawnDirectAsync(
msg.Context.Session,
args.VehicleConfigId,
new Vec3(args.Position.X, args.Position.Y, args.Position.Z),
args.FacingDirection,
rightOffset: 5.5f,
sourceType: 2,
reason: "client-summon");
if (!result.Ok)
{
conn.Log.Warn($"[VEHICLE] summon rejected: {result.Message}");
await conn.ReturnEmptyAsync(msg, 2);
return;
}
await conn.ReturnAsync(msg, new SceneMethods.SummonVehicleResult
{
VehicleEntityId = result.EntityId,
TaskToken = result.Token,
});
}
/// <summary>
/// Direct scene spawn shared by client summon and the debug panel: SyncLogicVehicleEnter
/// + SyncSpawnVehicle (interactable) + SyncChangeVehicleInteractable. Offsets proven by V2:
/// rightOffset meters to the right of the facing, +0.15m up.
/// </summary>
internal static async Task<(bool Ok, string Message, ulong EntityId, ulong Token, Vec3 SpawnPos)> SpawnDirectAsync(
TcpSession session, uint configId, Vec3 nearPos, float facing, float rightOffset, byte sourceType, string reason)
{
if (configId == 0)
return (false, "vehicle 0 does not exist", 0, 0, default);
// Unknown / model-less ids are allowed with a warning (newer client data may know
// more vehicles than our dump); the client validates against its own configs.
var seatCount = 2;
if (VehicleCatalog4229938.TryGet(configId, out var config))
{
if (config.SeatCount is >= 1 and <= 16)
seatCount = config.SeatCount;
if (string.IsNullOrWhiteSpace(config.Model))
session.Log.Warn($"[VEHICLE] config {configId} has no prefab model in dump, trying anyway");
}
else
{
session.Log.Warn($"[VEHICLE] config {configId} not in VehicleConfig dump, trying with {seatCount} seats");
}
var yaw = facing * (MathF.PI / 180f);
var spawn = new Vec3(
nearPos.X + MathF.Cos(yaw) * rightOffset,
nearPos.Y + 0.15f,
nearPos.Z - MathF.Sin(yaw) * rightOffset);
var entityId = (ulong)Interlocked.Increment(ref _vehicleEntitySeq);
var token = (ulong)Interlocked.Increment(ref _vehicleTaskSeq);
RegisterSummoned(entityId, configId, spawn.X, spawn.Y, spawn.Z, facing);
var euler = new SceneMethods.UxVector3(0f, facing, 0f);
var pos = new SceneMethods.UxVector3(spawn.X, spawn.Y, spawn.Z);
await session.NotifyAsync(MethodId.SyncLogicVehicleEnter, UxSerializer.Serialize(new SceneMethods.SyncLogicVehicleEnter
{
EntityId = entityId,
VehicleConfigId = configId,
CreateSourceType = sourceType,
Parts = [],
SuitId = 0,
LicensePlate = string.Empty,
Interactable = true,
MoveToken = 0,
Position = pos,
EulerAngles = euler,
VehicleSpoonName = null,
}), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncSpawnVehicle, UxSerializer.Serialize(new SceneMethods.VehicleClientInfo
{
ControllerPid = 0,
CreateSourceType = sourceType,
EntityId = entityId,
VehicleConfigId = configId,
Parts = [],
SuitId = 0,
Position = pos,
Facing = facing,
EulerAngles = euler,
Velocity = 0,
IsStatic = false,
DeformStatus = 0,
SeatInfos = Enumerable.Range(0, seatCount).Select(i => new SceneMethods.RaidVehicleSeatInfo
{
EntityId = 0,
SeatIndex = (byte)i,
SeatState = 0,
DestroyRelated = false,
}).ToList(),
SpoonId = 0,
IsDynamicGo = false,
VehicleEnemyId = 0,
DisableNavigation = false,
Interactable = true,
MoveToken = 0,
LicensePlate = string.Empty,
}), CancellationToken.None);
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable, UxSerializer.Serialize(
new SceneMethods.SyncChangeVehicleInteractable { VehicleInstanceId = entityId, Interactable = true }),
CancellationToken.None);
session.Log.Info($"[VEHICLE] DIRECT_SPAWN reason={reason} config={configId} entity={entityId} token={token} seats={seatCount} spawn=({spawn.X:F1},{spawn.Y:F1},{spawn.Z:F1})");
return (true, "spawned", entityId, token, spawn);
}
internal static async Task DestroyDirectAsync(TcpSession session, ulong entityId, string reason)
{
ForgetSummoned(entityId);
ResetVehicleStoryForEntity(session, entityId);
await session.NotifyAsync(MethodId.SyncDestroyVehicle, UxSerializer.Serialize(
new SceneMethods.DestroyVehicle { VehicleEntityId = entityId, VehicleDestroyType = 0, Distance = 0, DynamicGoId = 0 }),
CancellationToken.None);
session.Log.Info($"[VEHICLE] destroy entity={entityId} reason={reason}");
}
internal static SceneMethods.AskGetUnlockedVehiclesResult BuildUnlockedResult()
{
var fleet = PrivateServerConfigStore.Current.Gameplay.Vehicles.FleetIds;
return new SceneMethods.AskGetUnlockedVehiclesResult
{
Vehicles = fleet.Select(id => new SceneMethods.PlayerVehicleClientDetail
{
Id = id,
Parts = [],
SuitId = 0,
IsPersistent = true,
}).ToList(),
};
}
/// <summary>
/// Push the owned fleet right after world entry finalization (first gameplay movement),
/// so the phone/garage UI is populated without a manual resync. Once per entry.
/// </summary>
internal static async Task PublishGarageAsync(RpcContext ctx)
{
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
return;
if (!(ctx.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState world))
return;
bool first;
lock (world.SyncRoot)
{
first = !world.GaragePublished;
if (first)
world.GaragePublished = true;
}
if (!first)
return;
var result = BuildUnlockedResult();
await ctx.NotifyAsync(MethodId.SyncAllUnlockedVehicles, result);
ctx.Session.Log.Info($"[VEHICLE] garage auto-published count={result.Vehicles.Count}");
}
/// <summary>
/// Push Aether vehicle-AI init once per world entry (mirrors V2): RaidId +
/// zone-graph handle + empty lists. The client DriveManager gates vehicle
/// materialization on this init; without it spawns stay invisible.
/// </summary>
internal static async Task PublishAetherInitAsync(RpcContext ctx)
{
if (!PrivateServerConfigStore.Current.Gameplay.Vehicles.Enabled)
return;
if (!(ctx.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState world))
return;
bool first;
uint raidId;
lock (world.SyncRoot)
{
first = !world.AetherVehicleInitSent;
if (first)
world.AetherVehicleInitSent = true;
raidId = world.ActiveRaidId;
}
if (!first)
return;
var settings = PrivateServerConfigStore.Current.Gameplay.Vehicles;
var hasZoneGraph = raidId == PrivateServerConfigStore.Current.World.RaidId;
await ctx.NotifyAsync(MethodId.SyncAetherAIInitDatas, new SceneMethods.AetherAIInitData
{
RaidId = raidId,
HasZoneGraph = hasZoneGraph,
ZoneStorageDataHandle = hasZoneGraph ? settings.ZoneStorageDataHandle : 0,
Intersections = [],
Vehicles = [],
StaticVehicles = [],
});
ctx.Session.Log.Info($"[VEHICLE] aether-init raid={raidId} zoneGraph={hasZoneGraph} lists=empty");
}
[Handler(MethodId.AskGetUnlockedVehicles, HandlerPacketKind.Invoke)]
private Task AskGetUnlockedVehicles(Connection conn, UxRpcMessage msg)
{
var result = BuildUnlockedResult();
conn.Log.Info($"[VEHICLE] unlocked list -> {result.Vehicles.Count} vehicles");
return conn.ReturnAsync(msg, result);
}
// Client-driven enter/exit + movement reports. The client owns the simulation;
// the server accepts them so the drive loop never stalls waiting for a reply.
// AskVehicleMove additionally feeds the debug panel's live coordinates.
[Handler(MethodId.AskVehicleMove, HandlerPacketKind.Notify)]
private Task AskVehicleMove(Connection conn, UxRpcMessage msg)
{
try
{
var data = msg.GetArgs<SceneMethods.RaidVehicleSyncData>();
TrackVehicleMove(data.Id, data.Position.X, data.Position.Y, data.Position.Z, data.FacingDirection);
}
catch (Exception ex)
{
conn.Log.Warn($"[VEHICLE] move parse failed, accepted anyway: {ex.Message}");
}
return Task.CompletedTask;
}
[Handler(MethodId.AskPlayerStartEnterOrExitVehicle, HandlerPacketKind.Notify)]
[Handler(MethodId.AskPlayerFinishEnterOrExitVehicle, HandlerPacketKind.Notify)]
[Handler(MethodId.AskVehicleStartMove, HandlerPacketKind.Notify)]
[Handler(MethodId.AskVehicleStopMove, HandlerPacketKind.Notify)]
[Handler(MethodId.AskVehicleHorn, HandlerPacketKind.Notify)]
[Handler(MethodId.AskEnterVehicleIndoor, HandlerPacketKind.Notify)]
[Handler(MethodId.AskExitVehicleIndoor, HandlerPacketKind.Notify)]
[Handler(MethodId.ReportDrivingVehicle, HandlerPacketKind.Notify)]
[Handler(MethodId.AskKillVehicle, HandlerPacketKind.Notify)]
private Task VehicleNotifyAccept(Connection conn, UxRpcMessage msg) => Task.CompletedTask;
// Drive-loop invokes with void returns (combat/contact/horn-nitro/state signals).
[Handler(MethodId.VehicleDriveStateChange, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskChangeGoVehicleDriveState, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskVehicleDeadEnd, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskVehicleNitro, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskVehicleStuck, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskVehicleHit, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskVehicleHitEnd, HandlerPacketKind.Invoke)]
private Task VehicleInvokeAccept(Connection conn, UxRpcMessage msg)
=> conn.ReturnEmptyOkAsync(msg);
// Autonomous Driving & Vehicle Pathfinder Handlers
[Handler(MethodId.AskVehicleStartAutonomousDriving, HandlerPacketKind.Invoke)]
private async Task AskVehicleStartAutonomousDriving(Connection conn, UxRpcMessage msg)
{
var vehicleEntityId = LastSummonedEntity();
conn.Log.Info($"[AUTODRIVE] start requested vehicleEntity={vehicleEntityId}");
await conn.ReturnEmptyOkAsync(msg);
var syncVehicle = new SceneMethods.SyncVehicleAutonomousDrivingState
{
VehicleEntityId = vehicleEntityId,
IsStart = true
};
await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleAutonomousDrivingState, syncVehicle);
var syncPlayer = new SceneMethods.SyncPlayerAutonomousDrivingState
{
IsInOverrideMode = false,
IsAutoDrivingBlocked = false,
IsImmersiveModeBlocked = false
};
await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncPlayerAutonomousDrivingState, syncPlayer);
}
[Handler(MethodId.AskVehicleStopAutonomousDriving, HandlerPacketKind.Invoke)]
private async Task AskVehicleStopAutonomousDriving(Connection conn, UxRpcMessage msg)
{
var vehicleEntityId = LastSummonedEntity();
conn.Log.Info($"[AUTODRIVE] stop requested vehicleEntity={vehicleEntityId}");
await conn.ReturnEmptyOkAsync(msg);
var syncVehicle = new SceneMethods.SyncVehicleAutonomousDrivingState
{
VehicleEntityId = vehicleEntityId,
IsStart = false
};
await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleAutonomousDrivingState, syncVehicle);
}
[Handler(MethodId.AskVehicleChangeAutonomousDrivingTarget, HandlerPacketKind.Invoke)]
private Task AskVehicleChangeAutonomousDrivingTarget(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[AUTODRIVE] target position changed");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskVehicleCancelAutonomousDrivingTarget, HandlerPacketKind.Invoke)]
private Task AskVehicleCancelAutonomousDrivingTarget(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[AUTODRIVE] target position cancelled");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskVehicleNavigationPathPoints, HandlerPacketKind.Invoke)]
private Task AskVehicleNavigationPathPoints(Connection conn, UxRpcMessage msg)
{
uint navReqId = 0;
SceneMethods.UxVector3 targetPos = new(0, 0, 0);
try
{
var args = msg.GetArgs<SceneMethods.AskVehicleNavigationPathPointsArgs>();
navReqId = args.NavReqId;
targetPos = args.TargetPosition;
}
catch (Exception ex)
{
conn.Log.Warn($"[NAV] parse path points failed: {ex.Message}");
}
var state = conn.Session is not null ? GetStateIfExists(conn.Session) : null;
var playerPos = state?.LastReportedPlayerPosition ?? Profile.WorldSpawn;
var startUx = new SceneMethods.UxVector3(playerPos.X, playerPos.Y, playerPos.Z);
// Generate intermediate waypoints between player and target so GPS renders along roads
var points = GenerateNavWaypoints(startUx, targetPos);
var result = new SceneMethods.VehicleNavigationPathPointsResult
{
NavReqId = navReqId,
Points = points,
CenterPoints = points
};
conn.Log.Info($"[NAV] path points req={navReqId} pts={points.Count} from=({playerPos.X:F1},{playerPos.Y:F1},{playerPos.Z:F1}) to=({targetPos.X:F1},{targetPos.Y:F1},{targetPos.Z:F1})");
return conn.ReturnAsync(msg, result);
}
private static List<SceneMethods.UxVector3> GenerateNavWaypoints(SceneMethods.UxVector3 start, SceneMethods.UxVector3 end)
{
var list = new List<SceneMethods.UxVector3> { start };
float dx = end.X - start.X;
float dy = end.Y - start.Y;
float dz = end.Z - start.Z;
float dist = MathF.Sqrt(dx * dx + dy * dy + dz * dz);
// Generate smooth path segments for vehicle road line
if (dist > 25f)
{
int steps = Math.Clamp((int)(dist / 20f), 2, 12);
for (int i = 1; i < steps; i++)
{
float t = (float)i / steps;
list.Add(new SceneMethods.UxVector3(start.X + dx * t, start.Y + dy * t, start.Z + dz * t));
}
}
list.Add(end);
return list;
}
[Handler(MethodId.AskVehicleNavigationPathLength, HandlerPacketKind.Invoke)]
private Task AskVehicleNavigationPathLength(Connection conn, UxRpcMessage msg)
{
var state = conn.Session is not null ? GetStateIfExists(conn.Session) : null;
var playerPos = state?.LastReportedPlayerPosition ?? Profile.WorldSpawn;
return conn.ReturnAsync(msg, 150.0f);
}
[Handler(MethodId.AskVehicleNavigationPathLengthList, HandlerPacketKind.Invoke)]
private Task AskVehicleNavigationPathLengthList(Connection conn, UxRpcMessage msg)
{
return conn.ReturnAsync(msg, new List<float> { 150.0f });
}
// Metro / Subway System Handlers
[Handler(MethodId.AskGetAllMetroInfos, HandlerPacketKind.Invoke)]
private Task AskGetAllMetroInfos(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[METRO] GetAllMetroInfos requested -> returning active metro train lines");
// Active trains for rail lines 1, 2, 3, 4 across the city
var list = new List<MetroClientInfo>
{
new() { Id = 1001, LineId = 1, ElapsedTime = 12.0f, IsFinalTrain = false },
new() { Id = 1002, LineId = 2, ElapsedTime = 25.0f, IsFinalTrain = false },
new() { Id = 1003, LineId = 3, ElapsedTime = 38.0f, IsFinalTrain = false },
new() { Id = 1004, LineId = 4, ElapsedTime = 5.0f, IsFinalTrain = false }
};
return conn.ReturnAsync(msg, list);
}
[Handler(MethodId.AskMetroGadgetIds, HandlerPacketKind.Invoke)]
private Task AskMetroGadgetIds(Connection conn, UxRpcMessage msg)
{
return conn.ReturnAsync(msg, new List<MetroCarriageGadgetInfos>());
}
[Handler(MethodId.AskPlayerOnMetro, HandlerPacketKind.Invoke)]
private Task AskPlayerOnMetro(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[METRO] player entered/boarded metro train");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskOnMetroEnterStation, HandlerPacketKind.Notify)]
private Task AskOnMetroEnterStation(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[METRO] player entered station");
return Task.CompletedTask;
}
[Handler(MethodId.AskOnMetroExitStation, HandlerPacketKind.Notify)]
private Task AskOnMetroExitStation(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[METRO] player exited station");
return Task.CompletedTask;
}
[Handler(MethodId.AskPlayerLeaveMetro, HandlerPacketKind.Invoke)]
private Task AskPlayerLeaveMetro(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[METRO] player leave metro requested");
return conn.ReturnEmptyOkAsync(msg);
}
// Taffy Moto / Monowheel Handlers
[Handler(MethodId.AskTaffyMotoEnterRush, HandlerPacketKind.Invoke)]
private Task AskTaffyMotoEnterRush(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[TAFFY_MOTO] AskTaffyMotoEnterRush");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskTaffyMotoLeaveRush, HandlerPacketKind.Invoke)]
private Task AskTaffyMotoLeaveRush(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[TAFFY_MOTO] AskTaffyMotoLeaveRush");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.OnTafeiMotorColliding, HandlerPacketKind.Invoke)]
private Task OnTafeiMotorColliding(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[TAFFY_MOTO] OnTafeiMotorColliding");
return conn.ReturnEmptyOkAsync(msg);
}
[Handler(MethodId.AskGetOffMotor, HandlerPacketKind.Invoke)]
private Task AskGetOffMotor(Connection conn, UxRpcMessage msg)
{
conn.Log.Info("[TAFFY_MOTO] AskGetOffMotor");
return conn.ReturnEmptyOkAsync(msg);
}
}
@@ -0,0 +1,514 @@
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.Handlers;
using Ananta.Server.Gameplay;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>World entry and client-driven scene loading barriers.</summary>
internal sealed partial class GameRouter
{
WorldEntryState GetWorldState(RpcContext ctx)
{
if (ctx.Session.Items.TryGetValue(WorldStateKey, out var raw) && raw is WorldEntryState existing)
return existing;
var created = new WorldEntryState();
created.SeedHistoryFromGlobal();
ctx.Session.Items[WorldStateKey] = created;
return created;
}
void ArmWorldEntry4229938(WorldEntryState state)
{
lock (state.SyncRoot)
{
var next = state.WorldEntryControlGeneration + 1;
if (next <= 0)
next = 1;
state.WorldEntryControlGeneration = next;
state.WorldEntryControlPending = true;
state.WorldEntryControlFinalized = false;
state.WorldEntryControlUnit = Profile.InitialUnitId;
state.WorldEntryControlTemplate = Profile.InitialSpiritTemplateId;
state.WorldEntryLoadingCompletedGeneration = 0;
state.WorldEntryOpeningEndedGeneration = 0;
state.WorldEntryControlFinalizingGeneration = 0;
state.WorldEntryLogicProjectionPublishedGeneration = 0;
state.WorldEntryCurrentMetadataPublishedGeneration = 0;
state.WorldEntrySceneId4229938 = 0;
state.WorldEntrySessionId4229938 = 0;
state.WorldEntryCreateHeroPosition = Profile.WorldSpawn;
state.WorldEntryCreateHeroFacing = Profile.WorldFacing;
state.LastReportedPlayerPosition = Profile.WorldSpawn;
state.LastReportedPlayerRotation = new Vec3(0f, Profile.WorldFacing, 0f);
state.HasLastReportedPlayerTransform = false;
state.LastSwitchShowId = 0;
state.AllBuildBuffsPublished = false;
state.GaragePublished = false;
state.AetherVehicleInitSent = false;
state.InitialActorPresentationPublished = false;
state.ActiveSpiritUnitId = Profile.InitialUnitId;
state.ActiveSpiritTemplateId = Profile.InitialSpiritTemplateId;
state.WorldEntryIsAirportTravel = false;
}
}
async Task SendInitialGameState(RpcContext ctx)
{
var state = GetWorldState(ctx);
if (state.InitialStateSent)
{
ctx.Session.Log.Info("[WORLD-MIN] LoginGame repeat ignored");
return;
}
state.InitialStateSent = true;
await ctx.NotifyAsync(MethodId.SendServerTimeGame, LoginCodec.ServerTime());
await ctx.NotifyAsync(MethodId.SyncPlayerInfo, RuntimePayloadFactory.MinimalPlayerInfo4229938());
await SendEnterSceneIfNeeded(ctx);
}
async Task SendEnterSceneIfNeeded(RpcContext ctx)
{
var state = GetWorldState(ctx);
if (state.EnterSceneSent)
return;
state.EnterSceneSent = true;
ArmWorldEntry4229938(state);
state.WorldEntrySwitchShowId = 0;
var rollback = WorldEntryState.TryGetGlobalCheckpointSecondsAgo(10.0);
Vec3 spawnPos;
float spawnFacing;
if (rollback is not null && (DateTime.UtcNow - rollback.Timestamp).TotalSeconds < 60)
{
spawnPos = rollback.Position;
spawnFacing = rollback.Facing;
ctx.Session.Log.Info($"[RECONNECT-ROLLBACK] Reconnecting player restored to 10s prior position ({spawnPos.X:F1},{spawnPos.Y:F1},{spawnPos.Z:F1}) facing={spawnFacing:F1}");
}
else
{
spawnPos = Profile.WorldSpawn;
spawnFacing = Profile.WorldFacing;
}
state.WorldEntryCreateHeroPosition = spawnPos;
state.WorldEntryCreateHeroFacing = spawnFacing;
// Minimal mode intentionally does not ask the client to play an authored opening Timeline.
// Scene assets and native quest/story systems remain entirely client-owned.
await ctx.NotifyAsync(MethodId.SyncEnterScene, RuntimePayloadFactory.EnterScene(
Profile.RaidId,
Profile.SceneInstanceId,
Profile.UniverseId,
Profile.InitialUnitId,
Profile.InitialSpiritTemplateId,
spawnPos,
spawnFacing,
switchShowId: 0,
isSwitchSpiritShow: false));
ctx.Session.Log.Info($"[WORLD-MIN] enter generation={state.WorldEntryControlGeneration} raid={Profile.RaidId} instance={Profile.SceneInstanceId} universe={Profile.UniverseId} unit={Profile.InitialUnitId} template={Profile.InitialSpiritTemplateId} pos=({spawnPos.X:0.##},{spawnPos.Y:0.##},{spawnPos.Z:0.##}) opening=false switchShow=0");
}
async Task<bool> CommitWorldEntryCreateHeroData4229938(RpcContext ctx, int generation)
{
var state = GetWorldState(ctx);
ulong unitId;
uint templateId;
ulong playerPid;
Vec3 createHeroPosition;
float createHeroFacing;
string? reject = null;
// Port the proven v7 preflight literally: validate the complete canonical identity and
// CreateHero transform BEFORE claiming/sending the first authority packet. A failure after
// LogicAgentEnter would leave an orphan authority entity, so this block is fail-closed.
lock (state.SyncRoot)
{
if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized)
reject = "transaction-not-pending";
else if (generation <= 0 || generation != state.WorldEntryControlGeneration)
reject = "generation-mismatch";
else if (Profile.PlayerPid == 0 || state.WorldEntryControlUnit == 0 || state.WorldEntryControlTemplate == 0)
reject = "zero-identity";
else if (state.ActiveSpiritUnitId != state.WorldEntryControlUnit
|| state.ActiveSpiritTemplateId != state.WorldEntryControlTemplate)
reject = "active-identity-mismatch";
else if (state.WorldEntryControlUnit != Profile.InitialUnitId
|| state.WorldEntryControlTemplate != Profile.InitialSpiritTemplateId)
reject = "profile-identity-mismatch";
else if (!ClientConfigRepository.Characters().Any(x =>
x.UnitId == state.WorldEntryControlUnit &&
x.TemplateId == state.WorldEntryControlTemplate))
reject = "canonical-roster-row-missing";
else if (!float.IsFinite(state.WorldEntryCreateHeroPosition.X)
|| !float.IsFinite(state.WorldEntryCreateHeroPosition.Y)
|| !float.IsFinite(state.WorldEntryCreateHeroPosition.Z)
|| !float.IsFinite(state.WorldEntryCreateHeroFacing))
reject = "non-finite-createhero-transform";
else if (state.WorldEntryLogicProjectionPublishedGeneration == generation
&& state.WorldEntryCurrentMetadataPublishedGeneration == generation)
return true;
else if (state.WorldEntryLogicProjectionPublishedGeneration != 0
|| state.WorldEntryCurrentMetadataPublishedGeneration != 0)
reject = "projection-already-claimed";
unitId = state.WorldEntryControlUnit;
templateId = state.WorldEntryControlTemplate;
playerPid = Profile.PlayerPid;
createHeroPosition = state.WorldEntryCreateHeroPosition;
createHeroFacing = state.WorldEntryCreateHeroFacing;
if (reject is null)
{
// Both generation edges are claimed before any send. Partial failure remains
// fail-closed; a duplicate callback must never replay half of the transaction.
state.WorldEntryLogicProjectionPublishedGeneration = -generation;
state.WorldEntryCurrentMetadataPublishedGeneration = -generation;
}
}
if (reject is not null)
{
ctx.Session.Log.Warn($"[WORLD-V7] commit rejected generation={generation} reason={reject}");
return false;
}
// Exact proven order from the supplied v7 guide. No sleep, no presentation hydration,
// no buffs and no weapon/fashion deltas may be interleaved into this quartet.
await ctx.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(unitId));
await ctx.NotifyAsync(MethodId.SyncManagedLogicAgent, WorldCodec.ManagedLogicAgent(unitId, playerPid, 0));
await ctx.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit,
RuntimePayloadFactory.ExistingUnitProjection(unitId, templateId, createHeroPosition, createHeroFacing));
lock (state.SyncRoot)
{
if (state.WorldEntryLogicProjectionPublishedGeneration == -generation)
state.WorldEntryLogicProjectionPublishedGeneration = generation;
}
await ctx.NotifyAsync(MethodId.SyncPlayerCurrentSpirit,
WorldCodec.CurrentSpirit(playerPid, templateId, unitId, isAgentSwitch: false));
lock (state.SyncRoot)
{
if (state.WorldEntryCurrentMetadataPublishedGeneration == -generation)
state.WorldEntryCurrentMetadataPublishedGeneration = generation;
state.CurrentSpiritStateSent = true;
state.ControlPublished = true;
}
ctx.Session.Log.Info($"[HANDOFF-V7] generation={generation} exact=LogicAgentEnter->ManagedLogicAgent->sameID-AOI->CurrentSpirit unit={unitId} template={templateId} presentation=false buffs=false duplicateActor=false");
return true;
}
async Task OnLoadSceneCompleted(RpcContext ctx, ulong sceneId, ulong sessionId)
{
var state = GetWorldState(ctx);
int generation = 0;
string? reject = null;
lock (state.SyncRoot)
{
if (sceneId == 0 || sessionId == 0)
reject = "invalid-scene-session";
else if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized)
reject = "transaction-not-pending";
else if (state.WorldEntrySceneId4229938 == 0 && state.WorldEntrySessionId4229938 == 0)
{
state.WorldEntrySceneId4229938 = sceneId;
state.WorldEntrySessionId4229938 = sessionId;
}
else if (state.WorldEntrySceneId4229938 != sceneId || state.WorldEntrySessionId4229938 != sessionId)
reject = "correlation-mismatch";
if (reject is null)
{
generation = state.WorldEntryControlGeneration;
state.SceneLoadAckSeen = true;
state.SceneId = sceneId;
}
}
if (reject is not null)
{
ctx.Session.Log.Warn($"[WORLD] AskLoadSceneCompleted rejected scene={sceneId} session={sessionId} reason={reject}");
return;
}
if (!await CommitWorldEntryCreateHeroData4229938(ctx, generation))
return;
// The confirmed handoff transaction ends with CurrentSpirit. Do not interleave roster/combat/state
// hydration here; the client must reach AskLoadingFinished with the post-load quartet intact.
ctx.Session.Log.Info($"[WORLD] load-edge committed generation={generation} scene={sceneId} session={sessionId} logicProjection={state.WorldEntryLogicProjectionPublishedGeneration} currentMetadata={state.WorldEntryCurrentMetadataPublishedGeneration} sceneComplete=false hydration=false");
}
Task OnLoadGameResourcesCompleted(RpcContext ctx, ulong sceneId)
{
var state = GetWorldState(ctx);
state.GameResourcesReadySeen = true;
if (state.SceneId == 0 && sceneId != 0)
state.SceneId = sceneId;
return Task.CompletedTask;
}
async Task OnLoadingFinished(RpcContext ctx, ulong sceneId, ulong sessionId)
{
var state = GetWorldState(ctx);
int generation = 0;
string? reject = null;
lock (state.SyncRoot)
{
if (sceneId == 0 || sessionId == 0)
reject = "invalid-scene-session";
else if (!state.WorldEntryControlPending || state.WorldEntryControlFinalized)
reject = state.WorldEntryControlFinalized ? "already-finalized" : "transaction-not-pending";
else if (sceneId != state.WorldEntrySceneId4229938 || sessionId != state.WorldEntrySessionId4229938)
reject = "correlation-mismatch";
else
{
generation = state.WorldEntryControlGeneration;
if (state.WorldEntryLogicProjectionPublishedGeneration != generation
|| state.WorldEntryCurrentMetadataPublishedGeneration != generation)
reject = "v7-handoff-incomplete";
else if (state.WorldEntryLoadingCompletedGeneration == generation)
return;
else if (state.WorldEntryLoadingCompletedGeneration != 0)
reject = "loading-edge-already-claimed";
else if (state.WorldEntryControlFinalizingGeneration != 0)
reject = "finalizer-already-claimed";
else
{
state.WorldEntryLoadingCompletedGeneration = -generation;
state.WorldEntryControlFinalizingGeneration = -generation;
state.SceneId = sceneId;
}
}
}
if (reject is not null)
{
ctx.Session.Log.Warn($"[WORLD-V7] AskLoadingFinished rejected scene={sceneId} session={sessionId} generation={generation} reason={reject}");
return;
}
// Supplied guide invariant: SyncSceneLoadCompleted is the FIRST and ONLY S2C gameplay edge
// of AskLoadingFinished. Do not put buffs/weapon/fashion/current before it. All optional
// runtime hydration is deferred to the first real gameplay movement after Ready=true.
await ctx.NotifyAsync(MethodId.SyncSceneLoadCompleted, WorldCodec.SceneLoadCompleted(sceneId));
lock (state.SyncRoot)
{
state.SceneCompletionSent = true;
if (state.WorldEntryLoadingCompletedGeneration == -generation)
state.WorldEntryLoadingCompletedGeneration = generation;
state.LivePlayerProfilePublished = false;
state.CombatProfilePublished = false;
state.AccountArmoryPublished = true; // compact referenced subset already arrived in SyncPlayerInfo
state.FreeRoamReleased = false;
state.MovementCapabilityPublished = false;
state.AllBuildBuffsPublished = false;
state.GaragePublished = false;
state.AetherVehicleInitSent = false;
ResetVehicleStory(ctx.Session);
state.InitialActorPresentationPublished = false;
state.InitialCapabilityBuffsPublished = false;
state.WorldEntryControlPending = false;
state.WorldEntryControlFinalized = true;
if (state.WorldEntryControlFinalizingGeneration == -generation)
state.WorldEntryControlFinalizingGeneration = generation;
state.Ready = true;
state.WorldEntryIsAirportTravel = false;
}
// Normal post-load finalization, deliberately after the terminal scene-ready edge.
await ctx.NotifyAsync(MethodId.SyncGamePause, WorldCodec.GamePause(false));
await PublishGarageAsync(ctx);
await PublishAetherInitAsync(ctx);
await EnsureVehicleStoryRoot(ctx);
await PushSessionTimeAsync(ctx.Session);
await PushSessionWeatherAsync(ctx.Session);
var activitiesSync = new GameMethods.SyncAllActivities
{
activities =
[
new GameMethods.AwardActivityItem
{
BaseActivityInfo = new GameMethods.BaseActivityInfo { CfgId = 2684, StartTime = 0, EndTime = 2000000000 },
ActivityData = new GameMethods.ActivityData { CfgId = 2684, ShowRedPoint = true, IsOutOfDate = false }
},
new GameMethods.AwardActivityItem
{
BaseActivityInfo = new GameMethods.BaseActivityInfo { CfgId = 2685, StartTime = 0, EndTime = 2000000000 },
ActivityData = new GameMethods.ActivityData { CfgId = 2685, ShowRedPoint = true, IsOutOfDate = false }
},
new GameMethods.AwardActivityItem
{
BaseActivityInfo = new GameMethods.BaseActivityInfo { CfgId = 2686, StartTime = 0, EndTime = 2000000000 },
ActivityData = new GameMethods.ActivityData { CfgId = 2686, ShowRedPoint = true, IsOutOfDate = false }
}
]
};
await ctx.NotifyAsync(MethodId.SyncAllActivities, activitiesSync);
// Unlock all scene fog of war on the client
await ctx.NotifyAsync(MethodId.SyncSceneFogMapAllUnlock, new SceneMethods.SyncSceneFogMapAllUnlockInfo
{
SceneId = state.ActiveRaidId,
Unlocked = true
});
// Initialize story task tracking
await ctx.NotifyAsync(MethodId.SyncCurrentTask, new SceneMethods.SyncCurrentTaskInfo
{
Type = 1,
TaskId = 10001,
EventId = 0,
FirstTime = true,
Reason = 0
});
ctx.Session.Log.Info($"[WORLD-V7] ready generation={generation} scene={sceneId} session={sessionId} exactGuide=true sceneComplete=first actorPresentation=client-owned buffs=deferred-first-movement noStory=false noAOI=true");
}
async Task OnLoadedInSameScene(RpcContext ctx)
{
var state = GetWorldState(ctx);
bool firstPresentationEdge;
lock (state.SyncRoot)
{
firstPresentationEdge = !state.SameSceneAckSeen;
state.SameSceneAckSeen = true;
if (firstPresentationEdge && Profile.WorldEntryOpeningEnabled && state.WorldEntryControlGeneration > 0)
state.WorldEntryOpeningEndedGeneration = state.WorldEntryControlGeneration;
}
// 4229938 emits AskLoadedInSameScene after every authored same-scene character switch, not
// only once after login. SyncPlayerLoadRate is therefore an acknowledgement for EACH edge.
// Keep the opening-generation bookkeeping one-shot, but never suppress the per-switch ack.
await ctx.NotifyAsync(MethodId.SyncPlayerLoadRate, WorldCodec.PlayerLoadRate());
ctx.Session.Log.Info($"[SAME-SCENE] load-rate ack=true first={firstPresentationEdge} generation={state.WorldEntryControlGeneration} pendingSwitch={state.PendingSwitchTemplateId}/{state.PendingSwitchUnitId} controlMutation=false");
}
internal static async Task<(bool Ok, string Message)> SwitchSceneDirectAsync(
TcpSession session,
uint raidId,
ulong instanceId,
uint universeId,
Vec3 position,
float facing,
bool isAirport = false)
{
if (!session.Items.TryGetValue(WorldStateKey, out var raw) || raw is not WorldEntryState state)
return (false, "no active world state for session");
int generation;
lock (state.SyncRoot)
{
var next = state.WorldEntryControlGeneration + 1;
if (next <= 0) next = 1;
generation = next;
state.WorldEntryControlGeneration = next;
state.WorldEntryControlPending = true;
state.WorldEntryControlFinalized = false;
state.WorldEntryControlUnit = Profile.InitialUnitId;
state.WorldEntryControlTemplate = Profile.InitialSpiritTemplateId;
state.WorldEntryLoadingCompletedGeneration = 0;
state.WorldEntryOpeningEndedGeneration = 0;
state.WorldEntryControlFinalizingGeneration = 0;
state.WorldEntryLogicProjectionPublishedGeneration = 0;
state.WorldEntryCurrentMetadataPublishedGeneration = 0;
state.WorldEntrySceneId4229938 = 0;
state.WorldEntrySessionId4229938 = 0;
state.WorldEntryCreateHeroPosition = position;
state.WorldEntryCreateHeroFacing = facing;
state.LastReportedPlayerPosition = position;
state.LastReportedPlayerRotation = new Vec3(0f, facing, 0f);
state.HasLastReportedPlayerTransform = false;
state.LastSwitchShowId = 0;
state.AllBuildBuffsPublished = false;
state.GaragePublished = false;
state.AetherVehicleInitSent = false;
state.InitialActorPresentationPublished = false;
state.ActiveSpiritUnitId = Profile.InitialUnitId;
state.ActiveSpiritTemplateId = Profile.InitialSpiritTemplateId;
state.WorldEntryIsAirportTravel = isAirport;
state.Ready = false;
state.ActiveRaidId = raidId;
state.ActiveInstanceId = instanceId;
state.ActiveUniverseId = universeId;
}
var payload = RuntimePayloadFactory.EnterScene(
raidId,
instanceId,
universeId,
Profile.InitialUnitId,
Profile.InitialSpiritTemplateId,
position,
facing,
switchShowId: 0,
isSwitchSpiritShow: false);
var bytes = UxSerializer.Serialize(payload);
await session.NotifyAsync(MethodId.SyncEnterScene, bytes, CancellationToken.None);
session.Log.Info($"[WORLD] scene-switch generation={generation} raid={raidId} instance={instanceId} universe={universeId} pos=({position.X:0.##},{position.Y:0.##},{position.Z:0.##}) facing={facing:0.##} airport={isAirport}");
return (true, $"switched to raid={raidId} instance={instanceId} universe={universeId}");
}
internal static async Task HandleAirportOrTeleportAsync(TcpSession session)
{
if (!session.Items.TryGetValue(WorldStateKey, out var raw) || raw is not WorldEntryState state)
return;
uint currentRaid;
lock (state.SyncRoot)
{
currentRaid = state.ActiveRaidId;
}
if (currentRaid == 23300888)
{
session.Log.Info("[AIRPORT] travel: Main City (23300888) -> Longqi Village (23300999)");
await SwitchSceneDirectAsync(
session,
raidId: 23300999,
instanceId: 20001223,
universeId: 76000888,
position: new Vec3(-4719.8f, 168.5f, -2844.7f),
facing: 0f,
isAirport: true);
}
else
{
session.Log.Info($"[AIRPORT] travel: raid {currentRaid} -> Main City (23300888)");
await SwitchSceneDirectAsync(
session,
raidId: 23300888,
instanceId: 20001222,
universeId: 76000888,
position: new Vec3(-4468.3f, -6.15f, -2597.5f),
facing: 85.5f,
isAirport: true);
}
}
}
@@ -0,0 +1,197 @@
using Ananta.SDK.Rpc;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.Handlers.LoginGate;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Build 4229938 private-server surface: world entry, direct character switching,
/// combat, traversal/buffs, vehicles and movement. Everything else falls back to typed-neutral RPC replies.
/// </summary>
internal sealed partial class GameRouter
{
internal const string WorldStateKey = "client4229938-world";
private readonly GateSessionHub? _gateSessions;
internal GameRouter(GateSessionHub? gateSessions = null) => _gateSessions = gateSessions;
private static readonly HashSet<uint> Enabled4229938MethodIds = new()
{
// Login / world-entry barriers.
MethodId.LoginGame,
MethodId.RequestGameSceneData,
MethodId.GetServerTimeGame,
MethodId.GetSpriteToken,
MethodId.CheckPSOPermissions,
MethodId.AskSetGamePause,
MethodId.AskLoadSceneCompleted,
MethodId.AskLoadGameResCompleted,
MethodId.AskLoadingFinished,
MethodId.AskLoadedInSameScene,
// Roster + direct character switching.
MethodId.AskAllSpiritPanelData,
MethodId.AskSwitchSpirit,
// Combat / weapon sandbox.
MethodId.AskSwitchWeapon,
MethodId.AskSwitchFightStyle,
MethodId.AskSetWeaponFightStyle,
MethodId.AskLoadWeaponToSlot,
MethodId.AskDepositSpiritWeapon,
MethodId.AskExchangeWeaponSlot,
MethodId.AskClientUseCommonSkill,
MethodId.AskUseSkill,
MethodId.AskSkillUseWeaponDurability,
MethodId.AskWeaponEquipBullets,
MethodId.AskSetWeaponSkins,
MethodId.AskSyncWeaponSkinToSpirits,
MethodId.ReportSkillEnd,
MethodId.AskMultipleSkillHit2,
MethodId.AskInterruptSkillExecute,
MethodId.AskInterruptSkillExecuteStiff,
MethodId.AskSkillExecuteEnd,
MethodId.AskSkillExecute,
MethodId.AskSkillDestructibleCreate,
// Client-decided traversal/web buffs.
MethodId.AskAddClientBuff,
MethodId.AskRemoveClientBuff,
MethodId.AskEnterFeiSuoCrouch,
MethodId.AskFeiSuoSuccess,
MethodId.AskLeaveFeiSuoCrouch,
// Live transform tracking.
MethodId.AskReportLogicAgentSyncData,
MethodId.AskUnitMoveActionSimple,
MethodId.AskUnitMoveActionSimpleWithGround,
MethodId.AskUnitMoveActionWithGround,
MethodId.AskUnitMoveAction,
// Vehicles: summon + owned fleet + client-driven drive loop + S011 boarding story.
MethodId.AskSummonVehicle,
MethodId.AskGetUnlockedVehicles,
MethodId.SyncStoryCoreClientInfo,
MethodId.AskClaimVehicleSeat,
MethodId.AskPlayerStartEnterOrExitVehicle,
MethodId.AskPlayerFinishEnterOrExitVehicle,
MethodId.AskVehicleMove,
MethodId.AskVehicleStartMove,
MethodId.AskVehicleStopMove,
MethodId.AskVehicleHorn,
MethodId.AskEnterVehicleIndoor,
MethodId.AskExitVehicleIndoor,
MethodId.ReportDrivingVehicle,
MethodId.AskKillVehicle,
MethodId.VehicleDriveStateChange,
MethodId.AskChangeGoVehicleDriveState,
MethodId.AskVehicleDeadEnd,
MethodId.AskVehicleNitro,
MethodId.AskVehicleStuck,
MethodId.AskVehicleHit,
MethodId.AskVehicleHitEnd,
// GM console (ALT+F1): C2S invokes, server records real ids.
MethodId.GmSpawnVehicle,
MethodId.GmAddEnemyWithPosition,
MethodId.GmAddEnemy,
MethodId.GmAddEnemyByPlayer,
MethodId.GmTeleportXYZ,
MethodId.DavinciCode,
// Time of day: client-driven UI (accept + remember) + debug-panel slider push.
MethodId.AskPassingTime,
MethodId.GmPassingTime,
MethodId.GmSetTime,
MethodId.GmFixRaidTime,
MethodId.ChangePersonalTimeSetting,
MethodId.AddPersonalTimeSetting,
MethodId.GmSetWeather,
MethodId.GmSetWeatherParam,
// Fast travel, teleport, and scene transfers.
MethodId.AskTeleport,
MethodId.ReportPreTeleportFinish,
MethodId.ReportPostTeleportFinish,
MethodId.AskGadgetDoorTransfer,
MethodId.AskPlayerChangePositionByScenePortal,
MethodId.AskPublicSwitchToPublicScene,
// Gacha system.
MethodId.AskDrawGacha,
MethodId.AskClaimGachaMilestone,
MethodId.AskChaosMasterGacha,
// Activities.
MethodId.AskActivityCancelRedPoint,
// Wardrobe & Fashion dressing.
MethodId.AskSetSpiritFashionsWithSource,
MethodId.AskSetSpiritFashions,
MethodId.AskModifySpiritWearFashionsOnlyWearWithSource,
MethodId.AskModifySpiritWearFashionsOnlyWear,
MethodId.AskModifySpiritWearFashionsWithSource,
MethodId.AskModifySpiritWearFashionEditInfosWithSource,
MethodId.AskModifySpiritWearFashionEditInfos,
MethodId.AskSetSpiritWearFashionHiddenPartsWithSource,
MethodId.AskSetSpiritWearFashionHiddenParts,
MethodId.AskOpenOrCloseFashionPanel,
MethodId.AskUnlockFashionColoringSlot,
MethodId.AskUnlockFashionSuitSlot,
MethodId.AskApplyFashionColoringSchemeInf,
MethodId.AskSetFashionColoringSchemeInfos,
MethodId.AskFavoriteFashions,
MethodId.AskFavoriteFashionSuits,
MethodId.AskReadFashions,
MethodId.AskReadFashionSuits,
MethodId.AskSetSpiritFashionVariantPreference,
MethodId.AskTradeRecycleFashion,
MethodId.AskClearSwitchInFashionCache,
MethodId.AskRecommendGetTopWearFashionTag,
// Autonomous driving & Vehicle pathfinder.
MethodId.AskVehicleStartAutonomousDriving,
MethodId.AskVehicleStopAutonomousDriving,
MethodId.AskVehicleChangeAutonomousDrivingTarget,
MethodId.AskVehicleCancelAutonomousDrivingTarget,
MethodId.AskVehicleNavigationPathPoints,
MethodId.AskVehicleNavigationPathLength,
MethodId.AskVehicleNavigationPathLengthList,
// Metro / Subway system.
MethodId.AskGetAllMetroInfos,
MethodId.AskMetroGadgetIds,
MethodId.AskPlayerOnMetro,
MethodId.AskOnMetroEnterStation,
MethodId.AskOnMetroExitStation,
MethodId.AskPlayerLeaveMetro,
// Story & Quests.
MethodId.AskAcceptTask,
MethodId.AskAcceptAndSetCurrentTask,
MethodId.AskSubmitTask,
// Time App, Mobile Phone Skins & Interaction Actions.
MethodId.AskTimePanelInfo,
MethodId.AskSetMobileSkinPart,
MethodId.AskResetMobileSkinPart,
MethodId.AskInstallMobileApp,
MethodId.AskCancelInteractionActionRedPoint,
// Taffy Moto / Monowheel traversal.
MethodId.AskTaffyMotoEnterRush,
MethodId.AskTaffyMotoLeaveRush,
MethodId.OnTafeiMotorColliding,
MethodId.AskGetOffMotor,
};
internal RpcRouter Build()
{
var router = new RpcRouter("game");
MethodId.RegisterKnownNames(router);
_ = AttributedHandlerRegistry.RegisterSelected(router, this, Enabled4229938MethodIds);
router.OnUnknownInvoke(DefaultUnknownInvoke4229938);
router.OnUnknownNotify(DefaultUnknownNotify4229938);
return router;
}
}
@@ -0,0 +1,29 @@
using Ananta.SDK.Network;
namespace Ananta.Server.Handlers.LoginGate;
/// <summary>
/// Tracks the authenticated Gate socket. IMasterToClient is routed to ServerMark.Gate
/// in client build 4229938, so server-pushed master RPCs must use this session, not Game.
/// </summary>
internal sealed class GateSessionHub
{
private TcpSession? _current;
private long _lastSeenTicks;
internal TcpSession? Current => Volatile.Read(ref _current);
internal DateTimeOffset? LastSeenUtc
{
get
{
var ticks = Interlocked.Read(ref _lastSeenTicks);
return ticks == 0 ? null : new DateTimeOffset(ticks, TimeSpan.Zero);
}
}
internal void Touch(TcpSession session)
{
Volatile.Write(ref _current, session);
Interlocked.Exchange(ref _lastSeenTicks, DateTimeOffset.UtcNow.Ticks);
}
}
@@ -0,0 +1,121 @@
using Ananta.SDK.Rpc;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.LoginGate;
/// <summary>
/// Login/gate endpoints for build 4229938. Method mapping is visible directly on each handler.
/// Business values still come from private-server.json.
/// </summary>
internal sealed class LoginGateRouter
{
private readonly GateSessionHub? _gateSessions;
private static PrivateServerConfig Config => PrivateServerConfigStore.Current;
internal LoginGateRouter(GateSessionHub? gateSessions = null)
=> _gateSessions = gateSessions;
internal RpcRouter Build()
{
var router = new RpcRouter("login-gate");
MethodId.RegisterKnownNames(router);
AttributedHandlerRegistry.Register(router, this);
return router;
}
[Handler(MethodId.CheckVersion, HandlerPacketKind.Invoke)]
[Handler(MethodId.HasOnlinePlayer, HandlerPacketKind.Invoke)]
private Task EmptySuccess(Connection conn, UxRpcMessage msg) => conn.ReturnEmptyOkAsync(msg);
[Handler(MethodId.AskNewHotFixPatchLogin, HandlerPacketKind.Invoke)]
private Task AskNewHotFixPatchLogin(Connection conn, UxRpcMessage msg)
=> conn.ReturnEmptyAsync(msg, Config.Client.NoMoreHotfixPatchError);
[Handler(MethodId.RequestPatchesCheckDataFromLogin, HandlerPacketKind.Invoke)]
private Task RequestPatchesCheckDataFromLogin(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, LoginCodec.PatchCheck());
[Handler(MethodId.RequestPatchesFromLogin, HandlerPacketKind.Invoke)]
private Task RequestPatchesFromLogin(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, new List<uint>());
[Handler(MethodId.AskUniSdkShareToken_Login, HandlerPacketKind.Invoke)]
[Handler(MethodId.AskUniSdkShareToken_Gate, HandlerPacketKind.Invoke)]
private Task AskUniSdkShareToken(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, Config.Player.ShareToken);
[Handler(MethodId.RequestFpPassToken, HandlerPacketKind.Invoke)]
private Task RequestFpPassToken(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, Config.Player.FpPassToken);
[Handler(MethodId.CheckAccount, HandlerPacketKind.Invoke)]
[Handler(MethodId.CheckAccountPassBy, HandlerPacketKind.Invoke)]
[Handler(MethodId.CheckAccountOpenId, HandlerPacketKind.Invoke)]
private Task CheckAccount(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, LoginCodec.CheckAccount());
[Handler(MethodId.TryLogin, HandlerPacketKind.Invoke)]
private async Task TryLogin(Connection conn, UxRpcMessage msg)
{
await conn.ReturnEmptyOkAsync(msg);
await conn.NotifyAsync(MethodId.SyncRoleList, Profile.PlayerPid);
}
[Handler(MethodId.RequestCreateRoleEx, HandlerPacketKind.Invoke)]
private async Task RequestCreateRoleEx(Connection conn, UxRpcMessage msg)
{
await conn.ReturnAsync(msg, Profile.PlayerPid);
await conn.NotifyAsync(MethodId.SyncRoleList, Profile.PlayerPid);
}
[Handler(MethodId.RequestEnterGame, HandlerPacketKind.Invoke)]
[Handler(MethodId.DebugRequestEnterGame, HandlerPacketKind.Invoke)]
private Task RequestEnterGame(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, LoginCodec.EnterGame(Config.Network.AdvertisedHost, Config.Network.LoginPorts[0]));
[Handler(MethodId.AskCloseConnectionToGate, HandlerPacketKind.Invoke)]
private Task GateAskCloseConnection(Connection conn, UxRpcMessage msg) => conn.ReturnAsync(msg, 0u);
[Handler(MethodId.GetServerTime)]
private async Task GateGetServerTime(Connection conn, UxRpcMessage msg)
{
if (msg.IsInvoke)
{
await conn.ReturnEmptyOkAsync(msg);
return;
}
var args = msg.GetArgs<GameMethods.GetServerTime>();
await conn.NotifyAsync(MethodId.SendServerTime, LoginCodec.ServerTime(args.clientUnixTime));
}
[Handler(MethodId.GetFriendApplicationListToMe, HandlerPacketKind.Invoke)]
[Handler(MethodId.GetAllChatGroupLatestMessage, HandlerPacketKind.Invoke)]
[Handler(MethodId.GetSimplePlayerInfoByPidList, HandlerPacketKind.Invoke)]
private Task GateEmptyList(Connection conn, UxRpcMessage msg)
=> conn.ReturnAsync(msg, new List<uint>());
[Handler(MethodId.QuerySkey, HandlerPacketKind.Invoke)]
private Task QuerySkey(Connection conn, UxRpcMessage msg) => conn.ReturnAsync(msg, Config.Player.Skey);
[Handler(MethodId.ReportNetworkQuality, HandlerPacketKind.Notify)]
private static void ReportNetworkQuality4229938(Connection conn, UxRpcMessage msg) { }
[Handler(MethodId.DavinciCode, HandlerPacketKind.Notify)]
private static void DavinciCode(Connection conn, UxRpcMessage msg) { }
[Handler(MethodId.Login, HandlerPacketKind.Invoke)]
private async Task GateLogin(Connection conn, UxRpcMessage msg)
{
// Build 4229938 routes IMasterToClient to ServerMark.Gate. Capture the
// authenticated Gate socket here; login and gate can share the same TCP port.
_gateSessions?.Touch(conn.Session);
conn.Log.Info("[GATE] authenticated session registered for IMasterToClient S2C");
await conn.ReturnEmptyOkAsync(msg);
await conn.NotifyAsync(MethodId.SendServerTime, LoginCodec.ServerTime());
await conn.NotifyAsync(MethodId.SyncPlayerGameServerInfo,
LoginCodec.GameServerInfo(Config.Network.AdvertisedHost, Config.Network.GamePort));
}
}