acdream/src/AcDream.Core.Net/Messages/ObjDescEvent.cs
Erik 8a5d77f7f4 feat(net): port retail physics spawn and event timestamps
Parse the complete PhysicsDesc plus F754/F755 packets, correct every PhysicsState bit, and gate all nine retail update channels with generation-safe immutable snapshots. Preserve ForcePosition, teleport, placement, velocity, parent, pickup, delete, and same-generation CreateObject ordering from the named client.

Separate accepted logical lifecycle notifications from retained UI qualities, make GUID replacement and session reset clear every projection exactly once, and add packet, wraparound, malformed-input, parent FIFO, canonical-position, reconnect, and GUID-reuse conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 00:22:17 +02:00

80 lines
3.2 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>u16 instanceSequence</item>
/// <item>u16 visualDescSequence (OBJDESC_TS)</item>
/// </list>
/// </summary>
public static class ObjDescEvent
{
public const uint Opcode = 0xF625u;
/// <summary>
/// One ObjDescEvent: target guid, new ModelData, and retail's packed
/// INSTANCE_TS/OBJDESC_TS pair.
/// </summary>
public readonly record struct Parsed(
uint Guid,
CreateObject.ModelData ModelData,
ushort InstanceSequence,
ushort ObjDescSequence);
/// <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);
// PhysicsTimestampPack::UnPack 0x00516F50 is exactly two u16s.
// Reject a missing or overlong tail so cursor mistakes cannot turn
// into an apparently valid, ungated appearance update.
if (body.Length - pos != 4) return null;
ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
ushort objDesc = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2));
return new Parsed(guid, modelData, instance, objDesc);
}
catch
{
return null;
}
}
}