feat(net+app): apply ObjScale from PhysicsData (Phase 5c)

The Nullified Statue of a Drudge renders correctly in color/shape
after Phase 5b's SubPalette fix, but the user reported it's rendering
at base drudge size when it should be larger. AC statues use the
PhysicsDescriptionFlag.ObjScale field to scale the base mesh; my
parser was consuming-and-skipping those 4 bytes.

Changes:
  - CreateObject.TryParse: extract the u32 float from the ObjScale
    field instead of advancing past it. Declaration moved to the top
    of the method alongside other accumulators so the PartialResult
    local function at the bottom can reference it for the truncation
    fallback path. Same structural change for position and setupTableId
    since PartialResult already needed them too.
  - CreateObject.Parsed gains ObjScale (float?).
  - WorldSession.EntitySpawn gains ObjScale; propagated through the
    fire site in ProcessDatagram.
  - GameWindow.OnLiveEntitySpawned bakes a scale matrix into every
    MeshRef's PartTransform when ObjScale != 1.0, following the same
    pattern the offline scenery hydration already uses. No change to
    WorldEntity or StaticMeshRenderer — the scale is absorbed into the
    per-part transform the renderer already multiplies through.

Tests: 77 core + 83 net = 160, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 18:01:14 +02:00
parent 733f8ff601
commit f67f7851e6
3 changed files with 32 additions and 7 deletions

View file

@ -684,6 +684,14 @@ public sealed class GameWindow : IDisposable
}
}
// Apply ObjScale by baking a scale matrix into each MeshRef's
// PartTransform. Scenery hydration already does this pattern
// (scaleMat baked into PartTransform at Setup flatten time).
// Fallback to 1.0 if the server didn't send ObjScale (common for
// creatures/characters whose size is intrinsic to the mesh).
float scale = spawn.ObjScale ?? 1.0f;
var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale);
var meshRefs = new List<AcDream.Core.World.MeshRef>();
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
{
@ -697,7 +705,11 @@ public sealed class GameWindow : IDisposable
if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
surfaceOverrides = partOverrides;
meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform)
// Pre-multiply scale so it happens FIRST (local mesh coords) before
// the part-local transform and the entity-root transform.
var transform = scale == 1.0f ? mr.PartTransform : scaleMat * mr.PartTransform;
meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, transform)
{
SurfaceOverrides = surfaceOverrides,
});

View file

@ -89,6 +89,7 @@ public static class CreateObject
IReadOnlyList<TextureChange> TextureChanges,
IReadOnlyList<SubPaletteSwap> SubPalettes,
uint? BasePaletteId,
float? ObjScale,
string? Name);
/// <summary>
@ -134,6 +135,13 @@ public static class CreateObject
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
// Accumulators declared at the top so PartialResult (local function
// at the bottom) can reference them before they're conditionally
// populated — C# rejects forward references otherwise.
ServerPosition? position = null;
uint? setupTableId = null;
float? objScale = null;
try
{
int pos = 0;
@ -229,7 +237,6 @@ public static class CreateObject
pos += 4;
}
ServerPosition? position = null;
if ((physicsFlags & PhysicsDescriptionFlag.Position) != 0)
{
if (body.Length - pos < 32) return null;
@ -263,7 +270,6 @@ public static class CreateObject
pos += 4;
}
uint? setupTableId = null;
if ((physicsFlags & PhysicsDescriptionFlag.CSetup) != 0)
{
if (body.Length - pos < 4) return null;
@ -288,7 +294,12 @@ public static class CreateObject
if (body.Length - pos < childCount * 8) return null;
pos += childCount * 8; // each child = guid u32 + locationId u32
}
if ((physicsFlags & PhysicsDescriptionFlag.ObjScale) != 0) pos += 4;
if ((physicsFlags & PhysicsDescriptionFlag.ObjScale) != 0)
{
if (body.Length - pos < 4) return PartialResult();
objScale = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0) pos += 4;
if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0) pos += 4;
if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0) pos += 4;
@ -316,13 +327,13 @@ public static class CreateObject
}
return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, name);
textureChanges, subPalettes, basePaletteId, objScale, name);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes).
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale).
Parsed PartialResult() => new(
guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, null);
textureChanges, subPalettes, basePaletteId, objScale, null);
}
catch
{

View file

@ -50,6 +50,7 @@ public sealed class WorldSession : IDisposable
IReadOnlyList<CreateObject.TextureChange> TextureChanges,
IReadOnlyList<CreateObject.SubPaletteSwap> SubPalettes,
uint? BasePaletteId,
float? ObjScale,
string? Name);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
@ -234,6 +235,7 @@ public sealed class WorldSession : IDisposable
parsed.Value.TextureChanges,
parsed.Value.SubPalettes,
parsed.Value.BasePaletteId,
parsed.Value.ObjScale,
parsed.Value.Name));
}
}