DramaticShapeVoxelMod archival mirror · read-only
f841378330466dd9511f772cf21686828aca6228

add day night cycle

DramaticShape · 2026-07-29 · parent 6d7a93dbad

add day night cycle

Changed files

CHANGELOG.md                  |  58 +++++++++
 README.md                     |  56 +++++++-
 lib/BattleScene.lua           |  20 ++-
 lib/DayNight.lua              | 437 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/Sky.lua                   | 148 ++++++++++++++++++---
 lib/Voxel3D.lua               |  50 ++++++-
 lib/VoxelScene.lua            |  24 +++-
 main.lua                      |  45 +++++++
 tests/daynight_shots.lua      |  97 ++++++++++++++
 tests/dramatic_shape_test.lua | 220 +++++++++++++++++++++++++++++--
 10 files changed, 1117 insertions(+), 38 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f26651c..7898802 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -56,6 +56,64 @@
   The overworld only. A battle is a staged shot whose placed camera has the
   horizon above the frame, so the arena keeps exactly the flat sky it had.
 
+- **A day/night cycle**, on a new **DAYTIME** options row: `DAY`, `NIGHT`,
+  `DUSK`, `DAWN`, `CYCLE`. One twenty-minute clock underneath all five -- ten
+  minutes of sun, ten of moon -- where the four named settings are PINS on
+  that dial (noon, mid-night, sunset, sunrise) and `CYCLE` lets it run,
+  picking up from whichever pin the player was just looking at. Everything is
+  a pure function of the clock, so the pinned DUSK is exactly the running
+  cycle stopped at sunset. Setting **VOXEL** to `FULL` switches DAYTIME to
+  `CYCLE` along with the rest of the preset.
+
+  **The sun and the moon are in the sky**, and their positions are honest:
+  the disc is the light's own direction projected through the same matrix the
+  geometry is drawn with, so it stands over the point on the horizon its
+  shadows point away from, at every pitch, window shape and zoom. The sun's
+  noon is this mod's existing sun to the digit -- southeast, 45 degrees up,
+  overhead behind the north-facing camera and correctly out of frame -- and
+  its arc swings north at both ends, so the disc stands IN frame through dawn
+  and dusk, rising half-set on the horizon. The moon arcs the northern sky
+  all night, due north (screen centre) at mid-night, with scaled crater
+  cells. Both are cell art on the sky's own dither grid, sized by the frame
+  (a celestial body's apparent size is an angle, so zooming the ground does
+  not swell it), and both are SCISSORED to the sky's region: the horizon
+  point is where a setting body disappears -- it never hangs under the map.
+
+  **The sky follows the clock.** Phase palettes -- the daytime blues,
+  gold-to-violet dawn, a hotter gold-to-indigo dusk, moonlit navy -- six
+  bands each now, blended along the dial and re-quantised onto the 5-bit
+  lattice, so every mixed frame is still a colour the hardware could show.
+  The blends bend through designed WAYPOINTS rather than straight across --
+  a golden hour on the way into dusk, a violet civil twilight either side of
+  the night -- because day's blue and dusk's gold are near-complements, and
+  a straight lerp between complements bottoms out in dishwater grey. Through the twilights a posterised, checker-dithered GLOW
+  warms the bands around the low sun -- painted light, not an airbrush. The
+  blends are 75 seconds wide either side of each twilight and the pins land
+  on their phase palette unmixed.
+
+  **The shadows follow the sun and the moon.** The shear every shadow is
+  thrown by (direction opposite the body's bearing, length its elevation's
+  cotangent, clamped at twice the caster's height) comes off the clock, the
+  shadow map's signature carries it, and the light's press fades out over the
+  last twelve degrees before the horizon -- so sunset hands off to moonrise
+  through a soft shadowless gap, and moonlight presses at about two-thirds
+  the sun's weight. The scene shader also multiplies every surface by the
+  hour's tint: neutral at noon, warm at the twilights, dim blue at night.
+
+  **Outdoors only**, by the same `Map.isOutdoor` test the sky already rests
+  on: indoors keeps the noon rig, the neutral tint and no sky -- a cave at
+  midnight is exactly as dark as a cave at noon. A battle staged on an
+  outdoor map fights under the hour: the night sky behind the arena, the
+  tint on the mons, the sunset taking the arena's shadows with it; an indoor
+  arena is untouched. The engine's own `world.tod` hook is answered
+  (`MORNING`/`DAY`/`EVENING`/`NIGHT`), so palette or music packs keyed to the
+  period ride this clock for free.
+
+  **The clock rides the save slot.** On the engine's `save.writing` event the
+  cycle's time is written into the mod's own save-file bucket
+  (`save.modData.DRAMATIC_SHAPE`), and read back when a save is opened. A
+  save with no clock in it starts at day.
+
 - **A fade out of a battle, where there used to be a hard cut.** The engine
   wipes INTO a fight with one of the original's eight transitions and cuts
   straight out of it: `BattleState:finish` pops itself and the map is simply
diff --git a/README.md b/README.md
index 695444a..e81bc3e 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ menu.
 | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
 | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
 | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
+| the **DAYTIME** options row | DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors |
 
 **3D-BTL** is on by default and is independent of **VOXEL**: battles draw
 on the world whether or not the free-roam camera is pitched over.
@@ -58,11 +59,56 @@ everything else here, so GRAY gets four greys and CLASSIC four greens.
 Overworld only — a battle's placed camera looks up from under the horizon and
 keeps its flat sky.
 
-Tunables live at the top of `lib/Sky.lua`: `Sky.PALETTE` (lightest first; its
-length is the band count), `Sky.DITHER`, `Sky.DITHER_START` (how far down each
-band the checker begins — lower is a wider blend, `1` switches it off), and
-`Sky.SPAN` (how much of the frame the bands cover on the rungs whose horizon is
-off-screen).
+Tunables live at the top of `lib/Sky.lua` — `Sky.DITHER`, `Sky.DITHER_START`
+(how far down each band the checker begins — lower is a wider blend, `1`
+switches it off), `Sky.SPAN` (how much of the frame the bands cover on the
+rungs whose horizon is off-screen), and `Sky.DISC_FRAC` (the sun and moon's
+size, as a fraction of the frame) — and the palettes live in `lib/DayNight.lua`
+(`DayNight.PALETTES`, one per phase, lightest first), because the sky belongs
+to the clock:
+
+## What time it is
+
+The **DAYTIME** row is one twenty-minute clock — ten minutes of sun, ten of
+moon. `DAY`, `NIGHT`, `DUSK` and `DAWN` are pins on that dial (noon,
+mid-night, sunset, sunrise) and `CYCLE` lets it run, picking up from
+whichever pin was showing. Everything is a pure function of the clock, so
+the pinned DUSK is exactly the running cycle stopped at sunset. Setting
+**VOXEL** to `FULL` switches DAYTIME to `CYCLE` with the rest of the preset.
+
+The **sun and moon hang in the sky**, projected through the same matrix the
+geometry is drawn with, so each stands over the point on the horizon its
+shadows point away from at any pitch, window shape or zoom. Noon is this
+mod's existing sun to the digit — southeast, 45° up, overhead behind the
+north-facing camera and correctly out of frame — and the arc swings north at
+both ends, so the disc stands in frame through dawn and dusk, half-set on
+the horizon line. The moon walks the northern sky all night, due north at
+mid-night. Both are cell art on the sky's own grid, sized by the frame, and
+scissored to the sky's region: a setting body slips below the horizon point
+and is gone — never under the map.
+
+The **sky, the shadows and the light follow**. Six-band phase palettes
+blend along the dial and re-quantise onto the 5-bit lattice — the evening
+bending through a golden-hour waypoint and both edges of the night through
+a violet civil twilight, because a straight lerp between complements
+bottoms out in grey — and a dithered, posterised glow warms the bands
+around the low sun through the twilights.
+The shadow shear comes off the clock (opposite the body's bearing,
+cotangent-long, clamped at twice the caster's height), fading out over the
+last twelve degrees before the horizon so sunset hands off to moonrise
+through a soft shadowless gap; moonlight presses at about two-thirds the
+sun's weight. The scene shader multiplies every surface by the hour's tint —
+neutral at noon, warm at the twilights, dim blue under the moon.
+
+**Outdoors only.** Indoors keeps the noon rig, the neutral tint and no sky:
+a cave at midnight is exactly as dark as a cave at noon. A battle staged on
+an outdoor map fights under the hour — night void behind the arena, tinted
+mons, sunset taking the arena's shadows with it — and an indoor arena is
+untouched. The engine's `world.tod` hook is answered
+(`MORNING`/`DAY`/`EVENING`/`NIGHT`), so palette or music packs keyed to the
+period ride this clock for free. In `CYCLE`, the time is written into the
+mod's own bucket in the save file when the game saves, and read back when
+the save is opened; a save with no clock in it starts at day.
 
 While a battle can be staged on the map — **3D-BTL** on, or **VOXEL** on
 `FULL`, which owns that row — the engine's **BATTLE LAYOUT** row is set to
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index 50d8f3e..5acc21b 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -41,7 +41,9 @@ local VoxelScene = V.require("VoxelScene")
 local BattleCam = V.require("BattleCam")
 local BattleBillboard = V.require("BattleBillboard")
 local VoxelGrid = V.require("VoxelGrid")
+local DayNight = V.require("DayNight")
 local PaletteFX = require("src.render.PaletteFX")
+local Map = require("src.world.Map")
 
 local BattleScene = {}
 
@@ -215,7 +217,11 @@ BattleScene.monCards = monCards
 local function shadowSignature(state, arena, terrain, nbMesh, token)
   local host = arena.map or state.map
   local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
-                  tostring(terrain), tostring(token or 0) }
+                  tostring(terrain), tostring(token or 0),
+                  -- the cycle keeps running through a fight, and an arena lit
+                  -- from somewhere new must be re-cast from there
+                  math.floor(ShadowMap.KX * 128),
+                  math.floor(ShadowMap.KZ * 128) }
   for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
   return table.concat(parts, ",")
 end
