Commit graph

203 commits

Author SHA1 Message Date
Erik
2a491c6f92 test(N.3): conformance tests proving WB TextureHelpers matches our decode
Nine tests covering INDEX16 (normal + clipmap), P8, A8R8G8B8, R8G8B8,
A8Additive (matches our current DecodeA8), A8 non-additive (documents
the divergence), R5G6B5, A4R4G4B4. All run before any substitution --
they prove equivalence, not test the substitution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 11:27:39 +02:00
Erik
b0ec6deb50 phase(N.1): delete legacy scenery code path; WB is the only path
Phase N.1 step 8 (final code cleanup): now that ACDREAM_USE_WB_SCENERY
has been default-on (commit b84ecbd), remove the legacy in-line
algorithms so we don't accumulate dead-code drift.

Deleted:
- SceneryGenerator.UseWbScenery (feature flag)
- SceneryGenerator.IsOnRoad / DisplaceObject / RoadHalfWidth (legacy
  ports — Generate used to call them)
- The legacy in-line implementation in Generate()
- SceneryGeneratorTests.DisplaceObject_* (test the deleted method)
- SceneryWbConformanceTests.cs entirely (purpose served — proved
  equivalence pre-migration; would compare WB to WB after delete)

Renamed:
- GenerateViaWb -> GenerateInternal (it's the only path now)

Kept:
- Public IsRoadVertex predicate (small surface, useful)
- WbSceneryAdapter (consumed by GenerateInternal)
- All WbSceneryAdapterTests (still cover the adapter)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:37:55 +02:00
Erik
4bfcb2b190 phase(N.1): per-helper conformance tests for WB substitutions (rotation excluded)
Phase N.1 step 3: prove our inline algorithms match WorldBuilder's
helpers for representative inputs including the 0xA9B1 edge-vertex case.

Four conformance tests pass: Displace, OnRoad, GetNormalZ, ScaleObj.
Our hand-ported algorithms match WB's helpers exactly for these.

Rotation is intentionally NOT conformance-tested. Investigation against
retail's Frame::set_heading (named-retail 0x00535e40) and
Frame::set_vector_heading (0x00535db0) showed our acdream port uses a
shortcut formula `yawDeg = -(450-degrees)%360` that diverges from
retail's atan2 round-trip by ~180°. WorldBuilder's SetHeading ports
the round-trip faithfully and matches retail. Our existing port is
wrong — undetectable visually because per-tree rotation noise masks
the offset. The migration to WB.SceneryHelpers.RotateObj fixes this
bug; adding a conformance test would lock in the wrong behavior.

Bumps IsOnRoad to internal so the OnRoad conformance test can call it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:53:00 +02:00
Erik
26cf2b84e7 phase(N.1): add LandBlock → TerrainEntry[] adapter
Phase N.1 step 1: WbSceneryAdapter.BuildTerrainEntries converts our
LandBlock dat type into the TerrainEntry[81] shape WorldBuilder's
TerrainUtils / SceneryRenderManager consume.

Field mapping (TerrainInfo → TerrainEntry):
  TerrainInfo.Road    (bits 0-1)   → TerrainEntry.Road
  TerrainInfo.Type    (bits 2-6)   → TerrainEntry.Type
  TerrainInfo.Scenery (bits 11-15) → TerrainEntry.Scenery
  LandBlock.Height[i]              → TerrainEntry.Height

The spec listed the texture property as 'Texture' but TerrainEntry's
actual property is named 'Type' (confirmed from source). The spec also
described LandBlock.Terrain as ushort[81] but it is TerrainInfo[81] —
DatReaderWriter already decodes the bit fields so the adapter uses
TerrainInfo's named properties rather than raw bit-shift expressions.

Spec: docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:11:59 +02:00
Erik
833d167ebc fix(scenery): #49 9×9 loop, per-spawn building check, triangle slope
Three fixes to match retail CLandBlock::get_land_scenes (0x00530460):

1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8
   cells. Edge vertices (x=8 or y=8) produce valid spawns when the
   per-object displacement shifts the position back into [0, 192).
   Confirmed by named retail decomp do-while condition, WorldBuilder
   vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9].

2. Building suppression: check at the DISPLACED position's cell
   (CSortCell::has_building per spawn), not at the loop vertex index.
   Matches WorldBuilder buildingsGrid[gx2, gy2] pattern.

3. Slope filter: replace finite-difference gradient approximation
   with triangle-aware normal sampling via new static method
   TerrainSurface.SampleNormalZFromHeightmap. Picks the correct
   triangle via IsSplitSWtoNE, matching retail find_terrain_poly →
   polygon->plane.N.z and WorldBuilder's GetNormal().

Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1,
cross-validates with SampleSurface instance method) and DisplaceObject
edge-vertex validity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 21:15:11 +02:00
Erik
a4693954d8 fix(scenery): #48 unify scenery Z with physics-path triangle picker
Closes #48. Trees on sloped cells visibly hovered above the visible
terrain because GameWindow.SampleTerrainZ (the bilinear fallback used
during scenery hydration before physics registers a landblock) had
its diagonal arms swapped — used the SEtoNW triangle test on SWtoNE
cells and vice versa. The ACDREAM_DUMP_SCENERY_Z=1 diagnostic showed
every scenery line ran through the bilinear path (streaming race),
so on hilly terrain scenery was placed at a Z up to ~1.5 m off from
the visible mesh.

Latent since ff325ab (2026-04-17 "feat(ui): debug overlay + refined
input controls" carrying along the upgrade). That commit reached for
WorldBuilder TerrainUtils.GetHeight as the secondary oracle and
re-derived the triangle-pair tests; the named-retail / ACE algorithm
in TerrainSurface.SampleZ (used by the physics path / player Z) was
always correct, so player feet stayed flush — the two paths just
disagreed and only scenery noticed.

Fix:
- TerrainSurface.InterpolateZInTriangle (private static) — single
  source of truth for the triangle pick + barycentric Z, sourced
  from FUN_00532a50 / ACE LandblockStruct.ConstructPolygons.
- TerrainSurface.SampleZFromHeightmap (public static) — heightmap-
  byte-array variant for the scenery hydration fallback. Both this
  and TerrainSurface.SampleZ (instance) now delegate to the same
  InterpolateZInTriangle.
- GameWindow.SampleTerrainZ — thin wrapper over the new static.
- TerrainSurfaceTests.SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock
  asserts both sampler paths agree at 1500 sample points across both
  diagonals, so future drift gets caught.

The ACDREAM_DUMP_SCENERY_Z=1 diagnostic in BuildSceneryEntitiesForStreaming
is kept committed (env-var gated, zero cost when off) — useful for
the related #49 scenery (X, Y) placement investigation filed in the
same commit.

Visual verified at Holtburg landblock 0xA9B30001 2026-05-06: the
formerly floating 32 m pines (setups 0x020002D3 / 0x020002D9) now
sit flush on the visible terrain mesh.

Test baseline: dotnet test reports the same 8 pre-existing motion /
BSP step-up failures as the handoff doc warned about — no new
failures introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:30:25 +02:00
Erik
71b1622293 fix(camera): #38 render-interpolate player motion
Keep local physics authoritative at the retail 30 Hz MinQuantum, but expose a render-only position that lerps between completed physics ticks for the player mesh and chase-camera target. Network outbound continues to use the discrete physics position.

Also make the visually confirmed #47 humanoid close-detail DIDDegrade path default-on, with ACDREAM_RETAIL_CLOSE_DEGRADES=0 left as a diagnostic opt-out.

Verification: dotnet build AcDream.slnx -c Debug; focused #38 interpolation tests passed; visual confirmed smooth 2026-05-06. Full dotnet test AcDream.slnx -c Debug --no-build still has the known 8 AcDream.Core.Tests baseline failures.

Co-authored-by: Codex <codex@openai.com>
2026-05-06 17:53:34 +02:00
Erik
0bd9b9693b fix(rendering): #47 — walk DIDDegrade for retail close-detail meshes
Humanoid bodies (Setup 0x02000001 + heritage variants) rendered visibly
flat / bulky vs retail because we drew the base GfxObj id from Setup /
AnimPartChange directly. Retail's CPhysicsPart::LoadGfxObjArray
(0x0050DCF0) treats that base id as the entry point to a DIDDegrade
table; close/player rendering uses Degrades[0].Id, which is the
higher-detail mesh that carries bicep / deltoid / shoulder geometry.

ACViewer also has this bug — it was the key signal it isn't acdream-
specific. Both clients drew the LOD-3 base mesh (e.g. 14 verts / 17
polys for Aluvian Male upper arm 0x01000055), missing the close-
detail variant (0x01001795: 32 verts / 60 polys).

Adds GfxObjDegradeResolver that walks the table with safe fallbacks
at every step. Wired in GameWindow after AnimPartChange application
and before texture-change resolution so texture overrides match the
resolved mesh's surfaces. Gated by ACDREAM_RETAIL_CLOSE_DEGRADES=1
and scoped to humanoid setups (34 parts with >=8 null-sentinel
attachment slots) while the fix bakes — the change is harmless on
non-humanoid setups (resolver falls back to base when no degrade
table) but we hold the broader sweep until LOD distance plumbing
lands.

User confirmed visually 2026-05-06: bicep, deltoid, and back-muscle
definition match retail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:46:23 +02:00
Erik
e471527924 feat(net): wire 0xF625 ObjDescEvent for live appearance updates
Retail-driven players observed from acdream rendered with stale
appearance — wrong skin/hair palettes, missing clothing — because
ACE's mid-session appearance broadcasts (equip/unequip/tailoring/
recipe/option-toggle) ride opcode 0xF625 ObjDescEvent and acdream
silently dropped them. Initial CreateObject carries the appearance
at spawn time, but every later equip change only updates via 0xF625
(per Skunkwors protocol docs in ACE/.../GameMessageObjDescEvent.cs).
Retail handles via SmartBox::HandleObjDescEvent (named-retail 0x453340).

