DramaticShapeVoxelMod archival mirror · read-only
55bc993ed7699d64315b4dce6a612f3da1f3f4b6

fix battle rotations in vr

DramaticShape · 2026-08-06 · parent 6080531b08

fix battle rotations in vr

Changed files

data/battle_arenas.lua        | 138 +++++++++++++++++++++++++++++++++-----------------------------
 lib/Diorama.lua               |  35 ++++++++++++++++
 lib/VR.lua                    |   6 ++-
 tests/dramatic_shape_test.lua |  62 ++++++++++++++++++++++++++++
 4 files changed, 176 insertions(+), 65 deletions(-)

Diff

diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua
index 680cece..33f7164 100644
--- a/data/battle_arenas.lua
+++ b/data/battle_arenas.lua
@@ -63,14 +63,14 @@ return {
   -- height along the sightline and a hedge in the apron row is not terrain.
   -- So the choice is where in the clearing, and this is its west end: tree
   -- line square behind the pair, nothing crossing either of them.
-  ["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" },
+  ["ROUTE_1"] = { x = 10, y = 16, shape = "narrow", turn = 90 },
   ["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
-  ["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
-  ["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
-  ["ROUTE_5"] = { x = 13, y = 24, shape = "wide" },
-  ["ROUTE_6"] = { x = 5, y = 17, shape = "narrow" },
-  ["ROUTE_7"] = { x = 8, y = 8, shape = "narrow" },
-  ["ROUTE_8"] = { x = 25, y = 7, shape = "wide" },
+  ["ROUTE_3"] = { x = 52, y = 10, shape = "wide", turn = 90 },
+  ["ROUTE_4"] = { x = 46, y = 4, shape = "wide" },
+  ["ROUTE_5"] = { x = 7, y = 0, shape = "wide", turn = 270 },
+  ["ROUTE_6"] = { x = 4, y = 15, shape = "narrow" },
+  ["ROUTE_7"] = { x = 4, y = 3, shape = "narrow", cam = "wide" },
+  ["ROUTE_8"] = { x = 24, y = 5, shape = "wide", turn = 90 },
   -- the whole route admits six bare wide arenas, all in the west cliff
   -- corridor; this is the best of them. A flower cluster crosses the far
   -- mon's hind legs, which the brief allows -- every alternative put a
@@ -78,13 +78,13 @@ return {
   ["ROUTE_9"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
   ["ROUTE_10"] = { x = 7, y = 40, shape = "wide" },
   ["ROUTE_11"] = { x = 9, y = 6, shape = "wide" },
-  ["ROUTE_12"] = { x = 0, y = 73, shape = "wide" },
-  ["ROUTE_13"] = { x = 50, y = 8, shape = "narrow" },
-  ["ROUTE_14"] = { x = 11, y = 25, shape = "wide" },
-  ["ROUTE_15"] = { x = 9, y = 10, shape = "wide" },
-  ["ROUTE_16"] = { x = 6, y = 10, shape = "wide" },
+  ["ROUTE_12"] = { x = 10, y = 71, shape = "wide", turn = 90 },
+  ["ROUTE_13"] = { x = 51, y = 5, shape = "narrow" },
+  ["ROUTE_14"] = { x = 11, y = 24, shape = "wide" },
+  ["ROUTE_15"] = { x = 30, y = 10, shape = "wide", turn = 90 },
+  ["ROUTE_16"] = { x = 8, y = 10, shape = "wide", turn = 90 },
   ["ROUTE_17"] = { x = 14, y = 70, shape = "wide" },
-  ["ROUTE_18"] = { x = 11, y = 4, shape = "wide" },
+  ["ROUTE_18"] = { x = 11, y = 3, shape = "wide" },
 
   -- ------- buildings and caves
   --
@@ -92,10 +92,10 @@ return {
   -- or gravestones in it rarely holds a 3x6 clearing whose whole width is
   -- also SEEN, and giving up the apron is usually the difference between a
   -- fight in the open and one behind a console.
-  ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide" },
-  ["POKEMON_MANSION_2F"] = { x = 15, y = 17, shape = "wide" },
-  ["POKEMON_MANSION_3F"] = { x = 23, y = 2, shape = "narrow", cam = "wide" },
-  ["POKEMON_MANSION_B1F"] = { x = 19, y = 10, shape = "wide" },
+  ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide", cam = "wide" },
+  ["POKEMON_MANSION_2F"] = { x = 10, y = 1, shape = "wide", cam = "wide" },
+  ["POKEMON_MANSION_3F"] = { x = 24, y = 3, shape = "narrow", cam = "wide" },
+  ["POKEMON_MANSION_B1F"] = { x = 6, y = 19, shape = "wide", turn = 90 },
   ["POKEMON_TOWER_2F"] = { x = 4, y = 7, shape = "narrow" },
   ["POKEMON_TOWER_3F"] = { x = 4, y = 6, shape = "wide" },
   -- every one of 4F's thirty candidate spots puts a gravestone through a
@@ -103,73 +103,79 @@ return {
   -- is shot there
   ["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6,
                            shape = "wide" },
-  ["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" },
-  ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" },
+  ["POKEMON_TOWER_5F"] = { x = 8, y = 9, shape = "narrow", cam = "wide" },
+  ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow", cam = "wide" },
   ["POKEMON_TOWER_7F"] = { x = 9, y = 5, shape = "wide" },
   ["POWER_PLANT"] = { x = 18, y = 5, shape = "narrow" },
   ["ROCK_TUNNEL_1F"] = { x = 14, y = 15, shape = "wide" },
   ["ROCK_TUNNEL_B1F"] = { x = 20, y = 17, shape = "wide" },
-  ["ROCKET_HIDEOUT_B1F"] = { x = 11, y = 6, shape = "narrow" },
-  ["ROCKET_HIDEOUT_B2F"] = { x = 19, y = 7, shape = "narrow" },
-  ["ROCKET_HIDEOUT_B3F"] = { x = 22, y = 11, shape = "narrow" },
-  ["ROCKET_HIDEOUT_B4F"] = { x = 17, y = 3, shape = "narrow" },
+  ["ROCKET_HIDEOUT_B1F"] = { x = 20, y = 18, shape = "narrow" },
+  ["ROCKET_HIDEOUT_B2F"] = { x = 20, y = 10, shape = "narrow", cam = "wide" },
+  ["ROCKET_HIDEOUT_B3F"] = { x = 24, y = 15,
+                             shape = "narrow", turn = 90, cam = "wide" },
+  ["ROCKET_HIDEOUT_B4F"] = { x = 19, y = 17, shape = "wide", cam = "wide" },
   ["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, shape = "wide" },
-  ["SAFARI_ZONE_EAST"] = { x = 21, y = 8, shape = "wide" },
-  ["SAFARI_ZONE_NORTH"] = { x = 19, y = 14, shape = "wide" },
-  ["SAFARI_ZONE_WEST"] = { x = 18, y = 3, shape = "wide" },
+  ["SAFARI_ZONE_EAST"] = { x = 16, y = 8,
+                           shape = "wide", turn = 90, cam = "wide" },
+  ["SAFARI_ZONE_NORTH"] = { x = 22, y = 12, shape = "wide", turn = 90 },
+  ["SAFARI_ZONE_WEST"] = { x = 20, y = 2, shape = "wide" },
   ["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" },
-  ["SEAFOAM_ISLANDS_B1F"] = { x = 11, y = 1, shape = "wide" },
-  ["SEAFOAM_ISLANDS_B2F"] = { x = 16, y = 2, shape = "wide" },
-  ["SEAFOAM_ISLANDS_B3F"] = { x = 25, y = 7, shape = "wide" },
-  ["SEAFOAM_ISLANDS_B4F"] = { x = 12, y = 6, shape = "narrow" },
+  ["SEAFOAM_ISLANDS_B1F"] = { x = 9, y = 8,
+                              shape = "wide", turn = 90, cam = "wide" },
+  ["SEAFOAM_ISLANDS_B2F"] = { x = 15, y = 9, shape = "wide", turn = 90 },
+  ["SEAFOAM_ISLANDS_B3F"] = { x = 26, y = 7, shape = "wide", turn = 180 },
+  ["SEAFOAM_ISLANDS_B4F"] = { x = 9, y = 7, shape = "narrow" },
   -- Silph Co is office floors partitioned into small rooms, so the long lens
   -- often lands outside the walls it is meant to be looking between; the
   -- floors that could not be framed any other way ask for the wide one.
-  ["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" },
-  ["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" },
+  ["SILPH_CO_2F"] = { x = 4, y = 9, shape = "narrow", turn = 270 },
+  ["SILPH_CO_4F"] = { x = 14, y = 14, shape = "narrow", turn = 90 },
   ["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" },
-  ["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" },
+  ["SILPH_CO_6F"] = { x = 19, y = 2,
+                      shape = "narrow", turn = 90, cam = "wide" },
   ["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" },
   ["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" },
   ["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" },
-  ["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" },
+  ["SS_ANNE_1F_ROOMS"] = { x = 11, y = 1, shape = "narrow", cam = "wide" },
   -- the ship is all two-cell corridors, so the wide arena shape fits nowhere
   -- aboard and the long lens always lands outside the hull
   ["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" },
   -- these two decks are byte-identical geometry, so they take the same spot
-  ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
-  ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
+  ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
+  ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
   ["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" },
-  ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" },
-  ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" },
+  ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide", cam = "wide" },
+  ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide", cam = "wide" },
 
   -- ------- towns and the last interiors
   ["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" },
-  ["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" },
+  ["GAME_CORNER"] = { x = 8, y = 5, shape = "wide" },
   -- the lab is ten cells by twelve, so the long lens is always off-map, and
   -- on it a desk clipped one mon and a pillar the other
-  ["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" },
+  ["OAKS_LAB"] = { x = 1, y = 3, shape = "narrow", turn = 90, cam = "wide" },
   -- Saffron's gym is a grid of small walled cells: no wide shape exists
   -- anywhere in it, and the long lens sits inside a divider
   ["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" },
   ["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" },
-  ["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" },
-  ["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
+  ["SILPH_CO_10F"] = { x = 1, y = 1, shape = "wide", cam = "wide" },
+  ["SILPH_CO_11F"] = { x = 10, y = 6,
+                       shape = "wide", turn = 180, cam = "wide" },
   -- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the
   -- gym's barrier, so arenas there silently fail the fit test
-  ["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" },
-  ["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" },
+  ["VERMILION_GYM"] = { x = 3, y = 2,
+                        shape = "narrow", turn = 90, cam = "wide" },
+  ["VICTORY_ROAD_1F"] = { x = 11, y = 5, shape = "narrow", cam = "wide" },
   ["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" },
 
   -- ------- the remaining routes
-  ["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
+  ["ROUTE_19"] = { x = 8, y = 31, shape = "narrow" },
   -- the two surf routes fight AFLOAT, in the middle of their own sea rather
   -- than on the rim of beach the land search would otherwise find
   ["ROUTE_20"] = { x = 23, y = 7, shape = "wide" },
   ["ROUTE_21"] = { x = 8, y = 46, shape = "wide" },
-  ["ROUTE_22"] = { x = 35, y = 7, shape = "wide" },
-  ["ROUTE_23"] = { x = 4, y = 36, shape = "wide" },
-  ["ROUTE_24"] = { x = 13, y = 15, shape = "wide" },
+  ["ROUTE_22"] = { x = 35, y = 7, shape = "wide", cam = "wide" },
+  ["ROUTE_23"] = { x = 2, y = 34, shape = "wide", cam = "wide" },
+  ["ROUTE_24"] = { x = 6, y = 9, shape = "wide", turn = 270, cam = "wide" },
   ["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" },
 
   -- ------- caves, gyms and the Elite Four
@@ -177,25 +183,29 @@ return {
   -- None of these tilesets has a grass tile at all, so the no-grass rule
   -- constrained nothing here; what constrains them is furniture, rock
   -- pillars and how small the rooms are.
-  ["AGATHAS_ROOM"] = { x = 2, y = 1, shape = "narrow", cam = "wide" },
-  ["BRUNOS_ROOM"] = { x = 3, y = 1, shape = "narrow" },
-  ["CELADON_GYM"] = { x = 0, y = 3, shape = "narrow" },
-  ["CERULEAN_CAVE_1F"] = { x = 1, y = 7, shape = "narrow" },
+  ["AGATHAS_ROOM"] = { x = 4, y = 2, shape = "narrow", cam = "wide" },
+  ["BRUNOS_ROOM"] = { x = 2, y = 1, shape = "wide", turn = 90 },
+  ["CELADON_GYM"] = { x = 3, y = 4,
+                      shape = "narrow", turn = 90, cam = "wide" },
+  ["CERULEAN_CAVE_1F"] = { x = 12, y = 8, shape = "narrow", cam = "wide" },
   -- 2F is a maze of one-cell rock corridors; all 24 of its candidate spots
   -- hide a mon, so it borrows the floor below -- the same cave
   ["CERULEAN_CAVE_2F"] = { map = "CERULEAN_CAVE_B1F", x = 2, y = 0,
                            shape = "wide" },
-  ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" },
-  ["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" },
+  ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide", cam = "wide" },
+  ["CERULEAN_GYM"] = { x = 4, y = 2, shape = "wide" },
   ["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "wide" },
-  ["CINNABAR_GYM"] = { x = 18, y = 10, shape = "narrow" },
-  ["DIGLETTS_CAVE"] = { x = 19, y = 16, shape = "wide" },
-  ["FIGHTING_DOJO"] = { x = 4, y = 1, shape = "narrow" },
-  ["LANCES_ROOM"] = { x = 5, y = 15, shape = "wide" },
-  ["LORELEIS_ROOM"] = { x = 5, y = 2, shape = "narrow" },
-  ["MT_MOON_1F"] = { x = 24, y = 17, shape = "wide" },
-  ["MT_MOON_B1F"] = { x = 5, y = 12, shape = "wide" },
-  ["MT_MOON_B2F"] = { x = 2, y = 16, shape = "wide" },
+  ["CINNABAR_GYM"] = { x = 9, y = 16,
+                       shape = "narrow", turn = 90, cam = "wide" },
+  ["DIGLETTS_CAVE"] = { x = 14, y = 26,
+                        shape = "wide", turn = 90, cam = "wide" },
+  ["FIGHTING_DOJO"] = { x = 3, y = 5,
+                        shape = "narrow", turn = 90, cam = "wide" },
+  ["LANCES_ROOM"] = { x = 4, y = 2, shape = "wide", cam = "wide" },
+  ["LORELEIS_ROOM"] = { x = 4, y = 2, shape = "narrow" },
+  ["MT_MOON_1F"] = { x = 25, y = 16, shape = "wide" },
+  ["MT_MOON_B1F"] = { x = 4, y = 11, shape = "wide" },
+  ["MT_MOON_B2F"] = { x = 8, y = 20, shape = "wide" },
 
   -- The three gyms the default rig cannot stand back from. Five blocks is
   -- further than these rooms are wide, so the eye landed outside the map and
@@ -204,7 +214,7 @@ return {
   -- BattleCam), which fits inside the room; the mons come out smaller and all
   -- three became stageable. It is asked for HERE, per map, so every area that
   -- does not ask keeps the long lens it was framed for.
-  ["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" },
-  ["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" },
+  ["FUCHSIA_GYM"] = { x = 8, y = 6, shape = "narrow", cam = "wide" },
+  ["PEWTER_GYM"] = { x = 4, y = 1, shape = "narrow", turn = 90, cam = "wide" },
   ["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" },
 }
diff --git a/lib/Diorama.lua b/lib/Diorama.lua
index c63b3b1..7df22b4 100644
--- a/lib/Diorama.lua
+++ b/lib/Diorama.lua
@@ -113,6 +113,41 @@ function Diorama.reset()
   Diorama.release()
 end
 
+-- ------- which way a staged fight lies on the table
+--
+-- The disc's bearing while a battle is up: the player's own hand-turn, with
+-- the ARENA's quarter turn taken back out of it.
+--
+-- An arena may be laid down any of the four ways (BattleArena's `turn`), and
+-- the promise that field makes everywhere else is that turning it changes the
+-- GROUND under the fight and never the fight itself -- the two Pokemon land
+-- on the same marks, seen the same way round. Every other camera keeps that
+-- promise by construction: the flat shot and the standard VR mount are both
+-- built from BattleCam's eye, which turns with the arena, so the composition
+-- follows it round.
+--
+-- This one is not built from that eye. It is a disc of map lifted onto the
+-- table, and its bearing is the arena's bearing in the WORLD -- so a fight
+-- staged on a turned arena arrived on the table lying across the head that
+-- was looking at it, while the same fight on an unturned one faced properly.
+-- Same fight, same composition everywhere else, sideways here.
+--
+-- So the turn comes back out. Subtracted, matching the sign the standard
+-- mount already lands on: its yaw is atan2 of (eye - focus), and rotating
+-- that pair by +turn takes the bearing to (bearing - turn). One rule, two
+-- seats.
+--
+-- The hand-turn stays on top of it, because that is the player moving the
+-- model and is theirs to keep.
+function Diorama.battleYaw(arena)
+  local turn = (arena and arena.turn) or 0
+  if turn == 0 then return Diorama.yaw end
+  local yaw = Diorama.yaw - math.rad(turn)
+  -- kept in (-pi, pi] like the grips leave it, so nothing downstream has to
+  -- care which way round it came
+  return (yaw + math.pi) % (2 * math.pi) - math.pi
+end
+
 -- Open a diorama frame. `mode` is VR.mode()'s answer; anything that is
 -- not a diorama mode closes it.
 function Diorama.begin(mode)
diff --git a/lib/VR.lua b/lib/VR.lua
index 3682f20..c95ccea 100644
--- a/lib/VR.lua
+++ b/lib/VR.lua
@@ -371,7 +371,11 @@ local function renderWorld(views, ctl)
     end
     anchor = VRRig.dioramaAnchor(Voxel.angle, Diorama.offset)
     scale = VRRig.dioramaScale(frame, Voxel.FOCAL) / zoom
-    if Diorama.yaw ~= 0 then mountYaw = Diorama.yaw end
+    -- the hand-turn, and -- while a fight is staged -- the arena's own
+    -- quarter turn taken back out, so a turned arena arrives on the table
+    -- facing the head rather than lying across it (Diorama.battleYaw)
+    local dioYaw = battle and Diorama.battleYaw(battle) or Diorama.yaw
+    if dioYaw ~= 0 then mountYaw = dioYaw end
   elseif battle then
     -- the over-the-shoulder seat the flat battle shot stands in, pulled
     -- close enough for a headset's own lens (see VRRig.battleMount), at
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 977333f..4a7ecfa 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -5027,6 +5027,68 @@ local eyaw = select(2, VRRig.battleMount({ 150, 10, 200 }, { 100, 10, 200 }))
 T.check(near(eyaw, math.pi / 2),
   "a camera east of its focus turns the mapping a quarter toward west")
 
+-- ------- both VR seats follow the arena's quarter turn
+--
+-- `turn` promises the same composition on different ground, and every camera
+-- has to keep it or the promise is only true on the flat screen. The two VR
+-- seats keep it in different ways and both are pinned here.
+--
+-- THE STANDARD MOUNT keeps it by construction: it is built from the flat
+-- battle camera's own eye and aim, which turn with the arena, so the seat
+-- goes round with them. That is asserted rather than assumed, because the
+-- day someone builds this seat from the arena's cells instead, the picture
+-- stays plausible and starts pointing the wrong way.
+--
+-- THE DIORAMA disc does NOT get it for free: a fight arrives there as a
+-- pillar of map about the arena's midpoint, whose bearing is its bearing in
+-- the WORLD, so a turned arena landed on the table lying across the head.
+-- Diorama.battleYaw takes the turn back out; the hand-turn rides on top.
+--
+-- In its own scope: the suite's main chunk is at LuaJIT's local ceiling.
+;(function()
+  local Arena = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleArena")
+  local Cam = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleCam")
+  local Dio = run.loader.exports.DRAMATIC_SHAPE.lib.require("Diorama")
+
+  local function mountYawFor(turn)
+    Cam.reset()
+    local a = Arena.at(6, 6, "wide", turn)
+    local rig = Cam.rig(a, 0, true)
+    return select(2, VRRig.battleMount(rig.eye, rig.focus))
+  end
+
+  local base = mountYawFor(0)
+  for _, turn in ipairs({ 90, 180, 270 }) do
+    -- rotating (eye - focus) by +turn takes atan2(dx, dz) to (bearing - turn)
+    local want = (base - math.rad(turn) + math.pi) % (2 * math.pi) - math.pi
+    local got = (mountYawFor(turn) + math.pi) % (2 * math.pi) - math.pi
+    T.check(near(got, want, 1e-6),
+      ("the standard VR mount turns with a %d-degree arena: %.4f, wanted %.4f")
+      :format(turn, got, want))
+  end
+
+  -- the disc: the turn comes back out, so the fight lies the same way on the
+  -- table however the ground under it is laid
+  local hand = Dio.yaw
+  Dio.yaw = 0
+  T.eq(Dio.battleYaw({ turn = 0 }), 0,
+    "an unturned arena leaves the disc where the hands left it")
+  T.check(near(Dio.battleYaw({ turn = 90 }), -math.pi / 2, 1e-9),
+    "a quarter-turned arena counter-turns the disc a quarter")
+  T.check(near(Dio.battleYaw({ turn = 270 }), math.pi / 2, 1e-9),
+    "and three quarters comes round the other way, inside (-pi, pi]")
+  T.eq(Dio.battleYaw(nil), 0, "no arena is no turn, not an error")
+  -- and the same rule the standard mount lands on, so the two agree
+  T.check(near(Dio.battleYaw({ turn = 90 }) - Dio.battleYaw({ turn = 0 }),
+               mountYawFor(90) - mountYawFor(0), 1e-6),
+    "the disc and the standard mount answer a turn by the same amount")
+  -- the player's own hand-turn still rides on top of it
+  Dio.yaw = math.rad(30)
+  T.check(near(Dio.battleYaw({ turn = 90 }), math.rad(30) - math.pi / 2, 1e-9),
+    "the hands keep whatever they did to the model, on top of the turn")
+  Dio.yaw = hand
+end)()
+
 -- an eye seated with that yaw really faces the arena: an identity head at
 -- the seat comes out looking WEST, and the view agrees to the metre
 local seated = VRRig.eyeCamera(pose, fov, { 150, 10, 200 }, { 0, 0, 0 },