@@ -298,6 +304,13 @@ function BattleScene.render(state, arena, textures, token)
   local host = arena.map or state.map
   local neighbors = (host == state.map) and (state.neighbors or {}) or {}
 
+  -- the hour's light reaches the arena exactly as it reaches free-roam: the
+  -- shared rig follows the clock on an outdoor floor and stays at noon on an
+  -- indoor one, and the same tint multiplies the staged shot
+  local outdoor = host.def and Map.isOutdoor(host.def) or false
+  DayNight.applyRig(outdoor)
+  Voxel3D.tint = DayNight.tint(outdoor)
+
   -- shares the free-roam mode's request/evict bookkeeping, so a battle warms
   -- exactly the meshes walking around would have and nothing extra
   local terrain, nbMesh = prefetchArena(state, host)
@@ -342,9 +355,12 @@ function BattleScene.render(state, arena, textures, token)
 
   Voxel3D.camera = cam
   -- the sun is turned up for the arena and put back afterwards, so the
-  -- free-roam world it shares this module with keeps its own weight
+  -- free-roam world it shares this module with keeps its own weight -- and
+  -- the hour still has the last word: a sunset fades the arena's shadows
+  -- out and the moon presses more softly, exactly as it does outside
   local sunWas = Voxel3D.SHADOW_ALPHA
   Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
+                         * DayNight.shadowScale(outdoor)
   -- and the wireframe is ON for a battle whatever the V-GRID row says. The
   -- arena is a staged shot rather than the world being walked through, and
   -- the seams are what make it read as built rather than photographed. Forced