Why: the retail observer sees the *server-relayed* view of remotes,
not retail's local build, so dropping ObjDescEvent freezes appearance
at the partial state in the first CreateObject.

How:
- Extract CreateObject's ModelData parsing into reusable
  CreateObject.ReadModelData(span, ref pos) returning
  (BasePaletteId, SubPalettes, TextureChanges, AnimPartChanges).
- Add ObjDescEvent.cs (parser for 0xF625):
  body = u32 opcode | u32 guid | ModelData | u32 instanceSeq | u32 visualDescSeq.
- WorldSession.AppearanceUpdated event + dispatcher branch.
- GameWindow.OnLiveAppearanceUpdated splices new ModelData onto the
  cached spawn and replays via OnLiveEntitySpawned. The dedup at the
  start of OnLiveEntitySpawnedLocked tears down the old GPU/animated/
  collision state cleanly before rebuild.
- _lastSpawnByGuid cache populated at spawn-end and tracked through
  UpdatePosition so re-applies use current position (no pop-back to
  login spot on equip toggle).
- ACDREAM_DUMP_APPEARANCE=1 env var prints structured SP/TC/APC
  decode for every 0xF625 — replaces the earlier raw-hex preview.
- ACDREAM_DUMP_CLOTHING extended with setup.Parts.Count, flatten.Count,
  and per-part triangle counts for offline polygon-budget audit.

Tests: 4 new ObjDescEvent tests (round-trip + parser drift guard);
269 net tests green. User-verified live: skin/hair colors match
retail's character data; equip/unequip no longer pops position.

Note: a separate "puffy arms / bulky body" geometry issue remains
where base body parts visibly overlap clothing meshes — different
root cause, tracked separately.
2026-05-06 10:46:14 +02:00
Erik
9e4772a8f8 fix(motion): project anim root motion onto terrain plane (slope staircase)
Grounded player remotes were showing a ~5 Hz Z staircase when running
up/down slopes — the rate of server UpdatePositions. Body Z stayed flat
between UPs, then ramped over ~100ms during the queue-active chase to
each new server position, then went flat again until the next UP.

Diagnosis (no diagnostic needed — the math is unambiguous):
PositionManager.ComputeOffset has two modes via
InterpolationManager.AdjustOffset:

  - Queue active (body chasing a waypoint): returns
    `(head − body) / dist × min(catchUpSpeed × dt, dist)`. 3D direction,
    Z follows server's reported Z naturally.
  - Queue empty / head-reached (within DESIRED_DISTANCE = 0.05m of the
    most recent UP): returns Vector3.Zero. ComputeOffset falls back to
    `seqVel × dt rotated into world` — pure animation root motion. Every
    locomotion cycle bakes Z=0 in body-local, so the world result has
    Z=0 too. XY advances at the running pace; Z stays at the last UP.

For a runner at maxSpeed ≈ 4 m/s with catchUpSpeed = 2× = 8 m/s and
server UPs at ~5 Hz, body covers ~0.8m per UP, chases for ~100ms
(queue-active 3D path, Z ramps), then sits in seqVel-only mode for
~100ms (Z flat) until the next UP. Visible as a 5 Hz Z staircase.

Fix mirrors retail's CTransition::adjust_offset contact-plane projection
(named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded
motion, applied at the queue-empty boundary instead of inside the sweep:

  PositionManager.ComputeOffset gains an optional Vector3? terrainNormal.
  When the seqVel-only fallback runs AND a non-trivial terrain normal is
  supplied, project rootMotionWorld onto the plane:

      result = rootMotionWorld − N × dot(rootMotionWorld, N)

  Anim XY motion gains a corresponding Z component proportional to slope
  angle × forward speed, so body Z follows the terrain mesh between UPs.
  No-op on flat ground (N ≈ +Z, dot ≈ 0); cannot regress L.3 M2's
  flat-ground verification.

GameWindow.TickAnimations grounded-remote path samples
PhysicsEngine.SampleTerrainNormal at the body's current XY each tick
and passes it to ComputeOffset. SampleTerrainNormal is a thin public
wrapper over the existing internal SampleTerrainWalkable that returns
just the plane normal (no need to expose the internal sample shape).

Diagnostic: ACDREAM_SLOPE_DIAG=1 prints a per-tick [SLOPE] line with
guid, body Z before/after, offset, queue active flag, and the sampled
plane Nz so we can grep before/after the fix and confirm Z changes
continuously between UPs on slopes.

Tests: PositionManagerTests gains two cases:
  - slope projection: 30° east-tilted plane, body running due east at
    4 m/s for 1s → expect (3.0, 0, −1.732) (descends along slope, not
    flat). Math: dot(seqVel, N) = 2.0 → result = (4,0,0) − (0.5,0,0.866)
    × 2.0 = (3.0, 0, −1.732).
  - flat-ground no-op: N = +Z, expect identical Y-only motion as the
    pre-fix behavior.

Build green. 357 pass / 6 pre-existing fail (same set as ec59a08;
verified by stashing this change). The pre-existing
`ComputeOffset_BothActive_Combined` failure reflects an outdated
additive-design test docstring; the M2 commit (40d88b9) deliberately
changed the implementation to REPLACE semantics to fix the prior
3×-server-pace overshoot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:37:42 +02:00
Erik
ec59a08db5 fix(physics): #42 skip self in FindObjCollisions — airborne XY drift
Root cause confirmed via two-run diagnostic and the named-retail decomp:
the airborne sweep was colliding with the moving entity's OWN ShadowEntry
because FindObjCollisions had no self-skip filter. Live entities (local
player, remotes) register a Cylinder in ShadowObjectRegistry on spawn
(GameWindow.cs:2545) and UpdatePosition tracks its world position each
tick, so the moving sphere's own cylinder is always at the body's
position. Without a gate, CylinderCollision sees the sphere overlapping
its own cylinder volume and slides the sphere ~1m horizontally on every
frame the path produces non-zero motion.

Why grounded mostly hides it and airborne exposes it:
- Stationary grounded → numSteps=0, TransitionalInsert never runs.
- Walking grounded → push fires but motion escapes the cyl radius and
  the deflection blends into normal motion.
- Stationary airborne (jump) → pure +Z motion; the cyl push is the
  only horizontal contribution and manifests as a clean ~1m drift.

Run-2 evidence (launch-42-r2.log) — 152 [SWEEP-OBJ] events, every one
with type=Cylinder, gfxObj=0x02000001 (humanoid setup), R=0.679,
H=1.835, at obj.Position EXACTLY matching the body's pre.Position. Run
1 had already ruled out H1 (cpN=(0,0,1) flat, no slope projection).

Retail does the same skip — CObjCell::find_obj_collisions at
named-retail acclient_2013_pseudo_c.txt:308931:

    if ((physobj->parent == 0 && physobj != arg2->object_info.object))

`arg2->object_info.object` is the OBJECTINFO::object self-pointer set
by OBJECTINFO::init at acclient_2013_pseudo_c.txt:274435. Our port
mirrors this with an EntityId-based filter:

  - ObjectInfo gains a SelfEntityId field (default 0 = no filter).
  - ResolveWithTransition gains an optional `uint movingEntityId = 0`
    parameter that sets it.
  - FindObjCollisions skips entries whose EntityId matches
    SelfEntityId when the id is non-zero.
  - PlayerMovementController gains a LocalEntityId property; GameWindow
    refreshes it per-tick from `_entitiesByServerGuid[_playerServerGuid]`.
  - GameWindow's airborne-remote ResolveWithTransition call site passes
    `movingEntityId: kv.Key` (kv.Key is the local entity id keying
    `_animatedEntities`, same id used at the spawn-time
    ShadowObjects.Register).

Default 0 keeps tests and one-shot callers (no registered ShadowEntry)
working unchanged.

Lock-the-fix unit test:
`PhysicsEngineTests.ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches`
registers a humanoid Cylinder at the body's exact position (matching
GameWindow's spawn pattern), then asserts that:
  - movingEntityId=0 (control)        → unfiltered XY drift > 0.5m
  - movingEntityId=registered id (fix) → XY drift ≈ 0

Diagnostic wiring (a36369d + this commit's [SWEEP-OBJ] addition) stays
in tree, env-var gated (ACDREAM_AIRBORNE_DIAG=1) so it produces no
output in normal use but lets us verify the fix on the live client and
debug future regressions.

Build: green. Tests: 355 pass, 6 fail (all pre-existing per the handoff
prompt — verified by stashing this change; the BSPStepUp C3 failure is
on the prior commit too).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:01:07 +02:00
Erik
de129bc164 feat(motion): L.3 M1 — fresh InterpolationManager port + retail spec
Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec
in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API
preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive,
Count) so PositionManager + GameWindow callers continue to compile;
internals are full retail spec.

Bug fixes vs prior port (audit 04-interp-manager.md § 7):

  #1  progress_quantum accumulates dt (sum of frame deltas), not step
      magnitude. Retail line 353140; the prior port's `+= step` made
      the secondary stall ratio meaningless.

  #3  Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets
      _failCount = StallFailCountThreshold + 1 = 4, so the next
      AdjustOffset call's post-stall check fires an immediate blip-to-
      tail snap. Retail line 352944. Prior port silently drifted
      toward far targets at catch-up speed instead of teleporting.

  #4  Secondary stall test ports the retail formula verbatim:
      cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE.
      Audit notes the units are 1/sec (likely Turbine bug or x87 FPU
      misread by Binary Ninja) — mirrored byte-for-byte regardless.

  #5  Tail-prune is a tail-walking loop, not a single-tail compare.
      Multiple consecutive stale tail entries within DesiredDistance
      (0.05 m) of the new target collapse together. Retail line 352977.

  #6  Cap-eviction at the HEAD when count reaches 20 (already correct
      in the prior port; verified).

