fix #203: preserve animation on appearance updates

This commit is contained in:
Erik 2026-07-11 00:25:29 +02:00
parent ff06aa3107
commit c7607f019c
9 changed files with 237 additions and 22 deletions

View file

@ -318,6 +318,10 @@ public sealed class GameWindow : IDisposable
public readonly List<AcDream.Core.World.MeshRef> MeshRefsScratch = new();
}
private sealed record AppearanceUpdateState(
AcDream.Core.World.WorldEntity Entity,
AnimatedEntity? Animation);
private AcDream.Core.Physics.DatCollectionLoader? _animLoader;
// Phase E.1: central fan-out for animation hooks. Audio (E.2),
@ -2850,7 +2854,9 @@ public sealed class GameWindow : IDisposable
/// </summary>
private static bool IsDoorName(string? name) => name == "Door";
private void OnLiveEntitySpawnedLocked(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
private void OnLiveEntitySpawnedLocked(
AcDream.Core.Net.WorldSession.EntitySpawn spawn,
AppearanceUpdateState? appearanceUpdate = null)
{
_liveSpawnReceived++;
@ -2877,7 +2883,8 @@ public sealed class GameWindow : IDisposable
// For a respawn, drop the previous rendering state here before we
// build the new one. `_entitiesByServerGuid` is the canonical map,
// its value is the live WorldEntity we need to dispose.
RemoveLiveEntityByServerGuid(spawn.Guid);
if (appearanceUpdate is null)
RemoveLiveEntityByServerGuid(spawn.Guid);
// 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
@ -3430,6 +3437,19 @@ public sealed class GameWindow : IDisposable
animPartChanges[i].PartIndex, animPartChanges[i].NewModelId);
}
if (appearanceUpdate is { } visualUpdate)
{
AcDream.Core.World.WorldEntity existing = visualUpdate.Entity;
existing.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides);
if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax))
existing.SetLocalBounds(appearanceMin, appearanceMax);
if (visualUpdate.Animation is { } animation)
RebindAnimatedEntityForAppearance(animation, existing, setup, scale, meshRefs);
_classificationCache.InvalidateEntity(existing.Id);
_lastSpawnByGuid[spawn.Guid] = spawn;
return;
}
var entity = new AcDream.Core.World.WorldEntity
{
Id = _liveEntityIdCounter++,
@ -3541,8 +3561,6 @@ public sealed class GameWindow : IDisposable
else if (idleCycle.Animation.PartFrames.Count <= 1)
_liveAnimRejectPartFrames++;
if (idleCycle is not null && idleCycle.Framerate != 0f
&& idleCycle.HighFrame > idleCycle.LowFrame
&& idleCycle.Animation.PartFrames.Count > 1)
@ -3770,11 +3788,11 @@ public sealed class GameWindow : IDisposable
/// appearance changed (equip / unequip / tailoring / recipe result /
/// character option toggle). The wire payload only carries the new
/// ModelData (palette + texture + animpart changes), not position or
/// motion, so we splice it onto the cached spawn and replay through
/// <see cref="OnLiveEntitySpawned"/>. The dedup at the start of
/// <see cref="OnLiveEntitySpawnedLocked"/> tears down the previous
/// rendering state (GpuWorldState entry, animated entity, collision
/// registration) before rebuilding.
/// motion, so we splice it onto the cached spawn and rehydrate only the
/// visual projection. Retail <c>SmartBox::UpdateVisualDesc</c> calls
/// <c>CPhysicsObj::DoObjDescChangesFromDefault</c> on the existing object;
/// accordingly, entity identity and all animation/movement/physics state
/// survive while MeshRefs, palette, and part overrides are replaced.
/// </summary>
private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update)
{
@ -3796,15 +3814,49 @@ public sealed class GameWindow : IDisposable
SubPalettes = md.SubPalettes,
BasePaletteId = md.BasePaletteId,
};
OnLiveEntitySpawned(newSpawn);
lock (_datLock)
{
AppearanceUpdateState? appearanceState = CaptureLiveAppearanceState(update.Guid);
OnLiveEntitySpawnedLocked(newSpawn, appearanceState);
}
// Slice 2: a player appearance change (equip / unequip) rebuilt _entitiesByServerGuid[player]
// above; flag the paperdoll doll to re-clone from it on the next doll pass (the C# analog of
// RedressCreature). Cheap flag — the rebuild is deferred to the pre-UI hook when visible.
// Slice 2: flag the paperdoll doll to re-clone the player's newly
// mutated appearance on the next doll pass (the C# analog of
// RedressCreature). The rebuild is deferred until the doll is visible.
if (update.Guid == _playerServerGuid)
_paperdollDollDirty = true;
}
/// <summary>
/// Capture the runtime identity that retail preserves across
/// <c>DoObjDescChangesFromDefault</c>. Replacement visual data is hydrated
/// first and then assigned to this same entity instance.
/// </summary>
private AppearanceUpdateState? CaptureLiveAppearanceState(uint serverGuid)
{
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var entity))
return null;
_animatedEntities.TryGetValue(entity.Id, out var animation);
return new AppearanceUpdateState(entity, animation);
}
internal static void RebindAnimatedEntityForAppearance(
AnimatedEntity animation,
AcDream.Core.World.WorldEntity entity,
DatReaderWriter.DBObjs.Setup setup,
float scale,
IReadOnlyList<AcDream.Core.World.MeshRef> meshRefs)
{
animation.Entity = entity;
animation.Setup = setup;
animation.Scale = scale;
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
animation.PartTemplate = template;
}
/// <summary>
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /