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.
74 lines
2.9 KiB
C#
74 lines
2.9 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|