Code review identified a latent false-positive flake risk: physics
path clamps fx = localX/24 to (CellsPerSide - 0.001f) = 7.999, which
corresponds to localX <= 191.976. With samples up to 191.999f,
physics computes Z at the clamped position while the mesh sampler
uses the actual position — a difference of up to 23 mm at the upper
edge, which on a steep slope would falsely trip the 1 mm sentinel.
Tighten upper bound to 191.975f (strictly below the clamp boundary)
so both oracles compute Z at the same (cellX, tx). Also restored the
"worst-case from SplitFormulaDivergenceTest" inline comment for
landblock 0x4D96 per code review suggestion #3.
Test still passes: 10/10 landblocks, 1000 samples, max |delta|
= 0.0153 mm (previously 0.0305 mm — confirms the prior worst-case
was indeed at the boundary).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Z-conformance sentinel for issue #51's bug class. Sweeps 10
representative landblocks x 100 sample points (uniform random in
local 0..192 with fixed seed 42). For each point: compute meshTriZ
via barycentric interpolation in the matching triangle of the
LandblockMesh.Build output; compute physicsZ via
TerrainSurface.SampleZFromHeightmap; assert |delta| < 0.001m.
Catches any silent formula or vertex-layout drift between the
visual and physics paths. Skips gracefully if ACDREAM_DAT_DIR
isn't set (CI without dat data).
Local run with dat data: 10/10 landblocks loaded, 1000 samples,
max |delta| = 0.0305 mm (worst case: Direlands 0xC040).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sweeps all (lbX, lbY, cellX, cellY) tuples for the full 255x255
landblock map (~4.16M cells) and reports both the raw enum-output
disagreement (50.02%) and the diagonal-actually-painted disagreement
(49.98%) between WB's CalculateSplitDirection and acdream's
TerrainBlending.CalculateSplitDirection (which retail uses per
CLandBlockStruct::ConstructPolygons at retail addr 00531d10).
The two formulas behave like independent random hashes. Adopting
WB's pipeline wholesale would mis-render ~half the diagonals on
every landblock (Holtburg 0xA9B0: 29/64 cells = 45.3% wrong). This
data is the foundation for N.5b's Path A vs B vs C decision (kills
Path A).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final cross-cutting review of N.5 found that Task 15's deletion of
mesh_instanced.vert/.frag left InstancedMeshRenderer orphaned —
ACDREAM_USE_WB_FOUNDATION=0 silently rendered terrain+sky only with
no entities. The SHIP commit's "[x] ACDREAM_USE_WB_FOUNDATION=0 still
works" claim was inaccurate.
Resolution: formal retirement of the legacy renderer path within N.5
instead of deferring to N.6.
Deleted:
- src/AcDream.App/Rendering/InstancedMeshRenderer.cs
- src/AcDream.App/Rendering/StaticMeshRenderer.cs
- src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
GameWindow simplified — capability detection is unconditional, missing
bindless throws NotSupportedException with a clear message at startup.
WbDrawDispatcher + mesh_modern shader load are mandatory after init.
No escape hatch.
GpuWorldState simplified — WbFoundationFlag.IsEnabled guards on
AddLandblock/RemoveLandblock removed; adapter calls are unconditional
when the adapter is non-null.
PendingSpawnIntegrationTests updated — WbFoundationFlag.ForTestsOnly_ForceEnable
static ctor removed (flag is gone; adapter calls are unconditional).
The ApplyLoadedTerrain physics-data loop was also simplified: the
EnsureUploaded sub-loop that fed InstancedMeshRenderer is gone;
_pendingCellMeshes is now explicitly cleared to prevent unbounded
accumulation (the worker thread still populates it, but WB handles
EnvCell geometry through its own pipeline).
Spec §2 Decision 5 + §10 Out-of-Scope updated. Plan ship-amendment
section added. Roadmap updated (N.5 ships with retirement; N.6 scope
narrowed to perf-only). CLAUDE.md "WB integration cribs" updated.
Perf baseline doc updated. WbDrawDispatcher class summary docstring
corrected to describe the as-shipped SSBO + multi-draw-indirect path.
ISSUES.md #51 updated (terrain not in N.5 scope; deferred to N.7).
Bindless support is now a hard requirement. Modern desktop GPUs
universally expose GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters;
if a user hits the NotSupportedException, that's a real bug report
worth investigating, not a silent fallback.
Build: 0 errors, 0 warnings. Tests: 71/71 (Wb+MatrixComposition+TextureCacheBindless filter).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Locks in Decision 2 (Opaque + ClipMap → opaque indirect; AlphaBlend +
Additive + InvAlpha → transparent indirect). Catches future refactors
that drift the partition — silent visual regression otherwise (groups
rendered in the wrong pass with the wrong blend state).
Adds public static IsOpaquePublic shim on WbDrawDispatcher; the
underlying IsOpaque stays private.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code quality review caught:
- sizeofDEIC was a local; promoted to public const DrawCommandStride
so tests can reference it symbolically.
- BatchDataPublic layout invariant (size + field offsets) wasn't
asserted in tests. Added BatchDataPublic_LayoutMatchesPrivateBatchData
+ DrawCommandStride_MatchesStructSize tests to gate Task 10's
MemoryMarshal.Cast<BatchData, BatchDataPublic> safety.
- Plan doc updated: BatchDataPublic spec was Pack=4 (wrong — must
match private BatchData's Pack=8 for the cast to work). Implementation
was already correct; plan now matches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure CPU helper that lays out a group list into a contiguous indirect
buffer (DrawElementsIndirectCommand[]) and parallel BatchData[] —
opaque section first, transparent section second. Returns counts +
byte offset for the transparent section.
Tests cover: spec §5 walk-through layout; empty group list edge case;
ClipMap classification (treated as opaque, not transparent).
Static + public so tests can exercise without a GL context. Task 10
wires it into the rewritten Draw() method.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three Bindless variants (GetOrUploadBindless,
GetOrUploadWithOrigTextureOverrideBindless,
GetOrUploadWithPaletteOverrideBindless) that decode + upload via
UploadRgba8AsLayer1Array (Texture2DArray) and cache in three new
dictionaries that mirror the legacy three-cache structure. Each entry
stores both the GL texture name (for Dispose cleanup in Task 4) and
the resident bindless handle.
Constructor gains optional BindlessSupport param; null keeps backward
compat. EnsureBindlessAvailable throws InvalidOperationException if
Bindless* methods are called without BindlessSupport (fail-fast vs
silent zero handle that would produce GPU faults).
Dispose extended to make handles non-resident before deleting the
underlying Texture2DArray names (bindless handles must be made
non-resident before the texture is deleted; skipping this causes
GPU faults on driver cleanup).
Marker test in TextureCacheBindlessTests documents the throw contract
for future engineers; real bindless integration is verified at
Task 14's visual gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WbDrawDispatcher draws all entities through WB's ObjectRenderData
(VAO/VBO per GfxObj, per-batch IBO) using acdream's TextureCache for
texture resolution. Two-pass rendering (opaque+ClipMap, then
translucent) matching the existing InstancedMeshRenderer pattern.
Per-entity single-instance drawing for N.4 simplicity — true
instancing grouping deferred to N.6.
Atlas-tier entities: mesh from WB, texture from TextureCache via
batch SurfaceId. Per-instance-tier entities: AnimatedEntityState
drives part overrides + hidden-parts, palette/surface overrides
resolve through TextureCache's composite-key caches.
Side-table population (Task 23 folded in): WbMeshAdapter now takes
DatCollection and populates AcSurfaceMetadataTable on first
IncrementRefCount per GfxObj. The side-table provides TranslucencyKind
(critical for ClipMap alpha-test on vegetation) plus Luminosity,
Diffuse, SurfOpacity, NeedsUvRepeat, DisableFog for sky-pass and
lighting.
GameWindow wiring: when WbFoundationFlag is enabled, WbDrawDispatcher
draws everything and InstancedMeshRenderer is skipped. Flag-off path
is unchanged.
Matrix composition: restPose * animOverride * entityWorld, matching
the spec. Three MatrixCompositionTests verify the contract.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Routes server-spawned (CreateObject) entities through the per-instance
rendering path. Filter: ServerGuid != 0. Atlas-tier entities (procedural,
ServerGuid == 0) flow through LandblockSpawnAdapter (Task 11) instead.
For entities with PaletteOverride set, walks each MeshRef.SurfaceOverrides
map and calls TextureCache.GetOrUploadWithPaletteOverride to pre-warm the
palette-composed GL texture before the first draw. Surfaces not in the
SurfaceOverrides map (i.e. whose ids are only known after opening the GfxObj
dat) are decoded lazily by the draw dispatcher on first use, consistent with
StaticMeshRenderer.
Builds AnimatedEntityState per server-guid via injected sequencer factory
(Func<WorldEntity, AnimationSequencer>). The factory decouples the adapter
from DatCollection so tests pass a stub lambda without a GL context.
OnRemove releases per-entity state. Unknown guids no-op.
Introduces ITextureCachePerInstance: thin seam interface over the palette
decode path so EntitySpawnAdapter tests can use a CapturingTextureCache
mock without constructing a GL context. TextureCache implements it.
Adjustment 4 documented in source comments: WorldEntity does not currently
expose HiddenPartsMask or AnimPartChanges (they are consumed upstream in the
network layer before the WorldEntity is built). HideParts / SetPartOverride
calls are placeholder TODO'd for when those fields are promoted.
Wired into GpuWorldState.AppendLiveEntity (OnCreate) and
RemoveEntityByServerGuid (OnRemove). Constructed in GameWindow under the
ACDREAM_USE_WB_FOUNDATION flag alongside LandblockSpawnAdapter. Sequencer
factory captures _dats + _animLoader at construction time; falls back to an
empty Setup + MotionTable via NullAnimLoader when dats are unavailable.
10 new tests: server-spawn routing, atlas-tier skip, palette decode pre-warm
(with and without surface overrides), OnRemove lifecycle, unknown-guid noop,
multi-entity isolation. All pass; 8 pre-existing failures unchanged.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Per-entity render state for the per-instance rendering tier
(server-spawned characters / creatures / equipped items). Holds:
- partGfxObjOverrides: Dictionary<int, ulong> — AnimPartChange swaps
(e.g. wielding a weapon replaces a hand-part's GfxObj).
- hiddenMask: ulong — HiddenParts bitmask. Bit i set hides part i.
- AnimationSequencer reference — N.4 doesn't touch the sequencer;
this just exposes it for the draw dispatcher.
Public API: HideParts / IsPartHidden / SetPartOverride /
TryGetPartOverride / ResolvePartGfxObj. Bounds-checked
(partIdx < 0 or >= 64 → IsPartHidden returns false).
Twelve tests covering the type, the AnimPartChange resolution helper,
and the HiddenParts bitmask edge cases (theories for 0b0/0b1/MSB/all-ones,
plus negative-index + out-of-range guards).
Consumed by Task 17's EntitySpawnAdapter (creates one per CreateObject)
and Task 22's WbDrawDispatcher (reads via per-part draw loop).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Without this, ObjectMeshManager.StagedMeshData and
OpenGLGraphicsDevice._glThreadQueue grow unbounded as background
workers prep mesh data + queue GL actions. Visual stress test of
flag-on at radius 7 showed real FPS drop and rising frame latency
from this leak.
Tick() drains both queues:
1. _graphicsDevice.ProcessGLQueue() applies pending GL state.
2. Loop _meshManager.StagedMeshData.TryDequeue -> UploadMeshData
to materialize VAO/VBO/IBO for each prepared mesh.
Wired into GameWindow's render loop before draw work begins.
No-op when adapter is uninitialized or disposed.
Pattern matches WB's reference ObjectRenderManagerBase.ProcessUploads
without the prioritization heuristics (we're not yet drawing the
results — Task 22's WbDrawDispatcher will add prioritization when
visual budget matters).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Verifies Task 12's GpuWorldState wiring preserves the pending-spawn
list mechanism:
1. Live entity parked before its landblock loads — pending count = 1,
adapter not called yet.
2. Landblock arrives with its own atlas-tier entity AND drains the
pending live entity. Adapter sees ONLY the atlas-tier GfxObj
(server-spawned drained entity is filtered by ServerGuid != 0).
3. Live entity arriving AFTER landblock load goes straight to flat
view; adapter is not re-invoked.
4. Landblock unload decrements match load increments.
Three integration tests confirm the existing pending-spawn drain
semantics work correctly with the new adapter, and per-instance-tier
entities (server-spawned) never leak into WB's atlas pipeline.
To exercise the adapter code path (which GpuWorldState gates on
WbFoundationFlag.IsEnabled) without requiring the env var set before
process startup, WbFoundationFlag gains an internal
ForTestsOnly_ForceEnable() method and AcDream.App exposes internals
to AcDream.Core.Tests via InternalsVisibleTo.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bridges LoadedLandblock load/unload events to IWbMeshAdapter ref counts.
Tier-aware by design: walks WorldEntity collection filtered by
ServerGuid == 0 (procedural / atlas-tier only). Server-spawned entities
are skipped — those will go through EntitySpawnAdapter (Task 17).
Per-landblock id-set snapshot ensures unload pairs 1:1 with load even
when underlying data is released. Duplicate-load idempotency for
defensive resilience to streaming-controller bugs.
Six tests: registers per unique id; dedups across entities; skips
server-spawned; unload matches load; unknown landblock no-ops;
duplicate load no-ops.
Wiring into GpuWorldState lands in Task 12.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
WbMeshAdapter now actually constructs the WB pipeline:
- OpenGLGraphicsDevice(gl, logger, DebugRenderSettings)
- DefaultDatReaderWriter(datDir) — opens its own file handles for now
(memory cost ~50-100MB of duplicate index caches, acceptable for
foundation work per plan Adjustment 1)
- ObjectMeshManager(graphicsDevice, dats, NullLogger)
InstancedMeshRenderer.EnsureUploaded routes through the adapter when
ACDREAM_USE_WB_FOUNDATION=1 is set; uses a WbManagedSentinel entry
in the local cache to mark "this GfxObj lives in WB now". CollectGroups
skips sentinel entries; both Draw passes skip them; Dispose skips them
(no GL resources to free — ObjectMeshManager owns those). Task 22's
WbDrawDispatcher will eventually draw WB-managed objects. With flag
off, behavior is byte-identical to before.
WbMeshAdapter constructor signature changed from (GL, DatCollection,
Logger) to (GL, string datDir, Logger). Updated tests to use
CreateUninitialized() for behavior tests and single null-GL guard test
for constructor validation. GameWindow updated to pass _datDir and to
wire _wbMeshAdapter into InstancedMeshRenderer.
AcDream.App.csproj gets direct ProjectReferences to WorldBuilder.Shared
and Chorizite.OpenGLSDLBackend — project refs are not transitive in
.NET, so AcDream.App must list them explicitly even though AcDream.Core
already references them.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stub adapter that validates constructor args and exposes the public
shape (IncrementRefCount / DecrementRefCount / GetRenderData / Dispose).
Real ObjectMeshManager init is deferred to Task 9 — for now methods
no-op so call sites can wire the adapter without behavioral effect.
IWbMeshAdapter interface enables mocking in subsequent tasks
(LandblockSpawnAdapter tests in Task 11 need it).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mesh extraction (4 tests): quad output, double-sided via Stippling.Both,
double-sided via SidesType=Clockwise (AC's NoNeg-clear convention),
NoPos-only emission. Pins GfxObjMesh.Build's behavior.
Setup flatten (5 tests): identity (no frames), Default frame, Resting
beats Default, motion override beats Resting, DefaultScale per part.
Pins SetupMesh.Flatten's placement-frame fallback chain.
These run BEFORE substitution per N.1/N.3 pattern — they prove
equivalence, not test the substitution.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Holds Translucency / Luminosity / Diffuse / SurfOpacity / NeedsUvRepeat /
DisableFog keyed by (gfxObjId, surfaceIdx). Populated at extraction time,
queried by the draw dispatcher. ConcurrentDictionary because mesh
extraction happens on background workers.
No fork patches required — keeps WB's MeshBatchData pristine.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Decode_A8_ExpandsSingleByteToRgbaWithAlphaInAllChannels renamed to
Decode_A8_NonAdditive_ProducesWhitePlusAlpha with updated expectations
(R=G=B=255, A=val) matching the new default isAdditive:false WB semantics.
Decode_CustomLscapeAlpha_TreatedIdenticallyToA8 updated to the same
non-additive expectation (255,255,255,val).
New test Decode_A8_Additive_ReplicatesByteToAllChannels documents the
isAdditive:true path (R=G=B=A=val) used by TerrainAtlas alpha maps.
8 pre-existing failures unchanged. 883 pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>