DramaticShapeVoxelMod archival mirror · read-only
a8c2d8ce5b6197f4b3ff09fb7a04d673c516c372

menu fix, sky rendering, pokedex size

DramaticShape · 2026-08-02 · parent 395f51d268

menu fix, sky rendering, pokedex size

Changed files

CHANGELOG.md                  |  50 ++++++++++++++++++++++-
 README.md                     |   2 +-
 lib/Pokedex.lua               |   7 ++--
 lib/Sky.lua                   | 102 ++++++++++++++++++++++++++++++++++++++--------
 lib/VR.lua                    |  84 +++++++++++++++++++++-----------------
 lib/VRGL.lua                  |  37 +++++++++++++++++
 lib/Voxel3D.lua               |   5 +++
 main.lua                      |  30 ++++++++++++++
 mod.card                      |   6 +--
 tests/dramatic_shape_test.lua | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 10 files changed, 396 insertions(+), 63 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8640112..85cffb8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,52 @@
 
 ### Added
 
+- **The Pokédex in hand is a quarter larger.** Its voxel pitch went from
+  1.1 cm to 1.375 cm (the body from about 10x15 cm to about 12x19 cm),
+  because the screen carries every menu in first person and was
+  squint-small at the old size. The attachment -- flush along the left
+  controller -- is unchanged.
+
+- **Left stick click steps the VOXEL ladder.** In VR the click now makes
+  exactly the step the "3" key (and the pad's SELECT) makes -- the same
+  function, handed across, so the ladder walk, the FULL step-over and
+  the TILT/GBC FX clearing can never drift from the key's. It used to
+  toggle first/third person against a remembered return rung.
+
+- **The floating panel shows the same picture at every window size.**
+  The GB-frame region used to be copied into the headset's panel
+  pixel-for-pixel, and the panel's swapchain image has a fixed size --
+  so a window scaled past it (fullscreen above all) ran the frame off
+  the copy's edge and cut the START menu out of the panel. The region
+  is now blitted OUT of the window and SCALED into the swapchain image
+  at the frame's own aspect: identical picture, identical near-square
+  ratio, whatever size or shape the window takes.
+
+- **Menus stay inside the GB frame while a headset is live.** The
+  engine's new zoom-aware anchoring docks the START menu to the
+  WINDOW's edge -- and both VR screens (the floating panel and the
+  Pokédex) crop the window to the near-square GB frame, so a docked
+  menu was cropped away with the border it hugged. While a headset is
+  live the mod answers the engine's own "hold the anchors" predicate
+  with yes, and every menu blits where it was drawn: the START menu's
+  classic slot, flush with the frame's right edge, which is the right
+  edge of everything the headset shows.
+
+- **The sky's dither is glued to the sky.** The gradient's bands were
+  already read by true elevation, but the GBC checker between them kept
+  SCREEN-cell parity -- so a head's pitch or roll slid the world-fixed
+  band edges over a screen-fixed checkerboard and the whole gradient
+  shimmered as the pattern recomputed. The ray path is now a computed
+  SKYBOX: each pixel's ray lands in a cell of the sky's own angular
+  grid (azimuth columns, elevation rows, sized to match the diorama's
+  pixel grid on screen), and the band, the checker's parity and the
+  twilight glow -- now measured by the angle to the sun's own direction
+  -- are all answered from that cell's centre. The screen grid
+  quantises nothing, so the picture behaves exactly like a
+  nearest-filtered texture on a dome: its cells slide smoothly with the
+  world, no motion of the head recomputes the pattern, and only the
+  clock moves the sky.
+
 - **VR works from an installed release.** The OpenXR loader used to be
   looked for only against the working directory and the game's source --
   right for the dev tree, wrong for a release install, where importing
@@ -46,8 +92,8 @@
     runtime's own UI. In both modes: **left stick** moves (through the
     engine's own stick path, so it grid-walks the diorama and free-walks
     1ST), **A/B** are A/B, **either trigger** is START, and **clicking
-    the left stick** toggles first/third person (returning to the orbit
-    rung you left). In the diorama: **right stick up/down** zooms the
+    the left stick** steps the VOXEL angle ladder exactly as the "3"
+    key does. In the diorama: **right stick up/down** zooms the
     model, and **squeezing a grip** while moving that hand up or down
     drags the whole table with it. No controller button leaves VR --
     both directions belong to the VR row alone, so no mid-fight click
