DramaticShapeVoxelMod archival mirror · read-only
dfe2f1ae1af0218fcf0b775fae66c2321077661f

fix regressions with freemove

DramaticShape · 2026-08-08 · parent 41f1f07342

fix regressions with freemove

Changed files

CHANGELOG.md                  |  13 +++++
 lib/CatchThrow.lua            |  10 ++++
 tests/dramatic_shape_test.lua |  15 ++++++
 tests/freemove_probe.lua      | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/letsgo_empty.lua        | 105 +++++++++++++++++++++++++++++++++++----
 5 files changed, 299 insertions(+), 10 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63ea377..3425ca2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -79,6 +79,19 @@
 
 ### Fixed
 
+- **B now actually runs from capture mode -- and A throws, and L/R switch
+  balls.** The capture session read its button presses on the RENDER clock,
+  along with everything else it does per frame. Button edges do not survive
+  there: the engine rebuilds the edge table once per fixed logic step and
+  runs all of a frame's steps BEFORE the render-clock hooks, so any frame
+  carrying more than one step had already thrown the press away before
+  anything looked at it. That is not a rare race -- it is every press below
+  60fps, which is exactly where a 3D battle lives, so these buttons were
+  reliably dead on the machines that most needed them and fine on a 144Hz
+  one. They are read on the logic step now, through the engine's own
+  input.step seam, and taken rather than peeked so a press the capture used
+  does not also page the message it just queued.
+
 - **The grass moves during a staged battle.** The wind is switched on around
   the free-roam pass's grass draws and off again after them, and the battle
   pass -- which draws the same tufts, on the same map, from its own camera --
diff --git a/lib/CatchThrow.lua b/lib/CatchThrow.lua
index 47bc7c5..287e0f3 100644
--- a/lib/CatchThrow.lua
+++ b/lib/CatchThrow.lua
@@ -1228,6 +1228,16 @@ end
 
 function CatchThrow.buttons(g)
   if not (S and S.phase == "aim") then return end
+  -- This runs on EVERY logic step for the whole session, so it has to be
+  -- certain the fight it is aiming into is still the thing on screen. A
+  -- session that outlived its battle -- a script tearing the fight down, a
+  -- forced finish, an error between the throw and the sweep -- would
+  -- otherwise sit in the overworld silently eating A, B and L/R out of the
+  -- queue every step, which reads as "the buttons stopped working" and
+  -- points nowhere near here.
+  local b = S.battle
+  if not b or b.result then return end
+  if not (g and g.stack and g.stack:top() == b) then return end
   -- the same beat of deafness the drag has: the A that picked the ball out
   -- of the bag menu is still this step's edge, and must not become a throw
   if S.clock < 0.25 then return end
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 1a3524d..7cc7a53 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -6432,6 +6432,21 @@ end)()
   -- the row answers the mode, and OFF answers false
   T.eq(LetsGo.mode(), false, "LET'S GO defaults to OFF")
 