diff --git a/lib/DayNight.lua b/lib/DayNight.lua
new file mode 100644
index 0000000..b327eaa
--- /dev/null
+++ b/lib/DayNight.lua
@@ -0,0 +1,437 @@
+-- Voxel world mode: the day/night cycle -- one clock, and everything the
+-- frame asks it.
+--
+-- THE CLOCK is twenty minutes around: ten of day, ten of night. The DAYTIME
+-- row either PINS it -- DAY, NIGHT, DUSK and DAWN are fixed times on that
+-- dial, not separate looks -- or lets it run (CYCLE), in which case the pin
+-- the player left is where the cycle picks up. Everything below is a pure
+-- function of the clock, so the pinned settings and the running cycle can
+-- never drift apart: DUSK is simply the cycle stopped at sunset.
+--
+-- THE SUN's noon is this mod's existing sun, exactly: shear (-0.85, -0.55),
+-- hanging in the southeast about 45 degrees up. That is the DAY setting and
+-- the default, so a player who never touches the row sees the mod they
+-- already had. From there the arc swings NORTH at both ends -- rising 70
+-- degrees north of east, setting the mirror of that -- because the camera
+-- looks north and the northern sky is the only sky it ever frames: a sun
+-- that rose due east would light the world for ten minutes without once
+-- being seen. Swung north, the disc stands in frame through dawn and dusk
+-- (the hours worth looking at) and passes overhead-behind-the-camera
+-- through midday, which is where a noon sun belongs.
+--
+-- THE MOON arcs entirely through the northern sky -- rising northeast, due
+-- north at mid-night, setting northwest -- so it hangs over the diorama all
+-- night and the pinned NIGHT setting puts it dead centre. Its shadows fall
+-- softly south, away from it, at about two-thirds the sun's weight.
+--
+-- SHADOWS are the shear the light throws: direction opposite the body's
+-- bearing, length its elevation's cotangent (clamped -- a rising sun throws
+-- a long shadow, not an infinite one), strength fading to nothing over the
+-- last twelve degrees before the horizon so the handoff between sun and
+-- moon is a soft gap rather than a snap. Face shading (Voxel3D.FACE_SHADE)
+-- deliberately stays the noon bake: it is a subtle angle term baked into
+-- every mesh, and rebaking the world's geometry per phase buys less than
+-- the cast shadows, the sky and the tint already say.
+--
+-- OUTDOOR ONLY. Indoors keeps the noon rig, the untinted world and no sky:
+-- a cave at midnight is exactly as dark as a cave at noon, which is what a
+-- room with no windows looks like. Map.isOutdoor is the same test the sky
+-- already rests on; the caller passes its answer in (applyRig/tint).
+--
+-- Persistence: the running cycle's clock is written into the mod's own
+-- save-file bucket (save.modData.DRAMATIC_SHAPE, via mod.save) on the
+-- engine's save.writing event, and read back on save.loaded/created. A save
+-- with no clock in it starts at noon.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local ModSetting = V.require("ModSetting")
+local PaletteFX = require("src.render.PaletteFX")
+
+local DayNight = {}
+
+-- ------- the dial
+
+DayNight.CYCLE = 1200         -- seconds around the whole dial
+DayNight.DAY_LEN = 600        -- the sun's half; the moon has the rest
+DayNight.BLEND = 75           -- seconds of palette blend either side of a twilight
+
+-- where the pinned settings stop the clock
+DayNight.T = { dawn = 0, day = 300, dusk = 600, night = 900 }
+
+DayNight.KEY = "daytime"
+DayNight.LABEL = "DAYTIME"
+
+-- "day" first: an unset or unreadable value is DAY, per the row's contract
+-- (ModSetting values[1] is the default)
+DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL,
+                                  { "day", "night", "dusk", "dawn", "cycle" },
+                                  { "DAY", "NIGHT", "DUSK", "DAWN", "CYCLE" })
+
+DayNight.clock = DayNight.T.day     -- the running cycle's own position
+
+-- ------- the two arcs
+--
+-- Bearings in DEGREES from east toward south (the world's +X is east, +Z
+-- south), elevations in degrees up from the ground plane.
+
+-- noon IS the existing sun: shear (-0.85, -0.55) hangs it at
+-- atan2(0.55, 0.85) south of east, atan(1/hypot) = 44.65 degrees up
+local NOON_KX, NOON_KZ = -0.85, -0.55
+local TH_NOON = math.deg(math.atan2(-NOON_KZ, -NOON_KX))
+local EL_NOON = math.deg(math.atan(1 / math.sqrt(NOON_KX * NOON_KX
+                                                 + NOON_KZ * NOON_KZ)))
+
+local TH_RISE, TH_SET = -70, 250            -- north of east / north of west
+local TH_MRISE, TH_MMID, TH_MSET = -20, -90, -160
+local EL_MOON = 40
+
+DayNight.K_MAX = 2.0          -- shear clamp: a shadow at most twice its height
+DayNight.ALPHA_SUN = 0.40     -- the existing midday shadow weight
+DayNight.ALPHA_MOON = 0.26    -- moonlight is a softer press
+DayNight.FADE_DEG = 12        -- shadows fade out over the last degrees of a rise/set
+
+-- disc PLACEMENT only: the true elevation would put the noon sun far above
+-- any frame, so the arc the discs ride is squashed toward the horizon. The
+-- shadows always use the true elevation.
+DayNight.ELEV_SQUASH = 0.14
+
+-- three-point arc: a at s=0, b at s=0.5, c at s=1
+local function arc(a, b, c, s)
+  if s < 0.5 then return a + (b - a) * 2 * s end
+  return b + (c - b) * (2 * s - 1)
+end
+
+-- The body lighting the world at clock `t`: bearing and elevation in
+-- degrees, and whether it is the moon. The t == DAY_LEN boundary belongs to
+-- the SUN, so the pinned DUSK setting is the sun half-set in the northwest,
+-- not the moon rising.
+function DayNight.bodyAt(t)
+  t = t % DayNight.CYCLE
+  if t <= DayNight.DAY_LEN then
+    local s = t / DayNight.DAY_LEN
+    return arc(TH_RISE, TH_NOON, TH_SET, s),
+           EL_NOON * math.sin(math.pi * s), false
+  end
+  local s = (t - DayNight.DAY_LEN) / (DayNight.CYCLE - DayNight.DAY_LEN)
+  return arc(TH_MRISE, TH_MMID, TH_MSET, s),
+         EL_MOON * math.sin(math.pi * s), true
+end
+
+-- The shadow shear that body throws: drift per pixel of height, opposite
+-- the bearing, cot(elevation) long, clamped.
+function DayNight.shearAt(t)
+  local th, el, moon = DayNight.bodyAt(t)
+  if el < 0.5 then el = 0.5 end
+  local k = math.min(DayNight.K_MAX, 1 / math.tan(math.rad(el)))
+  return -math.cos(math.rad(th)) * k, -math.sin(math.rad(th)) * k, moon
+end
+
+-- How much shadow the light can press right now, 0..1 of the body's own
+-- weight: full up high, gone at the horizon, so sunset hands off to
+-- moonrise through a soft shadowless gap instead of snapping.
+function DayNight.strengthAt(t)
+  local _, el = DayNight.bodyAt(t)
+  local s = el / DayNight.FADE_DEG
+  if s < 0 then return 0 end
+  return s < 1 and s or 1
+end
+
+-- ------- the palettes
+--
+-- Sky bands, lightest FIRST (the horizon end), exactly the shape Sky.bands
+-- reads. Six bands, not four: twilight is the whole show here, and six rungs
+-- of it is what keeps a sunset reading as a gradient rather than as stripes.
+-- Every channel is a multiple of 8 -- the 5-bit GBC lattice -- including
+-- after blending, which re-quantises onto it.
+-- `golden` and `violet` are not pins -- they are WAYPOINTS the blends pass
+-- through. Day's blue horizon and dusk's gold one are near-complements, and
+-- a straight lerp between complements bottoms out in grey: mid-transition
+-- the whole sky went the colour of dishwater, and gold-to-navy did the same
+-- on the far side of sunset. So the evening bends through a golden hour
+-- (horizon warming, zenith still blue -- late afternoon), and both edges of
+-- the night bend through a violet civil twilight (rose horizon under a
+-- violet sky -- the real colour of that half hour).
+DayNight.PALETTES = {
+  day = { { 184, 216, 248 }, { 144, 192, 248 }, { 104, 160, 240 },
+          { 72, 128, 224 }, { 48, 96, 200 }, { 40, 72, 168 } },
+  golden = { { 248, 216, 144 }, { 232, 184, 136 }, { 176, 152, 168 },
+             { 120, 128, 192 }, { 80, 104, 184 }, { 56, 80, 152 } },
+  dawn = { { 248, 216, 152 }, { 248, 176, 136 }, { 232, 136, 144 },
+           { 176, 104, 168 }, { 112, 80, 168 }, { 64, 64, 136 } },
+  dusk = { { 248, 200, 112 }, { 248, 152, 96 }, { 232, 104, 96 },
+           { 184, 80, 136 }, { 120, 64, 152 }, { 56, 48, 120 } },
+  violet = { { 200, 136, 160 }, { 152, 104, 160 }, { 112, 80, 152 },
+             { 72, 56, 128 }, { 40, 40, 96 }, { 16, 24, 64 } },
+  night = { { 88, 104, 160 }, { 64, 80, 136 }, { 48, 56, 112 },
+            { 32, 40, 88 }, { 16, 24, 64 }, { 8, 8, 40 } },
+}
+
+-- what the world's own colours are multiplied by, per phase (0..255)
+DayNight.TINTS = {
+  day = { 255, 255, 255 },
+  golden = { 255, 232, 208 },
+  dawn = { 255, 216, 192 },
+  dusk = { 255, 192, 168 },
+  violet = { 184, 160, 200 },
+  night = { 120, 136, 192 },
+}
+
+-- the twilight glow around the low sun, and the discs' own four-shade
+-- palettes (lightest first, so a display mode transforms them like any
+-- other palette)
+DayNight.GLOWS = { dawn = { 248, 232, 176 }, dusk = { 248, 224, 168 } }
+DayNight.SUN_COLORS = { { 248, 240, 200 }, { 248, 208, 96 },
+                        { 248, 144, 80 }, { 216, 96, 64 } }
+DayNight.MOON_COLORS = { { 240, 244, 248 }, { 224, 232, 240 },
+                         { 168, 184, 208 }, { 120, 136, 168 } }
+
+-- The dial as keyframes: a repeated name is a plateau, a change is a
+-- BLEND-wide ramp. Laid out so DUSK and DAWN proper land exactly on their
+-- pinned times, and so the evening approaches dusk THROUGH the golden-hour
+-- waypoint rather than straight across the grey between blue and gold. The
+-- morning side needs no waypoint of its own: dawn's pinks into day's blues
+-- share a family and blend clean.
+local DIAL
+local function dial()
+  if DIAL then return DIAL end
+  local B, D, C = DayNight.BLEND, DayNight.DAY_LEN, DayNight.CYCLE
+  DIAL = {
+    { 0, "dawn" }, { B, "day" },
+    { D - 2 * B, "day" }, { D - B, "golden" }, { D, "dusk" },
+    { D + B / 2, "violet" }, { D + B, "night" },
+    { C - B, "night" }, { C - B / 2, "violet" }, { C, "dawn" },
+  }
+  return DIAL
+end
+
+-- Phase weights at clock `t`, off the dial above.
+function DayNight.mix(t)
+  t = t % DayNight.CYCLE
+  local d = dial()
+  for i = 1, #d - 1 do
+    local a, b = d[i], d[i + 1]
+    if t >= a[1] and t < b[1] then
+      if a[2] == b[2] then return { [a[2]] = 1 } end
+      local u = (t - a[1]) / (b[1] - a[1])
+      return { [a[2]] = 1 - u, [b[2]] = u }
+    end
+  end
+  return { dawn = 1 }
+end
+
+-- back onto the 5-bit lattice after any blend
+local function q8(v)
+  v = math.floor(v / 8 + 0.5) * 8
+  if v < 0 then return 0 end
+  return v > 248 and 248 or v
+end
+
+local function blend3(key, mix, fallback)
+  local r, g, b = 0, 0, 0
+  for name, w in pairs(mix) do
+    local c = key[name] or fallback
+    r = r + c[1] * w
+    g = g + c[2] * w
+    b = b + c[3] * w
+  end
+  return { q8(r), q8(g), q8(b) }
+end
+
+-- The sky palette for clock `t`, blended between the phase palettes and
+-- re-quantised to the lattice. Memoised per whole second: the answer only
+-- moves as the cycle runs, and the cycle moves it slowly.
+local palCache = { key = nil, pal = nil }
+
+function DayNight.palette(t)
+  t = t or DayNight.time()
+  local key = math.floor(t % DayNight.CYCLE)
+  if palCache.key == key then return palCache.pal end
+  local mix = DayNight.mix(t)
+  local pal = {}
+  for i = 1, #DayNight.PALETTES.day do
+    local r, g, b = 0, 0, 0
+    for name, w in pairs(mix) do
+      local c = DayNight.PALETTES[name][i]
+      r = r + c[1] * w
+      g = g + c[2] * w
+      b = b + c[3] * w
+    end
+    pal[i] = { q8(r), q8(g), q8(b) }
+  end
+  palCache.key, palCache.pal = key, pal
+  return pal
+end
+
+-- The world tint for clock `t`, {r, g, b} in 0..1. Neutral indoors -- the
+-- caller answers for where it is standing (see the header).
+local tintCache = { key = nil, tint = nil }
+local NEUTRAL = { 1, 1, 1 }
+
+function DayNight.tint(outdoor, t)
+  if not outdoor then return NEUTRAL end
+  t = t or DayNight.time()
+  local key = math.floor(t % DayNight.CYCLE)
+  if tintCache.key ~= key then
+    -- NOT re-quantised: this is a light level the shader multiplies by, not
+    -- a palette colour, and the lattice's 248 ceiling would make even noon
+    -- fractionally dim
+    local mix = DayNight.mix(t)
+    local r, g, b = 0, 0, 0
+    for name, w in pairs(mix) do
+      local c = DayNight.TINTS[name] or DayNight.TINTS.day
+      r = r + c[1] * w
+      g = g + c[2] * w
+      b = b + c[3] * w
+    end
+    tintCache.key = key
+    tintCache.tint = { r / 255, g / 255, b / 255 }
+  end
+  return tintCache.tint
+end
+
+-- The twilight glow: how strongly (0..1) and in what colour the sky warms
+-- around the low sun. Only the SUN glows -- a moonrise is silver, not gold.
+function DayNight.glow(t)
+  t = t or DayNight.time()
+  local _, _, moon = DayNight.bodyAt(t)
+  if moon then return 0, nil end
+  local mix = DayNight.mix(t)
+  local amt = (mix.dawn or 0) + (mix.dusk or 0)
+  if amt <= 0 then return 0, nil end
+  return amt, blend3(DayNight.GLOWS, mix, DayNight.GLOWS.dusk)
+end
+
+-- ------- the clock itself
+
+local lastMode = nil
+
+local function mode()
+  return DayNight.setting:get() or "day"
+end
+
+-- The effective time: the pin, or the running clock under CYCLE.
+function DayNight.time()
+  local m = mode()
+  if m == "cycle" then return DayNight.clock end
+  return DayNight.T[m] or DayNight.T.day
+end
+
+-- Advance the cycle. Runs every frame from the voxel pipeline's update hook
+-- (which ticks through battles and menus too, so night falls during a long
+-- fight exactly as it does on a walk). Stepping ONTO cycle picks up from
+-- the pin the player was just looking at: DUSK then CYCLE rolls on into
+-- night rather than teleporting the sky.
+function DayNight.update(dt)
+  local m = mode()
+  if m ~= lastMode then
+    if m == "cycle" and DayNight.T[lastMode] then
+      DayNight.clock = DayNight.T[lastMode]
+    end
+    lastMode = m
+  end
+  if m == "cycle" and dt and dt > 0 then
+    DayNight.clock = (DayNight.clock + dt) % DayNight.CYCLE
+  end
+end
+
+-- The clock the RIG runs on: quantised, so the shadow map redraws a few
+-- times a minute as the sun crawls rather than every frame.
+DayNight.STEP = 2
+
+function DayNight.rigTime()
+  local t = DayNight.time()
+  return math.floor(t / DayNight.STEP) * DayNight.STEP
+end
+
+-- ------- what the frame reads
+
+-- Point the shared light rig at the clock -- or at noon, indoors. This
+-- writes the same fields everything already reads (ShadowMap.KX/KZ for the
+-- sun pass and its frustum, Voxel3D.SHADOW_* for the decal fallback and the
+-- sunDark uniform), so no draw path changes to follow the sun; they follow
+-- the rig, and the rig follows the clock.
+function DayNight.applyRig(outdoor)
+  local ShadowMap = V.require("ShadowMap")
+  local Voxel3D = V.require("Voxel3D")
+  local t = outdoor and DayNight.rigTime() or DayNight.T.day
+  local kx, kz, moon = DayNight.shearAt(t)
+  ShadowMap.KX, ShadowMap.KZ = kx, kz
+  Voxel3D.SHADOW_KX, Voxel3D.SHADOW_KZ = kx, kz
+  local base = moon and DayNight.ALPHA_MOON or DayNight.ALPHA_SUN
+  Voxel3D.SHADOW_ALPHA = base * DayNight.strengthAt(t)
+  return t
+end
+
+-- How much of a pass's OWN shadow weight the hour leaves it, 0..1 -- for a
+-- caller that sets its own alpha (the battle arena) and should still lose
+-- its shadows to a sunset.
+function DayNight.shadowScale(outdoor, t)
+  if not outdoor then return 1 end
+  t = t or DayNight.rigTime()
+  local _, _, moon = DayNight.bodyAt(t)
+  local s = DayNight.strengthAt(t)
+  return moon and s * (DayNight.ALPHA_MOON / DayNight.ALPHA_SUN) or s
+end
+
+-- The disc to hang in the sky, or nil when the body is set or behind the
+-- camera's half of the sky. Returns a direction for the PLACEMENT arc --
+-- true bearing, squashed elevation (see ELEV_SQUASH) -- plus which body it
+-- is; the caller projects it through its own camera.
+function DayNight.body(t)
+  t = t or DayNight.time()
+  local th, el, moon = DayNight.bodyAt(t)
+  if el < -2 then return nil end
+  local e = math.rad(el * DayNight.ELEV_SQUASH)
+  local b = math.rad(th)
+  return {
+    dx = math.cos(b) * math.cos(e),
+    dy = math.sin(e),
+    dz = math.sin(b) * math.cos(e),
+    moon = moon,
+  }
+end
+
+-- The period name for the engine's world.tod hook (map.palette ctx.tod,
+-- music.select): the dominant phase, in the vocabulary day/night mods use.
+local TOD = { day = "DAY", golden = "DAY", night = "NIGHT",
+              violet = "NIGHT", dawn = "MORNING", dusk = "EVENING" }
+
+function DayNight.tod(t)
+  local mix = DayNight.mix(t or DayNight.time())
+  local best, bestW = "day", -1
+  for name, w in pairs(mix) do
+    if w > bestW then best, bestW = name, w end
+  end
+  return TOD[best] or "DAY"
+end
+
+-- ------- persistence
+--
+-- The clock rides the SAVE SLOT, not the options file: what time it is in
+-- Kanto is a fact about that journey, like where the player is standing.
+-- mod.save is the loader's per-mod bucket in save.modData, which persists
+-- with the slot on its own -- writing the value is all there is to do.
+
+DayNight.SAVE_KEY = "clock"
+
+function DayNight.store()
+  local saveApi = V.mod and V.mod.save
+  if not (saveApi and saveApi.set) then return end
+  pcall(saveApi.set, saveApi, DayNight.SAVE_KEY, DayNight.clock)
+end
+
+function DayNight.restore()
+  local saveApi = V.mod and V.mod.save
+  local stored = nil
+  if saveApi and saveApi.get then
+    local ok, got = pcall(saveApi.get, saveApi, DayNight.SAVE_KEY)
+    if ok then stored = got end
+  end
+  -- no time set: it is day (the requirement, verbatim)
+  DayNight.clock = type(stored) == "number"
+                   and stored % DayNight.CYCLE or DayNight.T.day
+end
+
+return DayNight
diff --git a/lib/Sky.lua b/lib/Sky.lua
index 600a51a..221e386 100644
--- a/lib/Sky.lua
+++ b/lib/Sky.lua
@@ -32,33 +32,33 @@
 -- PALETTE ORDER, which is easy to get wrong. Stored LIGHTEST FIRST, because that
 -- is shade order: a display mode transforms a four-colour palette by replacing it
 -- outright (PaletteFX.effectiveColors hands back GRAYS or CLASSIC), and those are