diff --git a/README.md b/README.md
index 0c6fd81..f20aa3d 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ alongside.
 | left stick | move — grid-walks the diorama, free-walks 1ST |
 | A / B (X / Y on the left hand) | A / B |
 | either trigger | START |
-| left stick click | toggle first / third person |
+| left stick click | step the VOXEL angle ladder (same as the "3" key) |
 | right stick up / down | *diorama only* — zoom the model |
 | right stick left / right | *1ST only* — snap-turn 45° |
 | grip squeeze + raise / lower that hand | *diorama only* — drag the table's height |
diff --git a/lib/Pokedex.lua b/lib/Pokedex.lua
index 07bba64..242bf70 100644
--- a/lib/Pokedex.lua
+++ b/lib/Pokedex.lua
@@ -37,9 +37,10 @@ local VRRig = V.require("VRRig")
 local Pokedex = {}
 
 -- one voxel, in metres: a centimetre-ish grid gives the classic chunky
--- read at a believable device size (the body below comes out about
--- 10 x 15 x 2.5 cm)
-Pokedex.VOX = 0.011
+-- read at a device you can read a battle off (the body below comes out
+-- about 12 x 19 x 3 cm -- a quarter up from the first, believable size,
+-- because the screen carries every menu and was squint-small in hand)
+Pokedex.VOX = 0.011 * 1.25
 
 -- Where the device sits relative to the GRIP pose, in metres, and how it
 -- is tipped. A full quarter turn forward lays the slab exactly along the
diff --git a/lib/Sky.lua b/lib/Sky.lua
index 1695aa0..54d5d63 100644
--- a/lib/Sky.lua
+++ b/lib/Sky.lua
@@ -198,10 +198,17 @@ uniform vec3 rayDv;     // base + u*du + v*dv, world axes -- so each pixel
 uniform float raySpan;  // radians of elevation the gradient covers
 uniform vec2 invSize;   // 1/w, 1/h: canvas pixels to fractions
 uniform float useRay;   // 0 = the flat screen's frame-linear gradient
+uniform float cellAng;  // one checker cell in RADIANS (ray path): the
+                        // dither's own grid, laid on azimuth/elevation so
+                        // the pattern is glued to the SKY -- a screen-cell
+                        // parity flips under every head motion and the
+                        // whole gradient shimmers
 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 vec2 glowPos;   // the sun disc, in canvas pixels (flat path)
+uniform float glowInvR; // 1 / the glow's reach in pixels (flat path)
+uniform vec3 glowDir;   // the sun's world direction (ray path)
+uniform float glowInvA; // 1 / the glow's reach in radians (ray path)
 uniform vec3 glowColor;
 
 // Band `i`, read from its own texel centre. The index is clamped rather than
@@ -214,37 +221,62 @@ vec3 bandAt(float i) {
 }
 
 vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
-  vec2 cc0 = floor(sc / cell) * cell;                 // top of this cell
   float tn;
