feat(runtime): readable joint bend and morph blend for the girls' bodies

Merged
TM
5d ago
+1278-4

Summary

Two game-owned rendering routines, rewritten as readable source and checked identical to the generated code:

  1. Joint bend (0x00067540, which calls 0x00067630 per joint). Each frame it bends a girl's joint pieces (ankles, elbows, wrists, toes):
    • welds each joint's rings to the neighbouring pieces;
    • builds per-joint frames: the parent's, the child's and halfway;
    • places the interior rings through frames blended along a quadratic between them.
    • Files: body_joints.c/.h, body_joints_adapter.c/.h, body_joints_test.c, docs/body-joints.md.
  2. Morph blend (0x00064FD0). It blends one morph record: a girl's hand gestures, face, chest and the swimsuit pieces over it.
    • A dirty record is cleared, the model's vertex buffer is locked, and one, two or three stored shapes are written into it, positions then normals.
    • Sums are in double, rounded once to float, as the game's x87 kernels do; the mirrored kind negates x.
    • Files: body_morph.c/.h, body_morph_adapter.c/.h, body_morph_test.c, docs/body-morph.md. The docs page also records the morph sets on the disc: each hand's 31–35 shapes, with shape 24 a fist and 14 a flat hand.

The maths in each core file has nothing of the guest; the adapters run it over the game's records.

  • Seams: the joint bend calls no game code. The morph blend calls the D3D vertex buffer lock (0x001E4D00), library code, at a seam documented beside the lookup and in the docs.
  • Recipe: both addresses are manual call targets. Regenerating changes only recomp_0002.c, whose direct calls become safe indirect calls. The recipe.json hashes and RECIPE_SHA256 are updated to match.

Verification

  • Same-state checks: RECOMP_BODY_JOINTS_VERIFY=1 and RECOMP_BODY_MORPH_VERIFY=1 run the generated code, then the readable code, from the same state on every call.
    • The joint check compares every byte either one writes. It excludes six w lanes the game fills from a stack word it never wrote, which no vertex reads.
    • The morph check compares the record, the locked buffer word, every vertex written, and the lock's resource and returned buffer.
    • The morph check does not compare the push buffer the lock writes to. The library lock may kick the push buffer and wait on a GPU fence; a second lock in the same check finds the fence the first one left and skips the kick. So the push buffer is not compared, and the lock's resource and result are checked instead.
  • Attract demo on this branch (macOS, Release, generated-program manifest 423110ef…):
    • joint bend: 11400 calls, 0 differing;
    • morph blend: 12000 calls, 0 differing.
  • Negative controls:
    • a one-ulp error planted in a joint weight was caught on the first call;
    • a one-ulp change to the morph blend's weight A was caught on the first two-shape blend.
  • Regeneration: pinned lifter 7adaf21. Only recomp_0002.c differs from main's recipe, and EBP overrides stay at 10824.
  • Public CI steps, run locally on macOS:
    • CMake build and CTest without private inputs: 32 tests pass, custom-music skipped. This includes the new recomp-runtime-body-joints and recomp-runtime-body-morph.
    • public_export.py verify --require-public-tree passes.
    • python -m unittest discover -s tools: 64 tests, OK (1 skipped), with TMPDIR set to a non-symlinked path. With macOS's default /var temp symlink, three texture/AFS tests stop at "Output must not traverse a symlink or junction". That comes from the environment, not this change.
    • Capstone here is 5.0.7, not the 5.0.9 that CI uses.
  • No play test beyond the demo. Both outputs are byte-identical to the generated code's, so rendering should not change.

Contribution checklist

  • The change follows AGENTS.md and CONTRIBUTING.md.
  • No XBE, generated game C, retail instruction bytes, extracted assets or
    filenames, saves, BIOS data, private paths, or run evidence are included.
  • Third-party material and its license are identified, if applicable (none).