diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
index 48ac917..6fefea3 100644
--- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
+++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
@@ -222,17 +222,31 @@ public sealed unsafe class SkyRenderer : IDisposable
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
- // Sky meshes need per-object wrap mode. The dome is 5 flat
- // walls meeting at edges โ under GL_REPEAT any UV drift
- // past [0,1] wraps to the opposite edge of the texture,
- // drawing a visible line along each wall seam. Static
- // sky GfxObjs (dome, sun, moon, stars) should use
- // CLAMP_TO_EDGE to avoid that bleed. Scrolling cloud
- // layers (TexVelocity != 0) still need REPEAT so the
- // animated UV offset wraps correctly. Detection heuristic:
- // non-zero TexVelocity on either axis โ scrolling layer.
- bool scrolling = obj.TexVelocityX != 0f || obj.TexVelocityY != 0f;
- int wrapMode = scrolling
+ // Sky meshes need per-object wrap mode driven by the
+ // mesh's authored UV range, not by TexVelocity:
+ // * The outer dome (0x010015EE/F0/F1/F2) authors UVs
+ // strictly in [0,1]. Under GL_REPEAT the bilinear
+ // filter at wall-seam edges would average a texel
+ // near the right edge with one near the left edge of
+ // the texture, drawing a visible "bleed line" along
+ // every dome seam. CLAMP_TO_EDGE avoids that.
+ // * The inner sky/star layer (0x010015EF) and the
+ // cloud meshes (0x010015B6, 0x01004C36 etc) author
+ // UVs that deliberately exceed [0,1] (~0.4..4.6) so
+ // the texture tiles across the geometry. CLAMP_TO_EDGE
+ // would clamp ~99% of the surface to a single edge
+ // texel, leaving only a small "square" where UVs
+ // happen to fall in [0,1] (Bug B in
+ // docs/research/2026-04-26-sky-investigation-handoff.md).
+ // The mesh builder pre-computes NeedsUvRepeat from the
+ // actual UV range so the right answer is data-driven.
+ // Scrolling clouds are also forced to REPEAT (the running
+ // UV offset can drift outside [0,1] regardless of authored
+ // range, and they'd show their own seam bleed otherwise).
+ bool needsRepeat = sub.NeedsUvRepeat
+ || obj.TexVelocityX != 0f
+ || obj.TexVelocityY != 0f;
+ int wrapMode = needsRepeat
? (int)TextureWrapMode.Repeat
: (int)TextureWrapMode.ClampToEdge;
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode);
@@ -423,6 +437,7 @@ public sealed unsafe class SkyRenderer : IDisposable
SurfaceId = sm.SurfaceId,
IsAdditive = isAdditive,
SurfLuminosity = sm.Luminosity,
+ NeedsUvRepeat = sm.NeedsUvRepeat,
};
}
@@ -462,5 +477,15 @@ public sealed unsafe class SkyRenderer : IDisposable
/// docs/research/2026-04-23-sky-retail-verbatim.md ยง6.
///
public float SurfLuminosity;
+ ///
+ /// True when the source mesh's authored UVs exceed [0,1] (e.g.
+ /// the inner sky/star layer 0x010015EF and the cloud meshes โ
+ /// they tile their texture across the geometry). The renderer
+ /// must use GL_REPEAT for these or only the small region
+ /// where UVs fall in [0,1] samples the actual texture; the rest
+ /// clamps to the edge texel ("square in one corner" symptom).
+ /// Computed once at mesh build from the actual UV range.
+ ///
+ public bool NeedsUvRepeat;
}
}
diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs
index f468dae..240e4db 100644
--- a/src/AcDream.Core/Meshing/GfxObjMesh.cs
+++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs
@@ -210,6 +210,23 @@ public static class GfxObjMesh
}
}
+ // Authored UV range determines the wrap-mode choice in the
+ // sky pass. A mesh whose UVs are strictly in [0,1] (e.g. the
+ // outer dome 0x010015EE) wants CLAMP_TO_EDGE to avoid
+ // bilinear-filter bleed at the wall-seam edges; a mesh whose
+ // UVs deliberately tile (e.g. 0x010015EF, ~0.4..4.6) wants
+ // REPEAT so the texture tiles across the geometry. We make
+ // the call data-driven here rather than guessing from
+ // TexVelocity at draw time. See
+ // docs/research/2026-04-26-sky-investigation-handoff.md (Bug B).
+ bool needsUvRepeat = false;
+ foreach (var v in kvp.Value.Vertices)
+ {
+ if (v.TexCoord.X < 0f || v.TexCoord.X > 1f
+ || v.TexCoord.Y < 0f || v.TexCoord.Y > 1f)
+ { needsUvRepeat = true; break; }
+ }
+
result.Add(new GfxObjSubMesh(
SurfaceId: surfaceId,
Vertices: kvp.Value.Vertices.ToArray(),
@@ -217,6 +234,7 @@ public static class GfxObjMesh
{
Translucency = translucency,
Luminosity = luminosity,
+ NeedsUvRepeat = needsUvRepeat,
});
}
return result;
diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs
index d6a9cd0..488b0dd 100644
--- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs
+++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs
@@ -39,4 +39,18 @@ public sealed record GfxObjSubMesh(
/// normal lighting path without change.
///
public float Luminosity { get; init; } = 0f;
+
+ ///
+ /// True when at least one vertex's UV component lies outside the
+ /// [0, 1] range, meaning the mesh was authored to have its
+ /// texture tile across the geometry (i.e. it expects
+ /// GL_REPEAT/D3DTADDRESS_WRAP). The sky renderer reads
+ /// this to decide between GL_REPEAT (this flag set, or any
+ /// scrolling layer) and GL_CLAMP_TO_EDGE (all UVs strictly
+ /// in [0,1]), which avoids wall-seam bleed on the dome
+ /// (UVs in [0,1]) while still tiling the inner star/cloud
+ /// layers (UVs in [~0.4, ~4.6]) correctly.
+ /// Defaults to false so non-sky consumers get the previous behavior.
+ ///
+ public bool NeedsUvRepeat { get; init; } = false;
}