--- written light to dark. So the sky reads the list backwards -- shade 4 overhead,
--- shade 1 at the horizon -- and GRAY gets four greys the right way up for
+-- written light to dark. So the sky reads the list backwards -- deepest shade
+-- overhead, shade 1 at the horizon -- and GRAY gets greys the right way up for
 -- nothing.
+--
+-- WHAT TIME IT IS decides the colours. The palette itself lives in DayNight
+-- (four phase palettes, blended along the clock and re-quantised to the
+-- lattice), and this file paints whatever the clock says: blue at noon, gold
+-- and violet through the twilights -- warmed further around the low sun by a
+-- dithered GLOW -- and deep navy under the moon. The sun and moon themselves
+-- hang here too: cell-art discs on the same grid as the dither, scissored to
+-- the sky's own region so a setting body slips below the horizon point and is
+-- gone, never wandering under the map.
 
 -- the mod namespace (see main.lua): V.require loads a sibling module
 local V = ...
 
+local DayNight = V.require("DayNight")
 local PaletteFX = require("src.render.PaletteFX")
 
 local unpack = table.unpack or unpack
 
 local Sky = {}
 
--- Lightest first. Every channel is a multiple of 8, which is where a five-bit
--- GBC channel lands: these are colours the hardware could actually have shown,
--- not blues picked off a 24-bit colour wheel.
-Sky.PALETTE = {
-  { 144, 192, 248 },   -- shade 1: pale, at the horizon
-  { 104, 160, 240 },
-  { 72, 128, 224 },
-  { 48, 96, 200 },     -- shade 4: deep, overhead
-}
-
--- The shader carries a fixed-size array, because a GLSL uniform array is a fixed
--- size; four is also what a display mode has to give (it substitutes a palette),
--- so this is the ceiling on the palette above rather than an arbitrary cap.
-Sky.MAX_BANDS = 4
+-- The shader carries a fixed-size array, because a GLSL uniform array is a
+-- fixed size; eight leaves headroom over DayNight's six-band phase palettes
+-- without paying for more.
+Sky.MAX_BANDS = 8
 
 -- The checkerboard between bands. DITHER_START is how far down a band it begins,
 -- as a fraction of that band: lower is a wider blend, and 1 switches it off. 0.6
@@ -87,8 +87,9 @@ Sky.SPAN = 0.23
 local cache = { bands = nil, key = {} }
 
 function Sky.bands()
