feat(net): L.3b — capture per-object friction + elasticity from CreateObject

Companion to L.3a (a1c27b3) which ported the velocity-reflection bounce.
Previously the CreateObject parser did `pos += 4` for both Friction and
Elasticity floats — silently dropping the wire data so every entity got
the PhysicsBody constructor default (0.05 elasticity, 0.5 friction).

Server-set bouncier surfaces or stickier objects therefore felt
identical to inert walls on collision. Inelastic projectiles via
PhysicsState bit 0x20000 (already plumbed in Commit A) had no per-
object elasticity to override.

Now the parser captures the floats, surfaces them on Parsed +
EntitySpawn, leaving the values at default (null) when their
PhysicsDescriptionFlag bits aren't set. Subscribers (e.g., the
remote-entity dead-reckoning path, future spell-projectile rendering)
can apply them when they wire elasticity to PhysicsBody.Elasticity.

The local player's PhysicsBody is constructed at controller init,
not from a CreateObject — so this commit alone produces no
user-visible local-player change. Effect lands when remote/projectile
physics consume EntitySpawn.Elasticity.

Files:
- CreateObject.cs:284-294: declare friction + elasticity accumulators.
- CreateObject.cs:467-487: parse floats instead of skipping.
- CreateObject.cs:543-555: propagate to Parsed via both return paths.
- WorldSession.cs:67-71: extend EntitySpawn record.
- WorldSession.cs:665-668: pipe through to subscribers.

Tests: 1491 still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-30 09:43:27 +02:00
parent a1c27b3afb
commit 851e88364d
2 changed files with 49 additions and 7 deletions

View file

@ -120,7 +120,13 @@ public static class CreateObject
ushort ServerControlSequence = 0, ushort ServerControlSequence = 0,
ushort ForcePositionSequence = 0, ushort ForcePositionSequence = 0,
uint? PhysicsState = null, uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null); uint? ObjectDescriptionFlags = null,
// L.3b (2026-04-30): per-object friction + elasticity from the
// wire. Default to null when their PhysicsDescriptionFlag bits
// weren't set; subscribers fall back to PhysicsBody constructor
// defaults (0.05f elasticity, 0.5f friction).
float? Friction = null,
float? Elasticity = null);
/// <summary> /// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> / /// The relevant subset of the server-sent <c>MovementData</c> /
@ -286,6 +292,13 @@ public static class CreateObject
// "ObjectDescriptionFlags" at the WeenieHeader trailer. // "ObjectDescriptionFlags" at the WeenieHeader trailer.
uint? physicsState = null; uint? physicsState = null;
uint? objectDescriptionFlags = null; uint? objectDescriptionFlags = null;
// L.3b (2026-04-30): per-object friction + elasticity. Wire-encoded
// when their PhysicsDescriptionFlag bits are set. Default values
// come from PhysicsBody constructors; these overrides drive the
// velocity-reflection bounce magnitude per object (e.g., bouncier
// platforms vs. inert walls).
float? friction = null;
float? elasticity = null;
try try
{ {
@ -453,8 +466,25 @@ public static class CreateObject
objScale = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); objScale = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4; pos += 4;
} }
if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0) pos += 4; if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0)
if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0) pos += 4; {
if (body.Length - pos < 4) return PartialResult();
friction = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0)
{
// L.3b (2026-04-30): capture instead of skipping. The wire
// float is the per-object elasticity used by the velocity-
// reflection bounce (CPhysicsObj::set_elasticity at
// acclient_2013_pseudo_c.txt:277817, clamped to [0, 0.1]).
// Was previously dropped — every object got the default
// 0.05f, so server-set bouncier surfaces felt identical to
// walls.
if (body.Length - pos < 4) return PartialResult();
elasticity = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0) pos += 4; if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0) pos += 4;
if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0) pos += 12; // vec3 if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0) pos += 12; // vec3
if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0) pos += 12; if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0) pos += 12;
@ -510,14 +540,18 @@ public static class CreateObject
return new Parsed(guid, position, setupTableId, animParts, return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId, textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq, instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
physicsState, objectDescriptionFlags); physicsState, objectDescriptionFlags,
friction, elasticity);
// Local helper: if we ran out of fields past PhysicsData, still // Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
Parsed PartialResult() => new( Parsed PartialResult() => new(
guid, position, setupTableId, animParts, guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId, textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId,
PhysicsState: physicsState, ObjectDescriptionFlags: objectDescriptionFlags); PhysicsState: physicsState,
ObjectDescriptionFlags: objectDescriptionFlags,
Friction: friction,
Elasticity: elasticity);
} }
catch catch
{ {

View file

@ -63,7 +63,13 @@ public sealed class WorldSession : IDisposable
// ObjectDescriptionFlags: retail PWD._bitfield (acclient.h:6431-6463) // ObjectDescriptionFlags: retail PWD._bitfield (acclient.h:6431-6463)
// — drives IsPlayer/IsPK/IsPKLite/IsImpenetrable for PvP gating. // — drives IsPlayer/IsPK/IsPKLite/IsImpenetrable for PvP gating.
uint? PhysicsState = null, uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null); uint? ObjectDescriptionFlags = null,
// L.3b (2026-04-30): per-object physics tuning from the wire.
// Friction defaults to PhysicsBody constructor value (0.5f).
// Elasticity defaults to 0.05f. When set, drives the velocity-
// reflection bounce magnitude (clamped to [0, 0.1] retail-side).
float? Friction = null,
float? Elasticity = null);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary> /// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned; public event Action<EntitySpawn>? EntitySpawned;
@ -657,7 +663,9 @@ public sealed class WorldSession : IDisposable
parsed.Value.MotionState, parsed.Value.MotionState,
parsed.Value.MotionTableId, parsed.Value.MotionTableId,
parsed.Value.PhysicsState, parsed.Value.PhysicsState,
parsed.Value.ObjectDescriptionFlags)); parsed.Value.ObjectDescriptionFlags,
parsed.Value.Friction,
parsed.Value.Elasticity));
} }
} }
else if (op == DeleteObject.Opcode) else if (op == DeleteObject.Opcode)