Commit graph

4 commits

Author SHA1 Message Date
Erik
9d4967a461 fix(core): ACME cross-check fixes — normals, placement, scenery
Four fixes from the ACME StaticObjectManager cross-reference:

1. GfxObjMesh: normalize vertex normals (1d). Dat normals may not be
   unit-length; without normalization, lighting is wrong per-vertex.

2. SetupMesh: add third-fallback placement frame (2a). If neither
   Resting nor Default exists, use the first available frame from
   PlacementFrames. Matches ACME's GetDefaultPlacementFrame.

3. SceneryGenerator: building cell exclusion (4d). Compute which
   terrain vertices have buildings (from LandBlockInfo.Objects +
   Buildings), skip scenery spawns in those cells. Prevents trees
   from spawning inside building footprints.

4. SceneryGenerator: slope filter (4e). Compute terrain normal Z at
   each displaced position and check against ObjectDesc.MinSlope /
   MaxSlope bounds. Prevents trees from spawning on cliff faces.

Also confirmed 4f (scenery Z=0) is NOT a bug — GameWindow's hydrator
lifts scenery to terrain Z at line 1213. The Z=0 in SceneryGenerator
is a placeholder correctly overridden at render time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:52:08 +02:00
Erik
090d265a4e feat(core+app): Phase 6.1 — resolve idle motion frame from MotionTable
Walks each entity's Setup → MotionTable → Animation chain to get the
per-part frame for its default idle pose, then uses that frame in
SetupMesh.Flatten instead of the static PlacementFrames lookup. For
creatures and characters this should produce the upright "Resting"
pose (e.g. for the Nullified Statue of a Drudge) instead of the
Setup-default crouch.

This is a minimal Phase 6 cut: render the FIRST frame of the IDLE
motion as a static pose. No per-frame interpolation, no walking, no
attack motions, no transitions. Those are larger pieces tracked in
docs/plans/2026-04-11-roadmap.md under Phase 6.

Algorithm ported from references/ACViewer/.../Physics/Animation/
MotionTable.SetDefaultState:

  1. Look up Setup.DefaultMotionTable (0x09XXXXXX). 0 → no motion,
     fall back to PlacementFrames.
  2. MotionTable.StyleDefaults[DefaultStyle] → default substate.
  3. cycleKey = (DefaultStyle << 16) | (substate & 0xFFFFFF)
  4. MotionTable.Cycles[cycleKey] → MotionData.
  5. MotionData.Anims[0].AnimId → Animation dat.
  6. Animation.PartFrames[animData.LowFrame] → AnimationFrame
     containing the per-part transforms for the idle pose.

Added:
  - Core/Meshing/MotionResolver.cs: pure function GetIdleFrame(setup,
    dats) that walks the chain and returns an AnimationFrame or null.
    null is the "no motion data" sentinel and means caller should fall
    back to PlacementFrames.
  - SetupMesh.Flatten now takes an optional AnimationFrame override
    parameter. Pose source priority is:
      override → PlacementFrames[Resting] → PlacementFrames[Default]
    So existing call sites that don't pass an override get the Phase 5d
    Resting-fallback behavior unchanged. Static scenery is unaffected.
  - GameWindow.OnLiveEntitySpawned (live-mode hydrator) calls
    MotionResolver.GetIdleFrame and passes the result to Flatten.

Other Flatten call sites (offline scenery, interior EnvCells, scenery
generator) NOT yet wired — those use static dat hydration where the
entities don't have meaningful motion tables. The user-visible win
from this commit is in the live spawn pipeline only.

Things I'm not certain about and will check via the live run:
  - Whether Animation.PartFrames are in entity-root-relative space
    (matching PlacementFrames) or parent-relative (would need a parent
    walk we don't do). ACViewer's UpdateParts applies frames per-part
    without walking parents, suggesting root-relative — same convention
    as PlacementFrames.
  - Whether the resolver's null fallback is hit for creatures whose
    Setup.DefaultMotionTable happens to be 0 (would silently regress
    to Default placement). Worth checking if drudge looks the same.

Tests: 77 core + 83 net = 160, all green. No new tests yet because
the change is data-driven and best validated end-to-end via the live
run rather than synthetic dat fixtures (which would require fabricating
a complete MotionTable + Animation chain just to test the lookup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:36:59 +02:00
Erik
82aa2ba1d9 fix(core): SetupMesh prefers Placement.Resting over Default (Phase 5d)
The Nullified Statue of a Drudge renders correctly in scale + color +
texture after Phases 5a/b/c, but the user reported the figure on top
looks like the wrong drudge model — specifically the pose is wrong.
acdream shows a hunched aggressive crouch with arms forward, retail
shows an upright statue stance with arms at sides. Same drudge mesh,
different pose.

Diagnosis from a targeted statue dump:
  [STATUE] objScale=3.500
  [STATUE] base Setup 0x020007DD has 17 parts (full drudge body rig)
  [STATUE] animPart index=1 newModel=0x01001B91   ← NO-OP, same as default
  [STATUE]   placementFrames count=1

The animPart change is a no-op (replaces part 1 with the id it already
has). The Setup is the standard drudge body. So the difference HAS to
come from the per-part placement frame. With only 1 placement frame,
there's exactly one pose to use — and our SetupMesh.Flatten only checks
Placement.Default.

Fix found by reading ACViewer's Physics/PartArray.cs::CreateMesh:

  public static PartArray CreateMesh(PhysicsObj owner, uint setupDID) {
      var mesh = new PartArray();
      ...
      if (!mesh.SetMeshID(setupDID)) return null;
      mesh.SetPlacementFrame(0x65);     // ← always Resting after create
      return mesh;
  }

0x65 = 101 = Placement.Resting. ACViewer puts EVERY mesh into the
Resting pose immediately after creation, regardless of object type.
For drudges/characters/creatures:
  - Default = aggressive battle crouch (what we render)
  - Resting = upright idle pose (what retail's statue actually shows)

The statue's single placement frame is keyed by Resting, so our
"only check Default" code returned no frame and the parts ended up at
Setup-root with identity orientation — which happened to look like a
clawing-forward pose because each part's local mesh starts in roughly
that shape.

Fix: SetupMesh.Flatten now tries Resting first and falls back to
Default. Static scenery setups (which only define Default) are
unaffected; creatures and characters now render in their proper idle
pose. One-line conceptual change with effects on every multi-part
live entity in the world.

The reference-priority rule in CLAUDE.md saved me here: I'd already
chased this through ACE.Server and DatReaderWriter looking for a parent-
hierarchy walking algorithm before checking ACViewer's Physics/PartArray.
The pose fix lives in ACViewer's renderer, exactly where I'd expect
"the canonical client-side visual pipeline" per the rule's wording.

Tests: 77 core + 83 net = 160, all green. Existing scenery
SetupMesh tests still pass because their setups only define Default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:21:13 +02:00
Erik
8f5b498be6 feat(core): add SetupMesh.Flatten for single-level part hierarchy 2026-04-10 18:01:16 +02:00