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
|
|
@ -269,6 +269,18 @@ public static class CreateObject
|
|||
/// </summary>
|
||||
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>
|
||||
/// Parse a reassembled CreateObject body. <paramref name="body"/> must
|
||||
/// 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);
|
||||
|
||||
// --- ModelData ---
|
||||
// Header: byte 0x11 marker, byte subPalettes, byte textureChanges, byte animPartChanges
|
||||
if (body.Length - pos < 4) return null;
|
||||
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) 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);
|
||||
var modelData = ReadModelData(body, ref pos);
|
||||
uint? basePaletteId = modelData.BasePaletteId;
|
||||
var subPalettes = modelData.SubPalettes;
|
||||
var textureChanges = modelData.TextureChanges;
|
||||
var animParts = modelData.AnimPartChanges;
|
||||
|
||||
// --- PhysicsData ---
|
||||
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)
|
||||
{
|
||||
if (source.Length - pos < 4) throw new FormatException("truncated u32");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue