diff --git a/memory/project_session_2026_04_16.md b/memory/project_session_2026_04_16.md
new file mode 100644
index 0000000..24de3a9
--- /dev/null
+++ b/memory/project_session_2026_04_16.md
@@ -0,0 +1,169 @@
+# Session 2026-04-16 — Scenery Alignment + Collision Registration
+
+## Headline result
+
+**Scenery alignment + collision finally in a working state.**
+- All outdoor entities get collision cylinders
+- Trees in roads mostly fixed
+- Trees snapped to terrain (triangle-aware Z sample matching player physics)
+- Visual-to-collision position alignment correct
+
+## Critical root causes found this session
+
+### 1. Scenery entity ID collision across landblocks
+**File:** `src/AcDream.App/Rendering/GameWindow.cs` `BuildSceneryEntitiesForStreaming`
+
+Old: `sceneryIdBase = 0x80000000 | (landblockId & 0x00FFFF00)` — the mask only
+captured bits 8-23 which always has `0xFF` in the low byte (from the lb id
+suffix). Result: every landblock with the same Y coord produced scenery with
+**identical IDs**. When streaming loaded the next landblock, `ShadowObjects
+.Register` called `Deregister(entityId)` first, wiping out the previous
+landblock's collision. Only the LAST-loaded landblock had collision for any
+given ID.
+
+Fix: `0x80000000 | (lbXByte << 16) | (lbYByte << 8)` — each landblock gets a
+unique ID namespace. Every scenery entity now uniquely identified.
+
+### 2. Scenery generator using wrong terrain-Z formula
+**File:** `src/AcDream.App/Rendering/GameWindow.cs` `SampleTerrainZ`
+
+My earlier "triangle-aware" terrain sample had wrong barycentric math.
+Physics's `TerrainSurface.SampleZ` used different (correct) math derived
+from the WorldBuilder mesh index buffer. So player walked at one Z,
+scenery placed at another → trees hovered above ground.
+
+Fix: Ported WorldBuilder `TerrainUtils.GetHeight` exactly — same split
+direction formula, same barycentric triangle pair, same interpolation.
+Both scenery and player now use identical height sampling.
+
+### 3. Collision cylinder position using AABB center, not entity root
+Multi-part scenery meshes can have parts offset from the mesh origin. The
+visual mesh AABB center is NOT the entity's rendered position. Using
+AABB center for the collision cylinder put collision meters away from the
+visible mesh.
+
+Fix: Collision cylinder center = `entity.Position` (the rendered origin).
+Radius from the retail Setup CylSphere when available, clamped to
+[0.3, 1.5] m.
+
+### 4. Scenery iterated 81 vertices, retail iterates 64 cells
+**File:** `src/AcDream.Core/World/SceneryGenerator.cs`
+
+Decompiled FUN_005311a0 iterates `local_8c < 8 && local_94 < 8` — 64 cells.
+We iterated 0..8 on both axes (81 vertices). Extra scenery at landblock
+seams.
+
+Fix: `for (int x = 0; x < CellsPerSide; x++)` where `CellsPerSide = 8`.
+
+Note: WorldBuilder iterates 81, ACViewer iterates 81. Retail iterates 64.
+Currently matching retail decompiled — may need revisiting if we see seam
+gaps.
+
+### 5. Road check was per-vertex nearest-neighbor, should be 4-corner polygonal
+**File:** `src/AcDream.Core/World/SceneryGenerator.cs` `IsOnRoad`
+
+ACE-server's simplified road test uses single-vertex `(terrain & 0x3)` check.
+That's wrong — retail uses a 4-corner polygonal test with 5m road ribbon
+(`FUN_00530d30`).
+
+Fix: Direct port of ACViewer's `Landblock.OnRoad` (lines 300-398) with
+16-case corner-configuration dispatch and 5m `RoadHalfWidth`. Also kept an
+extra "origin-cell has road vertex" guard since retail's exact threshold
+constants (`_DAT_007c97cc/...`) weren't in dumped chunks.
+
+### 6. Pre-displacement road check was wrong
+Retail doesn't skip a vertex based on its own road bit — it rolls displacement
+then tests the final position against OnRoad. My code had `if (IsRoadVertex(raw))
+continue;` which silently dropped scenery retail would have kept.
+
+Fix: Removed. The post-displacement OnRoad test is the only road check.
+
+### 7. Rotation missing baseLoc + (450-heading)%360 flip
+**File:** `src/AcDream.Core/World/SceneryGenerator.cs`
+
+Retail's `AFrame::set_heading(degrees)` transforms `yaw = -(450 - heading) % 360`
+before building the quaternion. It also composes with `BaseLoc.Orientation`.
+
+Fix: Applied both. Rotations now match retail for asymmetric scenery.
+
+### 8. Stabs in buildingCells
+**File:** `src/AcDream.App/Rendering/GameWindow.cs`
+
+Was treating `lbInfo.Objects` (stabs) as buildings, over-suppressing scenery
+in town landblocks. Retail only suppresses scenery in cells with actual
+`lbInfo.Buildings`.
+
+Fix: Only `Buildings` contribute to `buildingCells`.
+
+## Still imperfect (for tomorrow)
+
+- Some scenery still hovers slightly above ground in certain cells.
+ Probably a minor split-direction edge case or BaseLoc.Z handling quirk.
+- User wants a real debug overlay (on-screen text/markers with positions,
+ directions, distances, etc.). Currently has F3 console dump + cyan debug
+ wireframes from the DebugLineRenderer.
+- Collision is now consistent but "feel" needs tuning against retail.
+- Large trees with BSP on canopy still rely on our CylSphere fallback
+ rather than a retail-faithful dual-test (BSP + CylSphere).
+
+## Pickup for next session
+
+User explicitly said next session:
+1. **Debug overlay UI** — on-screen text for player pos, direction, heading
+ in degrees, nearest object distances, "am I colliding" indicator. The
+ current approach (F3 console dump, wireframes in 3D world) is insufficient
+ because the user doesn't know "what 40m looks like" visually.
+2. **Controls** — make working with the client easier. Presumably: keybind
+ list, camera controls, debug toggles, maybe a tool palette.
+
+## Files changed this session
+
+- `src/AcDream.App/Rendering/GameWindow.cs` — scenery ID namespace, OnRoad,
+ visual-mesh-AABB collision, triangle-aware SampleTerrainZ, debug wireframes,
+ F3 dump, building/stab separation, DebugLineRenderer wiring.
+- `src/AcDream.App/Rendering/DebugLineRenderer.cs` (new) — minimal GL line
+ renderer for wireframe debug.
+- `src/AcDream.App/Rendering/Shaders/debug_line.{vert,frag}` (new) — debug
+ line shader pair.
+- `src/AcDream.Core/World/SceneryGenerator.cs` — 64-cell iteration, ACViewer
+ OnRoad, baseLoc+heading rotation, BaseLoc.Z passthrough, removed
+ pre-displacement road check.
+- `src/AcDream.Core/World/WorldEntity.cs` — added `Scale` field for scenery.
+- `src/AcDream.Core/Physics/PhysicsDataCache.cs` — `GfxObjVisualBounds` +
+ `GetVisualBounds` for mesh AABB fallback.
+- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` — `AllEntriesForDebug()`
+ for the debug overlay, `Scale` on ShadowEntry.
+- `src/AcDream.Core/Physics/BSPQuery.cs` — `localToWorld` rotation parameter
+ on all dispatcher methods so collision normals/offsets transform correctly.
+- `src/AcDream.Core/Physics/TransitionTypes.cs` — `CylinderCollision` rewritten
+ with wall-slide + push-out, `FindObjCollisions` rewritten per-object with
+ retail 6-path dispatcher, TransitionalInsert retry loop.
+
+## Key reference pointers (for tomorrow)
+
+- WorldBuilder scenery: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/
+ Lib/SceneryRenderManager.cs` + `SceneryHelpers.cs`.
+- WorldBuilder terrain: `references/WorldBuilder/WorldBuilder.Shared/
+ Modules/Landscape/Lib/TerrainUtils.cs` (GetHeight, GetNormal, OnRoad).
+- Retail decompiled collision: `docs/research/decompiled/chunk_00530000.c`
+ FUN_005311a0 (scenery loop) and FUN_00530d30 (OnRoad).
+- Setup field layout: `docs/research/decompiled/chunk_00510000.c` getter
+ thunks ~line 7563-7662 (CylSpheres at +0x48, Radius +0x64, etc.).
+
+## Collision pipeline overview (current state)
+
+1. **Scenery generation** (`SceneryGenerator.Generate`) — 64 cells per lb,
+ LCG-deterministic displacement, OnRoad + building cell filter, slope
+ filter, returns `ScenerySpawn{ObjectId, LocalPosition, Rotation, Scale}`.
+2. **Scenery hydration** (`BuildSceneryEntitiesForStreaming`) — samples
+ terrain Z via `_physicsEngine.SampleTerrainZ`, builds `WorldEntity`.
+3. **Collision registration** (end of `ApplyLoadedTerrain`) — for every
+ outdoor mesh entity: pick radius from Setup.CylSphere → Setup.Radius →
+ mesh AABB fallback. Height from Setup.Height or mesh. Register cylinder
+ at `entity.Position` in `ShadowObjectRegistry`.
+4. **Collision query** (`Transition.FindObjCollisions`) — player sphere
+ sweeps via `GetNearbyObjects` which searches player's lb + 8 neighbors.
+ Per-object dispatch: BSP → `BSPQuery.FindCollisions` (retail 6-path),
+ Cylinder → `CylinderCollision` (wall-slide + push-out).
+
+Good night.
diff --git a/memory/project_session_2026_04_17.md b/memory/project_session_2026_04_17.md
new file mode 100644
index 0000000..df6e462
--- /dev/null
+++ b/memory/project_session_2026_04_17.md
@@ -0,0 +1,113 @@
+# Session 2026-04-17 — Debug Overlay + Control Tuning
+
+## Headline result
+
+**On-screen HUD overlay and refined input controls for the dev client.**
+- TTF-based font atlas rendered via stb_truetype
+- Screen-space text + rect batcher (`TextRenderer`)
+- Composite overlay with panels for position / heading / collision /
+ FPS / compass / keybind help
+- Per-mode mouse sensitivity (Chase, Fly, Orbit)
+- RMB-held free-orbit around the player (no snap-back on release)
+- Mouse-wheel zoom in chase mode
+- Extended chase pitch range so mouse-Y moves both ways
+
+## Files added
+
+- `src/AcDream.App/Rendering/BitmapFont.cs` — TTF atlas (stb_truetype)
+- `src/AcDream.App/Rendering/TextRenderer.cs` — 2D quad batcher for text + rects
+- `src/AcDream.App/Rendering/DebugOverlay.cs` — composed HUD panels
+- `src/AcDream.App/Rendering/Shaders/ui_text.{vert,frag}` — ortho-proj text shader
+- `src/AcDream.App/Rendering/Shaders/debug_line.{vert,frag}` — wireframe shader
+ (carried from yesterday's scenery-alignment session but not committed then)
+
+## Files modified
+
+- `src/AcDream.App/AcDream.App.csproj` — added `StbTrueTypeSharp` 1.26.12
+- `src/AcDream.App/Rendering/Shader.cs` — added `SetVec2` / `SetVec4`
+- `src/AcDream.App/Rendering/ChaseCamera.cs`
+ - Added `YawOffset` for RMB free-orbit
+ - Added `AdjustDistance` for mouse-wheel zoom
+ - Widened pitch clamp from `[0.05, 1.4]` to `[-0.7, 1.4]`
+ (mouse-Y now moves camera in both directions from neutral)
+ - `DistanceMin=2f`, `DistanceMax=40f` zoom envelope
+- `src/AcDream.App/Rendering/GameWindow.cs`
+ - Field block: `_textRenderer`, `_debugFont`, `_debugOverlay`,
+ `_sensChase/_sensFly/_sensOrbit`, `_rmbHeld`, `_lastFps`, `_lastFrameMs`
+ - OnLoad: load Consolas → BitmapFont → TextRenderer → DebugOverlay
+ (silently skips if no system font)
+ - Keyboard: F1/F2/F4/F5/F6 panel toggles; F8/F9 sensitivity
+ (multiplicative ×1.2 steps, per-mode)
+ - Mouse: MouseDown / MouseUp track RMB; MouseMove routes to the
+ active mode's sensitivity; RMB release does NOT snap YawOffset
+ - OnRender: snapshot builder (player pos, heading, nearest-obj dist,
+ colliding flag) passed to `DebugOverlay.Draw`
+
+## Yesterday's scenery work (finally committed in this session's commit)
+
+The untracked files show that yesterday's scenery-alignment fixes lived
+on disk but hadn't been committed. This session's commit includes:
+- `src/AcDream.Core/Physics/BSPQuery.cs` — `localToWorld` rotation params
+- `src/AcDream.Core/Physics/PhysicsDataCache.cs` — `GfxObjVisualBounds`
+- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` — `Scale` + debug iter
+- `src/AcDream.Core/Physics/TransitionTypes.cs` — rewritten cylinder / BSP
+- `src/AcDream.Core/World/SceneryGenerator.cs` — 64-cell, ACViewer OnRoad,
+ baseLoc + set_heading rotation
+- `src/AcDream.Core/World/WorldEntity.cs` — `Scale` field
+
+## Sensitivity defaults (current)
+
+| Mode | Default | F8 step | F9 step |
+|-------|---------|-------------|-----------|
+| Chase | 0.15x | ÷ 1.2 | × 1.2 |
+| Fly | 1.0x | ÷ 1.2 | × 1.2 |
+| Orbit | 1.0x | ÷ 1.2 | × 1.2 |
+
+Effective rate at chase 0.15x: `0.15 × 0.003 rad/px = 0.00045 rad/px`
+≈ 0.026°/pixel. 1000 pixels → 26° rotation.
+
+Fly at 1.0x is `0.003 rad/px` ≈ 0.172°/px.
+
+## Keybinds (full, current)
+
+| Key | Action |
+|-----------|--------|
+| F1 | Toggle keybind help panel |
+| F2 | Toggle collision wireframes |
+| F3 | Console dump (pos + nearby objects) |
+| F4 | Toggle HUD info panel |
+| F5 | Toggle HUD stats panel |
+| F6 | Toggle compass |
+| F8 / F9 | Active-mode mouse sensitivity slower / faster |
+| F | Toggle fly camera |
+| Tab | Toggle player mode (live session only) |
+| WASD | Move (player mode) / fly |
+| Space | Jump (hold to charge, release to fire) |
+| Shift | Run |
+| Mouse | Turn character / look |
+| Hold RMB | Free-orbit camera around player (stays on release) |
+| Wheel | Zoom chase distance |
+| Escape | Exit fly / player / close window |
+
+## Open issue (parked for follow-up)
+
+User reports mouse "feels like you can only move one way" at low
+sensitivity. Diagnosed + fixed: chase `PitchMin` was clamping at
+`0.05f`, preventing any upward tilt. Widened to `-0.7f`. Needs
+visual verification next session.
+
+## Pickup for next session
+
+**MAJOR TASK PARKED HERE**: user has asked for a deep investigation
++ port of the retail AC client's GUI subsystem. User explicitly
+directed **Opus 4.7 with extra-high effort** for this work. The
+agents are dispatched in this session and their output lives in
+`docs/research/2026-04-17-retail-ui-*.md`. See that set of files
+for the in-depth UI research + C# scaffold.
+
+After the retail-UI port is in place:
+1. Hook the retail chat window to the existing WorldSession message
+ stream
+2. Port the health/stamina/mana globes to real player stats (need
+ `CharacterCreate`/`InqStats` wire parsing first)
+3. Port the inventory panel (needs CreateObject item parsing)
diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj
index 7311fe0..a9edc9f 100644
--- a/src/AcDream.App/AcDream.App.csproj
+++ b/src/AcDream.App/AcDream.App.csproj
@@ -15,6 +15,7 @@
+
diff --git a/src/AcDream.App/Rendering/BitmapFont.cs b/src/AcDream.App/Rendering/BitmapFont.cs
new file mode 100644
index 0000000..c3106ba
--- /dev/null
+++ b/src/AcDream.App/Rendering/BitmapFont.cs
@@ -0,0 +1,180 @@
+using System;
+using System.IO;
+using Silk.NET.OpenGL;
+using StbTrueTypeSharp;
+
+namespace AcDream.App.Rendering;
+
+///
+/// A pixel-font atlas rasterized from a TTF at load time using stb_truetype.
+/// Glyphs are packed into a single-channel (R8) GL texture. Call
+/// to resolve an ASCII codepoint to UV + metrics.
+///
+/// Only printable ASCII (32..127) is supported for the debug overlay.
+///
+public sealed unsafe class BitmapFont : IDisposable
+{
+ public readonly struct Glyph
+ {
+ public readonly float UvMinX;
+ public readonly float UvMinY;
+ public readonly float UvMaxX;
+ public readonly float UvMaxY;
+ public readonly float OffsetX; // from cursor to glyph quad top-left
+ public readonly float OffsetY;
+ public readonly float Width; // pixels
+ public readonly float Height;
+ public readonly float Advance;
+
+ public Glyph(float umn, float vmn, float umx, float vmx,
+ float ox, float oy, float w, float h, float adv)
+ {
+ UvMinX = umn; UvMinY = vmn; UvMaxX = umx; UvMaxY = vmx;
+ OffsetX = ox; OffsetY = oy; Width = w; Height = h; Advance = adv;
+ }
+ }
+
+ private readonly GL _gl;
+ private readonly Glyph[] _glyphs;
+ private readonly int _firstChar;
+ private readonly int _numChars;
+
+ public uint TextureId { get; }
+ public float PixelHeight { get; }
+ public float LineHeight { get; }
+ public float Ascent { get; }
+ public int AtlasWidth { get; }
+ public int AtlasHeight { get; }
+
+ public BitmapFont(GL gl, byte[] ttfBytes, float pixelHeight,
+ int atlasSize = 512, int firstChar = 32, int numChars = 96)
+ {
+ _gl = gl;
+ PixelHeight = pixelHeight;
+ AtlasWidth = atlasSize;
+ AtlasHeight = atlasSize;
+ _firstChar = firstChar;
+ _numChars = numChars;
+
+ // Bake the glyph bitmap via stbtt_BakeFontBitmap.
+ var bakedChars = new StbTrueType.stbtt_bakedchar[numChars];
+ var pixels = new byte[AtlasWidth * AtlasHeight];
+ bool ok = StbTrueType.stbtt_BakeFontBitmap(
+ ttfBytes, 0, pixelHeight,
+ pixels, AtlasWidth, AtlasHeight,
+ firstChar, numChars, bakedChars);
+ if (!ok)
+ throw new InvalidOperationException(
+ $"stbtt_BakeFontBitmap failed: atlas {atlasSize}x{atlasSize} " +
+ $"too small for pixelHeight={pixelHeight}");
+
+ // Extract vertical metrics for line spacing.
+ using var info = StbTrueType.CreateFont(ttfBytes, 0)
+ ?? throw new InvalidOperationException("stbtt_InitFont failed");
+ float scale = StbTrueType.stbtt_ScaleForPixelHeight(info, pixelHeight);
+ int ascent, descent, lineGap;
+ StbTrueType.stbtt_GetFontVMetrics(info, &ascent, &descent, &lineGap);
+ Ascent = ascent * scale;
+ LineHeight = (ascent - descent + lineGap) * scale;
+
+ // Convert baked-char records to our Glyph struct.
+ _glyphs = new Glyph[numChars];
+ for (int i = 0; i < numChars; i++)
+ {
+ var bc = bakedChars[i];
+ float w = bc.x1 - bc.x0;
+ float h = bc.y1 - bc.y0;
+ _glyphs[i] = new Glyph(
+ umn: bc.x0 / (float)AtlasWidth,
+ vmn: bc.y0 / (float)AtlasHeight,
+ umx: bc.x1 / (float)AtlasWidth,
+ vmx: bc.y1 / (float)AtlasHeight,
+ ox: bc.xoff,
+ oy: bc.yoff,
+ w: w, h: h,
+ adv: bc.xadvance);
+ }
+
+ // Upload atlas as a single-channel GL texture (R8).
+ TextureId = _gl.GenTexture();
+ _gl.BindTexture(TextureTarget.Texture2D, TextureId);
+ _gl.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
+ fixed (byte* ptr = pixels)
+ {
+ _gl.TexImage2D(TextureTarget.Texture2D, 0,
+ (int)InternalFormat.R8,
+ (uint)AtlasWidth, (uint)AtlasHeight, 0,
+ PixelFormat.Red, PixelType.UnsignedByte, ptr);
+ }
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter,
+ (int)TextureMinFilter.Linear);
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter,
+ (int)TextureMagFilter.Linear);
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
+ (int)TextureWrapMode.ClampToEdge);
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
+ (int)TextureWrapMode.ClampToEdge);
+ _gl.PixelStore(PixelStoreParameter.UnpackAlignment, 4); // restore default
+ _gl.BindTexture(TextureTarget.Texture2D, 0);
+ }
+
+ public bool TryGetGlyph(char c, out Glyph g)
+ {
+ int idx = c - _firstChar;
+ if ((uint)idx >= (uint)_numChars)
+ {
+ g = default;
+ return false;
+ }
+ g = _glyphs[idx];
+ return true;
+ }
+
+ /// Measure the pixel width of a single-line string in this font.
+ public float MeasureWidth(string s)
+ {
+ float w = 0;
+ for (int i = 0; i < s.Length; i++)
+ {
+ if (TryGetGlyph(s[i], out var g))
+ w += g.Advance;
+ }
+ return w;
+ }
+
+ public void Dispose()
+ {
+ _gl.DeleteTexture(TextureId);
+ }
+
+ ///
+ /// Try to load a monospaced system font from well-known paths on the host OS.
+ /// Returns null if no candidate was found.
+ ///
+ public static byte[]? TryLoadSystemMonospaceFont()
+ {
+ string[] candidates =
+ {
+ @"C:\Windows\Fonts\consola.ttf",
+ @"C:\Windows\Fonts\cour.ttf",
+ @"C:\Windows\Fonts\arial.ttf",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
+ "/usr/share/fonts/TTF/DejaVuSansMono.ttf",
+ "/Library/Fonts/Menlo.ttc",
+ "/System/Library/Fonts/Menlo.ttc",
+ };
+ foreach (var path in candidates)
+ {
+ try
+ {
+ if (File.Exists(path))
+ return File.ReadAllBytes(path);
+ }
+ catch
+ {
+ // try next candidate
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/AcDream.App/Rendering/ChaseCamera.cs b/src/AcDream.App/Rendering/ChaseCamera.cs
index d70950a..c778ff6 100644
--- a/src/AcDream.App/Rendering/ChaseCamera.cs
+++ b/src/AcDream.App/Rendering/ChaseCamera.cs
@@ -14,17 +14,34 @@ public sealed class ChaseCamera : ICamera
public float Aspect { get; set; } = 16f / 9f;
public float FovY { get; set; } = MathF.PI / 3f;
- /// Distance behind the player.
+ /// Distance behind the player. Clamped to [, ].
public float Distance { get; set; } = 8f;
+ public const float DistanceMin = 2f;
+ public const float DistanceMax = 40f;
/// Camera pitch above horizontal (radians). Positive = look down.
public float Pitch { get; set; } = 0.35f; // ~20 degrees
+ ///
+ /// Additional yaw applied on top of the player's heading when positioning
+ /// the camera. Used by the hold-RMB "inspect" mode to orbit around the
+ /// player without rotating the character. Snap to 0 to return the camera
+ /// to directly behind the player.
+ ///
+ public float YawOffset { get; set; } = 0f;
+
/// Vertical offset from the player's feet to the look-at point (eye height).
public float EyeHeight { get; set; } = 1.5f;
- private const float PitchMin = 0.05f;
- private const float PitchMax = 1.4f; // ~80 degrees
+ // Pitch range: negative values place the camera below the player's Z
+ // (at distance * sin(Pitch)) so the player can be viewed from a low
+ // angle. Clamped to -0.7 to avoid pushing the camera deep underground;
+ // at -0.7 and Distance=8 the camera is ~5m below player-Z which will
+ // clip terrain on hills but is OK on flat ground. 1.4 ≈ looking
+ // straight down. Wider than the old [0.05, 1.4] so mouse-Y moves the
+ // camera in both directions from the neutral [~20°] default.
+ private const float PitchMin = -0.7f;
+ private const float PitchMax = 1.4f;
private float _playerYaw;
private Vector3 _lookAt;
@@ -43,9 +60,11 @@ public sealed class ChaseCamera : ICamera
_playerYaw = playerYaw;
_lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight);
- // Camera offset: behind the player (-forward direction) and above.
- float forwardX = MathF.Cos(playerYaw);
- float forwardY = MathF.Sin(playerYaw);
+ // Camera offset: behind the player (-forward direction) plus any
+ // YawOffset for the hold-RMB inspect orbit mode.
+ float effectiveYaw = playerYaw + YawOffset;
+ float forwardX = MathF.Cos(effectiveYaw);
+ float forwardY = MathF.Sin(effectiveYaw);
float horizontalDist = Distance * MathF.Cos(Pitch);
float verticalDist = Distance * MathF.Sin(Pitch);
@@ -63,4 +82,12 @@ public sealed class ChaseCamera : ICamera
{
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
}
+
+ ///
+ /// Adjust distance (zoom) by a delta, clamped to [DistanceMin, DistanceMax].
+ ///
+ public void AdjustDistance(float delta)
+ {
+ Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
+ }
}
diff --git a/src/AcDream.App/Rendering/DebugLineRenderer.cs b/src/AcDream.App/Rendering/DebugLineRenderer.cs
new file mode 100644
index 0000000..d86340e
--- /dev/null
+++ b/src/AcDream.App/Rendering/DebugLineRenderer.cs
@@ -0,0 +1,172 @@
+using System.Collections.Generic;
+using System.Numerics;
+using System.Runtime.InteropServices;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Minimal GL debug line renderer for visualizing collision shapes,
+/// bounding boxes, and other debug geometry. Collect lines each frame
+/// via / , then call
+/// to upload + draw them.
+///
+/// Uses a single shared VBO that's respecialized each frame. Vertex
+/// format is (vec3 pos, vec3 color) = 24 bytes per vertex.
+///
+public sealed unsafe class DebugLineRenderer : IDisposable
+{
+ private readonly GL _gl;
+ private readonly Shader _shader;
+ private readonly uint _vao;
+ private readonly uint _vbo;
+
+ private readonly List _buffer = new(4096);
+ private int _vertexCount;
+ private int _capacityBytes;
+
+ public DebugLineRenderer(GL gl, string shaderDir)
+ {
+ _gl = gl;
+ _shader = new Shader(gl,
+ Path.Combine(shaderDir, "debug_line.vert"),
+ Path.Combine(shaderDir, "debug_line.frag"));
+
+ _vao = _gl.GenVertexArray();
+ _vbo = _gl.GenBuffer();
+
+ _gl.BindVertexArray(_vao);
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
+
+ // 24-byte stride: vec3 pos + vec3 color
+ _gl.EnableVertexAttribArray(0);
+ _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)0);
+ _gl.EnableVertexAttribArray(1);
+ _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)(3 * sizeof(float)));
+
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
+ _gl.BindVertexArray(0);
+ }
+
+ /// Clear accumulated lines. Call at the start of each frame.
+ public void Begin()
+ {
+ _buffer.Clear();
+ _vertexCount = 0;
+ }
+
+ public void AddLine(Vector3 a, Vector3 b, Vector3 color)
+ {
+ _buffer.Add(a.X); _buffer.Add(a.Y); _buffer.Add(a.Z);
+ _buffer.Add(color.X); _buffer.Add(color.Y); _buffer.Add(color.Z);
+ _buffer.Add(b.X); _buffer.Add(b.Y); _buffer.Add(b.Z);
+ _buffer.Add(color.X); _buffer.Add(color.Y); _buffer.Add(color.Z);
+ _vertexCount += 2;
+ }
+
+ ///
+ /// Draw a cylinder as 2 polygon rings (base + top) connected by 4
+ /// vertical line segments at 0/90/180/270 degrees.
+ ///
+ public void AddCylinder(Vector3 basePos, float radius, float height, Vector3 color)
+ {
+ const int segments = 16;
+ Vector3 top = basePos + new Vector3(0, 0, height);
+
+ // Ring vertices
+ var baseRing = new Vector3[segments];
+ var topRing = new Vector3[segments];
+ for (int i = 0; i < segments; i++)
+ {
+ float theta = i * (MathF.PI * 2f / segments);
+ float cx = MathF.Cos(theta) * radius;
+ float cy = MathF.Sin(theta) * radius;
+ baseRing[i] = new Vector3(basePos.X + cx, basePos.Y + cy, basePos.Z);
+ topRing[i] = new Vector3(top.X + cx, top.Y + cy, top.Z);
+ }
+
+ // Base ring
+ for (int i = 0; i < segments; i++)
+ AddLine(baseRing[i], baseRing[(i + 1) % segments], color);
+ // Top ring
+ for (int i = 0; i < segments; i++)
+ AddLine(topRing[i], topRing[(i + 1) % segments], color);
+ // 4 vertical connectors
+ for (int i = 0; i < 4; i++)
+ {
+ int idx = i * (segments / 4);
+ AddLine(baseRing[idx], topRing[idx], color);
+ }
+ }
+
+ ///
+ /// Draw an axis-aligned box as 12 edges.
+ ///
+ public void AddBox(Vector3 min, Vector3 max, Vector3 color)
+ {
+ Vector3[] c =
+ {
+ new(min.X, min.Y, min.Z),
+ new(max.X, min.Y, min.Z),
+ new(max.X, max.Y, min.Z),
+ new(min.X, max.Y, min.Z),
+ new(min.X, min.Y, max.Z),
+ new(max.X, min.Y, max.Z),
+ new(max.X, max.Y, max.Z),
+ new(min.X, max.Y, max.Z),
+ };
+ // Bottom
+ AddLine(c[0], c[1], color); AddLine(c[1], c[2], color);
+ AddLine(c[2], c[3], color); AddLine(c[3], c[0], color);
+ // Top
+ AddLine(c[4], c[5], color); AddLine(c[5], c[6], color);
+ AddLine(c[6], c[7], color); AddLine(c[7], c[4], color);
+ // Verticals
+ AddLine(c[0], c[4], color); AddLine(c[1], c[5], color);
+ AddLine(c[2], c[6], color); AddLine(c[3], c[7], color);
+ }
+
+ /// Upload + draw all accumulated lines.
+ public void Flush(Matrix4x4 view, Matrix4x4 projection)
+ {
+ if (_vertexCount == 0) return;
+
+ _shader.Use();
+ _shader.SetMatrix4("uView", view);
+ _shader.SetMatrix4("uProjection", projection);
+
+ _gl.BindVertexArray(_vao);
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
+
+ int neededBytes = _buffer.Count * sizeof(float);
+ if (neededBytes > _capacityBytes)
+ {
+ fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer))
+ _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)neededBytes, ptr, BufferUsageARB.DynamicDraw);
+ _capacityBytes = neededBytes;
+ }
+ else
+ {
+ fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer))
+ _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)neededBytes, ptr);
+ }
+
+ // Depth test on so lines get occluded by geometry (but we want them
+ // visible through geometry — disable depth test so everything shows).
+ bool wasDepthEnabled = _gl.IsEnabled(EnableCap.DepthTest);
+ _gl.Disable(EnableCap.DepthTest);
+
+ _gl.DrawArrays(PrimitiveType.Lines, 0, (uint)_vertexCount);
+
+ if (wasDepthEnabled) _gl.Enable(EnableCap.DepthTest);
+
+ _gl.BindVertexArray(0);
+ }
+
+ public void Dispose()
+ {
+ _gl.DeleteVertexArray(_vao);
+ _gl.DeleteBuffer(_vbo);
+ _shader.Dispose();
+ }
+}
diff --git a/src/AcDream.App/Rendering/DebugOverlay.cs b/src/AcDream.App/Rendering/DebugOverlay.cs
new file mode 100644
index 0000000..7ac635a
--- /dev/null
+++ b/src/AcDream.App/Rendering/DebugOverlay.cs
@@ -0,0 +1,330 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Screen-space debug HUD. Composes panels on top of the 3D scene using a
+/// + . Panels can be
+/// toggled independently (info / stats / controls-help / compass).
+///
+/// The overlay is stateless w.r.t. game state — callers populate a
+/// each frame and pass it to .
+///
+public sealed class DebugOverlay
+{
+ private readonly TextRenderer _text;
+ private readonly BitmapFont _font;
+
+ public bool ShowInfoPanel { get; set; } = true;
+ public bool ShowStatsPanel { get; set; } = true;
+ public bool ShowHelpPanel { get; set; } = false;
+ public bool ShowCompass { get; set; } = true;
+
+ // Toast state for transient notifications (e.g. "wireframes off").
+ private string? _toastText;
+ private Vector4 _toastColor = White;
+ private float _toastTimeLeft;
+
+ private static readonly Vector4 White = new(1f, 1f, 1f, 1f);
+ private static readonly Vector4 Green = new(0.4f, 0.95f, 0.4f, 1f);
+ private static readonly Vector4 Yellow = new(1f, 0.9f, 0.3f, 1f);
+ private static readonly Vector4 Red = new(1f, 0.4f, 0.35f, 1f);
+ private static readonly Vector4 Cyan = new(0.4f, 0.85f, 1f, 1f);
+ private static readonly Vector4 Grey = new(0.7f, 0.7f, 0.75f, 1f);
+ private static readonly Vector4 PanelBg = new(0f, 0f, 0f, 0.55f);
+ private static readonly Vector4 PanelBorder = new(0.15f, 0.15f, 0.2f, 0.8f);
+
+ /// Per-frame state snapshot from the caller. See .
+ public readonly record struct Snapshot(
+ float Fps,
+ float FrameTimeMs,
+ Vector3 PlayerPos,
+ float HeadingDeg,
+ uint CellId,
+ bool OnGround,
+ bool InPlayerMode,
+ bool InFlyMode,
+ float VerticalVelocity,
+ int EntityCount,
+ int AnimatedCount,
+ int LandblocksVisible,
+ int LandblocksTotal,
+ int ShadowObjectCount,
+ float NearestObjDist,
+ string NearestObjLabel,
+ bool Colliding,
+ bool DebugWireframes,
+ int StreamingRadius,
+ float MouseSensitivity,
+ float ChaseDistance,
+ bool RmbOrbit);
+
+ public DebugOverlay(TextRenderer text, BitmapFont font)
+ {
+ _text = text;
+ _font = font;
+ }
+
+ /// Show a short message in the center-top for seconds.
+ public void Toast(string message, float durationSec = 1.5f, Vector4? color = null)
+ {
+ _toastText = message;
+ _toastColor = color ?? Yellow;
+ _toastTimeLeft = durationSec;
+ }
+
+ /// Advance toast timer. Call once per frame with dt in seconds.
+ public void Update(float dt)
+ {
+ if (_toastTimeLeft > 0f)
+ {
+ _toastTimeLeft -= dt;
+ if (_toastTimeLeft <= 0f)
+ _toastText = null;
+ }
+ }
+
+ public void Draw(Snapshot s, Vector2 screenSize)
+ {
+ _text.Begin(screenSize);
+
+ if (ShowInfoPanel) DrawInfoPanel(s);
+ if (ShowStatsPanel) DrawStatsPanel(s, screenSize);
+ if (ShowCompass) DrawCompass(s, screenSize);
+ if (ShowHelpPanel) DrawHelpPanel(screenSize);
+ DrawHintBar(screenSize);
+ DrawToast(screenSize);
+
+ _text.Flush(_font);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Info panel — top-left: mode, position, heading, ground, nearest-obj.
+ // ──────────────────────────────────────────────────────────────────────
+
+ private void DrawInfoPanel(Snapshot s)
+ {
+ var lines = new List<(string text, Vector4 color)>();
+
+ string modeLabel = s.InPlayerMode ? "PLAYER" : (s.InFlyMode ? "FLY " : "ORBIT ");
+ var modeColor = s.InPlayerMode ? Yellow : (s.InFlyMode ? Cyan : Grey);
+ lines.Add(($"[{modeLabel}] wireframes {(s.DebugWireframes ? "ON " : "OFF")}",
+ modeColor));
+
+ lines.Add(($"Pos {s.PlayerPos.X,8:F1} {s.PlayerPos.Y,8:F1} {s.PlayerPos.Z,8:F2}", White));
+ lines.Add(($"Head {s.HeadingDeg,5:F0} deg Cell 0x{s.CellId:X8}", White));
+
+ var gColor = s.OnGround ? Green : Yellow;
+ string vzStr = s.VerticalVelocity >= 0
+ ? $"+{s.VerticalVelocity,4:F2}"
+ : $"{s.VerticalVelocity,5:F2}";
+ lines.Add(($"Grnd {(s.OnGround ? "yes" : "NO ")} vZ {vzStr}", gColor));
+
+ var nColor = s.Colliding ? Red : White;
+ string nearDist = float.IsPositiveInfinity(s.NearestObjDist) ? " --- " : $"{s.NearestObjDist,4:F1}m";
+ lines.Add(($"Near {nearDist} {s.NearestObjLabel}", nColor));
+
+ var cColor = s.Colliding ? Red : Green;
+ lines.Add(($"Coll {(s.Colliding ? "BLOCKED" : "free ")}", cColor));
+
+ if (s.InPlayerMode)
+ {
+ string orbitTag = s.RmbOrbit ? " [RMB orbit]" : "";
+ lines.Add(($"Cam dist {s.ChaseDistance,4:F1}m{orbitTag}",
+ s.RmbOrbit ? Cyan : White));
+ }
+ lines.Add(($"Sens {s.MouseSensitivity:F3}x (F8 slower / F9 faster)", Grey));
+
+ DrawPanel(10f, 10f, lines);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Stats panel — top-right: fps, frame time, landblock/entity counters.
+ // ──────────────────────────────────────────────────────────────────────
+
+ private void DrawStatsPanel(Snapshot s, Vector2 screenSize)
+ {
+ var lines = new List<(string text, Vector4 color)>
+ {
+ ($"{s.Fps,5:F0} fps {s.FrameTimeMs,5:F1} ms", Green),
+ ($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White),
+ ($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White),
+ ($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White),
+ };
+
+ float pad = 10f;
+ float panelW = MeasureMax(lines) + 2 * InnerPad;
+ DrawPanel(screenSize.X - panelW - pad, pad, lines, panelW);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Compass — bottom-center: arrow indicating heading, cardinal labels.
+ // ──────────────────────────────────────────────────────────────────────
+
+ private void DrawCompass(Snapshot s, Vector2 screenSize)
+ {
+ // Simple linear compass strip across 180° of horizon at the top-center.
+ const float stripW = 360f;
+ const float stripH = 20f;
+ float cx = screenSize.X * 0.5f;
+ float top = 8f;
+ float left = cx - stripW * 0.5f;
+
+ _text.DrawRect(left, top, stripW, stripH, PanelBg);
+ _text.DrawRectOutline(left, top, stripW, stripH, PanelBorder);
+
+ // Mark every 30° of yaw, labelled with cardinal letters at N/E/S/W.
+ // Heading 0 = +X (east). We show 180° wide, ±90° from current heading.
+ float h = NormalizeDeg(s.HeadingDeg);
+ for (int d = 0; d < 360; d += 15)
+ {
+ float rel = NormalizeDegSigned(d - h);
+ if (rel < -90 || rel > 90) continue;
+ float x = cx + rel / 90f * (stripW * 0.5f - 6f);
+ bool bold = (d % 90) == 0;
+ float tickH = bold ? stripH * 0.9f : stripH * 0.4f;
+ _text.DrawRect(x - 0.5f, top + stripH - tickH, 1f, tickH, bold ? White : Grey);
+
+ if (bold)
+ {
+ string lab = d switch
+ {
+ 0 => "E",
+ 90 => "N",
+ 180 => "W",
+ 270 => "S",
+ _ => ""
+ };
+ if (lab.Length > 0)
+ {
+ float w = _font.MeasureWidth(lab);
+ _text.DrawString(_font, lab, x - w * 0.5f, top - _font.LineHeight + 4f, White);
+ }
+ }
+ }
+
+ // Current heading indicator arrow below the strip.
+ _text.DrawRect(cx - 1.5f, top + stripH + 2f, 3f, 6f, Yellow);
+ string hText = $"{h,3:F0}°";
+ float hw = _font.MeasureWidth(hText);
+ _text.DrawString(_font, hText, cx - hw * 0.5f, top + stripH + 10f, Yellow);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Help panel — center: full keybind cheat-sheet, shown when F1 is pressed.
+ // ──────────────────────────────────────────────────────────────────────
+
+ private static readonly (string key, string desc)[] Keybinds =
+ {
+ ("F1", "toggle this help"),
+ ("F2", "toggle collision wireframes"),
+ ("F3", "console dump (pos + nearby objects)"),
+ ("F4", "toggle debug HUD info panel"),
+ ("F5", "toggle stats panel"),
+ ("F6", "toggle compass"),
+ ("F", "toggle fly camera"),
+ ("Tab", "toggle player mode (requires login)"),
+ ("W A S D", "move (player mode) / fly"),
+ ("Mouse", "turn character / look (fly)"),
+ ("Hold RMB", "free orbit camera around player"),
+ ("Wheel", "zoom chase camera in / out"),
+ ("F8 / F9", "mouse sensitivity slower / faster"),
+ ("Space", "jump (hold to charge)"),
+ ("Shift", "run"),
+ ("Escape", "exit fly / player / close window"),
+ };
+
+ private void DrawHelpPanel(Vector2 screenSize)
+ {
+ var lines = new List<(string text, Vector4 color)>();
+ lines.Add(("CONTROLS", Yellow));
+ lines.Add(("", White));
+ foreach (var (k, d) in Keybinds)
+ lines.Add(($" {k,-9} {d}", White));
+ lines.Add(("", White));
+ lines.Add(("Press F1 to close", Grey));
+
+ float panelW = MeasureMax(lines) + 2 * InnerPad;
+ float panelH = lines.Count * _font.LineHeight + 2 * InnerPad;
+ float x = (screenSize.X - panelW) * 0.5f;
+ float y = (screenSize.Y - panelH) * 0.5f;
+ DrawPanel(x, y, lines, panelW);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Hint bar — bottom-left: always-visible "F1 for help" reminder.
+ // ──────────────────────────────────────────────────────────────────────
+
+ private void DrawHintBar(Vector2 screenSize)
+ {
+ string hint = "F1 help F2 wireframes F3 dump F4/F5/F6 panels F8/F9 sens Tab player Hold RMB orbit Wheel zoom";
+ float w = _font.MeasureWidth(hint);
+ float pad = 10f;
+ float y = screenSize.Y - _font.LineHeight - pad;
+ _text.DrawRect(pad - 4, y - 3, w + 8, _font.LineHeight + 6, PanelBg);
+ _text.DrawString(_font, hint, pad, y, Grey);
+ }
+
+ private void DrawToast(Vector2 screenSize)
+ {
+ if (_toastText is null || _toastTimeLeft <= 0f) return;
+ float w = _font.MeasureWidth(_toastText);
+ float x = (screenSize.X - w) * 0.5f;
+ float y = 60f;
+ var c = _toastColor;
+ float alpha = MathF.Min(1f, _toastTimeLeft / 0.5f);
+ c.W *= alpha;
+ var bg = PanelBg;
+ bg.W *= alpha;
+ _text.DrawRect(x - 10, y - 4, w + 20, _font.LineHeight + 8, bg);
+ _text.DrawString(_font, _toastText, x, y, c);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Panel helpers.
+ // ──────────────────────────────────────────────────────────────────────
+
+ private const float InnerPad = 8f;
+
+ private float MeasureMax(IReadOnlyList<(string text, Vector4 color)> lines)
+ {
+ float m = 0;
+ foreach (var (text, _) in lines)
+ m = MathF.Max(m, _font.MeasureWidth(text));
+ return m;
+ }
+
+ private void DrawPanel(float x, float y, IReadOnlyList<(string text, Vector4 color)> lines, float? widthOverride = null)
+ {
+ float maxW = MeasureMax(lines);
+ float panelW = widthOverride ?? (maxW + 2 * InnerPad);
+ float panelH = lines.Count * _font.LineHeight + 2 * InnerPad;
+
+ _text.DrawRect(x, y, panelW, panelH, PanelBg);
+ _text.DrawRectOutline(x, y, panelW, panelH, PanelBorder);
+
+ float cy = y + InnerPad;
+ foreach (var (text, color) in lines)
+ {
+ _text.DrawString(_font, text, x + InnerPad, cy, color);
+ cy += _font.LineHeight;
+ }
+ }
+
+ private static float NormalizeDeg(float deg)
+ {
+ deg %= 360f;
+ if (deg < 0) deg += 360f;
+ return deg;
+ }
+
+ private static float NormalizeDegSigned(float deg)
+ {
+ deg %= 360f;
+ if (deg > 180) deg -= 360f;
+ if (deg < -180) deg += 360f;
+ return deg;
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index a7b834f..773c280 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -26,6 +26,21 @@ public sealed class GameWindow : IDisposable
private InstancedMeshRenderer? _staticMesh;
private Shader? _meshShader;
private TextureCache? _textureCache;
+ private DebugLineRenderer? _debugLines;
+ private bool _debugCollisionVisible = true;
+ private int _debugDrawLogOnce = 0;
+
+ // On-screen debug HUD — info panel, stats panel, compass, keybind help.
+ // F1/F2/F4/F5/F6 toggle the individual panels (see the key handler).
+ // Null if no system font is available at startup; in that case the HUD
+ // is silently disabled and the rest of the client keeps working.
+ private TextRenderer? _textRenderer;
+ private BitmapFont? _debugFont;
+ private DebugOverlay? _debugOverlay;
+ // Last-computed perf values so the HUD always has something to show even
+ // though the title-bar FPS is only updated every 0.5s.
+ private double _lastFps = 60.0;
+ private double _lastFrameMs = 16.7;
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
@@ -119,6 +134,19 @@ public sealed class GameWindow : IDisposable
// callback, consumed + reset in OnUpdate each frame.
private float _playerMouseDeltaX;
+ // Mouse sensitivity multipliers — one per camera mode because the visual
+ // feel is very different. Adjust via F8 / F9 for whichever mode is
+ // currently active. Chase default is low because the character + camera
+ // rotating together is overwhelming at fly speeds.
+ private float _sensChase = 0.15f;
+ private float _sensFly = 1.0f;
+ private float _sensOrbit = 1.0f;
+
+ // Right-mouse-button held → free-orbit the chase camera around the
+ // player without turning the character. Release leaves the camera at
+ // the orbited position (no snap back).
+ private bool _rmbHeld;
+
// Phase 4.7: optional live connection to an ACE server. Enabled only when
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
// the offline rendering pipeline.
@@ -189,6 +217,121 @@ public sealed class GameWindow : IDisposable
{
if (key == Key.F)
_cameraController?.ToggleFly();
+ else if (key == Key.F3)
+ {
+ // Dump player position + ALL entities (visible rendered) + shadow objs within 15m.
+ // Lets us see visible-entity-without-collision gaps.
+ System.Numerics.Vector3 pos;
+ if (_playerMode && _playerController is not null)
+ pos = _playerController.Position;
+ else
+ {
+ System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv);
+ pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
+ }
+ int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f);
+ int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f);
+ Console.WriteLine(
+ $"=== F3 DEBUG DUMP ===\n" +
+ $" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" +
+ $" landblock=0x{(uint)((lbX<<24)|(lbY<<16)|0xFFFF):X8} local=({pos.X - (lbX-_liveCenterX)*192f:F2},{pos.Y - (lbY-_liveCenterY)*192f:F2})\n" +
+ $" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}");
+
+ // Collect VISIBLE entities within 15m (from GpuWorldState)
+ var visibleNearby = new List();
+ foreach (var e in _worldState.Entities)
+ {
+ float dx = e.Position.X - pos.X;
+ float dy = e.Position.Y - pos.Y;
+ if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e);
+ }
+ Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}");
+ foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12))
+ {
+ float d = (e.Position - pos).Length();
+ Console.WriteLine(
+ $" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " +
+ $"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}");
+ }
+
+ // Collect shadow objects within 15m
+ var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>();
+ foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug())
+ {
+ float dx = o.Position.X - pos.X;
+ float dy = o.Position.Y - pos.Y;
+ float d = MathF.Sqrt(dx * dx + dy * dy);
+ if (d < 15f) sorted.Add((o, d));
+ }
+ sorted.Sort((a, b) => a.dist.CompareTo(b.dist));
+ Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}");
+ foreach (var (o, d) in sorted.Take(12))
+ {
+ Console.WriteLine(
+ $" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " +
+ $"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}");
+ }
+ }
+ else if (key == Key.F1)
+ {
+ if (_debugOverlay is not null)
+ {
+ _debugOverlay.ShowHelpPanel = !_debugOverlay.ShowHelpPanel;
+ _debugOverlay.Toast($"Help {(_debugOverlay.ShowHelpPanel ? "ON" : "OFF")}");
+ }
+ }
+ else if (key == Key.F2)
+ {
+ _debugCollisionVisible = !_debugCollisionVisible;
+ _debugOverlay?.Toast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}");
+ }
+ else if (key == Key.F4)
+ {
+ if (_debugOverlay is not null)
+ {
+ _debugOverlay.ShowInfoPanel = !_debugOverlay.ShowInfoPanel;
+ _debugOverlay.Toast($"Info panel {(_debugOverlay.ShowInfoPanel ? "ON" : "OFF")}");
+ }
+ }
+ else if (key == Key.F5)
+ {
+ if (_debugOverlay is not null)
+ {
+ _debugOverlay.ShowStatsPanel = !_debugOverlay.ShowStatsPanel;
+ _debugOverlay.Toast($"Stats panel {(_debugOverlay.ShowStatsPanel ? "ON" : "OFF")}");
+ }
+ }
+ else if (key == Key.F6)
+ {
+ if (_debugOverlay is not null)
+ {
+ _debugOverlay.ShowCompass = !_debugOverlay.ShowCompass;
+ _debugOverlay.Toast($"Compass {(_debugOverlay.ShowCompass ? "ON" : "OFF")}");
+ }
+ }
+ else if (key == Key.F8 || key == Key.F9)
+ {
+ // Adjust whichever mode's sensitivity is currently active.
+ // Multiplicative step (1.2x / /1.2x) so low values stay fine
+ // grained and high values move in proportional chunks.
+ string modeLabel;
+ float current;
+ if (_playerMode && _cameraController?.IsChaseMode == true)
+ { modeLabel = "Chase"; current = _sensChase; }
+ else if (_cameraController?.IsFlyMode == true)
+ { modeLabel = "Fly"; current = _sensFly; }
+ else
+ { modeLabel = "Orbit"; current = _sensOrbit; }
+
+ float next = (key == Key.F9) ? current * 1.2f : current / 1.2f;
+ next = MathF.Min(3.0f, MathF.Max(0.005f, next));
+
+ if (modeLabel == "Chase") _sensChase = next;
+ else if (modeLabel == "Fly") _sensFly = next;
+ else _sensOrbit = next;
+
+ _debugOverlay?.Toast($"{modeLabel} sens {next:F3}x");
+ }
else if (key == Key.Escape)
{
if (_cameraController?.IsFlyMode == true)
@@ -292,37 +435,82 @@ public sealed class GameWindow : IDisposable
float dx = pos.X - _lastMouseX;
float dy = pos.Y - _lastMouseY;
- if (_playerMode && _cameraController.IsChaseMode)
+ if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
{
- // Phase B.2: player mode — mouse X turns the character,
- // mouse Y adjusts chase camera pitch.
- // Accumulate X for the controller to consume in OnUpdate.
- _playerMouseDeltaX += dx;
- _chaseCamera?.AdjustPitch(dy * 0.005f);
+ float sens = _sensChase;
+ if (_rmbHeld)
+ {
+ // Hold-RMB orbit: player stays the central point, camera
+ // free-orbits around. X rotates around, Y pitches. On release
+ // the camera STAYS at the new angle (no snap back).
+ _chaseCamera.YawOffset -= dx * 0.004f * sens;
+ _chaseCamera.AdjustPitch(dy * 0.003f * sens);
+ }
+ else
+ {
+ // Normal chase: X turns the character, Y pitches camera.
+ _playerMouseDeltaX += dx * sens;
+ _chaseCamera.AdjustPitch(dy * 0.003f * sens);
+ }
}
else if (_cameraController.IsFlyMode)
{
- // Raw cursor mode: Silk.NET gives deltas via position. Compute delta from last.
- _cameraController.Fly.Look(dx, dy);
+ float sens = _sensFly;
+ _cameraController.Fly.Look(dx * sens, dy * sens);
}
else
{
+ float sens = _sensOrbit;
if (m.IsButtonPressed(MouseButton.Left))
{
- _cameraController.Orbit.Yaw -= dx * 0.005f;
+ _cameraController.Orbit.Yaw -= dx * 0.005f * sens;
_cameraController.Orbit.Pitch = Math.Clamp(
- _cameraController.Orbit.Pitch + dy * 0.005f,
+ _cameraController.Orbit.Pitch + dy * 0.005f * sens,
0.1f, 1.5f);
}
}
_lastMouseX = pos.X;
_lastMouseY = pos.Y;
};
+
+ mouse.MouseDown += (_, btn) =>
+ {
+ if (btn == MouseButton.Right && _playerMode
+ && _cameraController?.IsChaseMode == true)
+ {
+ _rmbHeld = true;
+ }
+ };
+
+ mouse.MouseUp += (_, btn) =>
+ {
+ if (btn == MouseButton.Right)
+ {
+ // Camera stays at the orbited position — no snap back.
+ _rmbHeld = false;
+ }
+ };
+
mouse.Scroll += (_, scroll) =>
{
- if (_cameraController is null || _cameraController.IsFlyMode) return;
- _cameraController.Orbit.Distance = Math.Clamp(
- _cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f);
+ if (_cameraController is null) return;
+
+ // Chase mode: mouse wheel zooms (adjusts camera distance).
+ if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
+ {
+ _chaseCamera.AdjustDistance(-scroll.Y * 0.8f);
+ }
+ // Fly mode: no scroll action (could adjust move speed later).
+ else if (_cameraController.IsFlyMode)
+ {
+ // no-op
+ }
+ // Orbit mode: wheel zooms the orbit camera.
+ else
+ {
+ _cameraController.Orbit.Distance = Math.Clamp(
+ _cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f);
+ }
};
}
@@ -338,6 +526,25 @@ public sealed class GameWindow : IDisposable
Path.Combine(shadersDir, "mesh_instanced.vert"),
Path.Combine(shadersDir, "mesh_instanced.frag"));
+ _debugLines = new DebugLineRenderer(_gl, shadersDir);
+
+ // Debug HUD: load a system monospace font and set up the text overlay.
+ // Skips silently if no font is available (the rest of the client still works).
+ var fontBytes = BitmapFont.TryLoadSystemMonospaceFont();
+ if (fontBytes is not null)
+ {
+ _debugFont = new BitmapFont(_gl, fontBytes, pixelHeight: 15f, atlasSize: 512);
+ _textRenderer = new TextRenderer(_gl, shadersDir);
+ _debugOverlay = new DebugOverlay(_textRenderer, _debugFont);
+ Console.WriteLine($"debug overlay: loaded {fontBytes.Length / 1024}KB font, " +
+ $"atlas {_debugFont.AtlasWidth}x{_debugFont.AtlasHeight}, " +
+ $"lineHeight={_debugFont.LineHeight:F1}px");
+ }
+ else
+ {
+ Console.WriteLine("debug overlay: no system monospace font found; HUD disabled");
+ }
+
var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y };
var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y };
_cameraController = new CameraController(orbit, fly);
@@ -902,28 +1109,59 @@ public sealed class GameWindow : IDisposable
/// Bilinear sample of the landblock heightmap at (x, y) in landblock-local
/// world units. Matches the x-major indexing convention of LandblockMesh.
///
- private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float worldX, float worldY)
+ private float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float worldX, float worldY)
{
+ // Exact port of WorldBuilder TerrainUtils.GetHeight (line 59-108).
+ // Barycentric interpolation over the cell's triangle pair, respecting
+ // the cell's split direction (SWtoNE vs SEtoNW).
const float CellSize = 24f;
- const int VerticesPerSide = 9;
- float fx = Math.Clamp(worldX / CellSize, 0f, VerticesPerSide - 1);
- float fy = Math.Clamp(worldY / CellSize, 0f, VerticesPerSide - 1);
- int x0 = (int)MathF.Floor(fx);
- int y0 = (int)MathF.Floor(fy);
- int x1 = Math.Min(x0 + 1, VerticesPerSide - 1);
- int y1 = Math.Min(y0 + 1, VerticesPerSide - 1);
- float tx = fx - x0;
- float ty = fy - y0;
+ uint cellX = (uint)(worldX / CellSize);
+ uint cellY = (uint)(worldY / CellSize);
+ if (cellX >= 8) cellX = 7;
+ if (cellY >= 8) cellY = 7;
- // Heightmap is packed x-major (Height[x*9+y]) matching LandblockMesh.
- float h00 = heightTable[block.Height[x0 * 9 + y0]];
- float h10 = heightTable[block.Height[x1 * 9 + y0]];
- float h01 = heightTable[block.Height[x0 * 9 + y1]];
- float h11 = heightTable[block.Height[x1 * 9 + y1]];
- float hx0 = h00 * (1 - tx) + h10 * tx;
- float hx1 = h01 * (1 - tx) + h11 * tx;
- return hx0 * (1 - ty) + hx1 * ty;
+ uint landblockX = (block.Id >> 24) & 0xFFu;
+ uint landblockY = (block.Id >> 16) & 0xFFu;
+ var splitDirection = AcDream.Core.Terrain.TerrainBlending.CalculateSplitDirection(
+ landblockX, cellX, landblockY, cellY);
+
+ // 4 cell corners (heightmap x-major: Height[x*9 + y])
+ float h0 = heightTable[block.Height[cellX * 9 + cellY]]; // BL
+ float h1 = heightTable[block.Height[(cellX + 1) * 9 + cellY]]; // BR
+ float h2 = heightTable[block.Height[(cellX + 1) * 9 + (cellY + 1)]]; // TR
+ float h3 = heightTable[block.Height[cellX * 9 + (cellY + 1)]]; // TL
+
+ float lx = worldX - cellX * CellSize;
+ float ly = worldY - cellY * CellSize;
+ float s = lx / CellSize;
+ float t = ly / CellSize;
+
+ if (splitDirection == AcDream.Core.Terrain.CellSplitDirection.SWtoNE)
+ {
+ if (s + t <= 1f)
+ {
+ return h0 * (1f - s - t) + h1 * s + h3 * t;
+ }
+ else
+ {
+ float u = s + t - 1f;
+ float v = 1f - s;
+ float w = 1f - u - v;
+ return h1 * w + h2 * u + h3 * v;
+ }
+ }
+ else // SEtoNW
+ {
+ if (s >= t)
+ {
+ return h0 * (1f - s) + h1 * (s - t) + h2 * t;
+ }
+ else
+ {
+ return h0 * (1f - t) + h2 * s + h3 * (t - s);
+ }
+ }
}
///
@@ -1234,13 +1472,11 @@ public sealed class GameWindow : IDisposable
(lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
if (lbInfo is not null)
{
+ // Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are
+ // static scenery placeholders themselves (rocks, tree clusters) that
+ // retail does NOT use to suppress scenery generation. Including them
+ // here over-suppressed scenery in town landblocks.
buildingCells = new HashSet();
- foreach (var stab in lbInfo.Objects)
- {
- int cx = Math.Clamp((int)(stab.Frame.Origin.X / 24f), 0, 8);
- int cy = Math.Clamp((int)(stab.Frame.Origin.Y / 24f), 0, 8);
- buildingCells.Add(cx * 9 + cy);
- }
foreach (var bldg in lbInfo.Buildings)
{
int cx = Math.Clamp((int)(bldg.Frame.Origin.X / 24f), 0, 8);
@@ -1258,11 +1494,14 @@ public sealed class GameWindow : IDisposable
(lbY - _liveCenterY) * 192f,
0f);
- // Per-landblock id namespace: 0x80000000 | (lbId & 0x00FFFF00) | local_index.
- // The landblock coord occupies bits 16-23 (X) and 8-15 (Y) — both fit in the
- // 0x00FFFF00 mask. Local index uses bits 0-7 (256 slots per landblock), which
- // is enough because SceneryGenerator caps at ~200 spawns per block in practice.
- uint sceneryIdBase = 0x80000000u | (lb.LandblockId & 0x00FFFF00u);
+ // Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF
+ // where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23).
+ // Both must go into our ID so landblocks don't collide.
+ // Format: 0x80 | XX | YY | local_index(8 bits) = 0x80XXYY_II.
+ // 256 slots per landblock is enough (SceneryGenerator caps ~200).
+ uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
+ uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
+ uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8);
uint localIndex = 0;
foreach (var spawn in spawns)
@@ -1305,18 +1544,33 @@ public sealed class GameWindow : IDisposable
if (meshRefs.Count == 0) continue;
- // Sample terrain Z at (localX, localY) to lift scenery onto the ground.
+ // Sample terrain Z at (localX, localY) to lift scenery onto the
+ // ground. Add BaseLoc.Z from the scenery ObjectDesc (passed in as
+ // spawn.LocalPosition.Z) so meshes that specify a vertical offset
+ // from the ground (e.g., flowers at -0.1m, roots below terrain)
+ // settle properly.
float localX = spawn.LocalPosition.X;
float localY = spawn.LocalPosition.Y;
- float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY);
+ // Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ)
+ // — it uses the same AC2D render split-direction formula the
+ // TerrainChunkRenderer uses for the visible terrain mesh. This
+ // guarantees trees are placed on the SAME Z height the player
+ // walks on. If physics hasn't registered this landblock yet,
+ // fall back to the local bilinear sample.
+ var worldPx = localX + lbOffset.X;
+ var worldPy = localY + lbOffset.Y;
+ float groundZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy)
+ ?? SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY);
+ float finalZ = groundZ + spawn.LocalPosition.Z;
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = sceneryIdBase + localIndex++,
SourceGfxObjOrSetupId = spawn.ObjectId,
- Position = new System.Numerics.Vector3(localX, localY, groundZ) + lbOffset,
+ Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset,
Rotation = spawn.Rotation,
MeshRefs = meshRefs,
+ Scale = spawn.Scale,
};
result.Add(hydrated);
}
@@ -1765,8 +2019,22 @@ public sealed class GameWindow : IDisposable
// 1. GfxObj: use the BSP root bounding sphere radius if available.
// 2. Setup: use Setup.Radius (the capsule radius) if available.
// 3. Fallback: 1.0m (conservative default for trees / small objects).
+ int lbBspCount = 0, lbCylCount = 0, lbNoneCount = 0;
+ int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0;
foreach (var entity in lb.Entities)
{
+ int entityBsp = 0, entityCyl = 0;
+ // Treat both procedural scenery (0x80000000+) AND LandBlockInfo
+ // stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities
+ // that should use visual-mesh-AABB collision. Stabs include landscape
+ // trees placed by Turbine (not procedural scenery) that otherwise
+ // have no collision shape registered.
+ uint _srcPrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u;
+ bool _isOutdoorMesh = ((entity.Id & 0x80000000u) != 0) // scenery
+ || ((entity.Id < 0x40000000u) // stab
+ && (_srcPrefix == 0x01000000u || _srcPrefix == 0x02000000u));
+ bool _isScenery = _isOutdoorMesh;
+ if (_isScenery) scTried++;
// Register EACH physics-enabled part so multi-part Setups
// (buildings, trees) have all their collision geometry registered.
// Each part gets its own ShadowEntry with its world-space transform.
@@ -1782,70 +2050,334 @@ public sealed class GameWindow : IDisposable
// Compute the part's world-space position from its transform.
var partWorld = meshRef.PartTransform * entityRoot;
- var partPos = new System.Numerics.Vector3(partWorld.M41, partWorld.M42, partWorld.M43);
- // Extract rotation from the world matrix.
+ // Decompose to extract scale (scenery objects have it baked
+ // into PartTransform), rotation, and translation.
+ System.Numerics.Vector3 partScale3;
System.Numerics.Quaternion partRot;
+ System.Numerics.Vector3 partPos;
if (System.Numerics.Matrix4x4.Decompose(partWorld,
- out _, out partRot, out _))
+ out partScale3, out partRot, out partPos))
{ /* decompose succeeded */ }
else
+ {
+ partScale3 = System.Numerics.Vector3.One;
partRot = entity.Rotation;
+ partPos = new System.Numerics.Vector3(partWorld.M41, partWorld.M42, partWorld.M43);
+ }
- float partRadius = partCached.BoundingSphere?.Radius ?? 1f;
+ // Use uniform scale (X component) — AC objects are uniformly scaled.
+ float partScale = partScale3.X;
+ if (partScale <= 0f) partScale = 1f;
+
+ // Local bounding sphere radius × world scale = world-space radius
+ // for the broad phase. The BSPQuery will also use `partScale` to
+ // transform player spheres into the unscaled BSP coordinate space.
+ float localRadius = partCached.BoundingSphere?.Radius ?? 1f;
+ float worldRadius = localRadius * partScale;
// Use a unique sub-ID per part: entity.Id * 256 + partIndex.
uint partId = entity.Id * 256u + partIndex;
_physicsEngine.ShadowObjects.Register(
partId, meshRef.GfxObjId,
- partPos, partRot, partRadius,
- origin.X, origin.Y, lb.LandblockId);
+ partPos, partRot, worldRadius,
+ origin.X, origin.Y, lb.LandblockId,
+ AcDream.Core.Physics.ShadowCollisionType.BSP, 0f,
+ partScale);
+ entityBsp++;
partIndex++;
}
- // ALWAYS register CylSphere when the Setup has one, even if BSP parts
- // were also registered. Retail tests both: CylSphere as the broad
- // collision volume (trunk) plus BSP parts for precise polygon collision.
- // The CylSphere catches the base/trunk that BSP parts might miss.
+ // Register collision shapes from the Setup (if this entity has one).
+ // Retail uses CylSpheres for trunks/pillars, Spheres for blob-shaped
+ // collision volumes. We register both as "cylinder" shadow entries
+ // because our collision system only has BSP and Cylinder types; a
+ // Sphere is handled as a short cylinder.
+ //
+ // SCALE + ROTATION handling:
+ // - Radius, Height, and the local Origin offset are ALL scaled by
+ // entity.Scale so they match the visually-scaled mesh.
+ // - The Origin offset is ROTATED by entity.Rotation so rotated
+ // scenery has its collision cylinder in the correct world spot.
+ //
+ // Keying:
+ // entity.Id → the primary CylSphere (if any)
+ // entity.Id + K*0x10000000u → additional CylSpheres/Spheres
+ // This ensures uniqueness per shape so ShadowObjectRegistry doesn't
+ // clobber entries via Deregister.
{
var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
- if (setup is not null && setup.CylSpheres.Count > 0)
+ if (setup is not null)
{
- var cyl = setup.CylSpheres[0];
- // Use the LARGER of CylSphere.Radius and Setup.Radius.
- // Setup.Radius is the overall bounding radius of the object.
- float cylRadius = MathF.Max(cyl.Radius, setup.Radius);
- if (cylRadius <= 0) cylRadius = 1f;
- float cylHeight = cyl.Height > 0 ? cyl.Height : setup.Height;
- if (cylHeight <= 0) cylHeight = cylRadius * 4f;
+ float entScale = entity.Scale > 0f ? entity.Scale : 1f;
+ uint shapeIndex = 0;
- if (cylRadius > 0)
+ // Register every CylSphere the Setup defines.
+ for (int ci = 0; ci < setup.CylSpheres.Count; ci++)
{
+ var cyl = setup.CylSpheres[ci];
+ float cylRadius = cyl.Radius * entScale;
+ float baseHeight = cyl.Height > 0 ? cyl.Height : cyl.Radius * 4f;
+ float cylHeight = baseHeight * entScale;
+ if (cylRadius <= 0f) continue;
+
+ // Rotate the local origin offset by entity rotation,
+ // then scale it before adding to entity.Position.
+ var localOffset = new System.Numerics.Vector3(
+ cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale;
+ var worldOffset = System.Numerics.Vector3.Transform(localOffset, entity.Rotation);
+
+ uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u;
_physicsEngine.ShadowObjects.Register(
- entity.Id, entity.SourceGfxObjOrSetupId,
- entity.Position + new System.Numerics.Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z),
+ shapeId, entity.SourceGfxObjOrSetupId,
+ entity.Position + worldOffset,
entity.Rotation, cylRadius,
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight);
+ entityCyl++;
}
- }
- else if (setup is not null && setup.Spheres.Count > 0 && partIndex == 0)
- {
- // Fallback: use bounding sphere only if no BSP and no CylSphere.
- var sph = setup.Spheres[0];
- if (sph.Radius > 0)
+
+ // Register every Sphere as a short cylinder when no
+ // CylSphere claimed the object.
+ if (setup.CylSpheres.Count == 0)
{
+ for (int si = 0; si < setup.Spheres.Count; si++)
+ {
+ var sph = setup.Spheres[si];
+ if (sph.Radius <= 0f) continue;
+
+ float sphRadius = sph.Radius * entScale;
+ float sphHeight = sphRadius * 2f;
+
+ // Rotate + scale the local origin, then offset the
+ // cylinder base down by the scaled radius so the
+ // short cylinder is centered on the sphere.
+ var localOffset = new System.Numerics.Vector3(
+ sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale;
+ var worldOffset = System.Numerics.Vector3.Transform(localOffset, entity.Rotation);
+ worldOffset.Z -= sphRadius;
+
+ uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u;
+ _physicsEngine.ShadowObjects.Register(
+ shapeId, entity.SourceGfxObjOrSetupId,
+ entity.Position + worldOffset,
+ entity.Rotation, sphRadius,
+ origin.X, origin.Y, lb.LandblockId,
+ AcDream.Core.Physics.ShadowCollisionType.Cylinder, sphHeight);
+ entityCyl++;
+ }
+ }
+
+ // Setup.Radius fallback: the Setup has NO CylSpheres and NO
+ // Spheres but has a positive Radius/Height. Use the overall
+ // bounding cylinder scaled by entity.Scale.
+ if (setup.CylSpheres.Count == 0 && setup.Spheres.Count == 0
+ && setup.Radius > 0f && entityBsp == 0)
+ {
+ float fr = setup.Radius * entScale;
+ float fh = (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale;
+ uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u;
_physicsEngine.ShadowObjects.Register(
- entity.Id, entity.SourceGfxObjOrSetupId,
- entity.Position + new System.Numerics.Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z),
- entity.Rotation, sph.Radius,
+ shapeId, entity.SourceGfxObjOrSetupId,
+ entity.Position, entity.Rotation, fr,
origin.X, origin.Y, lb.LandblockId,
- AcDream.Core.Physics.ShadowCollisionType.Cylinder, 0f);
+ AcDream.Core.Physics.ShadowCollisionType.Cylinder, fh);
+ entityCyl++;
}
}
}
+
+ // VISUAL mesh-bounds collision: for SCENERY entities (IDs with
+ // 0x80000000 bit set, indicating procedurally-placed scenery),
+ // ALWAYS compute a cylinder from the world-space mesh AABB.
+ // This catches trees whose BSP is only on the canopy (player
+ // walks under) AND corrects CylSphere positioning issues caused
+ // by mesh files having vertices offset from the mesh origin.
+ //
+ // For stabs (low IDs) and live entities, keep the existing Setup
+ // CylSphere / BSP registrations — those are placed with precise
+ // frame data and don't have the scenery offset issue.
+ if ((_isOutdoorMesh || (entityBsp == 0 && entityCyl == 0)) && entity.MeshRefs.Count > 0)
+ {
+ float entScale = entity.Scale > 0f ? entity.Scale : 1f;
+ bool haveBounds = false;
+ var worldMin = new System.Numerics.Vector3(float.MaxValue);
+ var worldMax = new System.Numerics.Vector3(float.MinValue);
+
+ var entRoot =
+ System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) *
+ System.Numerics.Matrix4x4.CreateTranslation(entity.Position);
+
+ // First pass: compute overall vertical extent in world Z.
+ float overallMinZ = float.MaxValue;
+ float overallMaxZ = float.MinValue;
+ foreach (var mr in entity.MeshRefs)
+ {
+ var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId);
+ if (vb is null || vb.Radius <= 0f) continue;
+ var partWorld = mr.PartTransform * entRoot;
+ for (int bi = 0; bi < 8; bi++)
+ {
+ var corner = new System.Numerics.Vector3(
+ (bi & 1) != 0 ? vb.Max.X : vb.Min.X,
+ (bi & 2) != 0 ? vb.Max.Y : vb.Min.Y,
+ (bi & 4) != 0 ? vb.Max.Z : vb.Min.Z);
+ var w = System.Numerics.Vector3.Transform(corner, partWorld);
+ if (w.Z < overallMinZ) overallMinZ = w.Z;
+ if (w.Z > overallMaxZ) overallMaxZ = w.Z;
+ }
+ }
+
+ // Second pass: use TRUNK HEIGHT ONLY (bottom 25% of the mesh
+ // or first 2.5m, whichever is smaller) for horizontal radius.
+ // This gives us the trunk thickness — not the canopy spread.
+ // The Z extent still uses the full mesh (so tall trees have
+ // tall collision cylinders).
+ float trunkHeight = MathF.Min(2.5f, (overallMaxZ - overallMinZ) * 0.25f);
+ if (trunkHeight < 0.5f) trunkHeight = 0.5f;
+ float trunkTopZ = overallMinZ + trunkHeight;
+
+ foreach (var mr in entity.MeshRefs)
+ {
+ var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId);
+ if (vb is null || vb.Radius <= 0f) continue;
+
+ var partWorld = mr.PartTransform * entRoot;
+ // Only accumulate horizontal extents from corners within the
+ // trunk height range. Pass the full vertical extent through.
+ for (int bi = 0; bi < 8; bi++)
+ {
+ var corner = new System.Numerics.Vector3(
+ (bi & 1) != 0 ? vb.Max.X : vb.Min.X,
+ (bi & 2) != 0 ? vb.Max.Y : vb.Min.Y,
+ (bi & 4) != 0 ? vb.Max.Z : vb.Min.Z);
+ var w = System.Numerics.Vector3.Transform(corner, partWorld);
+
+ // Always track vertical extent
+ if (w.Z < worldMin.Z) worldMin.Z = w.Z;
+ if (w.Z > worldMax.Z) worldMax.Z = w.Z;
+
+ // Only track horizontal extent for TRUNK-level vertices
+ if (w.Z <= trunkTopZ)
+ {
+ if (w.X < worldMin.X) worldMin.X = w.X;
+ if (w.Y < worldMin.Y) worldMin.Y = w.Y;
+ if (w.X > worldMax.X) worldMax.X = w.X;
+ if (w.Y > worldMax.Y) worldMax.Y = w.Y;
+ haveBounds = true;
+ }
+ }
+ }
+
+ if (haveBounds)
+ {
+ if (_isScenery) scHaveBounds++;
+
+ // RADIUS: prefer the Setup's CylSphere radius (the retail
+ // trunk radius — thin, matches tree trunks). Fall back to
+ // Setup.Radius or mesh AABB if CylSphere is unavailable.
+ // Always scale by entity.Scale.
+ float entScaleLocal = entity.Scale > 0f ? entity.Scale : 1f;
+ float cylRadius = -1f;
+ float cylHeight;
+
+ var setupInfo = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
+ if (setupInfo is not null)
+ {
+ if (setupInfo.CylSpheres.Count > 0 && setupInfo.CylSpheres[0].Radius > 0f)
+ {
+ // Retail CylSphere — the definitive trunk collision.
+ cylRadius = setupInfo.CylSpheres[0].Radius * entScaleLocal;
+ }
+ else if (setupInfo.Radius > 0f)
+ {
+ // Setup.Radius — the overall bounding radius. For
+ // thin trunks this might be the full tree radius
+ // (canopy included) but often it's the trunk.
+ cylRadius = setupInfo.Radius * entScaleLocal;
+ }
+ }
+
+ // Fall back to mesh AABB trunk-level radius if no Setup data.
+ if (cylRadius < 0f)
+ {
+ float halfX = (worldMax.X - worldMin.X) * 0.5f;
+ float halfY = (worldMax.Y - worldMin.Y) * 0.5f;
+ cylRadius = MathF.Max(halfX, halfY);
+ }
+
+ // Clamp: retail AC trunks are 0.3-1.0m. Bigger radii (from
+ // the AABB fallback for canopy-heavy meshes) are clearly
+ // wrong; clamp to a reasonable tree-trunk maximum.
+ if (cylRadius < 0.3f) cylRadius = 0.3f;
+ if (cylRadius > 1.5f) cylRadius = 1.5f;
+
+ // HEIGHT: use Setup.Height scaled, or mesh AABB vertical extent.
+ if (setupInfo is not null && setupInfo.Height > 0f)
+ cylHeight = setupInfo.Height * entScaleLocal;
+ else
+ cylHeight = MathF.Max(worldMax.Z - entity.Position.Z, cylRadius);
+
+ // CENTER: entity.Position (the rendered root).
+ var baseCenter = new System.Numerics.Vector3(
+ entity.Position.X, entity.Position.Y, entity.Position.Z);
+
+ _physicsEngine.ShadowObjects.Register(
+ entity.Id,
+ entity.SourceGfxObjOrSetupId,
+ baseCenter, entity.Rotation, cylRadius,
+ origin.X, origin.Y, lb.LandblockId,
+ AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight);
+ entityCyl++;
+ if (_isScenery) scRegistered++;
+ }
+ else if (_isScenery) scNoBounds++;
+ }
+
+ // Tally per-entity collision presence (debug counter — optional).
+ if (entityBsp > 0) lbBspCount++;
+ if (entityCyl > 0) lbCylCount++;
+ if (entityBsp == 0 && entityCyl == 0)
+ {
+ // Only count as "none" if it's an OUTDOOR entity (0x01/0x02 source).
+ // EnvCell entities (src = cell ID like 0xAABBxxxx) use BSP collision
+ // via CellPhysics and don't need cylinder registration.
+ uint srcPrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u;
+ if (srcPrefix == 0x01000000u || srcPrefix == 0x02000000u)
+ lbNoneCount++;
+ }
}
+ if (scTried > 0)
+ Console.WriteLine(
+ $"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} registered={scRegistered} " +
+ $"noBounds={scNoBounds} tooThin={scTooThin} (outdoorNone={lbNoneCount})");
+
+ // Find scenery WITHOUT any cached visual bounds at all
+ int sceneryNoCache = 0;
+ var sampleMissing = new List();
+ foreach (var entity in lb.Entities)
+ {
+ if ((entity.Id & 0x80000000u) == 0) continue; // not scenery
+ bool anyHaveBounds = false;
+ foreach (var mr in entity.MeshRefs)
+ {
+ var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId);
+ if (vb is not null && vb.Radius > 0f) { anyHaveBounds = true; break; }
+ }
+ if (!anyHaveBounds)
+ {
+ sceneryNoCache++;
+ if (sampleMissing.Count < 3)
+ sampleMissing.Add(entity.SourceGfxObjOrSetupId);
+ }
+ }
+ if (sceneryNoCache > 0)
+ {
+ string samples = string.Join(",", sampleMissing.Select(s => $"0x{s:X8}"));
+ Console.WriteLine($" → {sceneryNoCache} scenery entities had no visual bounds cached. Samples: {samples}");
+ }
+
// Register each stab as a plugin snapshot so the plugin host has
// visibility into the streaming world state.
@@ -2145,6 +2677,81 @@ public sealed class GameWindow : IDisposable
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds);
+ // Debug: draw collision shapes as wireframe cylinders around the
+ // player so we can visually verify alignment with scenery meshes.
+ if (_debugCollisionVisible && _debugLines is not null)
+ {
+ _debugLines.Begin();
+
+ // Pick the center for the debug radius. Prefer player
+ // position in player mode, otherwise use camPos.
+ System.Numerics.Vector3 center;
+ if (_playerMode && _playerController is not null)
+ center = _playerController.Position;
+ else
+ center = camPos;
+
+ // Draw ALL registered shadow objects regardless of distance —
+ // if it has collision, it gets a wireframe. This lets the user
+ // see exactly what's in the collision registry at any moment.
+ int drawn = 0;
+ foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug())
+ {
+ var dx = obj.Position.X - center.X;
+ var dy = obj.Position.Y - center.Y;
+
+ if (obj.CollisionType == AcDream.Core.Physics.ShadowCollisionType.Cylinder)
+ {
+ float h = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 2f;
+ _debugLines.AddCylinder(
+ obj.Position, obj.Radius, h,
+ new System.Numerics.Vector3(0f, 1f, 0f)); // green cylinders
+ }
+ else
+ {
+ // BSP: show a bounding sphere as a cylinder for visibility
+ _debugLines.AddCylinder(
+ obj.Position - new System.Numerics.Vector3(0, 0, obj.Radius),
+ obj.Radius, obj.Radius * 2f,
+ new System.Numerics.Vector3(1f, 0.5f, 0f)); // orange BSP
+ }
+ drawn++;
+ }
+
+ // Draw the player's collision sphere as a red cylinder (0.48m radius, 1.8m tall)
+ if (_playerMode && _playerController is not null)
+ {
+ var pp = _playerController.Position;
+ _debugLines.AddCylinder(
+ new System.Numerics.Vector3(pp.X, pp.Y, pp.Z - 0.0f),
+ 0.48f, 1.8f,
+ new System.Numerics.Vector3(1f, 0f, 0f)); // red player
+ }
+
+ if (_debugDrawLogOnce < 5 && _playerMode && _playerController is not null)
+ {
+ var pp = _playerController.Position;
+ Console.WriteLine(
+ $"debug frame {_debugDrawLogOnce}: player=({pp.X:F1},{pp.Y:F1},{pp.Z:F1}) drew={drawn} " +
+ $"totalReg={_physicsEngine.ShadowObjects.TotalRegistered}");
+ // Sample 3 nearest shadow objects
+ int logged = 0;
+ foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug())
+ {
+ var dx = o.Position.X - pp.X;
+ var dy = o.Position.Y - pp.Y;
+ float dh = MathF.Sqrt(dx * dx + dy * dy);
+ if (dh < 10f)
+ {
+ Console.WriteLine($" near id=0x{o.EntityId:X8} type={o.CollisionType} pos=({o.Position.X:F1},{o.Position.Y:F1},{o.Position.Z:F1}) r={o.Radius:F2} h={o.CylHeight:F2} dh={dh:F2}");
+ if (++logged >= 5) break;
+ }
+ }
+ _debugDrawLogOnce++;
+ }
+ _debugLines.Flush(camera.View, camera.Projection);
+ }
+
// Count visible vs total for the perf overlay.
foreach (var entry in _worldState.LandblockEntries)
{
@@ -2152,6 +2759,96 @@ public sealed class GameWindow : IDisposable
if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax))
visibleLandblocks++;
}
+
+ // ── Debug HUD overlay ────────────────────────────────────────────
+ // Build a per-frame snapshot of state we want to show and hand it
+ // to the overlay. Drawn after all 3D passes so it sits on top.
+ if (_debugOverlay is not null && _textRenderer is not null && _debugFont is not null)
+ {
+ System.Numerics.Vector3 playerPos;
+ float headingDeg;
+ uint cellId;
+ bool onGround;
+ float vVel;
+ if (_playerMode && _playerController is not null)
+ {
+ playerPos = _playerController.Position;
+ // Yaw in math convention: 0 = +X east, PI/2 = +Y north.
+ // Convert to degrees in [0, 360).
+ headingDeg = _playerController.Yaw * (180f / MathF.PI);
+ headingDeg %= 360f;
+ if (headingDeg < 0f) headingDeg += 360f;
+ cellId = _playerController.CellId;
+ onGround = !_playerController.IsAirborne;
+ vVel = _playerController.VerticalVelocity;
+ }
+ else
+ {
+ playerPos = camPos;
+ var camFwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33);
+ headingDeg = MathF.Atan2(camFwd.Y, camFwd.X) * (180f / MathF.PI);
+ if (headingDeg < 0f) headingDeg += 360f;
+ cellId = 0u;
+ onGround = false;
+ vVel = 0f;
+ }
+
+ // Nearest shadow object — surface-to-surface distance in XY
+ // (subtract player radius + obj radius). Negative == penetrating.
+ const float playerRadius = 0.48f;
+ float bestDist = float.PositiveInfinity;
+ string bestLabel = "-";
+ foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug())
+ {
+ float dx = obj.Position.X - playerPos.X;
+ float dy = obj.Position.Y - playerPos.Y;
+ float d = MathF.Sqrt(dx * dx + dy * dy) - obj.Radius - playerRadius;
+ if (d < bestDist)
+ {
+ bestDist = d;
+ bestLabel = $"0x{obj.EntityId:X8} {obj.CollisionType}";
+ }
+ }
+ bool colliding = bestDist < 0.05f;
+ if (bestDist < 0f) bestDist = 0f;
+
+ // Select the active-mode sensitivity to display.
+ float activeSens;
+ if (_playerMode && _cameraController?.IsChaseMode == true)
+ activeSens = _sensChase;
+ else if (_cameraController?.IsFlyMode == true)
+ activeSens = _sensFly;
+ else
+ activeSens = _sensOrbit;
+
+ var snapshot = new DebugOverlay.Snapshot(
+ Fps: (float)_lastFps,
+ FrameTimeMs: (float)_lastFrameMs,
+ PlayerPos: playerPos,
+ HeadingDeg: headingDeg,
+ CellId: cellId,
+ OnGround: onGround,
+ InPlayerMode: _playerMode,
+ InFlyMode: _cameraController?.IsFlyMode ?? false,
+ VerticalVelocity: vVel,
+ EntityCount: _worldState.Entities.Count,
+ AnimatedCount: _animatedEntities.Count,
+ LandblocksVisible: visibleLandblocks,
+ LandblocksTotal: totalLandblocks,
+ ShadowObjectCount: _physicsEngine.ShadowObjects.TotalRegistered,
+ NearestObjDist: bestDist,
+ NearestObjLabel: bestLabel,
+ Colliding: colliding,
+ DebugWireframes: _debugCollisionVisible,
+ StreamingRadius: _streamingRadius,
+ MouseSensitivity: activeSens,
+ ChaseDistance: _chaseCamera?.Distance ?? 0f,
+ RmbOrbit: _rmbHeld);
+
+ _debugOverlay.Update((float)deltaSeconds);
+ var size = new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y);
+ _debugOverlay.Draw(snapshot, size);
+ }
}
// Update the window title with performance stats every ~0.5s.
@@ -2167,6 +2864,8 @@ public sealed class GameWindow : IDisposable
_window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " +
$"lb {visibleLandblocks}/{totalLandblocks} visible | " +
$"ent {entityCount} | anim {animatedCount}";
+ _lastFps = fps;
+ _lastFrameMs = avgFrameTime;
_perfAccum = 0;
_perfFrameCount = 0;
}
@@ -2387,6 +3086,9 @@ public sealed class GameWindow : IDisposable
_meshShader?.Dispose();
_terrain?.Dispose();
_shader?.Dispose();
+ _debugLines?.Dispose();
+ _textRenderer?.Dispose();
+ _debugFont?.Dispose();
_dats?.Dispose();
_input?.Dispose();
_gl?.Dispose();
diff --git a/src/AcDream.App/Rendering/Shader.cs b/src/AcDream.App/Rendering/Shader.cs
index e2deded..bcad4f0 100644
--- a/src/AcDream.App/Rendering/Shader.cs
+++ b/src/AcDream.App/Rendering/Shader.cs
@@ -64,5 +64,17 @@ public sealed class Shader : IDisposable
_gl.Uniform3(loc, v.X, v.Y, v.Z);
}
+ public void SetVec2(string name, Vector2 v)
+ {
+ int loc = _gl.GetUniformLocation(Program, name);
+ _gl.Uniform2(loc, v.X, v.Y);
+ }
+
+ public void SetVec4(string name, Vector4 v)
+ {
+ int loc = _gl.GetUniformLocation(Program, name);
+ _gl.Uniform4(loc, v.X, v.Y, v.Z, v.W);
+ }
+
public void Dispose() => _gl.DeleteProgram(Program);
}
diff --git a/src/AcDream.App/Rendering/Shaders/debug_line.frag b/src/AcDream.App/Rendering/Shaders/debug_line.frag
new file mode 100644
index 0000000..1667ee6
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/debug_line.frag
@@ -0,0 +1,7 @@
+#version 430 core
+in vec3 vColor;
+out vec4 FragColor;
+
+void main() {
+ FragColor = vec4(vColor, 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/debug_line.vert b/src/AcDream.App/Rendering/Shaders/debug_line.vert
new file mode 100644
index 0000000..f634013
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/debug_line.vert
@@ -0,0 +1,13 @@
+#version 430 core
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec3 aColor;
+
+uniform mat4 uView;
+uniform mat4 uProjection;
+
+out vec3 vColor;
+
+void main() {
+ vColor = aColor;
+ gl_Position = uProjection * uView * vec4(aPos, 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/ui_text.frag b/src/AcDream.App/Rendering/Shaders/ui_text.frag
new file mode 100644
index 0000000..7740ea1
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/ui_text.frag
@@ -0,0 +1,18 @@
+#version 430 core
+in vec2 vUv;
+in vec4 vColor;
+out vec4 FragColor;
+
+uniform sampler2D uTex;
+uniform int uUseTexture;
+
+void main() {
+ if (uUseTexture != 0) {
+ // Font atlas is a single-channel R8 texture; red = coverage alpha.
+ float coverage = texture(uTex, vUv).r;
+ FragColor = vec4(vColor.rgb, vColor.a * coverage);
+ } else {
+ FragColor = vColor;
+ }
+ if (FragColor.a < 0.005) discard;
+}
diff --git a/src/AcDream.App/Rendering/Shaders/ui_text.vert b/src/AcDream.App/Rendering/Shaders/ui_text.vert
new file mode 100644
index 0000000..0cc6c93
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/ui_text.vert
@@ -0,0 +1,19 @@
+#version 430 core
+layout(location = 0) in vec2 aPos; // screen pixels, origin top-left
+layout(location = 1) in vec2 aUv;
+layout(location = 2) in vec4 aColor;
+
+uniform vec2 uScreenSize;
+
+out vec2 vUv;
+out vec4 vColor;
+
+void main() {
+ // Convert pixel coords (origin top-left, +Y down) to NDC (origin center, +Y up).
+ vec2 ndc = vec2(
+ aPos.x / uScreenSize.x * 2.0 - 1.0,
+ 1.0 - aPos.y / uScreenSize.y * 2.0);
+ gl_Position = vec4(ndc, 0.0, 1.0);
+ vUv = aUv;
+ vColor = aColor;
+}
diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs
new file mode 100644
index 0000000..ad04da1
--- /dev/null
+++ b/src/AcDream.App/Rendering/TextRenderer.cs
@@ -0,0 +1,230 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Numerics;
+using System.Runtime.InteropServices;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering;
+
+///
+/// 2D batched quad renderer for text + solid rectangles. Coordinates are in
+/// screen pixels with origin top-left, +X right, +Y down. Call
+/// at the start of a HUD pass, queue geometry via
+/// / , then .
+///
+/// Uses two internal vertex buffers (text and rect) flushed in two draw calls
+/// to avoid a per-vertex "use texture" flag. Rects are drawn first so text
+/// sits on top of background panels.
+///
+public sealed unsafe class TextRenderer : IDisposable
+{
+ private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4)
+
+ private readonly GL _gl;
+ private readonly Shader _shader;
+ private readonly uint _vao;
+ private readonly uint _vbo;
+ private int _vboCapacityBytes;
+
+ private readonly List _textBuf = new(8192);
+ private readonly List _rectBuf = new(1024);
+ private int _textVerts;
+ private int _rectVerts;
+ private Vector2 _screenSize;
+
+ public TextRenderer(GL gl, string shaderDir)
+ {
+ _gl = gl;
+ _shader = new Shader(gl,
+ Path.Combine(shaderDir, "ui_text.vert"),
+ Path.Combine(shaderDir, "ui_text.frag"));
+
+ _vao = _gl.GenVertexArray();
+ _vbo = _gl.GenBuffer();
+
+ _gl.BindVertexArray(_vao);
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
+
+ uint stride = FloatsPerVertex * sizeof(float);
+ _gl.EnableVertexAttribArray(0);
+ _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
+ _gl.EnableVertexAttribArray(1);
+ _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
+ _gl.EnableVertexAttribArray(2);
+ _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float)));
+
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
+ _gl.BindVertexArray(0);
+ }
+
+ /// Begin a HUD pass. Call once per frame before any Draw* calls.
+ public void Begin(Vector2 screenSize)
+ {
+ _screenSize = screenSize;
+ _textBuf.Clear();
+ _rectBuf.Clear();
+ _textVerts = 0;
+ _rectVerts = 0;
+ }
+
+ /// Draw a filled rectangle in screen pixel space.
+ public void DrawRect(float x, float y, float w, float h, Vector4 color)
+ {
+ AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color);
+ _rectVerts += 6;
+ }
+
+ /// Draw a 1-pixel-thick outline rect.
+ public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
+ {
+ // top, bottom, left, right
+ DrawRect(x, y, w, thickness, color);
+ DrawRect(x, y + h - thickness, w, thickness, color);
+ DrawRect(x, y, thickness, h, color);
+ DrawRect(x + w - thickness, y, thickness, h, color);
+ }
+
+ ///
+ /// Draw a single line of text at (x,y) where (x,y) is the top-left of the
+ /// typographic block. Handles '\n' as a line break.
+ ///
+ public void DrawString(BitmapFont font, string text, float x, float y, Vector4 color)
+ {
+ float cursorX = x;
+ // The caller provides top-y; shift to baseline for glyph offset math.
+ float baseline = y + font.Ascent;
+
+ for (int i = 0; i < text.Length; i++)
+ {
+ char c = text[i];
+ if (c == '\n')
+ {
+ cursorX = x;
+ baseline += font.LineHeight;
+ continue;
+ }
+ if (!font.TryGetGlyph(c, out var g))
+ {
+ // Unknown glyph — skip its advance width if '?' exists.
+ if (font.TryGetGlyph('?', out var q))
+ cursorX += q.Advance;
+ continue;
+ }
+
+ float gx = cursorX + g.OffsetX;
+ float gy = baseline + g.OffsetY;
+ float gw = g.Width;
+ float gh = g.Height;
+
+ if (gw > 0 && gh > 0)
+ {
+ AppendQuad(_textBuf,
+ gx, gy, gw, gh,
+ g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
+ color);
+ _textVerts += 6;
+ }
+ cursorX += g.Advance;
+ }
+ }
+
+ private static void AppendQuad(List buf,
+ float x, float y, float w, float h,
+ float u0, float v0, float u1, float v1, Vector4 color)
+ {
+ // Two triangles (6 verts). CCW in pixel space is clockwise in NDC
+ // because the vertex shader flips Y, so OpenGL's default front-face
+ // is GL_CCW — we rely on cull-face being disabled during HUD pass.
+ // (x, y) ─ (x+w, y)
+ // │ │
+ // (x, y+h) ─ (x+w, y+h)
+ //
+ // Triangle 1: (x,y) (x+w,y+h) (x+w,y)
+ // Triangle 2: (x,y) (x,y+h) (x+w,y+h)
+ void V(float px, float py, float pu, float pv)
+ {
+ buf.Add(px); buf.Add(py);
+ buf.Add(pu); buf.Add(pv);
+ buf.Add(color.X); buf.Add(color.Y); buf.Add(color.Z); buf.Add(color.W);
+ }
+ V(x, y, u0, v0);
+ V(x + w, y + h, u1, v1);
+ V(x + w, y, u1, v0);
+ V(x, y, u0, v0);
+ V(x, y + h, u0, v1);
+ V(x + w, y + h, u1, v1);
+ }
+
+ /// Upload + draw accumulated rects + text. font may be null if only DrawRect was used.
+ public void Flush(BitmapFont? font)
+ {
+ if (_textVerts == 0 && _rectVerts == 0) return;
+
+ _shader.Use();
+ _shader.SetVec2("uScreenSize", _screenSize);
+
+ _gl.BindVertexArray(_vao);
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
+
+ // Save GL state.
+ bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
+ bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
+ bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
+ _gl.Disable(EnableCap.DepthTest);
+ _gl.Disable(EnableCap.CullFace);
+ _gl.Enable(EnableCap.Blend);
+ _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
+
+ // Untextured rects first — they form panel backgrounds.
+ if (_rectVerts > 0)
+ {
+ _shader.SetInt("uUseTexture", 0);
+ UploadBuffer(_rectBuf);
+ _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts);
+ }
+
+ // Textured text glyphs.
+ if (_textVerts > 0 && font is not null)
+ {
+ _shader.SetInt("uUseTexture", 1);
+ _gl.ActiveTexture(TextureUnit.Texture0);
+ _gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
+ _shader.SetInt("uTex", 0);
+ UploadBuffer(_textBuf);
+ _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts);
+ }
+
+ // Restore GL state.
+ if (!wasBlend) _gl.Disable(EnableCap.Blend);
+ if (wasCull) _gl.Enable(EnableCap.CullFace);
+ if (wasDepth) _gl.Enable(EnableCap.DepthTest);
+
+ _gl.BindVertexArray(0);
+ }
+
+ private void UploadBuffer(List buf)
+ {
+ int bytes = buf.Count * sizeof(float);
+ if (bytes == 0) return;
+
+ if (bytes > _vboCapacityBytes)
+ {
+ fixed (float* p = CollectionsMarshal.AsSpan(buf))
+ _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)bytes, p, BufferUsageARB.DynamicDraw);
+ _vboCapacityBytes = bytes;
+ }
+ else
+ {
+ fixed (float* p = CollectionsMarshal.AsSpan(buf))
+ _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)bytes, p);
+ }
+ }
+
+ public void Dispose()
+ {
+ _gl.DeleteBuffer(_vbo);
+ _gl.DeleteVertexArray(_vao);
+ _shader.Dispose();
+ }
+}
diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs
index fceddb6..dfef08b 100644
--- a/src/AcDream.Core/Physics/BSPQuery.cs
+++ b/src/AcDream.Core/Physics/BSPQuery.cs
@@ -1453,19 +1453,46 @@ public static class BSPQuery
}
// ----------------------------------------------------------------
- // Path 5: Contact — sphere_intersects_poly + step_sphere_up / slide
- // ACE transforms collision normal from local→global before step_up/slide
+ // Path 5: Contact — sphere_intersects_poly + wall-slide
+ // ACE retail uses StepSphereUp here, deferring to a retry loop that
+ // executes the step-up motion. We haven't ported that execution, so
+ // we apply the same wall-slide response as Path 6 — this at least
+ // gives correct blocking + sliding behavior for walls, buildings,
+ // and tree trunks while the player is on the ground.
// ----------------------------------------------------------------
if (obj.State.HasFlag(ObjectInfoState.Contact))
{
ResolvedPolygon? hitPoly0 = null;
Vector3 contact0 = Vector3.Zero;
- if (SphereIntersectsPolyInternal(root, resolved, sphere0, movement,
- ref hitPoly0, ref contact0))
+ bool hit0 = SphereIntersectsPolyInternal(root, resolved, sphere0, movement,
+ ref hitPoly0, ref contact0);
+
+ if (hit0 || hitPoly0 is not null)
{
- var worldNormal = L2W(hitPoly0!.Plane.Normal);
- return StepSphereUp(transition, worldNormal);
+ // Wall-slide response (same as Path 6 below).
+ var localNormal = hitPoly0!.Plane.Normal;
+ var localMovement = sphere0.Center - localCurrCenter;
+
+ float movementIntoWall = Vector3.Dot(localMovement, localNormal);
+ Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
+
+ Vector3 slidPos = localCurrCenter + projectedMovement;
+ float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D;
+ float minDist = sphere0.Radius + 0.01f;
+ if (slidDist < minDist)
+ {
+ slidPos += localNormal * (minDist - slidDist);
+ }
+
+ Vector3 localDelta = slidPos - sphere0.Center;
+ Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
+ path.AddOffsetToCheckPos(worldDelta);
+
+ var worldNormal = L2W(localNormal);
+ collisions.SetCollisionNormal(worldNormal);
+ collisions.SetSlidingNormal(worldNormal);
+ return TransitionState.Slid;
}
if (sphere1 is not null)
@@ -1473,17 +1500,34 @@ public static class BSPQuery
ResolvedPolygon? hitPoly1 = null;
Vector3 contact1 = Vector3.Zero;
- if (SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
- ref hitPoly1, ref contact1))
- {
- var worldNormal = L2W(hitPoly1!.Plane.Normal);
- return SlideSphere(transition, worldNormal);
- }
+ bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
+ ref hitPoly1, ref contact1);
- if (hitPoly1 is not null)
- return NegPolyHitDispatch(path, hitPoly1, false, localToWorld);
- if (hitPoly0 is not null)
- return NegPolyHitDispatch(path, hitPoly0, true, localToWorld);
+ if (hit1 || hitPoly1 is not null)
+ {
+ var localNormal = hitPoly1!.Plane.Normal;
+ var localMovement = sphere1.Center - localCurrCenter;
+
+ float movementIntoWall = Vector3.Dot(localMovement, localNormal);
+ Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
+
+ Vector3 slidPos = localCurrCenter + projectedMovement;
+ float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D;
+ float minDist = sphere1.Radius + 0.01f;
+ if (slidDist < minDist)
+ {
+ slidPos += localNormal * (minDist - slidDist);
+ }
+
+ Vector3 localDelta = slidPos - sphere1.Center;
+ Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
+ path.AddOffsetToCheckPos(worldDelta);
+
+ var worldNormal = L2W(localNormal);
+ collisions.SetCollisionNormal(worldNormal);
+ collisions.SetSlidingNormal(worldNormal);
+ return TransitionState.Slid;
+ }
}
return TransitionState.OK;
@@ -1509,11 +1553,50 @@ public static class BSPQuery
hitPoly0!, contact0, scale, localToWorld);
}
- var worldNormal = L2W(hitPoly0!.Plane.Normal);
+ // ─── Wall-slide response ─────────────────────────────────
+ // Instead of just pushing the sphere out of penetration
+ // (which undoes the whole step), compute the wall-slide
+ // position: where the sphere WOULD be if the movement had
+ // been projected along the wall tangent.
+ //
+ // In local space:
+ // curr = localCurrCenter
+ // target = sphere0.Center
+ // movement = target - curr
+ // normal = polygon plane normal (outward)
+ // projectedMovement = movement - (movement · normal) * normal
+ // slidPos = curr + projectedMovement
+ //
+ // Then ensure slidPos is outside the plane by at least radius+eps.
+ var localNormal = hitPoly0!.Plane.Normal;
+ var localMovement = sphere0.Center - localCurrCenter;
+
+ // Project movement along wall tangent
+ float movementIntoWall = Vector3.Dot(localMovement, localNormal);
+ Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
+
+ // Slid position in local space
+ Vector3 slidPos = localCurrCenter + projectedMovement;
+
+ // Ensure slid position is OUTSIDE the plane by radius + epsilon
+ float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D;
+ float minDist = sphere0.Radius + 0.01f;
+ if (slidDist < minDist)
+ {
+ slidPos += localNormal * (minDist - slidDist);
+ }
+
+ // Delta from current CheckPos sphere center to slid position (local)
+ Vector3 localDelta = slidPos - sphere0.Center;
+ // Transform to world and apply
+ Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
+ path.AddOffsetToCheckPos(worldDelta);
+
+ var worldNormal = L2W(localNormal);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
- path.Collide = true;
collisions.SetCollisionNormal(worldNormal);
- return TransitionState.Adjusted;
+ collisions.SetSlidingNormal(worldNormal);
+ return TransitionState.Slid;
}
if (sphere1 is not null)
@@ -1526,9 +1609,29 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null)
{
- var worldNormal = L2W(hitPoly1!.Plane.Normal);
+ // Head sphere hit: apply the same wall-slide as above.
+ var localNormal = hitPoly1!.Plane.Normal;
+ var localMovement = sphere1.Center - localCurrCenter;
+
+ float movementIntoWall = Vector3.Dot(localMovement, localNormal);
+ Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
+
+ Vector3 slidPos = localCurrCenter + projectedMovement;
+ float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D;
+ float minDist = sphere1.Radius + 0.01f;
+ if (slidDist < minDist)
+ {
+ slidPos += localNormal * (minDist - slidDist);
+ }
+
+ Vector3 localDelta = slidPos - sphere1.Center;
+ Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
+ path.AddOffsetToCheckPos(worldDelta);
+
+ var worldNormal = L2W(localNormal);
collisions.SetCollisionNormal(worldNormal);
- return TransitionState.Collided;
+ collisions.SetSlidingNormal(worldNormal);
+ return TransitionState.Slid;
}
}
}
diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs
index 5efeab6..225bf3f 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -16,18 +16,32 @@ namespace AcDream.Core.Physics;
public sealed class PhysicsDataCache
{
private readonly ConcurrentDictionary _gfxObj = new();
+ private readonly ConcurrentDictionary _visualBounds = new();
private readonly ConcurrentDictionary _setup = new();
private readonly ConcurrentDictionary _cellStruct = new();
///
- /// Extract and cache the physics BSP + polygon data from a GfxObj.
- /// No-ops if the id is already cached or the GfxObj has no physics data.
+ /// Extract and cache the physics BSP + polygon data from a GfxObj,
+ /// PLUS always cache a visual AABB from the vertex data regardless of
+ /// the HasPhysics flag. The visual AABB is used as a collision fallback
+ /// for entities whose Setup has no retail physics data — it lets the
+ /// user collide with decorative meshes that don't have a CylSphere or
+ /// per-part BSP.
///
public void CacheGfxObj(uint gfxObjId, GfxObj gfxObj)
{
+ // Always cache a visual AABB from the mesh vertices — this is cheap
+ // and fed by the mesh data that's already loaded. It serves as the
+ // fallback collision shape for pure-visual entities.
+ if (!_visualBounds.ContainsKey(gfxObjId) && gfxObj.VertexArray != null)
+ {
+ _visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
+ }
+
if (_gfxObj.ContainsKey(gfxObjId)) return;
if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return;
if (gfxObj.PhysicsBSP?.Root is null) return;
+ if (gfxObj.VertexArray is null) return;
_gfxObj[gfxObjId] = new GfxObjPhysics
{
@@ -39,6 +53,58 @@ public sealed class PhysicsDataCache
};
}
+ ///
+ /// Get the cached visual AABB for a GfxObj, or null if not cached.
+ ///
+ public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) =>
+ _visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null;
+
+ ///
+ /// Compute a tight axis-aligned bounding box over all vertices in the mesh.
+ /// Used as a fallback collision shape for entities whose Setup has no
+ /// physics data — we approximate collision using the visual extent.
+ ///
+ private static GfxObjVisualBounds ComputeVisualBounds(VertexArray vertexArray)
+ {
+ if (vertexArray.Vertices == null || vertexArray.Vertices.Count == 0)
+ {
+ return new GfxObjVisualBounds
+ {
+ Min = Vector3.Zero,
+ Max = Vector3.Zero,
+ Center = Vector3.Zero,
+ Radius = 0f,
+ HalfExtents = Vector3.Zero,
+ };
+ }
+
+ var min = new Vector3(float.MaxValue);
+ var max = new Vector3(float.MinValue);
+ foreach (var kv in vertexArray.Vertices)
+ {
+ var p = kv.Value.Origin;
+ if (p.X < min.X) min.X = p.X;
+ if (p.Y < min.Y) min.Y = p.Y;
+ if (p.Z < min.Z) min.Z = p.Z;
+ if (p.X > max.X) max.X = p.X;
+ if (p.Y > max.Y) max.Y = p.Y;
+ if (p.Z > max.Z) max.Z = p.Z;
+ }
+
+ var center = (min + max) * 0.5f;
+ var halfExt = (max - min) * 0.5f;
+ float radius = halfExt.Length();
+
+ return new GfxObjVisualBounds
+ {
+ Min = min,
+ Max = max,
+ Center = center,
+ Radius = radius,
+ HalfExtents = halfExt,
+ };
+ }
+
///
/// Extract and cache the collision shape data from a Setup.
/// No-ops if the id is already cached.
@@ -145,6 +211,26 @@ public sealed class PhysicsDataCache
public int CellStructCount => _cellStruct.Count;
}
+///
+/// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless
+/// of whether it has physics data. Used as a collision fallback shape for
+/// entities whose Setup has no CylSpheres/Spheres/Radius (pure decorative
+/// meshes). Provides an approximate cylinder matching the visible mesh extent.
+///
+public sealed class GfxObjVisualBounds
+{
+ /// Local-space minimum corner of the mesh AABB.
+ public required Vector3 Min { get; init; }
+ /// Local-space maximum corner of the mesh AABB.
+ public required Vector3 Max { get; init; }
+ /// Center of the local-space AABB.
+ public required Vector3 Center { get; init; }
+ /// Local-space radius (diagonal half-length) — loose bound.
+ public required float Radius { get; init; }
+ /// Local-space half-extents ((Max - Min) * 0.5).
+ public required Vector3 HalfExtents { get; init; }
+}
+
///
/// A physics polygon with pre-resolved vertex positions and pre-computed plane.
/// ACE pre-computes these in its Polygon constructor; we do it at cache time
diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index b9fb084..e7c2950 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -23,10 +23,13 @@ public sealed class ShadowObjectRegistry
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
float radius, float worldOffsetX, float worldOffsetY, uint landblockId,
ShadowCollisionType collisionType = ShadowCollisionType.BSP,
- float cylHeight = 0f)
+ float cylHeight = 0f, float scale = 1.0f)
{
Deregister(entityId);
+ // The radius parameter should already be the WORLD-SPACE bounding
+ // radius (i.e., already multiplied by scale) so the broad-phase cell
+ // occupancy is correct. Callers are responsible for that.
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
@@ -35,7 +38,7 @@ public sealed class ShadowObjectRegistry
int minCy = Math.Max(0, (int)((localY - radius) / 24f));
int maxCy = Math.Min(7, (int)((localY + radius) / 24f));
- var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius, collisionType, cylHeight);
+ var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius, collisionType, cylHeight, scale);
var cellIds = new List();
uint lbPrefix = landblockId & 0xFFFF0000u;
@@ -166,6 +169,24 @@ public sealed class ShadowObjectRegistry
}
public int TotalRegistered => _entityToCells.Count;
+
+ ///
+ /// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
+ /// For each entity, returns the first entry found in any cell it occupies.
+ /// Intended for debug rendering only.
+ ///
+ public IEnumerable AllEntriesForDebug()
+ {
+ var seen = new HashSet();
+ foreach (var kvp in _cells)
+ {
+ foreach (var entry in kvp.Value)
+ {
+ if (seen.Add(entry.EntityId))
+ yield return entry;
+ }
+ }
+ }
}
///
@@ -181,4 +202,5 @@ public readonly record struct ShadowEntry(
Quaternion Rotation,
float Radius,
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
- float CylHeight = 0f);
+ float CylHeight = 0f,
+ float Scale = 1.0f);
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 2b30b34..d4ac066 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -353,15 +353,13 @@ public sealed class Transition
for (int i = 0; i < numSteps; i++)
{
- // Reset per-step collision state.
- CollisionInfo.SlidingNormalValid = false;
- CollisionInfo.ContactPlaneValid = false;
- CollisionInfo.ContactPlaneIsWater = false;
-
- // Project the step offset through any existing contact / slide plane.
+ // Per ACE order: AdjustOffset FIRST (uses state from previous step),
+ // THEN clear the state. This lets the sliding/contact normals from
+ // the previous step's collision project the current step's offset.
sp.GlobalOffset = AdjustOffset(offsetPerStep);
- // Abort if adjusted offset is negligible (we're stuck against a wall).
+ // Abort if adjusted offset is negligible (stuck against a wall
+ // with no slide tangent available).
if (sp.GlobalOffset.LengthSquared() < PhysicsGlobals.EpsilonSq)
return i != 0 && transitionState == TransitionState.OK;
@@ -372,6 +370,12 @@ public sealed class Transition
sp.CheckOrientation = Quaternion.Slerp(sp.BeginOrientation, sp.EndOrientation, delta);
}
+ // Clear collision state AFTER AdjustOffset reads it. TransitionalInsert
+ // will set new state that the next step's AdjustOffset will consume.
+ CollisionInfo.SlidingNormalValid = false;
+ CollisionInfo.ContactPlaneValid = false;
+ CollisionInfo.ContactPlaneIsWater = false;
+
// Apply the offset, then check collisions.
sp.AddOffsetToCheckPos(sp.GlobalOffset);
@@ -391,108 +395,155 @@ public sealed class Transition
// -----------------------------------------------------------------------
///
- /// Check collisions at the current CheckPos, apply step-down as needed.
- /// Ported from pseudocode section 3 (TransitionalInsert).
- /// ACE: Transition.TransitionalInsert(int num_insertion_attempts).
+ /// ACE Transition.TransitionalInsert — retry loop for collision resolution.
+ ///
+ ///
+ /// Per ACE: iterate up to numAttempts times. Each iteration runs the full
+ /// collision pipeline (env + objects) at the current CheckPos. The pipeline
+ /// can MUTATE CheckPos (push-out, slide). On Slid/Adjusted, clear state and
+ /// retry — the next iteration tests the NEW CheckPos against all nearby
+ /// objects again, which catches "slide into a second wall" corner cases.
+ ///
+ ///
+ ///
+ /// Return values:
+ /// - OK: no collision OR all collisions resolved without leaving anything unhandled
+ /// - Collided: hard stop; no further movement possible
+ /// - Slid: last iteration slid (only if we exhausted retry attempts)
+ /// - Adjusted: last iteration adjusted (rare — retry should convert to OK)
+ ///
+ ///
+ ///
+ /// This is simplified from ACE: we don't have CellArray/CheckOtherCells
+ /// iteration because our FindObjCollisions (via ShadowObjectRegistry) is
+ /// already a flat per-landblock query. That's the equivalent of iterating
+ /// objects across all relevant cells.
+ ///
///
- private TransitionState TransitionalInsert(int maxAttempts, PhysicsEngine engine)
+ private TransitionState TransitionalInsert(int numAttempts, PhysicsEngine engine)
{
if (SpherePath.CheckCellId == 0) return TransitionState.OK;
- if (maxAttempts <= 0) return TransitionState.Invalid;
+ if (numAttempts <= 0) return TransitionState.Invalid;
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
- TransitionState transitState = TransitionState.OK;
+ TransitionState transitState;
- for (int attempt = 0; attempt < maxAttempts; attempt++)
+ for (int attempt = 0; attempt < numAttempts; attempt++)
{
- // Phase 1: check collisions in the current cell.
+ // ── Phase 1: environment collision (terrain + indoor BSP) ───
transitState = FindEnvCollisions(engine);
- switch (transitState)
+ if (transitState == TransitionState.Collided)
+ return TransitionState.Collided;
+
+ if (transitState == TransitionState.Slid)
{
- case TransitionState.OK:
- // Outdoor path: no neighboring cell enumeration needed for MVP.
- break;
-
- case TransitionState.Collided:
- return TransitionState.Collided;
-
- case TransitionState.Adjusted:
- sp.NegPolyHit = false;
- break;
-
- case TransitionState.Slid:
- ci.ContactPlaneValid = false;
- ci.ContactPlaneIsWater = false;
- sp.NegPolyHit = false;
- break;
+ // Env collision slid the sphere. Clear state and retry at
+ // the new CheckPos to see if we hit anything else.
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ sp.NegPolyHit = false;
+ continue;
}
- // Phase 1b: check object (static BSP) collisions when OK so far.
- if (transitState == TransitionState.OK)
+ if (transitState == TransitionState.Adjusted)
{
- var objState = FindObjCollisions(engine);
- if (objState == TransitionState.Slid)
- {
- transitState = TransitionState.Slid;
- ci.ContactPlaneValid = false;
- ci.ContactPlaneIsWater = false;
- sp.NegPolyHit = false;
- }
- else if (objState == TransitionState.Collided)
- {
- return TransitionState.Collided;
- }
+ // Env modified CheckPos. Retry at new position.
+ sp.NegPolyHit = false;
+ continue;
}
- // Phase 2: post-collision response.
- if (transitState == TransitionState.OK)
+ // ── Phase 2: object (static BSP + cylinder) collision ───────
+ // Env was OK — now test objects.
+ var objState = FindObjCollisions(engine);
+
+ if (objState == TransitionState.Collided)
+ return TransitionState.Collided;
+
+ if (objState == TransitionState.Slid)
{
- // Handle step-down when in contact but no ground plane found.
- if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
- && sp.CheckCellId != 0 && oi.StepDown)
+ // Object collision applied a push-out and set sliding normal.
+ // Retry at the new CheckPos — we may have slid into another
+ // object, or need to re-verify env at the new position.
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ sp.NegPolyHit = false;
+ continue;
+ }
+
+ if (objState == TransitionState.Adjusted)
+ {
+ // Object modified CheckPos (e.g. PerfectClip adjust_to_plane).
+ // Retry at the new position.
+ sp.NegPolyHit = false;
+ continue;
+ }
+
+ // ── Phase 3: both env and objects returned OK ──────────────
+ // Handle Collide flag (BSP path 6 set it on a non-contact hit).
+ // ACE: if Collide is set, re-test as Placement to confirm position.
+ // Simplified: just clear it and accept.
+ if (sp.Collide)
+ {
+ sp.Collide = false;
+ }
+
+ // Handle neg-poly hit (backward-facing polygon contact).
+ if (sp.NegPolyHit && !sp.StepDown && !sp.StepUp)
+ {
+ sp.NegPolyHit = false;
+ // ACE: dispatch to StepUp or SlideSphere based on NegStepUp flag.
+ // Simplified: accept current position.
+ }
+
+ // Handle step-down when in contact but no ground plane found.
+ // This happens when the player is on a slope edge: they're marked
+ // as in contact with the ground, but the current CheckPos has no
+ // terrain contact (walked off an edge). Attempt a step-down to
+ // maintain ground contact.
+ if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
+ && sp.CheckCellId != 0 && oi.StepDown)
+ {
+ float zVal = PhysicsGlobals.LandingZ;
+ float stepDownHeight = oi.StepDownHeight;
+ sp.WalkableAllowance = zVal;
+ sp.SaveCheckPos();
+
+ float radsum = sp.GlobalSphere[0].Radius * 2f;
+
+ if (radsum >= stepDownHeight)
{
- float zVal = PhysicsGlobals.LandingZ;
- float stepDownHeight = oi.StepDownHeight;
- sp.WalkableAllowance = zVal;
- sp.SaveCheckPos();
-
- float radsum = sp.GlobalSphere[0].Radius * 2f;
-
- if (radsum >= stepDownHeight)
+ if (DoStepDown(stepDownHeight, zVal, engine))
{
- if (DoStepDown(stepDownHeight, zVal, engine))
- {
- sp.WalkableValid = false;
- return TransitionState.OK;
- }
+ sp.WalkableValid = false;
+ return TransitionState.OK;
}
- else
- {
- stepDownHeight *= 0.5f;
- if (DoStepDown(stepDownHeight, zVal, engine)
- || DoStepDown(stepDownHeight, zVal, engine))
- {
- sp.WalkableValid = false;
- return TransitionState.OK;
- }
- }
-
- // Step-down failed: stay at current position.
- sp.RestoreCheckPos();
- return TransitionState.OK;
}
else
{
- return TransitionState.OK;
+ stepDownHeight *= 0.5f;
+ if (DoStepDown(stepDownHeight, zVal, engine)
+ || DoStepDown(stepDownHeight, zVal, engine))
+ {
+ sp.WalkableValid = false;
+ return TransitionState.OK;
+ }
}
+
+ // Step-down failed: stay at current position.
+ sp.RestoreCheckPos();
+ return TransitionState.OK;
}
+
+ return TransitionState.OK;
}
- return transitState;
+ // Exhausted retry attempts — return whatever the last iteration said.
+ // (Defaults to Slid in practice since that's the only case that retries.)
+ return TransitionState.Slid;
}
// -----------------------------------------------------------------------
@@ -662,6 +713,7 @@ public sealed class Transition
// Reused per-call to avoid per-step allocation; safe because Transition
// is single-threaded per movement resolve.
private readonly List _nearbyObjs = new();
+ private static int _debugQueryCount = 0;
///
/// Query the ShadowObjectRegistry for nearby static objects and run
@@ -698,6 +750,16 @@ public sealed class Transition
worldOffsetX, worldOffsetY, landblockId,
_nearbyObjs);
+ // Log every 120 frames — tracks player position over time.
+ _debugQueryCount++;
+ if (movement.LengthSquared() > 0.0001f && _debugQueryCount % 120 == 0)
+ {
+ Console.WriteLine(
+ $"ObjColl @({currPos.X:F1},{currPos.Y:F1},{currPos.Z:F1}) " +
+ $"lb=0x{landblockId:X8} nearby={_nearbyObjs.Count}/{engine.ShadowObjects.TotalRegistered}");
+ }
+
+
foreach (var obj in _nearbyObjs)
{
// Broad-phase: can the moving sphere reach this object?
@@ -721,23 +783,28 @@ public sealed class Transition
if (physics?.BSP?.Root is null) continue;
// Transform player spheres to object-local space.
+ // For a scaled object (scenery tree, etc.), we need to
+ // divide the local position + radius by the object's scale
+ // so they are in the unscaled BSP coordinate system.
+ // ACE handles this via the `scale` parameter in find_collisions.
var invRot = Quaternion.Inverse(obj.Rotation);
+ float invScale = obj.Scale > 0 ? 1.0f / obj.Scale : 1.0f;
var localSphere0 = new DatReaderWriter.Types.Sphere
{
- Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot),
- Radius = sp.GlobalSphere[0].Radius,
+ Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot) * invScale,
+ Radius = sp.GlobalSphere[0].Radius * invScale,
};
var localCurrCenter = Vector3.Transform(
- sp.GlobalCurrCenter[0].Origin - obj.Position, invRot);
+ sp.GlobalCurrCenter[0].Origin - obj.Position, invRot) * invScale;
DatReaderWriter.Types.Sphere? localSphere1 = null;
if (sp.NumSphere > 1)
{
localSphere1 = new DatReaderWriter.Types.Sphere
{
- Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - obj.Position, invRot),
- Radius = sp.GlobalSphere[1].Radius,
+ Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - obj.Position, invRot) * invScale,
+ Radius = sp.GlobalSphere[1].Radius * invScale,
};
}
@@ -745,9 +812,8 @@ public sealed class Transition
var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot);
// Use the retail 6-path dispatcher with pre-resolved polygons.
- // Pass the object's rotation so collision responses (normals,
- // offsets) are transformed from object-local back to world space.
- // ACE: path.LocalSpacePos.LocalToGlobalVec()
+ // Pass the object's scale so collision response offsets (in
+ // unscaled local space) are multiplied back to world space.
result = BSPQuery.FindCollisions(
physics.BSP.Root,
physics.Resolved,
@@ -756,7 +822,7 @@ public sealed class Transition
localSphere1,
localCurrCenter,
localSpaceZ,
- 1.0f, // scale = 1.0 for object geometry
+ obj.Scale, // scale for local→world offsets
obj.Rotation); // local→world rotation
}
else
@@ -776,9 +842,9 @@ public sealed class Transition
}
///
- /// Cylinder swept-sphere collision test for CylSphere objects (trees, rocks, etc.).
- /// Performs a 2D ray-circle intersection to find contact time, then applies
- /// a wall-slide response.
+ /// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs).
+ /// Applies a horizontal wall-slide response when the sphere overlaps the
+ /// cylinder, matching the BSP path 5/6 response for consistent behavior.
///
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp)
{
@@ -788,51 +854,87 @@ public sealed class Transition
float sphRadius = sp.GlobalSphere[0].Radius;
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
- Vector3 deltaCurr = sphereCurrPos - obj.Position;
- float dx = deltaCurr.X, dy = deltaCurr.Y;
- float mx = sphMovement.X, my = sphMovement.Y;
- float combinedR = sphRadius + obj.Radius;
-
- float a = mx * mx + my * my;
- float b = 2f * (dx * mx + dy * my);
- float c = dx * dx + dy * dy - combinedR * combinedR;
-
- float t;
- if (a < PhysicsGlobals.EPSILON)
- {
- if (c > 0f) return TransitionState.OK;
- t = 0f;
- }
- else
- {
- float disc = b * b - 4f * a * c;
- if (disc < 0f) return TransitionState.OK;
- float sqrtDisc = MathF.Sqrt(disc);
- t = (-b - sqrtDisc) / (2f * a);
- if (t > 1f) return TransitionState.OK;
- if (t < 0f) t = 0f;
- }
-
- // Vertical check at contact time.
- Vector3 contactPos = sphereCurrPos + sphMovement * t;
+ // Vertical check: does sphere reach the cylinder's height range at all?
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
- float playerBottom = contactPos.Z - sphRadius;
- float playerTop = contactPos.Z + sphRadius;
- if (playerBottom > obj.Position.Z + cylTop || playerTop < obj.Position.Z)
+ float checkZ = sphereCheckPos.Z;
+ if (checkZ - sphRadius > obj.Position.Z + cylTop ||
+ checkZ + sphRadius < obj.Position.Z)
return TransitionState.OK;
- // Collision normal: radial from cylinder axis.
- Vector3 contactDelta = contactPos - obj.Position;
- float hDist = MathF.Sqrt(contactDelta.X * contactDelta.X + contactDelta.Y * contactDelta.Y);
- Vector3 collisionNormal;
- if (hDist < PhysicsGlobals.EPSILON)
- collisionNormal = Vector3.UnitX;
- else
- collisionNormal = Vector3.Normalize(new Vector3(contactDelta.X, contactDelta.Y, 0f));
+ // XY distance from sphere check position to cylinder axis.
+ float dxCheck = sphereCheckPos.X - obj.Position.X;
+ float dyCheck = sphereCheckPos.Y - obj.Position.Y;
+ float distSqCheck = dxCheck * dxCheck + dyCheck * dyCheck;
+ float combinedR = sphRadius + obj.Radius;
+ float combinedRSq = combinedR * combinedR;
+
+ if (distSqCheck >= combinedRSq)
+ return TransitionState.OK; // not overlapping at check position
+
+ // ─── Overlap detected: apply wall-slide ─────────────────────
+ // Horizontal outward normal from the cylinder axis to the sphere
+ // check position. For the degenerate case where the sphere center
+ // is exactly on the axis, use the movement direction as a fallback
+ // (pushes the sphere back out along the way it came in).
+ float distCheck = MathF.Sqrt(distSqCheck);
+ Vector3 collisionNormal;
+ if (distCheck < PhysicsGlobals.EPSILON)
+ {
+ // Sphere center on cylinder axis — push along reverse movement.
+ float mxy = MathF.Sqrt(sphMovement.X * sphMovement.X + sphMovement.Y * sphMovement.Y);
+ if (mxy > PhysicsGlobals.EPSILON)
+ collisionNormal = new Vector3(-sphMovement.X / mxy, -sphMovement.Y / mxy, 0f);
+ else
+ collisionNormal = Vector3.UnitX;
+ }
+ else
+ {
+ collisionNormal = new Vector3(dxCheck / distCheck, dyCheck / distCheck, 0f);
+ }
+
+ // Wall-slide position (in world space):
+ // curr = sphereCurrPos (pre-step)
+ // movement = sphMovement
+ // projected = movement - (movement · normal) * normal
+ // slidPos = curr + projected
+ // Then push outward if still inside the cylinder radius.
+ Vector3 horizMovement = new Vector3(sphMovement.X, sphMovement.Y, 0f);
+ float movementIntoWall = Vector3.Dot(horizMovement, collisionNormal);
+ Vector3 projectedMovement = horizMovement - collisionNormal * movementIntoWall;
+ // Preserve vertical movement component (jumping/falling).
+ projectedMovement.Z = sphMovement.Z;
+
+ Vector3 slidPos = sphereCurrPos + projectedMovement;
+
+ // Ensure slid position is outside the cylinder radius horizontally.
+ float sdx = slidPos.X - obj.Position.X;
+ float sdy = slidPos.Y - obj.Position.Y;
+ float sDistSq = sdx * sdx + sdy * sdy;
+ float minDist = combinedR + 0.01f;
+ if (sDistSq < minDist * minDist)
+ {
+ float sDist = MathF.Sqrt(sDistSq);
+ if (sDist < PhysicsGlobals.EPSILON)
+ {
+ // Degenerate: push out along collisionNormal
+ slidPos.X = obj.Position.X + collisionNormal.X * minDist;
+ slidPos.Y = obj.Position.Y + collisionNormal.Y * minDist;
+ }
+ else
+ {
+ float pushDist = (minDist - sDist);
+ slidPos.X += (sdx / sDist) * pushDist;
+ slidPos.Y += (sdy / sDist) * pushDist;
+ }
+ }
+
+ // Apply the offset (difference between slid and current CheckPos)
+ Vector3 delta = slidPos - sphereCheckPos;
+ sp.AddOffsetToCheckPos(delta);
- // Apply collision response via wall-slide.
ci.SetCollisionNormal(collisionNormal);
- return SlideSphere(collisionNormal, sphereCurrPos);
+ ci.SetSlidingNormal(collisionNormal);
+ return TransitionState.Slid;
}
// -----------------------------------------------------------------------
diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs
index 2ff0dee..2dfcd77 100644
--- a/src/AcDream.Core/World/SceneryGenerator.cs
+++ b/src/AcDream.Core/World/SceneryGenerator.cs
@@ -72,11 +72,14 @@ public static class SceneryGenerator
uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock
uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
- // The original iterates Terrain[0..80] — 81 vertices of a 9x9 grid.
- // The heightmap is packed x-major (Height[x*9+y]), so we match that here.
- for (int x = 0; x < VerticesPerSide; x++)
+ // RETAIL iterates 8×8 = 64 CELLS, not 9×9 = 81 vertices.
+ // Decompiled FUN_005311a0 at chunk_00530000.c:1123-1253 uses
+ // `while (local_94 < 8)` and `while (local_8c < 8)` — bound by
+ // `param_1+0x40` which is SideCellCount=8 for outdoor landblocks.
+ // The terrain word at each cell's SW corner drives that cell's scenery.
+ for (int x = 0; x < CellsPerSide; x++)
{
- for (int y = 0; y < VerticesPerSide; y++)
+ for (int y = 0; y < CellsPerSide; y++)
{
int i = x * VerticesPerSide + y;
ushort raw = block.Terrain[i];
@@ -84,14 +87,12 @@ public static class SceneryGenerator
uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6
uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15
- // Skip road vertices: bits 0-1 of the terrain word encode the road
- // type (non-zero means this vertex is on a road). Ported from
- // ACViewer Physics/Common/Landblock.cs GetRoad() and the OnRoad()
- // check in get_land_scenes(). Roads should not have trees/rocks.
- if (IsRoadVertex(raw)) continue;
+ // NOTE: retail does NOT skip based on this vertex's road bit.
+ // The road test happens AFTER displacement via the 4-corner
+ // polygonal OnRoad check (see below). Removing the
+ // pre-displacement early-exit restores retail behavior.
- // Skip cells that contain buildings (ACME conformance fix 4d).
- // Building footprints shouldn't have scenery spawning inside them.
+ // Skip cells that contain buildings.
if (buildingCells is not null && buildingCells.Contains(i)) continue;
if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue;
@@ -154,15 +155,28 @@ public static class SceneryGenerator
if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize)
continue;
- // Check if the final displaced position lands on a road vertex.
- // The road status is per-vertex (9×9 grid); sample the nearest
- // vertex to the displaced position to catch scenery that drifted
- // from a non-road vertex onto a road.
+ // Retail post-displacement road check (FUN_00530d30).
+ // Ported from ACViewer Landblock.OnRoad — uses the 4-corner
+ // road bits of the containing cell plus the 5-unit road
+ // half-width to test whether the displaced (lx,ly) lies on
+ // the road ribbon.
+ bool isOnRoad = IsOnRoad(block, lx, ly);
+ if (isOnRoad)
{
- int nearX = Math.Clamp((int)(lx / CellSize + 0.5f), 0, VerticesPerSide - 1);
- int nearY = Math.Clamp((int)(ly / CellSize + 0.5f), 0, VerticesPerSide - 1);
- ushort nearRaw = block.Terrain[nearX * VerticesPerSide + nearY];
- if (IsRoadVertex(nearRaw)) continue;
+ continue;
+ }
+
+ // Also reject if the vertex CX,CY is a road vertex itself
+ // — scenery whose cell-origin vertex is on a road should
+ // not spawn, even if displacement moves it off the ribbon.
+ // Retail's frequency-based path is guarded by the road mask;
+ // our formula can yield valid positions adjacent to roads
+ // that the ACViewer OnRoad test lets through. This extra
+ // guard pushes scenery away from road vertices, matching
+ // retail's visually clearer road margins.
+ if (IsRoadVertex(block.Terrain[(int)cellX * VerticesPerSide + (int)cellY]))
+ {
+ continue;
}
// Slope filter (ACME conformance fix 4e): compute terrain normal
@@ -183,22 +197,41 @@ public static class SceneryGenerator
if (nz < obj.MinSlope || nz > obj.MaxSlope) continue;
}
- float lz = 0f; // lifted to ground at render time via landblock heightmap
+ // BaseLoc.Z offset: scenery-specific vertical offset from
+ // the ground (e.g., flowers planted at -0.1m so they
+ // don't float above grass). The renderer adds groundZ
+ // later, so pass the BaseLoc.Z through as-is.
+ float lz = obj.BaseLoc.Origin.Z;
// Rotation: chunk_005A0000.c lines 4924-4931 (FUN_005a6e60)
- // offset constant 0xf697 = 63127
- // iVar2 = (param_3 * 0x6c1ac587 - (param_2 * param_3 * 0x5111bfef + 0x70892fb7) * (param_4 + 0xf697))
- // + param_2 * -0x421be3bd
- // param_2=ix, param_3=iy, param_4=j
- Quaternion rotation = Quaternion.Identity;
- if (obj.MaxRotation > 0)
+ // Retail calls FUN_00425f10(baseLoc) to copy baseLoc.Orientation
+ // into the frame, THEN calls AFrame::set_heading(degrees).
+ //
+ // set_heading uses yaw = -(450 - heading) % 360 before converting
+ // to a quaternion, which introduces a 90° offset + sign flip
+ // relative to a naive Z rotation. WorldBuilder's
+ // SceneryHelpers.SetHeading reproduces this.
+ //
+ // For objects with Align != 0, retail uses FUN_005a6f60 to
+ // align to the landcell polygon's normal instead of setting
+ // heading from the noise.
+ //
+ // Composition: final = baseLoc.Orientation * headingQuat
+ Quaternion rotation = obj.BaseLoc.Orientation;
+ if (rotation.LengthSquared() < 0.0001f)
+ rotation = Quaternion.Identity;
+
+ if (obj.MaxRotation > 0f)
{
double rotNoise = unchecked((uint)(1813693831u * globalCellY
- (j + 63127u) * (1360117743u * globalCellY * globalCellX + 1888038839u)
- 1109124029u * globalCellX)) * 2.3283064e-10;
float degrees = (float)(rotNoise * obj.MaxRotation);
- float radians = degrees * MathF.PI / 180f;
- rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, radians);
+ // AFrame::set_heading transform — matches retail.
+ float yawDeg = -((450f - degrees) % 360f);
+ float yawRad = yawDeg * MathF.PI / 180f;
+ var headingQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRad);
+ rotation = headingQuat * rotation;
}
// Scale: ACViewer Physics/Common/ObjectDesc.cs ScaleObj() (confirmed matches pattern)
@@ -237,6 +270,121 @@ public static class SceneryGenerator
///
public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0;
+ ///
+ /// Half-width of a road ribbon in world units — the road extends from each
+ /// road vertex by this amount into the neighbor cells. Matches retail's
+ /// `_DAT_007c9cc0 = 5.0f` in FUN_00530d30.
+ ///
+ private const float RoadHalfWidth = 5.0f;
+
+ ///
+ /// Retail-faithful post-displacement road test. Ported from ACViewer
+ /// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which is
+ /// a direct port of FUN_00530d30 in the retail client.
+ ///
+ /// Examines the 4 corners of the cell containing (lx, ly) and, depending
+ /// on how many are road vertices (0, 1, 2, 3, or 4), applies a polygonal
+ /// test using the 5-unit road half-width to check if (lx, ly) lies on the
+ /// road ribbon. Returns true if the point is on a road.
+ ///
+ ///
+ /// Retail-faithful road ribbon test — direct port of ACViewer's
+ /// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which
+ /// itself is a port of FUN_00530d30 in acclient.exe.
+ ///
+ /// Classifies the 4 corners of the cell containing (lx, ly) by road type
+ /// (bits 0-1 of the terrain word) and applies a different geometric test
+ /// based on which corners are road vertices. Road ribbons have a 5m
+ /// half-width (TileLength - RoadWidth = 19m).
+ ///
+ private static bool IsOnRoad(LandBlock block, float lx, float ly)
+ {
+ int x = (int)MathF.Floor(lx / CellSize);
+ int y = (int)MathF.Floor(ly / CellSize);
+ // Clamp so we don't index past the 9x9 terrain grid
+ x = Math.Clamp(x, 0, CellsPerSide - 1);
+ y = Math.Clamp(y, 0, CellsPerSide - 1);
+
+ float rMin = RoadHalfWidth; // 5
+ float rMax = CellSize - RoadHalfWidth; // 19
+
+ // Corner road bits (ACViewer convention):
+ // r0 = (x0, y0) = SW
+ // r1 = (x0, y1) = NW
+ // r2 = (x1, y0) = SE
+ // r3 = (x1, y1) = NE
+ bool r0 = IsRoadVertex(block.Terrain[x * VerticesPerSide + y]);
+ bool r1 = IsRoadVertex(block.Terrain[x * VerticesPerSide + (y + 1)]);
+ bool r2 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + y]);
+ bool r3 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + (y + 1)]);
+
+ if (!r0 && !r1 && !r2 && !r3) return false;
+
+ float dx = lx - x * CellSize;
+ float dy = ly - y * CellSize;
+
+ if (r0)
+ {
+ if (r1)
+ {
+ if (r2)
+ {
+ if (r3) return true;
+ return dx < rMin || dy < rMin;
+ }
+ else
+ {
+ if (r3) return dx < rMin || dy > rMax;
+ return dx < rMin;
+ }
+ }
+ else
+ {
+ if (r2)
+ {
+ if (r3) return dx > rMax || dy < rMin;
+ return dy < rMin;
+ }
+ else
+ {
+ if (r3) return MathF.Abs(dx - dy) < rMin;
+ return dx + dy < rMin;
+ }
+ }
+ }
+ else
+ {
+ if (r1)
+ {
+ if (r2)
+ {
+ if (r3) return dx > rMax || dy > rMax;
+ return MathF.Abs(dx + dy - CellSize) < rMin;
+ }
+ else
+ {
+ if (r3) return dy > rMax;
+ return CellSize + dx - dy < rMin;
+ }
+ }
+ else
+ {
+ if (r2)
+ {
+ if (r3) return dx > rMax;
+ return CellSize - dx + dy < rMin;
+ }
+ else
+ {
+ if (r3) return CellSize * 2f - dx - dy < rMin;
+ return false;
+ }
+ }
+ }
+ }
+
+ private const int CellsPerSide = 8;
+
///
/// Pseudo-random displacement within a cell for a scenery object. Returns a
/// Vector3 in local cell-offset space (the caller adds it to the cell corner
diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs
index 479f5cd..33a4b2c 100644
--- a/src/AcDream.Core/World/WorldEntity.cs
+++ b/src/AcDream.Core/World/WorldEntity.cs
@@ -43,4 +43,16 @@ public sealed class WorldEntity
/// Null for outdoor entities (stabs, scenery, live server spawns).
///
public uint? ParentCellId { get; init; }
+
+ ///
+ /// Uniform scale applied to this entity's mesh by the scenery pipeline.
+ /// For scenery objects this is spawn.Scale (typically 0.8–1.3). For stabs
+ /// and interior static objects this is 1.0 (no scaling).
+ ///
+ /// Used by the collision registration path to scale CylSphere / Sphere /
+ /// Setup.Radius shapes so they match the visually-scaled mesh. Without
+ /// this, scaled scenery has a collision cylinder that's smaller than the
+ /// visible trunk, producing "partial passthrough" bugs.
+ ///
+ public float Scale { get; init; } = 1.0f;
}