Merge pull request #690 from spiritsnails/fix/faithful-ratio-mobile

This commit is contained in:
bryanthaboi
2026-08-02 17:58:41 -04:00
committed by GitHub
5 changed files with 274 additions and 12 deletions
+97 -6
View File
@@ -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 {}
+27 -1
View File
@@ -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),
+1 -1
View File
@@ -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,
+15 -4
View File
@@ -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
+134
View File
@@ -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")