+  float parity;
+  float glowD = 2.0;                                  // past the reach
   if (useRay > 0.5) {
-    // the cell's own ray, and its true elevation: the sky by ANGLE, so
-    // no motion of the head -- pitch, yaw or roll -- moves a band
-    vec2 cc1 = (floor(sc / cell) + 0.5) * cell;
-    vec3 dir = rayBase + rayDu * (cc1.x * invSize.x)
-                       + rayDv * (cc1.y * invSize.y);
+    // A SKYBOX, computed instead of stored: the pixel's own ray lands in
+    // a cell of the sky's angular grid (azimuth columns and elevation
+    // rows, cellAng square), and EVERYTHING -- the band, the checker's
+    // parity, the glow -- is answered from that cell's centre. The
+    // screen grid quantises nothing here; that is the point. A screen
+    // quantisation of similar pitch laid under the sky grid beats
+    // against it (moire), and every subpixel head motion re-snaps the
+    // beat -- the fizz. Sampled per pixel, the picture is exactly a
+    // nearest-filtered texture on a dome: its cells slide smoothly with
+    // the world and no motion of the head recomputes the pattern. The
+    // one seam, where azimuth wraps behind the camera, is a single cell
+    // column of a dither pattern.
+    vec3 dir = rayBase + rayDu * (sc.x * invSize.x)
+                       + rayDv * (sc.y * invSize.y);
     float elev = atan(dir.y, length(dir.xz));
-    if (elev < 0.0) { discard; }                      // below the horizon
-    tn = 1.0 - clamp(elev / max(raySpan, 0.001), 0.0, 1.0);
+    float ei = floor(elev / cellAng);                 // elevation row
+    if (ei < 0.0) { discard; }                        // below the horizon
+    float ai = floor(atan(dir.x, dir.z) / cellAng);   // azimuth column
+    float elc = (ei + 0.5) * cellAng;                 // the row's centre
+    tn = 1.0 - clamp(elc / max(raySpan, 0.001), 0.0, 1.0);
+    parity = mod(ai + ei, 2.0);
+    if (glowAmt > 0.0) {
+      // the glow by the angle between the CELL's centre direction and
+      // the sun's own, so its rings are pinned to the same sky grid
+      float azc = (ai + 0.5) * cellAng;
+      vec3 cd = vec3(cos(elc) * sin(azc), sin(elc), cos(elc) * cos(azc));
+      glowD = acos(clamp(dot(cd, glowDir), -1.0, 1.0)) * glowInvA;
+    }
   } else {
+    vec2 cc0 = floor(sc / cell) * cell;               // top of this cell
     float row = cc0.x * axisX + cc0.y * axisY;        // along the axis
     if (row > edge) { discard; }                      // below the horizon
     tn = clamp((row - top) / max(edge - top, 1.0), 0.0, 1.0);
+    parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
+    if (glowAmt > 0.0) {
+      vec2 cc = (floor(sc / cell) + 0.5) * cell;
+      glowD = length(cc - glowPos) * glowInvR;
+    }
   }
   float pos = tn * count;
   float base = min(floor(pos), count - 1.0);
   vec3 c = bandAt(base);
-  float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
   if (base < count - 1.0 && (pos - base) > start) {
     if (parity < 0.5) { c = bandAt(base + 1.0); }
   }
   // 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.
+  // measured cell-to-cell on the flat frame and angle-to-angle on the
+  // skybox, so its rings ride whichever grid the checker itself is on.
   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 g = glowAmt * pow(clamp(1.0 - glowD, 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);
@@ -600,6 +632,26 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis, ray)
   if g.setBlendMode then g.setBlendMode("alpha") end
 
   local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
+  -- the skybox glow needs the sun's world DIRECTION (skyBody carries it);
+  -- a body without one has nothing to measure angles against, so no glow
+  if ray and glowAmt > 0 and not (body and body.dx) then glowAmt = 0 end
+  -- the world direction a canvas fraction (u, v) looks along, normalised
+  -- -- for sizing the angular checker and the glow's angular reach below
+  local function rayDirAt(u, v)
+    local b, du, dv = ray.base, ray.du, ray.dv
+    local x = b[1] + du[1] * u + dv[1] * v
+    local y = b[2] + du[2] * u + dv[2] * v
+    local z = b[3] + du[3] * u + dv[3] * v
+    local l = math.sqrt(x * x + y * y + z * z)
+    if l < 1e-9 then return 0, 0, -1 end
+    return x / l, y / l, z / l
+  end
+  local function rayAngle(u0, v0, u1, v1)
+    local ax, ay, az = rayDirAt(u0, v0)
+    local bx, by, bz = rayDirAt(u1, v1)
+    local d = ax * bx + ay * by + az * bz
+    return math.acos(math.max(-1, math.min(1, d)))
+  end
   local sh = getShader()
   local ramp = sh and rampFor(bands)
   if not ramp then sh = nil end       -- no ramp, no gradient: paint it flat
@@ -620,6 +672,11 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis, ray)
         sh:send("rayDv", ray.dv)
         sh:send("raySpan", Sky.ELEV_SPAN)
         sh:send("invSize", { 1 / w, 1 / h })
+        -- the angular checker's cell: the angle one dither cell spans at
+        -- the frame's centre, so the sky-glued grid comes out the same
+        -- size on screen as the diorama's own pixel grid
+        sh:send("cellAng",
+                math.max(1e-4, rayAngle(0.5, 0, 0.5, 1) * cell / h))
       end
       sh:send("cell", cell)
       sh:send("start", Sky.DITHER and Sky.DITHER_START or 2)
@@ -627,8 +684,19 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis, ray)
       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 * Sky.GLOW_REACH))
