acdream/docs/research/2026-05-06-issue-49-handoff.md
Erik c5412aa795 docs(research): #49 handoff — scenery (X, Y) drift investigation
Self-contained brief for a fresh agent: bug description with the
2026-05-06 screenshot evidence, what's confirmed (Z fix from #48 is
in, Stab placement is correct, dat content matches), five competing
hypotheses (LCG noise drift / cell-coord transposition / Align
reference / slope filter / SceneInfo lookup), and the cdb retail
trace as the gold-standard diagnostic to disambiguate. Same pattern
as the #48 handoff — paste the doc into a new session as the prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:34:55 +02:00

14 KiB

Issue #49 handoff — scenery (X, Y) placement drifts from retail

Use this whole document as the prompt when handing off to a fresh agent. Everything they need to pick up cold is below.


Background you'll need

You're working in acdream, a from-scratch C# .NET 10 reimplementation of Asheron's Call's retail client. The project's house rule (in CLAUDE.md) is the code is modern, the behavior is retail: every gameplay-affecting algorithm is ported faithfully from the named retail decomp at docs/research/named-retail/ (Sept 2013 EoR build PDB, 99.6% function-name recovery, full pseudo-C in acclient_2013_pseudo_c.txt). Read CLAUDE.md end-to-end before touching code — the workflow ("grep named-retail first → cross-reference → pseudocode → port → conformance test → integrate") is mandatory. Especially relevant for this issue: the cdb-attach retail debugger workflow is documented in CLAUDE.md ("Retail debugger toolchain") and memory/project_retail_debugger.md. You will need it.

Outdoor scenery (trees, bushes, rocks, fences, small props) is generated procedurally per landblock by SceneryGenerator, which is a faithful port of retail's chunk_005A0000.c placement math. The generator returns (GfxObjId, LocalPosition, Rotation, Scale) triples; the renderer combines LocalPosition.X / .Y with the landblock origin and adds the terrain ground Z to get finalZ.

#48 fixed the Z (the bilinear fallback in GameWindow.SampleTerrainZ had its diagonal triangle-pair arms swapped). Trees now sit flush on terrain. #49 is the X/Y counterpart: same scene, same character coords, in some places acdream and retail place trees at different (X, Y).

The bug, in one paragraph

While verifying #48 at Holtburg landblock 0xA9B30001 (acdream character at local (15.20, 12.54, 91.07) with retail running side by side at the same coords), the user spotted a tree placed in dramatically different (X, Y) in acdream vs retail. In acdream, the tree appears on the LEFT of the road, near a chess board / picnic-bench area. In retail at the same character position, that spot is empty; the tree (or a tree of the same species) sits FAR across the road on the right (east) side. Side-by-side screenshot pair captured 2026-05-06 (attached to the issue). User reports they suspect this is widespread, not isolated to one tree.

This is not a Z bug — every tree in the same screenshot has its trunk meeting the visible terrain. It's also not the LandBlockInfo Stab path — the chess board / bench themselves are correctly placed (acdream and retail agree on those), so the landblock origin and lbOffset math are right. The bug is in the procedural scenery placement (SceneryGenerator).

The user's frustration: the scenery math was previously audited against the decomp by eeee4c5 chore(scenery): audit SceneryGenerator against decompiled acclient.exe — all MATCH. The audit confirmed the algorithm shape but did not prove runtime agreement against the real retail client. That's the gap this investigation closes.

Acceptance criterion

  • Side-by-side outdoor screenshot pair (acdream vs retail, same character coords, same time of day) shows scenery positions matching at multiple landblocks. The user verifies visually.
  • Quantitative agreement: a cdb retail trace + acdream ACDREAM_DUMP_SCENERY_Z=1 log together show every spawn at the same (gfxObjId, worldX, worldY, scale, heading) within float precision for at least one landblock end-to-end.
  • Don't break: all scenery that already places correctly must continue to. The Z fix from #48 must not regress (the new TerrainSurfaceTests.SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock conformance test must stay green).