-  local shades = PaletteFX.effectiveColors(Sky.PALETTE) or Sky.PALETTE
-  local n = math.min(#shades, #Sky.PALETTE, Sky.MAX_BANDS)
+  local pal = DayNight.palette()
+  local shades = PaletteFX.effectiveColors(pal) or pal
+  local n = math.min(#shades, #pal, Sky.MAX_BANDS)
   local key, k = cache.key, 0
   local same = cache.bands ~= nil and #cache.bands == n
   for i = 1, n do
@@ -111,6 +112,14 @@ function Sky.bands()
   return bands
 end
 
+-- The hour's haze -- the palest band, in 0..1 -- which is both the sky's
+-- bottom edge and the right flat fill for any outdoor void that wants to
+-- match the clock without painting bands (the battle arena's backdrop).
+function Sky.haze()
+  local bands = Sky.bands()
+  return bands and bands[#bands] or nil
+end
+
 -- Put the sky onto a flat descriptor: the bands to paint, plus the flat fill
 -- replaced by the palest of them. That fill is what the caller CLEARS to, so
 -- making it the bottom band's own colour means the haze below the sky and the
@@ -156,6 +165,10 @@ uniform float edge;     // the sky's bottom, in canvas pixels
 uniform float cell;     // the diorama's pixel size, in canvas pixels
 uniform float start;    // where the checker begins inside a band
 uniform float alpha;
+uniform float glowAmt;  // twilight warmth around the low sun; 0 = none
+uniform vec2 glowPos;   // the sun disc, in canvas pixels
+uniform float glowInvR; // 1 / the glow's reach
+uniform vec3 glowColor;
 
 // Indexed through a loop counter, which every GLSL ES compiler accepts for a
 // uniform array; a bare bands[idx] is not portable.
@@ -174,10 +187,22 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
   float base = floor(pos);
   int idx = int(base);
   vec3 c = bandAt(idx);
+  float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
   if (idx < count - 1 && (pos - base) > start) {
-    float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
     if (parity < 0.5) { c = bandAt(idx + 1); }
   }
+  // The sunset's warmth, radiating from the disc: posterised to a few rungs
+  // and checker-dithered between them -- the same 8-bit move as the bands,
+  // so the glow reads as painted light rather than as a smooth airbrush --
+  // and measured cell-to-cell, so its rings ride the diorama's own grid.
+  if (glowAmt > 0.0) {
+    vec2 cc = (floor(sc / cell) + 0.5) * cell;
+    float d = length(cc - glowPos) * glowInvR;
+    float g = glowAmt * pow(clamp(1.0 - d, 0.0, 1.0), 2.0);
+    float lvl = floor(g * 4.0);
+    if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; }
+    c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65);
+  }
   return vec4(c, alpha);
 }
 ]]
@@ -226,16 +251,88 @@ local function paintFlat(w, h, bands, edge, alpha, cell)
   end
 end
 
+-- ------- the discs
+--
+-- The sun and moon, as cell art: a circle of whole diorama cells with a
+-- lighter core, a dithered rim, and -- for the moon -- a few fixed crater
+-- cells. Drawn as plain rectangles on the same grid as the sky's own dither,
+-- through the same display-mode transform as every palette here, and
+-- SCISSORED to the sky's region: the horizon point is where a setting body
+-- disappears, so it can never hang under the map at a high pitch.
+--
+-- SIZED BY THE FRAME, not by the world: a celestial body's apparent size is
+-- an angle, so zooming the ground in and out must not swell and shrink the
+-- sun with it. The radius is a fraction of the frame height, converted to
+-- whole cells so the disc still sits on the diorama's grid -- chunky cells
+-- up close, fine ones at survey zoom, the same size body either way.
+Sky.DISC_FRAC = 0.030     -- disc radius, as a fraction of the frame height
+Sky.DISC_MIN = 3          -- but never fewer cells than this across a radius
+
+-- crater centres as fractions of the radius, so they ride any disc size
+local MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 },
+                       { -0.15, 0.7 }, { 0.05, 0.05 } }
+
+local function paintDisc(body, edge, cell, w, h)
+  local g = love.graphics
+  if not (body and body.y and g.setScissor) then return end
+  local src = body.moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS
+  local shades = PaletteFX.effectiveColors(src) or src
+  local twilight = (body.glowAmt or 0) > 0.25 and not body.moon
+  local r = math.max(Sky.DISC_MIN,
+                     math.floor(h * Sky.DISC_FRAC / cell + 0.5))
+  -- the low sun looms: the classic sunset exaggeration, and it reads
+  if twilight then r = r + math.max(1, math.floor(r * 0.4)) end
+  -- snap the centre to the cell grid, like everything else in this sky
+  local bx = math.floor(body.x / cell) * cell + cell / 2
+  local by = math.floor(body.y / cell) * cell + cell / 2
+  if by - r * cell > edge then return end     -- wholly below the horizon point
+  local core = shades[1]
+  local main = shades[twilight and 3 or 2]
+  local sx, sy, sw, sh = g.getScissor()
+  g.setScissor(0, 0, math.ceil(w), math.floor(edge))
+  local craterR = math.max(1, math.floor(r / 5))
+  for dy = -r, r do
+    for dx = -r, r do
+      local d = math.sqrt(dx * dx + dy * dy)
+      if d <= r + 0.1 then
+        local c = d <= r * 0.5 and core or main
+        -- dithered rim: the outer ring keeps only one parity of its cells
+        local keep = d <= r - 0.9 or (dx + dy) % 2 == 0
+        if body.moon then
+          for _, cr in ipairs(MOON_CRATERS) do
+            local cdx = dx - math.floor(cr[1] * r + 0.5)
+            local cdy = dy - math.floor(cr[2] * r + 0.5)
+            if cdx * cdx + cdy * cdy <= craterR * craterR then
+              c = shades[3]
+            end
+          end
+        end
+        if keep then
+          g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
+          g.rectangle("fill", bx + dx * cell - cell / 2,
+                      by + dy * cell - cell / 2, cell, cell)
+        end
+      end
+    end
+  end
+  if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end
+  g.setColor(1, 1, 1, 1)
+end
+
 -- Paint the sky into the bound canvas, filling it from the top edge down to
 -- `horizonY` (or to SPAN of the frame when the horizon is out of it).
 --
 -- `cell` is the diorama's pixel size in canvas pixels -- the pass's own
 -- pixels-per-world-pixel, handed in every frame so a zoom lands immediately.
 --
+-- `body` is the sun or moon to hang, already projected to canvas pixels by
+-- the caller's own camera (Voxel3D.skyBody), with the twilight glow riding
+-- along; nil hangs nothing and warms nothing.
+--
 -- Returns false when there is nothing to paint, in which case the caller's flat
 -- fill is the whole sky. That fill is the palest band, so a frame that declines
 -- this looks like a hazy day rather than like a bug.
-function Sky.paint(w, h, sky, horizonY, cell)
+function Sky.paint(w, h, sky, horizonY, cell, body)
   local bands = sky and sky.bands
   if not (bands and bands[1]) then return false end
   if not (w and h and w > 0 and h > 0) then return false end
@@ -259,6 +356,7 @@ function Sky.paint(w, h, sky, horizonY, cell)
   if g.getBlendMode then blend, blendAlpha = g.getBlendMode() end
   if g.setBlendMode then g.setBlendMode("alpha") end
 
+  local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
   local sh = getShader()
   if sh then
     local sent = pcall(function()
@@ -271,6 +369,13 @@ function Sky.paint(w, h, sky, horizonY, cell)
       sh:send("cell", cell)
       sh:send("start", Sky.DITHER and Sky.DITHER_START or 2)
       sh:send("alpha", alpha)
+      sh:send("glowAmt", glowAmt)
+      if glowAmt > 0 then
+        local gc = body.glowColor or { 248, 224, 168 }
+        sh:send("glowPos", { body.x, body.y })
+        sh:send("glowInvR", 1 / math.max(1, w * 0.55))
+        sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 })
+      end
     end)
     if sent then
       g.setShader(sh)
@@ -282,6 +387,9 @@ function Sky.paint(w, h, sky, horizonY, cell)
     end
   end
   if not sh then paintFlat(w, h, bands, edge, alpha, cell) end
+  -- the disc goes over the glow, under nothing: plain rectangles, so it is
+  -- there whether or not the shader built
+  paintDisc(body, math.min(h, edge), cell, w, h)
   g.setColor(1, 1, 1, 1)
 
   if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index 61951ea..3ec50e4 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -31,6 +31,7 @@ local ShadowMap = V.require("ShadowMap")
 local VoxelGrid = V.require("VoxelGrid")
 local WorldCurve = V.require("WorldCurve")
 local Sky = V.require("Sky")
+local DayNight = V.require("DayNight")
 
 local Voxel3D = {}
 