+        if ray then
+          -- the glow in ANGLES: its direction is the sun's own, and its
+          -- reach is the same fraction of the view the pixel reach was
+          -- of the frame, so the two paths agree on how wide it looks
+          local dx, dy, dz = body.dx, body.dy, body.dz
+          local l = math.sqrt(dx * dx + dy * dy + dz * dz)
+          sh:send("glowDir", { dx / l, dy / l, dz / l })
+          sh:send("glowInvA", 1 / math.max(
+            1e-3, rayAngle(0, 0.5, 1, 0.5) * Sky.GLOW_REACH))
+        else
+          sh:send("glowPos", { body.x, body.y })
+          sh:send("glowInvR", 1 / math.max(1, w * Sky.GLOW_REACH))
+        end
         sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 })
       end
     end)
diff --git a/lib/VR.lua b/lib/VR.lua
index 3a261a3..e00e149 100644
--- a/lib/VR.lua
+++ b/lib/VR.lua
@@ -77,11 +77,9 @@ local mirrorCanvas = nil
 local status = "off"
 
 -- the diorama's live adjustments: the right stick's zoom (a multiplier on
--- the model's size), the grab-drag's height (metres of world travel), and
--- the orbit rung the view toggle returns to from first person
+-- the model's size) and the grab-drag's height (metres of world travel)
 local zoom = 1
 local heightOff = 0
-local lastOrbit = 4             -- the 50-degree rung, a sane middle
 local held = {}                 -- GB buttons this module is holding down
 local lastHandY = nil           -- the gripping hand's height, last frame
 
@@ -396,27 +394,44 @@ local function updateQuad(worldUp, fp)
   if not tex then return nil end
   local ww, wh = qw, qh
   pcall(function() ww, wh = love.graphics.getPixelDimensions() end)
-  VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh))
-  VRXR.releaseQuad()
   -- The panel wears the GB FRAME, not the window: everything the flat
   -- screen has to say lives in the 160x144 letterbox (the world around
-  -- it is just the mirror's picture), so the quad's sub-rect crops to
-  -- it and the panel presents near-square instead of monitor-wide.
-  -- Image space is GL's, origin bottom-left -- exactly how
-  -- copyFrontBuffer landed the window in the texture.
+  -- it is just the mirror's picture). The frame region is blitted OUT
+  -- of the window and SCALED into the swapchain image -- never copied
+  -- pixel-for-pixel, because the swapchain's size is fixed at session
+  -- start and a fullscreened window outgrows it, running the frame (and
+  -- the START menu flush with its right edge) off the copy. Scaled, the
+  -- panel shows the identical picture at the identical ratio whatever
+  -- size the window is. Source coordinates are GL's, origin bottom-left.
   local crop = nil
+  local copied = false
   pcall(function()
     local BattleScene = V.require("BattleScene")
     local lx, ly, s = BattleScene.letterbox()
-    local wpx = BattleScene.GB_W * s
-    local hpx = BattleScene.GB_H * s
-    local x = math.max(0, math.floor(lx))
-    local y = math.max(0, math.floor(wh - ly - hpx))
-    crop = { x, y,
-             math.min(qw - x, math.ceil(wpx)),
-             math.min(qh - y, math.ceil(hpx)) }
-    if crop[3] < 1 or crop[4] < 1 then crop = nil end
+    local wpx = math.ceil(BattleScene.GB_W * s)
+    local hpx = math.ceil(BattleScene.GB_H * s)
+    local sx = math.max(0, math.floor(lx))
+    local sy = math.max(0, math.floor(wh - ly - hpx))
+    wpx = math.min(wpx, ww - sx)
+    hpx = math.min(hpx, wh - sy)
+    if wpx < 1 or hpx < 1 then return end
+    -- fitted to the swapchain image at the REGION's own aspect: the
+    -- crop then presents exactly that rect, so the panel's shape is the
+    -- GB frame's at any window and any swapchain size
+    local fit = math.min(qw / wpx, qh / hpx)
+    local dw = math.max(1, math.floor(wpx * fit))
+    local dh = math.max(1, math.floor(hpx * fit))
+    if VRGL.copyFrontRegionToTexture(tex, sx, sy, wpx, hpx, dw, dh) then
+      copied = true
+      crop = { 0, 0, dw, dh }
+    end
   end)
+  if not copied then
+    -- no letterbox to cut (or the blit refused): the old whole-window
+    -- copy, clamped, is still a readable panel
+    VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh))
+  end
+  VRXR.releaseQuad()
   local base = fp and QUAD_FP or QUAD_DIORAMA
   if not crop then return base end
   return { pos = base.pos, width = base.width, crop = crop }