Confirmed facts (don't redo these)

  • The Z fix from #48 is in. TerrainSurface.SampleZ (instance, used by physics / player) and TerrainSurface.SampleZFromHeightmap (static, used by scenery hydration's bilinear fallback) share TerrainSurface.InterpolateZInTriangle. Trees sit flush.
  • LandBlockInfo Stab placement (chess boards, benches, building decorations) is correct — landblock origin and lbOffset math agree with retail.
  • The 46544ef fix(scenery): drop non-retail extra-road-vertex suppression commit is not the cause. User has confirmed that was a good fix.
  • Same retail dat as we use; retail places trees flush AND in a different XY for at least one species. So the dat content is the same — the procedural placement algorithm differs.
  • ACDREAM_DUMP_SCENERY_Z=1 (gated by env var, kept committed after #48) dumps every spawn's (gfxObjId, worldX, worldY, groundZ, partT, zRange, scale) to launch.log. Reuse it for #49.

Competing hypotheses

You need a cdb retail trace to disambiguate. Don't pre-commit until the data tells you which one.

H1 — LCG noise constants drift

SceneryGenerator uses an LCG-based hash of (globalCellX, globalCellY, j) to produce the per-instance displacement noise. The constants 1813693831, 1360117743, 1888038839, 1109124029 came from the decomp port. Predicts: an off-by-one constant or a sign flip on the dx / dy derived from the noise; the displacement vector relative to the cell center is wrong by a constant or by a sign-flipped axis.

Fix if H1: the cdb trace will show retail's (global_cell_x, global_cell_y, j) → (dx, dy) triples. Diff against ours; fix the math to match.

H2 — Cell-coordinate handedness / transposition

SceneryGenerator computes globalCellX = landblockX * 8 + cellX and similarly for Y. The cellX / cellY indexing within a landblock might be reversed relative to retail. Predicts: all trees in the same cell get a consistent (X, Y) shift; the shift varies across cells but follows a transposition pattern (e.g. swapping X and Y around the cell's NW corner).

Fix if H2: swap the cell-indexing convention in SceneryGenerator's noise inputs to match retail.

H3 — obj.Align != 0 path uses wrong reference

Retail has a separate alignment path (FUN_005a6f60) that aligns scenery to the landcell polygon's normal. SceneryGenerator implements this via the Align flag — but the reference point (cell center vs cell origin vs vertex) might differ. Predicts: only species with Align != 0 are misplaced; species with Align == 0 are placed correctly.

Fix if H3: correct the reference-point math for the Align branch.

H4 — Slope filter rejects different cells than retail

The slope filter rejects cells where the terrain normal Z falls outside obj.MinSlope..obj.MaxSlope. If our normal computation disagrees with retail's, we reject some cells retail accepts (or vice versa) — the spawn either disappears or moves to the next eligible cell. Predicts: species-specific differences correlated with sloped vs flat cells; ours has more or fewer instances per landblock than retail.

Fix if H4: port retail's slope filter math exactly. Note the related TerrainSurface.SampleSurface produces the canonical plane normal; that's likely the right source.

H5 — Region.SceneInfo lookup uses wrong scenery list

The cell's (terrain_type, road_type, scenery_type) tuple selects which scenery list applies. If our terrain-type extraction differs from retail's, the wrong list is selected and we spawn a different species (or none) at each cell. Predicts: species mismatches not just position mismatches.

Fix if H5: audit Region.SceneInfo traversal vs retail.

Required first step — cdb trace of retail's placement

This is the gold-standard answer to "what does retail actually do at runtime?" The cdb toolchain is already set up; verify with tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe" (expect === MATCH ===). Then:

  1. Have the user launch retail and walk to Holtburg landblock 0xA9B30001, character at local (~15, ~13). Same spot as the #48 verification.
  2. Write a .cdb script with breakpoints on the placement functions. Candidates from the named-retail decomp:
    • acclient!CLandBlock::get_land_scenes (the outer driver)
    • acclient!FUN_005a6e60 (per-cell scenery placement, called by get_land_scenes)
    • acclient!FUN_005a6cc0 (cell-noise / displacement)
    • acclient!CPhysicsObj::set_initial_frame (final placement)
  3. Each breakpoint action: log gfxObjId (or setup id) and the final (worldX, worldY) going into set_initial_frame. Use gc (go conditional) to avoid trapping the process.
  4. Auto-detach via qd after 2000 hits to stop the trace.
  5. Diff the retail trace against acdream's ACDREAM_DUMP_SCENERY_Z=1 log for the same landblock. The spawn that's offset will be obvious; the offset pattern picks the hypothesis.

Watchouts (per project_retail_debugger.md):

  • qd / q / qq are FORBIDDEN inside bp actions — silently ignored. Use .detach.
  • Don't Stop-Process -Force cdb — kills the debuggee.
  • Per-frame breakpoints with .printf lag retail to ACE timeout — use counter-only actions for high-frequency targets.

Files most likely to need edits

Test workflow (acdream side)

Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 4

$env:ACDREAM_DUMP_SCENERY_Z = "1"
$env:ACDREAM_DAT_DIR   = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE      = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
    Tee-Object -FilePath "launch_issue49.log"

User spawns at Holtburg, types /loc to print position into the log, identifies a misplaced tree visually, reports the spot. You grep the log for entries near that spot, build a per-landblock table, diff against the cdb retail trace.

After the fix lands: rerun the test workflow, user verifies all trees match retail at multiple landblocks (Holtburg, plus at least one more region — Eastham / Glenden Wood). The SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock test must stay green throughout.

Constraints / don't-break

  • #48 Z fix must stay correct. The conformance test pins both sampler paths together — if you touch TerrainSurface, that test must stay green.
  • LandBlockInfo Stab placement (chess boards, benches, etc) is already correct — don't touch the Stab code path.
  • The 46544ef road-vertex suppression drop was a good fix per the user — don't reintroduce that suppression.
  • The eeee4c5 audit comment in SceneryGenerator.cs is now partially wrong (it claimed "all MATCH" but a real bug exists). Once the runtime trace lands, update the comment to reflect what was actually verified vs only-shape-checked.
  • 8 pre-existing motion / BSP step-up test failures are baseline — leave them. New failures from your changes are not OK.

When to stop and ask

Per CLAUDE.md, ask only for:

  • Visual verification (user looking at the client side-by-side with retail).
  • Genuine architectural disagreements (e.g. all five hypotheses prove wrong and a sixth needs brainstorming).
  • Hard-to-reverse destructive actions.

Otherwise act. Don't ask "should I continue?".

References to consult

  • docs/research/2026-05-06-issue-48-fix-pseudocode.md — the Z fix pseudocode; precedent for the static / instance sampler unification pattern.
  • docs/research/named-retail/acclient_2013_pseudo_c.txt — Sept 2013 PDB pseudo-C; grep this FIRST for CLandBlock::get_land_scenes, FUN_005a6e60, FUN_005a6cc0, FUN_005a6f60, CPhysicsObj::set_initial_frame.
  • docs/research/named-retail/symbols.json — raw symbol → address map for cdb breakpoints.
  • ACME WorldBuilder/Editors/Landscape/StaticObjectManager.cs — scenery rendering pipeline reference.
  • memory/project_retail_debugger.md — the cdb attach workflow, watchouts, what NOT to do.
  • memory/feedback_acviewer_as_oracle.md — when a rendering bug shows up in BOTH acdream AND ACViewer, it's in shared dat-interpretation logic, not our renderer. If you have an ACViewer install handy, a quick check there might short-circuit the cdb workflow.

When the fix lands

  1. Update docs/ISSUES.md to mark #49 DONE with the commit SHA.
  2. Add a new line to memory/project_phase_a1_state.md (or wherever scenery state lives) noting the runtime-traced conformance.
  3. Update the comment block at the top of SceneryGenerator.cs from "audit confirmed shape" to "audit + cdb runtime trace confirmed identity at landblock 0xA9B3 (and any others traced)".
  4. Commit on the current branch with a co-authored message (Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>), merge to main, push.

Final note

Don't pre-commit to a hypothesis. The cdb trace tells you the truth in 30 minutes; speculation will burn hours. Issue #48 proved the diagnostic-first pattern works — ACDREAM_DUMP_SCENERY_Z=1 plus one log line picked the right hypothesis (H2 in #48's case) immediately. For #49 the equivalent diagnostic is the cdb retail trace. Use it.