diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 2c5a4b12..6874bb33 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -37,10 +37,11 @@ accepted-divergence entries (#96, #49, #50).
---
-## 1. Intentional architecture (IA) — 22 active rows (IA-25 filed 2026-08-22 for Campaign VM VM6's opt-in weather-driven foliage wind — a render-only vertex displacement keyed by the DAT-classified `WeatherKind`, not the raw day-group index, with no authored retail wind direction to read; IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default)
+## 1. Intentional architecture (IA) — 23 active rows (IA-26 filed 2026-08-23 — the pack-gated procedural night sky replacing the stretched DAT star layer; IA-25 filed 2026-08-22 for Campaign VM VM6's opt-in weather-driven foliage wind — a render-only vertex displacement keyed by the DAT-classified `WeatherKind`, not the raw day-group index, with no authored retail wind direction to read; IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
+| IA-26 | **Filed 2026-08-23, user-directed ("I want the night sky to look very good").** When the Atmospheric render pack is the active runtime, the DAT star layer (GfxObj `0x010015EF`, identical across all 20 Dereth day groups — a small texture stretched over a 10-poly dome cap) renders instead as `sky.frag`'s fully procedural night sky: hash-derived stars on a cube-face grid in three density tiers plus sparse diffraction-spiked standouts, sized in SCREEN pixels via derivatives (crisp at any resolution/FOV — the stretched-texture flaw this exists to remove), over a 0.4–1.3% cool background mottle. The art direction is the user-approved 2026-08-23 generator recipe. The draw is forced onto the additive pipeline; the day/night fade derives from the star layer's own retail lighting product so timing matches the authored keyframes. Retail default (pack inactive) is byte-untouched. | `src/AcDream.App/Rendering/Shaders/sky.frag` (the `uParamA` branch); `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` / `.Rhi.cs`; `src/AcDream.App/Composition/FrameRootComposition.cs` | Pack-gated opt-in like IA-24/IA-25; `EnhancedNightSkyRuleTests` pins the gate, the exact star-layer id, the forced-additive draw, and the pack-runtime wiring. | A future region whose star layer uses a different GfxObj id keeps its retail stars even with the pack active (the swap simply never fires); anyone comparing acdream-with-pack to retail at night will see a deliberately different (denser, crisper) starfield. | `EnhancedNightSkyRuleTests`; user gate 2026-08-23; gen_starfield2.py (scratchpad, seed 11) |
| IA-1 | Contact-plane pre-seed on grounded movers (**#96 ACCEPTED** per ISSUES.md) — retail's `CTransition::init` clears `contact_plane_valid`; we seed from the body's previous-frame plane | `src/AcDream.Core/Physics/PhysicsEngine.cs:919` | Removing it broke last-step stair `step_up` (`892019b`, reverted); seed propagates the body's *real current* plane, behavior matched retail in the A6.P3 gates | A stale pre-seeded plane lets `AdjustOffset` project sub-step 1 onto a plane retail wouldn't have yet — wrong slope motion / step-up acceptance right after leaving a surface | `CTransition::init`, pc:272547 family |
| IA-2 | Lateral self-heal beyond retail's keep-curr: when no candidate contains the sphere, try `FindVisibleChildCell` over the claim's stab-list before keeping the claim | `src/AcDream.Core/Physics/CellTransit.cs:912` | Reuses the recovery retail's own `AdjustPosition` performs (:280028 stab-list mode), applied at the `find_cell_list` site to heal near-miss claims without a doorway crossing | In containment-gap geometry, membership flips to a neighbouring room where retail keeps curr — wrong render root / collision cell at gap positions | `find_cell_list` keep-curr pc:308788-308825; `find_visible_child_cell` :311444 |
| IA-3 | **NARROWED 2026-07-17 — `get_state_velocity` may prefer a nonzero dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant.** Production grounded player/remote translation no longer consumes this value; both use the literal CSequence root Frame. The accessor remains observable for jump launch and headless/test fallbacks | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`get_state_velocity`); grounded owners in `PlayerMovementController` / `RemotePhysicsUpdater` | Installed Humanoid Walk/Run MotionData velocity is zero, so the retail constants remain the jump/fallback result. A nonzero exotic/modded cycle may override them, preserving the earlier adapter contract without affecting ordinary grounded motion | Jump horizontal speed for an exotic MotionTable with nonzero authored velocity can differ from the retail binary's constants | `CMotionInterp::get_state_velocity` 0x00527D50; `CPhysicsObj::UpdatePositionInternal` 0x00512C30 |
diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index 8f5099c7..bfb78468 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -320,6 +320,20 @@ internal sealed class FrameRootCompositionPhase
renderPackDiagnostics.BindOwned(renderPackController));
}
atmosphericInputs = new AcDream.App.Rendering.Packs.AtmosphericFrameInputState();
+
+ // Enhanced night sky (register IA-26, user-directed 2026-08-23):
+ // while the Atmospheric pack is the active runtime, SkyRenderer
+ // swaps the DAT star layer for sky.frag's procedural
+ // view-direction starfield. Same-composition lifetimes on both
+ // sides, and the delegate re-reads ActiveRuntime per draw, so a
+ // pack activate/deactivate flips the sky the same frame.
+ if (live.SkyRenderer is { } skyForNightSky)
+ {
+ var nightSkyController = renderPackController;
+ skyForNightSky.EnhancedNightSkyActive = () =>
+ nightSkyController.ActiveRuntime?.Descriptor.Id
+ == AcDream.App.Rendering.Packs.BuiltInAtmosphericRenderPack.Id;
+ }
}
var renderWeatherFrame = new RenderWeatherFrameController(
diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag
index f02ebbe8..b84dda24 100644
--- a/src/AcDream.App/Rendering/Shaders/sky.frag
+++ b/src/AcDream.App/Rendering/Shaders/sky.frag
@@ -4,8 +4,15 @@
in vec2 vTex;
in vec3 vTint;
in float vFogFactor; // 1 = no fog, 0 = full fog color
+in vec3 vDir; // model-space view direction (enhanced night sky)
out vec4 fragColor;
+// Enhanced night sky (render-pack-gated, register IA-26): uParamA > 0.5 marks
+// the star-layer draw as the procedural night sky; uParamB carries its seed.
+// Push-constant-backed like uTextureIndexA (see VulkanGlslPreamble.cs).
+uniform float uParamA;
+uniform float uParamB;
+
// Campaign V slice V6e: the sky's texture is now read through the shared table
// (ACDREAM_SAMPLE_2D, injected by tools/ShaderCompiler/VulkanGlslPreamble.cs)
// rather than a `uniform sampler2D` bound to texture unit 0. Vulkan has no
@@ -52,7 +59,138 @@ layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
vec4 uCameraAndTime;
};
+// ============================== enhanced night sky ==========================
+// User-directed enhancement (register IA-26): when the Atmospheric render pack
+// is active, the DAT star layer (GfxObj 0x010015EF - a small texture stretched
+// over a 10-poly dome cap) is replaced by a fully procedural sky computed from
+// the VIEW DIRECTION. Stars are hash-derived on a cube-face grid, sized in
+// SCREEN pixels via fwidth, so they stay pixel-crisp at any resolution, FOV,
+// and view angle - the "stretched image" flaw this exists to remove. The art
+// direction (density tiers, colour distribution, spiked standouts, faint cool
+// mottle) is the user-approved 2026-08-23 generator recipe
+// (scratchpad gen_starfield2.py); the retail default look is untouched.
+
+uint nsPcg(uint v)
+{
+ v = v * 747796405u + 2891336453u;
+ v = ((v >> ((v >> 28u) + 4u)) ^ v) * 277803737u;
+ return (v >> 22u) ^ v;
+}
+
+float nsRand(uint s) { return float(s) * (1.0 / 4294967296.0); }
+
+vec3 nsTint(float r)
+{
+ if (r < 0.04) return vec3(1.00, 0.55, 0.35); // orange-red
+ if (r < 0.12) return vec3(1.00, 0.80, 0.60); // warm
+ if (r < 0.62) return vec3(1.00, 0.97, 0.93); // near-white
+ if (r < 0.88) return vec3(0.90, 0.95, 1.00); // blue-white
+ return vec3(0.70, 0.82, 1.00); // hot blue
+}
+
+float nsVnoise(vec3 p, uint seed)
+{
+ vec3 i = floor(p);
+ vec3 f = fract(p);
+ f = f * f * (3.0 - 2.0 * f);
+ float acc = 0.0;
+ for (int c = 0; c < 8; ++c)
+ {
+ vec3 o = vec3(float(c & 1), float((c >> 1) & 1), float((c >> 2) & 1));
+ vec3 q = i + o;
+ uint h = nsPcg(seed
+ ^ nsPcg(uint(int(q.x) + 512)
+ ^ nsPcg(uint(int(q.y) + 512)
+ ^ uint(int(q.z) + 512))));
+ vec3 w = mix(1.0 - f, f, o);
+ acc += nsRand(h) * w.x * w.y * w.z;
+ }
+ return acc;
+}
+
+vec3 nsStars(vec2 fuv, uint faceSeed, float cells, float density,
+ float bMin, float bMax, float sizePx, bool spikes)
+{
+ vec2 g = fuv * cells;
+ vec2 cellF = floor(g);
+ // Screen pixels per cell-space unit: the crispness anchor.
+ float pxPerCell = 1.0 / max(length(fwidth(g)), 1e-6);
+ vec3 acc = vec3(0.0);
+ for (int dy = -1; dy <= 1; ++dy)
+ for (int dx = -1; dx <= 1; ++dx)
+ {
+ vec2 c = cellF + vec2(float(dx), float(dy));
+ uint h = nsPcg(faceSeed
+ ^ nsPcg(uint(int(c.x) + 512)
+ ^ nsPcg(uint(int(c.y) + 512)
+ ^ uint(cells))));
+ if (nsRand(h) > density) continue;
+ uint h2 = nsPcg(h);
+ uint h3 = nsPcg(h2);
+ uint h4 = nsPcg(h3);
+ uint h5 = nsPcg(h4);
+ vec2 pos = c + vec2(nsRand(h2), nsRand(h3));
+ float t = nsRand(h4);
+ float b = mix(bMin, bMax, t * t * t);
+ vec3 tint = nsTint(nsRand(h5));
+ vec2 dPx = (g - pos) * pxPerCell;
+ float d2 = dot(dPx, dPx);
+ float star = exp(-d2 / (2.0 * sizePx * sizePx));
+ if (spikes)
+ {
+ // Axis-aligned diffraction cross + soft halo for the standouts.
+ float dist = sqrt(d2) + 1e-4;
+ float fall = exp(-dist / (14.0 * sizePx));
+ float arm = exp(-dPx.y * dPx.y * 0.8) + exp(-dPx.x * dPx.x * 0.8);
+ star += 0.35 * fall * arm;
+ star += 0.10 * exp(-d2 / (18.0 * sizePx * sizePx));
+ }
+ acc += b * tint * star;
+ }
+ return acc;
+}
+
+vec3 nightSky(vec3 dir, uint seed)
+{
+ // Cube-face parameterisation: uniform angular density, no pole pinch.
+ // Face-edge star continuity is not exact (adjacent faces hash their own
+ // grids); the sub-pixel population makes any seam visually negligible.
+ vec3 ad = abs(dir);
+ uint face; vec2 fuv; float ma;
+ if (ad.z >= ad.x && ad.z >= ad.y) { face = dir.z > 0.0 ? 4u : 5u; ma = ad.z; fuv = dir.xy; }
+ else if (ad.x >= ad.y) { face = dir.x > 0.0 ? 0u : 1u; ma = ad.x; fuv = dir.yz; }
+ else { face = dir.y > 0.0 ? 2u : 3u; ma = ad.y; fuv = dir.xz; }
+ fuv = fuv / ma * 0.5 + 0.5;
+ uint fs = nsPcg(seed ^ (face * 0x9E3779B9u));
+
+ // Faint cool background mottle (0.4%..1.3% - the user-approved level).
+ float n = 0.55 * nsVnoise(dir * 3.0, seed ^ 0x9E3779B9u)
+ + 0.30 * nsVnoise(dir * 7.0, seed ^ 0x85EBCA6Bu)
+ + 0.15 * nsVnoise(dir * 15.0, seed ^ 0xC2B2AE35u);
+ vec3 rgb = (0.004 + 0.009 * n) * vec3(0.85, 0.92, 1.10);
+
+ // The star carpet: three density tiers plus sparse spiked standouts.
+ rgb += nsStars(fuv, fs, 160.0, 0.90, 0.05, 0.35, 0.55, false);
+ rgb += nsStars(fuv, fs, 64.0, 0.50, 0.20, 0.70, 0.75, false);
+ rgb += nsStars(fuv, fs, 24.0, 0.35, 0.50, 1.40, 1.05, false);
+ rgb += nsStars(fuv, fs, 6.0, 0.10, 2.00, 4.00, 1.80, true);
+ return rgb;
+}
+// ============================================================================
+
void main() {
+ if (uParamA > 0.5) {
+ // The star layer's own retail lighting product (Emissive 0 ->
+ // ambient + diffuse) is the day/night signal: ~0.1 at deep night,
+ // ~1.0 at noon. Stars at full strength only under a dark sky.
+ float dayL = dot(vTint, vec3(0.299, 0.587, 0.114));
+ float night = 1.0 - smoothstep(0.18, 0.45, dayL);
+ vec3 sky = nightSky(normalize(vDir), uint(uParamB));
+ // Additive pipeline (forced for this draw): rgb adds over the dome.
+ fragColor = vec4(sky * night, 1.0);
+ return;
+ }
+
vec4 sampled = ACDREAM_SAMPLE_2D(uTextureIndexA, vTex);
vec3 rgb = sampled.rgb * vTint;
diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert
index 290436d2..28a94d1a 100644
--- a/src/AcDream.App/Rendering/Shaders/sky.vert
+++ b/src/AcDream.App/Rendering/Shaders/sky.vert
@@ -115,9 +115,14 @@ out gl_PerVertex {
out vec2 vTex;
out vec3 vTint;
out float vFogFactor; // 1 = no fog (close), 0 = full fog (far)
+// Model-space position of the (camera-centred) sky vertex — normalised in the
+// fragment stage it is the view DIRECTION. Only the enhanced night-sky branch
+// reads it (see sky.frag's uParamA gate).
+out vec3 vDir;
void main() {
vTex = aTex + uUvScroll;
+ vDir = aPos;
gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0);
// uModel for sky is pure rotation (Z then Y) — orthonormal, so
diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
index a363d5a2..7067f074 100644
--- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
+++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
@@ -311,12 +311,12 @@
"stages": [
{
"stage": "vert",
- "sourceSha256": "a89580f54c8d36b33d50b84b9fb61024861c737bb0b5bd7a4df0704bf010c3c3",
+ "sourceSha256": "9102b156bd4fd667831353640b2feee2ddde66e88579a4e425566c9b67304b64",
"compiled": true
},
{
"stage": "frag",
- "sourceSha256": "b1f3b924af2040c909337c02461ed6795b5320fb04b5bb371ce1546feead567c",
+ "sourceSha256": "bf5bc353959636b1c80a9f72dc918cffdd33fb6b7b655149de40422030b4f51a",
"compiled": true
}
]
diff --git a/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv
index 477ff9fe..df2533c5 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/sky.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/sky.vert.spv
index e8545970..e69c1a43 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/sky.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/sky.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs
index 7741acb1..eeced25f 100644
--- a/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs
+++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs
@@ -209,7 +209,7 @@ public sealed unsafe partial class SkyRenderer
/// those binds are what select the descriptor scope the sections must land in
/// (plan §5.5.14 item 2).
///
- private void DrawSubMeshRhi(SubMeshGpu sub, uint textureSlot)
+ private void DrawSubMeshRhi(SubMeshGpu sub, uint textureSlot, bool nightSky = false)
{
if (sub.IndexCount == 0 || sub.VertexBuffer is null || sub.IndexBuffer is null)
return;
@@ -220,12 +220,16 @@ public sealed unsafe partial class SkyRenderer
?? throw new InvalidOperationException(
"SkyRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
- encoder.BindPipeline(sub.IsAdditive ? _additivePipeline! : _alphaPipeline!);
+ // The enhanced night sky ADDS starlight over the dome, so its draw
+ // rides the additive pipeline even though the authored star surface
+ // is alpha-blended (register IA-26).
+ encoder.BindPipeline(nightSky || sub.IsAdditive ? _additivePipeline! : _alphaPipeline!);
var pushConstants = new GpuPushConstants
{
// uViewProjection is unread by sky.vert — the sky carries its own
// camera-anchored view and dome projection in SkyParams — so the
- // block's only live member here is the texture slot.
+ // live members here are the texture slot and the night-sky
+ // ParamA/ParamB pair (sky.frag's uParamA gate + seed).
ViewProjection = System.Numerics.Matrix4x4.Identity,
DrawIdOffset = 0,
LightingMode = 0,
@@ -233,8 +237,8 @@ public sealed unsafe partial class SkyRenderer
LightDebug = 0,
TextureIndexA = textureSlot,
TextureIndexB = 0,
- ParamA = 0f,
- ParamB = 0f,
+ ParamA = nightSky ? 1f : 0f,
+ ParamB = NightSkySeed,
};
encoder.SetPushConstants(in pushConstants);
encoder.BindVertexBuffer(0, sub.VertexBuffer, 0);
diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
index a8c2236b..aec455b6 100644
--- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
+++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs
@@ -89,6 +89,27 @@ public sealed partial class SkyRenderer : IDisposable
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 1_000_000f;
+ ///
+ /// Dereth's star layer — the one sky GfxObj (verified identical across
+ /// all 20 day groups) whose draw the enhanced night sky replaces.
+ ///
+ private const uint StarLayerGfxObjId = 0x010015EFu;
+
+ ///
+ /// User-directed enhancement gate (register IA-26): when this returns
+ /// true, the star layer renders as sky.frag's procedural night sky
+ /// (view-direction stars, pixel-crisp at any resolution) instead of the
+ /// authored stretched texture. Wired by composition to "the Atmospheric
+ /// render pack is active"; null/false keeps the retail look. Evaluated
+ /// per draw — never capture a stale pre-session snapshot
+ /// (claude-memory/feedback_resolve_deferred_funcs_per_call.md).
+ ///
+ internal Func? EnhancedNightSkyActive { get; set; }
+
+ /// Deterministic seed for the procedural sky's hash grids —
+ /// the approved-look generator's seed (gen_starfield2.py, 2026-08-23).
+ private const float NightSkySeed = 11f;
+
///
/// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds —
/// every SkyObject with Properties & 0x04 == 0).
@@ -426,7 +447,9 @@ public sealed partial class SkyRenderer : IDisposable
|| obj.TexVelocityX != 0f
|| obj.TexVelocityY != 0f;
uint slot = TextureTableSlot(sub.SurfaceId, needsRepeat);
- DrawSubMeshRhi(sub, slot);
+ bool nightSky = gfxObjId == StarLayerGfxObjId
+ && (EnhancedNightSkyActive?.Invoke() ?? false);
+ DrawSubMeshRhi(sub, slot, nightSky);
}
}
}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
index 1ca73d65..11d417a6 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
@@ -52,8 +52,8 @@ public sealed class VulkanShaderManifestTests
// override, retail's GameSky::Draw @0x00506FF0 rule (see
// SkyFogRuleTests). A deliberate default-path change, reviewed
// with the world-fog-range fix in the same commit.
- ["sky.frag.spv"] = "b3b544829f2dd85be04b6b16d78a0490e1fe7884590cb541b95756b7d7511620",
- ["sky.vert.spv"] = "77176cf33c761ee4e9730357895c941dbf5949d8e0d28e0bb0dcde87f4d30288",
+ ["sky.frag.spv"] = "8105984072fc1b9075b5efdffd75d959c4087710d354d50a0e00ee0500462ca7",
+ ["sky.vert.spv"] = "3b51945fa4ff1be1604144df92866bdd47aade22f9dd90267591ef36adb28cde",
["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
["terrain_modern.vert.spv"] = "9f4cb221ea6aed94a8d23af6cb8e3f3ed96c3cce6e50d135a72d3b55667b1557",
["ui_text.frag.spv"] = "37a281bf80441cb425eaa3ad8e0b3a43cfa21b74b60973ed4201718b9dc102df",
diff --git a/tests/AcDream.App.Tests/Rendering/Sky/EnhancedNightSkyRuleTests.cs b/tests/AcDream.App.Tests/Rendering/Sky/EnhancedNightSkyRuleTests.cs
new file mode 100644
index 00000000..0909720c
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Sky/EnhancedNightSkyRuleTests.cs
@@ -0,0 +1,106 @@
+using System;
+using System.IO;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering.Sky;
+
+///
+/// Source guards for the enhanced night sky (register IA-26, user-directed
+/// 2026-08-23): a render-pack-gated replacement of the DAT star layer
+/// (GfxObj 0x010015EF) with sky.frag's procedural view-direction starfield.
+/// The retail look must stay the default: everything here is inert unless the
+/// Atmospheric pack is the active runtime. The shader itself needs a GPU, so
+/// these pin the wiring the way SkyFogRuleTests pins the fog rules.
+///
+public sealed class EnhancedNightSkyRuleTests
+{
+ [Fact]
+ public void SkyFragmentShaderGatesTheProceduralSkyOnParamA()
+ {
+ string frag = File.ReadAllText(Path.Combine(ShaderRoot(), "sky.frag"));
+ string code = StripLineComments(frag);
+
+ Assert.Contains("if (uParamA > 0.5)", code, StringComparison.Ordinal);
+ Assert.Contains("nightSky(normalize(vDir), uint(uParamB))", code, StringComparison.Ordinal);
+ // Screen-pixel star sizing is the reason this exists — the stretched
+ // texture flaw must not creep back in via a fixed-size grid.
+ Assert.Contains("fwidth(g)", code, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void SkyRendererSwapsExactlyTheStarLayerAndOnlyUnderTheProvider()
+ {
+ string source = File.ReadAllText(Path.Combine(
+ RepositoryRoot(), "src", "AcDream.App", "Rendering", "Sky", "SkyRenderer.cs"));
+ string code = StripLineComments(source);
+
+ Assert.Contains(
+ "private const uint StarLayerGfxObjId = 0x010015EFu;",
+ code,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "bool nightSky = gfxObjId == StarLayerGfxObjId",
+ code,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "(EnhancedNightSkyActive?.Invoke() ?? false)",
+ code,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void NightSkyDrawForcesTheAdditivePipeline()
+ {
+ string source = File.ReadAllText(Path.Combine(
+ RepositoryRoot(), "src", "AcDream.App", "Rendering", "Sky", "SkyRenderer.Rhi.cs"));
+ string code = StripLineComments(source);
+
+ Assert.Contains(
+ "nightSky || sub.IsAdditive ? _additivePipeline! : _alphaPipeline!",
+ code,
+ StringComparison.Ordinal);
+ Assert.Contains("ParamA = nightSky ? 1f : 0f,", code, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void CompositionGatesTheNightSkyOnTheAtmosphericPackRuntime()
+ {
+ string source = File.ReadAllText(Path.Combine(
+ RepositoryRoot(), "src", "AcDream.App", "Composition", "FrameRootComposition.cs"));
+ string code = StripLineComments(source);
+
+ Assert.Contains("EnhancedNightSkyActive = () =>", code, StringComparison.Ordinal);
+ Assert.Contains(
+ "nightSkyController.ActiveRuntime?.Descriptor.Id",
+ code,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "BuiltInAtmosphericRenderPack.Id",
+ code,
+ StringComparison.Ordinal);
+ }
+
+ private static string StripLineComments(string source)
+ {
+ var lines = source.Split('\n');
+ for (int i = 0; i < lines.Length; i++)
+ {
+ int idx = lines[i].IndexOf("//", StringComparison.Ordinal);
+ if (idx >= 0)
+ lines[i] = lines[i][..idx];
+ }
+ return string.Join('\n', lines);
+ }
+
+ private static string ShaderRoot() =>
+ Path.Combine(RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders");
+
+ private static string RepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
+ directory = directory.Parent;
+ return directory?.FullName
+ ?? throw new InvalidOperationException("Could not locate repository root.");
+ }
+}