From dfe2f1ae1af0218fcf0b775fae66c2321077661f Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 8 Aug 2026 10:42:28 -0400 Subject: [PATCH] fix regressions with freemove --- 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(-) create mode 100644 tests/freemove_probe.lua 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)