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.
153 lines
5.8 KiB
C#
153 lines
5.8 KiB
C#
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());
|
|
}
|
|
}
|
|
}
|