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/world/MapLoader.lua b/src/world/MapLoader.lua index 110dc7df9..59a0b43e5 100644 --- a/src/world/MapLoader.lua +++ b/src/world/MapLoader.lua @@ -199,6 +199,18 @@ function MapLoader.cached(mapId) return m end +-- A source renderer may attach a presentation-only Map to a neighbor record. +-- Native drawing still belongs to the loader's resident map and renderer. +function MapLoader.forNeighbor(neighbor, data) + if not neighbor then return nil end + local id = neighbor.id + local map = id and MapLoader.cached(id) + if not map and data and data.maps and data.maps[id] then + map = MapLoader.load(data, id) + end + return map or neighbor.map +end + -- evict one resident map, releasing its renderer's GPU objects. Callers -- must ensure the map is not the current map and not drawn as a connected -- strip (nothing live may hold its renderer) -- MapLoader.trim guarantees diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a17b2dd6e..16623ae10 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -2187,6 +2187,10 @@ function OverworldState:neighborReach() end function OverworldState:rebuildNeighbors() + -- Source renderers inspect the same live map/tileset registries as the + -- native loader. Missing fields made an already resident connected world + -- appear empty to those readers. Rebind on every rebuild after data edits. + self.maps, self.tilesets = Game.data.maps, Game.data.tilesets local mapId = self.map.id self.neighbors = {} local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS @@ -2202,7 +2206,7 @@ function OverworldState:rebuildNeighbors() hops, reachW, reachH)) do keep[n.id] = true local m = MapLoader.load(Game.data, n.id) - table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy }) + table.insert(self.neighbors, { id = m.id, map = m, ox = n.ox, oy = n.oy, cx = n.ox / 16, cy = n.oy / 16 }) end -- bound resident memory: drop maps behind us that are neither current nor -- a drawn neighbor, releasing their window batch / border image / atlas @@ -17289,7 +17293,7 @@ function OverworldState:drawWorld() self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) self.map.renderer:draw(cam.x, bgY, vw, vh) for _, nb in ipairs(self.neighbors) do - nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) + MapLoader.forNeighbor(nb, Game.data).renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) end -- the Mirage Tower on BG0, under the sprites (src/world/Gen3MirageTower) if self.g3MirageTower then @@ -18095,7 +18099,7 @@ function OverworldState:drawWorld() self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) self.map.renderer:draw(cam.x, bgY, vw, vh) for _, nb in ipairs(self.neighbors) do - nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) + MapLoader.forNeighbor(nb, Game.data).renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) end end end @@ -18585,10 +18589,11 @@ function OverworldState:drawWorld() self.map.renderer:drawAbove(cam.x, bgY, vw, vh) aim(self.map.renderer) for _, nb in ipairs(self.neighbors) do - if nb.map.renderer.drawAbove then - aim(nb.map.renderer, nb.ox, nb.oy) - nb.map.renderer:drawAbove(cam.x - nb.ox, bgY - nb.oy, vw, vh) - aim(nb.map.renderer) + local renderer = MapLoader.forNeighbor(nb, Game.data).renderer + if renderer.drawAbove then + aim(renderer, nb.ox, nb.oy) + renderer:drawAbove(cam.x - nb.ox, bgY - nb.oy, vw, vh) + aim(renderer) end end for _, e in ipairs(onTop or {}) do drawEntity(e) end diff --git a/tests/engine/connected_map_publication.lua b/tests/engine/connected_map_publication.lua new file mode 100644 index 000000000..0d40ebce3 --- /dev/null +++ b/tests/engine/connected_map_publication.lua @@ -0,0 +1,69 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local World=require('src.world.OverworldController') +local Map=require('src.world.Map') +local Loader=require('src.world.MapLoader') +local previous=Version.get() +local gameSlot,oldGame +for i=1,100 do + local name,value=debug.getupvalue(World.rebuildNeighbors,i) + if not name then break end + if name=='Game'then gameSlot,oldGame=i,value;break end +end +assert(gameSlot,'actual native rebuild must have its game owner') +local oldLoad,oldTrim=Loader.load,Loader.trim +for _,edition in ipairs({'red','yellow','gold','crystal','emerald','firered'})do + Version.set(edition) + local pixel=Version.isGen3()and 16 or 32 + local data=T.fixtures.fresh();data.maps={} + local ts={id='FIX_OUT',blockCells=pixel/16,blockTiles=pixel/8,blocks={},walkable={}} + data.tilesets={FIX_OUT=ts} + local maps={} + for _,id in ipairs({'ROOT','EAST','NORTH'})do + local def={id=id,width=2,height=2,blockPx=pixel,tileset='FIX_OUT',blocks={0,0,0,0},objects={},connections={}} + data.maps[id]=def;maps[id]=Map.new(def,ts) + end + data.maps.ROOT.connections.east={map='EAST',offset=-1} + data.maps.EAST.connections.north={map='NORTH',offset=-3} + local game={data=data,save={flags={},party={}},renderer={worldViewSize=function()return 160,144 end}} + debug.setupvalue(World.rebuildNeighbors,gameSlot,game) + local loads=0 + Loader.load=function(actual,id) + T.eq(actual,data,'loader receives the actual native dataset') + loads=loads+1;return assert(maps[id]) + end + Loader.trim=function(keep) + T.check(keep.ROOT and keep.EAST and keep.NORTH,'resident set protects active and connected terrain') + end + local world=setmetatable({map=maps.ROOT,npcs={},entities={},npcPool={},neighbors={},ghosts={}}, {__index=World}) + world:rebuildNeighbors() + T.eq(world.maps,data.maps,edition..' exposes the actual native map registry') + T.eq(world.tilesets,data.tilesets,edition..' exposes the actual native tileset registry') + T.eq(#world.neighbors,2,'native BFS admits direct and farther connected terrain') + T.eq(loads,2,'source observation adds no parallel map loads') + local native={} + -- The source renderer's native-neighbor index, over actual loaded Maps. + for _,neighbor in ipairs(world.neighbors)do + native[neighbor.id]=neighbor + T.eq(neighbor.id,neighbor.map.id,'source ID is the loaded map identity') + T.eq(neighbor.ox,neighbor.cx*16,'pixel/cell X offsets agree') + T.eq(neighbor.oy,neighbor.cy*16,'pixel/cell Y offsets agree') + T.eq(world.maps[neighbor.id],neighbor.map.def,'renderer and loader see the same definition') + T.eq(world.tilesets[neighbor.map.def.tileset],ts,'neighbor tileset remains native') + end + T.eq(native.EAST.map,world.neighbors[1].map,'source neighbor index finds actual east map') + T.eq(native.EAST.ox,2*pixel,'Gen1/2 blocks and Gen3 metatiles preserve their own width') + T.eq(native.EAST.oy,-pixel,'negative connection offset preserves native units') + local defs,tilesets={},{} + for key,value in pairs(data.maps)do defs[key]=value end + for key,value in pairs(data.tilesets)do tilesets[key]=value end + data.maps,data.tilesets=defs,tilesets + world:rebuildNeighbors() + T.eq(world.maps,defs,'registry replacement retires the prior map table') + T.eq(world.tilesets,tilesets,'registry replacement retires the prior tileset table') +end +Loader.load,Loader.trim=oldLoad,oldTrim + debug.setupvalue(World.rebuildNeighbors,gameSlot,oldGame) +Version.set(previous) +T.finish('native connected-map publication for Gen1 through Gen3 source renderers') diff --git a/tests/engine/neighbor_renderer_ownership.lua b/tests/engine/neighbor_renderer_ownership.lua new file mode 100644 index 000000000..249a6733d --- /dev/null +++ b/tests/engine/neighbor_renderer_ownership.lua @@ -0,0 +1,55 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local T=require('tests.harness') +love=require('tests.love_stub') +local Version=require('src.core.GameVersion') +local Loader=require('src.world.MapLoader') +local Pipelines=require('src.render.Pipelines') +local Fixture=require('tests.world_draw.fixture') +local old=Version.get() +local cacheSlot,originalCache +for i=1,100 do + local name,value=debug.getupvalue(Loader.cached,i) + if name=='cache'then cacheSlot,originalCache=i,value;break end + if not name then break end +end +assert(cacheSlot) +local pipeline,draw,load=Pipelines.worldPipeline,Pipelines.drawWorld,Loader.load +for _,edition in ipairs({'red','gold','crystal','emerald','firered','platinum'})do + Version.set(edition) + for _,declines in ipairs({false,true})do + local f=Fixture.new(2);local draws,above=0,0 + local native={id='OWNED_NEIGHBOR',renderer={ + drawMapOnly=function(_,x,y,w,h) + T.eq(x,f.world.camera.x-32,'native strip keeps neighbor X origin') + T.eq(y,f.world.camera.y+16,'native strip keeps neighbor Y origin') + T.eq(w,160);T.eq(h,144);draws=draws+1 + end, + drawAbove=function()above=above+1 end, + }} + local view={id=native.id,renderer={image='source-owned-presentation'}} + local neighbor={id=native.id,map=view,ox=32,oy=-16} + f.world.neighbors={neighbor} + debug.setupvalue(Loader.cached,cacheSlot,{[native.id]=native}) + Pipelines.worldPipeline=function()if declines then return 'source_draw',{ }end end + Pipelines.drawWorld=function()return nil end + T.eq(Loader.forNeighbor(neighbor,f.game.data),native,'loader retains native renderer ownership') + local ok,why=pcall(f.world.drawWorld,f.world) + T.check(ok,edition..' native draw survives '..(declines and'pipeline decline'or'flat draw')..': '..tostring(why)) + T.eq(draws,1,'actual native ground pass draws the resident neighbor once') + T.eq(above,1,'actual upper layer uses the same native renderer') + T.eq(neighbor.map,view,'source presentation view is not overwritten') + T.eq(view.renderer.drawMapOnly,nil,'native draw does not inject methods into a borrowed view') + debug.setupvalue(Loader.cached,cacheSlot,{}) + f.game.data.maps={[native.id]={id=native.id}} + local reloads=0 + Loader.load=function(data,id)T.eq(data,f.game.data);T.eq(id,native.id);reloads=reloads+1;return native end + T.eq(Loader.forNeighbor(neighbor,f.game.data),native,'invalidated native renderer is resolved through its actual loader') + T.eq(reloads,1,'missing native resource is rebuilt once through loader authority') + T.eq(Loader.forNeighbor({id='CUSTOM',map=view},f.game.data),view,'custom non-native neighbor keeps its own renderer') + Loader.load=load + end +end +debug.setupvalue(Loader.cached,cacheSlot,originalCache) +Pipelines.worldPipeline,Pipelines.drawWorld,Loader.load=pipeline,draw,load +Version.set(old) +T.finish('native neighbor renderers survive borrowed source views and pipeline decline') diff --git a/tests/world_draw/fixture.lua b/tests/world_draw/fixture.lua new file mode 100644 index 000000000..d7f8f773c --- /dev/null +++ b/tests/world_draw/fixture.lua @@ -0,0 +1,118 @@ +-- Owned synthetic draw scene: no ROM, profile, asset extraction or game progress. +local F = {} +function F.new(count, options) + local World = options and options.worldModule or require("src.world.OverworldController") + local Game = require("src.core.Game") + -- The normal enter lifecycle supplies this shared engine reference. The + -- isolated rendering fixture has no map loader or save lifecycle to enter. + for index = 1, 100 do + local name = debug.getupvalue(World.drawWorld, index) + if not name then + break + end + if name == "Game" then + debug.setupvalue(World.drawWorld, index, Game) + break + end + end + Game.data = { field = { overworldFx = {} }, constants = {}, sprites = {} } + Game.save = { options = {} } + local trace = {} + local function mark(name, ...) + if options and options.quiet then + return + end + local row = { name, ... } + for i = 1, #row do + row[i] = tostring(row[i]) + end + trace[#trace + 1] = table.concat(row, ":") + end + local renderer = {} + for _, name in ipairs({ "drawBorderFill", "draw", "drawMapOnly", "drawAbove", "drawReflectionCover" }) do + renderer[name] = function(_, ...) + mark(name, ...) + end + end + local world = setmetatable({ + camera = { x = 3, y = 5 }, + map = { + id = "SYNTHETIC", + renderer = renderer, + isGrassCell = function() + return false + end, + }, + neighbors = {}, + ghosts = {}, + entities = {}, + gen4Shadows = false, + waterAnim = { x = 1, y = 1, frames = 12, total = 40 }, + ripples = { { px = 15, py = 31, clock = 0 } }, + }, { __index = World }) + for i = 1, count or 3 do + local e = { + px = i * 16, + py = i * 16, + cellX = i, + cellY = i, + facing = "down", + above = i % 7 == 0, + draw = function(self, cx, cy) + mark("entity", self.cellX, cx, cy) + end, + drawReflection = function(self, cx, cy) + mark("reflection", self.cellX, cx, cy) + end, + } + world.entities[i] = e + end + world.player = world.entities[1] + world.reflectionSearch = function(_, e) + return e.cellX % 2 == 0 and { { e.cellX, e.cellY + 1 } } or nil + end + world.stillReflection = function() + return false + end + world.reflectionRect = function(_, e) + return e.cellX, e.cellY + 1, e.cellX + 1, e.cellY + 2 + end + world.gen3AboveTopLayer = function(_, e) + return e.above + end + world.drawRipples = function(_, cx, cy) + mark("ripples", cx, cy) + end + world.drawGen3Gates = function() + mark("gates") + end + world.drawGen3Flash = function() + mark("flash") + end + world.drawGen3Decorate = function() + mark("decoration") + end + world.placeFreeCamera = function() end + world.reflectProbe, world.reflectProbe2 = 1, 1 + Game.renderer = { + worldViewSize = function() + return 160, 144 + end, + fitScale = function() + return 1 + end, + setWorldOverride = function() end, + } + return { + world = world, + game = Game, + trace = function() + return trace + end, + reset = function() + trace = {} + end, + mark = mark, + } +end +return F