From de28fe4ce82673a9a546d4f79789ae72bb2cc992 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sat, 24 Oct 2026 23:24:14 +0200 Subject: [PATCH 1/7] Fix legacy 3D world orientation on LOVE 12 --- .../workflows/world-override-orientation.yml | 14 ++ docs/world-override-orientation.md | 27 ++++ src/mods/Schemas.lua | 2 + src/render/Renderer.lua | 43 +++++-- src/world/OverworldController.lua | 121 ++++++++++-------- tests/engine/world_override_orientation.lua | 93 ++++++++++++++ 6 files changed, 235 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/world-override-orientation.yml create mode 100644 docs/world-override-orientation.md create mode 100644 tests/engine/world_override_orientation.lua diff --git a/.github/workflows/world-override-orientation.yml b/.github/workflows/world-override-orientation.yml new file mode 100644 index 000000000..c048d4594 --- /dev/null +++ b/.github/workflows/world-override-orientation.yml @@ -0,0 +1,14 @@ +name: world override orientation +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + compositor: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit + - run: luajit tests/engine/world_override_orientation.lua diff --git a/docs/world-override-orientation.md b/docs/world-override-orientation.md new file mode 100644 index 000000000..ee9312713 --- /dev/null +++ b/docs/world-override-orientation.md @@ -0,0 +1,27 @@ +# Legacy world orientation on LÖVE 12 + +The native preview bundles LÖVE 12. Its clip-space normalization changes the +result of legacy custom 3D shaders that already invert clip-space Y for LÖVE 11. +Unmodified Voxel Ascendant rendered a vertically inverted world in this preview, +including Red's house, while the separately composited UI remained upright. + +The renderer keeps the producer's orientation with its returned world image. +Legacy Gen1–Gen3 world pipelines receive one vertical presentation mirror on +LÖVE 12, on both OpenGL and Metal. LÖVE 11 and native Gen4+ world images keep +their existing presentation. Engine-drawn field effects are counter-mirrored +inside the legacy image, preserving both their projected anchors and artwork. +The UI is composited separately and is never mirrored by this correction. + +A pipeline that draws an already upright image using ordinary 2D drawing or +current clip coordinates can declare `worldOrientation = "native"` in its +`render_pipelines` definition. `worldOrientation = "legacy"` explicitly requests +the legacy convention. Detached `worldFormat = "renderData.v1"` pipelines retain +their native image convention. The optional metadata is additive; existing +legacy 3D mods need no source or manifest edits. A custom `render.compose` hook +receives `ctx.worldOverrideMirrored` with the image it is taking over. + +`tests/engine/world_override_orientation.lua` exercises the real compositor, +separate UI, canvas lifetime and native field-effect transform using recording +graphics calls. GPU acceptance must additionally use asymmetric pixels and +inspect real mod scenes; merely finding a non-black or changing image cannot +establish the correct orientation. diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..6216ce42a 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -894,6 +894,8 @@ R.render_pipelines = { -- you hand back to the window, so a supersampled or low-res canvas is -- fine too -- but matching the pixel size is the one that is exact. drawWorld = f.opt(f.fn), + -- Image convention for legacy LÖVE 11 custom 3D shaders on LÖVE 12. + worldOrientation = f.opt(f.enum { "legacy", "native" }), -- (canvas, ctx) -> canvas: post-process the WORLD image, before the UI -- composites over it -- a depth-of-field or colour grade that must not -- touch the dialog boxes and menus sitting on top. Only runs when some diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..098abbd33 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -33,6 +33,20 @@ end local Renderer = {} +-- Legacy world shaders pre-flip clip-space Y for LÖVE 11 render targets. +-- LÖVE 12 normalizes that coordinate itself on every backend, so those +-- completed images need one presentation mirror. Native targets and the +-- detached renderData contract already use LÖVE's current image convention. +function Renderer.mirrorsWorldOverride(pipeline) + if not love.getVersion or love.getVersion() < 12 then return false end + pipeline = pipeline or {} + if pipeline.worldOrientation == "native" or pipeline.worldFormat == "renderData.v1" then + return false + end + if pipeline.worldOrientation == "legacy" then return true end + return require("src.core.GameVersion").generation() <= 3 +end + -- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every -- screen is laid out in; uiWidth/uiHeight are the surface actually -- allocated this frame, which a state may widen through setUISize (the @@ -127,18 +141,20 @@ function Renderer:init() -- frame", which is every vanilla frame. self.uiOverride = nil self.worldOverride = nil + self.worldOverrideMirrored = nil end -- Hand endFrame a pipeline's world image to composite instead of the world -- canvas. Cleared every frame, so a pipeline that declines one frame falls -- straight back to the 2D path rather than showing a stale image. -function Renderer:setWorldOverride(canvas) +function Renderer:setWorldOverride(canvas, pipeline) -- Defensive: a pipeline that hands back a non-canvas (forgotten return, a -- truthy sentinel) must not reach the worldOverride blit in endFrame, where -- love.graphics.draw on it would crash the frame. Reject it and fall back -- to the 2D path rather than trust it. if canvas ~= nil and not isCanvas(canvas) then canvas = nil end self.worldOverride = canvas + self.worldOverrideMirrored = canvas ~= nil and Renderer.mirrorsWorldOverride(pipeline) end -- Integer framebuffer pixels per GB pixel that fit the window. Zoom / @@ -332,6 +348,7 @@ function Renderer:beginFrame(transparent) self.uprightActive = false self.uiOverride = nil self.worldOverride = nil + self.worldOverrideMirrored = nil -- warp-fade overlay from Transition (issue #121); cleared each frame so -- a popped transition cannot leave a sticky black veil self.worldFadeAlpha = nil @@ -897,6 +914,7 @@ function Renderer:endFrame(zones, worldZones) -- Gen 2 and the right one for Gen 3. generation = require("src.core.GameVersion").generation(), worldOverride = self.worldOverride, + worldOverrideMirrored = self.worldOverrideMirrored, worldActive = self.worldActive and true or false, zones = zones, worldZones = worldZones, ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy, @@ -908,7 +926,8 @@ function Renderer:endFrame(zones, worldZones) self.worldActive = false self.uprightActive = false self.uiOverride = nil - self.worldOverride = nil + self.worldOverride = nil + self.worldOverrideMirrored = nil PaletteFX.setPass(nil) return { width = ww, height = wh, gameX = ox, gameY = oy, @@ -1041,7 +1060,10 @@ function Renderer:endFrame(zones, worldZones) -- anyway. Cheap: one pcall per frame, and only when an override is set. if self.worldOverride then local measurable = pcall(self.worldOverride.getDimensions, self.worldOverride) - if not measurable then self.worldOverride = nil end + if not measurable then + self.worldOverride = nil + self.worldOverrideMirrored = nil + end end if self.worldOverride then @@ -1072,17 +1094,9 @@ function Renderer:endFrame(zones, worldZones) local ow, oh = self.worldOverride:getDimensions() local osx = (ow and ow > 0) and (ww / ow) or (1 / dpiX) local osy = (oh and oh > 0) and (wh / oh) or (1 / dpiY) - -- iOS still needs the historical presentation flip for the older - -- generations. Gen 4 is the exception: its 3D worldOverride already - -- reaches this point in the correct orientation, so applying the same - -- iOS flip a second time turns Platinum upside down while its UI remains - -- upright. - local loveMajor = love.getVersion() - local gen = require("src.core.GameVersion").generation() - if love.system and love.system.getOS - and love.system.getOS() == "iOS" - and loveMajor >= 12 - and gen ~= 4 then + -- Retain the producer's orientation with its image rather than guessing + -- from the OS at composition time. The UI is composited separately. + if self.worldOverrideMirrored then love.graphics.draw(self.worldOverride, 0, wh, 0, osx, -osy) else love.graphics.draw(self.worldOverride, 0, 0, 0, osx, osy) @@ -1415,6 +1429,7 @@ function Renderer:endFrame(zones, worldZones) self.uprightActive = false self.uiOverride = nil self.worldOverride = nil + self.worldOverrideMirrored = nil PaletteFX.setPass(nil) return { width = ww, height = wh, diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..172497e43 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -66,6 +66,23 @@ end local OverworldState = { isOpaque = true, isOverworld = true } +-- The legacy 3D image is mirrored at presentation on LÖVE 12. Its native +-- 2D field effects need the inverse transform inside that image so their +-- artwork and projected anchors remain upright after the final composite. +function OverworldState.withOverrideMirror(draw, pipeline) + local g = love.graphics + if not require("src.render.Renderer").mirrorsWorldOverride(pipeline) then return draw() end + local target = g.getCanvas() + if target and not target.getHeight then target = target[1] or target.canvas end + if target and not target.getHeight then target = target.canvas end + if not (target and target.getHeight) then return draw() end + g.push() + g.translate(0, target:getHeight()) + g.scale(1, -1) + draw() + g.pop() +end + local Game -- set on enter (avoids circular require at load time) -- AN EMPTY PLOT DRAWS NOTHING, asked once per entity per frame. Written @@ -16672,7 +16689,7 @@ function OverworldState:drawWorld() -- wins over it. It falls back to the tilt/flat path whenever it cannot -- run this frame -- headless, a driver with no depth canvas, or a mod -- that threw -- so no caller ever sees a blank frame. - local pipelineId = Pipelines.worldPipeline() + local pipelineId, pipelineDef = Pipelines.worldPipeline() local tilt = (not pipelineId) and Tilt.active() -- the pipeline's finished world image, once it has run; nil keeps every -- path below on the vanilla flat/tilt draw @@ -17422,55 +17439,57 @@ function OverworldState:drawWorld() love.graphics.pop() if shader then love.graphics.setShader() end end - -- ground-hugging effects sit on the cell they belong to - if self.dustAnim then - at(fxDust, self.dustAnim.x * 16 + 8, self.dustAnim.y * 16 + 8) - end - if self.cutAnim then - at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16) - end - if self.waterAnim then - at(fxWater, self.waterAnim.x * 16 + 8, self.waterAnim.y * 16 + 16) - end - if self.healAnim then - at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16) - end - -- THE WATER RIPPLES ARE NOT HERE, AND MUST NOT BE. - -- - -- They are the one field effect that is not a picture pasted over the - -- scene: a ring LIES ON THE WATER, so it has to be drawn with the - -- water, under whoever is standing in it. This seam composites over - -- the FINISHED scene -- terrain, water and every character already - -- down -- which is exactly the report it produced when the rings were - -- offered here: "the ripples are appearing over the player character". - -- A pipeline that wants them draws them itself, as ground geometry, in - -- its own pass ordering (DRAMATIC_SHAPE: VoxelScene.drawRipples, drawn - -- between the water pass and the character pass). The FLAT path is - -- unaffected and still draws them through OverworldState:drawRipples. - -- standing effects anchor at the foot of whoever they belong to - self:eachEmote(function(e) - if e.npc then at(fxEmote, e.npc.px + 8, e.npc.py + 16) end - end) - if self.flyAnim then - at(fxBird, self.player.px + 8, self.player.py + 16) - end - if self.fishing then - at(fxRod, self.player.px + 8, self.player.py + 16) - end - -- The S.S. Anne effects are authored in screen OAM coordinates for this - -- fixed-camera cutscene. Keep them as an upright overlay for a custom - -- world pipeline; the flat/tilt paths place the wake behind the ship. - fxSSAnneWake() - fxSSAnneSmoke() - for _, sp in ipairs(self.sparkles or {}) do - sparkleOne = sp - at(fxSparkleOne, sp.px + 8, sp.py + 16) - end - sparkleOne = nil - for _, pt in ipairs((self.gen4Radar and self.gen4Radar.active and self.gen4Radar.patches) or {}) do - if pt.active then radarOne = pt; at(fxRadarOne, pt.x * 16 + 8, pt.y * 16 + 16) end - end - radarOne = nil + OverworldState.withOverrideMirror(function() + -- ground-hugging effects sit on the cell they belong to + if self.dustAnim then + at(fxDust, self.dustAnim.x * 16 + 8, self.dustAnim.y * 16 + 8) + end + if self.cutAnim then + at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16) + end + if self.waterAnim then + at(fxWater, self.waterAnim.x * 16 + 8, self.waterAnim.y * 16 + 16) + end + if self.healAnim then + at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16) + end + -- THE WATER RIPPLES ARE NOT HERE, AND MUST NOT BE. + -- + -- They are the one field effect that is not a picture pasted over the + -- scene: a ring LIES ON THE WATER, so it has to be drawn with the + -- water, under whoever is standing in it. This seam composites over + -- the FINISHED scene -- terrain, water and every character already + -- down -- which is exactly the report it produced when the rings were + -- offered here: "the ripples are appearing over the player character". + -- A pipeline that wants them draws them itself, as ground geometry, in + -- its own pass ordering (DRAMATIC_SHAPE: VoxelScene.drawRipples, drawn + -- between the water pass and the character pass). The FLAT path is + -- unaffected and still draws them through OverworldState:drawRipples. + -- standing effects anchor at the foot of whoever they belong to + self:eachEmote(function(e) + if e.npc then at(fxEmote, e.npc.px + 8, e.npc.py + 16) end + end) + if self.flyAnim then + at(fxBird, self.player.px + 8, self.player.py + 16) + end + if self.fishing then + at(fxRod, self.player.px + 8, self.player.py + 16) + end + -- The S.S. Anne effects are authored in screen OAM coordinates for this + -- fixed-camera cutscene. Keep them as an upright overlay for a custom + -- world pipeline; the flat/tilt paths place the wake behind the ship. + fxSSAnneWake() + fxSSAnneSmoke() + for _, sp in ipairs(self.sparkles or {}) do + sparkleOne = sp + at(fxSparkleOne, sp.px + 8, sp.py + 16) + end + sparkleOne = nil + for _, pt in ipairs((self.gen4Radar and self.gen4Radar.active and self.gen4Radar.patches) or {}) do + if pt.active then radarOne = pt; at(fxRadarOne, pt.x * 16 + 8, pt.y * 16 + 16) end + end + radarOne = nil + end, pipelineDef) end override = Pipelines.drawWorld(pipelineId, ctx) -- world post-processes (a miniature-diorama blur, a colour grade) fold @@ -17478,7 +17497,7 @@ function OverworldState:drawWorld() if override then override = Pipelines.worldPresent(override, ctx) end - Game.renderer:setWorldOverride(override) + Game.renderer:setWorldOverride(override, pipelineDef) if not override then -- The pipeline declined this frame (nothing to draw, or it threw and -- was retired). The ground pass was skipped on its behalf above, so diff --git a/tests/engine/world_override_orientation.lua b/tests/engine/world_override_orientation.lua new file mode 100644 index 000000000..da3434ec1 --- /dev/null +++ b/tests/engine/world_override_orientation.lua @@ -0,0 +1,93 @@ +-- A legacy 3D producer pre-flips its clip coordinates for LÖVE 11. +-- Exercise the real presentation and field-FX paths, not just the policy. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +local Renderer = require("src.render.Renderer") +local Overworld = require("src.world.OverworldController") +local g = love.graphics +local major = 12 +love.getVersion = function() return major, 0, 0 end +Renderer:init() +local world = g.newCanvas(320, 240) +local rows = {} +local draw = g.draw +g.draw = function(image, x, y, rotation, sx, sy) + rows[#rows + 1] = { image = image, x = x, y = y, sx = sx, sy = sy } +end +local function present(version, pipeline, mirrored) + V.set(version) + Renderer:beginFrame() + rows = {} + Renderer:setWorldOverride(world, pipeline) + T.eq(Renderer.worldOverrideMirrored, mirrored, version .. " image carries orientation") + Renderer:endFrame() + local found, ui + for _, row in ipairs(rows) do + if row.image == world then found = row end + if row.image == Renderer.canvas then ui = row end + end + T.check(found ~= nil, "real compositor presented world") + if found then + T.eq(found.sy < 0, mirrored, "world is mirrored exactly once when required") + T.eq(found.y, mirrored and select(2, g.getDimensions()) or 0, "mirror uses target height") + T.eq(found.sx > 0, true, "left and right are preserved") + end + T.check(ui and ui.sy > 0, "UI stays upright independently of world") + T.eq(Renderer.worldOverride, nil, "completed image is cleared") + T.eq(Renderer.worldOverrideMirrored, nil, "completed orientation is cleared") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + present(version, nil, true) + present(version, { worldOrientation = "native" }, false) +end +present("emerald", { worldFormat = "renderData.v1" }, false) +present("platinum", nil, false) +present("black", nil, false) +major = 11 +present("red", nil, false) +present("red", { worldOrientation = "legacy" }, false) +major = 12 +V.set("red") +Renderer:setWorldOverride(world) +Renderer:setWorldOverride(true) +T.eq(Renderer.worldOverride, nil, "invalid image rejected") +T.eq(Renderer.worldOverrideMirrored, false, "invalid image cannot leave stale mirror") +Renderer:setWorldOverride(world) +Renderer:beginFrame() +T.eq(Renderer.worldOverrideMirrored, nil, "next frame never inherits mirror") +-- Simulate a canvas released after the producer returns it. +Renderer:setWorldOverride(world) +local dimensions = world.getDimensions +world.getDimensions = function() error("released") end +T.check(pcall(Renderer.endFrame, Renderer), "released image falls back without crashing") +world.getDimensions = dimensions +-- Ordinary 2D effects enter at already-projected screen coordinates. They +-- must be mirrored into the legacy image so the final mirror restores both +-- their anchor and their asymmetric artwork. +local calls = {} +local push, pop, translate, scale = g.push, g.pop, g.translate, g.scale +g.push = function() calls[#calls + 1] = "push" end +g.pop = function() calls[#calls + 1] = "pop" end +g.translate = function(x, y) calls[#calls + 1] = { "translate", x, y } end +g.scale = function(x, y) calls[#calls + 1] = { "scale", x, y } end +local function fx() calls[#calls + 1] = "draw"; return "result" end +g.setCanvas(world) +Overworld.withOverrideMirror(fx) +T.same(calls, { "push", { "translate", 0, 240 }, { "scale", 1, -1 }, "draw", "pop" }, + "native FX counter-mirror uses bound image height") +for _, target in ipairs({ {world}, { {canvas = world} }, {canvas = world} }) do + g.setCanvas(target); calls = {} + Overworld.withOverrideMirror(fx) + T.eq(#calls, 5, "MRT attachment forms use same height") +end +for _, pipeline in ipairs({ {worldOrientation = "native"}, {worldFormat = "renderData.v1"} }) do + calls = {} + T.eq(Overworld.withOverrideMirror(fx, pipeline), "result", "native effect return preserved") + T.same(calls, { "draw" }, "native effects need no inverse transform") +end +g.setCanvas(); calls = {} +Overworld.withOverrideMirror(fx) +T.same(calls, { "draw" }, "absent target does not transform screen") +g.draw, g.push, g.pop, g.translate, g.scale = draw, push, pop, translate, scale +T.finish("world override orientation") From 354b6ee8fe4d8428906e88ebba05ffef2bb8afca Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 00:12:04 +0200 Subject: [PATCH 2/7] Normalize legacy shader coordinates before baked HUD composition --- .../workflows/world-override-orientation.yml | 6 +- main.lua | 1 + src/render/ClipSpaceCompat.lua | 2 +- src/render/LegacyShaderCompat.lua | 138 ++++++++++++++++++ src/render/Renderer.lua | 11 +- tests/engine/legacy_shader_compat.lua | 53 +++++++ tests/engine/world_override_orientation.lua | 11 +- tools/legacy-shader-check/conf.lua | 9 ++ tools/legacy-shader-check/main.lua | 105 +++++++++++++ tools/world_orientation_gpu_driver.lua | 69 +++++++++ 10 files changed, 392 insertions(+), 13 deletions(-) create mode 100644 src/render/LegacyShaderCompat.lua create mode 100644 tests/engine/legacy_shader_compat.lua create mode 100644 tools/legacy-shader-check/conf.lua create mode 100644 tools/legacy-shader-check/main.lua create mode 100644 tools/world_orientation_gpu_driver.lua diff --git a/.github/workflows/world-override-orientation.yml b/.github/workflows/world-override-orientation.yml index c048d4594..8ae9e7faa 100644 --- a/.github/workflows/world-override-orientation.yml +++ b/.github/workflows/world-override-orientation.yml @@ -10,5 +10,9 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v7 - - run: sudo apt-get update && sudo apt-get install -y luajit + - run: sudo apt-get update && sudo apt-get install -y luajit love xvfb - run: luajit tests/engine/world_override_orientation.lua + - run: luajit tests/engine/legacy_shader_compat.lua + - run: xvfb-run -a love tools/legacy-shader-check + env: + LIBGL_ALWAYS_SOFTWARE: "1" diff --git a/main.lua b/main.lua index 18325ffdf..c149a3fed 100644 --- a/main.lua +++ b/main.lua @@ -486,6 +486,7 @@ end function love.load(args) BootTrace.mark("love.load enter") GraphicsStack.install() + require("src.render.LegacyShaderCompat").install() pcall(function() require('src.core.SwitchAudioCheck').run() end) -- Teach SDL about the pads its built-in database misses (Switch Pro over diff --git a/src/render/ClipSpaceCompat.lua b/src/render/ClipSpaceCompat.lua index 5f7a1848f..9d37936c2 100644 --- a/src/render/ClipSpaceCompat.lua +++ b/src/render/ClipSpaceCompat.lua @@ -7,7 +7,7 @@ function C.shader(source) local count source,count=source:gsub("vec4%s+position%s*%(","vec4 legacyPosition(") if count==0 then return source end - return source..[[ + return "#define POKEPORT_NATIVE_CLIP_SPACE 1\n"..source..[[ #ifdef VERTEX vec4 position(mat4 transform_projection, vec4 vertex_position) { vec4 clip=legacyPosition(transform_projection,vertex_position); diff --git a/src/render/LegacyShaderCompat.lua b/src/render/LegacyShaderCompat.lua new file mode 100644 index 000000000..141ba3c27 --- /dev/null +++ b/src/render/LegacyShaderCompat.lua @@ -0,0 +1,138 @@ +-- The engine and its high-level shader API were authored for LÖVE 11. +-- That runtime used a different clip-space convention on canvases. +-- Adapt the vertex entry point, before 2D artwork and HUDs join the image. +-- Mirroring a completed image cannot correct a mixture of 2D and 3D draws. +local Compat = {} +local shaders = setmetatable({}, { __mode = "k" }) +local installed +local uniform = "pokeportLegacyCanvasY" + +local function uncomment(source) + local out, at = {}, 1 + while at <= #source do + local start = source:find("/", at, true) + if not start then out[#out + 1] = source:sub(at); break end + out[#out + 1] = source:sub(at, start - 1) + local pair = source:sub(start, start + 1) + local finish + if pair == "//" then + finish = source:find("\n", start + 2, true) or (#source + 1) + elseif pair == "/*" then + local close = source:find("*/", start + 2, true) + finish = close and close + 2 or (#source + 1) + end + if finish then + out[#out + 1] = source:sub(start, finish - 1):gsub("[^\n]", " ") + at = finish + else + out[#out + 1] = "/"; at = start + 1 + end + end + return table.concat(out) +end + +local prefix = [[ +#define POKEPORT_LEGACY_CLIP_SPACE_ADAPTED 1 +#ifdef VERTEX +uniform float pokeportLegacyCanvasY; +mat4 pokeportLegacyMatrix(mat4 m) { + m[0].y *= pokeportLegacyCanvasY; + m[1].y *= pokeportLegacyCanvasY; + m[2].y *= pokeportLegacyCanvasY; + m[3].y *= pokeportLegacyCanvasY; + return m; +} +// Global matrix names and the position() argument must expose the same +// legacy projection. LÖVE's generated main precedes this user source. +mat4 pokeportLegacyProjection() { + return pokeportLegacyMatrix(ProjectionMatrix); +} +#undef ProjectionMatrix +#define ProjectionMatrix pokeportLegacyProjection() +#define position pokeportLegacyPosition +#endif +#line 1 +]] +local suffix = [[ + +#ifdef VERTEX +#undef position +vec4 position(mat4 matrix, vec4 vertex) { + vec4 clip = pokeportLegacyPosition(pokeportLegacyMatrix(matrix), vertex); + clip.y *= pokeportLegacyCanvasY; + return clip; +} +#endif +]] + +-- Modern low-level entry points and explicitly current-coordinate shaders +-- remain untouched. The high-level 2D matrix path cancels both reflections. +function Compat.source(source) + if type(source) ~= "string" then return source, false end + local code = uncomment(source) + if code:find("#%s*define%s+POKEPORT_NATIVE_CLIP_SPACE%f[%W]") + or code:find("#%s*define%s+POKEPORT_LEGACY_CLIP_SPACE_ADAPTED%f[%W]") + or code:find("void%s+vertexmain%s*%(") + or not code:find("vec4%s+position%s*%(") then + return source, false + end + return prefix .. source .. suffix, true +end + +function Compat.install() + local g = love and love.graphics + if installed or not (love and love.getVersion and love.getVersion() >= 12 + and g and g.newShader and g.getShader and g.getCanvas) then return false end + installed = true + local newShader, validateShader = g.newShader, g.validateShader + local setShader, setCanvas, pop = g.setShader, g.setCanvas, g.pop + + local function sync(shader) + if shader and shaders[shader] then + local y = g.getCanvas() and -1 or 1 + if shaders[shader] ~= y then shader:send(uniform, y); shaders[shader] = y end + end + end + local function source(arg) + local text = arg + if type(arg) == "string" and not arg:find("\n", 1, true) then + local fs = love.filesystem + if fs and fs.getInfo and fs.getInfo(arg, "file") then text = fs.read(arg) end + elseif type(arg) == "userdata" and arg.typeOf and arg:typeOf("FileData") then + text = arg:getString() + end + local adapted, changed = Compat.source(text) + return changed and adapted or arg, changed + end + g.newShader = function(a, b, c) + local changed, changed2 + a, changed = source(a); b, changed2 = source(b) + local shader = newShader(a, b, c) + if (changed or changed2) and shader:hasUniform(uniform) then + shaders[shader] = true + sync(shader) + end + return shader + end + if validateShader then + g.validateShader = function(es, a, b, c) + a = source(a); b = source(b) + return validateShader(es, a, b, c) + end + end + g.setShader = function(...) + setShader(...) + sync(g.getShader()) + end + g.setCanvas = function(...) + setCanvas(...) + sync(g.getShader()) + end + g.pop = function(...) + pop(...) + sync(g.getShader()) + end + return true +end + +return Compat diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 098abbd33..4a63ca2df 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -33,18 +33,17 @@ end local Renderer = {} --- Legacy world shaders pre-flip clip-space Y for LÖVE 11 render targets. --- LÖVE 12 normalizes that coordinate itself on every backend, so those --- completed images need one presentation mirror. Native targets and the --- detached renderData contract already use LÖVE's current image convention. +-- Legacy mod shaders are normalized at compilation, before HUDs or weather +-- are painted into their canvases (LegacyShaderCompat). Ordinary world images +-- are therefore upright too. A producer of an explicitly unconverted image +-- can still request the old presentation convention. function Renderer.mirrorsWorldOverride(pipeline) if not love.getVersion or love.getVersion() < 12 then return false end pipeline = pipeline or {} if pipeline.worldOrientation == "native" or pipeline.worldFormat == "renderData.v1" then return false end - if pipeline.worldOrientation == "legacy" then return true end - return require("src.core.GameVersion").generation() <= 3 + return pipeline.worldOrientation == "legacy" end -- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every diff --git a/tests/engine/legacy_shader_compat.lua b/tests/engine/legacy_shader_compat.lua new file mode 100644 index 000000000..1e6a3056d --- /dev/null +++ b/tests/engine/legacy_shader_compat.lua @@ -0,0 +1,53 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local C = require('src.render.LegacyShaderCompat') +local vertex = 'vec4 position(mat4 m, vec4 v){return m*v;}' +local pixel = 'vec4 effect(vec4 c,Image t,vec2 uv,vec2 sc){return Texel(t,uv)*c;}' +for _,source in ipairs({pixel, '// '..vertex..'\n'..pixel, '/* '..vertex..' */\n'..pixel, + '#define POKEPORT_NATIVE_CLIP_SPACE 1\n'..vertex, 'void vertexmain(){love_Position=vec4(0.);}', + '// /*\n'..pixel..'\n// */'}) do + local actual,changed=C.source(source);T.eq(actual,source,'native/pixel/comment text preserved');T.eq(changed,false) +end +for _,source in ipairs({vertex, '#pragma language glsl3\n'..vertex..'\n'..pixel, + '// /* ignored\n'..vertex, '/* // ignored */\n'..vertex, + '// #define POKEPORT_NATIVE_CLIP_SPACE 1\n'..vertex}) do + local actual,changed=C.source(source);T.eq(changed,true,'real position adapted');T.check(actual:find(source,1,true)~=nil,'source bytes retained') + local again,twice=C.source(actual);T.eq(again,actual,'adaptation idempotent');T.eq(twice,false) +end +local savedLove=love +local shader,canvas,stack=nil,nil,{} +local creations,validations,sends={},{},{} +local g={} +function g.newShader(a,b,c) + local s={a=a,b=b,c=c} + function s:hasUniform(name)return type(self.a)=='string'and self.a:find('pokeportLegacyCanvasY',1,true)~=nil or type(self.b)=='string'and self.b:find('pokeportLegacyCanvasY',1,true)~=nil end + function s:send(name,y)sends[#sends+1]={self,name,y}end + creations[#creations+1]=s;return s +end +function g.validateShader(es,a,b,c)validations[#validations+1]={es,a,b,c};return true,'compiler message'end +function g.getCanvas()return canvas end +function g.setCanvas(c)if c=='bad'then error('invalid target')end;canvas=c end +function g.getShader()return shader end +function g.setShader(s)shader=s end +function g.push()stack[#stack+1]={canvas,shader}end +function g.pop()local s=table.remove(stack);canvas,shader=s[1],s[2]end +love={graphics=g,getVersion=function()return 11,5,0 end} +T.eq(C.install(),false,'LOVE11 left untouched') +love.getVersion=function()return 12,0,0 end +love.filesystem={getInfo=function(p)return p=='legacy.glsl'and{}or nil end,read=function()return vertex end} +T.eq(C.install(),true);T.eq(C.install(),false,'installed once') +local options={defines={TEST=1}};local a=g.newShader(vertex,options) +T.eq(a.b,options,'compile options passed by identity');T.eq(#sends,1);T.eq(sends[1][3],1,'backbuffer factor') +g.setShader(a);T.eq(#sends,1,'unchanged factor not resent') +g.setCanvas({});T.eq(sends[#sends][3],-1,'canvas factor');T.eq(#sends,2) +g.push();g.setCanvas();T.eq(sends[#sends][3],1);g.pop();T.eq(sends[#sends][3],-1,'pop restores target factor') +g.push();g.setShader();g.setCanvas();g.pop();T.eq(g.getShader(),a);T.eq(sends[#sends][3],-1,'pop restores shader and target') +T.eq(pcall(g.setCanvas,'bad'),false,'target errors not swallowed') +local n=#sends;g.newShader(pixel);T.eq(#sends,n,'pixel-only shaders require no extra uniform') +local b=g.newShader(pixel,vertex,options);T.eq(b.a,pixel);T.eq(b.c,options);T.check(b.b:find('pokeportLegacyCanvasY',1,true)~=nil,'separate vertex adapted') +local f=g.newShader('legacy.glsl');T.check(f.a:find('pokeportLegacyCanvasY',1,true)~=nil,'file source adapted') +local missing=g.newShader('missing.glsl');T.eq(missing.a,'missing.glsl','missing file left for runtime error') +local ok,msg=g.validateShader(true,pixel,vertex,options);T.eq(ok,true);T.eq(msg,'compiler message');T.eq(validations[1][1],true);T.eq(validations[1][4],options) +T.check(validations[1][3]:find('pokeportLegacyCanvasY',1,true)~=nil,'validation uses same source') +love=savedLove +T.finish('legacy shader compatibility') diff --git a/tests/engine/world_override_orientation.lua b/tests/engine/world_override_orientation.lua index da3434ec1..6b545aeee 100644 --- a/tests/engine/world_override_orientation.lua +++ b/tests/engine/world_override_orientation.lua @@ -38,7 +38,8 @@ local function present(version, pipeline, mirrored) T.eq(Renderer.worldOverrideMirrored, nil, "completed orientation is cleared") end for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do - present(version, nil, true) + present(version, nil, false) + present(version, { worldOrientation = "legacy" }, true) present(version, { worldOrientation = "native" }, false) end present("emerald", { worldFormat = "renderData.v1" }, false) @@ -73,21 +74,21 @@ g.translate = function(x, y) calls[#calls + 1] = { "translate", x, y } end g.scale = function(x, y) calls[#calls + 1] = { "scale", x, y } end local function fx() calls[#calls + 1] = "draw"; return "result" end g.setCanvas(world) -Overworld.withOverrideMirror(fx) +Overworld.withOverrideMirror(fx, {worldOrientation = "legacy"}) T.same(calls, { "push", { "translate", 0, 240 }, { "scale", 1, -1 }, "draw", "pop" }, "native FX counter-mirror uses bound image height") for _, target in ipairs({ {world}, { {canvas = world} }, {canvas = world} }) do g.setCanvas(target); calls = {} - Overworld.withOverrideMirror(fx) + Overworld.withOverrideMirror(fx, {worldOrientation = "legacy"}) T.eq(#calls, 5, "MRT attachment forms use same height") end -for _, pipeline in ipairs({ {worldOrientation = "native"}, {worldFormat = "renderData.v1"} }) do +for _, pipeline in ipairs({ {}, {worldOrientation = "native"}, {worldFormat = "renderData.v1"} }) do calls = {} T.eq(Overworld.withOverrideMirror(fx, pipeline), "result", "native effect return preserved") T.same(calls, { "draw" }, "native effects need no inverse transform") end g.setCanvas(); calls = {} -Overworld.withOverrideMirror(fx) +Overworld.withOverrideMirror(fx, {worldOrientation = "legacy"}) T.same(calls, { "draw" }, "absent target does not transform screen") g.draw, g.push, g.pop, g.translate, g.scale = draw, push, pop, translate, scale T.finish("world override orientation") diff --git a/tools/legacy-shader-check/conf.lua b/tools/legacy-shader-check/conf.lua new file mode 100644 index 000000000..73fc0bf69 --- /dev/null +++ b/tools/legacy-shader-check/conf.lua @@ -0,0 +1,9 @@ +function love.conf(t) + t.identity = 'Gen2Recomp-Legacy-Shader-Check' + t.version = love.getVersion() >= 12 and '12.0' or '11.5' + t.window.width, t.window.height = 256, 256 + t.window.vsync = 0 + t.modules.audio, t.modules.joystick, t.modules.physics = false, false, false + local backend = os.getenv('POKEPORT_GRAPHICS_BACKEND') + if t.graphics and backend then t.graphics.renderers = {backend} end +end diff --git a/tools/legacy-shader-check/main.lua b/tools/legacy-shader-check/main.lua new file mode 100644 index 000000000..964147bc7 --- /dev/null +++ b/tools/legacy-shader-check/main.lua @@ -0,0 +1,105 @@ +-- ROM-free GPU acceptance for mixed legacy 3D / ordinary 2D render targets. +function love.errorhandler(err) + io.stderr:write(tostring(err) .. '\n') + return function() return 1 end +end +local root = love.filesystem.getSource():match('^(.*)/tools/legacy%-shader%-check$') + or love.filesystem.getWorkingDirectory() +package.path = root .. '/?.lua;' .. root .. '/?/init.lua;' .. package.path +local Compat = require('src.render.LegacyShaderCompat') +if os.getenv('POKEPORT_SHADER_CHECK_NEGATIVE') == '1' then + Compat.source = function(source) return source, false end +end +Compat.install() +local g = love.graphics +local checks, cases = 0, {} +local major = love.getVersion() +local function check(ok, why) checks = checks + 1; assert(ok, why) end +local function read(canvas) + if g.readbackTexture then return g.readbackTexture(canvas) end + return canvas:newImageData() +end +local function pixel(data, x, y, want, why) + local r, green, b = data:getPixel(x, y) + for i, actual in ipairs({r, green, b}) do + check(math.abs(actual - want[i]) < 2/255, why .. ' channel ' .. i .. ': ' .. actual) + end +end +local vertices = {} +local function band(y, r, green) + for _,p in ipairs({{0,y},{64,y},{64,y+32},{0,y},{64,y+32},{0,y+32}}) do + vertices[#vertices + 1] = {p[1],p[2],0,0,r,green,0,1} + end +end +band(0,1,0); band(32,0,1) +local sources = { + {name='legacy-3d', code='vec4 position(mat4 m,vec4 p){return vec4(p.x/32.-1.,p.y/32.-1.,0.,1.);}'}, + {name='2d-parameter', code='vec4 position(mat4 m,vec4 p){return m*p;}'}, + {name='2d-global', code='vec4 position(mat4 m,vec4 p){return TransformProjectionMatrix*p;}'}, + {name='2d-projection', code='vec4 position(mat4 m,vec4 p){return ProjectionMatrix*TransformMatrix*p;}'}, + {name='2d-alias', code='vec4 position(mat4 m,vec4 p){return ClipSpaceFromLocal*p;}'}, +} +if major >= 12 then + sources[#sources+1] = {name='native-opt-out', code=[[#define POKEPORT_NATIVE_CLIP_SPACE 1 + vec4 position(mat4 m,vec4 p){return vec4(p.x/32.-1.,1.-p.y/32.,0.,1.);}]]} + sources[#sources+1] = {name='native-low-level', code=[[layout(location=0) in vec4 VertexPosition; + out vec4 VaryingTexCoord; out vec4 VaryingColor; + void vertexmain(){VaryingTexCoord=vec4(0.);VaryingColor=vec4(1.);love_Position=vec4(VertexPosition.x/32.-1.,1.-VertexPosition.y/32.,0.,1.);}]], + -- Raw entry points own their vertex colour/UV varyings too. + pixel='vec4 effect(vec4 c,Image t,vec2 uv,vec2 sc){return sc.y<32.?vec4(1,0,0,1):vec4(0,1,0,1);}'} +end +local function render(mesh, canvas, shader, attachment) + g.push('all'); g.setShader(shader); g.setCanvas(attachment or canvas) + g.origin(); g.clear(0,0,0,1); g.setColor(1,1,1,1); g.setMeshCullMode('none') + g.draw(mesh); g.setShader() + -- A readable asymmetric F-shaped HUD painted *into* the 3D canvas. + g.setColor(0,0,1,1) + g.rectangle('fill',4,4,20,3); g.rectangle('fill',4,7,3,10) + g.rectangle('fill',7,10,10,3) + g.pop() +end +function love.load() + local mesh = g.newMesh(vertices,'triangles','static') + for _,fixture in ipairs(sources) do + local ok, message = g.validateShader(false,fixture.code,fixture.pixel) + check(ok,'validate ' .. fixture.name .. ': ' .. tostring(message)) + local shader = g.newShader(fixture.code,fixture.pixel) + local canvas = g.newCanvas(64,64,{dpiscale=1}) + for _,attachment in ipairs({canvas,{canvas}}) do + render(mesh,canvas,shader,attachment) + local data = read(canvas) + pixel(data,50,10,{1,0,0},fixture.name .. ' red top') + pixel(data,50,50,{0,1,0},fixture.name .. ' green bottom') + pixel(data,10,5,{0,0,1},fixture.name .. ' HUD top') + pixel(data,5,15,{0,0,1},fixture.name .. ' HUD stem') + pixel(data,15,11,{0,0,1},fixture.name .. ' HUD arm') + pixel(data,15,15,{1,0,0},fixture.name .. ' HUD gap') + end + if fixture.name ~= 'native-low-level' then + -- These expectations were independently measured on stock LÖVE 11.5. + for _,winding in ipairs({'cw','ccw'}) do + for _,cull in ipairs({'front','back'}) do + g.push('all');g.setCanvas(canvas);g.origin();g.clear(0,0,0,1) + g.setColor(1,1,1,1);g.setFrontFaceWinding(winding);g.setMeshCullMode(cull) + g.setShader(shader);g.draw(mesh);g.pop() + local data, n = read(canvas), 0 + for y=0,63 do for x=0,63 do + local r,green,b=data:getPixel(x,y) + if r+green+b>.2 then n=n+1 end + end end + local visible = (winding=='cw' and cull=='back') or (winding=='ccw' and cull=='front') + check(n==(visible and 4096 or 0),fixture.name .. ' culling ' .. winding .. '/' .. cull .. ': ' .. n) + end + end + end + -- Restoring the target and bound shader through pop must restore its ABI. + g.push('all'); g.setCanvas(canvas); g.setShader(shader) + local inner = g.newCanvas(64,64,{dpiscale=1}) + g.push('all');g.setCanvas(inner);g.pop() + g.origin();g.clear(0,0,0,1);g.setColor(1,1,1,1);g.setMeshCullMode('none');g.draw(mesh);g.pop() + pixel(read(canvas),50,10,{1,0,0},fixture.name .. ' restored target') + cases[#cases+1]=fixture.name + end + print(('PASS legacy shader GPU: %d checks, %d cases, LOVE %d, %s'):format(checks,#cases,major,g.getRendererInfo())) + love.event.quit(0) +end diff --git a/tools/world_orientation_gpu_driver.lua b/tools/world_orientation_gpu_driver.lua new file mode 100644 index 000000000..dcaf39809 --- /dev/null +++ b/tools/world_orientation_gpu_driver.lua @@ -0,0 +1,69 @@ +-- Asymmetric GPU oracle for the actual world compositor, UI and field FX. +-- POKEPORT_DRIVER= in a fresh, fused Red test preview. +-- PALETTE_GPU_OUT must name an existing isolated results directory. +love.errorhandler=function(err) + local f=assert(io.open(assert(os.getenv('PALETTE_GPU_OUT'))..'/driver-error.txt','w'));f:write(debug.traceback(tostring(err)));f:close() + return function()return 1 end +end +return function(game) + local U={wait=function(n)for _=1,n do coroutine.yield()end end};local Json=require('src.link.Json');local g=love.graphics + local Frame=require('src.render.WindowFrame');local Renderer=require('src.render.Renderer');local Overworld=require('src.world.OverworldController');local Version=require('src.core.GameVersion') + assert(os.getenv('POKEPORT_ISOLATED_TESTING')=='1','Run only in an explicitly isolated test process') + assert(love.filesystem.isFused()and love.filesystem.getIdentity():match('^Gen2Recomp%-NativePreview%-')) + assert(Version.get()=='red'and not next(game.mods.mods),'Use a separate Red import without mods') + local out=assert(os.getenv('PALETTE_GPU_OUT'));local R=setmetatable({},{__index=Renderer});R:init() + local report={backend=g.getRendererInfo(),cases={}} + local negative=os.getenv('POKEPORT_ORIENTATION_NEGATIVE')=='1';report.negativeControl=negative or nil + local function save()local f=assert(io.open(out..'/orientation-pixels.json','w'));f:write(Json.encode(report));f:close()end + local shader=g.newShader((negative and "#define POKEPORT_NATIVE_CLIP_SPACE 1\n" or "")..[[ + vec4 position(mat4 matrix,vec4 p){ + // A LÖVE 11 custom 3D shader: author projected Y-up then pre-flipped + // its render-target clip Y. LÖVE 12 will normalize clip Y again. + vec4 clip=vec4(p.x/32.0-1.0,1.0-p.y/32.0,0.0,1.0); + clip.y=-clip.y;return clip; + } + ]]) + local vertices={} + local function quad(y,h,r,gg,b) + for _,v in ipairs({{0,y},{64,y},{64,y+h},{0,y},{64,y+h},{0,y+h}})do vertices[#vertices+1]={v[1],v[2],0,0,r,gg,b,1}end + end + quad(0,32,1,0,0);quad(32,32,0,1,0) + local mesh=g.newMesh(vertices,'triangles','static');local world=g.newCanvas(64,64,{dpiscale=1});world:setFilter('nearest','nearest') + local originalDraw=love.draw + local function colour(data,x,y,want,label) + local r,gg,b=data:getPixel(x,y);local actual={r,gg,b} + for i=1,3 do assert(math.abs(actual[i]-want[i])<.08,label..' channel '..i..': '..actual[i])end + return actual + end + for _,case in ipairs({ + {name='legacy-3d',version='red',legacy=true}, + {name='native-2d',version='red',pipeline={worldOrientation='native'}}, + {name='portable-gen3',version='emerald',pipeline={worldFormat='renderData.v1'}}, + {name='native-gen4',version='platinum'}, + })do + local completed=false;local metrics + love.draw=function() + Version.set(case.version);R:beginFrame();g.push('all');g.setCanvas(world);g.origin();g.setShader();g.setScissor();g.setColor(1,1,1,1);g.clear(0,0,0,1) + if case.legacy then g.setShader(shader);g.draw(mesh);g.setShader() + else g.setColor(1,0,0,1);g.rectangle('fill',0,0,64,32);g.setColor(0,1,0,1);g.rectangle('fill',0,32,64,32)end + Overworld.withOverrideMirror(function() + g.setColor(1,1,0,1);g.rectangle('fill',4,10,8,2) + g.setColor(1,0,1,1);g.rectangle('fill',4,12,3,5) + end,case.pipeline) + g.setCanvas(R.canvas);g.origin();g.setShader();g.clear(0,0,0,0);g.setColor(0,0,1,1);g.rectangle('fill',70,2,20,8);g.pop() + R:setWorldOverride(world,case.pipeline);metrics=R:endFrame();Version.set('red') + end + Frame.observe(function(data) + local w,h=data:getDimensions();local points={} + points.worldTop=colour(data,math.floor(w*.8),math.floor(h*.2),{1,0,0},'red top '..case.name) + points.worldBottom=colour(data,math.floor(w*.8),math.floor(h*.8),{0,1,0},'green bottom '..case.name) + points.fxTop=colour(data,math.floor(w*8/64),math.floor(h*11/64),{1,1,0},'yellow FX cap '..case.name) + points.fxStem=colour(data,math.floor(w*5/64),math.floor(h*14/64),{1,0,1},'magenta FX stem '..case.name) + local dpiX=w/metrics.width;local dpiY=h/metrics.height + points.uiTop=colour(data,math.floor((metrics.gameX+80*metrics.scale/dpiX)*dpiX),math.floor((metrics.gameY+5*metrics.scale/dpiY)*dpiY),{0,0,1},'blue upright UI '..case.name) + if not completed then local f=assert(io.open(out..'/'..case.name..'.png','wb'));f:write(data:encode('png'):getString());f:close();report.cases[#report.cases+1]={name=case.name,passed=true,pixels=points};save();completed=true end + end) + U.wait(3);assert(completed,'No GPU readback for '..case.name) + end + Frame.observe(nil);love.draw=originalDraw;Version.set('red');report.passed=true;save() +end From 74229761a49ff40515283d5147382e89b8d3fd44 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 02:08:15 +0200 Subject: [PATCH 3/7] Document shader-level mixed HUD correction and scoped acceptance --- docs/world-override-orientation.md | 69 ++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/docs/world-override-orientation.md b/docs/world-override-orientation.md index ee9312713..aa0eeedf5 100644 --- a/docs/world-override-orientation.md +++ b/docs/world-override-orientation.md @@ -1,27 +1,50 @@ -# Legacy world orientation on LÖVE 12 +# Legacy world and embedded HUD orientation on LÖVE 12 -The native preview bundles LÖVE 12. Its clip-space normalization changes the -result of legacy custom 3D shaders that already invert clip-space Y for LÖVE 11. -Unmodified Voxel Ascendant rendered a vertically inverted world in this preview, -including Red's house, while the separately composited UI remained upright. +LÖVE 12 normalizes canvas clip coordinates differently from LÖVE 11. Legacy +custom 3D shaders already invert clip-space Y themselves. Reflecting the +completed world image corrected those 3D pixels but inverted ordinary 2D +menus, battle HUDs and effects drawn into that same image. -The renderer keeps the producer's orientation with its returned world image. -Legacy Gen1–Gen3 world pipelines receive one vertical presentation mirror on -LÖVE 12, on both OpenGL and Metal. LÖVE 11 and native Gen4+ world images keep -their existing presentation. Engine-drawn field effects are counter-mirrored -inside the legacy image, preserving both their projected anchors and artwork. -The UI is composited separately and is never mirrored by this correction. +`src/render/LegacyShaderCompat.lua` now restores the LÖVE 11 high-level shader +coordinate contract at shader creation, before those draws are combined. It +adapts the `position` entry point, its projection argument and the global +projection matrix consistently. The canvas uniform follows shader/canvas +changes and graphics-state restores. Ordinary matrix-based 2D shaders cancel +the paired reflections and retain their normal orientation. LÖVE 11, raw +`vertexmain` entry points and shaders declaring +`#define POKEPORT_NATIVE_CLIP_SPACE 1` are unchanged. -A pipeline that draws an already upright image using ordinary 2D drawing or -current clip coordinates can declare `worldOrientation = "native"` in its -`render_pipelines` definition. `worldOrientation = "legacy"` explicitly requests -the legacy convention. Detached `worldFormat = "renderData.v1"` pipelines retain -their native image convention. The optional metadata is additive; existing -legacy 3D mods need no source or manifest edits. A custom `render.compose` hook -receives `ctx.worldOverrideMirrored` with the image it is taking over. +The compositor no longer mirrors ordinary world overrides. An explicitly +unconverted image may still request `worldOrientation = "legacy"`; `native` +and detached `renderData.v1` retain their native convention. The existing +field-effect counter-transform follows only that explicit mirror policy. +The shader compatibility layer handles the usual mixed 3D/2D image before +presentation, so its HUD can remain upright without transforming the HUD. -`tests/engine/world_override_orientation.lua` exercises the real compositor, -separate UI, canvas lifetime and native field-effect transform using recording -graphics calls. GPU acceptance must additionally use asymmetric pixels and -inspect real mod scenes; merely finding a non-black or changing image cannot -establish the correct orientation. +## Scoped evidence + +- The real shader creation/state adapter passes 57 ROM-free checks and the + compositor/field-effect policy passes 151, both independently and on the + composed fork. +- The standalone GPU fixture passes 304 checks in seven cases on LÖVE 12 + OpenGL and Metal, and 220 on stock LÖVE 11.5. It covers actual custom and + matrix-based vertex paths, canvas/window targets, state restoration and + mixed artwork. The negative control disables the shader correction and + fails the asymmetric pixel assertion. +- Actual Red/VASC battle/menu work and native Emerald/FireRed OpenGL + regressions were tested at their recorded earlier source checkpoints. + These are distinct from a new complete Windows or all-games acceptance. +- The current Crystal checkpoint uses the unmodified supplied Johto package + on both backends: actual First/Third V/F6 input, native grid movement and + upright StartMenu, plus the Mart/Center geometry and menu probes. See the + [Johto report](https://github.com/Roxas2712/Gen2Recomped/blob/f56a81f6e0eb97ab5024706fca3f07cfe4d1eca0/docs/johto-native-compatibility.md) for exact sources, normal + no-mod staircase/house exits and the remaining renderer/interior gaps. + +The earlier whole-image-mirror evidence in +`docs/validation/love12-world-orientation.json` is historical; it does not +establish that an embedded battle HUD was correct. The repeatable current +fixtures are `tests/engine/legacy_shader_compat.lua`, +`tests/engine/world_override_orientation.lua`, +`tools/legacy-shader-check/main.lua` and +`tools/world_orientation_gpu_driver.lua`. Windows hardware, every mod feature, +all maps/doors and complete engine parity remain open. From 08647d6e1725807007d76feb2b66e542e3fb7258 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 14:58:41 +0100 Subject: [PATCH 4/7] Preserve explicit native shader compiler options in compatibility rendering --- src/render/LegacyShaderCompat.lua | 41 ++++++-- tests/engine/legacy_shader_compat.lua | 30 +++++- tools/shader-options-check/conf.lua | 10 ++ tools/shader-options-check/main.lua | 135 ++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 tools/shader-options-check/conf.lua create mode 100644 tools/shader-options-check/main.lua diff --git a/src/render/LegacyShaderCompat.lua b/src/render/LegacyShaderCompat.lua index 141ba3c27..d1b99c966 100644 --- a/src/render/LegacyShaderCompat.lua +++ b/src/render/LegacyShaderCompat.lua @@ -6,6 +6,23 @@ local Compat = {} local shaders = setmetatable({}, { __mode = "k" }) local installed local uniform = "pokeportLegacyCanvasY" +local markers = { POKEPORT_NATIVE_CLIP_SPACE = true, POKEPORT_LEGACY_CLIP_SPACE_ADAPTED = true } + +-- LÖVE accepts named defines and an array of preprocessor definitions. +-- Presence is what matters, including a value of 0 or false: #ifdef agrees. +local function nativeOptions(options) + if type(options) ~= "table" then return false end + local defines = rawget(options, "defines") + if type(defines) ~= "table" then return false end + for name in pairs(markers) do if rawget(defines, name) ~= nil then return true end end + for key, value in next, defines do + if type(key) == "number" and key >= 1 and key % 1 == 0 and type(value) == "string" then + local name = value:match("^%s*([%a_][%w_]*)") + if markers[name] then return true end + end + end + return false +end local function uncomment(source) local out, at = {}, 1 @@ -32,7 +49,9 @@ local function uncomment(source) end local prefix = [[ +#if !defined(POKEPORT_NATIVE_CLIP_SPACE) && !defined(POKEPORT_LEGACY_CLIP_SPACE_ADAPTED) #define POKEPORT_LEGACY_CLIP_SPACE_ADAPTED 1 +#define POKEPORT_LEGACY_CANVAS_Y_ACTIVE 1 #ifdef VERTEX uniform float pokeportLegacyCanvasY; mat4 pokeportLegacyMatrix(mat4 m) { @@ -51,11 +70,12 @@ mat4 pokeportLegacyProjection() { #define ProjectionMatrix pokeportLegacyProjection() #define position pokeportLegacyPosition #endif +#endif #line 1 ]] local suffix = [[ -#ifdef VERTEX +#if defined(VERTEX) && defined(POKEPORT_LEGACY_CANVAS_Y_ACTIVE) #undef position vec4 position(mat4 matrix, vec4 vertex) { vec4 clip = pokeportLegacyPosition(pokeportLegacyMatrix(matrix), vertex); @@ -67,11 +87,11 @@ vec4 position(mat4 matrix, vec4 vertex) { -- Modern low-level entry points and explicitly current-coordinate shaders -- remain untouched. The high-level 2D matrix path cancels both reflections. -function Compat.source(source) - if type(source) ~= "string" then return source, false end +function Compat.source(source, options) + if type(source) ~= "string" or nativeOptions(options) then return source, false end local code = uncomment(source) - if code:find("#%s*define%s+POKEPORT_NATIVE_CLIP_SPACE%f[%W]") - or code:find("#%s*define%s+POKEPORT_LEGACY_CLIP_SPACE_ADAPTED%f[%W]") + if code:find("#%s*define%s+POKEPORT_NATIVE_CLIP_SPACE%f[^%w_]") + or code:find("#%s*define%s+POKEPORT_LEGACY_CLIP_SPACE_ADAPTED%f[^%w_]") or code:find("void%s+vertexmain%s*%(") or not code:find("vec4%s+position%s*%(") then return source, false @@ -93,7 +113,10 @@ function Compat.install() if shaders[shader] ~= y then shader:send(uniform, y); shaders[shader] = y end end end - local function source(arg) + local function source(arg, preserve) + -- Keep file names, FileData and source strings intact for an explicitly + -- native compile. No redundant file read, comment scan or source copy. + if preserve then return arg, false end local text = arg if type(arg) == "string" and not arg:find("\n", 1, true) then local fs = love.filesystem @@ -106,7 +129,8 @@ function Compat.install() end g.newShader = function(a, b, c) local changed, changed2 - a, changed = source(a); b, changed2 = source(b) + local preserve = nativeOptions(type(b) == "table" and b or c) + a, changed = source(a, preserve); b, changed2 = source(b, preserve) local shader = newShader(a, b, c) if (changed or changed2) and shader:hasUniform(uniform) then shaders[shader] = true @@ -116,7 +140,8 @@ function Compat.install() end if validateShader then g.validateShader = function(es, a, b, c) - a = source(a); b = source(b) + local preserve = nativeOptions(type(b) == "table" and b or c) + a = source(a, preserve); b = source(b, preserve) return validateShader(es, a, b, c) end end diff --git a/tests/engine/legacy_shader_compat.lua b/tests/engine/legacy_shader_compat.lua index 1e6a3056d..7b720a868 100644 --- a/tests/engine/legacy_shader_compat.lua +++ b/tests/engine/legacy_shader_compat.lua @@ -14,6 +14,21 @@ for _,source in ipairs({vertex, '#pragma language glsl3\n'..vertex..'\n'..pixel, local actual,changed=C.source(source);T.eq(changed,true,'real position adapted');T.check(actual:find(source,1,true)~=nil,'source bytes retained') local again,twice=C.source(actual);T.eq(again,actual,'adaptation idempotent');T.eq(twice,false) end +for _,marker in ipairs({'POKEPORT_NATIVE_CLIP_SPACE','POKEPORT_LEGACY_CLIP_SPACE_ADAPTED'})do + for _,value in ipairs({0,1,false,'0'})do + local actual,changed=C.source(vertex,{defines={[marker]=value}}) + T.eq(actual,vertex,'named compiler marker preserves source identity');T.eq(changed,false) + end + local actual,changed=C.source(vertex,{defines={marker}}) + T.eq(actual,vertex,'array compiler marker preserves source');T.eq(changed,false) + for _,defines in ipairs({{[marker..'_EXTRA']=1},{marker..'_EXTRA'},{OTHER=marker}})do + local _,changed=C.source(vertex,{defines=defines});T.eq(changed,true,'unrelated compiler definition is not a marker') + end + local _,changed=C.source('#define '..marker..'_EXTRA 1\n'..vertex) + T.eq(changed,true,'underscore suffix is part of GLSL identifier') +end +local poison=setmetatable({},{__index=function()error('option metatable must not execute')end}) +T.eq(select(2,C.source(vertex,poison)),true,'compatibility inspection does not execute option metatable') local savedLove=love local shader,canvas,stack=nil,nil,{} local creations,validations,sends={},{},{} @@ -34,7 +49,8 @@ function g.pop()local s=table.remove(stack);canvas,shader=s[1],s[2]end love={graphics=g,getVersion=function()return 11,5,0 end} T.eq(C.install(),false,'LOVE11 left untouched') love.getVersion=function()return 12,0,0 end -love.filesystem={getInfo=function(p)return p=='legacy.glsl'and{}or nil end,read=function()return vertex end} +local reads=0 +love.filesystem={getInfo=function(p)return p=='legacy.glsl'and{}or nil end,read=function()reads=reads+1;return vertex end} T.eq(C.install(),true);T.eq(C.install(),false,'installed once') local options={defines={TEST=1}};local a=g.newShader(vertex,options) T.eq(a.b,options,'compile options passed by identity');T.eq(#sends,1);T.eq(sends[1][3],1,'backbuffer factor') @@ -49,5 +65,17 @@ local f=g.newShader('legacy.glsl');T.check(f.a:find('pokeportLegacyCanvasY',1,tr local missing=g.newShader('missing.glsl');T.eq(missing.a,'missing.glsl','missing file left for runtime error') local ok,msg=g.validateShader(true,pixel,vertex,options);T.eq(ok,true);T.eq(msg,'compiler message');T.eq(validations[1][1],true);T.eq(validations[1][4],options) T.check(validations[1][3]:find('pokeportLegacyCanvasY',1,true)~=nil,'validation uses same source') +for _,defines in ipairs({{POKEPORT_NATIVE_CLIP_SPACE=1},{POKEPORT_NATIVE_CLIP_SPACE=false},{'POKEPORT_NATIVE_CLIP_SPACE'}})do + local nativeOptions={defines=defines};local n=#sends + local native=g.newShader(vertex,nativeOptions) + T.eq(native.a,vertex,'native compile source bytes unchanged');T.eq(native.b,nativeOptions,'native compile options retain identity');T.eq(#sends,n,'native compile has no compatibility uniform') + local split=g.newShader(pixel,vertex,nativeOptions) + T.eq(split.a,pixel);T.eq(split.b,vertex);T.eq(split.c,nativeOptions,'split shader options retain identity') + local beforeReads=reads;local fromFile=g.newShader('legacy.glsl',nativeOptions) + T.eq(fromFile.a,'legacy.glsl','native shader file delegated without a redundant read') + T.eq(reads,beforeReads,'native compile avoids extra file IO') + g.validateShader(true,pixel,vertex,nativeOptions) + local last=validations[#validations];T.eq(last[2],pixel);T.eq(last[3],vertex);T.eq(last[4],nativeOptions,'validator retains native compile options') +end love=savedLove T.finish('legacy shader compatibility') diff --git a/tools/shader-options-check/conf.lua b/tools/shader-options-check/conf.lua new file mode 100644 index 000000000..8a26d2bea --- /dev/null +++ b/tools/shader-options-check/conf.lua @@ -0,0 +1,10 @@ +function love.conf(t) + t.identity = assert(os.getenv("POKEPORT_IDENTITY"), "isolated shader QA identity required") + assert(t.identity:match("^oasc%-gen2recomp%-qa%-shader%-options%-")) + t.version = "12.0" + t.highdpi = true + t.window.width, t.window.height = 640, 480 + t.window.vsync = 0 + t.modules.audio, t.modules.joystick, t.modules.physics = false, false, false + t.graphics.renderers = { assert(os.getenv("POKEPORT_GRAPHICS_BACKEND")) } +end diff --git a/tools/shader-options-check/main.lua b/tools/shader-options-check/main.lua new file mode 100644 index 000000000..c65e30e1d --- /dev/null +++ b/tools/shader-options-check/main.lua @@ -0,0 +1,135 @@ +-- Compare compatibility compilation with the runtime's unwrapped constructor. +io.stdout:setvbuf("no") +function love.errorhandler(err) + io.stderr:write(debug.traceback(tostring(err), 2) .. "\n") + return function() return 1 end +end +local root = assert(os.getenv("SHADER_OPTIONS_ENGINE")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local g = love.graphics +local rawShader, rawValidate = g.newShader, g.validateShader +local Compat = require("src.render.LegacyShaderCompat") +assert(Compat.install()) +local checks, comparisons = 0, 0 +local function check(ok, why) checks = checks + 1; assert(ok, why) end +local vertex = [[#pragma language glsl3 +#if !defined(POKEPORT_NATIVE_CLIP_SPACE) && !defined(POKEPORT_LEGACY_CLIP_SPACE_ADAPTED) +#error native option missing +#endif +vec4 position(mat4 m, vec4 p) { + return vec4(p.x/32.-1.,1.-p.y/32.,0.,1.); +} +]] +local pixel = "vec4 effect(vec4 c, Image t, vec2 uv, vec2 sc){return c;}" +local nativeVertex = "vec4 position(mat4 m,vec4 p){return vec4(p.x/32.-1.,1.-p.y/32.,0.,1.);}" +local legacyVertex = "vec4 position(mat4 m,vec4 p){return vec4(p.x/32.-1.,p.y/32.-1.,0.,1.);}" +local dimensions = { { 64, 64, 1 }, { 113, 79, 1 }, { 79, 113, 2 }, { 321, 179, 2 } } +local vertices = {} +for _, band in ipairs({ { 0, 1, 0 }, { 32, 0, 1 } }) do + for _, p in ipairs({ { 0, 0 }, { 64, 0 }, { 64, 32 }, { 0, 0 }, { 64, 32 }, { 0, 32 } }) do + vertices[#vertices + 1] = { p[1], p[2] + band[1], 0, 0, band[2], band[3], 0, 1 } + end +end +local function read(canvas) + return g.readbackTexture and g.readbackTexture(canvas) or canvas:newImageData() +end +local function render(mesh, shader, target) + g.push("all"); g.setCanvas(target); g.origin(); g.setShader(shader) + g.clear(0, 0, 0, 1); g.setColor(1, 1, 1, 1); g.setMeshCullMode("none") + g.draw(mesh); g.setShader() + g.setColor(0, 0, 1, 1); g.rectangle("fill", 4, 4, 20, 3); g.rectangle("fill", 4, 7, 3, 10) + g.pop() +end +local function create(ctor, kind, source, opts) + if kind == "split" then return ctor(pixel, source, opts) end + return ctor(source, opts) +end +local function save(data, name) + local out = os.getenv("SHADER_OPTIONS_OUT") + if not out then return end + local bytes = data:encode("png") + local file = assert(io.open(out .. "/" .. name, "wb")); file:write(bytes:getString()); file:close(); bytes:release() +end +function love.load() + local mesh = g.newMesh(vertices, "triangles", "static") + local fixtures = { + { "map", { defines = { POKEPORT_NATIVE_CLIP_SPACE = 1 } } }, + { "map-zero", { defines = { POKEPORT_NATIVE_CLIP_SPACE = 0 } } }, + { "map-false", { defines = { POKEPORT_NATIVE_CLIP_SPACE = false } } }, + { "array", { defines = { "POKEPORT_NATIVE_CLIP_SPACE" } } }, + { "already-adapted-option", { defines = { POKEPORT_LEGACY_CLIP_SPACE_ADAPTED = 1 } } }, + { "inherited-options", setmetatable({}, { __index = function(_, key) + if key == "defines" then return { POKEPORT_NATIVE_CLIP_SPACE = 1 } end + end }) }, + } + for _, fixture in ipairs(fixtures) do + local name, opts = fixture[1], fixture[2] + for _, kind in ipairs({ "combined", "split", "file", "filedata" }) do + local source = vertex + if kind == "file" then + check(love.filesystem.write("native-options.glsl", vertex), "own QA shader source written") + source = "native-options.glsl" + elseif kind == "filedata" then source = love.filesystem.newFileData(vertex, "native-options.glsl") end + local baseline = create(rawShader, kind, source, opts) + local shader = create(g.newShader, kind, source, opts) + -- Compiler options are part of the shader's coordinate contract. + check(not shader:hasUniform("pokeportLegacyCanvasY"), name .. "/" .. kind .. " native shader was rewritten") + local ok, why + if kind == "split" then ok, why = g.validateShader(false, pixel, source, opts) + else ok, why = g.validateShader(false, source, opts) end + check(ok, name .. "/" .. kind .. " validation: " .. tostring(why)) + local invalid = "#error test_shader_failure\n" .. vertex + local expectedOk = rawValidate(false, invalid, opts) + local actualOk = g.validateShader(false, invalid, opts) + check(expectedOk == false and actualOk == expectedOk, "invalid native shader retains compiler failure") + for _, size in ipairs(dimensions) do + local a = g.newCanvas(size[1], size[2], { dpiscale = size[3], format = "rgba8" }) + local b = g.newCanvas(size[1], size[2], { dpiscale = size[3], format = "rgba8" }) + for _, wrapped in ipairs({ false, true }) do + render(mesh, baseline, wrapped and { a } or a) + render(mesh, shader, wrapped and { b } or b) + local expected, actual = read(a), read(b) + local r, green, blue = expected:getPixel(expected:getWidth() - 2, math.floor(expected:getHeight() / 4)) + check(r == 1 and green == 0 and blue == 0, name .. "/" .. kind .. " independent reference is upright") + check(actual:getString() == expected:getString(), name .. "/" .. kind .. " native RGBA changed") + if name == "map" and kind == "combined" and not wrapped and size[1] == 79 then + save(actual, "native-portrait.png"); save(expected, "reference-portrait.png") + end + comparisons = comparisons + 1 + expected:release(); actual:release() + end + a:release(); b:release() + end + baseline:release(); shader:release() + if kind == "filedata" then source:release() end + end + end + -- Legacy correction must remain active for unrelated or similarly named + -- definitions. Its expected pixels come from the unwrapped native path. + for _, fixture in ipairs({ + { "legacy-default", nil, legacyVertex }, + { "legacy-unrelated-value", { defines = { OTHER = "POKEPORT_NATIVE_CLIP_SPACE" } }, legacyVertex }, + { "legacy-map-prefix", { defines = { POKEPORT_NATIVE_CLIP_SPACE_EXTRA = 1 } }, legacyVertex }, + { "legacy-array-prefix", { defines = { "POKEPORT_NATIVE_CLIP_SPACE_EXTRA" } }, legacyVertex }, + { "legacy-source-prefix", nil, "#define POKEPORT_NATIVE_CLIP_SPACE_EXTRA 1\n" .. legacyVertex }, + { "matrix-2d", nil, "vec4 position(mat4 m,vec4 p){return m*p;}" }, + }) do + local baseline = rawShader(fixture[1] == "matrix-2d" and fixture[3] or nativeVertex, fixture[2]) + local shader = g.newShader(fixture[3], fixture[2]) + for _, size in ipairs(dimensions) do + local a = g.newCanvas(size[1], size[2], { dpiscale = size[3], format = "rgba8" }) + local b = g.newCanvas(size[1], size[2], { dpiscale = size[3], format = "rgba8" }) + for _, wrapped in ipairs({ false, true }) do + render(mesh, baseline, wrapped and { a } or a); render(mesh, shader, wrapped and { b } or b) + local expected, actual = read(a), read(b) + check(actual:getString() == expected:getString(), fixture[1] .. " legacy/matrix RGBA changed") + comparisons = comparisons + 1; expected:release(); actual:release() + end + a:release(); b:release() + end + baseline:release(); shader:release() + end + mesh:release() + print(("PASS shader options GPU: %d checks, %d exact RGBA pairs, %s"):format(checks, comparisons, g.getRendererInfo())) + love.event.quit(0) +end From eef18e0446dc3c0c4d7fe80b87fb0afdfe4fce84 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 15:13:20 +0100 Subject: [PATCH 5/7] Document shader compiler option regression evidence --- docs/qa/shader-compiler-options.md | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/qa/shader-compiler-options.md diff --git a/docs/qa/shader-compiler-options.md b/docs/qa/shader-compiler-options.md new file mode 100644 index 000000000..628027e1c --- /dev/null +++ b/docs/qa/shader-compiler-options.md @@ -0,0 +1,54 @@ +# Shader compiler options and canvas orientation + +Explicit native-coordinate shaders must not be normalized a second time. The +compatibility compiler now recognizes `POKEPORT_NATIVE_CLIP_SPACE` and +`POKEPORT_LEGACY_CLIP_SPACE_ADAPTED` in the compiler's `defines` option, as well +as in source. Both named definitions and the array form preserve their original +arguments, including filenames, FileData, and the options table. Definition +presence counts even with a value of zero or false, matching the preprocessor. + +This path avoids an additional file read, comment scan and source allocation; +no frame-rate improvement is claimed. A preprocessor guard also preserves +definitions supplied by inherited compiler options without inspecting option +metatables in the Lua wrapper. Identifiers with an `_EXTRA` suffix are distinct +and do not disable legacy normalization. + +## Reproduction + +Run `tests/engine/legacy_shader_compat.lua` with the engine test runner. For GPU +verification, set `SHADER_OPTIONS_ENGINE` to this checkout, an unused isolated +`POKEPORT_IDENTITY=oasc-gen2recomp-qa-shader-options-` and +`POKEPORT_GRAPHICS_BACKEND` to `opengl` or `metal`, then launch +`tools/shader-options-check` with LÖVE 12. Optional `SHADER_OPTIONS_OUT` saves +the native and independent reference portrait images. Run GPU backends serially. + +The checker compares the wrapped compiler with the runtime's unwrapped +constructor, checks an independently upright reference, and verifies that +invalid shader sources still fail. It exercises combined/split source, +filenames/FileData, map/array/inherited definitions, direct/table render targets, +four sizes including portrait, and DPI scales 1 and 2. Native and already-adapted +definitions remain unchanged; ordinary matrix-based 2D and default legacy +coordinates retain their previous orientation. + +## Source-bound results + +- Production module SHA-256: + `c99b3c9b9ab8be4801ba894cb2bfb7cc0d2deb88cb8de24adff8554bed863dca`. +- GPU checker SHA-256: + `29539a3d522a8369347369b2fd312309d1743755200337800e6990ed9c065fba`. +- LÖVE 12 build `937eb3b`, macOS: OpenGL and Metal each pass **510 checks and + 240 exact full-RGBA image pairs** on those bytes. +- Existing legacy orientation GPU checker: **304 checks / seven cases** on each + backend, including embedded HUDs, culling, matrices and state restoration. +- Independent upstream patch: **119/119** targeted CPU checks. Composed fork: + **324/324** engine suites, **3/3** SDK suites, and B30 profile pass. +- Before correction, the new GPU checker fails because a shader explicitly + marked native in compiler options is rewritten. Previous production module + SHA-256: `b23fd6d20f136f54f78793839151a5f8d0abd76793da7eb541fc5802a47d00ca`. + +A fresh unchanged supplied Johto package also failed before camera verification +with a dynamically generated function exceeding LuaJIT's 60-upvalue limit. The +same package inventory fails identically on the parent composed fork without +this correction. Both failures are retained; neither is gameplay acceptance. +Windows GPU execution and full mod compatibility remain unverified. No ROMs, +mod sources/assets, imported caches or player profiles are included. From 3e9254e04c287985cbe80065a3c74aac1b85fc16 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 20:31:58 +0100 Subject: [PATCH 6/7] Preserve complete producer canvas coordinate conversions --- src/render/LegacyShaderCompat.lua | 18 +++++++++++++++++- tests/engine/legacy_shader_compat.lua | 12 ++++++++++++ tools/legacy-shader-check/main.lua | 6 ++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/render/LegacyShaderCompat.lua b/src/render/LegacyShaderCompat.lua index d1b99c966..84aff9e3d 100644 --- a/src/render/LegacyShaderCompat.lua +++ b/src/render/LegacyShaderCompat.lua @@ -85,6 +85,21 @@ vec4 position(mat4 matrix, vec4 vertex) { #endif ]] +-- Some producers already adapt their independent projection to current +-- canvas coordinates. Recognize only the complete forwarding wrapper, not +-- a sign in arbitrary projection arithmetic or a comment. Applying another +-- legacy transform to this wrapper reflects its world a second time. +local function nativeCanvasWrapper(code) + for matrix, vertex, body in code:gmatch("vec4%s+position%s*%(%s*mat4%s+([%w_]+)%s*,%s*vec4%s+([%w_]+)%s*%)%s*(%b{})") do + local compact = body:gsub("%s+", "") + local clip, helper, m, v, rest = compact:match("^%{vec4([%w_]+)=([%w_]+)%(([%w_]+),([%w_]+)%)%;(.*)%}$") + if clip and m == matrix and v == vertex and helper ~= "position" + and rest == clip .. ".y=-" .. clip .. ".y;return" .. clip .. ";" + and code:find("vec4%s+" .. helper .. "%s*%(") then return true end + end + return false +end + -- Modern low-level entry points and explicitly current-coordinate shaders -- remain untouched. The high-level 2D matrix path cancels both reflections. function Compat.source(source, options) @@ -93,7 +108,8 @@ function Compat.source(source, options) if code:find("#%s*define%s+POKEPORT_NATIVE_CLIP_SPACE%f[^%w_]") or code:find("#%s*define%s+POKEPORT_LEGACY_CLIP_SPACE_ADAPTED%f[^%w_]") or code:find("void%s+vertexmain%s*%(") - or not code:find("vec4%s+position%s*%(") then + or not code:find("vec4%s+position%s*%(") + or nativeCanvasWrapper(code) then return source, false end return prefix .. source .. suffix, true diff --git a/tests/engine/legacy_shader_compat.lua b/tests/engine/legacy_shader_compat.lua index 7b720a868..050cbc0b0 100644 --- a/tests/engine/legacy_shader_compat.lua +++ b/tests/engine/legacy_shader_compat.lua @@ -29,6 +29,18 @@ for _,marker in ipairs({'POKEPORT_NATIVE_CLIP_SPACE','POKEPORT_LEGACY_CLIP_SPACE end local poison=setmetatable({},{__index=function()error('option metatable must not execute')end}) T.eq(select(2,C.source(vertex,poison)),true,'compatibility inspection does not execute option metatable') +local converted = [[ +vec4 canvasProject(mat4 matrix,vec4 vertex){return vec4(vertex.xy,0.,1.);} +vec4 position(mat4 matrix,vec4 vertex){vec4 clip=canvasProject(matrix,vertex);clip.y=-clip.y;return clip;} +]] +local preserved, adapted = C.source(converted) +T.eq(preserved,converted,'producer canvas conversion is preserved byte for byte');T.eq(adapted,false) +for _,source in ipairs({converted:gsub('clip%.y=%-clip%.y;', ''), + converted:gsub('return clip;', 'clip.x+=1.;return clip;'), + converted:gsub('canvasProject%(matrix,vertex%)', 'canvasProject(vertex,matrix)'), + '// '..converted:gsub('\n',' ')..'\n'..vertex}) do + T.eq(select(2,C.source(source)),true,'partial, different or commented conversion retains legacy adaptation') +end local savedLove=love local shader,canvas,stack=nil,nil,{} local creations,validations,sends={},{},{} diff --git a/tools/legacy-shader-check/main.lua b/tools/legacy-shader-check/main.lua index 964147bc7..ba335ca52 100644 --- a/tools/legacy-shader-check/main.lua +++ b/tools/legacy-shader-check/main.lua @@ -48,6 +48,12 @@ if major >= 12 then -- Raw entry points own their vertex colour/UV varyings too. pixel='vec4 effect(vec4 c,Image t,vec2 uv,vec2 sc){return sc.y<32.?vec4(1,0,0,1):vec4(0,1,0,1);}'} end +if major >= 12 then + sources[#sources+1] = {name='self-adapted-canvas', code=[[ + vec4 ownCanvasPosition(mat4 m, vec4 p){return vec4(p.x/32.-1.,p.y/32.-1.,0.,1.);} + vec4 position(mat4 m,vec4 p){vec4 clip=ownCanvasPosition(m,p);clip.y=-clip.y;return clip;} + ]]} +end local function render(mesh, canvas, shader, attachment) g.push('all'); g.setShader(shader); g.setCanvas(attachment or canvas) g.origin(); g.clear(0,0,0,1); g.setColor(1,1,1,1); g.setMeshCullMode('none') From 4b80da946588ec474159525c5a5fc26d64611a5d Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Tue, 27 Oct 2026 05:53:45 +0100 Subject: [PATCH 7/7] Preserve native constructor ownership for derived shaders --- LICENSE-ROXAS2712.md | 88 +++++++++++++++++++++++++++ src/render/LegacyShaderCompat.lua | 5 ++ tests/engine/legacy_shader_compat.lua | 4 ++ 3 files changed, 97 insertions(+) create mode 100644 LICENSE-ROXAS2712.md diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..fbcffd47c --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, Gen1Recomp / BOIS CLUB GAMES +work, other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/src/render/LegacyShaderCompat.lua b/src/render/LegacyShaderCompat.lua index 84aff9e3d..30e9749b5 100644 --- a/src/render/LegacyShaderCompat.lua +++ b/src/render/LegacyShaderCompat.lua @@ -121,6 +121,11 @@ function Compat.install() and g and g.newShader and g.getShader and g.getCanvas) then return false end installed = true local newShader, validateShader = g.newShader, g.validateShader + -- Engine-owned derivative programs already use the admitted source's clip + -- convention. Re-analyzing their modified forwarding entry would adapt it + -- twice. Keep the exact preceding constructor, without injecting a macro + -- which could change the producer's preprocessor branches. + Compat.compileNative = newShader local setShader, setCanvas, pop = g.setShader, g.setCanvas, g.pop local function sync(shader) diff --git a/tests/engine/legacy_shader_compat.lua b/tests/engine/legacy_shader_compat.lua index 050cbc0b0..0a8490660 100644 --- a/tests/engine/legacy_shader_compat.lua +++ b/tests/engine/legacy_shader_compat.lua @@ -90,4 +90,8 @@ for _,defines in ipairs({{POKEPORT_NATIVE_CLIP_SPACE=1},{POKEPORT_NATIVE_CLIP_SP local last=validations[#validations];T.eq(last[2],pixel);T.eq(last[3],vertex);T.eq(last[4],nativeOptions,'validator retains native compile options') end love=savedLove +local beforeNative=#sends;local derived=C.compileNative(vertex,options) +T.eq(derived.a,vertex,'derived native source bypasses repeated clip adaptation') +T.eq(derived.b,options,'native compiler seam preserves options') +T.eq(#sends,beforeNative,'native compiler seam has no legacy uniform ownership') T.finish('legacy shader compatibility')