@@ -201,6 +202,7 @@ local SHADER = [[
 
   uniform vec3 ghostColor;    // the flat silhouette colour
   uniform float ghost;        // 0 = shade normally, 1 = flatten to it
+  uniform vec3 dayTint;       // the hour's light on the world; 1,1,1 = noon
 
   vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
     vec4 p = Texel(tex, tc);
@@ -208,7 +210,9 @@ local SHADER = [[
     // blending keeps those texels out of the depth buffer, so a model never
     // carves a transparent hole out of whatever stands behind it
     if (p.a < 0.5) discard;
-    vec3 rgb = p.rgb * vShade * sunlight(vSun);
+    // the hour's tint multiplies like the sun terms do: it is LIGHT, the
+    // same warm or moonlit cast on every surface, not a palette swap
+    vec3 rgb = p.rgb * vShade * sunlight(vSun) * dayTint;
 #ifdef VOXEL_GRID
     // darken what is there rather than painting a colour, so a seam across
     // dark grass and one across a white roof each stay in their own palette
@@ -427,6 +431,43 @@ function Voxel3D.horizonY(h)
   return (y / w * 0.5 + 0.5) * h
 end
 
+-- ------- the hour's light
+--
+-- What the scene shader multiplies every surface by (see dayTint in the
+-- shader). Set per pass by whoever knows what map is being drawn --
+-- VoxelScene for free-roam, BattleScene for the arena -- because "is this
+-- outdoors" is the map's question, not this pass's. Neutral until somebody
+-- answers it, so a caller that never does draws exactly what it always drew.
+Voxel3D.tint = { 1, 1, 1 }
+
+-- The sun or moon disc's place on this camera's canvas, or nil when the
+-- body is set, on the southern half of the sky, or behind the camera.
+--
+-- The direction comes from DayNight (true bearing, squashed elevation) and
+-- goes through the SAME matrix the geometry is drawn with, as a point at
+-- infinity -- exactly how horizonY finds the vanishing line. So the disc's
+-- azimuth is honest: it stands over the point on the horizon its shadows
+-- point away from, at every pitch, fov, window shape and zoom.
+--
+-- Must run after beginScene has set Voxel3D.vp for this frame's camera.
+function Voxel3D.skyBody(w, h)
+  local m = Voxel3D.vp
+  local b = m and DayNight.body()
+  if not b then return nil end
+  local x = m[1] * b.dx + m[2] * b.dy + m[3] * b.dz
+  local y = m[5] * b.dx + m[6] * b.dy + m[7] * b.dz
+  local ww = m[13] * b.dx + m[14] * b.dy + m[15] * b.dz
+  if ww <= 1e-6 then return nil end
+  local amt, color = DayNight.glow()
+  return {
+    x = (x / ww * 0.5 + 0.5) * w,
+    y = (y / ww * 0.5 + 0.5) * h,
+    moon = b.moon,
+    glowAmt = amt,
+    glowColor = color,
+  }
+end
+
 -- ----------------------------------------------------------------- scene --
 
 -- Begin the 3D pass into a `w` x `h` pixel canvas centred on world
@@ -480,7 +521,10 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
     -- w / vw is this frame's pixels per WORLD pixel, which is the size a diorama
     -- pixel is on screen: the sky's dither grid is cut to that, so its squares
     -- are the same size as the world's own and follow every resize and zoom.
-    Sky.paint(w, h, sky, Voxel3D.horizonY(h), w / math.max(1, vw or w))
+    -- The banded sky also hangs the hour's sun or moon (skyBody projects it
+    -- through this very camera); a flat sky has no bands and hangs nothing.
+    Sky.paint(w, h, sky, Voxel3D.horizonY(h), w / math.max(1, vw or w),
+              sky.bands and Voxel3D.skyBody(w, h) or nil)
   else
     love.graphics.clear(0, 0, 0, 0, true, true)
   end
@@ -514,6 +558,8 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
   -- start out flattening everything it drew.
   pcall(sh.send, sh, "ghost", 0)
   pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
+  -- the hour's light, as the caller last set it (see Voxel3D.tint)
+  pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 })
   -- the curved world bends about the camera's focus, so the horizon keeps
   -- a fixed distance ahead of the player rather than sitting on the map.
   -- A placed camera may decline it outright (Voxel3D.camera.curve = 0).
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 9a1d783..b0178d7 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -21,6 +21,7 @@ local TileShape = V.require("TileShape")
 local TerrainAtlas = V.require("TerrainAtlas")
 local Voxel = V.require("VoxelState")
 local Sky = V.require("Sky")
+local DayNight = V.require("DayNight")
 local PaletteFX = require("src.render.PaletteFX")
 local Map = require("src.world.Map")
 
@@ -121,7 +122,14 @@ end
 function VoxelScene.skyColor(map, t)
   if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
   if not t or t <= 0 then return nil end
-  return VoxelScene.skyShade(SKY_SHADE, t)
+  local sky = VoxelScene.skyShade(SKY_SHADE, t)
+  -- outdoors the flat fill follows the CLOCK: it becomes the hour's haze --
+  -- gold at dusk, navy at night -- so a battle staged on the map at
+  -- midnight is under a midnight void, not a noon one. Free-roam is
+  -- unchanged by this: Sky.dress overwrites the fill with the same value.
+  local haze = Sky.haze()
+  if haze then sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3] end
+  return sky
 end
 
 -- The free-roam sky: the flat one above, dressed with the banded gradient
@@ -470,6 +478,12 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
   -- standing perfectly still
   put(vw); put(vh)
   put(math.floor((V.require("VoxelState").angle or 0) * 512))
+  -- the sun itself: the cycle swings the shear as the clock runs, and a map
+  -- lit from somewhere new must be redrawn from there too. Quantised by the
+  -- rig's own step (DayNight.rigTime), so a running cycle redraws the map a
+  -- few times a minute rather than every frame.
+  put(math.floor(ShadowMap.KX * 128))
+  put(math.floor(ShadowMap.KZ * 128))
   put(tostring(terrain))
   for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
   for _, p in ipairs(posed) do
@@ -547,6 +561,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
   local cam = state.camera
   local cx, cy = cam.x + vw / 2, cam.y + vh / 2
 
+  -- the hour's light, before anything is cast or drawn: point the shared
+  -- rig at the clock (or at noon, indoors -- a cave at midnight is exactly
+  -- as dark as a cave at noon) and set the tint the scene shader multiplies
+  -- every surface by
+  local outdoor = state.map.def and Map.isOutdoor(state.map.def) or false
+  DayNight.applyRig(outdoor)
+  Voxel3D.tint = DayNight.tint(outdoor)
+
   local function atlasFor(map)
     return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
   end
diff --git a/main.lua b/main.lua
index 274aec2..d48149e 100644
--- a/main.lua
+++ b/main.lua
@@ -79,6 +79,7 @@ local VoxelGrid = V.require("VoxelGrid")
 local WorldCurve = V.require("WorldCurve")
 local OverworldBattle = V.require("OverworldBattle")
 local BattleExit = V.require("BattleExit")
+local DayNight = V.require("DayNight")
 
 -- Forward declaration: the voxel pipeline's update hook (registered below)
 -- calls this, and it is defined further down with the settings it drives.