+  -- The capture's buttons are read on the LOGIC STEP, through the engine's
+  -- input.step seam, because edges do not survive the render clock (see
+  -- CatchThrow.buttons). The hook calls it under pcall, so a rename would
+  -- be swallowed silently and B would simply stop working -- which is
+  -- exactly the failure it was written to fix. Assert the name exists, and
+  -- that it is harmless with no session, since it runs every single step.
+  do
+    local CatchThrow = lib.require("CatchThrow")
+    T.eq(type(CatchThrow.buttons), "function",
+      "the capture reads its buttons on the logic step, by name")
+    local q = { "a", "b" }
+    CatchThrow.buttons({ input = { pressQueue = q } })
+    T.eq(#q, 2, "and with no session in flight it takes nothing and does nothing")
+  end
+
   -- ------- the scripted catch tutorials are none of LET'S GO's business
   --
   -- The VIRIDIAN CITY old man and Yellow's PROF.OAK / PIKACHU intro are
diff --git a/tests/freemove_probe.lua b/tests/freemove_probe.lua
new file mode 100644
index 0000000..47cc62b
--- /dev/null
+++ b/tests/freemove_probe.lua
@@ -0,0 +1,166 @@
+-- Probe: does WASD still walk CAMERA-RELATIVE on the 1ST/3RD rungs?
+--
+-- The regression report is "in first and third person the wasd keys now
+-- move in cardinal directions". Cardinal means the yaw is not being
+-- applied -- either FreeMove.tick is not the handler that ran (so the
+-- engine's grid walk did, which is cardinal by construction), or it ran
+-- and moveWorld got a yaw of zero.
+--
+-- So measure both: which handler took the frame, what the yaw was, and
+-- which way the player actually travelled for a held W.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/freemove_probe.lua \
+--   "/c/Program Files/LOVE/lovec.exe" .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    U.log("DRAMATIC_SHAPE is not loaded")
+    return love.event.quit()
+  end
+  local V = handle.lib
+  local FirstPerson = V.require("FirstPerson")
+  local FreeMove = V.require("FreeMove")
+  local Voxel = V.require("VoxelState")
+  local ChunkMesher = V.require("ChunkMesher")
+
+  require("src.world.OverworldController").rollEncounter = function() return nil end
+
+  -- FirstPerson captures the mouse only with WINDOW FOCUS, and a driver
+  -- window never has it -- so without this the look reads as dead for a
+  -- reason that has nothing to do with the code under test. Force the
+  -- answer it gates on; setRelativeMode then arms and the relative-motion
+  -- wrap claims the deltas exactly as it would for a player.
+  love.window.hasFocus = function() return true end
+
+  -- count which walk handler actually takes the frames
+  local ticks = 0
+  local innerTick = FreeMove.tick
+  FreeMove.tick = function(...)
+    ticks = ticks + 1
+    return innerTick(...)
+  end
+
+  U.teleport(game, "ROUTE_1", 5, 8, "down")
+  U.wait(60)
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if FirstPerson.blend >= 1 and Voxel.ready then break end
+      U.wait(1)
+    end
+    U.wait(30)
+  end
+
+  -- W is the UP button; the B button's key is "x" (see Input's
+  -- DEFAULT_BINDINGS -- the driver must press KEYS, not button names, to
+  -- exercise the real path)
+  local function holdKey(k, frames)
+    love.keypressed(k, k, false)
+    U.wait(frames)
+    love.keyreleased(k, k)
+    U.wait(4)
+  end
+
+  for _, rung in ipairs({ { "1ST", Voxel.FP_LEVEL }, { "3RD", Voxel.TP_LEVEL } }) do
+    Pipelines.setLevel("voxel", rung[2])
+    settle()
+    -- face EAST: yaw is the free-roam look, and a camera-relative W must
+    -- then walk +X. A cardinal W walks -Y (north) whatever the camera does.
+    FirstPerson.yaw = math.pi / 2
+    U.wait(10)
+
+    local ow = game.stack:top()
+    local p = ow and ow.player
+    if not p then U.log(rung[1] .. ": no player") break end
+    local x0, y0 = p.px, p.py
+    ticks = 0
+    holdKey("w", 40)
+    local dx, dy = p.px - x0, p.py - y0
+    U.log(("%s: driving=%s freeMove ticks=%d yaw=%.2f  W moved dx=%.1f dy=%.1f")
+          :format(rung[1], tostring(FirstPerson.driving()), ticks,
+                  FirstPerson.yaw, dx, dy))
+    local wx, wz = FirstPerson.moveWorld(0, 1)
+    U.log(("%s: moveWorld(0,1) = %.2f,%.2f  (want a mostly-X vector at this yaw)")
+          :format(rung[1], wx, wz))
+
+    -- ------- and does the LOOK still turn?
+    --
+    -- A yaw that never moves is the same symptom from the player's seat:
+    -- it stays at the cardinal angle the rung was entered on (FACING_ANGLE
+    -- is one of four compass points), so W walks due north for ever and
+    -- "wasd moves in cardinal directions" is exactly what it feels like.
+    -- The capture mode's pointer wraps are installed OUTSIDE FirstPerson's,
+    -- so this is the path that could have regressed.
+    -- FirstPerson only claims relative motion while it has CAPTURED the
+    -- mouse, and it captures only with window focus. A driver window that
+    -- never got focus would show a dead look for a reason that has nothing
+    -- to do with the code -- so record the discriminator rather than read
+    -- a zero and blame the wrap.
+    local okF, focus = pcall(function() return love.window.hasFocus() end)
+    local okR, rel = pcall(function() return love.mouse.getRelativeMode() end)
+    U.log(("%s: engaged=%s focus=%s relativeMode=%s")
+          :format(rung[1], tostring(FirstPerson.engaged()),
+                  okF and tostring(focus) or "?",
+                  okR and tostring(rel) or "?"))
+
+    local before = FirstPerson.yaw
+    for _ = 1, 10 do
+      love.mousemoved(400, 300, 12, 0, false)
+      U.wait(1)
+    end
+    U.wait(4)
+    U.log(("%s: mouse look -- yaw %.3f -> %.3f (delta %.3f)%s")
+          :format(rung[1], before, FirstPerson.yaw, FirstPerson.yaw - before,
+                  math.abs(FirstPerson.yaw - before) < 1e-6
+                  and "   <-- THE LOOK IS DEAD" or ""))
+  end
+
+  -- ------- and now the way a PLAYER gets there: the "3" hotkey
+  --
+  -- Pipelines.setLevel above is the driver's shortcut. A player cycles the
+  -- rung with 3, which goes through the mod's own cycleVoxel. If that
+  -- leaves Voxel.level disagreeing with the pipeline's level, the camera
+  -- can be first-person while FreeMove.engaged() says no -- and then the
+  -- ENGINE's grid handler takes the frame, which walks cardinally. That is
+  -- the reported symptom exactly, so it is worth entering the rung the
+  -- same way the report did.
+  Pipelines.setLevel("voxel", 0)
+  U.wait(20)
+  for i = 1, 8 do
+    love.keypressed("3", "3", false)
+    U.wait(3)
+    love.keyreleased("3", "3")
+    U.wait(12)
+    local lvl = Pipelines.level("voxel")
+    U.log(("hotkey 3 x%d -> pipeline level=%s  Voxel.level=%s  freeCam=%s "
+           .. "engaged=%s driving=%s")
+          :format(i, tostring(lvl), tostring(Voxel.level),
+                  tostring(Voxel.isFreeCam(Voxel.level)),
+                  tostring(FirstPerson.engaged()),
+                  tostring(FirstPerson.driving())))
+    if Voxel.isFreeCam(Voxel.level) then
+      settle()
+      local ow = game.stack:top()
+      local p = ow and ow.player
+      FirstPerson.yaw = math.pi / 2
+      U.wait(6)
+      local yaw0 = FirstPerson.yaw
+      local x0, y0 = p.px, p.py
+      ticks = 0
+      holdKey("w", 30)
+      U.log(("  walked from the HOTKEY rung: ticks=%d yaw=%.2f dx=%.1f dy=%.1f%s")
+            :format(ticks, yaw0, p.px - x0, p.py - y0,
+                    ticks == 0 and "   <-- ENGINE GRID WALK (cardinal)" or ""))
+    end
+  end
+
+  FreeMove.tick = innerTick
+  U.log("done")
+end
diff --git a/tests/letsgo_empty.lua b/tests/letsgo_empty.lua
index a1c3342..9d8646b 100644
--- a/tests/letsgo_empty.lua
+++ b/tests/letsgo_empty.lua
@@ -67,7 +67,13 @@ return function(game)
   -- this driver has to be able to see (button edges read on the render
   -- clock instead of the logic step) lives between love.keypressed and
   -- whoever polls the edge, so a driver that writes the queue itself
-  -- jumps straight over it. "b" is the default binding for the B button.
+  -- jumps straight over it.
+  --
+  -- The B BUTTON's keyboard binding is "x" (or backspace) -- Input's
+  -- DEFAULT_BINDINGS. The `b = "b"` next to it is the GAMEPAD table, so a
+  -- driver that presses the "b" KEY presses nothing at all and reports a
+  -- dead button whatever the code does.
+  local B_KEY = "x"
   local function key(name)
     love.keypressed(name, name, false)
     U.wait(2)
@@ -90,6 +96,20 @@ return function(game)
     U.wait(30)
   end
 
+  -- Tap the fight all the way off the stack before the next case starts.
+  -- Cases that merely tapped A a fixed number of times left the previous
+  -- battle (and its session) alive whenever an outcome ran long, and the
+  -- next case then measured the leftover -- which reads as that case
+  -- failing, at a spot nowhere near the cause.
+  local function closeOut(battle)
+    for _ = 1, 400 do
+      if not CatchThrow.session() and game.stack:top() ~= battle then break end
+      U.tap(game, "a")
+      U.wait(4)
+    end
+    U.wait(40)
+  end
+
   local function describe(tag, battle)
     local s = CatchThrow.session()
     U.log(("%s: session=%s empty=%s phase=%s battle=%s veil=%s hidePlayer=%s")
@@ -116,7 +136,7 @@ return function(game)
   U.shot(game, DIR .. "/empty_1_aim.png")
 
   -- and B is the way out: a Let's Go wild always escapes
-  key("b")
+  key(B_KEY)
   for _ = 1, 120 do
     U.wait(2)
     if a.result then break end
@@ -126,8 +146,7 @@ return function(game)
   -- the session and the held camera are swept by battle.ended, which is the
   -- teardown BELOW this, not the moment `result` is written -- so the sweep
   -- is only worth asserting once the battle has actually left the stack
-  for _ = 1, 40 do U.tap(game, "a"); U.wait(4) end
-  U.wait(60)
+  closeOut(a)
   U.log(("A: after teardown -- session=%s veil=%s (want gone/down)")
         :format(CatchThrow.session() and "still up" or "gone",
                 BattleScene.capture and "still up" or "down"))
@@ -139,6 +158,13 @@ return function(game)
   U.log("== B: FULL with " .. ballCount() .. " ball")
   local b = BattleState.newWild(game, "PIDGEY", 5)
   b.onFinish = function() end
+  -- The outcome under test is what happens when the LAST ball MISSES, so
+  -- the roll must not be left to chance: a run where the Pidgey happened
+  -- to be caught used to cascade into every later case (the session lived
+  -- on into its epilogue, and C and D then measured that leftover instead
+  -- of their own). Forced failure, three shakes -- the roll is the
+  -- engine's business and is covered elsewhere.
+  b.catchAttempt = function() return false, 3 end
   game.overworld:pushBattle(b)
   toCapture(b)
   describe("B", b)
@@ -186,15 +212,14 @@ return function(game)
                 (sb and ("still holding " .. tostring(sb.ballId)))
                 or "SESSION GONE"))
   U.shot(game, DIR .. "/empty_2_ranout.png")
