feat(net): wire 0xF625 ObjDescEvent for live appearance updates
Retail-driven players observed from acdream rendered with stale appearance — wrong skin/hair palettes, missing clothing — because ACE's mid-session appearance broadcasts (equip/unequip/tailoring/ recipe/option-toggle) ride opcode 0xF625 ObjDescEvent and acdream silently dropped them. Initial CreateObject carries the appearance at spawn time, but every later equip change only updates via 0xF625 (per Skunkwors protocol docs in ACE/.../GameMessageObjDescEvent.cs). Retail handles via SmartBox::HandleObjDescEvent (named-retail 0x453340). Why: the retail observer sees the *server-relayed* view of remotes, not retail's local build, so dropping ObjDescEvent freezes appearance at the partial state in the first CreateObject. How: - Extract CreateObject's ModelData parsing into reusable CreateObject.ReadModelData(span, ref pos) returning (BasePaletteId, SubPalettes, TextureChanges, AnimPartChanges). - Add ObjDescEvent.cs (parser for 0xF625): body = u32 opcode | u32 guid | ModelData | u32 instanceSeq | u32 visualDescSeq. - WorldSession.AppearanceUpdated event + dispatcher branch. - GameWindow.OnLiveAppearanceUpdated splices new ModelData onto the cached spawn and replays via OnLiveEntitySpawned. The dedup at the start of OnLiveEntitySpawnedLocked tears down the old GPU/animated/ collision state cleanly before rebuild. - _lastSpawnByGuid cache populated at spawn-end and tracked through UpdatePosition so re-applies use current position (no pop-back to login spot on equip toggle). - ACDREAM_DUMP_APPEARANCE=1 env var prints structured SP/TC/APC decode for every 0xF625 — replaces the earlier raw-hex preview. - ACDREAM_DUMP_CLOTHING extended with setup.Parts.Count, flatten.Count, and per-part triangle counts for offline polygon-budget audit. Tests: 4 new ObjDescEvent tests (round-trip + parser drift guard); 269 net tests green. User-verified live: skin/hair colors match retail's character data; equip/unequip no longer pops position. Note: a separate "puffy arms / bulky body" geometry issue remains where base body parts visibly overlap clothing meshes — different root cause, tracked separately.
This commit is contained in:
parent
24407fec3c
commit
e471527924
5 changed files with 441 additions and 62 deletions
|
|
@ -682,6 +682,13 @@ public sealed class GameWindow : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
|
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
|
||||||
private readonly Dictionary<uint, LiveEntityInfo> _liveEntityInfoByGuid = new();
|
private readonly Dictionary<uint, LiveEntityInfo> _liveEntityInfoByGuid = new();
|
||||||
|
/// <summary>
|
||||||
|
/// Latest <see cref="AcDream.Core.Net.WorldSession.EntitySpawn"/> for each
|
||||||
|
/// guid. Captured at the end of <see cref="OnLiveEntitySpawnedLocked"/> so
|
||||||
|
/// <see cref="OnLiveAppearanceUpdated"/> can reuse the position/setup/motion
|
||||||
|
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new();
|
||||||
private uint? _selectedTargetGuid;
|
private uint? _selectedTargetGuid;
|
||||||
private readonly record struct LiveEntityInfo(
|
private readonly record struct LiveEntityInfo(
|
||||||
string? Name,
|
string? Name,
|
||||||
|
|
@ -1476,6 +1483,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
||||||
_liveSession.VectorUpdated += OnLiveVectorUpdated;
|
_liveSession.VectorUpdated += OnLiveVectorUpdated;
|
||||||
_liveSession.TeleportStarted += OnTeleportStarted;
|
_liveSession.TeleportStarted += OnTeleportStarted;
|
||||||
|
_liveSession.AppearanceUpdated += OnLiveAppearanceUpdated;
|
||||||
|
|
||||||
// Phase 6c — PlayScript (0xF754) arrives from the server as
|
// Phase 6c — PlayScript (0xF754) arrives from the server as
|
||||||
// a (guid, scriptId) pair. Resolve the guid's current world
|
// a (guid, scriptId) pair. Resolve the guid's current world
|
||||||
|
|
@ -1988,7 +1996,7 @@ public sealed class GameWindow : IDisposable
|
||||||
&& setup.Parts.Count >= 10;
|
&& setup.Parts.Count >= 10;
|
||||||
if (dumpClothing)
|
if (dumpClothing)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' setup=0x{setup.Id:X8} APC={animPartChanges.Count} ===");
|
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} ===");
|
||||||
foreach (var c in animPartChanges)
|
foreach (var c in animPartChanges)
|
||||||
Console.WriteLine($" APC part={c.PartIndex:D2} -> gfx=0x{c.NewModelId:X8}");
|
Console.WriteLine($" APC part={c.PartIndex:D2} -> gfx=0x{c.NewModelId:X8}");
|
||||||
|
|
||||||
|
|
@ -2158,14 +2166,27 @@ public sealed class GameWindow : IDisposable
|
||||||
var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale);
|
var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale);
|
||||||
|
|
||||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||||
|
int dumpClothingTotalTris = 0;
|
||||||
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
|
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
|
||||||
{
|
{
|
||||||
var mr = parts[partIdx];
|
var mr = parts[partIdx];
|
||||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||||
if (gfx is null) continue;
|
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);
|
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||||
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||||
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
|
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
|
||||||
|
if (dumpClothing)
|
||||||
|
{
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
IReadOnlyDictionary<uint, uint>? surfaceOverrides = null;
|
IReadOnlyDictionary<uint, uint>? surfaceOverrides = null;
|
||||||
if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
|
if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
|
||||||
|
|
@ -2194,6 +2215,8 @@ public sealed class GameWindow : IDisposable
|
||||||
$"(guid=0x{spawn.Guid:X8})");
|
$"(guid=0x{spawn.Guid:X8})");
|
||||||
return;
|
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
|
// Build optional per-entity palette override from the server's base
|
||||||
// palette + subpalette overlays. The renderer applies these to
|
// palette + subpalette overlays. The renderer applies these to
|
||||||
|
|
@ -2241,6 +2264,10 @@ public sealed class GameWindow : IDisposable
|
||||||
// UpdateMotion / UpdatePosition events can reseat this entity by guid.
|
// UpdateMotion / UpdatePosition events can reseat this entity by guid.
|
||||||
_entitiesByServerGuid[spawn.Guid] = entity;
|
_entitiesByServerGuid[spawn.Guid] = entity;
|
||||||
|
|
||||||
|
// Cache the spawn so OnLiveAppearanceUpdated can replay it with new
|
||||||
|
// appearance fields when a later 0xF625 ObjDescEvent arrives.
|
||||||
|
_lastSpawnByGuid[spawn.Guid] = spawn;
|
||||||
|
|
||||||
// Commit B 2026-04-29 — live-entity collision registration. The
|
// Commit B 2026-04-29 — live-entity collision registration. The
|
||||||
// local player is the simulator (its PhysicsBody is the source of
|
// local player is the simulator (its PhysicsBody is the source of
|
||||||
// truth for our own movement); only remotes register as targets.
|
// truth for our own movement); only remotes register as targets.
|
||||||
|
|
@ -2470,6 +2497,40 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server broadcast a <c>0xF625 ObjDescEvent</c> — a creature/player's
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update)
|
||||||
|
{
|
||||||
|
if (!_lastSpawnByGuid.TryGetValue(update.Guid, out var oldSpawn))
|
||||||
|
{
|
||||||
|
// Server can broadcast ObjDescEvent before we've seen a
|
||||||
|
// CreateObject for this guid (race on landblock entry, or
|
||||||
|
// if the entity is in a state we couldn't render). Drop —
|
||||||
|
// when CreateObject lands, ACE includes the same ModelData
|
||||||
|
// body inside it, so the appearance won't be lost.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var md = update.ModelData;
|
||||||
|
var newSpawn = oldSpawn with
|
||||||
|
{
|
||||||
|
AnimPartChanges = md.AnimPartChanges,
|
||||||
|
TextureChanges = md.TextureChanges,
|
||||||
|
SubPalettes = md.SubPalettes,
|
||||||
|
BasePaletteId = md.BasePaletteId,
|
||||||
|
};
|
||||||
|
OnLiveEntitySpawned(newSpawn);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Commit B 2026-04-29 — register a live (server-spawned) entity into
|
/// Commit B 2026-04-29 — register a live (server-spawned) entity into
|
||||||
/// the <see cref="ShadowObjectRegistry"/> as a single collision body.
|
/// the <see cref="ShadowObjectRegistry"/> as a single collision body.
|
||||||
|
|
@ -2580,6 +2641,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_remoteLastMove.Remove(serverGuid);
|
_remoteLastMove.Remove(serverGuid);
|
||||||
_liveEntityInfoByGuid.Remove(serverGuid);
|
_liveEntityInfoByGuid.Remove(serverGuid);
|
||||||
_entitiesByServerGuid.Remove(serverGuid);
|
_entitiesByServerGuid.Remove(serverGuid);
|
||||||
|
_lastSpawnByGuid.Remove(serverGuid);
|
||||||
if (_selectedTargetGuid == serverGuid)
|
if (_selectedTargetGuid == serverGuid)
|
||||||
_selectedTargetGuid = null;
|
_selectedTargetGuid = null;
|
||||||
|
|
||||||
|
|
@ -3614,6 +3676,13 @@ public sealed class GameWindow : IDisposable
|
||||||
var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||||
DumpMovementTruthServerEcho(update, worldPos);
|
DumpMovementTruthServerEcho(update, worldPos);
|
||||||
|
|
||||||
|
// Keep the cached spawn's Position in sync with server truth so a
|
||||||
|
// later ObjDescEvent (which only carries new appearance, not new
|
||||||
|
// position) re-applies at the entity's CURRENT location instead of
|
||||||
|
// popping back to its login spot. See OnLiveAppearanceUpdated.
|
||||||
|
if (_lastSpawnByGuid.TryGetValue(update.Guid, out var cached))
|
||||||
|
_lastSpawnByGuid[update.Guid] = cached with { Position = update.Position };
|
||||||
|
|
||||||
// Capture the pre-update render position for the soft-snap residual
|
// Capture the pre-update render position for the soft-snap residual
|
||||||
// calculation below. Assign entity.Position to the server truth up
|
// calculation below. Assign entity.Position to the server truth up
|
||||||
// front; if we then compute a snap residual, we restore the rendered
|
// front; if we then compute a snap residual, we restore the rendered
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,18 @@ public static class CreateObject
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct AnimPartChange(byte PartIndex, uint NewModelId);
|
public readonly record struct AnimPartChange(byte PartIndex, uint NewModelId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ModelData block — palette/texture/animpart changes — that lives
|
||||||
|
/// inside both CreateObject (initial spawn) and ObjDescEvent (0xF625
|
||||||
|
/// appearance update). Factored out so both sites parse the same wire
|
||||||
|
/// shape with one implementation.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct ModelData(
|
||||||
|
uint? BasePaletteId,
|
||||||
|
IReadOnlyList<SubPaletteSwap> SubPalettes,
|
||||||
|
IReadOnlyList<TextureChange> TextureChanges,
|
||||||
|
IReadOnlyList<AnimPartChange> AnimPartChanges);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse a reassembled CreateObject body. <paramref name="body"/> must
|
/// Parse a reassembled CreateObject body. <paramref name="body"/> must
|
||||||
/// start with the 4-byte opcode. Returns <c>null</c> if the body is
|
/// start with the 4-byte opcode. Returns <c>null</c> if the body is
|
||||||
|
|
@ -310,64 +322,11 @@ public static class CreateObject
|
||||||
|
|
||||||
uint guid = ReadU32(body, ref pos);
|
uint guid = ReadU32(body, ref pos);
|
||||||
|
|
||||||
// --- ModelData ---
|
var modelData = ReadModelData(body, ref pos);
|
||||||
// Header: byte 0x11 marker, byte subPalettes, byte textureChanges, byte animPartChanges
|
uint? basePaletteId = modelData.BasePaletteId;
|
||||||
if (body.Length - pos < 4) return null;
|
var subPalettes = modelData.SubPalettes;
|
||||||
byte _marker = body[pos]; pos += 1;
|
var textureChanges = modelData.TextureChanges;
|
||||||
byte subPaletteCount = body[pos]; pos += 1;
|
var animParts = modelData.AnimPartChanges;
|
||||||
byte textureChangeCount = body[pos]; pos += 1;
|
|
||||||
byte animPartChangeCount = body[pos]; pos += 1;
|
|
||||||
|
|
||||||
uint? basePaletteId = null;
|
|
||||||
if (subPaletteCount > 0)
|
|
||||||
basePaletteId = ReadPackedDwordOfKnownType(body, ref pos, PaletteTypePrefix);
|
|
||||||
|
|
||||||
var subPalettes = subPaletteCount == 0
|
|
||||||
? (IReadOnlyList<SubPaletteSwap>)Array.Empty<SubPaletteSwap>()
|
|
||||||
: new SubPaletteSwap[subPaletteCount];
|
|
||||||
for (int i = 0; i < subPaletteCount; i++)
|
|
||||||
{
|
|
||||||
uint subPalId = ReadPackedDwordOfKnownType(body, ref pos, PaletteTypePrefix);
|
|
||||||
if (body.Length - pos < 2) return null;
|
|
||||||
byte offset = body[pos]; pos += 1;
|
|
||||||
byte length = body[pos]; pos += 1;
|
|
||||||
((SubPaletteSwap[])subPalettes)[i] = new SubPaletteSwap(subPalId, offset, length);
|
|
||||||
}
|
|
||||||
|
|
||||||
var textureChanges = textureChangeCount == 0
|
|
||||||
? (IReadOnlyList<TextureChange>)Array.Empty<TextureChange>()
|
|
||||||
: new TextureChange[textureChangeCount];
|
|
||||||
for (int i = 0; i < textureChangeCount; i++)
|
|
||||||
{
|
|
||||||
if (body.Length - pos < 1) return null;
|
|
||||||
byte partIndex = body[pos]; pos += 1;
|
|
||||||
uint oldTex = ReadPackedDwordOfKnownType(body, ref pos, SurfaceTextureTypePrefix);
|
|
||||||
uint newTex = ReadPackedDwordOfKnownType(body, ref pos, SurfaceTextureTypePrefix);
|
|
||||||
((TextureChange[])textureChanges)[i] = new TextureChange(partIndex, oldTex, newTex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract AnimPartChanges — the server uses these to replace
|
|
||||||
// base Setup parts with armored/statue/whatever-specific meshes.
|
|
||||||
// Without decoding these, characters render "naked" and custom
|
|
||||||
// weenies render as whatever their base Setup looks like.
|
|
||||||
//
|
|
||||||
// NOTE: ACE writes the NewModelId through WritePackedDwordOfKnownType
|
|
||||||
// with knownType=0x01000000 (GfxObj type prefix). That writer STRIPS
|
|
||||||
// the high-byte type if present before writing the PackedDword. We
|
|
||||||
// have to OR it back on read or our GfxObj dat lookup will fail
|
|
||||||
// (silently, producing no mesh refs — hence the Phase 4.7h regression).
|
|
||||||
var animParts = animPartChangeCount == 0
|
|
||||||
? (IReadOnlyList<AnimPartChange>)Array.Empty<AnimPartChange>()
|
|
||||||
: new AnimPartChange[animPartChangeCount];
|
|
||||||
for (int i = 0; i < animPartChangeCount; i++)
|
|
||||||
{
|
|
||||||
if (body.Length - pos < 1) return null;
|
|
||||||
byte partIndex = body[pos]; pos += 1;
|
|
||||||
uint newModelId = ReadPackedDwordOfKnownType(body, ref pos, GfxObjTypePrefix);
|
|
||||||
((AnimPartChange[])animParts)[i] = new AnimPartChange(partIndex, newModelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
AlignTo4(ref pos);
|
|
||||||
|
|
||||||
// --- PhysicsData ---
|
// --- PhysicsData ---
|
||||||
if (body.Length - pos < 8) return null;
|
if (body.Length - pos < 8) return null;
|
||||||
|
|
@ -559,6 +518,80 @@ public static class CreateObject
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the ModelData block — palette swaps + texture overrides +
|
||||||
|
/// animation-part replacements — that lives inside both CreateObject
|
||||||
|
/// (initial spawn) and ObjDescEvent (0xF625 appearance update).
|
||||||
|
///
|
||||||
|
/// <para>Layout: byte marker (0x11), byte subPaletteCount, byte
|
||||||
|
/// textureChangeCount, byte animPartChangeCount. Then:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>BasePaletteId (PackedDword of palette type), only present when subPaletteCount > 0</item>
|
||||||
|
/// <item>SubPalettes[subPaletteCount]: PackedDword id + byte offset + byte length</item>
|
||||||
|
/// <item>TextureChanges[textureChangeCount]: byte partIndex + PackedDword oldTex + PackedDword newTex</item>
|
||||||
|
/// <item>AnimPartChanges[animPartChangeCount]: byte partIndex + PackedDword newModelId</item>
|
||||||
|
/// <item>4-byte alignment pad</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>Throws <see cref="FormatException"/> on truncated input —
|
||||||
|
/// callers wrap in try/catch and convert to a null result. Advances
|
||||||
|
/// <paramref name="pos"/> past the alignment pad so the caller can
|
||||||
|
/// continue reading the next field.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static ModelData ReadModelData(ReadOnlySpan<byte> body, ref int pos)
|
||||||
|
{
|
||||||
|
if (body.Length - pos < 4) throw new FormatException("truncated ModelData header");
|
||||||
|
byte _marker = body[pos]; pos += 1;
|
||||||
|
byte subPaletteCount = body[pos]; pos += 1;
|
||||||
|
byte textureChangeCount = body[pos]; pos += 1;
|
||||||
|
byte animPartChangeCount = body[pos]; pos += 1;
|
||||||
|
|
||||||
|
uint? basePaletteId = null;
|
||||||
|
if (subPaletteCount > 0)
|
||||||
|
basePaletteId = ReadPackedDwordOfKnownType(body, ref pos, PaletteTypePrefix);
|
||||||
|
|
||||||
|
var subPalettes = subPaletteCount == 0
|
||||||
|
? (IReadOnlyList<SubPaletteSwap>)Array.Empty<SubPaletteSwap>()
|
||||||
|
: new SubPaletteSwap[subPaletteCount];
|
||||||
|
for (int i = 0; i < subPaletteCount; i++)
|
||||||
|
{
|
||||||
|
uint subPalId = ReadPackedDwordOfKnownType(body, ref pos, PaletteTypePrefix);
|
||||||
|
if (body.Length - pos < 2) throw new FormatException("truncated SubPaletteSwap");
|
||||||
|
byte offset = body[pos]; pos += 1;
|
||||||
|
byte length = body[pos]; pos += 1;
|
||||||
|
((SubPaletteSwap[])subPalettes)[i] = new SubPaletteSwap(subPalId, offset, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
var textureChanges = textureChangeCount == 0
|
||||||
|
? (IReadOnlyList<TextureChange>)Array.Empty<TextureChange>()
|
||||||
|
: new TextureChange[textureChangeCount];
|
||||||
|
for (int i = 0; i < textureChangeCount; i++)
|
||||||
|
{
|
||||||
|
if (body.Length - pos < 1) throw new FormatException("truncated TextureChange");
|
||||||
|
byte partIndex = body[pos]; pos += 1;
|
||||||
|
uint oldTex = ReadPackedDwordOfKnownType(body, ref pos, SurfaceTextureTypePrefix);
|
||||||
|
uint newTex = ReadPackedDwordOfKnownType(body, ref pos, SurfaceTextureTypePrefix);
|
||||||
|
((TextureChange[])textureChanges)[i] = new TextureChange(partIndex, oldTex, newTex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACE writes NewModelId via WritePackedDwordOfKnownType(0x01000000)
|
||||||
|
// which strips the high-byte type if present before packing.
|
||||||
|
// ReadPackedDwordOfKnownType ORs it back on read.
|
||||||
|
var animParts = animPartChangeCount == 0
|
||||||
|
? (IReadOnlyList<AnimPartChange>)Array.Empty<AnimPartChange>()
|
||||||
|
: new AnimPartChange[animPartChangeCount];
|
||||||
|
for (int i = 0; i < animPartChangeCount; i++)
|
||||||
|
{
|
||||||
|
if (body.Length - pos < 1) throw new FormatException("truncated AnimPartChange");
|
||||||
|
byte partIndex = body[pos]; pos += 1;
|
||||||
|
uint newModelId = ReadPackedDwordOfKnownType(body, ref pos, GfxObjTypePrefix);
|
||||||
|
((AnimPartChange[])animParts)[i] = new AnimPartChange(partIndex, newModelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
AlignTo4(ref pos);
|
||||||
|
return new ModelData(basePaletteId, subPalettes, textureChanges, animParts);
|
||||||
|
}
|
||||||
|
|
||||||
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
|
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
|
||||||
{
|
{
|
||||||
if (source.Length - pos < 4) throw new FormatException("truncated u32");
|
if (source.Length - pos < 4) throw new FormatException("truncated u32");
|
||||||
|
|
|
||||||
74
src/AcDream.Core.Net/Messages/ObjDescEvent.cs
Normal file
74
src/AcDream.Core.Net/Messages/ObjDescEvent.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inbound <c>ObjDescEvent</c> GameMessage (opcode <c>0xF625</c>). ACE
|
||||||
|
/// broadcasts this whenever a creature/player's appearance changes after
|
||||||
|
/// the initial <see cref="CreateObject"/> spawn — equip / unequip
|
||||||
|
/// (Creature_Equipment.cs:365), tailoring (Tailoring.cs:504), recipe
|
||||||
|
/// results (RecipeManager.cs:403), character-option toggles. Skunkwors
|
||||||
|
/// protocol docs: "F625: Change Model — Sent whenever a character changes
|
||||||
|
/// their clothes. It contains the entire description of what they're
|
||||||
|
/// wearing (and possibly their facial features as well). This message is
|
||||||
|
/// only sent for changes; when the character is first created, the body
|
||||||
|
/// of this message is included inside the creation message."
|
||||||
|
///
|
||||||
|
/// <para>Retail handles it via <c>SmartBox::HandleObjDescEvent</c>
|
||||||
|
/// (named-retail symbol 0x453340). acdream silently dropped it through
|
||||||
|
/// 2026-05-06 — the bug was that retail-driven characters observed from
|
||||||
|
/// acdream rendered with the wrong skin/hair palettes because the
|
||||||
|
/// follow-up appearance updates were never applied.</para>
|
||||||
|
///
|
||||||
|
/// <para>Wire layout (ACE WorldObject_Networking.cs:48-54
|
||||||
|
/// <c>SerializeUpdateModelData</c>):</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>u32 opcode (0xF625)</item>
|
||||||
|
/// <item>u32 guid — target object</item>
|
||||||
|
/// <item>ModelData block — see <see cref="CreateObject.ReadModelData"/></item>
|
||||||
|
/// <item>u32 instanceSequence</item>
|
||||||
|
/// <item>u32 visualDescSequence</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static class ObjDescEvent
|
||||||
|
{
|
||||||
|
public const uint Opcode = 0xF625u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One ObjDescEvent: target guid + the new ModelData. Sequence
|
||||||
|
/// counters are read but not surfaced (subscribers don't need them
|
||||||
|
/// — the event always carries the full new appearance).
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct Parsed(uint Guid, CreateObject.ModelData ModelData);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse an ObjDescEvent body (must start with the 4-byte opcode).
|
||||||
|
/// Returns null on truncation or wrong opcode.
|
||||||
|
/// </summary>
|
||||||
|
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int pos = 0;
|
||||||
|
if (body.Length - pos < 4) return null;
|
||||||
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
|
pos += 4;
|
||||||
|
if (opcode != Opcode) return null;
|
||||||
|
|
||||||
|
if (body.Length - pos < 4) return null;
|
||||||
|
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
|
pos += 4;
|
||||||
|
|
||||||
|
var modelData = CreateObject.ReadModelData(body, ref pos);
|
||||||
|
|
||||||
|
// Trailing instanceSeq + visualDescSeq are read for completeness
|
||||||
|
// but not surfaced — subscribers re-render unconditionally on
|
||||||
|
// every event since each carries the full appearance.
|
||||||
|
return new Parsed(guid, modelData);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -139,6 +139,20 @@ public sealed class WorldSession : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<uint>? TeleportStarted;
|
public event Action<uint>? TeleportStarted;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires when the server broadcasts an <c>ObjDescEvent (0xF625)</c> —
|
||||||
|
/// a creature/player's appearance changed after the initial CreateObject
|
||||||
|
/// (equip / unequip / tailoring / recipe result / character option toggle).
|
||||||
|
/// Subscribers re-apply the new <c>ModelData</c> to the existing entity:
|
||||||
|
/// AnimPartChanges replace mesh refs, TextureChanges update per-part
|
||||||
|
/// surface texture overrides, and SubPalettes rebuild the palette
|
||||||
|
/// override (the channel that carries skin/hair tone). Without this,
|
||||||
|
/// retail-driven characters observed from acdream end up "stuck" at
|
||||||
|
/// whatever appearance was in their first CreateObject — see issue
|
||||||
|
/// notes in commit history around 2026-05-06.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<ObjDescEvent.Parsed>? AppearanceUpdated;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase H.1: fires when a local or ranged speech message (0x02BB /
|
/// Phase H.1: fires when a local or ranged speech message (0x02BB /
|
||||||
/// 0x02BC) is received. Subscribers typically feed these into a
|
/// 0x02BC) is received. Subscribers typically feed these into a
|
||||||
|
|
@ -314,12 +328,18 @@ public sealed class WorldSession : IDisposable
|
||||||
private readonly FragmentAssembler _assembler = new();
|
private readonly FragmentAssembler _assembler = new();
|
||||||
|
|
||||||
// Issue #5 diagnostics (env-var-gated):
|
// Issue #5 diagnostics (env-var-gated):
|
||||||
// ACDREAM_DUMP_OPCODES=1 → log first occurrence of each unhandled opcode
|
// ACDREAM_DUMP_OPCODES=1 → log first occurrence of each unhandled opcode
|
||||||
// ACDREAM_DUMP_VITALS=1 → log every PrivateUpdateVital(Current) parse
|
// ACDREAM_DUMP_VITALS=1 → log every PrivateUpdateVital(Current) parse
|
||||||
|
// ACDREAM_DUMP_APPEARANCE=1 → log every 0xF625 ObjDescEvent + 0xF7DB UpdateObject
|
||||||
|
// with body len, target guid, hex preview. Used to
|
||||||
|
// debug remote-player appearance asymmetry (retail
|
||||||
|
// observer in acdream renders wrong skin/hair).
|
||||||
private static readonly bool DumpOpcodesEnabled =
|
private static readonly bool DumpOpcodesEnabled =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_OPCODES") == "1";
|
Environment.GetEnvironmentVariable("ACDREAM_DUMP_OPCODES") == "1";
|
||||||
private static readonly bool DumpVitalsEnabled =
|
private static readonly bool DumpVitalsEnabled =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1";
|
Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1";
|
||||||
|
private static readonly bool DumpAppearanceEnabled =
|
||||||
|
Environment.GetEnvironmentVariable("ACDREAM_DUMP_APPEARANCE") == "1";
|
||||||
private readonly System.Collections.Generic.HashSet<uint> _seenUnhandledOpcodes = new();
|
private readonly System.Collections.Generic.HashSet<uint> _seenUnhandledOpcodes = new();
|
||||||
|
|
||||||
private IsaacRandom? _inboundIsaac;
|
private IsaacRandom? _inboundIsaac;
|
||||||
|
|
@ -861,6 +881,36 @@ public sealed class WorldSession : IDisposable
|
||||||
_teleportSequence = sequence; // track for outbound movement messages
|
_teleportSequence = sequence; // track for outbound movement messages
|
||||||
TeleportStarted?.Invoke(sequence);
|
TeleportStarted?.Invoke(sequence);
|
||||||
}
|
}
|
||||||
|
else if (op == ObjDescEvent.Opcode)
|
||||||
|
{
|
||||||
|
// 0xF625 ObjDescEvent — per-entity appearance update. ACE
|
||||||
|
// broadcasts on equip/unequip/tailoring/recipe/option-change
|
||||||
|
// (Creature_Equipment.cs:365, Tailoring.cs:504,
|
||||||
|
// RecipeManager.cs:403, GameActionSetSingleCharacterOption.cs:27).
|
||||||
|
// Retail handler: SmartBox::HandleObjDescEvent (named-retail
|
||||||
|
// 0x453340). Body layout: u32 opcode | u32 guid | ModelData |
|
||||||
|
// u32 instanceSeq | u32 visualDescSeq.
|
||||||
|
var parsed = ObjDescEvent.TryParse(body);
|
||||||
|
if (parsed is not null)
|
||||||
|
{
|
||||||
|
if (DumpAppearanceEnabled)
|
||||||
|
{
|
||||||
|
var md = parsed.Value.ModelData;
|
||||||
|
Console.WriteLine($"appearance: 0xF625 guid=0x{parsed.Value.Guid:X8} basePal=0x{(md.BasePaletteId ?? 0):X8} subPals={md.SubPalettes.Count} texChanges={md.TextureChanges.Count} animParts={md.AnimPartChanges.Count}");
|
||||||
|
foreach (var sp in md.SubPalettes)
|
||||||
|
Console.WriteLine($" SP id=0x{sp.SubPaletteId:X8} offset={sp.Offset} length={sp.Length}");
|
||||||
|
foreach (var tc in md.TextureChanges)
|
||||||
|
Console.WriteLine($" TC part={tc.PartIndex:D2} oldTex=0x{tc.OldTexture:X8} -> newTex=0x{tc.NewTexture:X8}");
|
||||||
|
foreach (var apc in md.AnimPartChanges)
|
||||||
|
Console.WriteLine($" APC part={apc.PartIndex:D2} -> gfx=0x{apc.NewModelId:X8}");
|
||||||
|
}
|
||||||
|
AppearanceUpdated?.Invoke(parsed.Value);
|
||||||
|
}
|
||||||
|
else if (DumpAppearanceEnabled)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"appearance: 0xF625 PARSE FAILED body.len={body.Length}");
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (DumpOpcodesEnabled)
|
else if (DumpOpcodesEnabled)
|
||||||
{
|
{
|
||||||
// ACDREAM_DUMP_OPCODES=1 — emit a one-line trace per
|
// ACDREAM_DUMP_OPCODES=1 — emit a one-line trace per
|
||||||
|
|
|
||||||
153
tests/AcDream.Core.Net.Tests/Messages/ObjDescEventTests.cs
Normal file
153
tests/AcDream.Core.Net.Tests/Messages/ObjDescEventTests.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
|
public sealed class ObjDescEventTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_RejectsWrongOpcode()
|
||||||
|
{
|
||||||
|
byte[] body = new byte[16];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xF745u); // CreateObject opcode
|
||||||
|
Assert.Null(ObjDescEvent.TryParse(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_RejectsTruncatedBody()
|
||||||
|
{
|
||||||
|
Assert.Null(ObjDescEvent.TryParse(new byte[3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round-trip a synthesized body: opcode + guid + ModelData (3 SubPalettes,
|
||||||
|
/// 4 TextureChanges, 0 AnimPartChanges — same shape as the captured retail
|
||||||
|
/// 152-byte +Je ObjDescEvent body) + trailing sequence pair. Verifies the
|
||||||
|
/// parser surfaces the same fields the writer wrote.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_SynthesizedBody_ExtractsGuidAndModelData()
|
||||||
|
{
|
||||||
|
// Build a body matching the wire shape we see from ACE.
|
||||||
|
var bytes = new List<byte>();
|
||||||
|
AppendU32(bytes, ObjDescEvent.Opcode);
|
||||||
|
AppendU32(bytes, 0x50000001u); // target guid
|
||||||
|
|
||||||
|
// ModelData header: marker, subPalCount, texCount, animPartCount.
|
||||||
|
bytes.Add(0x11);
|
||||||
|
bytes.Add(3); // subPalCount
|
||||||
|
bytes.Add(4); // texChangeCount
|
||||||
|
bytes.Add(0); // animPartCount
|
||||||
|
|
||||||
|
// BasePaletteId (palette type prefix stripped before packing).
|
||||||
|
AppendPackedDword(bytes, 0x0400007Eu, 0x04000000u);
|
||||||
|
|
||||||
|
// SubPalettes — three skin/hair-style overlays at varied offsets.
|
||||||
|
AppendPackedDword(bytes, 0x04001FE3u, 0x04000000u);
|
||||||
|
bytes.Add(24); bytes.Add(8);
|
||||||
|
AppendPackedDword(bytes, 0x040002BAu, 0x04000000u);
|
||||||
|
bytes.Add(0); bytes.Add(24);
|
||||||
|
AppendPackedDword(bytes, 0x040002BCu, 0x04000000u);
|
||||||
|
bytes.Add(32); bytes.Add(8);
|
||||||
|
|
||||||
|
// TextureChanges — four part textures.
|
||||||
|
for (byte partIdx = 0; partIdx < 4; partIdx++)
|
||||||
|
{
|
||||||
|
bytes.Add(partIdx);
|
||||||
|
AppendPackedDword(bytes, 0x05000100u + partIdx, 0x05000000u);
|
||||||
|
AppendPackedDword(bytes, 0x05000200u + partIdx, 0x05000000u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4-byte align after AnimPartChanges (none here, so just align).
|
||||||
|
while (bytes.Count % 4 != 0) bytes.Add(0);
|
||||||
|
|
||||||
|
// Trailing instance + visual-desc sequences (consumed but ignored).
|
||||||
|
AppendU32(bytes, 0x12345678u);
|
||||||
|
AppendU32(bytes, 0x9ABCDEF0u);
|
||||||
|
|
||||||
|
var parsed = ObjDescEvent.TryParse(bytes.ToArray());
|
||||||
|
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(0x50000001u, parsed!.Value.Guid);
|
||||||
|
|
||||||
|
var md = parsed.Value.ModelData;
|
||||||
|
Assert.Equal(0x0400007Eu, md.BasePaletteId);
|
||||||
|
Assert.Equal(3, md.SubPalettes.Count);
|
||||||
|
Assert.Equal(0x04001FE3u, md.SubPalettes[0].SubPaletteId);
|
||||||
|
Assert.Equal(24, md.SubPalettes[0].Offset);
|
||||||
|
Assert.Equal(8, md.SubPalettes[0].Length);
|
||||||
|
Assert.Equal(0x040002BAu, md.SubPalettes[1].SubPaletteId);
|
||||||
|
Assert.Equal(0, md.SubPalettes[1].Offset);
|
||||||
|
Assert.Equal(24, md.SubPalettes[1].Length);
|
||||||
|
|
||||||
|
Assert.Equal(4, md.TextureChanges.Count);
|
||||||
|
Assert.Equal(0, md.TextureChanges[0].PartIndex);
|
||||||
|
Assert.Equal(0x05000100u, md.TextureChanges[0].OldTexture);
|
||||||
|
Assert.Equal(0x05000200u, md.TextureChanges[0].NewTexture);
|
||||||
|
Assert.Equal(3, md.TextureChanges[3].PartIndex);
|
||||||
|
|
||||||
|
Assert.Empty(md.AnimPartChanges);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirms ReadModelData (the shared helper) round-trips identically
|
||||||
|
/// when called from CreateObject and from ObjDescEvent — same bytes,
|
||||||
|
/// same parsed output. Guards against the two callers drifting.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ReadModelData_SameOutputFromBothCallers()
|
||||||
|
{
|
||||||
|
// Bare ModelData block — used as a substring in both messages.
|
||||||
|
var modelDataBytes = new List<byte>();
|
||||||
|
modelDataBytes.Add(0x11);
|
||||||
|
modelDataBytes.Add(1); // subPalCount
|
||||||
|
modelDataBytes.Add(0); // texCount
|
||||||
|
modelDataBytes.Add(0); // animPartCount
|
||||||
|
AppendPackedDword(modelDataBytes, 0x0400007Eu, 0x04000000u);
|
||||||
|
AppendPackedDword(modelDataBytes, 0x04001084u, 0x04000000u);
|
||||||
|
modelDataBytes.Add(80); modelDataBytes.Add(12);
|
||||||
|
while (modelDataBytes.Count % 4 != 0) modelDataBytes.Add(0);
|
||||||
|
|
||||||
|
ReadOnlySpan<byte> span = modelDataBytes.ToArray();
|
||||||
|
int pos = 0;
|
||||||
|
var md = CreateObject.ReadModelData(span, ref pos);
|
||||||
|
|
||||||
|
Assert.Equal(0x0400007Eu, md.BasePaletteId);
|
||||||
|
Assert.Single(md.SubPalettes);
|
||||||
|
Assert.Equal(0x04001084u, md.SubPalettes[0].SubPaletteId);
|
||||||
|
Assert.Equal(80, md.SubPalettes[0].Offset);
|
||||||
|
Assert.Equal(12, md.SubPalettes[0].Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendU32(List<byte> dest, uint value)
|
||||||
|
{
|
||||||
|
Span<byte> tmp = stackalloc byte[4];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(tmp, value);
|
||||||
|
dest.AddRange(tmp.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirror of ACE's WritePackedDwordOfKnownType: strip the type prefix
|
||||||
|
/// if it matches <paramref name="knownType"/>, then write as a 16- or
|
||||||
|
/// 32-bit packed value.
|
||||||
|
/// </summary>
|
||||||
|
private static void AppendPackedDword(List<byte> dest, uint value, uint knownType)
|
||||||
|
{
|
||||||
|
uint packed = (value & 0xFF000000u) == knownType ? (value & ~knownType) : value;
|
||||||
|
if (packed <= 0x7FFFu)
|
||||||
|
{
|
||||||
|
Span<byte> tmp = stackalloc byte[2];
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(tmp, (ushort)packed);
|
||||||
|
dest.AddRange(tmp.ToArray());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ushort high = (ushort)((packed >> 16) | 0x8000);
|
||||||
|
ushort low = (ushort)(packed & 0xFFFFu);
|
||||||
|
Span<byte> tmp = stackalloc byte[4];
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(tmp, high);
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(tmp.Slice(2), low);
|
||||||
|
dest.AddRange(tmp.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue