diff --git a/src/core/FaithfulRes.lua b/src/core/FaithfulRes.lua index c7fc4946..e1535c77 100644 --- a/src/core/FaithfulRes.lua +++ b/src/core/FaithfulRes.lua @@ -8,8 +8,21 @@ -- letterbox entirely, so the surface is the Game Boy screen and nothing else. -- -- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu --- and on boot via Game:applyOptions. No-ops on mobile and in headless stubs --- that lack love.window. +-- and on boot via Game:applyOptions. No-ops in headless stubs that lack +-- love.window. +-- +-- MOBILE takes the other route to the same place. There is no window to +-- resize -- the window IS the screen, and it rotates -- so the lock caps the +-- RENDER scale instead: the renderer draws the Game Boy screen at exactly N +-- physical pixels per GB pixel and centres it, and the rest of the display +-- stays black. Same promise as the desktop lock (a GB pixel is exactly N +-- screen pixels, no more) reached by moving the picture rather than the +-- window. This used to return false on the first line, so the row sat in +-- OPTIONS on Android and iOS doing nothing at all. +-- +-- Scale, not size, is also what makes rotation free: Renderer:fitScale runs +-- every frame off the live drawable size, so portrait and landscape both get +-- the same locked scale with the bars falling wherever the screen is longer. local FaithfulRes = {} @@ -17,6 +30,10 @@ FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144 FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 } FaithfulRes.DEFAULT = 0 +-- mobile only: the locked scale in physical pixels per GB pixel, 0 for OFF. +-- Renderer:fitScale reads it through FaithfulRes.scaleCap. +FaithfulRes.mobileScale = 0 + -- conf.lua's floor for the resizable desktop window, restored when the lock -- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum -- as well as set the size or LOVE clamps the window back up. @@ -25,21 +42,52 @@ FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360 -- whether this module currently owns the window size FaithfulRes.locked = false +-- The highest level this display can actually show. +-- +-- On desktop it is 4: the levels are window sizes, and 4X is the ceiling the +-- feature shipped with. On mobile there is no window to size, so a fixed +-- 1..4 ladder is meaningless -- 4X is a quarter of a 1080p phone, and the +-- levels the panel could really use are not on the list at all. Derive it +-- from the screen instead, so a 1080x2400 phone offers up to 6X and the top +-- of the ladder is the biggest exact-pixel picture it can draw. +-- +-- OFF (0) is untouched by any of this and keeps doing exactly what it always +-- did: the renderer fits and letterboxes as usual. +function FaithfulRes.maxLevel() + -- Mobile is ON or OFF. A ladder of absolute multiples is a desktop idea -- + -- there it names a window size you can see. On a phone the same number + -- means a different fraction of every device, and every level below the top + -- is just a smaller picture for no reason. ON means one thing instead: + -- lock the viewport to the Game Boy's 10:9 and size it to this screen. + if FaithfulRes.isMobile() then return 1 end + return 4 +end + +-- the selectable ladder for this display: OFF, then 1X..maxLevel +function FaithfulRes.levels() + local out = { 0 } + for i = 1, FaithfulRes.maxLevel() do out[#out + 1] = i end + return out +end + function FaithfulRes.normalize(v) v = math.floor(tonumber(v) or FaithfulRes.DEFAULT) if v < 0 then return 0 end - if v > 4 then return 4 end + local max = FaithfulRes.maxLevel() + if v > max then return max end return v end function FaithfulRes.label(v) v = FaithfulRes.normalize(v) if v == 0 then return "OFF" end + -- mobile has one ON: the level is chosen from the display, not the player + if FaithfulRes.isMobile() then return "ON" end return tostring(v) .. "X" end function FaithfulRes.cycle(v, dir) - local levels = FaithfulRes.LEVELS + local levels = FaithfulRes.levels() local cur = 1 for i, level in ipairs(levels) do if level == FaithfulRes.normalize(v) then cur = i break end @@ -48,6 +96,12 @@ function FaithfulRes.cycle(v, dir) end function FaithfulRes.isMobile() + -- POKEPORT_FORCE_MOBILE=1: take the mobile branch on a desktop build, so the + -- scale lock can be seen and driven without a device. The window is still + -- resizable, which is the point -- drag it to a phone aspect, rotate it by + -- dragging the other way, and the lock has to hold through both. Only this + -- module reads isMobile, so the override cannot leak into anything else. + if os.getenv("POKEPORT_FORCE_MOBILE") == "1" then return true end if not love or not love.system or not love.system.getOS then return false end local osName = love.system.getOS() return osName == "Android" or osName == "iOS" @@ -86,13 +140,50 @@ end -- Push the lock into the live window. Returns true when the window is -- locked afterwards. +-- The largest WHOLE multiple of the Game Boy screen this display can hold. +-- Integer, never fractional: a GB pixel has to be the same number of screen +-- pixels in both axes or it is not pixel perfect, it is resampled. +-- +-- The leftover is black bars, and on a tall phone there is a lot of it +-- vertically -- that is simply what a 10:9 screen looks like on a 9:20 +-- display, and it is what an emulator shows too. +function FaithfulRes.deviceScale() + local g = love and love.graphics + if not (g and g.getPixelDimensions) then return 1 end + local pw, ph = g.getPixelDimensions() + if not pw or not ph or pw <= 0 or ph <= 0 then return 1 end + return math.max(1, math.floor(math.min(pw / FaithfulRes.WIDTH, + ph / FaithfulRes.HEIGHT))) +end + +-- The scale the renderer must lock to, or nil for "fit the window as usual". +-- Only ever set on mobile: on desktop the window itself is the lock, so +-- fitScale already lands on N and this would be a second, redundant one. +-- +-- Always the device maximum. Anything less is a smaller picture for no gain, +-- which is how the first cut ended up showing a postage stamp on a 1080p +-- phone. +function FaithfulRes.scaleCap() + if not FaithfulRes.locked then return nil end + if not FaithfulRes.isMobile() then return nil end + return FaithfulRes.deviceScale() +end + function FaithfulRes.apply(v) - if FaithfulRes.isMobile() then return false end + v = FaithfulRes.normalize(v) + -- Mobile: lock the render scale instead of the window. The scale itself + -- comes from the display (deviceScale), not from v -- v only says whether + -- the lock is on. Nothing to restore on release: the renderer simply goes + -- back to filling the display. + if FaithfulRes.isMobile() then + FaithfulRes.locked = v > 0 + FaithfulRes.mobileScale = FaithfulRes.locked and FaithfulRes.deviceScale() or 0 + return FaithfulRes.locked + end if not love or not love.window or not love.window.setMode or not love.window.getMode then return false end - v = FaithfulRes.normalize(v) local curW, curH, flags = love.window.getMode() flags = flags or {} diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 6eb57edf..28618cb2 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,8 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +-- leaf module (no renderer dependency), so requiring it here cannot cycle +local FaithfulRes = require("src.core.FaithfulRes") local Renderer = {} @@ -124,7 +126,16 @@ end function Renderer:fitScale() local _, _, pw, ph = displayMetrics() local w, h = self:uiSize() - return math.max(1, math.floor(math.min(pw / w, ph / h))) + local s = math.max(1, math.floor(math.min(pw / w, ph / h))) + -- FAITHFUL RATIO on mobile locks the scale here rather than by resizing the + -- window, which a phone does not have (see src/core/FaithfulRes.lua). The + -- cap is the largest WHOLE multiple the display holds, so the picture is as + -- big as exact pixels allow and the remainder is bars. Computed per frame + -- off the live drawable size, so a rotate re-derives it with nothing to + -- re-apply. + local cap = FaithfulRes.scaleCap() + if cap and cap < s then s = cap end + return s end -- Integer framebuffer pixels per GB pixel for the UI pass. @@ -224,6 +235,21 @@ end -- tilt is inactive). function Renderer:worldViewSize() local _, _, pw, ph = displayMetrics() + -- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the + -- WHOLE display, so letterbox voids become more map instead of black bars. + -- That is why the lock appeared to do nothing in the overworld: it shrank + -- the UI blit while the map kept filling the screen -- and showed MORE of + -- the map, because a smaller scale fits more world pixels in. + -- + -- Size the view against the LOCKED VIEWPORT rather than the display. A + -- desktop lock gets this for free by making the window exactly 160N x 144N; + -- this is the same sum with the viewport standing in for the window, so + -- both platforms show the same map area at the same zoom. + local cap = FaithfulRes.scaleCap() + if cap then + local uiw, uih = self:uiSize() + pw, ph = uiw * cap, uih * cap + end local sp = Zoom.scale(self:fitScale()) local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp) -- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral), diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 5d066ce6..0cdc7f36 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -354,7 +354,7 @@ local function buildRows(game) -- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE -- because it overrides it: holding an exact size means dropping -- fullscreen. - { id = "faithfulRes", label = Strings("FAITHFUL RES"), + { id = "faithfulRes", label = Strings("FAITHFUL RATIO"), value = function(g) return FaithfulRes.label(g.save.options.faithfulRes) end, diff --git a/tests/engine/faithful_res.lua b/tests/engine/faithful_res.lua index d98b1e26..0f17b044 100644 --- a/tests/engine/faithful_res.lua +++ b/tests/engine/faithful_res.lua @@ -1,4 +1,4 @@ --- FAITHFUL RES: lock the window to an exact 160x144 multiple so the surface +-- FAITHFUL RATIO: lock the window to an exact 160x144 multiple so the surface -- is the Game Boy screen with no letterbox at all. -- -- The interesting parts are the two things a naive setMode gets wrong: the @@ -121,11 +121,22 @@ T.eq(calls[1].flags.minwidth, FaithfulRes.MIN_W, "with conf.lua's floor restored T.eq(calls[1].flags.minheight, FaithfulRes.MIN_H, "on both axes") T.eq(FaithfulRes.locked, false, "and the module no longer claims the window") --- mobile has no resizable window to lock +-- Mobile has no resizable window to lock, so it locks the RENDER scale +-- instead and never calls setMode. It used to report unlocked and do +-- nothing at all, which is why the OPTIONS row was inert on Android and iOS; +-- tests/engine/faithful_res_mobile.lua covers the scale side. calls = stubWindow(1) love.system = { getOS = function() return "Android" end } -T.eq(FaithfulRes.apply(4), false, "mobile reports unlocked") -T.eq(#calls, 0, "and never touches the window") +T.eq(FaithfulRes.apply(4), true, "mobile locks, by capping the render scale") +-- the level asked for is irrelevant on mobile: ON is ON, and the scale is +-- read off the display so the picture is as big as exact pixels allow +T.eq(FaithfulRes.scaleCap(), FaithfulRes.deviceScale(), + "and the scale comes from the display, not from the level") +T.eq(#calls, 0, "still without ever touching the window") +T.eq(FaithfulRes.apply(0), false, "OFF releases it") +T.eq(FaithfulRes.scaleCap(), nil, "and the cap goes away with it") +T.eq(#calls, 0, "the window is left alone either way") +FaithfulRes.mobileScale = 0 love.window, love.system = savedWindow, savedSystem if love.graphics then diff --git a/tests/engine/faithful_res_mobile.lua b/tests/engine/faithful_res_mobile.lua new file mode 100644 index 00000000..9457e5a5 --- /dev/null +++ b/tests/engine/faithful_res_mobile.lua @@ -0,0 +1,134 @@ +-- FAITHFUL RATIO on Android / iOS. +-- +-- On mobile the setting is ON or OFF, and ON means one thing: lock the +-- viewport to the Game Boy's 10:9 at the largest WHOLE multiple this screen +-- can hold, centred, black around it -- the way an emulator opens a Game Boy +-- game on a phone. +-- +-- Three things had to be true and none of them were: +-- +-- * it had to apply at all. FaithfulRes.apply returned false on its first +-- line for mobile, so the OPTIONS row did nothing on Android and iOS. +-- A phone has no window to resize, so the lock caps the RENDER scale. +-- +-- * it had to be sized for the device. The first cut kept the desktop's +-- absolute 1X-4X ladder, which names a window size you can see on a +-- desktop and means a different fraction of every phone: 4X was a quarter +-- of a 1080p display and 5X/6X were not on the list at all. The scale is +-- read off the display now, not chosen by the player. +-- +-- * it had to work in the OVERWORLD. The world pass deliberately expands +-- to cover the whole display so letterbox voids become more map. So the +-- lock shrank the UI blit while the map kept filling the screen -- and +-- showed MORE map, since a smaller scale fits more world pixels in. +-- +-- Pixel perfect throughout: whole multiples only. The leftover is bars, and +-- on a 9:20 phone there is a lot of it vertically. That is what a 10:9 +-- screen looks like on a tall display; stretching to reach the edges would +-- resample every pixel, which is the one thing this setting exists to refuse. +-- luajit tests/engine/faithful_res_mobile.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local FaithfulRes = require("src.core.FaithfulRes") +local Renderer = require("src.render.Renderer") +local Zoom = require("src.render.Zoom") + +local g = love.graphics +local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions +local realOS = love.system and love.system.getOS +local savedOffset = Zoom.offset +love.system = love.system or {} + +local function pose(w, h, osName) + love.system.getOS = function() return osName or "Android" end + g.getDimensions = function() return w, h end + g.getPixelDimensions = function() return w, h end +end + +Renderer.uiWidth, Renderer.uiHeight = Renderer.WIDTH, Renderer.HEIGHT +Zoom.offset = 0 + +-- ------------------------------------------------------- it applies at all + +pose(1080, 2400) -- a Pixel 7, portrait +T.eq(FaithfulRes.isMobile(), true, "the fixture reads as a phone") +T.eq(FaithfulRes.apply(1), true, "FAITHFUL RATIO applies on mobile at all now") +T.eq(FaithfulRes.locked, true, "and reports itself locked") + +-- ------------------------------------------------- one ON, sized by the device + +T.eq(FaithfulRes.maxLevel(), 1, "mobile offers ON, not a ladder of multiples") +T.eq(#FaithfulRes.levels(), 2, "so the row is exactly OFF and ON") +T.eq(FaithfulRes.label(1), "ON", "and ON is spelled ON, not 1X") +T.eq(FaithfulRes.label(0), "OFF", "with OFF unchanged") + +-- 1080/160 = 6.75 and 2400/144 = 16.6, so the largest WHOLE multiple is 6 +T.eq(FaithfulRes.deviceScale(), 6, "the scale comes off the display: 6x here") +T.eq(FaithfulRes.scaleCap(), 6, "and that is what the renderer is told to lock") +T.eq(Renderer:fitScale(), 6, "so a GB pixel is exactly 6 screen pixels") + +-- the player never picks a smaller one, which is how the first cut managed to +-- draw a postage stamp on a 1080p phone +T.eq(FaithfulRes.normalize(3), 1, "any ON value is just ON") +FaithfulRes.apply(1) +T.eq(Renderer:fitScale(), 6, "and always lands on the device maximum") + +-- --------------------------------------------------------- the overworld + +FaithfulRes.apply(0) +local unlockedW = Renderer:worldViewSize() +T.check(unlockedW > 160, "unlocked, the overworld view covers the whole display") + +FaithfulRes.apply(1) +local lockedW, lockedH = Renderer:worldViewSize() +T.eq(lockedW, 160, "locked, the overworld shows exactly a GB screen wide") +T.eq(lockedH, 144, "and exactly a GB screen tall") +T.check(lockedW < unlockedW, + "so the lock SHRINKS the map area instead of growing it") + +-- ------------------------------------------------------ pixel perfect + +-- 6 x 160 = 960 of 1080 wide. Reaching the edges would need 6.75, which +-- resamples every pixel; the bars are the honest answer. +local cap = FaithfulRes.scaleCap() +T.eq(cap, math.floor(cap), "the locked scale is a whole number, never fractional") +T.eq(160 * cap, 960, "which puts the GB screen at 960 of 1080 pixels wide") + +-- ------------------------------------------------------- rotation is free + +-- fitScale reads the live drawable size every frame, so a rotate re-derives +-- the scale with nothing to re-apply +pose(1080, 2400); FaithfulRes.apply(1) +T.eq(Renderer:fitScale(), 6, "portrait locks at 6x") +pose(2400, 1080); FaithfulRes.apply(1) +T.eq(Renderer:fitScale(), 7, "landscape re-derives to 7x (1080/144), still whole") +T.eq(Renderer:worldViewSize(), 160, "and the overworld stays locked through it") + +-- ---------------------------------------------------------- OFF is OFF + +-- Nothing about OFF changed: it is the behaviour the game already had. +pose(1080, 2400) +FaithfulRes.apply(0) +T.eq(FaithfulRes.locked, false, "OFF releases the lock") +T.eq(FaithfulRes.scaleCap(), nil, "with no cap on the renderer") +T.eq(Renderer:fitScale(), 6, "the UI fits exactly as it did before") +T.check(Renderer:worldViewSize() > 160, "and the overworld fills the screen again") + +-- ------------------------------------------------- desktop is untouched + +pose(1280, 800, "Windows") +T.eq(FaithfulRes.isMobile(), false, "the fixture reads as desktop") +T.eq(FaithfulRes.maxLevel(), 4, "desktop keeps its 1X-4X ladder") +T.eq(FaithfulRes.label(2), "2X", "and its labels") +T.eq(FaithfulRes.scaleCap(), nil, "no cap: the window itself is the lock there") +T.eq(Renderer:fitScale(), 5, "so fitScale is untouched (800/144 = 5)") + +g.getDimensions, g.getPixelDimensions = realDims, realPixelDims +if realOS then love.system.getOS = realOS end +Zoom.offset = savedOffset +FaithfulRes.locked = false +FaithfulRes.mobileScale = 0 + +T.finish("faithful ratio mobile")