-  key("b")
+  key(B_KEY)
   for _ = 1, 120 do
     U.wait(2)
     if b.result then break end
     U.tap(game, "a")
   end
   U.log("B: after B -- result=" .. tostring(b.result) .. " (want run)")
-  for _ = 1, 40 do U.tap(game, "a"); U.wait(4) end
-  U.wait(60)
+  closeOut(b)
 
   -- ------- C: the scripted tutorial, untouched
 
@@ -256,7 +281,68 @@ return function(game)
     toCapture(d)
     local sd = describe("D" .. speed, d)
     if sd and not sd.empty then
-      key("b")
+      -- where does the key actually get to? Each stage of the chain, so a
+      -- dead B is attributed rather than guessed at
+      local top = game.stack and game.stack:top()
+      U.log(("D%dX probe: top=%s onKeyPressed=%s  fullWild=%s declinable=%s")
+            :format(speed, tostring(top and top.screenId or "?"),
+                    tostring(top and top.onKeyPressed ~= nil),
+                    tostring(sd.fullWild), tostring(sd.declinable)))
+      -- ------- the counter-factual, measured rather than argued
+      --
+      -- How many times an edge would have been visible to a poll on the
+      -- RENDER clock -- where these reads used to live. Measured on UP
+      -- rather than B: the fix TAKES B out of the queue, so B never
+      -- reaches `pressed` any more and would read as a false zero. UP is
+      -- untouched by everything while the battle is parked, and the
+      -- question is about step-vs-frame ordering, not about which button.
+      local onFrame, onStep = 0, 0
+      local innerU = CatchThrow.update
+      CatchThrow.update = function(dt)
+        if game.input.wasPressed and game.input:wasPressed("up") then
+          onFrame = onFrame + 1
+        end
+        return innerU(dt)
+      end
+      local innerB0 = CatchThrow.buttons
+      CatchThrow.buttons = function(g)
+        local q = g and g.input and g.input.pressQueue
+        if q then
+          for i = 1, #q do if q[i] == "up" then onStep = onStep + 1 end end
+        end
+        return innerB0(g)
+      end
+      love.keypressed("up", "up", false)
+      U.wait(2)
+      love.keyreleased("up", "up")
+      U.wait(2)
+      CatchThrow.buttons = innerB0
+      CatchThrow.update = innerU
+      U.log(("D%dX counter-factual: one UP press -- the logic step saw it "
+             .. "%d time(s), the RENDER clock %d time(s)%s")
+            :format(speed, onStep, onFrame,
+                    onFrame == 0 and "  <-- a frame poll misses it entirely"
+                    or ""))
+
+      local seen = 0
+      local innerB = CatchThrow.buttons
+      CatchThrow.buttons = function(g)
+        local q = g and g.input and g.input.pressQueue
+        if q and #q > 0 then
+          seen = seen + 1
+          U.log("D probe: buttons saw queue [" .. table.concat(q, ",") .. "]")
+        end
+        return innerB(g)
+      end
+      love.keypressed(B_KEY, B_KEY, false)
+      U.log(("D probe: right after keypressed -- queue=[%s] state.b=%s")
+            :format(table.concat(game.input.pressQueue, ","),
+                    tostring(game.input.state.b)))
+      U.wait(2)
+      love.keyreleased(B_KEY, B_KEY)
+      U.wait(2)
+      CatchThrow.buttons = innerB
+      U.log("D probe: buttons saw a non-empty queue " .. seen .. " time(s)")
       local ran = false
       for _ = 1, 60 do
         U.wait(2)
@@ -274,8 +360,7 @@ return function(game)
       pcall(CatchThrow.onBattleEnded)
       d.result, d.phase, d.afterQueue = "run", "messages", "finish"
     end
-    for _ = 1, 80 do U.tap(game, "a"); U.wait(4) end
-    U.wait(60)
+    closeOut(d)
     game.speedOverride = nil
   end
   U.log("done -- " .. DIR)