New API: Enqueue gains an optional `currentBodyPosition` parameter so
the far-branch detection can reference the body when the queue is
empty. Backward-compatible (default null = pre-far-branch behavior).

UseTime collapsed into AdjustOffset's tail (post-stall blip check)
since acdream has no per-tick UseTime call separate from
adjust_offset; identical semantic outcome.

State fields renamed to retail names with sentinel values:
  _frameCounter, _progressQuantum, _originalDistance (init = 999999f
  sentinel per retail line 0x00555D30 ctor), _failCount.

Tests:
- 17/17 InterpolationManagerTests green.
- New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset
  pins the bug #3 fix: enqueueing 150 m away triggers a same-tick
  blip (delta length ≈ 150 m), and the queue clears.

Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/.
00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick,
03-up-routing, 04-interp-manager, 05-position-manager-and-partarray,
06-acdream-audit, 14-local-player-audit are the L.3 spec used by this
commit and the M2 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:56:42 +02:00
Erik
08fbbef3c4 feat(physics): PositionManager combiner class + 6 unit tests (L.3.2)
Pure-function ComputeOffset(dt, pos, seqVel, ori, interp, maxSpeed) →
Vector3. Combines animation root motion (seqVel × dt rotated by body
orientation) with InterpolationManager.AdjustOffset world-space
correction. Mirrors retail CPhysicsObj::UpdateObjectInternal
(acclient @ 0x00513730).

Composed into RemoteMotion in subsequent task (L.3.1+L.3.2 Task 3);
not yet consumed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 10:13:02 +02:00
Erik
5b26d28b08 test(physics): MyRunRate fallback test for GetMaxSpeed (L.3.1 Task 2 polish)
Code-quality review on commit 9c5634a flagged that the existing 4
GetMaxSpeed tests didn't cover the case where WeenieObj is null and
RunForward must fall back to MyRunRate. Without this test, a
regression that hardcoded the fallback to 1.0f would silently pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:20:39 +02:00
Erik
9c5634af17 feat(physics): MotionInterpreter.GetMaxSpeed for InterpolationManager (L.3.1 Task 2)
Ports retail's CMotionInterp::get_max_speed (0x00527cb0). Returns
motion-table-derived max speed (m/s) for InterpretedState.ForwardCommand:
- RunForward:   RunAnimSpeed (4.0) × (InqRunRate ?? MyRunRate)
- WalkForward:  WalkAnimSpeed (3.12)
- WalkBackward: WalkAnimSpeed × 0.65 (BackwardsFactor from adjust_motion @ 0x00528010)
- otherwise:    0

Decomp note: Binary Ninja emits a spurious void return for x87 FPU-returning
functions; the actual float return is confirmed by both callers
(StickyManager::adjust_offset @ 0x00555430,
InterpolationManager::AdjustOffset @ 0x00555d52) which multiply the result
by 2.0 to produce a catch-up speed in m/s. The per-command switch is
consistent with get_state_velocity (0x00527d50) which uses the same constants.

Used by InterpolationManager.AdjustOffset in Task 5 as 2 × GetMaxSpeed().
Until Task 5 wires it, the method is unused — covered by 4 unit tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:16:38 +02:00
Erik
927636ec77 fix(physics): InterpolationManager review findings (L.3.1 Task 1 polish)
Addresses code-quality review findings on commit f43f168:

C-1: Stall detection re-implemented to match retail (acclient lines
353071-353275). Tracks _progressQuantum (sum of step values per window)
+ _distanceAtWindowStart (set at window start). Primary check:
cumulative_progress < MIN_DISTANCE_TO_REACH_POSITION (0.20m absolute).
Secondary check: cumulative_progress / _progressQuantum < 0.30.
Either failing increments fail counter; blip-to-tail at >3 consecutive
fails (already correct).

C-2: Renamed StallFailCountForBlip -> StallFailCountThreshold with
clearer XML doc explaining the > vs >= semantics (blip fires when fail
count EXCEEDS the threshold, i.e. on the 4th consecutive failed window).

I-1: _haveBaselineDistance sentinel prevents first-window false
positive that was triggering spurious fails on every new motion sequence
(old code defaulted _distanceAtWindowStart to 0, making cumulative
progress always negative on frame 5).

I-3: dt <= 0 || NaN guard at AdjustOffset entry prevents NaN
propagation into PhysicsBody.Position.

I-4: Internal field renames for clarity:
  _failFrameCounter        -> _framesSinceLastStallCheck
  _failDistanceLastCheck   -> merged into _distanceAtWindowStart

I-5: Added internal Count property + InternalsVisibleTo (via
AssemblyAttribute in .csproj) so Enqueue_DropsOldestWhenAtCap20
actually verifies cap enforcement. Added assertion that head is the
second-enqueued position after overflow.

3 new tests (AdjustOffset_FirstWindow_DoesNotFalseFail,
AdjustOffset_DtZeroOrNegative_ReturnsZero,
Enqueue_AtCap20_HeadIsSecondOriginal), 16 total. All green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:10:23 +02:00
Erik
f43f168916 feat(physics): InterpolationManager core (L.3.1 Task 1)
Pure-data class + 13 unit tests.

Ports retail's CPhysicsObj::InterpolateTo (acclient @ 0x005104F0)
and InterpolationManager::adjust_offset (@ 0x00555D30) — FIFO position-
waypoint queue (cap 20) + per-frame catch-up math walking the body
toward the head node at 2 × motion-table-max-speed (clamped, with
7.5 m/s fallback). Reach @ 0.05m. Duplicate-prune @ 0.05m.

Stall detection: every 5 frames; if progress < 30% of expected,
increment fail counter; > 3 fails → blip-to-TAIL (resolved via
decomp dive of UseTime @ 0x00555F20: tail_ is the snap target,
not head_).

Constants verified from binary at named addresses (not guesses):
MAX_INTERPOLATED_VELOCITY_MOD=2.0, MAX_INTERPOLATED_VELOCITY=7.5,
MIN_DISTANCE_TO_REACH_POSITION=0.20, DESIRED_DISTANCE=0.05.