@@ -429,7 +444,8 @@ end
 --   both modes    left stick moves (through the engine's own stick path,
 --                 so it grid-walks the diorama and free-walks 1ST);
 --                 A/B are A/B; either trigger is START; clicking the
---                 LEFT stick toggles first/third person.
+--                 LEFT stick steps the VOXEL angle ladder exactly as
+--                 the "3" key (and the pad's SELECT) does.
 --   1ST only      right stick left/right SNAP-TURNS 45 degrees a flick.
 --   diorama only  right stick up/down zooms the model; squeezing a grip
 --                 and moving that hand up or down drags the whole table
@@ -438,25 +454,19 @@ end
 -- Leaving VR is the VR row's job alone (OPTIONS menu or the manager) --
 -- no controller button does it. VR.leave below stays as the API for it.
 
--- Flip between the 1ST rung and the last orbit rung, through the same
--- gate and plumbing the keyboard hotkey uses.
-function VR.toggleView()
+-- The left stick click makes EXACTLY the step the "3" key makes: one
+-- rung up the VOXEL angle ladder, wrapping, stepping over FULL, clearing
+-- TILT and GBC FX in the save -- by calling the very function the key
+-- and the pad's SELECT button already share. main.lua installs it below
+-- (cycleVoxel is a local of that file); the free-roam gate is the
+-- registry's own, inside it, so a click over a menu or mid-warp is a
+-- no-op exactly like the key.
+VR.cycleVoxel = nil             -- cycleVoxel(game), set by main.lua
+
+function VR.stepView()
   pcall(function()
-    local Game = require("src.core.Game")
-    local Pipelines = require("src.render.Pipelines")
-    local top = Game.stack and Game.stack:top()
-    if not Pipelines.canToggle("voxel", top, Game.overworld) then return end
-    local level = Pipelines.level("voxel")
-    if Voxel.isFirstPerson(level) then
-      Pipelines.setLevel("voxel", lastOrbit)
-    else
-      if level > 0 and not Voxel.isFull(level) then lastOrbit = level end
-      Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
-    end
-    if Game.save and Game.save.options then
-      Pipelines.syncOptions(Game.save.options)
-      pcall(Game.writeOptions, Game)
-    end
+    if not VR.cycleVoxel then return end
+    VR.cycleVoxel(require("src.core.Game"))
   end)
 end
 
@@ -502,7 +512,7 @@ local function driveControls(ctl, dt, fp)
   inp:gamepadaxis(nil, "leftx", ctl.moveX or 0)
   inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0))
 
-  if ctl.toggleChanged and ctl.toggle then VR.toggleView() end
+  if ctl.toggleChanged and ctl.toggle then VR.stepView() end
 
   -- first person's snap turn: a flick of the right stick steps the view
   -- 45 degrees, once per flick -- it re-arms only when the stick comes
diff --git a/lib/VRGL.lua b/lib/VRGL.lua
index bf9245e..bbfdf97 100644
--- a/lib/VRGL.lua
+++ b/lib/VRGL.lua
@@ -166,6 +166,43 @@ function VRGL.copyFrontBuffer(tex, w, h)
   return ok
 end
 
