Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions LICENSE-ROXAS2712.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions src/ui/Gen2OptionGroups.lua
Original file line number Diff line number Diff line change
@@ -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
102 changes: 98 additions & 4 deletions src/ui/OptionsMenu.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions tests/engine/gen2_option_groups.lua
Original file line number Diff line number Diff line change
@@ -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')