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/ui/Gen2OptionGroups.lua b/src/ui/Gen2OptionGroups.lua new file mode 100644 index 000000000..28794da1a --- /dev/null +++ b/src/ui/Gen2OptionGroups.lua @@ -0,0 +1,44 @@ +-- Gen1Recomp's Gen2 option-page vocabulary, backed by native row handlers. +-- Group ids/order follow reference 535a5ec0 src/ui/gen2/OptionsMenu.lua. +-- Original reference rights and the existing project licenses remain applicable. +local Strings = require("src.core.Strings") +local M = {} +M.groups = { + {id="group.speed", label=Strings.source("SPEED"), members={"textSpeed","speed"}}, + {id="group.video", label=Strings.source("VIDEO"), members={"videoMode","orientation","faithfulRes","screenPos","fpsCap","vsync","logicClock"}}, + {id="group.graphics", label=Strings.source("GRAPHICS"), members={"color","colors","gbcColorCorrection","gbcfx","shaderPreset","uiLetterbox","shaderfx","shaderfx2","frame"}}, + {id="group.audio", label=Strings.source("AUDIO"), members={"sound","stereo","musicVol","sfxVol","musicFilter","audioMode"}}, + {id="group.battle", label=Strings.source("BATTLE OPTIONS"), members={"battleScene","battleAnim","animations","battleStyle","battleLayout","battleHud","battleFit","battleBg"}}, + {id="group.extras", label=Strings.source("EXTRAS"), members={"zoom","voidFill","tilt"}}, +} +function M.build(rows, open) + local owners, picked, made, byId = {}, {}, {}, {} + for _, group in ipairs(M.groups) do + picked[group.id] = {} + for _, id in ipairs(group.members) do owners[id] = group.id end + end + for _, row in ipairs(rows) do + local owner = owners[row.id] + if owner then picked[owner][#picked[owner]+1] = row + elseif row.id then byId[row.id] = row end + end + for _, group in ipairs(M.groups) do + local members = picked[group.id] + if #members > 0 then + made[group.id] = {id=group.id, label=group.label, group=true, + activate=function() return open(members) end} + end + end + local view, taken = {}, {} + for _, id in ipairs({"group.speed","group.video","group.graphics","group.audio", + "performance","group.battle","group.extras"}) do + local row = made[id] or byId[id] + if row then view[#view+1] = row; taken[id] = true end + end + for _, row in ipairs(rows) do + if not row.cancel and not owners[row.id] and not taken[row.id] then view[#view+1] = row end + end + view[#view+1] = {id="cancel", label=Strings("BACK"), cancel=true} + return view +end +return M diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..c60db7b95 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -166,8 +166,8 @@ local function buildRows(game) end }, { id = "textSpeed", label = Strings("TEXT SPEED"), value = function(g) return Strings(SPEEDS[speedIndex(g)][2]) end, - step = function(g) - local i = speedIndex(g) % #SPEEDS + 1 + step = function(g, dir) + local i = (speedIndex(g) - 1 + (dir or 1)) % #SPEEDS + 1 g.save.options.textSpeed = SPEEDS[i][1] return true end }, @@ -768,6 +768,15 @@ end function OptionsMenu.new(game, opts) opts = opts or {} + -- Explicit pages use the same public constructor as the root. Presentation + -- consumers wrap this entry to attach their per-instance draw/ownership + -- contract; creating a raw table in a group callback bypasses that contract. + if opts.rows then + assert(type(opts.rows) == "table", "option page rows must be a table") + return setmetatable({game=game, rows=opts.rows, view=opts.rows, + options=game.save and game.save.options or {}, index=1, scroll=0, sub=true, + onCancel=opts.onCancel, onDone=opts.onDone}, OptionsMenu) + end local rows = buildRows(game) local hooked = Runtime.call("ui.options.rows", sameRows, game, rows) if type(hooked) == "table" then @@ -776,11 +785,93 @@ function OptionsMenu.new(game, opts) Logger.error("ui.options.rows returned %s; keeping the vanilla rows", type(hooked)) end - return setmetatable({ game = game, rows = rows, index = 1, scroll = 0, - onCancel = opts.onCancel }, OptionsMenu) + local self = setmetatable({ game = game, rows = rows, index = 1, scroll = 0, + onCancel = opts.onCancel, onDone = opts.onDone }, OptionsMenu) + if GameVersion.isGen2() then + self.options = game.save.options + self.view = require("src.ui.Gen2OptionGroups").build(rows, function(members) + local pageRows = {} + for i,row in ipairs(members) do pageRows[i]=row end + pageRows[#pageRows+1]={id="cancel",label=Strings("BACK"),cancel=true} + local page = OptionsMenu.new(game, {rows=pageRows}) + game.stack:push(page) + return page + end) + end + return self +end + +function OptionsMenu:visible() return self.view or self.rows end +function OptionsMenu:row() return self:visible()[self.index] end +function OptionsMenu:ensureVisible() + local count = #self:visible() + self.scroll = math.max(0, math.min(OptionRows.clampScroll(self.index, + self.scroll or 0, count), math.max(0,count-OptionRows.VISIBLE))) +end +function OptionsMenu:focusRow(id) + for i,row in ipairs(self:visible()) do + if row.id==id then self.index=i;self:ensureVisible();return self end + end + for i,row in ipairs(self:visible()) do + if row.group then + local found=false + for _,group in ipairs(require("src.ui.Gen2OptionGroups").groups) do + if group.id==row.id then + for _,member in ipairs(group.members) do if member==id then found=true end end + end + end + if found then + -- A hook can remove a member; do not open its group in that case. + for _,member in ipairs(self.rows) do + if member.id==id then + self.index=i;self:ensureVisible() + local page=row.activate(self.game) + return page and page:focusRow(id) + end + end + end + end + end +end +function OptionsMenu:cycle(row, delta) + if not row or row.group or row.cancel then return end + local changed + if row.step then changed=row.step(self.game,delta) + elseif row.cycle then row.cycle(self.options,delta,self.game);changed=true + elseif row.values and row.key then + local at=1 + for i,value in ipairs(row.values) do if value==self.options[row.key] then at=i;break end end + if #row.values>0 then self.options[row.key]=row.values[(at-1+delta)%#row.values+1];changed=true end + end + if changed and self.game.writeOptions then self.game:writeOptions() end +end +function OptionsMenu:leave_() + if self.game.stack:top()~=self then return end + self.game.stack:pop() + if self.onDone then self.onDone(self.options) end + if self.onCancel then self.onCancel() end +end +function OptionsMenu:updateGrouped() + local input=self.game.input + if input:wasPressed("b") or input:wasPressed("start") then self:leave_();return end + local rows=self:visible() + if #rows==0 then return end + if input:wasPressed("up") then self.index=(self.index-2)%#rows+1 + elseif input:wasPressed("down") then self.index=self.index%#rows+1 + else + local row=self:row() + if input:wasPressed("a") and row then + if row.cancel then self:leave_() + elseif row.activate then row.activate(self.game) + else self:cycle(row,1) end + elseif input:wasPressed("left") then self:cycle(row,-1) + elseif input:wasPressed("right") then self:cycle(row,1) end + end + self:ensureVisible() end function OptionsMenu:update(dt) + if self.view then return self:updateGrouped() end local input = self.game.input local rows = self.rows -- CANCEL sits below the hook-built rows so a mod cannot orphan the exit @@ -825,6 +916,9 @@ function OptionsMenu:update(dt) end function OptionsMenu:draw() + if self.view then + return OptionRows.draw(self.game,self.view,self.index,self.scroll or 0) + end OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0, "CANCEL", #self.rows + 1) end diff --git a/tests/engine/gen2_option_groups.lua b/tests/engine/gen2_option_groups.lua new file mode 100644 index 000000000..7a444dcbd --- /dev/null +++ b/tests/engine/gen2_option_groups.lua @@ -0,0 +1,115 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local before=Version.get() +local Menu=require('src.ui.OptionsMenu');local Stack=require('src.core.StateStack') +local Groups=require('src.ui.Gen2OptionGroups');local Save=require('src.core.SaveData') +local function session() + local g={data=T.fixtures.fresh(),save=Save.newGame(),writes=0,input={pressed={}}} + g.input.wasPressed=function(self,key)return self.pressed[key]==true end + g.stack=setmetatable({}, {__index=Stack});g.stack:init() + g.writeOptions=function(self)self.writes=self.writes+1;self.persisted=Save.encode(self.save)end + return g +end +local function press(g,key) + g.input.pressed={[key]=true};g.stack:top():update(1/60);g.input.pressed={} +end +for _,edition in ipairs({'gold','silver','crystal'}) do + Version.set(edition);local g=session();local done,cancel=0,0 + local m=Menu.new(g,{onDone=function(o)T.eq(o,g.save.options,'completion receives actual native options');done=done+1 end,onCancel=function()cancel=cancel+1 end}) + g.stack:push(m) + T.eq(m.options,g.save.options,edition..' observes actual native table') + local ids={};for _,r in ipairs(m.rows)do ids[r.id]=r end + T.eq(m:visible()[1].id,'group.speed',edition..' reference group order begins SPEED') + local flat={};for i,row in ipairs(m.rows)do flat[i]=row end + for _,group in ipairs(Groups.groups)do + local child=m:focusRow(group.members[1]) + -- Some reference ids differ from the shared native names. Find a present member. + if not child then for _,id in ipairs(group.members)do if ids[id]then child=m:focusRow(id);break end end end + if child then + T.eq(g.stack:top(),child,edition..' focus opens actual group page') + T.eq(child.options,g.save.options,edition..' group edits native option table') + T.eq(child:row(),ids[child:row().id],edition..' keeps exact native row handler identity') + press(g,'b');T.eq(g.stack:top(),m,edition..' BACK restores parent') + end + end + local page=m:focusRow('textSpeed');local previous=g.save.options.textSpeed + press(g,'right');T.neq(g.save.options.textSpeed,previous,edition..' native value handler runs') + T.eq(g.writes,1,edition..' change persists through native writer exactly once') + T.eq(assert(Save.decode(g.persisted)).options.textSpeed,g.save.options.textSpeed,edition..' normal save codec retains selected value') + press(g,'left');T.eq(g.save.options.textSpeed,previous,edition..' left reverses the native text-speed ladder') + T.eq(g.writes,2,edition..' reversal also reaches normal persistence') + press(g,'b');T.eq(done,0,edition..' group does not finish root') + T.same(m.rows,flat,edition..' flat hook rows retained unchanged') + local count=#g.stack.states;T.eq(m:focusRow('absent'),nil,edition..' absent focus fails honestly') + T.eq(#g.stack.states,count,edition..' absent focus cannot open another page') + press(g,'b');T.eq(done,1,edition..' root completion once');T.eq(cancel,1,edition..' caller cancellation once') + m:leave_();T.eq(done,1,edition..' retired root cannot repeat completion') +end +Version.set('red');local g=session();local m=Menu.new(g);T.eq(m.view,nil,'Gen1 retains its flat menu path') +Version.set('emerald');g=session();m=Menu.new(g);T.eq(m.view,nil,'Gen3 retains its independent option presentation') +local custom={id='own',label='OWN',step=function()end};local native={id='textSpeed',label='TEXT'} +local grouped=Groups.build({native,custom},function()end) +T.eq(grouped[2],custom,'unknown authored rows remain accessible at root') +T.eq(grouped[#grouped].cancel,true,'BACK remains reachable') + +-- Real presentation consumers wrap the public constructor, attaching draw +-- functions and ownership to each returned instance. A grouped child must +-- reach that same seam, with its original native rows and no second root hook. +local Runtime=require('src.mods.Runtime') +local nativeNew,nativeCall=Menu.new,Runtime.call +local constructed,rootHooks=0,0 +Runtime.call=function(name,...) + if name=='ui.options.rows'then rootHooks=rootHooks+1 end + return nativeCall(name,...) +end +Menu.new=function(game,opts) + local page=nativeNew(game,opts) + constructed=constructed+1 + page.constructorOwner=constructed + page.isOpaque=false + page.drawWidescreen=function(self)return self.constructorOwner end + return page +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition);local g=session();local completions=0 + local first=constructed;local hooks=rootHooks + local parent=Menu.new(g,{onDone=function()completions=completions+1 end}) + g.stack:push(parent) + T.eq(rootHooks,hooks+1,edition..' root hook runs once') + local count=0 + for _,row in ipairs(parent:visible())do + if row.group then + local nativeRows={};for i,member in ipairs(parent.rows)do nativeRows[i]=member end + local child=row.activate(g);count=count+1 + T.eq(g.stack:top(),child,edition..' group admits returned native page') + T.eq(child.constructorOwner,first+1+count,edition..' child reaches actual public constructor wrapper') + T.eq(child:drawWidescreen(),child.constructorOwner,edition..' child retains per-instance presentation') + T.eq(child.isOpaque,false,edition..' wrapped overlay ownership survives admission') + T.eq(getmetatable(child),Menu,edition..' native option controller still owns input') + T.eq(child.options,g.save.options,edition..' child retains exact native options table') + T.eq(child:visible()[#child:visible()].cancel,true,edition..' actual BACK remains last') + T.same(parent.rows,nativeRows,edition..' child cannot append BACK into root hook rows') + T.eq(rootHooks,hooks+1,edition..' child never replays root row hook') + press(g,'b');T.eq(g.stack:top(),parent,edition..' wrapped child BACK restores exact parent') + T.eq(completions,0,edition..' child BACK never finishes root') + end + end + T.eq(count,6,edition..' every native group reaches constructor contract') + press(g,'b');T.eq(completions,1,edition..' root completion still runs once') + local explicitRows={{id='own',label='OWN',step=function(game)game.ownSteps=(game.ownSteps or 0)+1;return true end}, + {id='cancel',label='BACK',cancel=true}} + local calls=rootHooks;local cancelled=0 + local explicit=Menu.new(g,{rows=explicitRows,onCancel=function()cancelled=cancelled+1 end}) + g.stack:push(explicit) + T.eq(explicit.rows,explicitRows,edition..' explicit page retains caller-owned row identity') + T.eq(explicit:visible(),explicitRows,edition..' explicit page stays ungrouped') + T.eq(rootHooks,calls,edition..' explicit page does not build/hook a second root') + press(g,'right');T.eq(g.ownSteps,1,edition..' explicit page executes supplied native handler') + press(g,'b');T.eq(cancelled,1,edition..' explicit page keeps caller cancellation') + local empty=Menu.new(g,{rows={}});g.stack:push(empty) + press(g,'down');T.eq(empty.index,1,edition..' empty explicit page ignores navigation safely') + press(g,'b');T.eq(g.stack:top(),nil,edition..' empty explicit page remains cancellable') +end +Menu.new,Runtime.call=nativeNew,nativeCall +Version.set(before);T.finish('native Gen2 grouped options, handler identity and persistence')