+-- Blit a REGION of the window's front buffer into a GL texture (an XR
+-- swapchain image), SCALED to (dw, dh) at the texture's origin. This is
+-- the panel's route: the whole-window copy above is pixel-for-pixel, so
+-- a window larger than the swapchain image simply ran off its edges --
+-- fullscreen cut the GB frame's own menu off the panel. A scaled blit
+-- has no such cliff: the letterbox region lands whole at the texture's
+-- own resolution whatever size the window is. Source coordinates are GL
+-- window space, origin bottom-left; LINEAR, because the region rarely
+-- matches the target size exactly and dropped rows read worse than a
+-- soft one. Restores the read buffer and framebuffer LOVE believes in.
+function VRGL.copyFrontRegionToTexture(tex, sx, sy, sw, sh, dw, dh)
+  if not ready then return false end
+  local ok = pcall(function()
+    if not fbo then
+      local out = ffi.new("unsigned int[1]")
+      ext.glGenFramebuffers(1, out)
+      fbo = out[0]
+    end
+    ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, 0)
+    gl.glReadBuffer(GL.FRONT)
+    ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, fbo)
+    ext.glFramebufferTexture2D(GL.DRAW_FRAMEBUFFER, GL.COLOR_ATTACHMENT0,
+                               GL.TEXTURE_2D, tex, 0)
+    ext.glBlitFramebuffer(sx, sy, sx + sw, sy + sh, 0, 0, dw, dh,
+                          GL.COLOR_BUFFER_BIT, GL.LINEAR)
+    ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
+    gl.glReadBuffer(GL.BACK)
+  end)
+  if not ok then
+    pcall(function()
+      ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
+      gl.glReadBuffer(GL.BACK)
+    end)
+  end
+  return ok
+end
+
 -- Copy the window's front buffer into a LOVE CANVAS (by its FBO id, from
 -- canvasFBO), flipped so the canvas reads top-down exactly like the
 -- window: what LOVE then draws from that canvas at (0,0) is the screen,
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index c161aff..fb5ef87 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -721,6 +721,11 @@ function Voxel3D.skyBody(w, h)
   return {
     x = (x / ww * 0.5 + 0.5) * w,
     y = (y / ww * 0.5 + 0.5) * h,
+    -- the body's WORLD direction, for the skybox path: a ray-fan caller
+    -- measures the twilight glow by the angle between a pixel's ray and
+    -- this, so the glow is pinned to the sky like the bands are (see
+    -- Sky.paint's glowDir)
+    dx = b.dx, dy = b.dy, dz = b.dz,
     moon = b.moon,
     glowAmt = amt,
     glowColor = color,
diff --git a/main.lua b/main.lua
index 2b07e0f..1fff18a 100644
--- a/main.lua
+++ b/main.lua
@@ -524,6 +524,11 @@ local function cycleVoxel(game)
   return true
 end
 
+-- The VR stick click makes this same step (VR.stepView): the function is
+-- a local of this file, so the handoff is explicit rather than a
+-- reimplementation drifting out of date in lib/VR.lua.
+VR.cycleVoxel = cycleVoxel
+
 do
   local Game = require("src.core.Game")
   local Pipelines = require("src.render.Pipelines")
@@ -913,6 +918,31 @@ do
   end
 end
 
+-- ------- edge-anchored menus stay in the GB frame while a headset is live
+--
+-- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START
+-- menu to the WINDOW's top-right edge. Both VR screens -- the floating
+-- panel and the Pokedex -- crop the window to the GB frame, so a menu at
+-- the window's edge is cropped away with the border it docked to. The
+-- engine's own answer to "a state composes its screen, keep every element
+-- inside it" is uiAnchorHold, computed per frame from this predicate; a
+-- live headset is exactly that situation for the WHOLE window, so the
+-- predicate answers yes for as long as one is. Held menus blit where they
+-- were drawn in the 160x144 canvas -- the START menu's 9,0 x 11 slot is
+-- already flush with the frame's right edge, which is the right edge of
+-- what the headset sees. Off-headset frames fall through untouched.
+do
+  local Game = require("src.core.Game")
+  if not Game.dramaticShapeAnchorHold then
+    local inner = Game.uiAnchorsHeldInStack
+    function Game.uiAnchorsHeldInStack(stack)
+      if VR.active() then return true end
+      return inner(stack)
+    end
+    Game.dramaticShapeAnchorHold = true
+  end
+end
+
 -- The overworld's own pushBattle is the choke point for a wild encounter or
 -- a trainer, and it is wrapped. A battle that arrives some other way -- a
 -- link battle, a script pushing a BattleState directly -- reaches this
diff --git a/mod.card b/mod.card
index 4866e2d..307bdde 100644
--- a/mod.card
+++ b/mod.card
@@ -27,8 +27,8 @@ return {
       "WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march",
       "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
       "BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map",
-      "VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space, the floating panel wearing the GB frame near-square rather than the whole monitor-wide window, the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder",
-      "VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click toggles first/third person; in 1ST the right stick snap-turns 45 degrees a flick; in the diorama the right stick zooms and a squeezed grip drags the table's height; no controller button leaves VR -- that is the VR row's job",
+      "VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder",
+      "VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click steps the VOXEL angle ladder exactly as the 3 key and SELECT do; in 1ST the right stick snap-turns 45 degrees a flick; in the diorama the right stick zooms and a squeezed grip drags the table's height; no controller button leaves VR -- that is the VR row's job",
       "a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday",
       "an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
       "a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode",
@@ -51,7 +51,7 @@ return {
       "VR on and off are both the VR row's job (options menu or manager); no controller button does either",
       "the Pokedex needs a TRACKED left controller; without one there is no device in hand and the floating panel carries the UI as before",
       "while the VR row is ON, 3D-BTL is held ON and BACK SPRITES held OFF (the headset's battle staging assumes both), and both rows leave the OPTIONS menu until VR goes off -- their stored values come back with them",
-      "while a headset is live the battle HUDs keep their classic in-frame slots instead of snapping to the window's edges, on the flat mirror too -- both VR screens crop to the GB frame, and a block at the window's edge would be cropped away with it",
+      "while a headset is live the battle HUDs keep their classic in-frame slots instead of snapping to the window's edges, and the engine's edge-anchored menus (the START menu above all) are held inside the frame the same way, on the flat mirror too -- both VR screens crop to the GB frame, and a block at the window's edge would be cropped away with it",
       "VR swapchains prefer plain RGBA8; a runtime that only offers sRGB shows slightly lifted colours",
     },
   },
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 0bf3ee2..a6fb494 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -4015,6 +4015,142 @@ T.eq(type(VRMod.leave), "function",
 end
 vrRigSection()
 
+-- ------- the skybox's checker and glow are the sky's own, not the screen's
+--
+-- The bands were already read by angle, but the DITHER between them kept
+-- screen-cell parity: a world-fixed band edge sliding over a screen-fixed
+-- checkerboard recomputes the pattern with every head motion -- the
+-- shimmer. Now the ray path lays the checker (and the twilight glow's
+-- rings) on azimuth/elevation cells, and these pin what it sends.
+;(function()
+  local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky")
+  local realGraphics, realImage = love.graphics, love.image
+  local sent = {}
+  local fakeShader = {
+    send = function(_, name, a, b, c, d) sent[name] = { a, b, c, d } end,
+  }
+  local function fakeImage(w, h)
+    return {
+      getWidth = function() return w end,
+      getHeight = function() return h end,
+      getDimensions = function() return w, h end,
+      setPixel = function() end,
+      setFilter = function() end,
+      setWrap = function() end,
+    }
+  end
+  love.image = { newImageData = function(w, h) return fakeImage(w, h) end }
+  love.graphics = {
+    getShader = function() return nil end,
+    setShader = function() end,
+    getDepthMode = function() return "lequal", true end,
+    setDepthMode = function() end,
+    setColor = function() end,
+    newShader = function() return fakeShader end,
+    newImage = function(data) return data end,
+    rectangle = function() end,
+  }
+  Sky.invalidate()   -- rebuild the shader and ramp through the fakes
+
+  local grad = { bands = { { 8, 8, 16 }, { 48, 64, 96 }, { 96, 128, 160 } } }
+  -- a level fan looking north: base + u*du + v*dv, spanning 2*atan(0.5)
+  -- both ways at depth 1 -- easy angles to pin the sends against
+  local fan = { base = { -0.5, 0.5, -1 }, du = { 1, 0, 0 },
+                dv = { 0, -1, 0 } }
+  local span = 2 * math.atan(0.5)
+  local body = { x = 160, y = 40, dx = 0, dy = 0.5, dz = -1,
+                 glowAmt = 0.5, glowColor = { 248, 224, 168 } }
+  T.eq(Sky.paint(320, 288, grad, nil, 7, body, nil, nil, fan), true,
+    "the skybox paints with a ray fan and a bodied glow")
+  T.check(sent.cellAng and sent.cellAng[1] > 0,
+    "the checker gets an ANGULAR cell: the dither grid is laid on "
+    .. "azimuth and elevation, so no head motion reslides it")
+  T.check(math.abs(sent.cellAng[1] - span * 7 / 288) < 1e-6,
+    "sized so the sky's grid matches the diorama's pixel grid on screen")
+  T.check(sent.glowDir ~= nil, "the glow gets the sun's world direction")
+  local gd = sent.glowDir[1]
+  local gl = math.sqrt(gd[1] ^ 2 + gd[2] ^ 2 + gd[3] ^ 2)
+  T.check(math.abs(gl - 1) < 1e-6 and math.abs(gd[2] - 0.4472) < 1e-3,
+    "normalised, the hour's own")
+  T.check(math.abs(sent.glowInvA[1] - 1 / (span * Sky.GLOW_REACH)) < 1e-6,
+    "and an angular reach cut from the view the way the pixel reach was")
+  T.eq(sent.glowPos, nil,
+    "the screen-space glow stays the flat frame's path alone")
+
+  sent = {}
+  fakeShader.send = function(_, name, a, b, c, d) sent[name] = { a, b, c, d } end
+  local bare = { x = 160, y = 40, glowAmt = 0.5,
+                 glowColor = { 248, 224, 168 } }
+  T.eq(Sky.paint(320, 288, grad, nil, 7, bare, nil, nil, fan), true,
+    "a body with no world direction still paints")
+  T.eq(sent.glowAmt[1], 0,
+    "but offers no glow -- there is no direction to measure angles against")
+  T.eq(sent.glowDir, nil, "and no direction is sent")
+
+  love.graphics, love.image = realGraphics, realImage
+  Sky.invalidate()
+
+  -- ------- a live headset holds every menu inside the GB frame
+  --
+  -- The engine's zoom-aware anchoring docks the START menu to the WINDOW's
+  -- edge; both VR screens crop the window to the GB frame, so a docked
+  -- menu is cropped away with the border it hugged. The wrap answers the
+  -- engine's own uiAnchorsHeldInStack predicate with yes while a headset
+  -- is live, which blits every menu where it was drawn -- the START
+  -- menu's slot is already flush with the frame's right edge.
+  local Game = require("src.core.Game")
+  local VRMod = run.loader.exports.DRAMATIC_SHAPE.lib.require("VR")
+  T.eq(Game.dramaticShapeAnchorHold, true,
+    "the anchor-hold wrap installed at load, once")
+  T.eq(Game.uiAnchorsHeldInStack({ states = {} }), false,
+    "with no headset the engine's own answer stands: an empty stack docks")
+  local innerActive = VRMod.active
+  VRMod.active = function() return true end
+  T.eq(Game.uiAnchorsHeldInStack({ states = {} }), true,
+    "a live headset holds anchors -- menus stay inside the GB frame, "
+    .. "which is all either VR screen shows")
+  VRMod.active = innerActive
+  T.eq(Game.uiAnchorsHeldInStack({ states = {} }), false,
+    "and hands the predicate back when the headset is gone")
+  T.eq(Game.uiAnchorsHeldInStack({ states = { { holdsUIAnchors = true } } }),
+    true, "a self-composing state still holds them on its own")
+
+  -- and the panel's route to the headset is the SCALED region blit: the
+  -- pixel-for-pixel copy ran the GB frame off a swapchain image smaller
+  -- than the window (fullscreen cut the menu), so the scaled seam must
+  -- exist for updateQuad to reach for first
+  local VRGL_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("VRGL")
+  T.eq(type(VRGL_.copyFrontRegionToTexture), "function",
+    "the letterbox reaches the panel scaled, not pixel-for-pixel")
+
+  -- ------- the stick click IS the "3" key
+  --
+  -- Left stick click makes exactly the step the key (and SELECT) makes:
+  -- the very same function, handed across from main.lua, so the ladder
+  -- walk, the FULL step-over and the TILT/GBC FX clearing can never
+  -- drift. Through the same fixture the key tests lend.
+  T.eq(VRMod.cycleVoxel ~= nil, true,
+    "main.lua hands its cycleVoxel to the stick click")
+  local hadStack2, hadOw2 = Game.stack, Game.overworld
+  local hadSave2, hadWrite2 = Game.save, Game.writeOptions
+  Game.stack, Game.overworld = keyGame.stack, keyGame.overworld
+  Game.save, Game.writeOptions = keyGame.save, keyGame.writeOptions
+  Pipelines.setLevel("voxel", 0)
+  VRMod.stepView()
+  T.eq(Pipelines.levelLabel("voxel"), "15",
+    "the stick click steps the VOXEL ladder exactly as 3 does")
+  VRMod.stepView()
+  T.eq(Pipelines.levelLabel("voxel"), "35", "and keeps walking it")
+  keyGame.save.options.tilt = 2
+  require("src.render.Tilt").setLevel(2)
+  VRMod.stepView()
+  T.eq(keyGame.save.options.tilt, 0,
+    "each click clears TILT in the save, exactly as each keypress does")
+  Game.stack, Game.overworld = hadStack2, hadOw2
+  Game.save, Game.writeOptions = hadSave2, hadWrite2
+  Pipelines.setLevel("voxel", 0)
+end)()
+
 Pipelines.reset()
 run.release()