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);
+ }
}