diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs new file mode 100644 index 00000000..79660df4 --- /dev/null +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -0,0 +1,1119 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Core.Meshing; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Plugins; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Rendering; + +/// +/// DAT-backed CPhysicsObj::set_description @ 0x00514F40 projection +/// materializer. It owns no identity map and may mutate only the exact +/// supplied by . +/// Logical registration and spatial rebucketing remain owned by +/// . +/// +internal sealed class DatLiveEntityProjectionMaterializer + : ILiveEntityProjectionMaterializer +{ + private readonly RuntimeOptions _options; + private readonly IDatReaderWriter _dats; + private readonly LiveEntityRuntime _runtime; + private readonly PhysicsDataCache _physicsData; + private readonly IAnimationLoader _animationLoader; + private readonly EntitySpawnAdapter _spawnAdapter; + private readonly TextureCache _textures; + private readonly EntityClassificationCache _classification; + private readonly EntityEffectPoseRegistry _effectPoses; + private readonly EquippedChildRenderController _equippedChildren; + private readonly WorldGameState _worldState; + private readonly WorldEvents _worldEvents; + private readonly ShadowObjectRegistry _shadows; + private readonly LiveEntityCollisionBuilder _collisionBuilder; + private readonly ProjectileController _projectiles; + private readonly LiveEntityAnimationPresenter _animationPresenter; + private readonly RetailStaticAnimatingObjectScheduler _staticAnimations; + private readonly LiveWorldOriginState _origin; + private readonly Func _gameTime; + + private int _received; + private int _hydrated; + private int _noPosition; + private int _noSetup; + private int _missingSetup; + private int _noMesh; + private int _noCycle; + private int _zeroFramerate; + private int _singleFrame; + private int _missingPartFrames; + + public DatLiveEntityProjectionMaterializer( + RuntimeOptions options, + IDatReaderWriter dats, + LiveEntityRuntime runtime, + PhysicsDataCache physicsData, + IAnimationLoader animationLoader, + EntitySpawnAdapter spawnAdapter, + TextureCache textures, + EntityClassificationCache classification, + EntityEffectPoseRegistry effectPoses, + EquippedChildRenderController equippedChildren, + WorldGameState worldState, + WorldEvents worldEvents, + ShadowObjectRegistry shadows, + LiveEntityCollisionBuilder collisionBuilder, + ProjectileController projectiles, + LiveEntityAnimationPresenter animationPresenter, + RetailStaticAnimatingObjectScheduler staticAnimations, + LiveWorldOriginState origin, + Func gameTime) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _physicsData = physicsData ?? throw new ArgumentNullException(nameof(physicsData)); + _animationLoader = animationLoader ?? throw new ArgumentNullException(nameof(animationLoader)); + _spawnAdapter = spawnAdapter ?? throw new ArgumentNullException(nameof(spawnAdapter)); + _textures = textures ?? throw new ArgumentNullException(nameof(textures)); + _classification = classification ?? throw new ArgumentNullException(nameof(classification)); + _effectPoses = effectPoses ?? throw new ArgumentNullException(nameof(effectPoses)); + _equippedChildren = equippedChildren ?? throw new ArgumentNullException(nameof(equippedChildren)); + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents)); + _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); + _collisionBuilder = collisionBuilder ?? throw new ArgumentNullException(nameof(collisionBuilder)); + _projectiles = projectiles ?? throw new ArgumentNullException(nameof(projectiles)); + _animationPresenter = animationPresenter ?? throw new ArgumentNullException(nameof(animationPresenter)); + _staticAnimations = staticAnimations ?? throw new ArgumentNullException(nameof(staticAnimations)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _gameTime = gameTime ?? throw new ArgumentNullException(nameof(gameTime)); + } + + public void ResetSessionState() + { + _received = 0; + _hydrated = 0; + _noPosition = 0; + _noSetup = 0; + _missingSetup = 0; + _noMesh = 0; + _noCycle = 0; + _zeroFramerate = 0; + _singleFrame = 0; + _missingPartFrames = 0; + } + + public bool TryMaterialize( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn canonicalSpawn, + LiveProjectionPurpose purpose, + ulong expectedCreateIntegrationVersion, + LiveEntityAppearanceUpdateState? appearanceUpdate = null) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (purpose is LiveProjectionPurpose.AppearanceMutation + != (appearanceUpdate is not null)) + { + throw new ArgumentException( + "Appearance mutation requires the exact captured visual owner.", + nameof(appearanceUpdate)); + } + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || canonicalSpawn.Guid != expectedRecord.ServerGuid + || canonicalSpawn.InstanceSequence != expectedRecord.Generation) + { + return false; + } + + _received++; + bool dumpLiveSpawns = _options.DumpLiveSpawns; + DumpSpawn(canonicalSpawn, dumpLiveSpawns); + + if (!_origin.IsKnown) + return false; + if (canonicalSpawn.Position is null || canonicalSpawn.SetupTableId is null) + { + if (canonicalSpawn.Position is null) + _noPosition++; + else + _noSetup++; + return false; + } + + CreateObject.ServerPosition position = canonicalSpawn.Position.Value; + int lbX = (int)((position.LandblockId >> 24) & 0xFFu); + int lbY = (int)((position.LandblockId >> 16) & 0xFFu); + var worldOrigin = new Vector3( + (lbX - _origin.CenterX) * 192f, + (lbY - _origin.CenterY) * 192f, + 0f); + Vector3 worldPosition = new( + position.PositionX + worldOrigin.X, + position.PositionY + worldOrigin.Y, + position.PositionZ); + var rotation = new Quaternion( + position.RotationX, + position.RotationY, + position.RotationZ, + position.RotationW); + + Setup? setup = _dats.Get(canonicalSpawn.SetupTableId.Value); + if (setup is not null) + _physicsData.CacheSetup(canonicalSpawn.SetupTableId.Value, setup); + if (setup is null) + { + _missingSetup++; + if (dumpLiveSpawns) + { + Console.WriteLine( + $"live: DROP setup dat 0x{canonicalSpawn.SetupTableId.Value:X8} missing " + + $"(guid=0x{canonicalSpawn.Guid:X8})"); + } + return false; + } + + expectedRecord.HasPartArray = true; + ushort? stanceOverride = canonicalSpawn.MotionState?.Stance; + ushort? commandOverride = canonicalSpawn.MotionState?.ForwardCommand; + var idleCycle = MotionResolver.GetIdleCycle( + setup, + _dats, + _animationLoader, + motionTableIdOverride: canonicalSpawn.MotionTableId, + stanceOverride: stanceOverride, + commandOverride: commandOverride); + AnimationFrame? idleFrame = null; + if (idleCycle is not null) + { + int startIndex = idleCycle.LowFrame; + if (startIndex < 0 || startIndex >= idleCycle.Animation.PartFrames.Count) + startIndex = 0; + idleFrame = idleCycle.Animation.PartFrames[startIndex]; + } + + List flattened = [.. SetupMesh.Flatten(setup, idleFrame)]; + IReadOnlyList animPartChanges = + canonicalSpawn.AnimPartChanges ?? Array.Empty(); + bool dumpClothing = _options.DumpClothing && setup.Parts.Count >= 10; + DumpClothingHeader( + canonicalSpawn, + setup, + flattened, + idleFrame, + animPartChanges, + dumpClothing); + + foreach (CreateObject.AnimPartChange change in animPartChanges) + { + if (change.PartIndex < flattened.Count) + { + flattened[change.PartIndex] = new MeshRef( + change.NewModelId, + flattened[change.PartIndex].PartTransform); + } + } + + uint[] collisionPartGfxObjIds = + LiveEntityCollisionBuilder.ResolveEffectivePartIdentities( + flattened.Select(static part => part.GfxObjId).ToArray(), + baseId => ResolveCollisionPart(baseId)); + + if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup)) + ApplyRetailCloseDegrades(flattened, dumpClothing); + + IReadOnlyList textureChanges = + canonicalSpawn.TextureChanges ?? Array.Empty(); + Dictionary>? surfaceOverrides = + ResolveSurfaceOverrides( + canonicalSpawn, + flattened, + textureChanges, + dumpClothing, + dumpLiveSpawns); + + float scale = canonicalSpawn.ObjScale ?? 1f; + Matrix4x4 scaleMatrix = Matrix4x4.CreateScale(scale); + IReadOnlyList rigidPartTransforms = + SetupPartTransforms.Compute(setup, idleFrame, scale); + var meshRefs = new List(); + var indexedPartTransforms = new Matrix4x4[flattened.Count]; + var indexedPartAvailable = new bool[flattened.Count]; + var animatedPartTemplate = new LiveAnimationPartTemplate[flattened.Count]; + var bounds = new LocalBoundsAccumulator(); + int clothingTriangles = 0; + + for (int partIndex = 0; partIndex < flattened.Count; partIndex++) + { + MeshRef part = flattened[partIndex]; + IReadOnlyDictionary? overrides = null; + if (surfaceOverrides?.TryGetValue(partIndex, out var found) == true) + overrides = found; + + Matrix4x4 transform = scale == 1f + ? part.PartTransform + : part.PartTransform * scaleMatrix; + indexedPartTransforms[partIndex] = partIndex < rigidPartTransforms.Count + ? rigidPartTransforms[partIndex] + : Matrix4x4.Identity; + GfxObj? gfx = _dats.Get(part.GfxObjId); + bool drawable = gfx is not null; + indexedPartAvailable[partIndex] = drawable; + animatedPartTemplate[partIndex] = new LiveAnimationPartTemplate( + part.GfxObjId, + overrides, + drawable); + if (gfx is null) + { + if (dumpClothing) + Console.WriteLine($" EMIT part={partIndex:D2} gfx=0x{part.GfxObjId:X8} GFXOBJ_DAT_MISSING -> 0 tris"); + continue; + } + + _physicsData.CacheGfxObj(part.GfxObjId, gfx); + if (dumpClothing) + { + var subMeshes = GfxObjMesh.Build(gfx, _dats); + int triangles = 0; + foreach (var subMesh in subMeshes) + triangles += subMesh.Indices.Length / 3; + clothingTriangles += triangles; + Console.WriteLine( + $" EMIT part={partIndex:D2} gfx=0x{part.GfxObjId:X8} " + + $"subMeshes={subMeshes.Count} tris={triangles}"); + } + + if (GfxObjBounds.Get(gfx) is { } partBounds) + bounds.Add(transform, partBounds); + meshRefs.Add(new MeshRef(part.GfxObjId, transform) + { + SurfaceOverrides = overrides, + }); + } + + if (meshRefs.Count == 0) + { + _noMesh++; + if (dumpLiveSpawns) + { + Console.WriteLine( + $"live: DROP no mesh refs from setup 0x{canonicalSpawn.SetupTableId.Value:X8} " + + $"(guid=0x{canonicalSpawn.Guid:X8})"); + } + return false; + } + if (dumpClothing) + { + Console.WriteLine( + $" TOTAL tris={clothingTriangles} meshRefs={meshRefs.Count} " + + $"(parts.Count={flattened.Count})"); + } + + PaletteOverride? paletteOverride = CreatePaletteOverride(canonicalSpawn); + PartOverride[] partOverrides = CreatePartOverrides(animPartChanges); + + bool supersessionRecovery = + purpose is LiveProjectionPurpose.CreateSupersessionRecovery; + if (supersessionRecovery && expectedRecord.WorldEntity is not null) + { + return LiveEntityCreateSupersessionRecovery.TryApply( + _runtime, + expectedRecord, + expectedCreateIntegrationVersion, + captureAppearance: () => appearanceUpdate + ?? LiveEntityAppearanceBinding.Capture( + _runtime, + expectedRecord.ServerGuid), + publishAppearance: visualUpdate => ApplyAppearance( + expectedRecord, + canonicalSpawn, + visualUpdate, + setup, + scale, + worldOrigin, + collisionPartGfxObjIds, + meshRefs, + paletteOverride, + partOverrides, + indexedPartTransforms, + indexedPartAvailable, + animatedPartTemplate, + bounds, + expectedCreateIntegrationVersion), + publishCurrentSnapshot: visualUpdate => + { + var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( + visualUpdate.Entity.Id, + visualUpdate.Entity.SourceGfxObjOrSetupId, + visualUpdate.Entity.Position, + visualUpdate.Entity.Rotation); + _worldState.Add(snapshot); + _worldEvents.UpsertCurrent(snapshot); + }, + synchronizeAnimation: visualUpdate => RegisterAnimation( + expectedRecord, + visualUpdate.Entity, + setup, + canonicalSpawn, + idleCycle, + scale, + animatedPartTemplate, + indexedPartAvailable, + retainedAnimation: + expectedRecord.AnimationRuntime is LiveEntityAnimationState, + synchronizeAnimation: true)); + } + + if (appearanceUpdate is { } visualUpdate) + { + return ApplyAppearance( + expectedRecord, + canonicalSpawn, + visualUpdate, + setup, + scale, + worldOrigin, + collisionPartGfxObjIds, + meshRefs, + paletteOverride, + partOverrides, + indexedPartTransforms, + indexedPartAvailable, + animatedPartTemplate, + bounds, + expectedCreateIntegrationVersion); + } + + return MaterializeProjection( + expectedRecord, + canonicalSpawn, + setup, + idleCycle, + worldOrigin, + worldPosition, + rotation, + scale, + collisionPartGfxObjIds, + meshRefs, + paletteOverride, + partOverrides, + indexedPartTransforms, + indexedPartAvailable, + animatedPartTemplate, + bounds, + dumpLiveSpawns, + expectedCreateIntegrationVersion, + synchronizeAnimation: supersessionRecovery); + } + + private uint ResolveCollisionPart(uint baseId) + { + if (!GfxObjDegradeResolver.TryResolveCloseGfxObj( + _dats, + baseId, + out uint slotZeroId, + out GfxObj? slotZeroGfx)) + { + return baseId; + } + + if (slotZeroGfx is not null) + _physicsData.CacheGfxObj(slotZeroId, slotZeroGfx); + return slotZeroId; + } + + private void ApplyRetailCloseDegrades( + List parts, + bool dumpClothing) + { + for (int partIndex = 0; partIndex < parts.Count; partIndex++) + { + MeshRef part = parts[partIndex]; + if (!GfxObjDegradeResolver.TryResolveCloseGfxObj( + _dats, + part.GfxObjId, + out uint resolvedId, + out _) + || resolvedId == part.GfxObjId) + { + continue; + } + + parts[partIndex] = new MeshRef(resolvedId, part.PartTransform); + if (dumpClothing) + { + Console.WriteLine( + $" DEGRADE part={partIndex:D2} gfx=0x{part.GfxObjId:X8} " + + $"-> close=0x{resolvedId:X8}"); + } + } + } + + private Dictionary>? ResolveSurfaceOverrides( + WorldSession.EntitySpawn spawn, + IReadOnlyList parts, + IReadOnlyList textureChanges, + bool dumpClothing, + bool dumpLiveSpawns) + { + if (dumpClothing) + { + Console.WriteLine($" TextureChanges count={textureChanges.Count}"); + foreach (CreateObject.TextureChange change in textureChanges) + { + Console.WriteLine( + $" TC part={change.PartIndex:D2} oldTex=0x{change.OldTexture:X8} " + + $"-> newTex=0x{change.NewTexture:X8}"); + } + } + + if (textureChanges.Count == 0) + return null; + + var oldToNewByPart = new Dictionary>(); + foreach (CreateObject.TextureChange change in textureChanges) + { + if (!oldToNewByPart.TryGetValue(change.PartIndex, out var oldToNew)) + { + oldToNew = []; + oldToNewByPart.Add(change.PartIndex, oldToNew); + } + oldToNew[change.OldTexture] = change.NewTexture; + } + + bool statueDiagnostic = dumpLiveSpawns + && spawn.Name?.Contains("Statue", StringComparison.OrdinalIgnoreCase) == true; + var result = new Dictionary>(); + for (int partIndex = 0; partIndex < parts.Count; partIndex++) + { + if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew)) + continue; + + GfxObj? gfx = _dats.Get(parts[partIndex].GfxObjId); + if (gfx is null) + { + if (statueDiagnostic) + { + Console.WriteLine( + $"live: [STATUE] resolve part={partIndex} " + + $"GfxObj 0x{parts[partIndex].GfxObjId:X8} missing"); + } + continue; + } + _physicsData.CacheGfxObj(parts[partIndex].GfxObjId, gfx); + + Dictionary? resolved = null; + foreach (var surfaceQid in gfx.Surfaces) + { + uint surfaceId = (uint)surfaceQid; + Surface? surface = _dats.Get(surfaceId); + if (surface is null) + continue; + uint originalTexture = (uint)surface.OrigTextureId; + if (originalTexture == 0 + || !oldToNew.TryGetValue(originalTexture, out uint newTexture)) + { + continue; + } + + (resolved ??= [])[surfaceId] = newTexture; + } + + if (resolved is not null) + result[partIndex] = resolved; + } + + return result.Count == 0 ? null : result; + } + + private static PaletteOverride? CreatePaletteOverride( + WorldSession.EntitySpawn spawn) + { + if (spawn.SubPalettes is not { Count: > 0 } subPalettes) + return null; + + var ranges = new PaletteOverride.SubPaletteRange[subPalettes.Count]; + for (int i = 0; i < subPalettes.Count; i++) + { + ranges[i] = new PaletteOverride.SubPaletteRange( + subPalettes[i].SubPaletteId, + subPalettes[i].Offset, + subPalettes[i].Length); + } + return new PaletteOverride(spawn.BasePaletteId ?? 0u, ranges); + } + + private static PartOverride[] CreatePartOverrides( + IReadOnlyList changes) + { + if (changes.Count == 0) + return []; + + var result = new PartOverride[changes.Count]; + for (int i = 0; i < changes.Count; i++) + result[i] = new PartOverride(changes[i].PartIndex, changes[i].NewModelId); + return result; + } + + private bool ApplyAppearance( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn spawn, + LiveEntityAppearanceUpdateState visualUpdate, + Setup setup, + float scale, + Vector3 worldOrigin, + IReadOnlyList collisionPartGfxObjIds, + IReadOnlyList meshRefs, + PaletteOverride? paletteOverride, + IReadOnlyList partOverrides, + IReadOnlyList indexedPartTransforms, + IReadOnlyList indexedPartAvailable, + IReadOnlyList animatedPartTemplate, + LocalBoundsAccumulator bounds, + ulong expectedCreateIntegrationVersion) + { + WorldEntity entity = visualUpdate.Entity; + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + LiveEntityAppearanceCollisionUpdate? appearanceCollision = + LiveEntityAppearanceBinding.PrepareCollision( + _runtime, + _collisionBuilder, + entity, + setup, + collisionPartGfxObjIds, + spawn, + worldOrigin); + + bool published = _spawnAdapter.OnAppearanceChanged( + entity, + meshRefs, + partOverrides, + () => + { + _textures.ReleaseOwner(entity.Id); + entity.ApplyAppearance(meshRefs, paletteOverride, partOverrides); + }, + () => + { + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return; + } + if (appearanceCollision is not null) + { + LiveEntityAppearanceBinding.CommitCollision( + _runtime, + _shadows, + appearanceCollision); + } + entity.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); + if (bounds.TryGet(out Vector3 minimum, out Vector3 maximum)) + entity.SetLocalBounds(minimum, maximum); + if (visualUpdate.Animation is { } animation) + { + LiveEntityAppearanceBinding.RebindAnimation( + animation, + entity, + setup, + scale, + animatedPartTemplate, + indexedPartAvailable); + } + _classification.InvalidateEntity(entity.Id); + if (_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + && expectedRecord.ProjectionKind is LiveEntityProjectionKind.Attached) + { + _equippedChildren.OnSpawn(expectedRecord.Snapshot); + } + else + { + _effectPoses.PublishMeshRefs(entity); + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return; + } + _equippedChildren.OnPosePublished(spawn.Guid); + } + }); + return published + && _runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + && ReferenceEquals(expectedRecord.WorldEntity, entity); + } + + private bool MaterializeProjection( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn spawn, + Setup setup, + MotionResolver.IdleCycle? idleCycle, + Vector3 worldOrigin, + Vector3 worldPosition, + Quaternion rotation, + float scale, + IReadOnlyList collisionPartGfxObjIds, + IReadOnlyList meshRefs, + PaletteOverride? paletteOverride, + IReadOnlyList partOverrides, + IReadOnlyList indexedPartTransforms, + IReadOnlyList indexedPartAvailable, + IReadOnlyList animatedPartTemplate, + LocalBoundsAccumulator bounds, + bool dumpLiveSpawns, + ulong expectedCreateIntegrationVersion, + bool synchronizeAnimation) + { + if (!_runtime.TryGetEffectProfile(spawn.Guid, out _)) + { + EntityEffectProfile profile = spawn.Physics is { } physics + ? EntityEffectProfile.CreateLive(setup, physics) + : EntityEffectProfile.CreateDatStatic(setup); + _runtime.SetEffectProfile(spawn.Guid, profile); + } + + bool createdProjection = false; + WorldEntity? entity = _runtime.MaterializeLiveEntity( + spawn.Guid, + spawn.Position!.Value.LandblockId, + localId => + { + createdProjection = true; + var created = new WorldEntity + { + Id = localId, + ServerGuid = spawn.Guid, + SourceGfxObjOrSetupId = spawn.SetupTableId!.Value, + Position = worldPosition, + Rotation = rotation, + MeshRefs = meshRefs, + PaletteOverride = paletteOverride, + PartOverrides = partOverrides, + ParentCellId = spawn.Position.Value.LandblockId, + }; + created.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); + if (bounds.TryGet(out Vector3 minimum, out Vector3 maximum)) + created.SetLocalBounds(minimum, maximum); + return created; + }); + if (entity is null + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + if (!createdProjection) + { + entity.SetPosition(worldPosition); + entity.Rotation = rotation; + entity.ParentCellId = spawn.Position.Value.LandblockId; + entity.ApplyAppearance(meshRefs, paletteOverride, partOverrides); + entity.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); + if (bounds.TryGet(out Vector3 minimum, out Vector3 maximum)) + entity.SetLocalBounds(minimum, maximum); + _effectPoses.PublishMeshRefs(entity); + } + + bool retainedAnimation = !createdProjection + && expectedRecord.AnimationRuntime is LiveEntityAnimationState; + var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation); + _worldState.Add(snapshot); + _worldEvents.UpsertCurrent(snapshot); + if (_runtime.TryMarkWorldSpawnPublished(spawn.Guid)) + _worldEvents.FireEntitySpawned(snapshot); + _hydrated++; + + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + _equippedChildren.OnWorldEntityRegistered(spawn.Guid); + + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + if (_collisionBuilder.Build( + entity, + setup, + collisionPartGfxObjIds, + spawn, + expectedRecord, + worldOrigin) is { } collision) + { + LiveEntityCollisionBuilder.Register(_shadows, collision); + } + + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + return false; + _projectiles.TryBind( + expectedRecord, + setup, + _gameTime(), + _origin.CenterX, + _origin.CenterY); + + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + RegisterAnimation( + expectedRecord, + entity, + setup, + spawn, + idleCycle, + scale, + animatedPartTemplate, + indexedPartAvailable, + retainedAnimation, + synchronizeAnimation); + + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, entity)) + { + return false; + } + + if (dumpLiveSpawns && _received % 20 == 0) + { + Console.WriteLine( + $"live: animated={_runtime.AnimationRuntimes.Count} " + + $"animReject: noCycle={_noCycle} fr0={_zeroFramerate} " + + $"1frame={_singleFrame} partFrames={_missingPartFrames}"); + Console.WriteLine( + $"live: summary recv={_received} hydrated={_hydrated} " + + $"drops: noPos={_noPosition} noSetup={_noSetup} " + + $"setupMissing={_missingSetup} noMesh={_noMesh}"); + } + + return _runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion); + } + + private void RegisterAnimation( + LiveEntityRecord expectedRecord, + WorldEntity entity, + Setup setup, + WorldSession.EntitySpawn spawn, + MotionResolver.IdleCycle? idleCycle, + float scale, + IReadOnlyList partTemplate, + IReadOnlyList partAvailability, + bool retainedAnimation, + bool synchronizeAnimation) + { + if (retainedAnimation + && synchronizeAnimation + && expectedRecord.AnimationRuntime is LiveEntityAnimationState retained) + { + SynchronizeRetainedAnimation( + expectedRecord, + retained, + setup, + spawn, + idleCycle); + } + if (!retainedAnimation) + { + if (idleCycle is null) + _noCycle++; + else if (idleCycle.Framerate == 0f) + _zeroFramerate++; + else if (idleCycle.HighFrame <= idleCycle.LowFrame) + _singleFrame++; + else if (idleCycle.Animation.PartFrames.Count <= 1) + _missingPartFrames++; + } + + if (!retainedAnimation + && idleCycle is not null + && idleCycle.Framerate != 0f + && idleCycle.HighFrame > idleCycle.LowFrame + && idleCycle.Animation.PartFrames.Count > 1) + { + AnimationSequencer? sequencer = CreateMotionSequencer(setup, spawn); + _runtime.SetAnimationRuntime( + spawn.Guid, + new LiveEntityAnimationState + { + Entity = entity, + Setup = setup, + Animation = idleCycle.Animation, + LowFrame = Math.Max(0, idleCycle.LowFrame), + HighFrame = Math.Min( + idleCycle.HighFrame, + idleCycle.Animation.PartFrames.Count - 1), + Framerate = idleCycle.Framerate, + Scale = scale, + PartTemplate = partTemplate, + PartAvailability = partAvailability, + CurrFrame = idleCycle.LowFrame, + Sequencer = sequencer, + }); + } + else if (!retainedAnimation) + { + uint motionTableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; + if (motionTableId != 0 + && _dats.Get(motionTableId) is { } motionTable) + { + AnimationSequencer sequencer = SpawnMotionInitializer.Create( + setup, + motionTable, + _animationLoader, + spawn.MotionState); + _runtime.SetAnimationRuntime( + spawn.Guid, + new LiveEntityAnimationState + { + Entity = entity, + Setup = setup, + Animation = null!, + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = scale, + PartTemplate = partTemplate, + PartAvailability = partAvailability, + CurrFrame = 0, + Sequencer = sequencer, + }); + + if (PhysicsDiagnostics.ProbeBuildingEnabled) + { + var initial = SpawnMotionInitializer.ResolvePlan( + motionTable, + spawn.MotionState); + Console.WriteLine( + $"[reactive-anim] registered guid=0x{spawn.Guid:X8} " + + $"entityId=0x{entity.Id:X8} mtable=0x{motionTableId:X8} " + + $"initialStyle=0x{initial.Style:X8} initialCycle=0x{initial.Motion:X8}"); + } + } + } + + bool physicsStatic = (expectedRecord.FinalPhysicsState + & PhysicsStateFlags.Static) != 0; + if (!retainedAnimation + && expectedRecord.AnimationRuntime is null + && (uint)setup.DefaultAnimation != 0) + { + var sequencer = new AnimationSequencer( + setup, + new MotionTable(), + _animationLoader); + if (sequencer.HasCurrentNode) + { + _runtime.SetAnimationRuntime( + spawn.Guid, + new LiveEntityAnimationState + { + Entity = entity, + Setup = setup, + Animation = null!, + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = scale, + PartTemplate = partTemplate, + PartAvailability = partAvailability, + CurrFrame = 0, + Sequencer = sequencer, + }); + } + } + + if (expectedRecord.AnimationRuntime is not LiveEntityAnimationState animation) + return; + + _animationPresenter.PrepareAnimation(expectedRecord, animation); + if (!physicsStatic + || animation.Sequencer is not { } staticSequencer + || spawn.Position is not { } staticPosition) + { + return; + } + + PhysicsBody body = _runtime.GetOrCreatePhysicsBody( + spawn.Guid, + incarnation => + { + var created = new PhysicsBody { Orientation = entity.Rotation }; + RemotePhysicsBodyInitializer.Initialize(created, incarnation); + created.SnapToCell( + staticPosition.LandblockId, + entity.Position, + new Vector3( + staticPosition.PositionX, + staticPosition.PositionY, + staticPosition.PositionZ)); + return created; + }); + _staticAnimations.BindLiveOwner( + entity, + animation, + body); + } + + private void SynchronizeRetainedAnimation( + LiveEntityRecord expectedRecord, + LiveEntityAnimationState animation, + Setup setup, + WorldSession.EntitySpawn spawn, + MotionResolver.IdleCycle? idleCycle) + { + // Retail's same-instance HandleCreateObject branch never constructs a + // second CPhysicsObj/CPartArray/MovementManager. A completed object has + // already consumed the newer MovementData through SetObjectMovement, + // so its sequencer and every MotionInterpreter binding stay intact. + // Only an interrupted *initial* hydration needs the retained, not-yet- + // published sequencer reset to the newest canonical spawn state. + uint motionTableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; + MotionTable? motionTable = motionTableId == 0 + ? null + : _dats.Get(motionTableId); + LiveEntityCreateAnimationSynchronization + .TrySynchronizeInterruptedInitialOwner( + expectedRecord, + animation, + idleCycle?.Animation, + idleCycle is null ? 0 : Math.Max(0, idleCycle.LowFrame), + idleCycle is null + ? 0 + : Math.Min( + idleCycle.HighFrame, + idleCycle.Animation.PartFrames.Count - 1), + idleCycle?.Framerate ?? 0f, + motionTable, + spawn.MotionState); + } + + private AnimationSequencer? CreateMotionSequencer( + Setup setup, + WorldSession.EntitySpawn spawn) + { + uint motionTableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; + return motionTableId != 0 + && _dats.Get(motionTableId) is { } motionTable + ? SpawnMotionInitializer.Create( + setup, + motionTable, + _animationLoader, + spawn.MotionState) + : null; + } + + private void DumpSpawn(WorldSession.EntitySpawn spawn, bool enabled) + { + if (!enabled) + return; + + string position = spawn.Position is { } p + ? $"({p.PositionX:F1},{p.PositionY:F1},{p.PositionZ:F1})@0x{p.LandblockId:X8}" + : "no-pos"; + string setup = spawn.SetupTableId is { } setupId + ? $"0x{setupId:X8}" + : "no-setup"; + string physicsTable = spawn.Physics?.PhysicsScriptTableId is { } tableId + ? $"0x{tableId:X8}" + : "no-petable"; + string name = spawn.Name is { Length: > 0 } foundName + ? $"\"{foundName}\"" + : "no-name"; + string itemType = spawn.ItemType is { } foundItemType + ? $"0x{foundItemType:X8}" + : "no-itemtype"; + Console.WriteLine( + $"live: spawn guid=0x{spawn.Guid:X8} name={name} setup={setup} pos={position} " + + $"petable={physicsTable} itemType={itemType} " + + $"animParts={spawn.AnimPartChanges?.Count ?? 0} " + + $"texChanges={spawn.TextureChanges?.Count ?? 0} " + + $"subPalettes={spawn.SubPalettes?.Count ?? 0}"); + } + + private void DumpClothingHeader( + WorldSession.EntitySpawn spawn, + Setup setup, + IReadOnlyList flattened, + AnimationFrame? idleFrame, + IReadOnlyList changes, + bool enabled) + { + if (!enabled) + return; + + Console.WriteLine( + $"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' " + + $"setup=0x{setup.Id:X8} setup.Parts.Count={setup.Parts.Count} " + + $"flatten.Count={flattened.Count} APC={changes.Count} ==="); + foreach (CreateObject.AnimPartChange change in changes) + Console.WriteLine($" APC part={change.PartIndex:D2} -> gfx=0x{change.NewModelId:X8}"); + Console.WriteLine( + $" basePalette=0x{spawn.BasePaletteId ?? 0:X8} " + + $"subPalettes={spawn.SubPalettes?.Count ?? 0}"); + } + + private static bool IsIssue47HumanoidSetup(Setup setup) + { + if (setup.Parts.Count != 34) + return false; + const uint nullPartGfx = 0x010001ECu; + int nullSlots = 0; + for (int i = 17; i < setup.Parts.Count; i++) + { + if ((uint)setup.Parts[i] == nullPartGfx) + nullSlots++; + } + return nullSlots >= 8; + } +} diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index 87adf4fb..4da28c83 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -36,7 +36,7 @@ public sealed class EquippedChildRenderController : IDisposable private ParentAttachmentState Relations => _liveEntities.ParentAttachments; /// Raised after the attached projection is fully registered. - public event Action? EntityReady; + internal event Action? EntityReady; /// Raised after an attached projection has its composed pose. public event Action? ProjectionPoseReady; /// Raised when an attached projection leaves its cell presentation. @@ -448,14 +448,46 @@ public sealed class EquippedChildRenderController : IDisposable // CPhysicsObj::set_hidden (0x00514C60). _liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true); } + ulong readyCreateIntegrationVersion = childRecord.CreateIntegrationVersion; PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose); Console.WriteLine( $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + $"location={parentLocation} placement={placement}"); Relations.MarkProjected(pending, candidateKind); ProjectionPoseReady?.Invoke(childGuid); - EntityReady?.Invoke(childGuid); - return true; + return PublishEntityReadyExact( + _liveEntities, + childRecord, + readyCreateIntegrationVersion, + entity, + EntityReady); + } + + internal static bool PublishEntityReadyExact( + LiveEntityRuntime runtime, + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion, + WorldEntity expectedEntity, + Action? publish) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(expectedRecord); + ArgumentNullException.ThrowIfNull(expectedEntity); + if (!runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !ReferenceEquals(expectedRecord.WorldEntity, expectedEntity)) + { + return false; + } + + publish?.Invoke(new LiveEntityReadyCandidate( + expectedRecord, + expectedCreateIntegrationVersion)); + return runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + && ReferenceEquals(expectedRecord.WorldEntity, expectedEntity); } private void PublishChildPose( diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 37de367f..e4d299cb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -195,6 +195,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.Physics.ProjectileController? _projectileController; private AcDream.App.World.LiveEntityProjectionWithdrawalController? _liveEntityProjectionWithdrawal; + private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration; // Step 4: portal-based interior cell visibility. private readonly CellVisibility _cellVisibility = new(); @@ -213,7 +214,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Those closures already acquire _datLock, so no additional wrapping is // needed for reads inside BuildLandblockForStreamingLocked / // BuildSceneryEntitiesForStreaming / BuildInteriorEntitiesForStreaming. - // Render-thread paths (ApplyLoadedTerrain, OnLiveEntitySpawned) already + // Render-thread paths (ApplyLoadedTerrain and live hydration) already // hold this lock via their outer wrappers; all remaining render-thread // _dats.Get calls run only when no worker dat read can be in flight (during // initialization or within the same lock scope). @@ -362,26 +363,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Backed by ACDREAM_DUMP_SCENERY_Z via RuntimeOptions.DumpSceneryZ. /// - /// Issue #47 humanoid-setup detector. Matches Aluvian Male - /// (0x02000001) and the 34-part heritage sibling setups - /// (Aluvian Female, Sho M/F, Gharu M/F, Viamont/Empyrean, etc.) - /// by structure rather than id list: a humanoid setup has exactly - /// 34 parts, and the trailing attachment slots (parts 17–33) are - /// the AC null-part sentinel 0x010001EC. Non-humanoid - /// 34-part setups (rare) won't have the sentinel pattern. - /// - private static bool IsIssue47HumanoidSetup(DatReaderWriter.DBObjs.Setup setup) - { - if (setup.Parts.Count != 34) return false; - const uint NullPartGfx = 0x010001ECu; - int nullSlots = 0; - for (int i = 17; i < setup.Parts.Count; i++) - if ((uint)setup.Parts[i] == NullPartGfx) nullSlots++; - // At least half of slots 17–33 wired to the null sentinel — enough - // to distinguish humanoids from any future 34-part creature setup. - return nullSlots >= 8; - } - private readonly HashSet _activeSkyPes = new(); private readonly HashSet _missingSkyPes = new(); @@ -415,7 +396,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates, /// keyed by server guid. Retail keeps these stamps on CPhysicsObj /// (update_times); seeded from CreateObject's PhysicsDesc timestamp - /// block in , consulted at the + /// block during , consulted at the /// top of , dropped with the entity in /// . /// @@ -589,7 +570,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 _characterOptions1 = AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; - private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character private MovementTruthOutbound? _lastMovementTruthOutbound; private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new(); @@ -767,8 +747,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext /// /// Latest for each /// guid. Captured before the renderability gate so no-position inventory / - /// parented children retain Setup and parent metadata; hydrated world objects - /// refresh it again at the end of . + /// parented children retain Setup and parent metadata; hydration rereads + /// this canonical snapshot after every relationship callback. /// reuses the cached position/setup/motion /// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals. /// @@ -787,20 +767,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // (player); pruned only by logical LiveEntityRuntime teardown. private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; + private static bool IsDoorName(string? name) => name == "Door"; // ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184 // Slice 2a — the DR tick's stale-velocity anim-stop was its only user). - private int _liveSpawnReceived; // diagnostics - private int _liveSpawnHydrated; - private int _liveDropReasonNoPos; - private int _liveDropReasonNoSetup; - private int _liveDropReasonSetupDatMissing; - private int _liveDropReasonNoMeshRefs; - // Phase 6.4 animation-registration diagnostics - private int _liveAnimRejectNoCycle; - private int _liveAnimRejectFramerate; - private int _liveAnimRejectSingleFrame; - private int _liveAnimRejectPartFrames; - public GameWindow( AcDream.App.RuntimeOptions options, WorldGameState worldGameState, @@ -2365,10 +2334,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext deferShadowRestore), (guid, generation) => _liveEntityPresentation.BeginAuthoritativePlacement(guid, generation)); - _equippedChildRenderer.EntityReady += guid => - { - CompleteLiveEntityReady(guid); - }; _equippedChildRenderer.ProjectionPoseReady += guid => _liveEntityLights.OnAttachedPoseReady(guid); _hookRouter.Register(entityEffects); @@ -2544,7 +2509,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // #138: restore retained server objects when a landblock reloads // (dungeon-exit expand or Far→Near promote). ACE won't re-send the // objects it thinks we still know, so we re-project them ourselves. - onLandblockLoaded: RehydrateServerEntitiesForLandblock, + onLandblockLoaded: loadedLandblockId => + _liveEntityHydration!.OnLandblockLoaded(loadedLandblockId), ensureEnvCellMeshes: EnsureEnvCellMeshesAfterPin, retirementCoordinator: _landblockRetirements); // A.5 T22.5: apply max-completions from resolved quality. @@ -2565,6 +2531,59 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable, log: message => Console.WriteLine(message)); + var networkUpdateBridge = + new AcDream.App.World.DeferredLiveEntityNetworkUpdateSink(); + var projectionMaterializer = new DatLiveEntityProjectionMaterializer( + _options, + _dats!, + _liveEntities!, + _physicsDataCache, + _animLoader!, + _wbEntitySpawnAdapter!, + _textureCache!, + _classificationCache, + _effectPoses, + _equippedChildRenderer!, + _worldGameState, + _worldEvents, + _physicsEngine.ShadowObjects, + _liveEntityCollisionBuilder!, + _projectileController!, + _animationPresenter, + _staticAnimationScheduler!, + _liveWorldOrigin, + () => _physicsScriptGameTime); + var originCoordinator = + new AcDream.App.World.LiveEntityWorldOriginCoordinator( + _liveWorldOrigin, + _streamingController, + _worldState, + _worldReveal, + () => _playerServerGuid, + IsSealedDungeonCell, + Console.WriteLine); + _liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController( + _liveEntities, + Objects, + _datLock, + projectionMaterializer, + new AcDream.App.World.DelegateLiveEntitySpawnRelationshipSink( + _equippedChildRenderer.OnSpawn), + new AcDream.App.World.LiveEntityReadyPublisher( + _liveEntities, + _entityEffects!, + _liveEntityPresentation!), + originCoordinator, + networkUpdateBridge, + PublishLocalPhysicsTimestamps, + () => _playerServerGuid, + _options.DumpLiveSpawns ? Console.WriteLine : null); + networkUpdateBridge.Bind( + new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink( + RouteSameGenerationCreateObject)); + _equippedChildRenderer.EntityReady += candidate => + _liveEntityHydration.OnEntityReady(candidate); + // Phase 4.7: optional live-mode startup. Connect to the ACE server, // enter the world as the first character on the account, and stream // CreateObject messages into _worldGameState as they arrive. Entirely @@ -2712,20 +2731,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _activeToonKey = "default"; _characterOptions1 = AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; - _playerMotionTableId = null; _lastSeenRunSkill = -1; _lastSeenJumpSkill = -1; _lastLivePlayerLandblockId = null; - _liveSpawnReceived = 0; - _liveSpawnHydrated = 0; - _liveDropReasonNoPos = 0; - _liveDropReasonNoSetup = 0; - _liveDropReasonSetupDatMissing = 0; - _liveDropReasonNoMeshRefs = 0; - _liveAnimRejectNoCycle = 0; - _liveAnimRejectFramerate = 0; - _liveAnimRejectSingleFrame = 0; - _liveAnimRejectPartFrames = 0; + _liveEntityHydration?.ResetSessionState(); Shortcuts = Array.Empty(); DesiredComponents = Array.Empty<(uint Id, uint Amount)>(); _paperdollDollDirty = true; @@ -2808,7 +2817,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.App.Net.LiveEntitySessionSink CreateLiveEntitySessionSink() => new( Spawned: spawn => _inboundEntityEvents.Run( - this, spawn, static (window, value) => window.OnLiveEntitySpawned(value)), + _liveEntityHydration!, + spawn, + static (hydration, value) => hydration.OnCreate(value)), Deleted: deletion => _inboundEntityEvents.Run( this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)), PickedUp: pickup => _inboundEntityEvents.Run( @@ -2983,1187 +2994,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext roomId, chatType, dispatchType, senderGuid, text, cookie), Log: Console.WriteLine); - private enum LiveProjectionPurpose - { - LogicalRegistration, - SpatialRecovery, - AppearanceMutation, - } - - private void OnLiveEntitySpawned(AcDream.Core.Net.WorldSession.EntitySpawn spawn) - { - // Phase A.1 hotfix: live CreateObject handler reads dats extensively - // (Setup, GfxObj, Surface, SurfaceTexture) to hydrate the spawned - // entity. All of it must run under the dat lock so it doesn't race - // with BuildLandblockForStreaming on the worker thread. - lock (_datLock) - { - AcDream.App.World.LiveEntityRegistrationResult registration = - _liveEntities!.RegisterLiveEntity(spawn); - AcDream.App.World.InboundCreateResult result = registration.Inbound; - if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration) - return; - - try - { - PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps); - - AcDream.Core.Net.ObjectTableWiring.ApplyEntitySpawn( - Objects, - spawn, - replaceGeneration: result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration); - - if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.ExistingGeneration) - { - if (result.SameGenerationEvents is { } refresh) - RouteSameGenerationCreateObject(refresh); - return; - } - - OnLiveEntitySpawnedLocked( - result.Snapshot, - LiveProjectionPurpose.LogicalRegistration); - } - catch (Exception applyFailure) when (registration.PriorGenerationCleanupFailure is not null) - { - throw new AggregateException( - $"Live entity 0x{spawn.Guid:X8} replacement cleanup and installation both failed.", - registration.PriorGenerationCleanupFailure, - applyFailure); - } - - if (registration.PriorGenerationCleanupFailure is { } cleanupFailure) - throw new AggregateException( - $"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.", - cleanupFailure); - } - } - - /// - /// #138: re-hydrate retained server objects (doors, NPCs, chests, portals) - /// into a landblock that just (re)loaded. Fired by - /// after - /// AddLandblock / AddEntitiesToExistingLandblock. - /// - /// - /// A full landblock unload drops its dat-static render layer and parks live - /// projections pending, while keeping the parsed spawns in - /// (our weenie_object_table for world objects). ACE never - /// re-broadcasts objects it believes we still know — its per-player - /// KnownObjects set is not cleared on a normal teleport (verified - /// against references/ACE ObjectMaint; a real client keeps its - /// table and re-renders from it, per references/holtburger). So on - /// reload the render side would stay empty. We re-project the objects from - /// our own retained table instead — independent of any server re-send. - /// - /// - /// - /// Idempotent and cheap when nothing is missing. A materialized record is - /// rebucketed with the same identity; a record whose Setup was unavailable - /// on first arrival gets one first-materialization attempt. Neither path - /// replays logical renderer or script registration. - /// - /// - private void RehydrateServerEntitiesForLandblock(uint loadedLandblockId) - { - if (_liveEntities is null || _liveEntities.Count == 0) return; - - uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu; - LiveEntityRecord[] records = _liveEntities.Records - .Where(record => record.ServerGuid != _playerServerGuid - && record.ProjectionCellId != 0 - && ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical - && record.Snapshot.SetupTableId is not null) - .ToArray(); - if (records.Length == 0) return; - - int projected = 0; - lock (_datLock) - { - foreach (LiveEntityRecord record in records) - { - // A materialized object keeps the same WorldEntity, renderer - // registration, animation owner, and scripts. Reloading a - // landblock changes only its spatial bucket. Hydration is used - // solely for a record that never acquired a projection. - if (record.WorldEntity is not null) - { - if (_liveEntities.RebucketLiveEntity( - record.ServerGuid, - record.ProjectionCellId)) - projected++; - } - else - { - OnLiveEntitySpawnedLocked( - record.Snapshot, - LiveProjectionPurpose.SpatialRecovery); - if (record.WorldEntity is not null) - projected++; - } - } - } - - if (_options.DumpLiveSpawns && projected > 0) - Console.WriteLine( - $"live: re-projected {projected} server object(s) into landblock 0x{loadedLandblockId:X8}"); - } - - /// - /// Diagnostic-only label filter for the [door-cycle] UM dispatch trail - /// (below). #187: this is NOT the functional registration gate — that - /// was an exact-name match (`spawn.Name == "Door"`) which silently - /// dropped every door-like object whose display name isn't literally - /// "Door" (Sliding Door, Portcullis, Gate, "Magic Wall"). The real - /// registration gate is now data-driven (MotionTableId != 0, matching - /// retail's CPartArray::SetMotionTableID 0x005186e0) — see the rescue - /// branch below. Kept only to scope the debug print to door-ish - /// entities so the log doesn't fill with every reactive-motion prop. - /// - private static bool IsDoorName(string? name) => name == "Door"; - - private void TryInitializeLiveCenter( - AcDream.Core.Net.WorldSession.EntitySpawn spawn) - { - if (spawn.Guid != _playerServerGuid - || spawn.Position is not { } position) - return; - - int lbX = (int)((position.LandblockId >> 24) & 0xFFu); - int lbY = (int)((position.LandblockId >> 16) & 0xFFu); - int oldCenterX = _liveWorldOrigin.CenterX; - int oldCenterY = _liveWorldOrigin.CenterY; - if (!_liveWorldOrigin.TryInitialize(lbX, lbY)) - return; - - if (lbX != oldCenterX || lbY != oldCenterY) - { - Console.WriteLine( - $"live: first player position — recentering streaming from ({oldCenterX},{oldCenterY}) " + - $"to ({lbX},{lbY}) @0x{position.LandblockId:X8}"); - } - - if (_streamingController is not null && _dats is not null) - { - _streamingController.InitializeKnownLoginCenter( - lbX, - lbY, - isSealedDungeon: IsSealedDungeonCell(position.LandblockId)); - } - - // Retail SmartBox::UseTime (0x00455410) does not complete the player - // position while CellManager is blocking for destination cells. Begin - // the same shared reveal lifetime used by portal arrivals: initial - // login must not inherit the dispatcher's default/previous composite - // readiness and expose a terrain-only or partially uploaded world. - _worldReveal?.Begin(AcDream.App.Streaming.WorldRevealKind.Login); - - // Streaming is normally gated until this point, so the next loaded- - // landblock callback will materialize deferred records. Also cover an - // already-resident block (tests, reconnect teardown overlap) without - // rebuilding any still-live identity. - foreach (uint loaded in _worldState.LoadedLandblockIds.ToArray()) - RehydrateServerEntitiesForLandblock(loaded); - } - - private void OnLiveEntitySpawnedLocked( - AcDream.Core.Net.WorldSession.EntitySpawn spawn, - LiveProjectionPurpose purpose, - LiveEntityAppearanceUpdateState? appearanceUpdate = null) - { - _liveSpawnReceived++; - - // LiveEntityRuntime has already classified this as a first - // materialization, a spatial re-entry of the same incarnation, or an - // appearance mutation. This method never de-duplicates by destroying a - // still-live projection and never reconstructs from stale spawn data. - - // Retail's weenie-object table retains CreateObject data for inventory - // and parented children even when they have no world Position. Held - // weapon CreateObjects are intentionally no-position and carry their - // Parent + Placement in PhysicsData, so cache before the renderability - // gate and offer the relationship to the focused child controller. - _equippedChildRenderer?.OnSpawn(spawn); - - // A ParentEvent may have arrived before this CreateObject. Resolving - // it above can synchronously advance the canonical child POSITION_TS - // and turn this object into an attachment. Select the projection from - // that post-callback snapshot, never from the stale method argument. - if (_liveEntities!.TryGetSnapshot(spawn.Guid, out var canonicalSpawn)) - spawn = canonicalSpawn; - - // Streaming readiness belongs to the first accepted canonical player - // Position, not to mesh projection. A partial CreateObject with no - // Setup can establish the real world origin before a later mutation or - // spatial recovery makes it renderable. - TryInitializeLiveCenter(spawn); - - // When requested, log every spawn that arrives so we can inventory what the server - // sends (including the ones we can't render yet). The Name field - // is the critical one — we can grep the log for "Nullified Statue - // of a Drudge" or similar to find a specific weenie by its - // in-game name. - bool dumpLiveSpawns = _options.DumpLiveSpawns; - if (dumpLiveSpawns) - { - string posStr = spawn.Position is { } sp - ? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}" - : "no-pos"; - string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup"; - string physicsTableStr = spawn.Physics?.PhysicsScriptTableId is { } pe - ? $"0x{pe:X8}" - : "no-petable"; - string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name"; - string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype"; - int animPartCount = spawn.AnimPartChanges?.Count ?? 0; - int texChangeCount = spawn.TextureChanges?.Count ?? 0; - int subPalCount = spawn.SubPalettes?.Count ?? 0; - Console.WriteLine( - $"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " + - $"petable={physicsTableStr} itemType={itemTypeStr} animParts={animPartCount} " + - $"texChanges={texChangeCount} subPalettes={subPalCount}"); - } - - // Target the statue specifically for full diagnostic dump: Name match - // is cheap and gives us exactly one entity's worth of log regardless - // of arrival order. - bool isStatue = dumpLiveSpawns - && spawn.Name is not null - && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase); - if (isStatue) - { - Console.WriteLine($"live: [STATUE] objScale={spawn.ObjScale?.ToString("F3") ?? "null"}"); - Console.WriteLine($"live: [STATUE] mtable=0x{(spawn.MotionTableId ?? 0):X8} stance=0x{(spawn.MotionState?.Stance ?? 0):X4} cmd=0x{(spawn.MotionState?.ForwardCommand ?? 0):X4}"); - if (spawn.TextureChanges is { } tcs) - { - foreach (var tc in tcs) - Console.WriteLine($"live: [STATUE] texChange part={tc.PartIndex} old=0x{tc.OldTexture:X8} new=0x{tc.NewTexture:X8}"); - } - if (spawn.SubPalettes is { } sps) - { - Console.WriteLine($"live: [STATUE] basePalette=0x{(spawn.BasePaletteId ?? 0):X8}"); - foreach (var subPal in sps) - Console.WriteLine($"live: [STATUE] subPalette id=0x{subPal.SubPaletteId:X8} offset={subPal.Offset} length={subPal.Length}"); - } - if (spawn.AnimPartChanges is { } apcs) - { - foreach (var apc in apcs) - Console.WriteLine($"live: [STATUE] animPart index={apc.PartIndex} newModel=0x{apc.NewModelId:X8}"); - } - - // Dump the BASE setup's part list before AnimPartChanges, so we can - // see how many parts the statue's Setup actually has + what their - // default GfxObjs are. The retail statue may have additional parts - // (e.g. a pedestal sub-mesh) that our setup loader is dropping or - // we're rendering with wrong default GfxObjs. - if (spawn.SetupTableId is { } sid && _dats is not null) - { - var baseSetup = _dats.Get(sid); - if (baseSetup is not null) - { - Console.WriteLine($"live: [STATUE] base Setup 0x{sid:X8} has {baseSetup.Parts.Count} parts:"); - for (int pi = 0; pi < baseSetup.Parts.Count; pi++) - { - uint partGfxId = (uint)baseSetup.Parts[pi]; - var pgfx = _dats.Get(partGfxId); - int subCount = pgfx?.Surfaces.Count ?? -1; - Console.WriteLine($"live: [STATUE] part[{pi}] gfxObj=0x{partGfxId:X8} surfaces={subCount}"); - } - Console.WriteLine($"live: [STATUE] placementFrames count={baseSetup.PlacementFrames.Count}"); - } - } - } - - if (_dats is null) return; - - // The retained live record and its canonical packet state are valid - // before the player's first Position, but render-world coordinates are - // not. Defer every projection until the authoritative origin exists; - // StreamingController's loaded callback (or the already-resident pass - // in TryInitializeLiveCenter) materializes it once with the correct - // landblock translation. - if (!_liveCenterKnown) - return; - - if (spawn.Position is null || spawn.SetupTableId is null) - { - // Can't place a mesh without both. Most of these are inventory - // items anyway (no position because they're held), which have no - // visible world presence. - if (spawn.Position is null) _liveDropReasonNoPos++; - else _liveDropReasonNoSetup++; - return; - } - - var p = spawn.Position.Value; - - // Translate server position into acdream world space. The server sends - // (landblockId, local x/y/z). acdream's world origin is the center - // landblock; each neighbor landblock is offset by 192 units per step. - int lbX = (int)((p.LandblockId >> 24) & 0xFFu); - int lbY = (int)((p.LandblockId >> 16) & 0xFFu); - - var origin = new System.Numerics.Vector3( - (lbX - _liveCenterX) * 192f, - (lbY - _liveCenterY) * 192f, - 0f); - var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin; - - // AC quaternion wire order is (W, X, Y, Z); System.Numerics.Quaternion is (X, Y, Z, W). - var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); - - // Hydrate mesh refs from the Setup dat. This is the same code path - // used by the static scenery pipeline (see the Setup hydration above). - var setup = _dats.Get(spawn.SetupTableId.Value); - if (setup is not null) - _physicsDataCache.CacheSetup(spawn.SetupTableId.Value, setup); - if (setup is null) - { - _liveDropReasonSetupDatMissing++; - if (dumpLiveSpawns) - Console.WriteLine($"live: DROP setup dat 0x{spawn.SetupTableId.Value:X8} missing " + - $"(guid=0x{spawn.Guid:X8})"); - return; - } - if (_liveEntities.TryGetRecord(spawn.Guid, out var setupRecord)) - setupRecord.HasPartArray = true; - - // Phase 6: resolve the entity's idle motion frame from its - // MotionTable chain. For creatures and characters this gives us - // the upright "Resting" pose instead of the Setup's Default - // (T-pose / aggressive crouch). Static items with no motion table - // get null and fall back to PlacementFrames in Flatten. - // Honor the server's CurrentMotionState (CreateObject MovementData) - // when present. The Foundry's drudge statue is the canonical case: - // its MotionTable's default style is upright "Ready" but the weenie - // is sent with a combat stance + Crouch ForwardCommand override, so - // resolving the cycle key from those gives the aggressive crouch. - ushort? stanceOverride = spawn.MotionState?.Stance; - ushort? commandOverride = spawn.MotionState?.ForwardCommand; - // Critical for entities like the Foundry's drudge statue: their - // base Setup has DefaultMotionTable=0, but the server tells us - // which motion table to use via PhysicsDescriptionFlag.MTable. - // Without this override the resolver returns null and we fall - // back to PlacementFrames[Default] which renders the wrong pose. - // Phase 6.4: prefer the full cycle so we can play it forward over - // time. Falls back to GetIdleFrame's static-frame behavior when - // the cycle resolves but only the first frame is rendered (no - // animated entry registered) — this happens for entities the - // resolver short-circuits on. - var idleCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( - setup, _dats, _animLoader!, - motionTableIdOverride: spawn.MotionTableId, - stanceOverride: stanceOverride, - commandOverride: commandOverride); - DatReaderWriter.Types.AnimationFrame? idleFrame = null; - if (idleCycle is not null) - { - int startIdx = idleCycle.LowFrame; - if (startIdx < 0 || startIdx >= idleCycle.Animation.PartFrames.Count) startIdx = 0; - idleFrame = idleCycle.Animation.PartFrames[startIdx]; - } - var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup, idleFrame); - - // Apply the server's AnimPartChanges: "replace part at index N - // with GfxObj M". This is how characters become clothed (head → - // helmet, torso → chestplate, ...) and how server-weenie statues - // and props pick up their unique visual meshes on top of a generic - // base Setup. Start with a mutable copy, patch in the replacements, - // then proceed with the normal upload loop. - var parts = new List(flat); - var animPartChanges = spawn.AnimPartChanges ?? Array.Empty(); - // Diagnostic: dump AnimPartChanges + TextureChanges for humanoid setups - // gated on ACDREAM_DUMP_CLOTHING=1. Used to verify whether the server is - // sending coverage for the neck (part 9 for Aluvian Male) etc. - bool dumpClothing = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DUMP_CLOTHING"), "1", StringComparison.Ordinal) - && setup.Parts.Count >= 10; - if (dumpClothing) - { - Console.WriteLine($"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' setup=0x{setup.Id:X8} setup.Parts.Count={setup.Parts.Count} flatten.Count={flat.Count} APC={animPartChanges.Count} ==="); - // Dump the Setup's ParentIndex + DefaultScale arrays to verify hierarchy. - var parentStr = string.Join(",", setup.ParentIndex.Take(Math.Min(34, setup.ParentIndex.Count)).Select(p => p == 0xFFFFFFFFu ? "-1" : p.ToString())); - Console.WriteLine($" ParentIndex[{setup.ParentIndex.Count}]: {parentStr}"); - var scaleStr = string.Join(",", setup.DefaultScale.Take(Math.Min(34, setup.DefaultScale.Count)).Select(s => $"({s.X:F2},{s.Y:F2},{s.Z:F2})")); - Console.WriteLine($" DefaultScale[{setup.DefaultScale.Count}]: {scaleStr}"); - // Dump the resolved idle frame's per-part Origin + Orientation. - // If retail composes parent_world * animation_local but acdream - // treats animation_local as world-relative, we'd see specific - // patterns of non-zero per-part origins/rotations that should - // be parent-relative. For setups whose idle has all parts at - // (0,0,0)/identity, parent walking would be a no-op (which - // matches my earlier "no change" experiment if that was the - // human-idle case) — diagnostic confirms. - if (idleFrame is not null) - { - Console.WriteLine($" IdleFrame.Frames[{idleFrame.Frames.Count}]:"); - int dumpCount = Math.Min(idleFrame.Frames.Count, 17); // first 17 (real body parts, not the 17-33 placeholders) - for (int fi = 0; fi < dumpCount; fi++) - { - var f = idleFrame.Frames[fi]; - Console.WriteLine($" [{fi:D2}] Origin=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) Orient=(W={f.Orientation.W:F3} X={f.Orientation.X:F3} Y={f.Orientation.Y:F3} Z={f.Orientation.Z:F3})"); - } - } - else - { - Console.WriteLine($" IdleFrame: NULL"); - } - foreach (var c in animPartChanges) - Console.WriteLine($" APC part={c.PartIndex:D2} -> gfx=0x{c.NewModelId:X8}"); - - // #37: per-spawn palette swaps. The server's clothing pipeline - // sends a basePalette + a list of (subPaletteId, offset, length) - // triples that splice palette ranges into the rendered character. - // We need their IDs to know whether the coat texture's underlying - // palette is being overridden by a coat-tone subPalette or left - // alone (in which case the texture's DefaultPaletteId — a SKIN - // palette — leaks through and the coat ends up neck-colored). - Console.WriteLine($" basePalette=0x{(spawn.BasePaletteId ?? 0):X8} subPalettes={(spawn.SubPalettes?.Count ?? 0)}"); - if (spawn.SubPalettes is { } subPaletteList) - { - foreach (var subPal in subPaletteList) - { - int rawOffset = subPal.Offset * 8; - int rawLen = subPal.Length == 0 ? 2048 : subPal.Length * 8; - var pal = _dats.Get(subPal.SubPaletteId); - string palInfo = pal is null ? "Palette dat NOT FOUND (might be PaletteSet 0x0F?)" : $"Colors.Count={pal.Colors.Count}"; - Console.WriteLine($" SP id=0x{subPal.SubPaletteId:X8} wireOffset={subPal.Offset} wireLength={subPal.Length} -> rawIdx[{rawOffset}..{rawOffset + rawLen}) {palInfo}"); - // If pal is non-null and small, show first 4 colors - if (pal is not null && pal.Colors.Count > 0) - { - int sample = Math.Min(4, pal.Colors.Count); - for (int s = 0; s < sample; s++) - { - var c = pal.Colors[s]; - Console.WriteLine($" pal[{s:D3}] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2}"); - } - // Also probe at the rawOffset (if in range) — that's where overlay copies FROM in our code - if (rawOffset < pal.Colors.Count) - { - var c = pal.Colors[rawOffset]; - Console.WriteLine($" pal[{rawOffset:D4}] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2} <-- our code reads here"); - } - else - { - Console.WriteLine($" pal[{rawOffset:D4}] OUT OF RANGE (Colors.Count={pal.Colors.Count}) -- our code's read SKIPS the overlay !!"); - } - } - } - } - } - foreach (var change in animPartChanges) - { - if (change.PartIndex < parts.Count) - { - parts[change.PartIndex] = new AcDream.Core.World.MeshRef( - change.NewModelId, parts[change.PartIndex].PartTransform); - } - } - - // Issue #47 — retail's close/player rendering path resolves each - // part's base GfxObj through its DIDDegrade table to the close- - // detail mesh in slot 0. Without this, humanoid arms/torso draw - // the LOW-detail base GfxObj (e.g. 0x01000055, 14 verts / 17 - // polys) instead of the close mesh (0x01001795, 32 verts / 60 - // polys), losing all bicep/shoulder/back geometry. See - // for the named-retail - // citation (CPhysicsPart::LoadGfxObjArray at 0x0050DCF0, - // ::UpdateViewerDistance at 0x0050E030, ::Draw at 0x0050D7A0). - // - // Order matters: the swap happens AFTER AnimPartChanges have - // installed the server's body/clothing/head ids, BEFORE texture - // changes resolve (which match against the resolved mesh's - // surfaces) and BEFORE the GfxObjMesh.Build / texture upload - // path consumes the part list. - // Collision reads slot 0 of the installed CPhysicsPart degrade array. - // Keep this independent from the optional visual close-LOD policy: - // draw settings and Setup type must never change physical geometry. - uint[] collisionPartGfxObjIds = - AcDream.App.Physics.LiveEntityCollisionBuilder.ResolveEffectivePartIdentities( - parts.Select(static part => part.GfxObjId).ToArray(), - baseId => - { - if (!AcDream.Core.Meshing.GfxObjDegradeResolver.TryResolveCloseGfxObj( - _dats, - baseId, - out uint slotZeroId, - out DatReaderWriter.DBObjs.GfxObj? slotZeroGfx)) - { - return baseId; - } - - if (slotZeroGfx is not null) - _physicsDataCache.CacheGfxObj(slotZeroId, slotZeroGfx); - return slotZeroId; - }); - - if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup)) - { - for (int partIdx = 0; partIdx < parts.Count; partIdx++) - { - var part = parts[partIdx]; - if (!AcDream.Core.Meshing.GfxObjDegradeResolver.TryResolveCloseGfxObj( - _dats, part.GfxObjId, - out uint resolvedId, out _)) - continue; - if (resolvedId == part.GfxObjId) - continue; - - parts[partIdx] = new AcDream.Core.World.MeshRef( - resolvedId, part.PartTransform); - - if (dumpClothing) - Console.WriteLine($" DEGRADE part={partIdx:D2} gfx=0x{part.GfxObjId:X8} -> close=0x{resolvedId:X8}"); - } - } - - // Build per-part texture overrides. The server sends TextureChanges as - // (partIdx, oldSurfaceTextureId, newSurfaceTextureId) where both ids - // are in the SurfaceTexture (0x05) range. Our sub-meshes are keyed - // by Surface (0x08) ids whose `OrigTextureId` field points to a - // SurfaceTexture. So we have to resolve each Surface → OrigTextureId, - // match that against the part's oldSurfaceTextureId set, and build - // a new dict keyed by Surface id → replacement OrigTextureId. The - // renderer then resolves an owner-scoped texture with the replacement - // SurfaceTexture substituted inside the Surface's decode chain. - var textureChanges = spawn.TextureChanges ?? Array.Empty(); - if (dumpClothing) - { - Console.WriteLine($" TextureChanges count={textureChanges.Count}"); - foreach (var tc in textureChanges) - Console.WriteLine($" TC part={tc.PartIndex:D2} oldTex=0x{tc.OldTexture:X8} -> newTex=0x{tc.NewTexture:X8}"); - - // For each part (post-AnimPartChange), dump its Surface chain so we - // can see which OrigTextureIds the part references and check which - // are covered by our TextureChanges. - var tcByPart = new Dictionary>(); - foreach (var tc in textureChanges) - { - if (!tcByPart.TryGetValue(tc.PartIndex, out var set)) { set = new HashSet(); tcByPart[tc.PartIndex] = set; } - set.Add(tc.OldTexture); - } - for (int pi = 0; pi < parts.Count; pi++) - { - var pgfx = _dats.Get(parts[pi].GfxObjId); - if (pgfx is null) continue; - if (pgfx.Surfaces.Count == 0) continue; - tcByPart.TryGetValue(pi, out var coveredOldTex); - int matched = 0; - int unmatched = 0; - var unmatchedList = new List(); - foreach (var surfQid in pgfx.Surfaces) - { - uint surfId = (uint)surfQid; - var surf = _dats.Get(surfId); - if (surf is null) continue; - uint origTex = (uint)surf.OrigTextureId; - if (coveredOldTex is not null && coveredOldTex.Contains(origTex)) matched++; - else { unmatched++; unmatchedList.Add($"surf=0x{surfId:X8} origTex=0x{origTex:X8}"); } - } - if (pgfx.Surfaces.Count > 0) - Console.WriteLine($" part[{pi:D2}] gfx=0x{parts[pi].GfxObjId:X8} surfaces={pgfx.Surfaces.Count} matched={matched} unmatched={unmatched}"); - foreach (var s in unmatchedList) - Console.WriteLine($" UNMATCHED {s}"); - } - } - Dictionary>? resolvedOverridesByPart = null; - if (textureChanges.Count > 0) - { - // First pass: group (oldOrigTex → newOrigTex) per part. - var perPartOldToNew = new Dictionary>(); - foreach (var tc in textureChanges) - { - if (!perPartOldToNew.TryGetValue(tc.PartIndex, out var dict)) - { - dict = new Dictionary(); - perPartOldToNew[tc.PartIndex] = dict; - } - // Last write wins — matches observed duplicate semantics. - dict[tc.OldTexture] = tc.NewTexture; - } - - // Second pass: resolve each affected part's Surface chain and - // build the Surface-id-keyed override map the renderer consumes. - bool isStatueDiag = dumpLiveSpawns - && spawn.Name is not null - && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase); - resolvedOverridesByPart = new Dictionary>(); - for (int pi = 0; pi < parts.Count; pi++) - { - if (!perPartOldToNew.TryGetValue(pi, out var oldToNew)) continue; - var partGfx = _dats.Get(parts[pi].GfxObjId); - if (partGfx is null) - { - if (isStatueDiag) - Console.WriteLine($"live: [STATUE] resolve part={pi} GfxObj 0x{parts[pi].GfxObjId:X8} missing"); - continue; - } - _physicsDataCache.CacheGfxObj(parts[pi].GfxObjId, partGfx); - - if (isStatueDiag) - Console.WriteLine($"live: [STATUE] resolve part={pi} gfx=0x{parts[pi].GfxObjId:X8} surfaces={partGfx.Surfaces.Count}"); - - Dictionary? resolved = null; - foreach (var surfQid in partGfx.Surfaces) - { - uint surfId = (uint)surfQid; - var surfDat = _dats.Get(surfId); - if (surfDat is null) continue; - uint origTexId = (uint)surfDat.OrigTextureId; - bool hit = origTexId != 0 && oldToNew.TryGetValue(origTexId, out uint newOrigTex) && (newOrigTex != 0 || true); - if (isStatueDiag) - Console.WriteLine($"live: [STATUE] surface=0x{surfId:X8} origTex=0x{origTexId:X8} " + (hit ? "[MATCH]" : "[miss]")); - if (origTexId == 0) continue; - if (oldToNew.TryGetValue(origTexId, out uint newId)) - { - resolved ??= new Dictionary(); - resolved[surfId] = newId; - } - } - - if (resolved is not null) - resolvedOverridesByPart[pi] = resolved; - } - } - - // Apply ObjScale by baking a scale matrix into each MeshRef's - // PartTransform. Scenery hydration already does this pattern - // (scaleMat baked into PartTransform at Setup flatten time). - // Fallback to 1.0 if the server didn't send ObjScale (common for - // creatures/characters whose size is intrinsic to the mesh). - float scale = spawn.ObjScale ?? 1.0f; - var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale); - IReadOnlyList rigidPartTransforms = - AcDream.Core.Meshing.SetupPartTransforms.Compute( - setup, - idleFrame, - scale); - - var meshRefs = new List(); - var indexedPartTransforms = new System.Numerics.Matrix4x4[parts.Count]; - var indexedPartAvailable = new bool[parts.Count]; - var animatedPartTemplate = new LiveAnimationPartTemplate[parts.Count]; - var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); - int dumpClothingTotalTris = 0; - for (int partIdx = 0; partIdx < parts.Count; partIdx++) - { - var mr = parts[partIdx]; - IReadOnlyDictionary? surfaceOverrides = null; - if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides)) - surfaceOverrides = partOverrides; - - // Keep the Setup index even when the visual GfxObj is absent. - // MeshRefs is a drawable-only list, while hooks and holding - // locations address the stable CPartArray slot number. - var transform = scale == 1.0f ? mr.PartTransform : mr.PartTransform * scaleMat; - indexedPartTransforms[partIdx] = partIdx < rigidPartTransforms.Count - ? rigidPartTransforms[partIdx] - : System.Numerics.Matrix4x4.Identity; - var gfx = _dats.Get(mr.GfxObjId); - bool isDrawable = gfx is not null; - indexedPartAvailable[partIdx] = isDrawable; - animatedPartTemplate[partIdx] = new LiveAnimationPartTemplate( - mr.GfxObjId, - surfaceOverrides, - isDrawable); - if (gfx is null) - { - if (dumpClothing) - Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} GFXOBJ_DAT_MISSING -> 0 tris"); - continue; - } - _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); - if (dumpClothing) - { - var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); - int tris = 0; int subs = 0; - foreach (var sm in subMeshes) { tris += sm.Indices.Length / 3; subs++; } - dumpClothingTotalTris += tris; - Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} subMeshes={subs} tris={tris}"); - } - - // Multiplication order matches offline scenery hydration: - // `PartTransform * scaleMat`. In row-vector semantics this means - // "apply PartTransform first (which includes the part-attachment - // translation), then scale in the resulting space." Using the - // opposite order (`scaleMat * PartTransform`) scales in mesh-local - // space first, which leaves the part-attachment offset unscaled — - // for multi-part entities like the Nullified Statue that causes - // the parts to drift relative to each other ("distorted") and the - // base anchor to end up below the ground ("sinks into foundry"). - // #119 follow-up: vertex-derived root-local bounds (see WorldEntity.RefreshAabb). - var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); - if (pb is not null) liveBounds.Add(transform, pb.Value); - - meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, transform) - { - SurfaceOverrides = surfaceOverrides, - }); - } - if (meshRefs.Count == 0) - { - _liveDropReasonNoMeshRefs++; - if (dumpLiveSpawns) - Console.WriteLine($"live: DROP no mesh refs from setup 0x{spawn.SetupTableId.Value:X8} " + - $"(guid=0x{spawn.Guid:X8})"); - return; - } - if (dumpClothing) - Console.WriteLine($" TOTAL tris={dumpClothingTotalTris} meshRefs={meshRefs.Count} (parts.Count={parts.Count})"); - - // Build optional per-entity palette override from the server's base - // palette + subpalette overlays. The renderer applies these to - // palette-indexed textures (PFID_P8 / PFID_INDEX16) to get per-entity - // skin/hair/body colors and statue stone recoloring. Non-palette - // textures ignore the override. - AcDream.Core.World.PaletteOverride? paletteOverride = null; - if (spawn.SubPalettes is { Count: > 0 } spList) - { - var ranges = new AcDream.Core.World.PaletteOverride.SubPaletteRange[spList.Count]; - for (int i = 0; i < spList.Count; i++) - ranges[i] = new AcDream.Core.World.PaletteOverride.SubPaletteRange( - spList[i].SubPaletteId, spList[i].Offset, spList[i].Length); - paletteOverride = new AcDream.Core.World.PaletteOverride( - BasePaletteId: spawn.BasePaletteId ?? 0, - SubPalettes: ranges); - } - - AcDream.Core.World.PartOverride[] entityPartOverrides; - if (animPartChanges.Count == 0) - { - entityPartOverrides = Array.Empty(); - } - else - { - entityPartOverrides = new AcDream.Core.World.PartOverride[animPartChanges.Count]; - for (int i = 0; i < animPartChanges.Count; i++) - entityPartOverrides[i] = new AcDream.Core.World.PartOverride( - animPartChanges[i].PartIndex, animPartChanges[i].NewModelId); - } - - if (appearanceUpdate is { } visualUpdate) - { - AcDream.Core.World.WorldEntity existing = visualUpdate.Entity; - LiveEntityAppearanceCollisionUpdate? appearanceCollision = - LiveEntityAppearanceBinding.PrepareCollision( - _liveEntities!, - _liveEntityCollisionBuilder!, - existing, - setup, - collisionPartGfxObjIds, - spawn, - origin); - _wbEntitySpawnAdapter!.OnAppearanceChanged( - existing, - meshRefs, - entityPartOverrides, - () => - { - // Retail CSurface::SetTextureAndPalette (0x00535FB0) - // releases the prior combined ImgTex before installing the - // replacement. Mesh references for the replacement have - // already been acquired before this publication callback. - _textureCache!.ReleaseOwner(existing.Id); - existing.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides); - }, - () => - { - if (appearanceCollision is not null) - LiveEntityAppearanceBinding.CommitCollision( - _liveEntities, - _physicsEngine.ShadowObjects, - appearanceCollision); - existing.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); - if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax)) - existing.SetLocalBounds(appearanceMin, appearanceMax); - if (visualUpdate.Animation is { } animation) - LiveEntityAppearanceBinding.RebindAnimation( - animation, - existing, - setup, - scale, - animatedPartTemplate, - indexedPartAvailable); - _classificationCache.InvalidateEntity(existing.Id); - if (_liveEntities!.TryGetRecord(spawn.Guid, out LiveEntityRecord record) - && record.ProjectionKind is LiveEntityProjectionKind.Attached) - { - // The attachment controller composes child-local parts - // through the parent's current animated pose. Re-run - // that composition after replacing the child visual. - _equippedChildRenderer?.OnSpawn(spawn); - } - else - { - // SmartBox::UpdateVisualDesc mutates the existing - // PartArray. Publish those exact replacement part frames - // before another packet can execute an attached hook. - _effectPoses.PublishMeshRefs(existing); - _equippedChildRenderer?.OnPosePublished(spawn.Guid); - } - }); - return; - } - - if (!_liveEntities!.TryGetEffectProfile(spawn.Guid, out _)) - { - var effectProfile = spawn.Physics is { } physics - ? AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateLive(setup, physics) - : AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup); - _liveEntities.SetEffectProfile(spawn.Guid, effectProfile); - } - - bool createdProjection = false; - var entity = _liveEntities!.MaterializeLiveEntity( - spawn.Guid, - spawn.Position!.Value.LandblockId, - localId => - { - createdProjection = true; - var created = new AcDream.Core.World.WorldEntity - { - Id = localId, - ServerGuid = spawn.Guid, - SourceGfxObjOrSetupId = spawn.SetupTableId.Value, - Position = worldPos, - Rotation = rot, - MeshRefs = meshRefs, - PaletteOverride = paletteOverride, - PartOverrides = entityPartOverrides, - ParentCellId = spawn.Position.Value.LandblockId, - }; - created.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); - if (liveBounds.TryGet(out var createdMin, out var createdMax)) - created.SetLocalBounds(createdMin, createdMax); - return created; - }); - if (entity is null) - return; - - if (!createdProjection) - { - // A parented child already owns this incarnation's WorldEntity. - // Reuse it when a fresh Position returns the object to the world. - entity.SetPosition(worldPos); - entity.Rotation = rot; - entity.ParentCellId = spawn.Position.Value.LandblockId; - entity.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides); - entity.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable); - if (liveBounds.TryGet(out var retainedMin, out var retainedMax)) - entity.SetLocalBounds(retainedMin, retainedMax); - _effectPoses.PublishMeshRefs(entity); - } - - // Retail CPhysicsObj::leave_world removes cell/shadow membership but - // retains PartArray and MovementManager. A Position after Pickup or - // parenting therefore re-enters with the same animation owner; do not - // replace its sequencer or replay initialization. - bool retainedAnimationRuntime = !createdProjection - && _animatedEntities.TryGetValue(entity.Id, out _); - - var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( - Id: entity.Id, - SourceId: entity.SourceGfxObjOrSetupId, - Position: entity.Position, - Rotation: entity.Rotation); - _worldGameState.Add(snapshot); - _worldEvents.UpsertCurrent(snapshot); - if (_liveEntities!.TryMarkWorldSpawnPublished(spawn.Guid)) - _worldEvents.FireEntitySpawned(snapshot); - - // Phase A.1: register entity into GpuWorldState so the next frame picks - // it up. Materialization parks the projection when its landblock is - // not loaded yet, then AddLandblock merges the same identity. - // MaterializeLiveEntity above performed the spatial projection. - _liveSpawnHydrated++; - - // Phase 6.6/6.7: remember the server-guid → WorldEntity mapping so - // UpdateMotion / UpdatePosition events can reseat this entity by guid. - // The GUID/local-id mapping is owned by LiveEntityRuntime. - - // The root now exists, so parent relations that arrived before this - // object's render projection can compose their child meshes. - _equippedChildRenderer?.OnWorldEntityRegistered(spawn.Guid); - - // Commit B 2026-04-29 — live-entity collision registration. - // The local player is the simulator (its PhysicsBody is the source of - // truth for our own movement), but retail still registers its resolved - // CPhysicsObj as a collision target for remote creatures. The player's - // own transition skips this entry by LocalEntityId / OBJECTINFO::object. - // Phantom-Setup entities (no CylSpheres / no Spheres / no Radius) - // are deliberately skipped — retail FUN's `FindObjCollisions` - // falls through to OK_TS for any object with no collision - // geometry (acclient_2013_pseudo_c.txt:276917,276987). - if (_liveEntities.TryGetRecord(spawn.Guid, out var liveRecord) - && _liveEntityCollisionBuilder!.Build( - entity, - setup, - collisionPartGfxObjIds, - spawn, - liveRecord, - origin) is { } collision) - { - AcDream.App.Physics.LiveEntityCollisionBuilder.Register( - _physicsEngine.ShadowObjects, - collision); - } - if (_liveEntities.TryGetRecord(spawn.Guid, out liveRecord)) - _projectileController?.TryBind( - liveRecord, - setup, - _physicsScriptGameTime, - _liveCenterX, - _liveCenterY); - - // Phase B.2: capture the server-sent MotionTableId for our own - // character so UpdatePlayerAnimation can pass it to GetIdleCycle. - // The Setup's DefaultMotionTable is often 0 for human characters; - // the real table comes from PhysicsDescriptionFlag.MTable. - if (spawn.Guid == _playerServerGuid && spawn.MotionTableId is not null) - _playerMotionTableId = spawn.MotionTableId; - - // Phase 6.4: register for per-frame playback if we resolved a real - // cycle with a non-zero framerate and at least two frames in the - // cycle (single-frame poses are static and don't need ticking). - // Diagnostic: log why we did / didn't register so we can tell - // which entities fall through the filter. - if (!retainedAnimationRuntime) - { - if (idleCycle is null) - _liveAnimRejectNoCycle++; - else if (idleCycle.Framerate == 0f) - _liveAnimRejectFramerate++; - else if (idleCycle.HighFrame <= idleCycle.LowFrame) - _liveAnimRejectSingleFrame++; - else if (idleCycle.Animation.PartFrames.Count <= 1) - _liveAnimRejectPartFrames++; - } - - if (!retainedAnimationRuntime - && idleCycle is not null && idleCycle.Framerate != 0f - && idleCycle.HighFrame > idleCycle.LowFrame - && idleCycle.Animation.PartFrames.Count > 1) - { - // Snapshot per-part identity from the hydrated meshRefs so the - // tick can rebuild MeshRefs without redoing AnimPartChanges or - // texture-override resolution every frame. - LiveAnimationPartTemplate[] template = animatedPartTemplate; - - // Create an AnimationSequencer if we can load the MotionTable. - AcDream.Core.Physics.AnimationSequencer? sequencer = null; - if (_animLoader is not null) - { - uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; - if (mtableId != 0) - { - var mtable = _dats.Get(mtableId); - if (mtable is not null) - { - sequencer = SpawnMotionInitializer.Create( - setup, mtable, _animLoader, spawn.MotionState); - } - } - } - - _animatedEntities[entity.Id] = new LiveEntityAnimationState - { - Entity = entity, - Setup = setup, - Animation = idleCycle.Animation, - LowFrame = Math.Max(0, idleCycle.LowFrame), - HighFrame = Math.Min(idleCycle.HighFrame, idleCycle.Animation.PartFrames.Count - 1), - Framerate = idleCycle.Framerate, - Scale = scale, - PartTemplate = template, - PartAvailability = indexedPartAvailable, - CurrFrame = idleCycle.LowFrame, - Sequencer = sequencer, - }; - - } - else if (!retainedAnimationRuntime && _animLoader is not null) - { - // Phase B.4c / #187 — reactive motion-table rescue. An entity - // whose REST pose is a static single frame fails the generic - // multi-frame-idle gate above, but may still carry a MotionTable - // with On/Off (or other) cycles the server drives reactively via - // UpdateMotion. Hinged doors, sliding doors, gates/portcullises, - // and disguised secret-passage props ("Magic Wall") all share - // this exact shape. #187: this branch used to be gated on - // `spawn.Name == "Door"`, which silently dropped every door-like - // object whose display name isn't literally "Door" — the - // production weenie data confirms Sliding Door / Portcullis / - // Gate / Magic Wall all carry the SAME WeenieType=Door + - // non-zero MotionTableId shape as a plain Door, differing only - // in name. Retail's own gate for creating a motion dispatcher is - // purely data-driven on the motion table id - // (CPartArray::SetMotionTableID 0x005186e0: `if (ebx != 0)`) — no - // WeenieType switch, no name check — so the `mtableId != 0` test - // just below is now the ENTIRE gate, matching retail exactly. - // Register with a seeded sequencer so the per-frame tick has - // frames to advance from frame 1 (without the seed, - // Sequencer.Advance(dt) returns no frames and the MeshRefs - // rebuild at line 7691 collapses the entity to origin). - // - // #204: this path is not door-specific. Corpses also arrive here - // because Dead is a persistent static rest pose. The old rescue - // hard-coded Door On/Off from PhysicsState, discarded the wire's - // authoritative Dead command, and left the corpse on Ready. Both - // spawn paths must seed the same wire MovementData through retail's - // description-then-enter-world lifecycle. - uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; - if (mtableId != 0) - { - var mtable = _dats.Get(mtableId); - if (mtable is not null) - { - var sequencer = SpawnMotionInitializer.Create( - setup, mtable, _animLoader, spawn.MotionState); - - LiveAnimationPartTemplate[] template = animatedPartTemplate; - - _animatedEntities[entity.Id] = new LiveEntityAnimationState - { - Entity = entity, - Setup = setup, - Animation = null!, // sequencer-driven; tick reads sequencer state - LowFrame = 0, - HighFrame = 0, - Framerate = 0f, - Scale = scale, - PartTemplate = template, - PartAvailability = indexedPartAvailable, - CurrFrame = 0, - Sequencer = sequencer, - }; - - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) - { - var initial = SpawnMotionInitializer.ResolvePlan(mtable, spawn.MotionState); - Console.WriteLine(System.FormattableString.Invariant( - $"[reactive-anim] registered guid=0x{spawn.Guid:X8} entityId=0x{entity.Id:X8} mtable=0x{mtableId:X8} initialStyle=0x{initial.Style:X8} initialCycle=0x{initial.Motion:X8}")); - } - } - } - } - - // CPartArray::InitDefaults (0x00518980) installs Setup.DefaultAnimation - // for every setup-backed PartArray, even when there is no MotionTable - // and no independently resolved idle cycle. Physics-Static owners are - // advanced by RetailStaticAnimatingObjectScheduler; a non-static live - // owner remains in the ordinary CPhysics object workset. - bool isPhysicsStatic = _liveEntities.TryGetRecord( - spawn.Guid, - out LiveEntityRecord animationRecord) - && (animationRecord.FinalPhysicsState - & AcDream.Core.Physics.PhysicsStateFlags.Static) != 0; - AcDream.Core.Physics.IAnimationLoader? setupDefaultLoader = _animLoader; - if (!retainedAnimationRuntime - && !_animatedEntities.TryGetValue(entity.Id, out _) - && setupDefaultLoader is not null - && (uint)setup.DefaultAnimation != 0) - { - var sequencer = new AcDream.Core.Physics.AnimationSequencer( - setup, - new DatReaderWriter.DBObjs.MotionTable(), - setupDefaultLoader); - if (sequencer.HasCurrentNode) - { - _animatedEntities[entity.Id] = new LiveEntityAnimationState - { - Entity = entity, - Setup = setup, - Animation = null!, - LowFrame = 0, - HighFrame = 0, - Framerate = 0f, - Scale = scale, - PartTemplate = animatedPartTemplate, - PartAvailability = indexedPartAvailable, - CurrFrame = 0, - Sequencer = sequencer, - }; - } - } - - // MotionDone ownership is established with the logical animation - // runtime, before EnterPlayerModeNow can dispatch SetPosition -> - // StopCompletely. That call may synchronously finish a zero-tick - // Ready entry; dropping its callback leaves CMotionInterp with an - // unmatched node that permanently starves later MoveTo/TurnTo work. - if (_animatedEntities.TryGetValue(entity.Id, out var registeredAnimation)) - { - if (_liveEntities.TryGetRecord(spawn.Guid, out LiveEntityRecord bindingRecord)) - _animationPresenter.PrepareAnimation(bindingRecord, registeredAnimation); - - if (isPhysicsStatic - && registeredAnimation.Sequencer is { } staticSequencer - && spawn.Position is { } staticPosition) - { - AcDream.Core.Physics.PhysicsBody staticBody = - _liveEntities.GetOrCreatePhysicsBody( - spawn.Guid, - incarnation => - { - var body = new AcDream.Core.Physics.PhysicsBody - { - Orientation = entity.Rotation, - }; - AcDream.App.Physics.RemotePhysicsBodyInitializer.Initialize( - body, - incarnation); - body.SnapToCell( - staticPosition.LandblockId, - entity.Position, - new System.Numerics.Vector3( - staticPosition.PositionX, - staticPosition.PositionY, - staticPosition.PositionZ)); - return body; - }); - _staticAnimationScheduler?.BindLiveOwner( - entity, - registeredAnimation, - staticBody); - } - } - - // Renderer, script owner, optional animation owner, collision body, - // and effect profile are now all installed. Only at this boundary may - // the mixed pre-Create F754/F755 FIFO replay against the local ID. - CompleteLiveEntityReady(spawn.Guid); - - // Dump a summary periodically so we can see drop breakdowns without - // waiting for a graceful shutdown. - if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0) - { - Console.WriteLine( - $"live: animated={_animatedEntities.Count} " + - $"animReject: noCycle={_liveAnimRejectNoCycle} fr0={_liveAnimRejectFramerate} " + - $"1frame={_liveAnimRejectSingleFrame} partFrames={_liveAnimRejectPartFrames}"); - Console.WriteLine( - $"live: summary recv={_liveSpawnReceived} hydrated={_liveSpawnHydrated} " + - $"drops: noPos={_liveDropReasonNoPos} noSetup={_liveDropReasonNoSetup} " + - $"setupMissing={_liveDropReasonSetupDatMissing} noMesh={_liveDropReasonNoMeshRefs}"); - } - } - - /// - /// Triangle-aware terrain Z sample directly from a landblock's raw - /// heightmap. Used as the bilinear fallback in scenery hydration when - /// physics hasn't built a TerrainSurface for the landblock yet - /// (streaming race). Delegates to - /// - /// so this fallback and the player-physics path stay in lock-step on - /// sloped cells. - /// - /// - /// Issue #48: the previous in-place implementation here had its two - /// diagonal arms swapped (SWtoNE cells used the SEtoNW triangle test - /// and vice versa), so scenery on hilly terrain sat at a different Z - /// than the visible terrain mesh — a multi-meter offset in some - /// cells, the user-reported "floating trees" symptom. - /// - /// private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY) { uint landblockX = (block.Id >> 24) & 0xFFu; @@ -4230,13 +3060,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext return; } - lock (_datLock) + if (_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record)) { LiveEntityAppearanceUpdateState? appearanceState = LiveEntityAppearanceBinding.Capture(_liveEntities, update.Guid); - OnLiveEntitySpawnedLocked( + _liveEntityHydration!.ApplyAppearance( + record, newSpawn, - LiveProjectionPurpose.AppearanceMutation, appearanceState); } @@ -4785,19 +3615,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext entityHost.PositionManager.StickTo(targetGuid, radius, height); } - /// - /// Completes retail object construction in order: register the effect - /// owner, apply constructor/PhysicsDesc state transitions, then replay - /// SmartBox's queued F754/F755 blobs. - /// - private void CompleteLiveEntityReady(uint serverGuid) - { - if (_entityEffects?.PrepareLiveEntityOwner(serverGuid) != true) - return; - _liveEntityPresentation?.OnLiveEntityReady(serverGuid); - _entityEffects.ReplayPendingForLiveEntity(serverGuid); - } - private void ClearTargetForHiddenEntity(uint serverGuid) { if (_selectionInteractions is { } interactions) @@ -5973,9 +4790,11 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // readiness directly to this first accepted canonical Position before // translating it through the current world origin; projection recovery // below is a separate concern and may never be needed for UI-only state. - lock (_datLock) - TryInitializeLiveCenter(acceptedSpawn); - if (!IsCurrentPositionOwner()) + if (_liveEntityHydration?.EnsureWorldOrigin( + acceptedPositionRecord, + acceptedPositionAuthorityVersion, + acceptedSpawn) != true + || !IsCurrentPositionOwner()) return; var p = update.Position; @@ -6013,10 +4832,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { if (!IsCurrentPositionOwner()) return; - lock (_datLock) - OnLiveEntitySpawnedLocked( - acceptedSpawn, - LiveProjectionPurpose.SpatialRecovery); + _liveEntityHydration!.RecoverProjection( + acceptedPositionRecord, + acceptedPositionAuthorityVersion, + acceptedSpawn); }) ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached; if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.Superseded @@ -6026,10 +4845,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext return; if (unparented is AcDream.App.Rendering.ChildUnparentDisposition.NotAttached) { - lock (_datLock) - OnLiveEntitySpawnedLocked( - acceptedSpawn, - LiveProjectionPurpose.SpatialRecovery); + _liveEntityHydration!.RecoverProjection( + acceptedPositionRecord, + acceptedPositionAuthorityVersion, + acceptedSpawn); if (!IsCurrentPositionOwner()) return; } diff --git a/src/AcDream.App/Rendering/LiveEntityCreateSupersessionRecovery.cs b/src/AcDream.App/Rendering/LiveEntityCreateSupersessionRecovery.cs new file mode 100644 index 00000000..416d7aa4 --- /dev/null +++ b/src/AcDream.App/Rendering/LiveEntityCreateSupersessionRecovery.cs @@ -0,0 +1,105 @@ +using AcDream.App.World; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// Completes a fresher same-incarnation CreateObject after it interrupted an +/// active projection transaction. Logical entity/resource ownership is +/// retained; the exact current appearance is republished, the plugin/current +/// snapshot is refreshed, and the retained animation owner is synchronized in +/// that order. Every potentially reentrant stage is guarded by the monotonic +/// Create-integration authority. +/// +internal static class LiveEntityCreateSupersessionRecovery +{ + public static bool TryApply( + LiveEntityRuntime runtime, + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion, + Func captureAppearance, + Func publishAppearance, + Action publishCurrentSnapshot, + Action synchronizeAnimation) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(expectedRecord); + ArgumentNullException.ThrowIfNull(captureAppearance); + ArgumentNullException.ThrowIfNull(publishAppearance); + ArgumentNullException.ThrowIfNull(publishCurrentSnapshot); + ArgumentNullException.ThrowIfNull(synchronizeAnimation); + + if (!runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || captureAppearance() is not { } visualUpdate + || !runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !publishAppearance(visualUpdate) + || !runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + + publishCurrentSnapshot(visualUpdate); + if (!runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + + synchronizeAnimation(visualUpdate); + return runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion); + } +} + +/// +/// Reconciles the exceptional case where a fresher same-incarnation Create +/// interrupts initial PartArray construction. Completed owners already route +/// MovementData through SetObjectMovement and therefore retain their exact +/// sequencer, dispatch sink, and paired manager/interpreter queues. +/// +internal static class LiveEntityCreateAnimationSynchronization +{ + public static bool TrySynchronizeInterruptedInitialOwner( + LiveEntityRecord record, + LiveEntityAnimationState animation, + Animation? canonicalAnimation, + int canonicalLowFrame, + int canonicalHighFrame, + float canonicalFramerate, + MotionTable? motionTable, + CreateObject.ServerMotionState? wireState) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(animation); + if (record.InitialHydrationCompleted) + return false; + + if (canonicalAnimation is not null) + { + animation.Animation = canonicalAnimation; + animation.LowFrame = canonicalLowFrame; + animation.HighFrame = canonicalHighFrame; + animation.Framerate = canonicalFramerate; + animation.CurrFrame = canonicalLowFrame; + } + + if (animation.Sequencer is { } sequencer && motionTable is not null) + { + SpawnMotionInitializer.Reinitialize( + sequencer, + motionTable, + wireState); + } + return true; + } +} diff --git a/src/AcDream.App/Rendering/SpawnMotionInitializer.cs b/src/AcDream.App/Rendering/SpawnMotionInitializer.cs index 05df260b..75ac456d 100644 --- a/src/AcDream.App/Rendering/SpawnMotionInitializer.cs +++ b/src/AcDream.App/Rendering/SpawnMotionInitializer.cs @@ -41,6 +41,26 @@ internal static class SpawnMotionInitializer return sequencer; } + /// + /// Replays description-time MovementData into the same retained PartArray + /// sequencer. This is used only when a fresher same-incarnation CreateObject + /// interrupted initial hydration before the object crossed its ready edge; + /// completed objects consume the packet through SetObjectMovement instead. + /// + public static void Reinitialize( + AnimationSequencer sequencer, + MotionTable motionTable, + CreateObject.ServerMotionState? wireState) + { + ArgumentNullException.ThrowIfNull(sequencer); + ArgumentNullException.ThrowIfNull(motionTable); + Plan plan = ResolvePlan(motionTable, wireState); + sequencer.Reset(); + sequencer.InitializeState(); + sequencer.SetCycle(plan.Style, plan.Motion); + sequencer.Manager.HandleEnterWorld(); + } + internal static Plan ResolvePlan( MotionTable motionTable, CreateObject.ServerMotionState? wireState) diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 8fa4ae39..82e3e3a5 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -40,6 +40,7 @@ public sealed record RuntimeOptions( bool RetailCloseDegrades, bool DumpSceneryZ, bool DumpLiveSpawns, + bool DumpClothing, int? LegacyStreamRadius, bool RetailUi, string? AcDir, @@ -89,6 +90,7 @@ public sealed record RuntimeOptions( RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal), DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")), DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")), + DumpClothing: IsExactlyOne(env("ACDREAM_DUMP_CLOTHING")), // Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on // top of the quality preset's radii. Null when unset or invalid. LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs new file mode 100644 index 00000000..d361a7fb --- /dev/null +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -0,0 +1,613 @@ +using AcDream.App.Rendering; +using AcDream.Core.Items; +using AcDream.Core.Net; + +namespace AcDream.App.World; + +internal enum LiveProjectionPurpose +{ + LogicalRegistration, + SpatialRecovery, + CreateSupersessionRecovery, + AppearanceMutation, +} + +/// +/// DAT-backed projection boundary used by . +/// The implementation owns mesh/animation/collision construction, but never +/// owns or indexes live identity. +/// +internal interface ILiveEntityProjectionMaterializer +{ + bool TryMaterialize( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn canonicalSpawn, + LiveProjectionPurpose purpose, + ulong expectedCreateIntegrationVersion, + LiveEntityAppearanceUpdateState? appearanceUpdate = null); + + void ResetSessionState(); +} + +internal interface ILiveEntitySpawnRelationshipSink +{ + void OnSpawn(WorldSession.EntitySpawn spawn); +} + +internal interface ILiveEntityReadyPublisher +{ + bool Publish(LiveEntityRecord expectedRecord); +} + +internal readonly record struct LiveEntityReadyCandidate( + LiveEntityRecord Record, + ulong CreateIntegrationVersion); + +internal interface ILiveEntityNetworkUpdateSink +{ + void ApplySameGeneration(SameGenerationCreateObjectEvents events); +} + +/// +/// Single-assignment construction seam between hydration and the Slice-4F +/// network-update owner. A session cannot publish CreateObject until this is +/// bound. +/// +internal sealed class DeferredLiveEntityNetworkUpdateSink : ILiveEntityNetworkUpdateSink +{ + private ILiveEntityNetworkUpdateSink? _inner; + + public void Bind(ILiveEntityNetworkUpdateSink inner) + { + ArgumentNullException.ThrowIfNull(inner); + if (Interlocked.CompareExchange(ref _inner, inner, null) is not null) + throw new InvalidOperationException("The live-entity network sink is already bound."); + } + + public void ApplySameGeneration(SameGenerationCreateObjectEvents events) => + (_inner ?? throw new InvalidOperationException( + "The live-entity network sink must be bound before a session starts.")) + .ApplySameGeneration(events); +} + +internal sealed class DelegateLiveEntityNetworkUpdateSink( + Action apply) : ILiveEntityNetworkUpdateSink +{ + private readonly Action _apply = + apply ?? throw new ArgumentNullException(nameof(apply)); + + public void ApplySameGeneration(SameGenerationCreateObjectEvents events) => + _apply(events); +} + +internal readonly record struct LiveEntityOriginInitialization( + bool IsKnown, + IReadOnlyList AlreadyLoadedLandblocks); + +internal interface ILiveEntityWorldOriginCoordinator +{ + bool IsKnown { get; } + LiveEntityOriginInitialization TryInitialize(WorldSession.EntitySpawn spawn); +} + +/// +/// Owns the retail CreateObject integration transaction over the canonical +/// . It has no GUID dictionary: every callback +/// captures and revalidates the exact record supplied by the runtime. +/// +/// Retail anchors: SmartBox::HandleCreateObject @ 0x00454C80 and +/// ACCObjectMaint::CreateObject @ 0x00558870. +/// +internal sealed class LiveEntityHydrationController +{ + private readonly LiveEntityRuntime _runtime; + private readonly ClientObjectTable _objects; + private readonly object _datLock; + private readonly ILiveEntityProjectionMaterializer _materializer; + private readonly ILiveEntitySpawnRelationshipSink _relationships; + private readonly ILiveEntityReadyPublisher _ready; + private readonly ILiveEntityWorldOriginCoordinator _origin; + private readonly ILiveEntityNetworkUpdateSink _networkUpdates; + private readonly Action _publishTimestamps; + private readonly Func _playerGuid; + private readonly Action? _diagnostic; + + public LiveEntityHydrationController( + LiveEntityRuntime runtime, + ClientObjectTable objects, + object datLock, + ILiveEntityProjectionMaterializer materializer, + ILiveEntitySpawnRelationshipSink relationships, + ILiveEntityReadyPublisher ready, + ILiveEntityWorldOriginCoordinator origin, + ILiveEntityNetworkUpdateSink networkUpdates, + Action publishTimestamps, + Func playerGuid, + Action? diagnostic = null) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); + _materializer = materializer ?? throw new ArgumentNullException(nameof(materializer)); + _relationships = relationships ?? throw new ArgumentNullException(nameof(relationships)); + _ready = ready ?? throw new ArgumentNullException(nameof(ready)); + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _networkUpdates = networkUpdates ?? throw new ArgumentNullException(nameof(networkUpdates)); + _publishTimestamps = publishTimestamps + ?? throw new ArgumentNullException(nameof(publishTimestamps)); + _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _diagnostic = diagnostic; + } + + public void OnCreate(WorldSession.EntitySpawn spawn) + { + // DatCollection uses one mutable reader cursor shared with streaming. + // Registration, canonical reread, and projection construction remain + // one update-thread transaction under that lock. + lock (_datLock) + { + LiveEntityRegistrationResult registration = + _runtime.RegisterLiveEntity(spawn); + InboundCreateResult result = registration.Inbound; + if (result.Disposition is + AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration) + { + return; + } + + // An accepted retransmit can still be parked behind a retryable + // teardown tombstone. It owns no active record and therefore must + // not mutate retained qualities or route an update tail. + if (registration.Record is not { } record) + return; + ulong createIntegrationVersion = record.CreateIntegrationVersion; + + try + { + _publishTimestamps(spawn.Guid, result.Timestamps); + if (_runtime.IsCurrentCreateIntegration( + record, + createIntegrationVersion) + && ObjectTableWiring.ApplyEntitySpawn( + _objects, + spawn, + replaceGeneration: result.Disposition is + AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration, + accepting: () => _runtime.IsCurrentCreateIntegration( + record, + createIntegrationVersion)) + && _runtime.IsCurrentCreateIntegration( + record, + createIntegrationVersion)) + { + if (result.Disposition is + AcDream.Core.Physics.CreateObjectTimestampDisposition.ExistingGeneration) + { + if (registration.LogicalRegistrationCreated) + { + // A converged rollback tombstone can be repaired by + // a same-generation retransmit. Its accepted + // snapshot is already canonical, so construct from + // that snapshot rather than replaying a delta tail + // against owners that did not exist. + ProjectExact( + record, + result.Snapshot, + LiveProjectionPurpose.LogicalRegistration, + expectedCreateIntegrationVersion: + createIntegrationVersion); + } + else + { + if (result.SameGenerationEvents is { } refresh) + _networkUpdates.ApplySameGeneration(refresh); + + if (_runtime.IsCurrentCreateIntegration( + record, + createIntegrationVersion) + && (record.CreateProjectionSynchronizationPending + || !record.InitialHydrationCompleted)) + { + ProjectExact( + record, + record.Snapshot, + record.CreateProjectionSynchronizationPending + ? LiveProjectionPurpose.CreateSupersessionRecovery + : LiveProjectionPurpose.SpatialRecovery, + expectedCreateIntegrationVersion: + createIntegrationVersion); + } + } + } + else + { + ProjectExact( + record, + result.Snapshot, + LiveProjectionPurpose.LogicalRegistration, + expectedCreateIntegrationVersion: + createIntegrationVersion); + } + } + } + catch (Exception applyFailure) + when (registration.PriorGenerationCleanupFailure is not null) + { + throw new AggregateException( + $"Live entity 0x{spawn.Guid:X8} replacement cleanup and installation both failed.", + registration.PriorGenerationCleanupFailure, + applyFailure); + } + + if (registration.PriorGenerationCleanupFailure is { } cleanupFailure) + { + throw new AggregateException( + $"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.", + cleanupFailure); + } + } + } + + /// + /// Reprojects retained live objects after their landblock is loaded. An + /// fully hydrated record only rebuckets. A retained partial projection + /// resumes its exact initial transaction without registering logical + /// mesh/script resources a second time. + /// + public void OnLandblockLoaded(uint loadedLandblockId) + { + if (_runtime.Count == 0) + return; + + uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu; + LiveEntityRecord[] records = _runtime.Records + .Where(record => (record.ServerGuid != _playerGuid() + || !record.InitialHydrationCompleted + || record.CreateProjectionSynchronizationPending) + && record.ProjectionCellId != 0 + && ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical + && record.Snapshot.SetupTableId is not null) + .ToArray(); + if (records.Length == 0) + return; + + int projected = 0; + lock (_datLock) + { + foreach (LiveEntityRecord record in records) + { + if (!_runtime.IsCurrentRecord(record)) + continue; + + if (record.CreateProjectionSynchronizationPending) + { + if (ProjectExact( + record, + record.Snapshot, + LiveProjectionPurpose.CreateSupersessionRecovery)) + { + projected++; + } + } + else if (record.WorldEntity is not null + && record.InitialHydrationCompleted) + { + if (_runtime.RebucketLiveEntity( + record.ServerGuid, + record.ProjectionCellId)) + { + projected++; + } + } + else if (ProjectExact( + record, + record.Snapshot, + LiveProjectionPurpose.SpatialRecovery)) + { + projected++; + } + } + } + + if (projected > 0) + { + _diagnostic?.Invoke( + $"live: re-projected {projected} server object(s) into landblock 0x{loadedLandblockId:X8}"); + } + } + + public bool EnsureWorldOrigin( + LiveEntityRecord expectedRecord, + ulong positionAuthorityVersion, + WorldSession.EntitySpawn acceptedSpawn) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + lock (_datLock) + { + if (!_runtime.IsCurrentPositionAuthority( + expectedRecord, + positionAuthorityVersion)) + { + return false; + } + + InitializeOriginAndRecoverLoaded(acceptedSpawn); + return _runtime.IsCurrentPositionAuthority( + expectedRecord, + positionAuthorityVersion); + } + } + + public bool RecoverProjection( + LiveEntityRecord expectedRecord, + ulong positionAuthorityVersion, + WorldSession.EntitySpawn acceptedSpawn) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + lock (_datLock) + { + if (!_runtime.IsCurrentPositionAuthority( + expectedRecord, + positionAuthorityVersion)) + { + return false; + } + + return ProjectExact( + expectedRecord, + acceptedSpawn, + expectedRecord.CreateProjectionSynchronizationPending + ? LiveProjectionPurpose.CreateSupersessionRecovery + : LiveProjectionPurpose.SpatialRecovery) + && _runtime.IsCurrentPositionAuthority( + expectedRecord, + positionAuthorityVersion); + } + } + + public bool ApplyAppearance( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn acceptedSpawn, + LiveEntityAppearanceUpdateState? appearanceUpdate) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + lock (_datLock) + { + return ProjectExact( + expectedRecord, + acceptedSpawn, + appearanceUpdate is null + ? LiveProjectionPurpose.SpatialRecovery + : LiveProjectionPurpose.AppearanceMutation, + appearanceUpdate); + } + } + + public bool OnEntityReady(LiveEntityReadyCandidate candidate) + { + if (!PublishReady( + candidate.Record, + candidate.CreateIntegrationVersion)) + { + return false; + } + + return !candidate.Record.CreateProjectionSynchronizationPending + || _runtime.TryCompleteCreateProjectionSynchronization( + candidate.Record, + candidate.CreateIntegrationVersion); + } + + public void ResetSessionState() => _materializer.ResetSessionState(); + + private bool ProjectExact( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn acceptedSpawn, + LiveProjectionPurpose purpose, + LiveEntityAppearanceUpdateState? appearanceUpdate = null, + ulong? expectedCreateIntegrationVersion = null) + { + while (true) + { + ulong createIntegrationVersion = expectedCreateIntegrationVersion + ?? expectedRecord.CreateIntegrationVersion; + if (purpose is LiveProjectionPurpose.CreateSupersessionRecovery + && !_runtime.TryMarkCreateProjectionSynchronizationPending( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + if (!_runtime.TryBeginProjectionHydration( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + + bool retry; + bool result; + try + { + result = ProjectExactOnce( + expectedRecord, + acceptedSpawn, + purpose, + appearanceUpdate, + createIntegrationVersion); + } + finally + { + retry = _runtime.EndProjectionHydration(expectedRecord); + } + + if (!retry || !_runtime.IsCurrentRecord(expectedRecord)) + return result; + + // A fresher same-incarnation CreateObject arrived while this + // transaction owned the hydration edge. Resume in-place from the + // newest canonical snapshot; logical resources remain registered. + acceptedSpawn = expectedRecord.Snapshot; + purpose = LiveProjectionPurpose.CreateSupersessionRecovery; + appearanceUpdate = null; + expectedCreateIntegrationVersion = + expectedRecord.CreateIntegrationVersion; + } + } + + private bool ProjectExactOnce( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn acceptedSpawn, + LiveProjectionPurpose purpose, + LiveEntityAppearanceUpdateState? appearanceUpdate, + ulong createIntegrationVersion) + { + _relationships.OnSpawn(acceptedSpawn); + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion)) + return false; + + // A queued Parent event can synchronously mutate the canonical + // Position/Parent snapshot. Never continue from the stale argument. + WorldSession.EntitySpawn canonicalSpawn = expectedRecord.Snapshot; + InitializeOriginAndRecoverLoaded(canonicalSpawn); + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion) + || !_origin.IsKnown) + { + return false; + } + + // Retail's same-instance CreateObject branch completes a POSITION_TS + // with no world Position through DoParentEvent or DoPickupEvent, not + // through CPhysicsObj::enter_world. The relationship/update owners + // above have already committed that cell-less state. An attached + // child discharges the obligation at its exact EntityReady edge; + // pickup completes here after exact projection withdrawal. + if (canonicalSpawn.Position is null) + { + if (canonicalSpawn.ParentGuid is not null and not 0) + { + if (expectedRecord.InitialHydrationCompleted + && !expectedRecord.CreateProjectionSynchronizationPending) + { + return _runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion); + } + + if (expectedRecord.WorldEntity is null + || expectedRecord.ProjectionKind is not + LiveEntityProjectionKind.Attached + || !expectedRecord.IsSpatiallyProjected + || !PublishReady( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + + return !expectedRecord.CreateProjectionSynchronizationPending + || _runtime.TryCompleteCreateProjectionSynchronization( + expectedRecord, + createIntegrationVersion); + } + + if (expectedRecord.FullCellId != 0 + || expectedRecord.IsSpatiallyProjected) + { + return false; + } + + if (expectedRecord.WorldEntity is null) + { + // A pickup can supersede initial hydration before a render + // projection exists. The logical snapshot/table state is + // already canonical; ready remains false so a later world + // Position still constructs and publishes the first owner. + return !expectedRecord.CreateProjectionSynchronizationPending + || _runtime.TryCompleteCreateProjectionSynchronization( + expectedRecord, + createIntegrationVersion); + } + + if (!PublishReady( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + + return !expectedRecord.CreateProjectionSynchronizationPending + || _runtime.TryCompleteCreateProjectionSynchronization( + expectedRecord, + createIntegrationVersion); + } + + bool materialized = _materializer.TryMaterialize( + expectedRecord, + expectedRecord.Snapshot, + purpose, + createIntegrationVersion, + appearanceUpdate); + if (!materialized + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion) + || purpose is LiveProjectionPurpose.AppearanceMutation) + { + return materialized; + } + + if (!PublishReady( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + + return purpose is not LiveProjectionPurpose.CreateSupersessionRecovery + || _runtime.TryCompleteCreateProjectionSynchronization( + expectedRecord, + createIntegrationVersion); + } + + private bool PublishReady( + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion) + { + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion) + || !_ready.Publish(expectedRecord) + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + + return _runtime.TryMarkInitialHydrationCompleted( + expectedRecord, + expectedCreateIntegrationVersion) + && _runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion); + } + + private void InitializeOriginAndRecoverLoaded( + WorldSession.EntitySpawn canonicalSpawn) + { + LiveEntityOriginInitialization initialization = + _origin.TryInitialize(canonicalSpawn); + if (!initialization.IsKnown) + return; + + // Preserve SmartBox's blocking world-entry ordering: already-resident + // cells are made available before the outer CreateObject completes. + for (int i = 0; i < initialization.AlreadyLoadedLandblocks.Count; i++) + OnLandblockLoaded(initialization.AlreadyLoadedLandblocks[i]); + } +} diff --git a/src/AcDream.App/World/LiveEntityHydrationPorts.cs b/src/AcDream.App/World/LiveEntityHydrationPorts.cs new file mode 100644 index 00000000..be5f9007 --- /dev/null +++ b/src/AcDream.App/World/LiveEntityHydrationPorts.cs @@ -0,0 +1,150 @@ +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.Core.Net; + +namespace AcDream.App.World; + +internal sealed class DelegateLiveEntitySpawnRelationshipSink( + Action onSpawn) : ILiveEntitySpawnRelationshipSink +{ + private readonly Action _onSpawn = + onSpawn ?? throw new ArgumentNullException(nameof(onSpawn)); + + public void OnSpawn(WorldSession.EntitySpawn spawn) => _onSpawn(spawn); +} + +internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher +{ + private readonly LiveEntityRuntime _runtime; + private readonly Func _prepareEffects; + private readonly Func _presentState; + private readonly Func _replayEffects; + + public LiveEntityReadyPublisher( + LiveEntityRuntime runtime, + EntityEffectController effects, + LiveEntityPresentationController presentation) + : this( + runtime, + record => effects.PrepareLiveEntityOwner(record.ServerGuid), + record => presentation.OnLiveEntityReady(record.ServerGuid), + record => effects.ReplayPendingForLiveEntity(record.ServerGuid)) + { + ArgumentNullException.ThrowIfNull(effects); + ArgumentNullException.ThrowIfNull(presentation); + } + + internal LiveEntityReadyPublisher( + LiveEntityRuntime runtime, + Func prepareEffects, + Func presentState, + Func replayEffects) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _prepareEffects = prepareEffects + ?? throw new ArgumentNullException(nameof(prepareEffects)); + _presentState = presentState + ?? throw new ArgumentNullException(nameof(presentState)); + _replayEffects = replayEffects + ?? throw new ArgumentNullException(nameof(replayEffects)); + } + + public bool Publish(LiveEntityRecord expectedRecord) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + ulong createIntegrationVersion = expectedRecord.CreateIntegrationVersion; + if (!_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion) + || !_prepareEffects(expectedRecord) + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion)) + return false; + + if (!_presentState(expectedRecord) + || !_runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion)) + { + return false; + } + + return _replayEffects(expectedRecord) + && _runtime.IsCurrentCreateIntegration( + expectedRecord, + createIntegrationVersion); + } +} + +/// +/// Converts the first accepted local-player Position into the shared streaming +/// origin. Retail SmartBox::UseTime @ 0x00455410 blocks completion until +/// destination cells are ready; already-resident landblocks are returned to +/// hydration for the same synchronous recovery edge. +/// +internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginCoordinator +{ + private readonly LiveWorldOriginState _origin; + private readonly StreamingController _streaming; + private readonly GpuWorldState _worldState; + private readonly WorldRevealCoordinator _worldReveal; + private readonly Func _playerGuid; + private readonly Func _isSealedDungeonCell; + private readonly Action? _diagnostic; + + public LiveEntityWorldOriginCoordinator( + LiveWorldOriginState origin, + StreamingController streaming, + GpuWorldState worldState, + WorldRevealCoordinator worldReveal, + Func playerGuid, + Func isSealedDungeonCell, + Action? diagnostic = null) + { + _origin = origin ?? throw new ArgumentNullException(nameof(origin)); + _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); + _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); + _worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal)); + _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); + _isSealedDungeonCell = isSealedDungeonCell + ?? throw new ArgumentNullException(nameof(isSealedDungeonCell)); + _diagnostic = diagnostic; + } + + public bool IsKnown => _origin.IsKnown; + + public LiveEntityOriginInitialization TryInitialize( + WorldSession.EntitySpawn spawn) + { + if (spawn.Guid != _playerGuid() + || spawn.Position is not { } position) + { + return new(_origin.IsKnown, Array.Empty()); + } + + int lbX = (int)((position.LandblockId >> 24) & 0xFFu); + int lbY = (int)((position.LandblockId >> 16) & 0xFFu); + int oldCenterX = _origin.CenterX; + int oldCenterY = _origin.CenterY; + if (!_origin.TryInitialize(lbX, lbY)) + return new(true, Array.Empty()); + + if (lbX != oldCenterX || lbY != oldCenterY) + { + _diagnostic?.Invoke( + $"live: first player position — recentering streaming from ({oldCenterX},{oldCenterY}) " + + $"to ({lbX},{lbY}) @0x{position.LandblockId:X8}"); + } + + _streaming.InitializeKnownLoginCenter( + lbX, + lbY, + isSealedDungeon: _isSealedDungeonCell(position.LandblockId)); + _worldReveal.Begin(WorldRevealKind.Login); + + return new( + true, + _worldState.LoadedLandblockIds.ToArray()); + } +} diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index e544e380..0e8f33a2 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -258,7 +258,32 @@ public sealed class LiveEntityRecord internal ulong VectorAuthorityVersion { get; private set; } internal ulong VelocityAuthorityVersion { get; private set; } internal ulong MovementAuthorityVersion { get; private set; } + /// + /// Advances for every accepted CreateObject affecting this incarnation, + /// including a fresher same-INSTANCE_TS retransmit. App integration uses + /// it to distinguish two transactions that intentionally share identity. + /// + internal ulong CreateIntegrationVersion { get; private set; } = 1UL; public bool WorldSpawnPublished { get; internal set; } + /// + /// True only after the incarnation's projection, collision/animation + /// components, effect owner, state presentation, and pending-effect replay + /// have crossed the initial ready barrier. A non-null + /// alone is not sufficient: failures after + /// resource registration retain the same projection for exact retry. + /// + public bool InitialHydrationCompleted { get; internal set; } + /// + /// A fresher same-incarnation CreateObject interrupted projection after + /// logical ownership had already been established. The retained entity + /// must replay the newest canonical appearance/current snapshot/animation + /// before ordinary rebucketing can resume. This obligation deliberately + /// survives a failed recovery attempt. + /// + internal bool CreateProjectionSynchronizationPending { get; set; } + internal bool ProjectionHydrationInProgress { get; set; } + internal ulong ProjectionHydrationCreateVersion { get; set; } + internal bool ProjectionHydrationRetryRequested { get; set; } public LiveEntityProjectionKind ProjectionKind { get; internal set; } internal bool RuntimeComponentsTeardownCompleted { get; set; } internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; } @@ -320,6 +345,7 @@ public sealed class LiveEntityRecord VectorAuthorityVersion++; VelocityAuthorityVersion++; MovementAuthorityVersion++; + CreateIntegrationVersion++; } internal void SetChildNoDraw(bool noDraw) @@ -2031,6 +2057,111 @@ public sealed class LiveEntityRuntime return true; } + internal bool TryMarkInitialHydrationCompleted( + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (!IsCurrentRecord(expectedRecord) + || expectedRecord.CreateIntegrationVersion != expectedCreateIntegrationVersion + || expectedRecord.WorldEntity is null + || !expectedRecord.ResourcesRegistered) + { + return false; + } + + expectedRecord.InitialHydrationCompleted = true; + return true; + } + + internal bool TryMarkCreateProjectionSynchronizationPending( + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (!IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + + expectedRecord.CreateProjectionSynchronizationPending = true; + return true; + } + + internal bool TryCompleteCreateProjectionSynchronization( + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (!IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + + expectedRecord.CreateProjectionSynchronizationPending = false; + return true; + } + + internal bool TryBeginProjectionHydration( + LiveEntityRecord expectedRecord, + ulong? expectedCreateIntegrationVersion) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (!IsCurrentRecord(expectedRecord) + || (expectedCreateIntegrationVersion is { } expected + && expectedRecord.CreateIntegrationVersion != expected)) + { + return false; + } + + if (expectedRecord.ProjectionHydrationInProgress) + { + if (expectedRecord.CreateIntegrationVersion + != expectedRecord.ProjectionHydrationCreateVersion) + { + expectedRecord.ProjectionHydrationRetryRequested = true; + } + return false; + } + + expectedRecord.ProjectionHydrationInProgress = true; + expectedRecord.ProjectionHydrationCreateVersion = + expectedRecord.CreateIntegrationVersion; + expectedRecord.ProjectionHydrationRetryRequested = false; + return true; + } + + internal bool EndProjectionHydration(LiveEntityRecord expectedRecord) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + bool retry = IsCurrentRecord(expectedRecord) + && (expectedRecord.ProjectionHydrationRetryRequested + || expectedRecord.CreateIntegrationVersion + != expectedRecord.ProjectionHydrationCreateVersion); + if (retry) + { + // Persist the exact replay obligation at the point version drift + // is observed. The stale projection body may be unwinding through + // an exception and therefore never reach the controller's next + // retry-loop iteration. + expectedRecord.CreateProjectionSynchronizationPending = true; + } + expectedRecord.ProjectionHydrationInProgress = false; + expectedRecord.ProjectionHydrationCreateVersion = 0UL; + expectedRecord.ProjectionHydrationRetryRequested = false; + return retry; + } + + internal bool IsCurrentCreateIntegration( + LiveEntityRecord expectedRecord, + ulong expectedCreateIntegrationVersion) => + IsCurrentRecord(expectedRecord) + && expectedRecord.CreateIntegrationVersion == expectedCreateIntegrationVersion; + public void Clear() { if (_isClearing || _isRegisteringResources) @@ -2586,6 +2717,11 @@ public sealed class LiveEntityRuntime record.IsSpatiallyProjected = false; record.IsSpatiallyVisible = false; record.WorldSpawnPublished = false; + record.InitialHydrationCompleted = false; + record.CreateProjectionSynchronizationPending = false; + record.ProjectionHydrationInProgress = false; + record.ProjectionHydrationCreateVersion = 0UL; + record.ProjectionHydrationRetryRequested = false; record.WorldEntity = null; } finally diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index b204bb5c..17ba4e5b 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -101,16 +101,31 @@ public static class ObjectTableWiring /// owning runtime accepts its physics generation. Internal for a /// socket-free conformance test of the subscription body. /// - public static void ApplyEntitySpawn( + public static bool ApplyEntitySpawn( ClientObjectTable table, WorldSession.EntitySpawn spawn, - bool replaceGeneration = false) + bool replaceGeneration = false, + Func? accepting = null) { + ArgumentNullException.ThrowIfNull(table); + if (accepting?.Invoke() == false) + return false; + WeenieData data = ToWeenieData(spawn); if (replaceGeneration) - table.ReplaceGeneration(data, spawn.InstanceSequence); + { + return table.ReplaceGeneration( + data, + spawn.InstanceSequence, + accepting) is not null; + } else table.Ingest(data); + + // Ingest publishes ObjectAdded/ObjectUpdated synchronously. A nested + // replacement wins; report the invalidated outer application so its + // remaining CreateObject tail cannot run. + return accepting?.Invoke() != false; } /// diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 0093e68b..3a5c3d87 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -576,7 +576,10 @@ public sealed class ClientObjectTable /// Atomically replaces the retained qualities for a newer server object /// incarnation while publishing a generation-specific teardown event. /// - public ClientObject ReplaceGeneration(WeenieData data, ushort generation) + public ClientObject? ReplaceGeneration( + WeenieData data, + ushort generation, + Func? canInstallReplacement = null) { RemoveCore( data.Guid, @@ -584,6 +587,13 @@ public sealed class ClientObjectTable generation, notifyObjectRemoved: true); + // Removal observers may synchronously accept a newer CreateObject for + // this GUID. The outer generation must not resume and merge its stale + // qualities into that replacement. The authoritative live-identity + // owner supplies the exact-incarnation predicate. + if (canInstallReplacement?.Invoke() == false) + return null; + return Ingest(data); } diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs new file mode 100644 index 00000000..6ceb0d5c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs @@ -0,0 +1,439 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.App.Tests.Rendering; + +public sealed class LiveEntityCreateSupersessionRecoveryTests +{ + [Fact] + public void Recovery_RepublishesAppearanceThenCurrentSnapshotThenReplacesAnimation() + { + var operations = new List(); + var resources = new CountingResources(); + var runtime = Runtime(resources); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + ulong version = record.CreateIntegrationVersion; + ulong installedAnimationVersion = 0; + + bool recovered = LiveEntityCreateSupersessionRecovery.TryApply( + runtime, + record, + version, + captureAppearance: () => + { + operations.Add("capture"); + return new LiveEntityAppearanceUpdateState(record.WorldEntity!, null); + }, + publishAppearance: _ => + { + operations.Add("appearance"); + return true; + }, + publishCurrentSnapshot: _ => operations.Add("current"), + synchronizeAnimation: _ => + { + operations.Add("animation"); + installedAnimationVersion = version; + }); + + Assert.True(recovered); + Assert.Equal(["capture", "appearance", "current", "animation"], operations); + Assert.Equal(version, installedAnimationVersion); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + } + + [Fact] + public void Recovery_CreateVersionAdvanceDuringAppearance_StopsLaterOwners() + { + var operations = new List(); + var resources = new CountingResources(); + var runtime = Runtime(resources); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + ulong staleVersion = record.CreateIntegrationVersion; + + bool recovered = LiveEntityCreateSupersessionRecovery.TryApply( + runtime, + record, + staleVersion, + captureAppearance: () => + { + operations.Add("capture"); + return new LiveEntityAppearanceUpdateState(record.WorldEntity!, null); + }, + publishAppearance: _ => + { + operations.Add("appearance"); + runtime.RegisterLiveEntity(Spawn() with { Name = "fresher" }); + return true; + }, + publishCurrentSnapshot: _ => operations.Add("current"), + synchronizeAnimation: _ => operations.Add("animation")); + + Assert.False(recovered); + Assert.Equal(["capture", "appearance"], operations); + Assert.Same(record, AssertRecord(runtime)); + Assert.Equal(staleVersion + 1, record.CreateIntegrationVersion); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Recovery_FailureAtEachPublicationStage_CanReplayOnRetainedOwner( + int failingStage) + { + var resources = new CountingResources(); + var runtime = Runtime(resources); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + ulong version = record.CreateIntegrationVersion; + ulong appearanceVersion = 1; + ulong currentVersion = 1; + ulong animationVersion = 1; + bool fail = true; + void FailOnce(int stage) + { + if (fail && failingStage == stage) + { + fail = false; + throw new InvalidOperationException($"fixture stage {stage} failure"); + } + } + + bool Apply() => LiveEntityCreateSupersessionRecovery.TryApply( + runtime, + record, + version, + captureAppearance: () => + new LiveEntityAppearanceUpdateState(record.WorldEntity!, null), + publishAppearance: _ => + { + appearanceVersion = version; + FailOnce(0); + return true; + }, + publishCurrentSnapshot: _ => + { + currentVersion = version; + FailOnce(1); + }, + synchronizeAnimation: _ => + { + animationVersion = version; + FailOnce(2); + }); + + Assert.Throws(() => Apply()); + Assert.True(Apply()); + + Assert.Equal(version, appearanceVersion); + Assert.Equal(version, currentVersion); + Assert.Equal(version, animationVersion); + Assert.Same(record, AssertRecord(runtime)); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(0, resources.UnregisterCount); + } + + [Fact] + public void CompletedOwner_PreservesSequencerSinkAndPendingQueue() + { + var runtime = Runtime(new CountingResources()); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + record.InitialHydrationCompleted = true; + (Setup setup, MotionTable table, Loader loader) = MotionAssets(); + AnimationSequencer sequencer = SpawnMotionInitializer.Create( + setup, + table, + loader, + Wire(Ready)); + var animation = AnimationState(record.WorldEntity!, setup, sequencer); + var body = MotionBody(); + var interpreter = new MotionInterpreter(body); + var sink = new MotionTableDispatchSink(sequencer); + interpreter.DefaultSink = sink; + sequencer.MotionDoneTarget = interpreter.MotionDone; + interpreter.DoMotion(Walk, new MovementParameters()); + int pendingBefore = sequencer.Manager.PendingAnimations.Count(); + + bool reinitialized = LiveEntityCreateAnimationSynchronization + .TrySynchronizeInterruptedInitialOwner( + record, + animation, + canonicalAnimation: null, + canonicalLowFrame: 0, + canonicalHighFrame: 0, + canonicalFramerate: 0f, + motionTable: table, + wireState: Wire(Ready)); + + Assert.False(reinitialized); + Assert.Same(sequencer, animation.Sequencer); + Assert.Equal(pendingBefore, sequencer.Manager.PendingAnimations.Count()); + Assert.True(interpreter.MotionsPending()); + Assert.True(sink.ApplyMotion(Ready, 1f)); + Assert.Equal(Ready, sequencer.CurrentMotion); + } + + [Fact] + public void CompletedLegacyOwner_PreservesAnimationAndCurrentPhase() + { + var runtime = Runtime(new CountingResources()); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + record.InitialHydrationCompleted = true; + var setup = new Setup(); + var retainedAnimation = new Animation(); + var canonicalAnimation = new Animation(); + var state = new LiveEntityAnimationState + { + Entity = record.WorldEntity!, + Setup = setup, + Animation = retainedAnimation, + LowFrame = 2, + HighFrame = 11, + Framerate = 12f, + Scale = 1f, + PartTemplate = [], + PartAvailability = [], + CurrFrame = 7.25f, + Sequencer = null, + }; + + bool synchronized = LiveEntityCreateAnimationSynchronization + .TrySynchronizeInterruptedInitialOwner( + record, + state, + canonicalAnimation, + canonicalLowFrame: 0, + canonicalHighFrame: 4, + canonicalFramerate: 30f, + motionTable: null, + wireState: null); + + Assert.False(synchronized); + Assert.Same(retainedAnimation, state.Animation); + Assert.Equal(2, state.LowFrame); + Assert.Equal(11, state.HighFrame); + Assert.Equal(12f, state.Framerate); + Assert.Equal(7.25f, state.CurrFrame); + } + + [Fact] + public void InterruptedInitialOwner_ReinitializesSameSequencerAndDrainsPairedQueues() + { + var runtime = Runtime(new CountingResources()); + LiveEntityRecord record = RegisterAndMaterialize(runtime); + Assert.False(record.InitialHydrationCompleted); + (Setup setup, MotionTable table, Loader loader) = MotionAssets(); + AnimationSequencer sequencer = SpawnMotionInitializer.Create( + setup, + table, + loader, + Wire(Ready)); + var animation = AnimationState(record.WorldEntity!, setup, sequencer); + var body = MotionBody(); + var interpreter = new MotionInterpreter(body); + var sink = new MotionTableDispatchSink(sequencer); + interpreter.DefaultSink = sink; + sequencer.MotionDoneTarget = interpreter.MotionDone; + interpreter.DoMotion(Walk, new MovementParameters()); + Assert.True(interpreter.MotionsPending()); + Assert.NotEmpty(sequencer.Manager.PendingAnimations); + + bool reinitialized = LiveEntityCreateAnimationSynchronization + .TrySynchronizeInterruptedInitialOwner( + record, + animation, + canonicalAnimation: null, + canonicalLowFrame: 0, + canonicalHighFrame: 0, + canonicalFramerate: 0f, + motionTable: table, + wireState: Wire(Ready)); + + Assert.True(reinitialized); + Assert.Same(sequencer, animation.Sequencer); + Assert.False(interpreter.MotionsPending()); + Assert.Empty(sequencer.Manager.PendingAnimations); + Assert.Equal(Ready, sequencer.CurrentMotion); + Assert.True(sink.ApplyMotion(Walk, 1f)); + Assert.Equal(Walk, sequencer.CurrentMotion); + } + + private const uint Guid = 0x70000061u; + private const uint Cell = 0x01010001u; + private const uint NonCombat = 0x8000003Du; + private const uint Ready = 0x41000003u; + private const uint Walk = 0x45000005u; + private const uint ReadyAnimation = 0x03000001u; + private const uint WalkAnimation = 0x03000002u; + + private static LiveEntityRuntime Runtime(CountingResources resources) + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty())); + return new LiveEntityRuntime( + spatial, + resources, + new DelegateLiveEntityRuntimeComponentLifecycle(_ => { })); + } + + private static LiveEntityRecord RegisterAndMaterialize(LiveEntityRuntime runtime) + { + LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn()).Record!; + runtime.MaterializeLiveEntity( + Guid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [], + ParentCellId = Cell, + }); + return record; + } + + private static LiveEntityRecord AssertRecord(LiveEntityRuntime runtime) + { + Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + return record; + } + + private static WorldSession.EntitySpawn Spawn() => new( + Guid, + new CreateObject.ServerPosition( + Cell, + 0f, + 0f, + 0f, + 1f, + 0f, + 0f, + 0f), + 0x02000001u, + [], + [], + [], + BasePaletteId: null, + ObjScale: null, + Name: "supersession fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: 1); + + private static LiveEntityAnimationState AnimationState( + WorldEntity entity, + Setup setup, + AnimationSequencer sequencer) => new() + { + Entity = entity, + Setup = setup, + Animation = new Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = 1f, + PartTemplate = [], + PartAvailability = [], + Sequencer = sequencer, + }; + + private static PhysicsBody MotionBody() => new() + { + InWorld = true, + State = PhysicsStateFlags.ReportCollisions, + TransientState = TransientStateFlags.Contact + | TransientStateFlags.OnWalkable + | TransientStateFlags.Active, + }; + + private static CreateObject.ServerMotionState Wire(uint command) => new( + Stance: (ushort)(NonCombat & 0xFFFFu), + ForwardCommand: (ushort)(command & 0xFFFFu)); + + private static (Setup Setup, MotionTable Table, Loader Loader) MotionAssets() + { + var setup = new Setup(); + setup.Parts.Add(0x01000001u); + setup.DefaultScale.Add(Vector3.One); + var loader = new Loader(); + loader.Add(ReadyAnimation, AnimationWithFrames(4)); + loader.Add(WalkAnimation, AnimationWithFrames(6)); + var table = new MotionTable + { + DefaultStyle = (DRWMotionCommand)NonCombat, + }; + table.StyleDefaults[(DRWMotionCommand)NonCombat] = + (DRWMotionCommand)Ready; + table.Cycles[(int)((NonCombat << 16) | (Ready & 0xFFFFFFu))] = + Motion(ReadyAnimation); + table.Cycles[(int)((NonCombat << 16) | (Walk & 0xFFFFFFu))] = + Motion(WalkAnimation); + return (setup, table, loader); + } + + private static Animation AnimationWithFrames(int count) + { + var animation = new Animation(); + for (int i = 0; i < count; i++) + { + var frame = new AnimationFrame(1); + frame.Frames.Add(new Frame + { + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }); + animation.PartFrames.Add(frame); + } + return animation; + } + + private static MotionData Motion(uint animationId) + { + var data = new MotionData(); + QualifiedDataId qualified = animationId; + data.Anims.Add(new AnimData + { + AnimId = qualified, + LowFrame = 0, + HighFrame = -1, + Framerate = 30f, + }); + return data; + } + + private sealed class Loader : IAnimationLoader + { + private readonly Dictionary _animations = []; + public void Add(uint id, Animation animation) => _animations[id] = animation; + public Animation? LoadAnimation(uint id) => + _animations.GetValueOrDefault(id); + } + + private sealed class CountingResources : ILiveEntityResourceLifecycle + { + public int RegisterCount { get; private set; } + public int UnregisterCount { get; private set; } + public void Register(WorldEntity entity) => RegisterCount++; + public void Unregister(WorldEntity entity) => UnregisterCount++; + } +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 98b177b1..6aecc398 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -38,6 +38,7 @@ public sealed class RuntimeOptionsTests // Default-on: RetailCloseDegrades is true unless explicitly disabled. Assert.True(opts.RetailCloseDegrades); Assert.False(opts.DumpSceneryZ); + Assert.False(opts.DumpClothing); Assert.Null(opts.LegacyStreamRadius); Assert.False(opts.UiProbeDump); Assert.Null(opts.UiProbeScript); @@ -190,6 +191,7 @@ public sealed class RuntimeOptionsTests ["ACDREAM_NO_AUDIO"] = "1", ["ACDREAM_ENABLE_SKY_PES"] = "1", ["ACDREAM_DUMP_SCENERY_Z"] = "1", + ["ACDREAM_DUMP_CLOTHING"] = "1", })); Assert.True(allOn.DevTools); Assert.True(allOn.UncappedRendering); @@ -197,6 +199,7 @@ public sealed class RuntimeOptionsTests Assert.True(allOn.NoAudio); Assert.True(allOn.EnableSkyPesDebug); Assert.True(allOn.DumpSceneryZ); + Assert.True(allOn.DumpClothing); // Any non-"1" value leaves them off, matching the // string.Equals(env, "1", StringComparison.Ordinal) check. @@ -208,6 +211,7 @@ public sealed class RuntimeOptionsTests ["ACDREAM_NO_AUDIO"] = "2", ["ACDREAM_ENABLE_SKY_PES"] = "on", ["ACDREAM_DUMP_SCENERY_Z"] = " 1", + ["ACDREAM_DUMP_CLOTHING"] = "true", })); Assert.False(anyOther.DevTools); Assert.False(anyOther.UncappedRendering); @@ -215,6 +219,7 @@ public sealed class RuntimeOptionsTests Assert.False(anyOther.NoAudio); Assert.False(anyOther.EnableSkyPesDebug); Assert.False(anyOther.DumpSceneryZ); + Assert.False(anyOther.DumpClothing); } [Fact] diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs index 7cf0d0c6..354a888e 100644 --- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -17,6 +17,11 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData("LeaveWorldLiveEntityRuntimeComponents")] [InlineData("WithdrawLiveEntityWorldProjection")] [InlineData("RebindAnimatedEntityForAppearance")] + [InlineData("OnLiveEntitySpawned")] + [InlineData("OnLiveEntitySpawnedLocked")] + [InlineData("RehydrateServerEntitiesForLandblock")] + [InlineData("TryInitializeLiveCenter")] + [InlineData("CompleteLiveEntityReady")] public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName) { Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation)); @@ -36,6 +41,8 @@ public sealed class GameWindowLiveEntityCompositionTests [InlineData(typeof(LiveEntityProjectionWithdrawalController))] [InlineData(typeof(LocalPlayerShadowState))] [InlineData(typeof(LiveEntityAppearanceBinding))] + [InlineData(typeof(LiveEntityHydrationController))] + [InlineData(typeof(DatLiveEntityProjectionMaterializer))] public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType) { foreach (FieldInfo field in helperType.GetFields( diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs new file mode 100644 index 00000000..9cff0e04 --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -0,0 +1,1413 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntityHydrationControllerTests +{ + [Fact] + public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization() + { + using var fixture = new Fixture(originKnown: true); + WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1); + fixture.Materializer.BeforeMaterialize = (_, _) => + Assert.NotNull(fixture.Objects.Get(Guid)); + + fixture.Controller.OnCreate(spawn); + + Assert.Equal( + ["timestamps:1", "relationship:1", "materialize:LogicalRegistration:1", "ready"], + fixture.Operations); + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + Assert.NotNull(record.WorldEntity); + Assert.True(record.InitialHydrationCompleted); + Assert.Equal(1, fixture.Resources.RegisterCount); + } + + [Fact] + public void UnknownOrigin_DefersFirstMaterializationUntilLandblockRecovery() + { + using var fixture = new Fixture(originKnown: false); + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + Assert.Null(record.WorldEntity); + Assert.Equal(0, fixture.Resources.RegisterCount); + Assert.Empty(fixture.Materializer.Calls); + + fixture.Origin.IsKnownValue = true; + fixture.Controller.OnLandblockLoaded(Cell); + + Assert.NotNull(record.WorldEntity); + Assert.Single(fixture.Materializer.Calls); + Assert.Equal(LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[0].Purpose); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void InitialPlayerOriginRecovery_DoesNotReenterSameHydrationTransaction() + { + using var fixture = new Fixture(originKnown: true, playerGuid: Guid); + fixture.Origin.AlreadyLoadedLandblocks = [Cell]; + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + Assert.True(fixture.Record.InitialHydrationCompleted); + Assert.Single(fixture.Materializer.Calls); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void LoadedCallback_RebucketsMaterializedIdentityWithoutReactivation() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity entity = record.WorldEntity!; + + fixture.Controller.OnLandblockLoaded(Cell); + + Assert.Same(entity, record.WorldEntity); + Assert.Single(fixture.Materializer.Calls); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void AppearanceAfterDeferredProjection_FirstMaterializesAndPublishesReady() + { + using var fixture = new Fixture(originKnown: true); + fixture.Materializer.SkipMaterializationCount = 1; + WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1); + fixture.Controller.OnCreate(spawn); + LiveEntityRecord record = fixture.Record; + Assert.Null(record.WorldEntity); + + Assert.True(fixture.Controller.ApplyAppearance( + record, + record.Snapshot, + appearanceUpdate: null)); + + Assert.NotNull(record.WorldEntity); + Assert.Equal(2, fixture.Materializer.Calls.Count); + Assert.Equal(LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[1].Purpose); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void SameOlderNewerAndWrappedGenerations_RouteOrReplaceExactlyOnce() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 0xFFFF, PositionSequence: 1)); + LiveEntityRecord first = fixture.Record; + + fixture.Controller.OnCreate(Spawn(Generation: 0xFFFF, PositionSequence: 2)); + Assert.Equal(1, fixture.Network.ApplyCount); + Assert.Same(first, fixture.Record); + + fixture.Controller.OnCreate(Spawn(Generation: 0xFFFE, PositionSequence: 3)); + Assert.Equal(1, fixture.Network.ApplyCount); + Assert.Same(first, fixture.Record); + + fixture.Controller.OnCreate(Spawn(Generation: 0, PositionSequence: 4)); + + Assert.NotSame(first, fixture.Record); + Assert.Equal((ushort)0, fixture.Record.Generation); + Assert.Equal(2, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Resources.UnregisterCount); + Assert.Equal(2, fixture.Ready.PublishCount); + } + + [Fact] + public void RelationshipCallback_ReplacesGuidAndSupersedesOuterProjection() + { + using var fixture = new Fixture(originKnown: true); + bool replaced = false; + fixture.Relationships.OnSpawnAction = spawn => + { + if (!replaced && spawn.InstanceSequence == 1) + { + replaced = true; + fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 2)); + } + }; + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + Assert.Equal((ushort)2, fixture.Record.Generation); + Assert.Single(fixture.Materializer.Calls); + Assert.Equal((ushort)2, fixture.Materializer.Calls[0].Generation); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void RegistrationFailure_RollsBackAndLandblockRecoveryRetriesOnce() + { + var resources = new FailingOnceResources(); + using var fixture = new Fixture(originKnown: true, resources); + + Assert.Throws(() => + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1))); + + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + Assert.Null(record.WorldEntity); + Assert.Equal(1, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + + fixture.Controller.OnLandblockLoaded(Cell); + + Assert.NotNull(record.WorldEntity); + Assert.Equal(2, resources.RegisterCount); + Assert.Equal(1, resources.UnregisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void SameGenerationRetransmit_RecoversAfterRegistrationTombstoneConverges() + { + var resources = new FailingRegisterAndRollbackOnceResources(); + using var fixture = new Fixture(originKnown: true, resources); + WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1); + + Assert.Throws(() => fixture.Controller.OnCreate(spawn)); + Assert.Equal(0, fixture.Runtime.Count); + Assert.Equal(1, fixture.Runtime.PendingTeardownCount); + Assert.Equal(1, fixture.Runtime.RetryPendingTeardowns()); + + fixture.Controller.OnCreate(spawn); + + Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord recovered)); + Assert.NotNull(recovered.WorldEntity); + Assert.True(recovered.InitialHydrationCompleted); + Assert.Equal(2, resources.RegisterCount); + Assert.Equal(2, resources.UnregisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void PriorGenerationCleanupFailure_ReportsAfterReplacementIsFullyInstalled() + { + var resources = new FailingUnregisterOnceResources(); + using var fixture = new Fixture(originKnown: true, resources); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + Assert.Throws(() => fixture.Controller.OnCreate(Spawn( + Generation: 2, + PositionSequence: 2, + Name: "replacement"))); + + Assert.Equal((ushort)2, fixture.Record.Generation); + Assert.True(fixture.Record.InitialHydrationCompleted); + Assert.Equal("replacement", fixture.Objects.Get(Guid)!.Name); + Assert.Equal(1, fixture.Runtime.PendingTeardownCount); + Assert.Equal(1, fixture.Runtime.RetryPendingTeardowns()); + Assert.Equal(0, fixture.Runtime.PendingTeardownCount); + } + + [Fact] + public void PartialProjection_IsRetriedInsteadOfMistakenForCompletedHydration() + { + using var fixture = new Fixture(originKnown: true, playerGuid: Guid); + bool failOnce = true; + fixture.Materializer.AfterMaterialize = (_, _) => + { + if (!failOnce) + return; + failOnce = false; + throw new InvalidOperationException("fixture post-registration failure"); + }; + + Assert.Throws(() => + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1))); + + LiveEntityRecord record = fixture.Record; + WorldEntity partial = Assert.IsType(record.WorldEntity); + Assert.False(record.InitialHydrationCompleted); + + // Local-player records ordinarily do not rebucket from streaming + // callbacks; an incomplete initial transaction must still recover. + fixture.Controller.OnLandblockLoaded(Cell); + + Assert.Same(partial, record.WorldEntity); + Assert.True(record.InitialHydrationCompleted); + Assert.Equal(2, fixture.Materializer.Calls.Count); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void FailedReadyBarrier_RetriesSameProjectionOnRetransmit() + { + using var fixture = new Fixture(originKnown: true); + fixture.Ready.FailPublishCount = 1; + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity partial = Assert.IsType(record.WorldEntity); + Assert.False(record.InitialHydrationCompleted); + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 2)); + + Assert.Same(partial, record.WorldEntity); + Assert.True(record.InitialHydrationCompleted); + Assert.Equal(2, fixture.Materializer.Calls.Count); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(2, fixture.Ready.PublishCount); + } + + [Fact] + public void TimestampCallbackReplacement_StopsOuterObjectAndProjectionTail() + { + using var fixture = new Fixture(originKnown: true); + bool replaced = false; + fixture.PublishTimestampsAction = (_, timestamps) => + { + if (replaced || timestamps.Instance != 1) + return; + replaced = true; + fixture.Controller.OnCreate(Spawn( + Generation: 2, + PositionSequence: 2, + Name: "generation 2")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 1, + Name: "generation 1")); + + Assert.Equal((ushort)2, fixture.Record.Generation); + Assert.Equal("generation 2", fixture.Objects.Get(Guid)!.Name); + Assert.Single(fixture.Materializer.Calls); + Assert.Equal((ushort)2, fixture.Materializer.Calls[0].Generation); + } + + [Fact] + public void TimestampCallbackFresherSameGeneration_StopsOuterCreateVersion() + { + using var fixture = new Fixture(originKnown: true); + bool refreshed = false; + fixture.PublishTimestampsAction = (_, timestamps) => + { + if (refreshed) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "same generation newer")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 1, + Name: "same generation older")); + + Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); + Assert.True(fixture.Record.InitialHydrationCompleted); + Assert.Single(fixture.Materializer.Calls); + Assert.Equal((ushort)2, fixture.Materializer.PositionSequences[0]); + } + + [Fact] + public void ObjectRemovalCallbackNewerGeneration_CannotBeOverwrittenByOuterCreate() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 1, + Name: "generation 1")); + bool replaced = false; + fixture.Objects.ObjectRemovalClassified += removal => + { + if (replaced + || removal.Reason is not ClientObjectRemovalReason.GenerationReplacement) + { + return; + } + + replaced = true; + fixture.Controller.OnCreate(Spawn( + Generation: 3, + PositionSequence: 3, + Name: "generation 3")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 2, + PositionSequence: 2, + Name: "generation 2")); + + Assert.Equal((ushort)3, fixture.Record.Generation); + Assert.Equal("generation 3", fixture.Objects.Get(Guid)!.Name); + Assert.Equal((ushort)3, fixture.Materializer.Calls[^1].Generation); + Assert.DoesNotContain( + fixture.Materializer.Calls, + call => call.Generation == 2); + } + + [Fact] + public void ObjectRemovalCallbackFresherSameGeneration_WinsOuterReplacement() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn( + Generation: 0, + PositionSequence: 1, + Name: "generation zero")); + bool refreshed = false; + fixture.Objects.ObjectRemovalClassified += removal => + { + if (refreshed + || removal.Reason is not ClientObjectRemovalReason.GenerationReplacement) + { + return; + } + + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 3, + Name: "same generation newer")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "same generation older")); + + Assert.Equal((ushort)1, fixture.Record.Generation); + Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); + Assert.True(fixture.Record.InitialHydrationCompleted); + Assert.Equal((ushort)3, fixture.Materializer.PositionSequences[^1]); + Assert.DoesNotContain( + (ushort)2, + fixture.Materializer.PositionSequences.Skip(1)); + } + + [Fact] + public void MaterializerCallbackFresherSameGeneration_ReplaysCanonicalHydrationOnce() + { + using var fixture = new Fixture(originKnown: true); + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "same generation newer")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 1, + Name: "same generation older")); + + Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); + Assert.Equal([1, 2], fixture.Materializer.PositionSequences); + Assert.Equal( + LiveProjectionPurpose.CreateSupersessionRecovery, + fixture.Materializer.Calls[^1].Purpose); + Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + Assert.True(fixture.Record.InitialHydrationCompleted); + } + + [Fact] + public void ReadyCallbackFresherSameGeneration_ReplaysCanonicalHydrationBeforeCompletion() + { + using var fixture = new Fixture(originKnown: true); + bool refreshed = false; + fixture.Ready.BeforePublish = _ => + { + if (refreshed) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "same generation newer")); + }; + + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 1, + Name: "same generation older")); + + Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); + Assert.Equal([1, 2], fixture.Materializer.PositionSequences); + Assert.Equal( + LiveProjectionPurpose.CreateSupersessionRecovery, + fixture.Materializer.Calls[^1].Purpose); + Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(2, fixture.Ready.PublishCount); + Assert.True(fixture.Record.InitialHydrationCompleted); + } + + [Fact] + public void CompletedSpatialRecovery_DetectsCreateVersionDriftWithoutNestedHydrationRequest() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + ulong stalePositionAuthority = record.PositionAuthorityVersion; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "same generation newer")); + }; + + Assert.False(fixture.Controller.RecoverProjection( + record, + stalePositionAuthority, + record.Snapshot)); + + Assert.True(record.InitialHydrationCompleted); + Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name); + Assert.Equal([1, 1, 2], fixture.Materializer.PositionSequences); + Assert.Equal( + LiveProjectionPurpose.CreateSupersessionRecovery, + fixture.Materializer.Calls[^1].Purpose); + Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FailedCompletedSupersession_RemainsPendingUntilExactRetry( + bool retryFromLandblock) + { + using var fixture = new Fixture( + originKnown: true, + playerGuid: retryFromLandblock ? Guid : 0u); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity retained = record.WorldEntity!; + ulong positionAuthority = record.PositionAuthorityVersion; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "recovery v2")); + }; + fixture.Materializer.ThrowAfterMaterializePurposeOnce = + LiveProjectionPurpose.CreateSupersessionRecovery; + + Assert.Throws(() => + fixture.Controller.RecoverProjection( + record, + positionAuthority, + record.Snapshot)); + + Assert.True(record.InitialHydrationCompleted); + Assert.True(record.CreateProjectionSynchronizationPending); + Assert.Same(retained, record.WorldEntity); + Assert.Equal(1, fixture.Resources.RegisterCount); + + fixture.Materializer.BeforeMaterialize = null; + if (retryFromLandblock) + { + fixture.Controller.OnLandblockLoaded(Cell); + } + else + { + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 3, + Name: "recovery v3")); + } + + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.Same(retained, record.WorldEntity); + Assert.Equal( + retryFromLandblock ? 2UL : 3UL, + fixture.Materializer.InstalledCreateIntegrationVersion); + Assert.Equal( + LiveProjectionPurpose.CreateSupersessionRecovery, + fixture.Materializer.Calls[^1].Purpose); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Resources.UnregisterCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void StaleProjectionExceptionAfterCreateDrift_PersistsExactRetryObligation( + bool retryFromLandblock) + { + using var fixture = new Fixture( + originKnown: true, + playerGuid: retryFromLandblock ? Guid : 0u); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity retained = record.WorldEntity!; + ulong positionAuthority = record.PositionAuthorityVersion; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "drift v2")); + throw new InvalidOperationException("fixture stale projection failure"); + }; + + Assert.Throws(() => + fixture.Controller.RecoverProjection( + record, + positionAuthority, + record.Snapshot)); + + Assert.True(record.InitialHydrationCompleted); + Assert.True(record.CreateProjectionSynchronizationPending); + Assert.Same(retained, record.WorldEntity); + + fixture.Materializer.BeforeMaterialize = null; + if (retryFromLandblock) + { + fixture.Controller.OnLandblockLoaded(Cell); + } + else + { + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 3, + Name: "drift v3")); + } + + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.Same(retained, record.WorldEntity); + Assert.Equal( + LiveProjectionPurpose.CreateSupersessionRecovery, + fixture.Materializer.Calls[^1].Purpose); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Resources.UnregisterCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CompletedSupersessionReadyFailure_RetriesWholeCommitBoundary( + bool retryFromLandblock) + { + using var fixture = new Fixture( + originKnown: true, + playerGuid: retryFromLandblock ? Guid : 0u); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity retained = record.WorldEntity!; + ulong positionAuthority = record.PositionAuthorityVersion; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 2, + Name: "ready v2")); + }; + fixture.Ready.FailPublishCount = 1; + + Assert.False(fixture.Controller.RecoverProjection( + record, + positionAuthority, + record.Snapshot)); + + Assert.True(record.CreateProjectionSynchronizationPending); + fixture.Materializer.BeforeMaterialize = null; + if (retryFromLandblock) + { + fixture.Controller.OnLandblockLoaded(Cell); + } + else + { + fixture.Controller.OnCreate(Spawn( + Generation: 1, + PositionSequence: 3, + Name: "ready v3")); + } + + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.Same(retained, record.WorldEntity); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Resources.UnregisterCount); + Assert.True(fixture.Ready.PublishCount >= 3); + } + + [Fact] + public void PickupSupersession_CompletesCelllessWithoutDatMaterialization() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + ulong positionAuthority = record.PositionAuthorityVersion; + fixture.Network.ApplyAction = events => + { + Assert.NotNull(events.Pickup); + Assert.True(fixture.Runtime.TryApplyPickup(events.Pickup!.Value, out _)); + Assert.True(fixture.Runtime.WithdrawLiveEntityProjectionToCellless(Guid)); + }; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 2, + parentGuid: null)); + }; + + Assert.False(fixture.Controller.RecoverProjection( + record, + positionAuthority, + record.Snapshot)); + + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.Equal(0u, record.FullCellId); + Assert.False(record.IsSpatiallyProjected); + Assert.DoesNotContain( + fixture.Materializer.Calls, + call => call.Purpose is LiveProjectionPurpose.CreateSupersessionRecovery); + Assert.Equal(1, fixture.Resources.RegisterCount); + } + + [Fact] + public void PickupSupersedesInitialHydration_WithoutInventingRenderOwner() + { + using var fixture = new Fixture(originKnown: true); + fixture.Network.ApplyAction = events => + { + Assert.NotNull(events.Pickup); + Assert.True(fixture.Runtime.TryApplyPickup(events.Pickup!.Value, out _)); + }; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 2, + parentGuid: null)); + }; + + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + + LiveEntityRecord record = fixture.Record; + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.False(record.InitialHydrationCompleted); + Assert.Null(record.WorldEntity); + Assert.Equal(0, fixture.Resources.RegisterCount); + + fixture.Materializer.BeforeMaterialize = null; + fixture.Network.ApplyAction = events => + { + if (events.Position is { } position) + { + fixture.Runtime.TryApplyPosition( + position, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _); + } + }; + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 3)); + + Assert.True(record.InitialHydrationCompleted); + Assert.NotNull(record.WorldEntity); + Assert.Equal(1, fixture.Resources.RegisterCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ParentSupersession_CompletesAtExactAttachedReadyBoundary( + bool failFirstReadyAttempt) + { + const uint parentGuid = 0x70000002u; + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity retained = record.WorldEntity!; + ulong positionAuthority = record.PositionAuthorityVersion; + fixture.Ready.FailPublishCount = failFirstReadyAttempt ? 2 : 0; + CreateParentUpdate? pendingParent = null; + fixture.Network.ApplyAction = events => + { + Assert.NotNull(events.Parent); + Assert.True(fixture.Runtime.TryApplyCreateParent( + events.Parent!.Value, + out _)); + pendingParent = events.Parent.Value; + }; + fixture.Relationships.OnSpawnAction = _ => + { + if (pendingParent is not { } parent + || !record.CreateProjectionSynchronizationPending) + { + return; + } + + Assert.True(fixture.Runtime.CommitStagedParent( + new ParentAttachmentRelation( + parent.ParentGuid, + parent.ChildGuid, + parent.ParentLocation, + parent.PlacementId, + ParentInstanceSequence: 0, + parent.ChildPositionSequence), + out _)); + ulong positionVersion = record.PositionAuthorityVersion; + Assert.True(fixture.Runtime.CommitAcceptedParentCellless( + record, + positionVersion)); + Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(record)); + WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity( + Guid, + Cell, + _ => throw new InvalidOperationException("retained identity expected"), + LiveEntityProjectionKind.Attached); + Assert.Same(retained, attached); + fixture.Controller.OnEntityReady( + new LiveEntityReadyCandidate( + record, + record.CreateIntegrationVersion)); + }; + bool refreshed = false; + fixture.Materializer.BeforeMaterialize = (_, spawn) => + { + if (refreshed || spawn.PositionSequence != 1) + return; + refreshed = true; + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 2, + parentGuid)); + }; + + Assert.False(fixture.Controller.RecoverProjection( + record, + positionAuthority, + record.Snapshot)); + + if (failFirstReadyAttempt) + { + Assert.True(record.CreateProjectionSynchronizationPending); + fixture.Materializer.BeforeMaterialize = null; + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 3, + parentGuid)); + } + + Assert.False(record.CreateProjectionSynchronizationPending); + Assert.Equal(LiveEntityProjectionKind.Attached, record.ProjectionKind); + Assert.Same(retained, record.WorldEntity); + Assert.DoesNotContain( + fixture.Materializer.Calls, + call => call.Purpose is LiveProjectionPurpose.CreateSupersessionRecovery); + Assert.Equal(1, fixture.Resources.RegisterCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InitialParentReadyFailure_RetriesCelllessReadyBoundary( + bool retryFromLandblock) + { + const uint parentGuid = 0x70000002u; + using var fixture = new Fixture( + originKnown: true, + playerGuid: retryFromLandblock ? Guid : 0u); + fixture.Ready.FailPublishCount = 2; + fixture.Network.ApplyAction = events => + { + if (events.Parent is { } parent) + fixture.Runtime.TryApplyCreateParent(parent, out _); + }; + fixture.Relationships.OnSpawnAction = _ => + { + LiveEntityRecord record = fixture.Record; + if (record.WorldEntity is not null) + return; + + WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity( + Guid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [], + ParentCellId = Cell, + }, + LiveEntityProjectionKind.Attached); + Assert.NotNull(attached); + fixture.Controller.OnEntityReady(new LiveEntityReadyCandidate( + record, + record.CreateIntegrationVersion)); + }; + + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 1, + parentGuid)); + + LiveEntityRecord initial = fixture.Record; + Assert.False(initial.InitialHydrationCompleted); + Assert.Equal(LiveEntityProjectionKind.Attached, initial.ProjectionKind); + Assert.Equal(1, fixture.Resources.RegisterCount); + + if (retryFromLandblock) + { + fixture.Controller.OnLandblockLoaded(Cell); + } + else + { + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 2, + parentGuid)); + } + + Assert.True(initial.InitialHydrationCompleted); + Assert.False(initial.CreateProjectionSynchronizationPending); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Resources.UnregisterCount); + } + + [Theory] + [InlineData(0, 1, 0, 0)] + [InlineData(1, 1, 1, 0)] + [InlineData(2, 1, 1, 1)] + public void ReadyPublisher_RevalidatesExactRecordBetweenEveryStage( + int replacementStage, + int expectedPrepare, + int expectedPresent, + int expectedReplay) + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord expected = fixture.Record; + int prepare = 0; + int present = 0; + int replay = 0; + bool replaced = false; + bool Stage(int stage) + { + if (!replaced && replacementStage == stage) + { + replaced = true; + fixture.Runtime.RegisterLiveEntity( + Spawn(Generation: 1, PositionSequence: 2)); + } + return true; + } + + var publisher = new LiveEntityReadyPublisher( + fixture.Runtime, + _ => { prepare++; return Stage(0); }, + _ => { present++; return Stage(1); }, + _ => { replay++; return Stage(2); }); + + Assert.False(publisher.Publish(expected)); + Assert.Equal(expectedPrepare, prepare); + Assert.Equal(expectedPresent, present); + Assert.Equal(expectedReplay, replay); + Assert.Same(expected, fixture.Record); + Assert.Equal(2UL, fixture.Record.CreateIntegrationVersion); + } + + [Fact] + public void EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + WorldEntity entity = record.WorldEntity!; + ulong capturedCreateIntegrationVersion = record.CreateIntegrationVersion; + + // Models ProjectionPoseReady synchronously accepting a fresher + // same-generation CreateObject before EntityReady is emitted. + fixture.Runtime.RegisterLiveEntity( + Spawn(Generation: 1, PositionSequence: 2)); + bool published = false; + + Assert.False(EquippedChildRenderController.PublishEntityReadyExact( + fixture.Runtime, + record, + capturedCreateIntegrationVersion, + entity, + _ => published = true)); + Assert.False(published); + } + + [Fact] + public void PositionAuthorityGuard_PreventsStaleRecoveryAfterNestedUpdate() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); + LiveEntityRecord record = fixture.Record; + fixture.Runtime.WithdrawLiveEntityProjection(Guid); + ulong staleVersion = record.PositionAuthorityVersion; + + Assert.True(fixture.Runtime.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + Guid, + new CreateObject.ServerPosition(Cell, 12f, 10f, 5f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: 0, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out WorldSession.EntitySpawn accepted, + out _)); + + Assert.False(fixture.Controller.RecoverProjection( + record, + staleVersion, + accepted)); + Assert.Empty(fixture.Runtime.MaterializedWorldEntities); + } + + [Fact] + public void DeferredNetworkSink_IsSingleAssignmentAndFailsBeforeBind() + { + var deferred = new DeferredLiveEntityNetworkUpdateSink(); + SameGenerationCreateObjectEvents events = default; + + Assert.Throws(() => + deferred.ApplySameGeneration(events)); + + var first = new RecordingNetworkSink(); + deferred.Bind(first); + deferred.ApplySameGeneration(events); + + Assert.Equal(1, first.ApplyCount); + Assert.Throws(() => + deferred.Bind(new RecordingNetworkSink())); + } + + private const uint Guid = 0x70000001u; + private const uint Cell = 0x01010001u; + + private sealed class Fixture : IDisposable + { + public readonly List Operations = []; + public readonly ClientObjectTable Objects = new(); + public readonly RecordingResources Resources; + public readonly LiveEntityRuntime Runtime; + public readonly RecordingMaterializer Materializer; + public readonly RecordingRelationships Relationships; + public readonly RecordingReadyPublisher Ready; + public readonly RecordingOrigin Origin; + public readonly RecordingNetworkSink Network = new(); + public readonly LiveEntityHydrationController Controller; + + public Action? PublishTimestampsAction { get; set; } + + public Fixture( + bool originKnown, + RecordingResources? resources = null, + uint playerGuid = 0u) + { + Resources = resources ?? new RecordingResources(); + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty())); + Runtime = new LiveEntityRuntime( + spatial, + Resources, + new DelegateLiveEntityRuntimeComponentLifecycle(_ => { })); + Materializer = new RecordingMaterializer(Runtime, Operations); + Relationships = new RecordingRelationships(Operations); + Ready = new RecordingReadyPublisher(Operations); + Origin = new RecordingOrigin(originKnown); + Network.ApplyAction = events => + { + if (events.Position is not { } position) + return; + + Runtime.TryApplyPosition( + position, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _); + }; + Controller = new LiveEntityHydrationController( + Runtime, + Objects, + new object(), + Materializer, + Relationships, + Ready, + Origin, + Network, + (guid, timestamps) => + { + Operations.Add($"timestamps:{timestamps.Instance}"); + PublishTimestampsAction?.Invoke(guid, timestamps); + }, + () => playerGuid); + } + + public LiveEntityRecord Record + { + get + { + Assert.True(Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); + return record; + } + } + + public void Dispose() + { + try + { + Runtime.Clear(); + } + catch + { + // Individual failure-path tests assert the relevant exception. + } + } + } + + private class RecordingResources : ILiveEntityResourceLifecycle + { + public int RegisterCount { get; protected set; } + public int UnregisterCount { get; protected set; } + public virtual void Register(WorldEntity entity) => RegisterCount++; + public virtual void Unregister(WorldEntity entity) => UnregisterCount++; + } + + private sealed class FailingOnceResources : RecordingResources + { + private bool _fail = true; + + public override void Register(WorldEntity entity) + { + base.Register(entity); + if (_fail) + { + _fail = false; + throw new InvalidOperationException("fixture registration failure"); + } + } + } + + private sealed class FailingRegisterAndRollbackOnceResources : RecordingResources + { + private bool _failRegister = true; + private bool _failRollback = true; + + public override void Register(WorldEntity entity) + { + base.Register(entity); + if (_failRegister) + { + _failRegister = false; + throw new InvalidOperationException("fixture registration failure"); + } + } + + public override void Unregister(WorldEntity entity) + { + base.Unregister(entity); + if (_failRollback) + { + _failRollback = false; + throw new InvalidOperationException("fixture rollback failure"); + } + } + } + + private sealed class FailingUnregisterOnceResources : RecordingResources + { + private bool _fail = true; + + public override void Unregister(WorldEntity entity) + { + base.Unregister(entity); + if (_fail) + { + _fail = false; + throw new InvalidOperationException("fixture generation cleanup failure"); + } + } + } + + private sealed class RecordingMaterializer( + LiveEntityRuntime runtime, + List operations) : ILiveEntityProjectionMaterializer + { + public readonly List<(LiveProjectionPurpose Purpose, ushort Generation)> Calls = []; + public readonly List PositionSequences = []; + public ulong? InstalledCreateIntegrationVersion { get; private set; } + public Action? BeforeMaterialize { get; set; } + public Action? AfterMaterialize { get; set; } + public LiveProjectionPurpose? ThrowAfterMaterializePurposeOnce { get; set; } + public int SkipMaterializationCount { get; set; } + + public bool TryMaterialize( + LiveEntityRecord expectedRecord, + WorldSession.EntitySpawn canonicalSpawn, + LiveProjectionPurpose purpose, + ulong expectedCreateIntegrationVersion, + AcDream.App.Rendering.LiveEntityAppearanceUpdateState? appearanceUpdate = null) + { + Calls.Add((purpose, expectedRecord.Generation)); + PositionSequences.Add(canonicalSpawn.PositionSequence); + operations.Add($"materialize:{purpose}:{expectedRecord.Generation}"); + BeforeMaterialize?.Invoke(expectedRecord, canonicalSpawn); + if (!runtime.IsCurrentCreateIntegration( + expectedRecord, + expectedCreateIntegrationVersion)) + { + return false; + } + if (InstalledCreateIntegrationVersion is null + || purpose is LiveProjectionPurpose.CreateSupersessionRecovery) + { + InstalledCreateIntegrationVersion = expectedCreateIntegrationVersion; + } + if (SkipMaterializationCount > 0) + { + SkipMaterializationCount--; + return false; + } + if (canonicalSpawn.Position is not { } position + || canonicalSpawn.SetupTableId is null) + { + return false; + } + + WorldEntity? entity = runtime.MaterializeLiveEntity( + canonicalSpawn.Guid, + position.LandblockId, + id => new WorldEntity + { + Id = id, + ServerGuid = canonicalSpawn.Guid, + SourceGfxObjOrSetupId = canonicalSpawn.SetupTableId.Value, + Position = new Vector3(position.PositionX, position.PositionY, position.PositionZ), + Rotation = Quaternion.Identity, + MeshRefs = [], + ParentCellId = position.LandblockId, + }); + if (ThrowAfterMaterializePurposeOnce == purpose) + { + ThrowAfterMaterializePurposeOnce = null; + throw new InvalidOperationException( + "fixture supersession projection failure"); + } + AfterMaterialize?.Invoke(expectedRecord, canonicalSpawn); + return entity is not null + && runtime.IsCurrentRecord(expectedRecord); + } + + public void ResetSessionState() { } + } + + private sealed class RecordingRelationships(List operations) + : ILiveEntitySpawnRelationshipSink + { + public Action? OnSpawnAction { get; set; } + public void OnSpawn(WorldSession.EntitySpawn spawn) + { + operations.Add($"relationship:{spawn.InstanceSequence}"); + OnSpawnAction?.Invoke(spawn); + } + } + + private sealed class RecordingReadyPublisher(List operations) + : ILiveEntityReadyPublisher + { + public int PublishCount { get; private set; } + public int FailPublishCount { get; set; } + public Action? BeforePublish { get; set; } + public bool Publish(LiveEntityRecord expectedRecord) + { + PublishCount++; + operations.Add("ready"); + BeforePublish?.Invoke(expectedRecord); + if (FailPublishCount > 0) + { + FailPublishCount--; + return false; + } + return true; + } + } + + private sealed class RecordingOrigin(bool isKnown) : ILiveEntityWorldOriginCoordinator + { + public bool IsKnownValue { get; set; } = isKnown; + public IReadOnlyList AlreadyLoadedLandblocks { get; set; } = []; + public bool IsKnown => IsKnownValue; + public LiveEntityOriginInitialization TryInitialize(WorldSession.EntitySpawn spawn) => + new(IsKnownValue, AlreadyLoadedLandblocks); + } + + private sealed class RecordingNetworkSink : ILiveEntityNetworkUpdateSink + { + public int ApplyCount { get; private set; } + public Action? ApplyAction { get; set; } + public void ApplySameGeneration(SameGenerationCreateObjectEvents events) + { + ApplyCount++; + ApplyAction?.Invoke(events); + } + } + + private static WorldSession.EntitySpawn Spawn( + ushort Generation, + ushort PositionSequence, + string Name = "fixture") + { + var position = new CreateObject.ServerPosition( + Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + PositionSequence, 1, 1, 1, 0, 1, 0, 1, Generation); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + Guid, + position, + 0x02000001u, + [], + [], + [], + null, + null, + Name, + (uint)ItemType.Creature, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: Generation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: PositionSequence, + Physics: physics); + } + + private static WorldSession.EntitySpawn CelllessSpawn( + ushort PositionSequence, + uint? parentGuid) + { + var timestamps = new PhysicsTimestamps( + PositionSequence, 1, 1, 1, 0, 1, 0, 1, 1); + PhysicsAttachment? parent = parentGuid is { } guid + ? new PhysicsAttachment(guid, LocationId: 1u) + : null; + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: null, + Movement: null, + AnimationFrame: parentGuid is null ? null : 1u, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: parent, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + Guid, + Position: null, + SetupTableId: 0x02000001u, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: parentGuid is null ? "pickup" : "parented", + ItemType: (uint)ItemType.Creature, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: PositionSequence, + ParentGuid: parentGuid, + ParentLocation: parentGuid is null ? null : 1u, + PlacementId: parentGuid is null ? null : 1u, + Physics: physics); + } +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs b/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs new file mode 100644 index 00000000..aadcd00e --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityWorldOriginCoordinatorTests.cs @@ -0,0 +1,117 @@ +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.World; + +namespace AcDream.App.Tests.World; + +public sealed class LiveEntityWorldOriginCoordinatorTests +{ + [Fact] + public void LocalPlayerPosition_InitializesOnceAndReturnsResidentRecoverySet() + { + const uint playerGuid = 0x50000001u; + var origin = new LiveWorldOriginState(); + origin.SetPlaceholder(10, 20); + var world = new GpuWorldState(); + world.AddLandblock(EmptyLandblock(0x0101FFFFu)); + world.AddLandblock(EmptyLandblock(0x0202FFFFu)); + var coordinator = new LiveEntityWorldOriginCoordinator( + origin, + Streaming(world), + world, + Reveal(), + () => playerGuid, + _ => false); + + LiveEntityOriginInitialization ignored = coordinator.TryInitialize( + Spawn(0x50000002u, 0x09090001u)); + Assert.False(ignored.IsKnown); + + LiveEntityOriginInitialization initialized = coordinator.TryInitialize( + Spawn(playerGuid, 0x03040001u)); + + Assert.True(initialized.IsKnown); + Assert.True(origin.IsKnown); + Assert.Equal(3, origin.CenterX); + Assert.Equal(4, origin.CenterY); + Assert.Equal( + [0x0101FFFFu, 0x0202FFFFu], + initialized.AlreadyLoadedLandblocks.Order().ToArray()); + + LiveEntityOriginInitialization repeated = coordinator.TryInitialize( + Spawn(playerGuid, 0x07080001u)); + Assert.Empty(repeated.AlreadyLoadedLandblocks); + Assert.Equal(3, origin.CenterX); + Assert.Equal(4, origin.CenterY); + } + + [Fact] + public void Reset_AllowsNextSessionPlayerPositionToOwnOrigin() + { + const uint playerGuid = 0x50000001u; + var origin = new LiveWorldOriginState(); + var world = new GpuWorldState(); + var coordinator = new LiveEntityWorldOriginCoordinator( + origin, + Streaming(world), + world, + Reveal(), + () => playerGuid, + _ => false); + + Assert.True(coordinator.TryInitialize(Spawn(playerGuid, 0x03040001u)).IsKnown); + origin.Reset(); + Assert.False(coordinator.IsKnown); + + Assert.True(coordinator.TryInitialize(Spawn(playerGuid, 0x07080001u)).IsKnown); + Assert.Equal(7, origin.CenterX); + Assert.Equal(8, origin.CenterY); + } + + private static StreamingController Streaming(GpuWorldState world) => new( + (_, _, _) => { }, + (_, _) => { }, + _ => Array.Empty(), + (_, _) => { }, + world, + nearRadius: 1, + farRadius: 2); + + private static WorldRevealCoordinator Reveal() => new( + (_, _) => true, + _ => true, + (_, _) => true, + () => true, + (_, _) => { }, + () => { }, + _ => false); + + private static LoadedLandblock EmptyLandblock(uint id) => new( + id, + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty()); + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new( + Guid: guid, + Position: new CreateObject.ServerPosition( + cell, + 12f, + 24f, + 6f, + 1f, + 0f, + 0f, + 0f), + SetupTableId: 0x02000001u, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: "origin fixture", + ItemType: null, + MotionState: null, + MotionTableId: null); +} diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs index 56e83ea3..9bc6a0e7 100644 --- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs @@ -258,4 +258,43 @@ public sealed class ObjectTableWiringTests Assert.Equal("Refreshed Name", table.Get(guid)!.Name); Assert.Equal(456, table.Get(guid)!.Properties.Ints[123u]); } + + [Fact] + public void GenerationReplacement_InvalidatedByRemovalObserver_DoesNotInstallOuterQualities() + { + const uint guid = 0x80000500u; + var table = new ClientObjectTable(); + table.Ingest(MinimalWeenie(guid)); + bool accepting = true; + table.ObjectRemovalClassified += removal => + { + if (removal.Reason is not ClientObjectRemovalReason.GenerationReplacement) + return; + + table.Ingest(MinimalWeenie(guid) with { Name = "Nested Newer" }); + accepting = false; + }; + var outer = new WorldSession.EntitySpawn( + Guid: guid, + Position: null, + SetupTableId: null, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: "Outer Older", + ItemType: null, + MotionState: null, + MotionTableId: null); + + bool applied = ObjectTableWiring.ApplyEntitySpawn( + table, + outer, + replaceGeneration: true, + accepting: () => accepting); + + Assert.False(applied); + Assert.Equal("Nested Newer", table.Get(guid)!.Name); + } }