Composed into RemoteMotion in subsequent task; not yet used.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:00:17 +02:00
Erik
b1af56eb19 fix(physics): L.4 — steep airborne hits slide-tangent (interim, deviates from retail)
Phase L.4 closes the "stuck in falling animation on a steep roof" bug
the user reported on 2026-04-30 ("I jump up, I land on it. It should not
even let me land, should just slide with a falling animation"). After
this commit the body no longer sticks to a steep roof when jumping
into it — it slides along the slope while keeping the falling animation.

Two pieces:

1. BSPQuery Path 6 steep-poly slide
   When an airborne sphere hits a polygon whose world normal Z is below
   FloorZ (≈ 0.6642, slope > ~49°), the previous flow was:
   Path 6 SetCollide → Path 4 set_walkable → ContactPlane committed →
   body "lands" on the steep poly with Contact bit + falling animation.
   This left the player stuck mid-slope because OnWalkable was cleared
   but Contact stayed set.

   The new branch detects the steep normal in Path 6 BEFORE SetCollide
   is called. Instead of entering the landing path, it removes the
   into-wall component of the move (project onto the steep face), sets
   CollisionNormal + SlidingNormal, and returns Slid. Same shape as
   Path 5's step-up fallback and CylinderCollision. The resolver retries;
   the sphere is now outside the poly; FindCollisions returns OK;
   ValidateTransition commits the slid position. ContactPlane is never
   set, so the body stays airborne with falling animation.

2. PlayerMovementController L.3a-bounce carve-out + Inelastic stop
   Re-enables the velocity-reflection bounce when the contact normal is
   upward-facing but steeper than walkable (0 < N.Z < FloorZ). The base
   L.3a rule suppresses bounce on landing transitions to avoid micro-
   bounce on flat terrain; that suppression also stuck the player to
   too-steep roofs they shouldn't land on. This carve-out re-enables
   the reflection specifically for the steep upward case.

Also lands related L.2c precipice / edge-slide work that was in flight:

- TransitionTypes EdgeSlideAfterStepDownFailed: walkable-poly-steep
  cliff route + steep-ContactPlane cliff route ordering, so that
  CliffSlide fires when the stored walkable polygon itself is too
  steep (Path 4 had previously accepted it as a "landing" via the
  permissive LandingZ threshold).
- CliffSlide reference-normal selection: prefer LastWalkable, fall back
  to LastKnownContactPlane only when walkable, else use world-up. This
  prevents the cross(steepN, steepN) = 0 degenerate case that left the
  cliff slide as a no-op when both current and last-known were steep.
- Phase 2 / step-down branch / edge-slide branch / cliff-slide
  diagnostic helpers gated on ACDREAM_DUMP_EDGE_SLIDE / ACDREAM_DUMP_STEEP_ROOF.
- Two new airborne-mover regression tests in BSPStepUpTests +
  PhysicsEngineTests covering wall-slide and edge tangent motion.

DEVIATION FROM RETAIL — DOCUMENTED FOR FOLLOW-UP

The Path 6 steep slide is NOT what retail does. Retail's flow on the
same hit is:

  Path 6 SetCollide (no steep check) → Path 4 find_walkable returns
  nothing for steep → Phase 3 reset path: restore_check_pos +
  kill_velocity → return COLLIDED → validate_transition reverts CheckPos
  to CurPos and forces OK.

Net retail behavior: position reverts to pre-failed-move (typically
just below the roof in the common jump-up case), velocity zeroed,
gravity rebuilds Z next frame, body falls back down naturally with
the falling animation. The "freeze" framing I used earlier was wrong;
in the typical case retail just bounces the body off and lets gravity
take over.

Strict retail behavior would match the user's intent better in the
common case AND avoid the bounce-energy-accumulation we saw with the
slide-tangent approach (V grew to ~50 m/s in continuous-contact frames).
However, retail's behavior degenerates in the edge case of an overhead
landing onto a steep slope (body would freeze mid-air above the roof).

This commit ships the slide-tangent fix as an interim "much better"
state per user verification on 2026-04-30. Follow-up work to match
retail strictly: revert Path 6 steep-slide, audit Phase 3 reset to
ensure kill_velocity (matching OBJECTINFO::kill_velocity ->
CPhysicsObj::set_velocity({0,0,0}, 0)) actually fires, and re-test.

Refs:
  - acclient_2013_pseudo_c.txt:323784-323821 (Path 6 SetCollide)
  - acclient_2013_pseudo_c.txt:273191-273239 (Phase 3 reset path)
  - acclient_2013_pseudo_c.txt:272563-272596 (validate_transition revert)
  - acclient_2013_pseudo_c.txt:274467-274475 (kill_velocity)
  - acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions bounce)

Tests: 833/833 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:22:07 +02:00
Erik
261322b48e fix(physics): #32 L.2c precipice edge-slide context
Port the first retail precipice-slide slice from named retail/ACE: terrain and BSP walkable hits now preserve polygon vertices, failed step-down edges back-probe to rediscover the walkable polygon, and edge-slide can run precipice/cliff slide instead of only hard-stopping.

Adds pseudocode anchors plus regression coverage for terrain polygon context and loaded-terrain boundary edge-slide.

Co-authored-by: Codex <codex@openai.com>
2026-04-30 08:04:37 +02:00
Erik
1ec40f2a4f fix(physics): #32 L.2c wire edge-slide movement flag 2026-04-30 07:40:43 +02:00
Erik
9fea9b13ad fix(physics): #31 update outdoor cell id during transition movement 2026-04-29 22:00:30 +02:00
Erik
3be0c8b7c7 fix(physics): #30 #34 L.2a movement truth diagnostics
Pass explicit grounded/airborne contact bytes from MovementResult into MoveToState and AutonomousPosition, and add ACDREAM_DUMP_MOVE_TRUTH logging for outbound movement plus player UpdatePosition echoes.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-04-29 21:52:53 +02:00
Erik
d2f6067960 fix(physics): L.2.3c — preserve contact plane through failed step-up
The "stuck in falling animation against walls" live-test bug (intermittent,
hard to recover from). Two compounding issues, fixed at both layers.

(1) DoStepUp cleared CollisionInfo.ContactPlaneValid unconditionally at
    the start of step-up. On step-up FAILURE, RestoreCheckPos restored
    the position but the contact plane stayed cleared. Added a save/
    restore around the clear so a failed step-up returns the mover to
    its pre-attempt grounded state.

(2) ValidateTransition propagated the current frame's invalid contact
    state into LastKnownContactPlane via:
        ci.LastKnownContactPlaneValid = ci.ContactPlaneValid
    This destroyed the prior frame's ground memory whenever the current
    contact was momentarily lost (StepUpSlide clears ContactPlane).
    Changed to: only OVERWRITE LastKnown when current is valid.

(3) The same ValidateTransition then set
        oi.State &= ~(Contact | OnWalkable)
    when ContactPlaneValid was false, even if LastKnown was still
    valid. Added an "else if (LastKnownContactPlaneValid)" branch that
    sets Contact + OnWalkable from LastKnown so the animation system
    sees the mover as grounded.

Combined effect: walking into a too-tall wall now consistently slides
along the wall without ever flickering to the falling animation. The
mover's grounded state survives transient ContactPlane invalidation
during the step-up retry cycle.

Retail's `transitional_insert` has different upstream invariants that
keep ContactPlane valid more often, so retail doesn't need the
acdream-specific LastKnown fallback path. ACE has the same pattern as
retail; acdream's per-frame Resolve architecture exposes the gap that
this fix closes.

Tests:
- New D1 regression test: grounded mover into too-tall wall — must
  end frame with grounded state preserved.
- New D2 regression test: same scenario — execution time bounded
  (<100ms) to catch any future recursion issues.

Files:
- TransitionTypes.cs DoStepUp: save+restore ContactPlane around step-up
- TransitionTypes.cs ValidateTransition: preserve LastKnown + grounded
  state from last-known when current is invalid
- BSPStepUpTests.cs: D1, D2 regression tests

Test count 825 → 825 (D1+D2 added in L.2.3 patch series). Build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:24:49 +02:00
Erik
670f892bd3 feat(physics): Phase L.2.1+L.2.2 — BSP step-up and rooftop landing
Port CTransition::step_up (Path 5) and SPHEREPATH::set_collide (Path 6)
from the retail decomp, turning wall-slides into proper step-up climbs
and airborne-to-roof landings.

Path 5 (grounded mover hits polygon):
- StepSphereUp calls DoStepUp which runs DoStepDown with StepUp=true
- DoStepDown now includes the retail Placement validation step
  (ACE Transition.cs:731-741) — sphere must not be inside solid geometry
  after finding a contact plane; this correctly blocks the tall-wall case
- FindObjCollisions now allocates a local ShadowEntry list per call to
  prevent "collection modified" exceptions when DoStepUp recurses back
  through TransitionalInsert → FindObjCollisions
- BSPQuery.FindCollisions passes engine through to StepSphereUp

Path 6 (airborne mover hits polygon):
- SpherePath.SetCollide: saves backup pos, records StepUpNormal, sets
  WalkInterp=1 — then returns Adjusted so TransitionalInsert retries
- SpherePath.StepUpSlide: clears ContactPlane, sets SlidingNormal for
  the tall-wall fallback
- TransitionalInsert Collide branch: re-tests as Placement when
  ContactPlaneValid; on failure restores backup and returns Collided

Test fixes (BSPStepUpTests.cs + BSPStepUpFixtures.cs):
- Tests use foot-position convention (CurPos = foot, sphere center =
  CurPos + (0,0,r)); from/to corrected from sphere-center to foot coords
- MakeTestEngine terrainZ param: 0f for grounded tests (keeps Contact
  state between sub-steps), -50f for airborne/roof tests
- to.X adjusted so sub-steps land sphere inside (not exactly touching)
  the wall, avoiding the EPSILON-shrink false-negative edge case
- All 12 BSPStepUp tests now GREEN; full suite 823/823