@@ -158,6 +159,11 @@ mod.content.render_pipelines:register("voxel", {
     -- would fight anyone who changed one deliberately.
     applyFull(level)
     Voxel.update(dt, level)
+    -- the day/night clock, on the same always-running tick: Pipelines.update
+    -- runs whatever the level, so time passes with the mode off, through
+    -- battles and menus, and a CYCLE evening falls mid-fight exactly as it
+    -- would mid-walk
+    DayNight.update(dt)
     -- The overworld battle rides this hook rather than owning a pipeline of
     -- its own, because it owns no pass of the FRAME: it draws under a battle
     -- screen the engine composites, which is not a stage the registry has.
@@ -287,6 +293,10 @@ applyFull = function(level)
   -- is solved against (OverworldBattle.forceOG); FULL has just switched staged
   -- fights on, so the layout follows them.
   OverworldBattle.forceOG(Game)
+  -- FULL is the whole diorama, and a diorama with a running sky is more of
+  -- one: the clock is set going (CYCLE is the ladder's last rung). Set, not
+  -- held, like everything else here -- the player can pin it back afterwards.
+  DayNight.setting:setIndex(#DayNight.setting.values, Game)
   if Game.writeOptions then pcall(Game.writeOptions, Game) end
 end
 
@@ -309,6 +319,10 @@ local SETTINGS = {
   { OverworldBattle.setting,
     "Fight on the map: the battle draws over the nearest clear ground, "
     .. "shot over the shoulder with a slow parallax drift." },
+  { DayNight.setting,
+    "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
+    .. "or let CYCLE run it -- ten minutes of sun, ten of moon, with the "
+    .. "shadows, the sky and the light following." },
 }
 
 local schema = {}
@@ -690,6 +704,37 @@ mod.content.transitions:register(BattleExit.ID, {
 
 BattleExit.install()
 
+-- ------- what time it is
+--
+-- The cycle's clock rides the SAVE SLOT (save.modData, via mod.save): what
+-- time it is in Kanto is a fact about that journey, like where the player is
+-- standing. Written on the engine's save.writing event -- the moment before
+-- the bytes hit disk -- and read back whenever a save is opened or begun. A
+-- save with no clock in it starts at day; that is DayNight.restore's
+-- fallback, and also the DAYTIME row's own default.
+mod.events:on("save.writing", function()
+  DayNight.store()
+end)
+
+mod.events:on("save.loaded", function()
+  DayNight.restore()
+end)
+
+mod.events:on("save.created", function()
+  DayNight.restore()
+end)
+
+-- The engine's own time-of-day seam. OverworldState:timeOfDay() is an
+-- eternal "DAY" until a mod answers here; answering it hands the period to
+-- the map.palette hook (ctx.tod) and music.select, so a palette or music
+-- pack keyed to night works with this mod's clock for free. next() first: a
+-- mod loaded before this one that already moved the time keeps its answer.
+mod.hooks:wrap("world.tod", function(next, tod, ctx)
+  local out = next(tod, ctx)
+  if out ~= tod then return out end
+  return DayNight.tod()
+end)
+
 mod.exports.version = "1.1.1"
 -- exposed so a companion mod can pin its own tiles' shapes or read the
 -- camera without reaching into this mod's file layout
diff --git a/tests/daynight_shots.lua b/tests/daynight_shots.lua
new file mode 100644
index 0000000..90e841e
--- /dev/null
+++ b/tests/daynight_shots.lua
@@ -0,0 +1,97 @@
+-- Driver: screenshot the day/night cycle.
+--
+-- One vantage (Pallet Town, VOXEL 75) through every pinned phase, two
+-- mid-blend moments of the running cycle, a battle staged under the night
+-- sky, and a room at midnight -- which must look exactly like a room at
+-- noon, because indoors the clock does not reach.
+--
+--   SHOT_DIR=.scratchpad/daynightcycle POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/daynight_shots.lua love .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local DIR = os.getenv("SHOT_DIR") or ".scratchpad/daynightcycle"
+  local Pokemon = require("src.pokemon.Pokemon")
+  local BattleState = require("src.battle.BattleState")
+
+  local exports = game.mods and game.mods.exports
+  local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
+  if not lib then
+    U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
+    return
+  end
+  local DayNight = lib.require("DayNight")
+
+  local function setTime(value, clock)
+    DayNight.setting:sync(value)
+    if clock then DayNight.clock = clock end
+    DayNight.update(0)
+  end
+
+  U.teleport(game, "PALLET_TOWN", 12, 10, "up")
+  -- straight to the top rung, whatever the persisted options say: pressing
+  -- the key would walk the ladder RELATIVE to wherever a previous run left it
+  local Pipelines = require("src.render.Pipelines")
+  Pipelines.setLevel("voxel", 5)
+  U.wait(150)                 -- let the camera ease over and the meshes land
+
+  -- ------- the four pins, one vantage
+  for _, phase in ipairs({ "day", "dawn", "dusk", "night" }) do
+    setTime(phase)
+    U.wait(30)                -- a couple of rig steps + palette settle
+    U.shot(game, ("%s/10_pin_%s.png"):format(DIR, phase))
+  end
+
+  -- ------- the running cycle, mid-blend
+  setTime("cycle", 560)       -- day leaning into dusk
+  U.wait(10)
+  U.shot(game, DIR .. "/20_cycle_day_into_dusk.png")
+  setTime("cycle", 645)       -- dusk falling into night
+  U.wait(10)
+  U.shot(game, DIR .. "/21_cycle_dusk_into_night.png")
+
+  -- ------- golden hour on open ground, long shadows
+  U.teleport(game, "ROUTE_1", 8, 12, "up")
+  U.wait(120)
+  setTime("cycle", 555)       -- low western sun, shadows long and eastward
+  U.wait(30)
+  U.shot(game, DIR .. "/30_route1_low_sun.png")
+
+  -- ------- a fight staged under the night sky
+  setTime("night")
+  game.save.party = game.save.party or {}
+  if #game.save.party == 0 then
+    game.save.party[1] = Pokemon.new(game.data, "CHARIZARD", 45)
+  end
+  -- any real trainer class: the dataset's ids vary by merge, so take the
+  -- first one in stable order rather than guessing a name
+  local classes = {}
+  for id, rec in pairs(game.data.trainers) do
+    if type(id) == "string" and id:sub(1, 1) ~= "_"
+       and type(rec) == "table" and rec.parties and rec.parties[1] then
+      classes[#classes + 1] = id
+    end
+  end
+  table.sort(classes)
+  local battle = classes[1] and BattleState.newTrainer(game, classes[1], 1)
+  if battle then
+    battle.onFinish = function() end
+    game.overworld:pushBattle(battle)
+    U.wait(70)
+    for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
+    U.shot(game, DIR .. "/40_night_battle.png")
+    while game.stack:top() and game.stack:top() ~= game.overworld do
+      game.stack:pop()
+    end
+    U.wait(10)
+  end
+
+  -- ------- and a room, where midnight must change nothing
+  setTime("night")
+  if game.data.maps.REDS_HOUSE_1F then
+    U.teleport(game, "REDS_HOUSE_1F", 4, 4, "down")
+    U.wait(90)
+    U.shot(game, DIR .. "/50_indoor_at_night.png")
+  end
+
+  setTime("day")
+  U.log("done -- " .. DIR)
+end
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index dd0a5ad..2b59176 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -293,8 +293,12 @@ end
 Pipelines.setLevel("voxel", 2)
 local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
                                { data = Data }, { { id = "text_speed" } })
-T.eq(#hookedRows, 4, "the options hook added a row per setting")
+T.eq(#hookedRows, 5, "the options hook added a row per setting")
 local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4]
+local daytime = hookedRows[5]
+T.eq(daytime.label, "DAYTIME", "the day/night row carries its label")
+T.eq(daytime.value(), "DAY",
+  "and starts pinned to DAY -- no time set means it is day")
 T.eq(grid.label, "V-GRID", "the grid row carries its label")
 T.eq(grid.value(), "OFF", "the grid starts off")
 T.eq(curve.label, "V-CURVE", "the curve row carries its label")
@@ -1129,21 +1133,25 @@ T.check(skyRGB("gbc_inv")[3] ~= blue[3],
 do
 local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky")
 local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
+local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight")
 
 local function luma(c) return 0.299 * c[1] + 0.587 * c[2] + 0.114 * c[3] end
 
--- the palette is the band list: add a colour and the sky gains a band
-for i, c in ipairs(Sky.PALETTE) do
+-- the palette is the band list, and it belongs to the CLOCK now: DayNight
+-- owns one per phase and blends between them. The row defaults to DAY, so
+-- what the sky paints here is the day palette -- still every inch a GBC one.
+local dayPal = DayNight.PALETTES.day
+for i, c in ipairs(dayPal) do
   T.check(c[1] % 8 == 0 and c[2] % 8 == 0 and c[3] % 8 == 0,
     "palette entry " .. i .. " is a colour a Game Boy Color could show -- five "
     .. "bits a channel, so every one is a multiple of 8")
   T.check(c[3] > c[1], "and it is blue: more blue than red")
 end
-T.eq(#Sky.PALETTE, 4, "four of them, which is one GBC background palette")
+T.eq(#dayPal, 6, "six of them: twilight needs the rungs, and day matches")
 
 Voxel.angle = TOP
 local skyGrad = skyRGB("gbc")
-T.eq(#(skyGrad.bands or {}), #Sky.PALETTE,
+T.eq(#(skyGrad.bands or {}), #dayPal,
   "the sky arrives with one band per palette entry")
 
 for i, band in ipairs(skyGrad.bands) do
@@ -1155,7 +1163,7 @@ for i, band in ipairs(skyGrad.bands) do
 end
 -- read backwards out of the palette, which is stored in shade order (lightest
 -- first) so a display mode's own four colours drop straight in
-local deepest = Sky.PALETTE[#Sky.PALETTE]
+local deepest = dayPal[#dayPal]
 T.check(math.abs(skyGrad.bands[1][1] - deepest[1] / 255) < 1e-9,
   "the top band is the palette's deep rung, unmixed")
 
@@ -1174,8 +1182,10 @@ T.eq(skyGrad[4], 1, "and the tween strength still rides on the descriptor")
 -- frame, so there would be no gradient to see from down there anyway.
 local flat = VoxelScene.skyColor(outside, 1)
 T.eq(flat.bands, nil, "a battle's arena sky is the flat fill, not the gradient")
-T.check(luma(flat) < luma(palest),
-  "and it is the ramp's own sky rung, unchanged: not the gradient's pale end")
+T.check(math.abs(flat[1] - palest[1]) < 1e-9
+        and math.abs(flat[3] - palest[3]) < 1e-9,
+  "and it is the hour's haze, so a staged fight stands under the same sky "
+  .. "free-roam does -- navy at midnight, gold at dusk")
 
 -- the same call, the same numbers, and the same TABLE: the bands are memoised
 -- per display mode, so a frame that paints the sky allocates nothing to do it
@@ -1902,6 +1912,200 @@ T.eq(Exit.veil(), nil, "and a fade popped from under itself veils nothing")
 Exit.modeOn = realModeOn
 end
 
+-- ------- the day/night cycle
+--
+-- One twenty-minute clock, and everything is a pure function of it: the
+-- pinned DAYTIME settings are fixed times on the dial, CYCLE lets it run,
+-- and the sun, the moon, the shadows, the sky and the tint all read the same
+-- number. What is checked here is the dial itself, the noon-exactness pledge
+-- (DAY is the mod's existing sun, to the digit), the arcs' visibility (the
+-- camera looks north, so the discs must actually cross the northern sky),
+-- and the clock's ride through the save file.
+do
+local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight")
+local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap")
+local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
+local Voxel = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
+
+-- the dial and its pins
+T.eq(DayNight.setting:get(), "day", "no time set means DAY: the default pin")
+T.eq(DayNight.time(), 300, "and DAY is noon on the dial")
+local PINS = { day = 300, night = 900, dusk = 600, dawn = 0 }
+for name, t in pairs(PINS) do
+  DayNight.setting:sync(name)
+  T.eq(DayNight.time(), t, name .. " pins the clock to " .. t)
+end
+
+-- CYCLE picks up from the pin the player was just looking at
+DayNight.setting:sync("dusk")
+DayNight.update(0)
+DayNight.setting:sync("cycle")
+DayNight.update(0)
+T.eq(DayNight.clock, 600, "stepping onto CYCLE picks up from the pin: dusk")
+DayNight.update(30)
+T.check(math.abs(DayNight.time() - 630) < 1e-9, "and the clock then runs")
+DayNight.clock = 1195
+DayNight.update(10)
+T.check(math.abs(DayNight.clock - 5) < 1e-9,
+  "the dial wraps at twenty minutes, back into dawn")
+
+-- the sun: noon is the mod's existing sun, exactly
+local kx, kz, moon = DayNight.shearAt(300)
+T.check(not moon, "noon is the sun's")
+T.check(math.abs(kx - (-0.85)) < 1e-9 and math.abs(kz - (-0.55)) < 1e-9,
+  ("DAY throws the shadows the mod always threw: (%.4f, %.4f)"):format(kx, kz))
+local kx0, kz0 = DayNight.shearAt(0)
+T.check(math.abs(math.sqrt(kx0 * kx0 + kz0 * kz0) - DayNight.K_MAX) < 1e-9,
+  "a rising sun throws a LONG shadow, clamped -- never an infinite one")
+T.eq(DayNight.strengthAt(0), 0, "and at the horizon it presses nothing")
+T.eq(DayNight.strengthAt(300), 1, "at noon it presses in full")
+
+-- the moon: due north at mid-night, pressing softly south
+local mkx, mkz, mmoon = DayNight.shearAt(900)
+T.check(mmoon, "mid-night is the moon's")
+T.check(math.abs(mkx) < 1e-9, "due north: no east-west drift at all")
+T.check(math.abs(mkz - 1 / math.tan(math.rad(40))) < 1e-9,
+  "shadows fall south, away from it, cot(40) long")
+
+-- the palettes: pins land on their phase palette unmixed, blends stay on
+-- the 5-bit lattice
+local function palEq(a, b)
+  for i = 1, #a do
+    for ch = 1, 3 do if a[i][ch] ~= b[i][ch] then return false end end
+  end
+  return #a == #b
+end
+T.check(palEq(DayNight.palette(300), DayNight.PALETTES.day),
+  "noon paints the day palette, unmixed")
+T.check(palEq(DayNight.palette(600), DayNight.PALETTES.dusk),
+  "the dusk pin paints dusk proper -- the blend is centred on it, not over it")
+T.check(palEq(DayNight.palette(0), DayNight.PALETTES.dawn),
+  "and dawn's pin paints dawn")
+local mid = DayNight.palette(562)         -- halfway through day -> dusk
+for i, c in ipairs(mid) do
+  T.check(c[1] % 8 == 0 and c[2] % 8 == 0 and c[3] % 8 == 0,
+    "blended band " .. i .. " is re-quantised onto the GBC lattice")
+end
+T.check(mid[1][3] < DayNight.PALETTES.day[1][3]
+        and mid[1][3] > DayNight.PALETTES.dusk[1][3],
+  "and sits between the two phases it blends")
+-- day's blue horizon and dusk's gold one are near-complements, and a
+-- straight lerp between complements bottoms out in grey -- so the evening
+-- path bends through the golden-hour waypoint, and halfway down the blend
+-- the horizon band must already be WARM
+T.check(mid[1][1] > mid[1][3],
+  "mid-evening the horizon is gold, not the grey between blue and gold")
+-- and the far side of sunset bends through violet the same way: halfway
+-- from dusk to night the horizon is rose, not the taupe between gold and navy
+local ev = DayNight.palette(645)[1]
+T.check(ev[1] > ev[2] and ev[3] > ev[2],
+  "mid-fall of night the horizon is violet-rose, not grey")
+T.eq(DayNight.palette(300), DayNight.palette(300.4),
+  "the palette is memoised within the second, not rebuilt per frame")
+
+-- the tint: noon is neutral, night is dim and blue, indoors is always noon
+local tn = DayNight.tint(true, 900)
+T.check(tn[1] < 1 and tn[3] > tn[1], "night light is dim and leans blue")
+T.eq(DayNight.tint(true, 300)[1], 1, "noon multiplies by one")
+T.eq(DayNight.tint(false, 900)[1], 1,
+  "a cave at midnight is exactly as dark as a cave at noon: neutral indoors")
+
+-- the twilight glow: gold at the sun's horizons, never for the moon
+local amt, gc = DayNight.glow(600)
+T.check(math.abs(amt - 1) < 1e-9, "dusk glows in full")
+T.check(gc[1] > gc[3], "and warm: more red than blue")
+T.eq((DayNight.glow(300)), 0, "noon does not glow")
+T.eq((DayNight.glow(900)), 0, "and the moon rises silver, not gold")
+
+-- the discs cross the sky the camera can actually see
+Voxel.angle = math.rad(75)
+Voxel3D.camera = nil
+Voxel3D.vp = Voxel3D.viewProjection(0, 0, 320, 288)
+local horizon = Voxel3D.horizonY(288)
+
+DayNight.setting:sync("night")
+local mb = Voxel3D.skyBody(320, 288)
+T.check(mb and mb.moon, "at the NIGHT pin the moon is in frame")
+T.check(math.abs(mb.x - 160) < 8,
+  ("due north is screen centre: got x %.1f"):format(mb.x))
+T.check(mb.y > 0 and mb.y < horizon,
+  ("hanging above the horizon point: y %.1f vs %.1f"):format(mb.y, horizon))
+
+DayNight.setting:sync("day")
+T.eq(Voxel3D.skyBody(320, 288), nil,
+  "the noon sun is overhead behind the camera -- correctly not in frame")
+
+DayNight.setting:sync("dawn")
+local db = Voxel3D.skyBody(320, 288)
+T.check(db and not db.moon, "at the DAWN pin the rising sun is in frame")
+T.check(db.x > 160 and db.x < 320,
+  ("north of east is screen right: got x %.1f"):format(db.x))
+T.check(math.abs(db.y - horizon) < 2,
+  "standing on the horizon point, half-risen")
+T.check(db.glowAmt > 0.9, "and wrapped in the dawn glow")
+
+DayNight.setting:sync("dusk")
+local sb = Voxel3D.skyBody(320, 288)
+T.check(sb and sb.x < 160, "the DUSK sun sets screen LEFT -- north of west")
+
+-- the rig: outdoor follows the clock, indoor is pinned to noon
+DayNight.setting:sync("night")
+DayNight.applyRig(true)
+T.check(math.abs(ShadowMap.KX - mkx) < 1e-9
+        and math.abs(ShadowMap.KZ - mkz) < 1e-9,
+  "outdoors at night the sun pass is lit by the moon")
+T.check(math.abs(Voxel3D.SHADOW_ALPHA - DayNight.ALPHA_MOON) < 1e-9,
+  "at the moon's own softer weight")
+DayNight.applyRig(false)
+T.check(math.abs(ShadowMap.KX - (-0.85)) < 1e-9
+        and math.abs(ShadowMap.KZ - (-0.55)) < 1e-9,
+  "indoors the rig stays the mod's noon sun, whatever the clock says")
+T.check(math.abs(Voxel3D.SHADOW_ALPHA - DayNight.ALPHA_SUN) < 1e-9,
+  "at the weight it always had")
+T.eq(DayNight.shadowScale(false), 1, "an indoor arena keeps its full shadows")
+T.check(math.abs(DayNight.shadowScale(true, 900)
+                 - DayNight.ALPHA_MOON / DayNight.ALPHA_SUN) < 1e-9,
+  "an outdoor arena under the moon presses at the moon's ratio")
+T.check(DayNight.shadowScale(true, 600) < 1e-9,
+  "and a sunset takes the arena's shadows with it")
+
+-- the engine's own vocabulary, for map.palette and music.select
+T.eq(DayNight.tod(300), "DAY", "noon is DAY")
+T.eq(DayNight.tod(900), "NIGHT", "mid-night is NIGHT")
+T.eq(DayNight.tod(0), "MORNING", "dawn is MORNING")
+T.eq(DayNight.tod(600), "EVENING", "dusk is EVENING")
+
+-- the clock rides the save slot
+local modApi = run.loader.exports.DRAMATIC_SHAPE.lib.mod
+DayNight.setting:sync("cycle")
+DayNight.clock = 777
+DayNight.store()
+DayNight.clock = 5
+DayNight.restore()
+T.eq(DayNight.clock, 777, "the clock survives the round trip through mod.save")
+modApi.save:set(DayNight.SAVE_KEY, nil)
+DayNight.restore()
+T.eq(DayNight.clock, 300, "a save with no clock in it starts at day")
+
+-- arriving at FULL sets the clock going
+local Game = require("src.core.Game")
+local hadSave = Game.save
+Game.save = { options = {} }
+DayNight.setting:sync("day")
+defs.voxel.update(0, 2)               -- any rung that is not FULL
+defs.voxel.update(0, 1)               -- and the arrival
+T.eq(DayNight.setting:get(), "cycle",
+  "FULL switches DAYTIME to CYCLE -- the diorama gets its running sky")
+Game.save = hadSave
+
+-- put the room back the way it was found
+DayNight.setting:sync("day")
+DayNight.clock = 300
+DayNight.applyRig(false)
+Voxel3D.tint = { 1, 1, 1 }
+Voxel3D.vp = nil
+end
+
 Pipelines.reset()
 run.release()