Retail refs:
  CTransition::step_up — acclient_2013_pseudo_c.txt:273099 / ACE:746
  CTransition::step_down — acclient_2013_pseudo_c.txt:273069 / ACE:710
  SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594 / ACE:279
  CTransition::transitional_insert Collide — pseudo_c:273193 / ACE:891

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:16:39 +02:00
Erik
b0c29454d0 test(physics): conformance fixtures for BSP step-up + roof-landing (Phase L.2.0)
Adds two files under tests/:
  BSPStepUpFixtures.cs — synthetic PhysicsBSPNode trees for four canonical
    collision shapes: low step (25 cm), too-tall wall (5 m), flat roof (3 m),
    and steep slope (60deg).  Pre-builds ResolvedPolygon dicts with correct
    polygon_hits_sphere_precise winding (CCW relative to outward normal).
  BSPStepUpTests.cs — 11 conformance tests:
    A1-A6: baselines that pass before and after implementation (no-hit, geometry
           fixture sanity checks).
    B1-B3: Phase L.2.1 targets, currently RED (Path 5 wall-slides).
    C1-C3: Phase L.2.2 targets, currently RED (Path 6 wall-slides).

Retail refs in test docstrings:
  BSPTREE::find_collisions Path 5 acclient_2013_pseudo_c.txt:323849 /
    ACE BSPTree.cs:192-196.
  CTransition::step_up acclient_2013_pseudo_c.txt:273099-273133 /
    ACE Transition.cs:746-777.
  SPHEREPATH::set_collide acclient_2013_pseudo_c.txt:321594-321607 /
    ACE SpherePath.cs:279-286.
  CTransition::transitional_insert Collide branch
    acclient_2013_pseudo_c.txt:273193-273239 / ACE Transition.cs:891-930.

Also adds PhysicsDataCache.RegisterGfxObjForTest() for test-only GfxObjPhysics
injection without real DAT content.

Test delta: 811 -> 823 (+12). 6 passing (A1-A6 + B2), 5 intentionally failing.

Pre-flight: object-translation plane D is in object-local space. Bug is dormant
for outdoor movement where terrain sets the world-space ContactPlane. Tagged TODO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:44:16 +02:00
Erik
7d6fe90607 feat(physics): retail PvP exemption + viewer/creature/missile gates (Commit C)
The retail-faithful exemption block at the top of
CPhysicsObj::FindObjCollisions
(acclient_2013_pseudo_c.txt:276782-276839,276971), ported line-for-
line as a small static helper.

Behaviour now matches retail:
  - Two non-PK players walk through each other.
  - Two PK players collide.
  - Two PKLite players collide.
  - Mismatched PK status (PK vs non-PK, PK vs PKLite) — exempt.
  - Impenetrable target ("Free" PK status) — always collides.
  - Player vs creature/NPC — always collides (this is what closes the
    user-facing complaint that walking into a Holtburg vendor was
    walking through them).
  - Mover with IGNORE_CREATURES — walks through creature targets.
  - Viewer (camera ray) — walks through creatures.
  - Target with ETHEREAL+IGNORE_COLLISIONS — universally exempt.

CollisionExemption.ShouldSkip(targetState, targetFlags, moverState)
  - new file src/AcDream.Core/Physics/CollisionExemption.cs.
  - 13-test matrix covering every documented case
    (CollisionExemptionTests.cs).
  - Static + pure → cheap to call from the hot path.

Wiring:
  - TransitionTypes.FindObjCollisions: after broadphase distance
    reject, call ShouldSkip on the obj and ObjectInfo.State; on true,
    `continue`. Static landblock entries (State=0, Flags=None) fall
    through cheaply — no behavior change for static collision.
  - PhysicsEngine.ResolveWithTransition: new optional moverFlags
    parameter (default None for back-compat). PlayerMovementController
    passes ObjectInfoState.IsPlayer; remote dead-reckoning leaves it
    None (matches non-player movers, no PvP exemption applies).
  - PK/PKLite/Impenetrable bits for the LOCAL player are not yet
    sourced from PlayerDescription's PlayerKillerStatus property —
    that's a follow-up. Default "non-PK player" matches ACE's
    character-creation default and the user's +Acdream test
    character.

Cross-checked against ACE PhysicsObj.cs:381-405 (line-for-line C# port
of the same retail block). Only intentional divergence: ACE adds
state.HasFlag(IsImpenetrable) (mover-impenetrable) to the collide list;
retail's pseudo-C only checks the target — acdream follows retail.

dotnet build green, dotnet test 1467 passing (+13 new). Live test:
+Acdream walking into Holtburg vendors now stops at their cylinder;
walking through small plants still passes (Commit B's phantom skip).

Closes the live-entity collision arc: A (plumbing) + B (registration)
+ C (exemption).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:21:36 +02:00
Erik
ffefc6977f feat(physics): live-entity collision plumbing (Commit A)
Plumbing-only foundation for the upcoming live-entity (NPC / monster
/ player) collision port. No behavior change — the new fields default
to zero/None so the 5 existing static-entity Register call sites in
GameWindow.cs are untouched.

Wire layer:
- CreateObject parser now surfaces PhysicsState (acclient.h:2815 —
  ETHEREAL_PS=0x4, IGNORE_COLLISIONS_PS=0x10, HAS_PHYSICS_BSP_PS=0x10000,
  ...) which the parser previously dropped at line ~337 with a bare
  `pos += 4`.
- CreateObject parser now surfaces ObjectDescriptionFlags (the retail
  PWD._bitfield trailer per acclient.h:6431-6463), where
  acclient_2013_pseudo_c.txt:406898-406918 ACCWeenieObject::IsPK /
  IsPKLite / IsImpenetrable read bits 5 / 25 / 21 directly. Previously
  read-and-discarded.
- WorldSession.EntitySpawn carries both new fields through to subscribers.

Physics layer:
- New `EntityCollisionFlags` enum (IsPlayer / IsCreature / IsPK /
  IsPKLite / IsImpenetrable) + `FromPwdBitfield` helper. Bit
  positions verified against retail's SetPlayerKillerStatus (
  acclient_2013_pseudo_c.txt:441868-441890) which maps
  PKStatusEnum→bitfield exactly: PK=0x4→bit5, PKLite=0x40→bit25,
  Free=0x20→bit21.
- `ShadowEntry` extended with `State` (raw PhysicsState bits) +
  `Flags` (decoded EntityCollisionFlags). Backward-compatible — all
  five existing landblock-entity Register call sites omit them.
- `ShadowObjectRegistry.UpdatePosition(entityId, pos, rot, ...)` —
  fast-path for the 5–10 Hz UpdatePosition (0xF748) stream the server
  emits per visible entity. Reuses the entry's existing shape +
  state + flags. Mirrors retail's CPhysicsObj::SetPosition
  (acclient_2013_pseudo_c.txt:284276) which keeps the same shape and
  re-registers cell membership.
- `ObjectInfoState` adds `IsPK = 0x800` and `IsPKLite = 0x1000`
  matching retail's OBJECTINFO::state bits (acclient.h:6190-6194).
  Used by Commit C's PvP exemption gate.

Tests:
- `EntityCollisionFlagsTests` — 7 tests covering empty / each bit
  alone / PK+player combo / unrelated-bit ignore.
- `ShadowObjectRegistryTests` — 5 new tests: UpdatePosition moves
  entry to new cell, preserves State/Flags, unregistered no-op,
  Register stores State/Flags, defaults are zero/None.
- `CreateObjectTests` — 3 new tests verifying PhysicsState + PWD
  bitfield (with PK / PKLite bit cases) parse and surface.

1454 → 1454 + 15 = covered by suite. dotnet build + dotnet test
green.

Foundation for Commit B (live-entity registration) and Commit C
(PvP exemption block in FindObjCollisions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:12:56 +02:00
Erik
b93dfe95d8 Merge feature/animation-system-complete — Phase L.1c animation MVP
21 commits porting retail's MoveToManager-equivalent client-side
behavior for server-controlled creature locomotion and combat
engagement. Shipped as MVP after live visual verification across
multiple iteration rounds with the user.

Highlights:
- 186a584 — initial Phase L.1c port: extracts Origin / target guid /
  MovementParameters block from MoveTo packets (movementType 6/7),
  adds RemoteMoveToDriver per-tick body-orientation steering with
  ±20° aux-turn-equivalent snap tolerance.
- d247aef — corrected arrival predicate semantics + 1.5 s
  stale-destination timeout for entities leaving the streaming view.
- f794832 — root-caused "creature won't stop to attack" via two
  research subagents converging on retail
  CMotionInterp::move_to_interpreted_state's unconditional
  forward_command bulk-copy. Lifted ServerMoveToActive flag clearing
  + InterpretedState bulk-copy out of substate-only branch so
  Action-class swing UMs (mt=0 ForwardCommand=AttackHigh1) clear
  stale MoveTo state and zero forward velocity.
- ff6d3d0 — RemoteMoveToDriver.ClampApproachVelocity caps horizontal
  velocity at the final-approach tick so body lands EXACTLY at
  DistanceToObject instead of overshooting through the player.
- 37de771 — bulk-copy ForwardCommand for MoveTo packets too (closed
  the regression where MoveTo creatures stayed at default
  ForwardCommand=Ready in InterpretedState and only translated via
  UpdatePosition snaps).
- 34d7f4d + e71ed73 — AnimationSequencer.HasCycle query +
  fallback chain (requested → WalkForward → Ready → no-op) at BOTH
  the OnLiveMotionUpdated path AND the spawn handler. Prevents
  ClearCyclicTail from wiping the body's cyclic tail when ACE
  CreateObject carries CurrentMotionState.ForwardCommand pointing
  to an Action-class motion (e.g. AttackHigh1 from a mid-swing
  creature) which has no cyclic-table entry — was the "torso on
  the ground" symptom for monsters seen in combat by a fresh
  observer.

Cross-references: docs/research/named-retail/acclient_2013_pseudo_c.txt
(MoveToManager 0x00529680 + 0x0052a240 + 0x00529d80,
CMotionInterp::move_to_interpreted_state 0x00528xxx,
MovementParameters::UnPackNet 0x0052ac50), references/ACE/Source/
ACE.Server/Physics/Animation/MoveToManager.cs (port aid),
references/holtburger/ (cross-check on snapshot-only client
behavior), docs/research/2026-04-28-remote-moveto-pseudocode.md
(the Phase L.1c pseudocode doc).

Tests: 1404 → 1422 (parser type-7 path retention, type-6 target
guid retention, driver arrival semantics, retail-faithful
chase/flee branches, approach-velocity clamp scenarios,
HasCycle present/missing, AttackHigh1 wire layout).

Pending follow-ups (filed for future): target-guid live resolution
for type 6 packets (residual chase lag), StickToObject sticky-target
guid trailing field, full MoveToManager state machine port
(CheckProgressMade stall detector, Sticky/StickTo, use_final_heading,
pending_actions queue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:50:59 +02:00
Erik
34d7f4def2 fix(anim): Phase L.1c sequencer cycle fallback for missing MoveTo motion
User-observed regression on commit 37de771: monsters in combat with
another client appear as "just a torso on the ground" until they
move. User correctly identified this as a regression I introduced.

Cause traced to the SEQUENCER side, not the InterpretedState side.
AnimationSequencer.SetCycle (AnimationSequencer.cs:392-396)
unconditionally calls ClearCyclicTail() BEFORE looking up the
requested cycle in the MotionTable. If the cycle is missing
(_mtable.Cycles.TryGetValue returns false), the body is left without
ANY cyclic tail at all — and every part snaps to its setup-default
offset on the next Advance(). Most creatures' setup-defaults put
all limbs at the torso origin, so the visual collapses to "just a
torso on the ground" until a different (working) cycle arrives.

This is specifically a regression from commit 186a584 (Phase L.1c
port). Pre-fix, MoveTo packets fell through to fullMotion=Ready
(every MotionTable contains a Ready cycle). Post-fix, MoveTo packets
seed fullMotion=RunForward via PlanMoveToStart. Some combat-stance
creatures (e.g. monsters in HandCombat 0x003C) have no
(combat, RunForward) cycle in their MotionTable — they're meant to
walk in combat, with retail's apply_run_to_command upgrading
WalkForward → RunForward at the velocity layer rather than the
animation-cycle layer.

Fix: add `AnimationSequencer.HasCycle(style, motion)` query and gate
the SetCycle call site in GameWindow.OnLiveMotionUpdated behind it.
Fall back chain: requested motion → WalkForward → Ready →
no-op-don't-clear. The InterpretedState.ForwardCommand bulk-copy
(commit 37de771) is unchanged — body still gets RunForward velocity
even when the visible animation falls back to WalkForward or Ready.

Tests: 1420 → 1422.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:33:48 +02:00
Erik
ff6d3d0c94 fix(anim): Phase L.1c clamp approach velocity to prevent overshoot
User-observed residual after f794832: creature stops to attack but
still runs slightly through the player before stopping.

Cause: at 4 m/s body velocity (RunAnimSpeed × ~1.0 speedMod) and a
60 fps tick (~16 ms), the body advances ~6.4 cm per tick. When dist
falls just below the 0.6 m DistanceToObject arrival threshold, the
arrival predicate fires and zeroes velocity — but the body has
already advanced one full tick INTO the threshold zone. That last
tick is the "running through" the user sees, especially when
combined with a player visual radius of ~0.5 m.

Fix: cap horizontal velocity in the steering branch so the body lands
EXACTLY at the arrival threshold instead of overshooting it. Pure
function in RemoteMoveToDriver (ClampApproachVelocity) so it's
testable; called from GameWindow.cs after apply_current_movement
sets RunForward velocity from the active cycle.

The clamp is a strict scale-down of the X/Y components; Z is left
to gravity / terrain handling. No-op for the flee branch — fleeing
has no overshoot risk by definition.

Tests: 1416 → 1420. Four new clamp scenarios: exact-landing (FP
tolerance), would-overshoot scale-down, already-at-threshold zeroing,
flee no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:14:35 +02:00
Erik
f794832ebc fix(anim): Phase L.1c clear MoveTo state + bulk-copy ForwardCommand on overlay UMs
User-observed regression on commit d247aef: creature reaches melee
range and "just runs" instead of stopping to attack. Two independent
research subagents converged on the same root cause.

When ACE broadcasts a melee swing, it sends an mt=0 UpdateMotion with
ForwardCommand=AttackHigh1 (Action class, 0x10000062), motion_flags
=StickToObject, and a trailing 4-byte sticky-target guid — there is
NO preceding cmd=Ready. The swing UM IS the stop signal.

Retail's CMotionInterp::move_to_interpreted_state
(acclient_2013_pseudo_c.txt:305936-305992) bulk-copies forward_command
from the wire into InterpretedState UNCONDITIONALLY, regardless of
motion class. With forward_command=AttackHigh1, get_state_velocity
(:305172-305180) returns velocity.Y=0 because its gate is
RunForward||WalkForward — body stops moving forward. The animation
overlay (the swing) is appended on top of whatever cyclic tail is
active.

Acdream's overlay branch in GameWindow.OnLiveMotionUpdated routed
Action-class commands through PlayAction (animation overlay only) and
SKIPPED:
  - ServerMoveToActive flag update — stale RunForward MoveTo state
    persisted, the per-tick driver kept steering toward the prior
    Origin and calling apply_current_movement.
  - InterpretedState.ForwardCommand bulk-copy — even if the flag had
    been cleared, the body's InterpretedState.ForwardCommand stayed
    at RunForward from the prior MoveTo cycle, so
    apply_current_movement kept producing forward velocity.
  - MoveToPath capture — staleness-timeout band-aid masked this.

Fix: lift the _remoteDeadReckon state-update block out of the
substate-only `else` branch so it runs for both overlay and substate
paths. For non-MoveTo packets, write fullMotion + speedMod directly to
InterpretedState.ForwardCommand/ForwardSpeed (bypassing
ApplyMotionToInterpretedState, which is a heuristic helper that
silently no-ops for Action class — see MotionInterpreter.cs:941-970).
This matches retail's copy_movement_from
(acclient_2013_pseudo_c.txt:293301-293311) bulk-copy semantics.

Also corrected RemoteMoveToDriver arrival predicate to retail-faithful:
chase = dist <= DistanceToObject; flee = dist >= MinDistance. The
prior max(MinDistance, DistanceToObject) defensive port happened to
compute the right value for ACE's wire defaults but had wrong
semantics (would have failed for any retail config with MinDistance >
DistanceToObject).

Tests: 1414 → 1416. New parser test for the AttackHigh1 wire layout;
new driver tests for retail-faithful chase/flee arrival.

Defers: target-guid live resolution for type 6 packets (chase-lag
mitigation, symptom #3), StickToObject sticky-target guid trailing
field, full MoveToManager port (CheckProgressMade, pending_actions
queue, Sticky/StickTo, use_final_heading).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:02:53 +02:00
Erik
d247aef2e4 fix(anim): Phase L.1c chase arrival + stale destination
User-observed regressions on commit 186a584:

1. "Monster keeps running in different directions when it should be
   attacking" — chase oscillates around the player at melee range
   instead of stopping. Root cause: arrival check used MinDistance
   only (retail's algorithm), but ACE puts the melee threshold in
   DistanceToObject (default 0.6) and leaves MinDistance at 0. So
   our check was never satisfied; body kept re-targeting around the
   player as each MoveTo refresh moved the destination.

   Fix: arrival = dist <= max(MinDistance, DistanceToObject) + epsilon.
   Honors retail when retail sets MinDistance > 0; falls through to
   ACE's DistanceToObject when MinDistance is 0. Confirmed by
   independent research (named retail decomp, ACE wire writers,
   holtburger client) that DistanceToObject is the documented chase
   threshold in ACE; retail's MinDistance is only meaningful when
   server config overrides the default 0.

2. "Monster disappears, then runs in place" — entity left our
   streaming view, server stopped emitting MoveTo, last destination
   stayed cached. When entity re-entered view, body still steered
   toward the stale point, eventually arrived (V=0), animation kept
   playing → "running on the spot."

   Fix: 1.5 s stale-destination timeout. ACE re-emits MoveTo at
   ~1 Hz during active chase; if no fresh packet for 1.5 s, the
   entity has either left view, transitioned off MoveTo without us
   seeing the cancel UM, or had its move cancelled server-side.
   Clear destination + zero velocity so the next interpreted-motion
   UM (or fresh MoveTo) drives the body cleanly.

Also confirmed (via dispatched research subagent against ACE writer
side, named retail MovementManager::PerformMovement, and holtburger):
the wire's "Origin" field IS the destination, not the start position.
My driver's interpretation was correct; the symptoms were arrival
threshold + staleness, not a misread of the wire.

Tests: 1412 → 1414 (ACE-melee arrival, retail-MinDistance arrival).

Origin-stale lag during active chase remains — server's Origin is
the target's position at packet-emit time, ~1 s behind the player.
For type 6 MoveToObject, the retail-faithful fix is target-guid
live resolution per HandleUpdateTarget @ 0x0052a7d0; deferred per
the pseudocode doc's "out of scope" list. For type 7 there's no
fix without target-velocity prediction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:32:54 +02:00
Erik
ec1bbb4f43 feat(vfx): Phase C.1 — PES particle renderer + post-review fixes
Ports retail's ParticleEmitterInfo / Particle::Init / Particle::Update
(0x005170d0..0x0051d400) and PhysicsScript runtime to a C# data-layer
plus a Silk.NET billboard renderer. Sky-PES path is debug-only behind
ACDREAM_ENABLE_SKY_PES because named-retail decomp confirms GameSky
copies SkyObject.pes_id but never reads it (CreateDeletePhysicsObjects
0x005073c0, MakeObject 0x00506ee0, UseTime 0x005075b0).

Post-review fixes folded into this commit:

H1: AttachLocal (is_parent_local=1) follows live parent each frame.
    ParticleSystem.UpdateEmitterAnchor + ParticleHookSink.UpdateEntityAnchor
    let the owning subsystem refresh AnchorPos every tick — matches
    ParticleEmitter::UpdateParticles 0x0051d2d4 which re-reads the live
    parent frame when is_parent_local != 0. Drops the renderer-side
    cameraOffset hack that only worked when the parent was the camera.

H3: Strip the long stale comment in GfxObjMesh.cs that contradicted the
    retail-faithful (1 - translucency) opacity formula. The code was
    right; the comment was a leftover from an earlier hypothesis and
    would have invited a wrong "fix".

M1: SkyRenderer tracks textures whose wrap mode it set to ClampToEdge
    and restores them to Repeat at end-of-pass, so non-sky renderers
    that share the GL handle can't silently inherit clamped wrap state.

M2: Post-scene Z-offset (-120m) only fires when the SkyObject is
    weather-flagged AND bit 0x08 is clear, matching retail
    GameSky::UpdatePosition 0x00506dd0. The old code applied it to
    every post-scene object — a no-op today (every Dereth post-scene
    entry happens to be weather-flagged) but a future post-scene-only
    sun rim would have been pushed below the camera.

M4: ParticleSystem.EmitterDied event lets ParticleHookSink prune dead
    handles from the per-entity tracking dictionaries, fixing a slow
    leak where naturally-expired emitters' handles stayed in the
    ConcurrentBag forever during long sessions.

M5: SkyPesEntityId moves the post-scene flag bit to 0x08000000 so it
    can't ever overlap the object-index range. Synthetic IDs stay in
    the reserved 0xFxxxxxxx space.

New tests (ParticleSystemTests + ParticleHookSinkTests):
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed (retail-faithful
  single-emit-per-frame behavior)
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking

dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(up from 1325).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:47:11 +02:00
Erik
186a584404 feat(anim): Phase L.1c port MoveTo path data + per-tick steer
Root-causing the user-reported "monsters disappearing some time +
laggy/jittery locomotion" via systematic-debugging Phase 1: our
UpdateMotion parser kept only speed/runRate/flags from a movementType
6/7 packet and discarded Origin (destination), targetGuid, and the
distance/walkRunThreshold/desiredHeading half of MovementParameters.
The integrator consequently held Body.Velocity at zero during MoveTo
("incomplete state" stabilizer 882a07c), so the body froze with legs
animating until UpdatePosition snap-teleported it — sometimes outside
the visible window (disappearing) — and constant-velocity drift along
the old heading between snaps produced jitter on every UP correction.

The 882a07c stabilizer was deliberately conservative because the state
WAS incomplete. Completing the data plumbing makes its restriction
moot: with the full MoveTo payload captured, the body solver has every
field retail's MoveToManager::HandleMoveToPosition (0x00529d80) reads.

Why: server re-emits MoveTo packets ~1 Hz with refreshed Origin while
chasing — verified in the live log (guid 0x800003B5 seq 0x01FE→0x0204
all show different cell/xyz floats). Those are heading updates we'd
been throwing away. With the full payload retained, the per-tick driver
steers body orientation toward Origin (±20° snap tolerance, π/2 rad/s
turn rate above tolerance) and lets apply_current_movement fill in
Velocity from the existing RunForward cycle — no new motion path,
just the right heading.

Scope is the minimum viable subset: target re-tracking, sticky/StickTo,
fail-distance progress detector, and sphere-cylinder distance are
server-side concerns we don't need (server's emit cadence handles all
of them). MoveToObject_Internal target-guid resolution is also skipped
— Origin is refreshed each packet, so the effective target tracks the
real entity even without a guid lookup.

Cross-references:
- docs/research/named-retail/acclient_2013_pseudo_c.txt — MoveToManager
  + MovementParameters::UnPackNet (0x0052ac50) + apply_run_to_command
  (0x00527be0). 18,366 named PDB symbols make this the primary oracle.
- references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs
  — port aid; flagged divergences (WalkRunThreshold default, set_heading
  snap, inRange one-shot) called out in the new pseudocode doc.
- docs/research/2026-04-28-remote-moveto-pseudocode.md — pseudocode +
  ACE divergence flags + out-of-scope list per CLAUDE.md mandatory
  workflow (decompile → cross-reference → pseudocode → port).

Tests: 1404 → 1412 (parser type-7 path retention + type-6 target guid
retention; driver arrival, in-tolerance snap, beyond-tolerance step,
behind-target shortest-path turn, arrival preserves orientation,
Origin→world landblock-grid arithmetic).

Pending visual sign-off — handoff stabilizer 882a07c was the last
commit the user tested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:49:22 +02:00
Erik
9812965183 fix(anim): Phase L.1c match MoveTo run speed
Retail MovementManager::PerformMovement (0x00524440) reads MoveTo speed and runRate from the packet, MovementParameters::UnPackNet (0x0052AC50) defines the layout, and CMotionInterp::apply_run_to_command (0x00527BE0) multiplies RunForward by runRate. Parse those fields for UpdateMotion/CreateObject, seed server-controlled MoveTo locomotion with the retail speed multiplier, and avoid overriding active monster MoveTo with sparse UpdatePosition-derived velocity.
2026-04-28 20:58:22 +02:00
Erik
4dd8d4b46e fix(anim): Phase L.1c seed move-to locomotion
Retail MoveToManager::BeginMoveForward calls MovementParameters::get_command (0x0052AA00) and then _DoMotion/adjust_motion, so a server-controlled MoveTo begins visible forward locomotion before the next UpdatePosition echo. Seed RunForward for MoveTo packets that omit ForwardCommand, while preserving active locomotion and letting position velocity refine walk/run/stop.
2026-04-28 19:48:12 +02:00
Erik
7656fe0970 fix(anim): Phase L.1c animate server-controlled chase 2026-04-28 19:38:52 +02:00
Erik
b96b680a20 fix(anim): Phase L.1c route creature actions and despawns
Handle retail ObjectDelete (0xF747) using CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 / SmartBox::HandleDeleteObject 0x00451EA0 and ACE GameMessageDeleteObject so dead creatures are removed when corpses spawn.

Route action-class ForwardCommand values through AnimationCommandRouter/PlayAction instead of SetCycle so creature attack commands 0x51/0x52/0x53 survive the immediate Ready echo, matching CMotionTable::GetObjectSequence 0x00522860 / ACE MotionTable.GetObjectSequence.

Use server-authoritative UpdatePosition velocity, or observed server position delta for non-player entities when HasVelocity is absent, to reduce monster/NPC chase lag without applying player RUM prediction to server-controlled creatures.
2026-04-28 19:21:02 +02:00
Erik
4874d8595a feat(combat): Phase L.1c wire live attack input 2026-04-28 11:58:57 +02:00
Erik
d1fb68f419 test(world): serialize DerethDateTime offset tests 2026-04-28 11:58:50 +02:00
Erik
646246ba84 feat(anim): Phase L.1c select combat maneuvers 2026-04-28 11:44:17 +02:00
Erik
831392a7b2 feat(anim): Phase L.1c classify combat animation commands 2026-04-28 11:37:49 +02:00
Erik
268af82e28 fix(combat): Phase L.1c align attack type flags 2026-04-28 10:59:29 +02:00
Erik
25b9616703 feat(combat): Phase L.1c add outbound combat actions 2026-04-28 10:57:12 +02:00
Erik
29afc94b94 fix(net): Phase L.1c conform combat wire events 2026-04-28 10:54:50 +02:00
Erik
460f95cb42 fix(anim): Phase L.1b route motion commands 2026-04-28 10:46:22 +02:00
Erik
f7c9e88b6a Merge branch 'feature/sky-fixes' — sky/weather rendering retail-faithful pass
Six commits on the branch, three retail-decomp investigations
(in-house + two external code-review agents) converging on the
same root causes:

  97fc1b5 fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip
  05a8a72 fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor
  034a684 fix(sky): partition sky pass on Properties bit 0x01, not bit 0x04
  375065b fix(meshing): Translucent flag overrides Additive blend per retail SetSurface
  646ccca feat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten
  0c82d2c docs(issues): #28 root-caused (PES particles), #29 filed

Net effect:

  * Sun + ambient colors now use retail's |sunVec| magnitude formula
    from PrimD3DRender::UpdateLightsInternal at decomp 424118 — fixes
    blue-white sky tint at most keyframes.
  * Surface.Translucency is used DIRECTLY as opacity (not 1-x) per
    D3DPolyRender::SetSurface at decomp 425255 — fixes 3× too-bright
    cloud + correct rain alpha.
  * Sky fog re-enabled with SKY_FOG_FLOOR=0.2 mitigation — horizon
    haze visible without flat-fogging the dome at storm keyframes.
  * Additive surfaces skip fog per SetFFFogAlphaDisabled at decomp
    425295 — sun stays bright at horizon dusk/dawn.
  * Pre/post-scene partition is bit 0x01 (post-scene placement) instead
    of bit 0x04 (weather gate), per GameSky::CreateDeletePhysicsObjects
    at decomp 269036. Fixes double-rendered foreground rain.
  * Translucent flag forces alpha-blend over Additive when ClipMap is
    set, matching retail's blend resolution at decomp 425246-425260.
    Cloud surface 0x08000023 now classified correctly.
  * Setup-backed sky objects (0x020xxxxx) now load via SetupMesh.Flatten
    instead of being silently dropped by EnsureMeshUploaded.

Tests: 1227 pass.

User-visible improvements: foreground rain matches retail's
volumetric look, sky tint shifted from blue-white toward retail's
warm-gray, additive sun stays bright through horizon haze.

Outstanding:
  * Issue #28 — PES particle rendering ("aurora light play"). Now
    root-caused with implementation outline; defer to its own Phase.
  * Issue #29 — residual cloud-density gap; likely rolls into #28.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

# Conflicts:
#	src/AcDream.App/Rendering/GameWindow.cs
2026-04-27 23:30:50 +02:00
Erik
05a8a7209f fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor
Two independent investigations (in-house decomp re-check + two
external agent reports) converged on the same root cause for the
"too blue-white sky" symptom:

acdream computed SunColor = DirColor × DirBright and AmbientColor =
AmbColor × AmbBright. Retail computes them from the magnitude of a
specially-shaped sun vector instead. Per the named retail decomp:

  SkyDesc::GetLighting at 0x00500ac9 (decomp 261343-261353):
    sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
    sunVec.y = cos(P_rad)                    ← NOT scaled by DirBright
    sunVec.z = DirBright × sin(P_rad)

  PrimD3DRender::UpdateLightsInternal at 0x0059b57c (decomp 424118):
    D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)

  SmartBox::SetWorldAmbientLight callsite at 0x0050560b (decomp 267117):
    SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ...)

Y stays unscaled by DirBright on purpose, so |sunVec| ≠ DirBright in
general — the magnitude varies with sun pitch/heading. That's what
gives retail's "sun feels stronger when it's overhead, ambient warms
up at midday" behavior we were missing.

Added SkyStateProvider.RetailSunVector(kf) that builds the vector
verbatim. SkyKeyframe.SunColor / AmbientColor now compose via |sunVec|.
SunDirectionFromKeyframe normalizes the same vector (replaces our
geometrically-clean spherical convention which didn't match retail's
deliberate Y-decoupled-from-heading shape).

Tests:
- Replaced the linear-interp assumption in
  Interpolate_BetweenKeyframes_LerpsColors with a test on the RAW
  inputs (DirColor, AmbBright, etc.) — those still lerp linearly;
  the composite SunColor doesn't, intentionally.
- Added 4 golden-value tests for the new formulas
  (RetailSunVector_AtZenith, _AtHorizonNorth,
  SunColor_UsesRetailMagnitudeNotDirBrightDirectly,
  AmbientColor_BoostsByTwentyPercentOfSunVectorLength).
- Updated stale LoadFromRegion_SunColor_IsPrepreMultipliedByBrightness
  test to LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude
  with the new expected magnitude.

User visually verified — acdream's sky shifted from blue-white toward
the warm tint retail shows at the same keyframe.

1227 tests pass.
2026-04-27 22:42:53 +02:00
Erik
63b50c5291 fix(sky): retail-faithful keyframe lerp — separate-channel color/bright
Retail's SkyDesc::GetLighting at 0x00500ac9 (decomp lines 261317-261331)
lerps each color channel and the brightness scalar SEPARATELY, then
multiplies post-lerp:

  arg4.r = lerp(k1.amb_color.r, k2.amb_color.r, u)
  arg4.g = lerp(k1.amb_color.g, k2.amb_color.g, u)
  arg4.b = lerp(k1.amb_color.b, k2.amb_color.b, u)
  arg3   = lerp(k1.amb_bright, k2.amb_bright, u)
  final  = (arg4.rgb * arg3, ...)

acdream pre-multiplied (color × bright) at LOAD time
(`SkyDescLoader.cs:558-559`) and then lerped the product. For any
keyframe pair where both color and brightness change, the two are
mathematically distinct. Example, k1=(white, b=0.5) k2=(black, b=1.0)
at u=0.5:
  - retail: color=gray(0.5), bright=0.75 → final = (0.375, 0.375, 0.375)
  - acdream: lerp((0.5,0.5,0.5), (0,0,0), 0.5) = (0.25, 0.25, 0.25)

For Rainy/Cloudy DayGroups transitioning between dim and bright
keyframes, this contributes to subtle brightness divergence vs retail.

Refactor:
  SkyKeyframe stores DirColor / DirBright / AmbColor / AmbBright
    SEPARATELY (raw, not pre-multiplied).
  Computed properties SunColor and AmbientColor return the
    post-multiplied product, keeping the shader uniform interface
    (uSunColor / uAmbientColor) unchanged.
  SkyStateProvider.Interpolate lerps each raw channel, then constructs
    a new SkyKeyframe whose computed properties yield the correct
    post-lerp multiply.
  SkyDescLoader now stores raw values without pre-multiplying.
  GameWindow comment updated; no functional change there.
  Default factory + tests updated to use the new constructor parameters
    with DirBright=AmbBright=1.0 (preserving exact existing behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:02:35 +02:00
Erik
dbe6690a4e fix(time): retail-canonical month enum + absolute Portal Year + title-bar calendar
Two bugs in calendar display (the CLOCK ITSELF was already correct):

1. **Month enum had wrong order + non-retail names.** Old enum:
   Snowreap=0, ColdMeet, Leafdawning, Seedsow, Rosetide, Solclaim, ...
   At day-of-year 83 this gave month index 2 = Leafdawning. Retail's
   @timestamp at the same moment shows "Seedsow 24". Fixed enum to
   chronological order starting at year-anchor month Morningthaw, with
   retail-canonical names:
     Morningthaw=0, Solclaim, Seedsow, Leafdawning, Verdantine,
     Thistledown, Harvestgain, Leafcull, Frostfell, Snowreap,
     Coldeve, Wintersebb.
   At day-of-year 83 → month 2 = Seedsow ✓

2. **ToCalendar returned relative year, not absolute Portal Year.**
   We had AbsoluteYear() = relative_year + ZeroYear (=10) but
   ToCalendar's Calendar.Year was the relative one. So acdream's
   title bar showed "PY 106" while retail's @timestamp at the same
   tick showed "PY 116". Fixed ToCalendar to add ZeroYear so the
   exposed Calendar.Year matches retail's display.

3. **GameWindow title bar now shows the calendar.** Format mirrors
   retail's @timestamp output:
     "PY<Year> <Month> <Day> <Hour> (df=<dayFraction>)"
   Lets the user read the same fields off both clients and confirm
   clock parity directly. Drift > 1 hour = real bug.

Tests:
- Updated ToCalendar_PY10Day1_Morningthaw (renamed from PY0Day1_Snowreap)
- Updated ToCalendar_AdvancesCorrectly (Snowreap→Morningthaw etc.)
- Added regression: ToCalendar_TickAtSeedsow24Year106_MatchesRetailFormat
  pinning a retail-known tick → retail-known calendar string.

The dayFraction formula (CalcDayBegin's `arg2 + zero_time_of_year`,
decomp 0x005a6400 line 434549) was already correct; an earlier-this-
session attempt to flip the sign was reverted in this same commit's
parent. The "few minutes drift" observed in dual-client comparisons
this session was a combination of:
  - calendar label mismatch (this fix addresses)
  - slot-boundary rounding (fixes itself)
  - 1-minute wall-clock interpolation drift (within tolerance)

NOT a clock-formula bug. ISSUE #3 in docs/ISSUES.md is now misnamed
("Client clock drifts from retail"); plan to re-title or close in a
follow-up commit after the visual-divergence investigation lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:43:49 +02:00