From fb22e4a1ad1e7278559fb42ae4d290e226a78a32 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 00:54:27 +0200 Subject: [PATCH 01/64] Publish Gen2 palette row metadata for native atlas renderers --- src/render/PaletteFX.lua | 4 ++++ tests/engine/gen2_palette_queries.lua | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 tests/engine/gen2_palette_queries.lua diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..b07099033 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -467,6 +467,10 @@ local gen2ClockTod = "DAY" -- what the clock alone says local gen2MapPalette = 0 -- PALETTE_* from the current map's header -- OverworldState:timeOfDay answers "MORNING"/"NITE"; a mod's world.tod hook may -- answer "NIGHT". Normalise to the four row names the importer writes. +-- Public ROM row order, shared with native Gen2 atlas consumers. +PaletteFX.DAYTIMES = { "MORN", "DAY", "NITE", "DARK" } +PaletteFX.DAYTIME_ID = { MORN = 1, DAY = 2, NITE = 3, DARK = 4 } + local GEN2_TOD_ALIAS = { MORN = "MORN", MORNING = "MORN", DAY = "DAY", DAYTIME = "DAY", NITE = "NITE", NIGHT = "NITE", EVE = "NITE", EVENING = "NITE", diff --git a/tests/engine/gen2_palette_queries.lua b/tests/engine/gen2_palette_queries.lua new file mode 100644 index 000000000..3bc458519 --- /dev/null +++ b/tests/engine/gen2_palette_queries.lua @@ -0,0 +1,27 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local P=require('src.render.PaletteFX') +local Data=require('src.core.Data') +local original={tilesets=Data.tilesets,palettes=Data.gen2Palettes,mode=P.mode,dark=P.darkWorld()} +P.setMode('gbc');P.setDarkWorld(false) +local rows={};for i,tod in ipairs({'MORN','DAY','NITE','DARK'})do + rows[tod]={};for slot=1,8 do rows[tod][slot]={{i,slot,1},{i,slot,2},{i,slot,3},{i,slot,4}}end +end +Data.tilesets={JOHTO={palColorsByTod=rows,palMap={0,1,7}}};Data:publishGen2Palettes() +for i,tod in ipairs({'MORN','DAY','NITE','DARK'})do + T.eq(P.DAYTIMES[i],tod,'ROM row order '..tod) + T.eq(P.DAYTIME_ID[tod],i,'public daytime lookup '..tod) + -- Native external atlas call shape: preserve the effective world daytime, + -- then query the published data without changing the live native state. + local effective=tod + local daytime=P.daytimeFor({tileset='JOHTO'},12,false) + if P.DAYTIME_ID[effective]then daytime=effective end + local set,row=P.bgSet(Data.gen2Palettes,{tileset='JOHTO'},daytime) + T.eq(set,rows[tod],'native external atlas receives actual row '..tod) + T.eq(row,tod,'resolved row name '..tod) +end +T.eq(P.DAYTIME_ID.UNKNOWN,nil,'unknown row not accepted') +T.eq(P.bgSet(Data.gen2Palettes,{tileset='MISSING'},'DAY'),nil,'missing tileset stays unavailable') +P.setMode('og');T.eq(P.bgSet(Data.gen2Palettes,{tileset='JOHTO'},'DAY'),nil,'DMG color choice honored') +Data.tilesets,Data.gen2Palettes=original.tilesets,original.palettes;P.setMode(original.mode);P.setDarkWorld(original.dark) +T.finish('Gen2 public palette query rows') From 3b170ce280c4bb1414ed1a6bfb6b45bf0102404b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:01:32 +0100 Subject: [PATCH 02/64] Expose raw RTC daytime alongside native Gen2 map palette queries --- src/render/PaletteFX.lua | 20 +++++++++----- tests/engine/gen2_palette_clock.lua | 43 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 tests/engine/gen2_palette_clock.lua diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index b07099033..0475e29a4 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -539,6 +539,18 @@ function PaletteFX.gen2Tod() return gen2Tod end -- bgSet fetches it (LoadMapPals). Neither reads or writes the live state, so -- calling them cannot disturb what the 2D atlas was baked with. +-- Public RTC-only query used by Gen2 scene renderers (including train rides). +-- It must not inherit the live map override, darkness, or atlas bake state. +function PaletteFX.clockDaytime(hour) + hour = tonumber(hour) + if not hour then hour = tonumber(os.date("%H")) end + hour = math.floor(hour or 12) % 24 + if hour < 4 then return "NITE" end + if hour < 10 then return "MORN" end + if hour < 18 then return "DAY" end + return "NITE" +end + -- Which of MORN / DAY / NITE / DARK a map takes. -- -- mapDef a record out of data.maps; its `mapPalette` is map header @@ -563,13 +575,7 @@ function PaletteFX.daytimeFor(mapDef, hour, flashUsed) if override then return override end -- AUTO: GetTimeOfDay's own table (5:$4032 .TimeOfDayTable). - hour = tonumber(hour) - if not hour then hour = tonumber(os.date("%H")) end - hour = math.floor(hour or 12) % 24 - if hour < 4 then return "NITE" end - if hour < 10 then return "MORN" end - if hour < 18 then return "DAY" end - return "NITE" + return PaletteFX.clockDaytime(hour) end -- The eight BG palettes a map wears on a given row. diff --git a/tests/engine/gen2_palette_clock.lua b/tests/engine/gen2_palette_clock.lua new file mode 100644 index 000000000..1beb3dc13 --- /dev/null +++ b/tests/engine/gen2_palette_clock.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes') +local previous={dark=P.darkWorld(),tod=P.gen2Tod(),map=P.gen2MapPalette(),date=os.date} +T.eq(P,require('src.render.PaletteFX'),'public Gen2 palette spelling shares actual native module') +P.setDarkWorld(false);P.setGen2MapPalette(0) +local cases={{0,'NITE'},{3.99,'NITE'},{4,'MORN'},{9.99,'MORN'},{10,'DAY'},{17.99,'DAY'}, + {18,'NITE'},{23.99,'NITE'},{24,'NITE'},{28,'MORN'},{34,'DAY'},{-1,'NITE'}} +for _,case in ipairs(cases)do + T.eq(P.clockDaytime(case[1]),case[2],'RTC query boundary '..case[1]) + T.eq(P.daytimeFor({mapPalette=0},case[1],false),case[2],'native AUTO query shares boundary '..case[1]) +end +for _,v in ipairs({'gold','silver','crystal'})do + V.set(v) + os.date=function(format,...)if format=='%H'then return '19'end;return previous.date(format,...)end + P.setGen2Tod('MORN');P.setGen2MapPalette(1);P.setDarkWorld(true) + T.eq(P.clockDaytime(),'NITE',v..' scene RTC query ignores map DAY and darkness') + T.eq(P.daytimeFor({mapPalette=1},nil,true),'DAY',v..' map override still selects its own row') + T.eq(P.daytimeFor({mapPalette=1},nil,false),'DARK',v..' native darkness still wins before map override') + T.eq(P.gen2Tod(),'DAY',v..' public clock query never changes live bake state') + local Json=require('src.link.Json');local d=T.fixtures.fresh() + local files={['mods/clock/manifest.json']=Json.encode({id='clock',name='Clock contract',version='1.0.0',api=2,entry='main.lua',affects_link=false}), + ['mods/clock/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + -- Authored scene contract: select a sky from the raw RTC, independently + -- of the current room's forced palette. No renderer/mod art is included. + local sky={MORN='early',DAY='sunny',NITE='late'} + mod.exports.sky=sky[Palettes.clockDaytime()] + mod.exports.override=Palettes.daytimeFor({mapPalette=1},nil,true) + ]]} + local r=T.sdk.loadMods({'mods/clock'},{data=d,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,v..' actual Loader accepts unchanged public clock call') + local exports=r.loader.exports.clock + T.eq(exports.sky,'late',v..' loaded scene gets RTC sky instead of nil-method failure') + T.eq(exports.override,'DAY',v..' loaded scene can separately query map palette') + require('src.mods.Loader').teardown();r.release() +end +os.date=previous.date;P.setDarkWorld(previous.dark);P.setGen2MapPalette(previous.map);P.setGen2Tod(previous.tod);V.set(version) +T.finish('Gen2 public scene RTC palette query') From f50726a5cde7be61d26685785a352a056e164249 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:37:00 +0100 Subject: [PATCH 03/64] Share native per-map palettes, roof rules and entered world context --- docs/native-gen2-palette-context.md | 53 +++++++++ src/core/Data.lua | 4 + src/core/GameVersion.lua | 5 + src/mods/Loader.lua | 4 + src/render/Gen2PaletteContext.lua | 74 ++++++++++++ src/render/PaletteFX.lua | 36 +++++- src/render/TileRenderer.lua | 98 ++++------------ src/world/OverworldController.lua | 21 +++- tests/engine/gen2_palette_clock.lua | 2 +- tests/engine/gen2_palette_context.lua | 158 ++++++++++++++++++++++++++ 10 files changed, 374 insertions(+), 81 deletions(-) create mode 100644 docs/native-gen2-palette-context.md create mode 100644 src/render/Gen2PaletteContext.lua create mode 100644 tests/engine/gen2_palette_context.lua diff --git a/docs/native-gen2-palette-context.md b/docs/native-gen2-palette-context.md new file mode 100644 index 000000000..68450c9e8 --- /dev/null +++ b/docs/native-gen2-palette-context.md @@ -0,0 +1,53 @@ +# Native Gen2 map palette context + +Native GSC maps store the header's palette nibble in `mapPalette`. External +renderers also read the symbolic `def.palette`, `world.daytime` and +`world.flashUsed` contracts. These read views now expose native facts without +seeding another clock, Flash bit or palette catalogue. + +Map palette names are computed from the current byte. Explicit authored +values and existing record metatables are preserved. Unused native header +slots 5..7 follow AUTO; invalid/missing bytes do not invent a named palette. +The public world's effective daytime reads the actual palette bake state, +including darkness; Flash reads the actual `save.flashLit` field written by +the native script command. Explicit authored Lua fields retain precedence. +This does not implement the separate saved RTC-base protocol or every legacy +world mutation method. + +`PaletteFX.daytimeFor(def, hour, flashUsed)` resolves a known map from its own +header. A neighbouring room stays DAY while the current cave is dark, and an +unentered DARK cave stays dark until Flash is supplied. Metadata-free old +queries retain their prior live-state fallback. The native renderer uses +`gen2RowFor` to distinguish the entered map's actual presentation from a +neighbour's own header and the active clock/Flash presentation state. Native +map entry and Flash update that state; atlas cache keys include the selected +row. Animated overdraw receives the same selected palette as the static bake. +An omitted hour uses the entered world's native clock row, including its +`world.tod` selection; outside an entered context it keeps the host-clock +fallback. An explicit hour remains authoritative. Mod load/unload and edition +changes retire the entered context, so returning to the launcher cannot +revive a previous world's clock selection. + +`PaletteFX.bgSet` accepts the full native dataset or its published +`gen2Palettes` view. The latter carries a private association with its owner, +so imported special-map rules and roof pairs remain available. A superseded +view cannot combine old tileset rows with new metadata. Colourless caches and +DMG mode still decline colouring. + +Special-map rule ordering and roof selection are shared with `TileRenderer`. +The first matching special rule owns the decision even if its set is missing; +darkness skips specials. Roof replacement copies only the row list and two +middle roof colours, retaining endpoints and the source tileset. Live metadata +edits are observed through the normal native invalidation/rebuild lifecycle. +No ROM colours, dialogue, source art or private mod implementation is bundled. + +Synthetic tests exercise the actual Loader, public module calls, native Flash +writer and `TileRenderer` CPU atlas bake. Owned in-memory pixel/image adapters +replace graphics IO, and baked RGB samples are compared with the public query +for AUTO, forced rows, dark caves, special maps and roofs. These are not GPU +or complete unchanged-VASC gameplay acceptance tests. General legacy +`map.palette` row-hook semantics and saved RTC integration remain separate +parity work; the existing SGB palette-name hook is preserved. + +Original native selection routines retain Cedric's rights; additional +integration is covered by [the Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/core/Data.lua b/src/core/Data.lua index ca41e93a0..8863bbd8b 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -685,6 +685,9 @@ local function gen2PaletteRows(tileset) end function Data:publishGen2Palettes() + if require('src.core.GameVersion').isGen2()then + for _,def in pairs(self.maps or{})do require('src.render.Gen2PaletteContext').publish(def)end + end local tilesets = self.tilesets if type(tilesets) ~= "table" then return false end @@ -735,6 +738,7 @@ function Data:publishGen2Palettes() self.gen2Palettes = setmetatable({}, { __index = function(_, id) return gen2PaletteRows(tilesets[id]) end, }) + require('src.render.Gen2PaletteContext').bind(self.gen2Palettes,self) return true end diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..6c92886cf 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1082,7 +1082,12 @@ GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", GameVersion.current = "red" function GameVersion.set(id) + local previous=GameVersion.current GameVersion.current = GameVersion.VERSIONS[id] and id or "red" + if previous~=GameVersion.current then + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end + end return GameVersion.current end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..cd3ab6876 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1310,6 +1310,8 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the @@ -1653,6 +1655,8 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/render/Gen2PaletteContext.lua b/src/render/Gen2PaletteContext.lua new file mode 100644 index 000000000..d5631b5f4 --- /dev/null +++ b/src/render/Gen2PaletteContext.lua @@ -0,0 +1,74 @@ +-- Native special-map/roof selection adapted from TileRenderer.lua: +-- Copyright (c) 2026 Cedric. All rights reserved. See LICENSE.md. +-- Additional context/view integration (c) 2026 Roxas2712. +-- See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md; existing upstream rights remain unchanged. +local M={} +local names={[0]='PALETTE_AUTO','PALETTE_DAY','PALETTE_NITE','PALETTE_MORN','PALETTE_DARK', + 'PALETTE_AUTO','PALETTE_AUTO','PALETTE_AUTO'} +local ids={PALETTE_AUTO=0,PALETTE_DAY=1,PALETTE_NITE=2,PALETTE_MORN=3,PALETTE_DARK=4} +local views=setmetatable({},{__mode='k'}) +function M.id(def) + if type(def)~='table'then return nil end + local authored=rawget(def,'palette') + if ids[authored]~=nil then return ids[authored]end + local n=tonumber(def.mapPalette) + return n and n%1==0 and n>=0 and n<=7 and n or nil +end +function M.publish(def) + if type(def)~='table'or rawget(def,'palette')~=nil or getmetatable(def)~=nil then return false end + setmetatable(def,{__index=function(t,key) + if key=='palette'then return names[M.id(t)]end + end}) + return true +end +function M.bind(view,data)views[view]={data=data,tilesets=data.tilesets}end +function M.owner(data) + if type(data)~='table'then return nil end + local binding=views[data] + if binding then + local owner=binding.data + return owner.gen2Palettes==data and owner.tilesets==binding.tilesets and owner or nil,true + end + return type(data.tilesets)=='table'and data or nil,false +end +function M.special(data,def,tileset,row) + if row=='DARK'then return nil end + local special=data and data.field and data.field.gen2MapPalettes + if type(def)~='table'or type(special)~='table'or type(special.rules)~='table'then return nil end + for _,rule in ipairs(special.rules)do + local hit + if rule.kind=='map'then hit=rule.group==def.group and rule.number==def.number + elseif rule.kind=='landmark'then hit=rule.landmark==def.landmark + elseif rule.kind=='tileset'then hit=rule.tileset==tileset end + if hit and rule.landmarkMin then hit=type(def.landmark)=='number'and def.landmark>=rule.landmarkMin end + if hit and rule.landmarkEq then hit=def.landmark==rule.landmarkEq end + if hit then + local set=special.sets and special.sets[rule.set] + local rows=set and(set.ALL or set[row]or set.DAY) + if rows and #rows>0 then return '#pal:'..tostring(rule.set),rows end + return nil -- The first matching rule owns the decision, even if unavailable. + end + end +end +function M.roof(data,def,tileset,row) + local roofs=data and data.field and data.field.gen2Roofs + if type(roofs)~='table'or type(tileset)~='table'or + type(roofs.tilesets)~='table'or not roofs.tilesets[tileset.id]then return nil end + local group=def and def.group + local id=group~=nil and type(roofs.byGroup)=='table'and roofs.byGroup[group]or nil + if id==nil then return nil end + local image=type(roofs.images)=='table'and roofs.images[id]or nil + if not image then return nil end + local byTod=type(roofs.colors)=='table'and roofs.colors[group]or nil + local pair=byTod and(byTod[row]or byTod.DAY)or nil + return{image=image,pair=pair,slot=roofs.slot or 10,count=roofs.count or 9,palIndex=roofs.palIndex or 6, + variants=tileset.tileVariants,key='#roof:'..tostring(id)..'.'..tostring(group)} +end +function M.withRoof(rows,roof) + if not(roof and roof.pair)then return rows end + local out={};for i=1,#rows do out[i]=rows[i]end + local slot=roof.palIndex+1;local row=out[slot] + if row then out[slot]={row[1],roof.pair[1],roof.pair[2],row[4]}end + return out +end +return M diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 0475e29a4..c95d9d31e 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -489,6 +489,23 @@ local GEN2_TOD_ALIAS = { local GEN2_MAP_PALETTE_ROW = { [1] = "DAY", [2] = "NITE", [3] = "MORN", [4] = "NITE", } +local gen2FlashUsed=false +local gen2CurrentDef +function PaletteFX.clearGen2Context()gen2CurrentDef=nil;gen2FlashUsed=false end +function PaletteFX.bindGen2Context(def,flashUsed) + gen2CurrentDef=type(def)=='table'and def or nil;gen2FlashUsed=flashUsed==true +end +function PaletteFX.setGen2FlashUsed(on)gen2FlashUsed=on==true end +function PaletteFX.effectiveGen2Tod()return darkWorld and 'DARK'or gen2Tod end +-- A neighbour uses its own header, the native hooked clock and the same Flash +-- presentation state. It must not inherit the current map's forced DAY row. +function PaletteFX.gen2RowFor(def) + if def~=nil and def==gen2CurrentDef then return PaletteFX.effectiveGen2Tod()end + local id=require('src.render.Gen2PaletteContext').id(def) + if id==nil then return PaletteFX.effectiveGen2Tod() end + if id==4 then return gen2FlashUsed and 'NITE'or 'DARK'end + return GEN2_MAP_PALETTE_ROW[id]or gen2ClockTod +end local function gen2RecomputeTod() local key = GEN2_MAP_PALETTE_ROW[gen2MapPalette] or gen2ClockTod @@ -506,7 +523,8 @@ end -- Called on every map change with the map's PALETTE_* byte (0 = AUTO). -- Same contract as setGen2Tod: true means the atlases are stale. -function PaletteFX.setGen2MapPalette(palette) +function PaletteFX.setGen2MapPalette(palette, mapDef) + gen2CurrentDef=type(mapDef)=='table'and mapDef or nil local value = tonumber(palette) or 0 if value < 0 or value > 7 then value = 0 end -- PALETTE_5..7 are unused slots that ReplaceTimeOfDayPals maps to $E4, the @@ -562,6 +580,11 @@ end -- a caller that has no flash flag to offer gets the engine's own -- darkWorld state, which already folds in save.flashLit. function PaletteFX.daytimeFor(mapDef, hour, flashUsed) + local id=require('src.render.Gen2PaletteContext').id(mapDef) + if id~=nil then + if id==4 then return flashUsed==true and 'NITE'or 'DARK'end + return GEN2_MAP_PALETTE_ROW[id]or(hour==nil and gen2CurrentDef and gen2ClockTod)or PaletteFX.clockDaytime(hour) + end -- Darkness wins outright and the clock stops mattering, which is what -- .NeedsFlash does: an unlit cave is the DARKNESS row at noon. if flashUsed ~= true and darkWorld then return "DARK" end @@ -606,9 +629,18 @@ function PaletteFX.bgSet(data, mapDef, daytime) end if type(rows) ~= "table" then return nil end - local row = GEN2_TOD_ALIAS[tostring(daytime or ""):upper()] or gen2Tod + local row = GEN2_TOD_ALIAS[tostring(daytime or ""):upper()] or PaletteFX.gen2RowFor(mapDef) local set = rows[row] or rows.DAY if type(set) ~= "table" or set[1] == nil then return nil end + local context=require('src.render.Gen2PaletteContext') + local owner,bound=context.owner(data) + if bound and not owner then return nil end + if owner then + local _,special=context.special(owner,mapDef,tilesetId,row) + if special then set=special end + local tileset=owner.tilesets and owner.tilesets[tilesetId] + set=context.withRoof(set,context.roof(owner,mapDef,tileset,row)) + end return set, row end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 5f7a007a5..a336f0035 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -568,19 +568,20 @@ end -- palColorsByTod is what the Gen2 importer now writes; palColors alone is the -- older single-row shape, kept working so a dataset extracted before this -- change still renders instead of falling back to grey. -local function gen2PalColors(tileset) +local function gen2PalColors(tileset, row) local byTod = tileset.palColorsByTod if type(byTod) ~= "table" then return tileset.palColors end - if PaletteFX.darkWorld() then return byTod.DARK or tileset.palColors end - return byTod[PaletteFX.gen2Tod()] or byTod.DAY or tileset.palColors + row=row or PaletteFX.effectiveGen2Tod() + if row=='DARK'then return byTod.DARK or tileset.palColors end + return byTod[row] or byTod.DAY or tileset.palColors end -- One atlas per (tileset image, COLORS mode, time of day). Leaving the mode -- out of this key is why switching COLORS did nothing out in the Gen2 -- overworld: the first bake won and every later mode kept being handed it. -local function gen2AtlasKey(imagePath, roof, special) +local function gen2AtlasKey(imagePath, roof, special, row) return imagePath .. "#gen2pal:" .. tostring(PaletteFX.mode) .. ":" - .. (PaletteFX.darkWorld() and "DARK" or tostring(PaletteFX.gen2Tod())) + .. tostring(row or PaletteFX.effectiveGen2Tod()) -- TWO MAPS ON ONE TILESET ARE TWO DIFFERENT PICTURES once the roof is in -- it. Violet and Azalea share TilesetJohto1 and wear different roofs, so -- leaving the roof out of the key would hand the second town the first @@ -601,36 +602,10 @@ end -- -- Skipped outright while the world is dark, which keeps the ROM's ordering: -- the darkness row is the first row in that table and beats every other. -local function gen2SpecialPalette(map, data) - if PaletteFX.darkWorld() then return nil end - local special = data and data.field and data.field.gen2MapPalettes - if type(special) ~= "table" or type(special.rules) ~= "table" then return nil end - local def = data.maps and map.id and data.maps[map.id] - if type(def) ~= "table" then return nil end - local tileset = map.tileset and map.tileset.id - for _, rule in ipairs(special.rules) do - local hit - if rule.kind == "map" then - hit = rule.group == def.group and rule.number == def.number - elseif rule.kind == "landmark" then - hit = rule.landmark == def.landmark - elseif rule.kind == "tileset" then - hit = rule.tileset == tileset - end - -- RegionCheck, as PokeCenterSpecialCase applies it - if hit and rule.landmarkMin then - hit = type(def.landmark) == "number" and def.landmark >= rule.landmarkMin - end - if hit and rule.landmarkEq then hit = def.landmark == rule.landmarkEq end - if hit then - local set = special.sets and special.sets[rule.set] - local rows = set and (set.ALL - or set[PaletteFX.gen2Tod()] or set.DAY) - if rows and #rows > 0 then return "#pal:" .. tostring(rule.set), rows end - return nil - end - end - return nil +local function gen2SpecialPalette(map, data, row) + local def=data.maps and map.id and data.maps[map.id] or map.def + return require('src.render.Gen2PaletteContext').special(data,def, + map.tileset and map.tileset.id,row or PaletteFX.effectiveGen2Tod()) end -- THE ROOF A MAP WEARS, or nil when it wears none. @@ -639,33 +614,10 @@ end -- tileset (see RomExtractorGen2:gen2Roofs for the cartridge routines). The -- art is a nine-tile strip on disk and the colour is a PAIR -- colours 1 and 2 -- of the roof palette row, which is all LoadMapPals ever writes. -local function gen2RoofFor(map, data) - local roofs = data and data.field and data.field.gen2Roofs - if type(roofs) ~= "table" then return nil end - local tileset = map and map.tileset - if not (tileset and type(roofs.tilesets) == "table" - and roofs.tilesets[tileset.id]) then - return nil - end - local def = data.maps and map.id and data.maps[map.id] - local group = (def and def.group) or map.group - local id = group ~= nil and type(roofs.byGroup) == "table" - and roofs.byGroup[group] or nil - if id == nil then return nil end - local image = type(roofs.images) == "table" and roofs.images[id] or nil - if not image then return nil end - local byTod = type(roofs.colors) == "table" and roofs.colors[group] or nil - local pair = byTod and (PaletteFX.darkWorld() and byTod.DARK - or byTod[PaletteFX.gen2Tod()] or byTod.DAY) or nil - return { - image = image, - pair = pair, - slot = roofs.slot or 10, - count = roofs.count or 9, - palIndex = roofs.palIndex or 6, - variants = tileset.tileVariants, - key = "#roof:" .. tostring(id) .. "." .. tostring(group), - } +local function gen2RoofFor(map, data, row) + local def=data.maps and map.id and data.maps[map.id] or map.def or map + return require('src.render.Gen2PaletteContext').roof(data,def,map.tileset, + row or PaletteFX.effectiveGen2Tod()) end local gen2AtlasCache = {} @@ -1368,14 +1320,17 @@ function TileRenderer.new(map, data) -- "show me the hardware", and the DMG/SGB-flavoured modes have to fall -- through to the raw sheet and their own shade treatment. Unconditional, it -- made COLORS a no-op for every Gen2 tile on screen. - local gen2Colors = PaletteFX.usesGen2BgPal() and gen2PalColors(map.tileset) + local gen2Def=data and data.maps and map.id and data.maps[map.id] or map.def + local gen2Enabled=PaletteFX.usesGen2BgPal() + local gen2Row=gen2Enabled and PaletteFX.gen2RowFor(gen2Def) + local gen2Colors = gen2Enabled and gen2PalColors(map.tileset,gen2Row) -- ...and this map's own seven, where the cartridge gives it seven of its -- own. Before the roof swap below, because the ROM does it in that order: -- LoadMapPals picks the palettes, special or environment, and only then -- writes the map group's roof pair into whichever set it ended up with. local specialKey if gen2Colors then - local key, rows = gen2SpecialPalette(map, data) + local key, rows = gen2SpecialPalette(map, data,gen2Row) if rows then gen2Colors, specialKey = rows, key end end if not self.gbcAtlas and map.tileset.palMap and #map.tileset.palMap > 0 @@ -1386,18 +1341,9 @@ function TileRenderer.new(map, data) -- list with just that pair replaced is the whole of it. A copy, because -- `gen2Colors` is the tileset def's own table and two groups sharing a -- tileset would otherwise overwrite each other's roof. - local roof = gen2RoofFor(map, data) - if roof and roof.pair then - local swapped = {} - for i = 1, #gen2Colors do swapped[i] = gen2Colors[i] end - local row = swapped[roof.palIndex + 1] - if row then - swapped[roof.palIndex + 1] = - { row[1], roof.pair[1], roof.pair[2], row[4] } - end - gen2Colors = swapped - end - local key = gen2AtlasKey(map.tileset.image, roof, specialKey) + local roof = gen2RoofFor(map, data,gen2Row) + gen2Colors=require('src.render.Gen2PaletteContext').withRoof(gen2Colors,roof) + local key = gen2AtlasKey(map.tileset.image, roof, specialKey,gen2Row) local gen2img = getGen2Atlas(key, map.tileset.image, map.tileset.tilesPerRow, map.tileset.palMap, gen2Colors, TileRenderer.borrowStart(map.tileset), roof) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 54ac1c659..48fa02855 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -67,6 +67,13 @@ end local OverworldState = { isOpaque = true, isOverworld = true } local Game -- set on enter (avoids circular require at load time) +-- Read the active native bake/Flash fields rather than copying another world. +setmetatable(OverworldState,{__index=function(world,key) + if(key=='daytime'or key=='flashUsed')and GameVersion.isGen2()and world.map then + if key=='daytime'then return PaletteFX.effectiveGen2Tod()end + return Game and Game.save and Game.save.flashLit==true or false + end +end}) -- AN EMPTY PLOT DRAWS NOTHING, asked once per entity per frame. Written -- with `pcall(function() ... require(...) ... end)` this was a closure, a @@ -167,6 +174,8 @@ local function isDarkMap(mapId) -- ReplaceTimeOfDayPals sends down .NeedsFlash. Reading Gen1's pokered -- list here matched nothing, so Dark Cave and the Whirl Islands were lit. local def = Game.data.maps[mapId] + local palette=require('src.render.Gen2PaletteContext').id(def) + if palette~=nil then return palette==4 end local dark = Game.data.field.gen2DarkMaps if (def and dark and dark[def.label]) == true then return true end -- Fallback when gen2DarkMaps was not extracted (stale cache): maps @@ -1164,7 +1173,8 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- and the Ruins of Alph chambers taking the NITE row after 6pm. if GameVersion.isGen2() then local paletteDef = Game.data.maps[mapId] - if PaletteFX.setGen2MapPalette(paletteDef and paletteDef.mapPalette or 0) + PaletteFX.setGen2FlashUsed(Game.save.flashLit == true) + if PaletteFX.setGen2MapPalette(require('src.render.Gen2PaletteContext').id(paletteDef) or 0,paletteDef) and PaletteFX.usesGen2BgPal() then MapLoader.invalidateAll() end @@ -2292,6 +2302,9 @@ end -- modes that do not use the ROM's palettes at all. function OverworldState:syncGen2Tod(tod) local PaletteFX = require("src.render.PaletteFX") + if GameVersion.isGen2() then + PaletteFX.bindGen2Context(self.map and self.map.def,Game and Game.save and Game.save.flashLit == true) + end if not PaletteFX.setGen2Tod(tod) then return end if not (GameVersion.isGen2() and PaletteFX.usesGen2BgPal()) then return end pcall(function() @@ -2339,7 +2352,10 @@ end function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") - local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset) + -- Gen2's PALETTE_* identity is not a Gen1/SGB colour-pack name. + local authored = map.def.palette + if GameVersion.isGen2() and type(authored)=='string'and authored:match('^PALETTE_')then authored=nil end + local name = authored or paletteLookup(palettes, map.id, map.def.tileset) if not name then -- Interiors inherit the outdoor map they sit in. Before the player has -- been outdoors at all, that is wLastMap's zero-fill -- map 0, @@ -2467,6 +2483,7 @@ end function OverworldState:setDark(on) on = on and true or false self.dark = on + if GameVersion.isGen2() then PaletteFX.setGen2FlashUsed(Game and Game.save and Game.save.flashLit == true) end if PaletteFX.setDarkWorld(on) and PaletteFX.bakesDarkness() and self.map then MapLoader.invalidateAll() self:reloadMap(self.map.id, "dark") diff --git a/tests/engine/gen2_palette_clock.lua b/tests/engine/gen2_palette_clock.lua index 1beb3dc13..c9d0dc01b 100644 --- a/tests/engine/gen2_palette_clock.lua +++ b/tests/engine/gen2_palette_clock.lua @@ -20,7 +20,7 @@ for _,v in ipairs({'gold','silver','crystal'})do P.setGen2Tod('MORN');P.setGen2MapPalette(1);P.setDarkWorld(true) T.eq(P.clockDaytime(),'NITE',v..' scene RTC query ignores map DAY and darkness') T.eq(P.daytimeFor({mapPalette=1},nil,true),'DAY',v..' map override still selects its own row') - T.eq(P.daytimeFor({mapPalette=1},nil,false),'DARK',v..' native darkness still wins before map override') + T.eq(P.daytimeFor({mapPalette=1},nil,false),'DAY',v..' another map does not inherit live cave darkness') T.eq(P.gen2Tod(),'DAY',v..' public clock query never changes live bake state') local Json=require('src.link.Json');local d=T.fixtures.fresh() local files={['mods/clock/manifest.json']=Json.encode({id='clock',name='Clock contract',version='1.0.0',api=2,entry='main.lua',affects_link=false}), diff --git a/tests/engine/gen2_palette_context.lua b/tests/engine/gen2_palette_context.lua new file mode 100644 index 000000000..270e60353 --- /dev/null +++ b/tests/engine/gen2_palette_context.lua @@ -0,0 +1,158 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes');local Data=require('src.core.Data') +local W=require('src.world.gen2.World');local TR=require('src.render.TileRenderer') +local Context=require('src.render.Gen2PaletteContext');local Assets=require('src.render.Assets') +local Map=require('src.world.Map');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local saved={mode=P.mode,dark=P.darkWorld(),tod=P.gen2Tod(),palette=P.gen2MapPalette(),map=W.map,game=W.game,worldDark=W.dark, + image=Assets.image,imageData=Assets.imageData,loveImage=love.image,newImage=love.graphics.newImage} +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.setDark,i);if name=='Game'then slot=i;oldGame=value;break end end +assert(slot) +local function buffer(w,h,source) + local b={w=w,h=h,pixels={}} + function b:getDimensions()return self.w,self.h end + function b:getWidth()return self.w end + function b:getHeight()return self.h end + function b:getPixel(x,y) + local p=self.pixels[y*self.w+x] + if p then return unpack(p)end + local c=source and(1-x%4/3)or 0;return c,c,c,1 + end + function b:setPixel(x,y,...)self.pixels[y*self.w+x]={...}end + function b:setFilter()end + function b:setWrap()end + function b:release()end + return b +end +love.image={newImageData=function(w,h)return buffer(w,h)end} +love.graphics.newImage=function(value)assert(type(value)=='table');return value end +Assets.image=function()return buffer(16,8,true)end +Assets.imageData=function()return buffer(16,8,true)end +local function palette(seed) + local set={} + for slot=1,8 do set[slot]={};for c=1,4 do set[slot][c]={seed+slot+c,slot*10+c,c*20}end end + return set +end +local function fixture(version) + local d=T.fixtures.fresh();local rows={} + for i,row in ipairs({'MORN','DAY','NITE','DARK'})do rows[row]=palette(i*20)end + local ts={id='QA_CONTEXT',image='qa-context-'..version,tilesPerRow=2,palMap={0,6},palColorsByTod=rows, + animatedTiles={},blockCells=2,blocks={{0,1,0,1}},walkable={0},grassTiles={},waterTiles={}} + d.tilesets={QA_CONTEXT=ts} + d.maps={} + for id,p in pairs({AUTO=0,ROOM=1,NIGHT=2,MORNING=3,CAVE=4,UNUSED=7})do + d.maps[id]={id=id,label=id,width=1,height=1,blocks={0},tileset=ts.id,environment=1,mapPalette=p,group=1,number=1} + end + d.maps.CENTER={id='CENTER',label='CENTER',width=1,height=1,blocks={0},tileset=ts.id,environment=3,mapPalette=1,group=2,number=1} + d.field.gen2MapPalettes={rules={{kind='map',group=2,number=1,set='CENTER'},{kind='landmark',landmark=99,set='OTHER'}}, + sets={CENTER={DAY=palette(120)},OTHER={ALL=palette(160)}}} + d.field.gen2Roofs={tilesets={[ts.id]=true},byGroup={[1]=0},images={[0]='synthetic-roof'},slot=10,count=0,palIndex=6, + colors={[1]={DAY={{201,2,3},{204,5,6}},NITE={{211,12,13},{214,15,16}},DARK={{221,22,23},{224,25,26}}}}} + Data.publishGen2Palettes(d) + return d,ts,rows +end +local function samePixels(renderer,colors,label) + for tile,slot in ipairs({1,7})do + for c=1,4 do + local r,g,b=renderer.image:getPixel((tile-1)*8+c-1,0) + T.same({r,g,b},{colors[slot][c][1]/255,colors[slot][c][2]/255,colors[slot][c][3]/255},label..' native baked tile '..tile..' shade '..c) + end + end +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);P.setMode('gbc');P.setGen2Tod('NITE');P.setGen2MapPalette(1);P.setGen2FlashUsed(false);P.setDarkWorld(false) + local d,ts,rows=fixture(version) + T.eq(d.maps.AUTO.palette,'PALETTE_AUTO',version..' publishes native clock identity') + T.eq(d.maps.ROOM.palette,'PALETTE_DAY',version..' publishes forced room identity') + T.eq(rawget(d.maps.ROOM,'palette'),nil,version..' projection never serializes a copied palette field') + d.maps.ROOM.mapPalette=3;T.eq(d.maps.ROOM.palette,'PALETTE_MORN',version..' byte edit updates public identity immediately') + d.maps.ROOM.palette='PALETTE_DAY';T.eq(Context.id(d.maps.ROOM),1,version..' explicit authored identity remains authoritative') + d.maps.ROOM.mapPalette=1 + local authored={mapPalette=1,palette='CUSTOM'};Context.publish(authored);T.eq(authored.palette,'CUSTOM','authored non-native name preserved') + local meta={__index={other=7}};local custom=setmetatable({mapPalette=2},meta) + T.eq(Context.publish(custom),false,'authored lookup metatable is not replaced');T.eq(getmetatable(custom),meta,'authored metatable identity retained') + T.eq(P.daytimeFor(d.maps.ROOM,20,false),'DAY',version..' pinned room remains day') + T.eq(P.daytimeFor(d.maps.CAVE,12,false),'DARK',version..' neighbouring cave is dark before entry') + P.setDarkWorld(true) + T.eq(P.daytimeFor(d.maps.AUTO,12,false),'DAY',version..' outdoor neighbour cannot inherit current cave darkness') + T.eq(P.daytimeFor(d.maps.ROOM,20,false),'DAY',version..' pinned neighbour cannot inherit current cave darkness') + T.eq(P.daytimeFor(d.maps.CAVE,12,true),'NITE',version..' explicit Flash lights queried cave') + P.setDarkWorld(false) + P.setGen2MapPalette(4,d.maps.CAVE) + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' current-map query retains actual native presentation overrides') + T.eq(P.gen2RowFor(d.maps.ROOM),'DAY',version..' neighbour query still uses own header') + T.eq(P.daytimeFor(d.maps.AUTO,nil,false),'NITE',version..' absent explicit hour follows active native clock even in pinned current map') + P.setGen2MapPalette(1) + for id,row in pairs({AUTO='NITE',ROOM='DAY',NIGHT='NITE',MORNING='MORN',CAVE='DARK',UNUSED='NITE',CENTER='DAY'})do + local def=d.maps[id];local map=Map.new(def,ts) + local colors,chosen=P.bgSet(d.gen2Palettes,def,P.gen2RowFor(def)) + T.eq(chosen,row,version..' native header row '..id) + local renderer=TR.new(map,d) + T.eq(renderer.trueColor,true,version..' native constructor bakes actual colours '..id) + samePixels(renderer,colors,version..' '..id) + if id=='CENTER'then T.eq(colors[1][1][1],d.field.gen2MapPalettes.sets.CENTER.DAY[1][1][1],version..' Center special rule reaches public query')end + end + local before=rows.NITE[7][2] + local set=P.bgSet(d.gen2Palettes,d.maps.AUTO,'NITE') + T.same(set[7][2],{211,12,13},version..' native roof writes only imported middle pair') + T.eq(rows.NITE[7][2],before,version..' public roof query does not modify shared tileset') + T.eq(set[7][1],rows.NITE[7][1],version..' roof preserves endpoint zero') + T.eq(set[7][4],rows.NITE[7][4],version..' roof preserves endpoint three') + d.field.gen2Roofs.colors[1].NITE[1]={231,32,33} + T.same(P.bgSet(d.gen2Palettes,d.maps.AUTO,'NITE')[7][2],{231,32,33},version..' metadata view sees live roof replacements') + TR.invalidate() + samePixels(TR.new(Map.new(d.maps.AUTO,ts),d),P.bgSet(d,d.maps.AUTO,'NITE'),version..' native invalidation observes changed roof') + d.field.gen2MapPalettes.rules[1].set='MISSING';d.maps.CENTER.landmark=99 + T.eq(P.bgSet(d,d.maps.CENTER,'DAY')[1],rows.DAY[1],version..' unavailable first rule does not fall through to later match') + d.field.gen2MapPalettes.rules[1].set='CENTER' + local game={data=d,save={flags={}}};debug.setupvalue(W.setDark,slot,game) + W.map=Map.new(d.maps.CAVE,ts) + P.setGen2MapPalette(4,d.maps.CAVE) + -- Keep the actual presentation setter while controlling only reload IO. + local reload=W.reloadMap;W.reloadMap=function()end + W:setDark(true) + T.eq(W.daytime,'DARK',version..' public world reflects actual native darkness') + T.eq(W.flashUsed,false,version..' public Flash view reads canonical save state') + require('src.script.Gen2Commands') + require('src.script.Commands').g2_blinding_flash({game=game,save=game.save,overworld=W}) + T.eq(game.save.flashLit,true,version..' original native Flash writer sets actual save bit') + T.eq(W.flashUsed,true,version..' public Flash view follows native script write') + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' native neighbour context follows script Flash write') + T.eq(W.daytime,'NITE',version..' world view follows actual lit-cave palette bake') + W.reloadMap=reload + local oldView=d.gen2Palettes;Data.publishGen2Palettes(d) + T.eq(P.bgSet(oldView,d.maps.ROOM,'DAY'),nil,version..' retired published view cannot mix old/new metadata') + local files={['mods/context/manifest.json']=Json.encode({id='context',name='Synthetic context',version='1.0.0',api=2,entry='main.lua',affects_link=false}), + ['mods/context/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + mod.exports.query=function(world,map) + local row=Palettes.daytimeFor(map.def,20,world.flashUsed) + local current=world.map + local same=current.id==map.id + local clock=current.def.palette=='PALETTE_AUTO'and map.def.palette=='PALETTE_AUTO' + if(same or clock)and Palettes.DAYTIME_ID[world.daytime]then row=world.daytime end + return Palettes.bgSet(world.game.data.gen2Palettes,map.def,row),row + end + ]]} + local r=T.sdk.loadMods({'mods/context'},{data=d,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,version..' real Loader imports public atlas context') + W.game=game;W.map=Map.new(d.maps.AUTO,ts);P.setGen2MapPalette(0);P.setGen2Tod('NITE');W:setDark(false) + local query=r.loader.exports.context.query + local colors,row=query(W,Map.new(d.maps.ROOM,ts)) + T.eq(row,'DAY',version..' actual loaded public consumer keeps neighbouring room pinned') + T.same(colors,P.bgSet(d,d.maps.ROOM,'DAY'),version..' loaded consumer uses same native roof selection') + colors,row=query(W,W.map);T.eq(row,W.daytime,version..' loaded consumer uses actual current bake') + W:syncGen2Tod('NITE') + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' native clock sync rebinds canonical Flash after mod reload') + Loader.teardown();r.release() + T.eq(P.gen2RowFor(d.maps.CAVE),'DARK',version..' teardown retires Flash presentation state') +end +Assets.image,Assets.imageData=saved.image,saved.imageData;love.image=saved.loveImage;love.graphics.newImage=saved.newImage +W.map=saved.map;W.game=saved.game;W.dark=saved.worldDark;debug.setupvalue(W.setDark,slot,oldGame) +P.setMode(saved.mode);P.setGen2MapPalette(saved.palette);P.setGen2Tod(saved.tod);P.setDarkWorld(saved.dark);P.setGen2FlashUsed(false);V.set(original) +T.finish('native Gen2 map context, roof/special rows, world views and actual CPU atlas bake') From 75aa131dc16124819704419d1a42e9ed110ab072 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:01:35 +0100 Subject: [PATCH 04/64] Use shared native saved RTC for GSC world scripts and clock card --- docs/native-gen2-clock.md | 41 ++++++++++++++++ src/core/GameVersion.lua | 2 + src/core/Gen2Clock.lua | 81 +++++++++++++++++++++++++++++++ src/core/ModCompat.lua | 1 + src/mods/Loader.lua | 4 ++ src/pokemon/Evolution.lua | 11 +++-- src/render/PaletteFX.lua | 4 ++ src/script/Gen2Commands.lua | 14 +++--- src/script/Gen2Daily.lua | 2 +- src/ui/PokegearMenu.lua | 8 +-- src/world/OverworldController.lua | 13 +++-- 11 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 docs/native-gen2-clock.md create mode 100644 src/core/Gen2Clock.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md new file mode 100644 index 000000000..bda4ee37a --- /dev/null +++ b/docs/native-gen2-clock.md @@ -0,0 +1,41 @@ +# Shared native Gen2 RTC + +The Pokégear previously displayed elapsed play time while world palettes, +object time masks and script variables used the host clock. The native +`Gen2Clock` now provides the same saved RTC-base reads to these consumers, +and the native happiness evolution gate. +The existing `src.core.gen2.Clock` name resolves to this native module. +`world.hour()`, `world.minute()` and `world.weekday()` read that same save. + +`setTime(save, hour, minute)` stores `rtc.startMinute`, an offset from the +host RTC, rather than a frozen hour. `setWeekday` stores `rtc.startDay` and +keeps the older native `g2DayOffset` field synchronized. Old saves without +an RTC base retain their host-hour and existing weekday-offset behavior. +Read calls never create clock data. Invalid/non-finite writes are rejected. +The normal native save codec preserves the offsets; play time is separate. + +World palettes, object-load period bits and `VAR_HOUR` share the saved hour; +the normal object-load freeze still applies. An explicit palette-query hour +retains precedence. The existing `world.tod` hook remains a presentation +override and does not rewrite the saved RTC. A raw scene-clock query uses +the current native save, independently of a room's forced palette. + +`clock.day_changed` is produced by actual weekday reads, including the live +world clock poll. It reports `day`, `previous` and `reason` (`rollover` or +`set`). A save's first observation is silent. Observations are per save, +reentrant-safe, subscription-aware and retired on mod/edition reload. +Native API 1/2 use the existing event dispatch boundary. The native GSC +producer remains generation-scoped. + +The clock card, native script readers, evolution time gate and world methods +are exercised directly in synthetic engine tests, including minute wrap, +weekly rollover, save roundtrip, RTC-versus-play-time display, the native callback +path and actual mod loading. Graphics IO alone is replaced for the clock +card. These checks do not establish a complete original-story clock-setting +sequence, DST, battery-backed SRAM RTC conversion, all daily scheduling or +fresh GPU acceptance for this change. Existing daily date keys still use +host calendar dates; lottery weekday selection now shares the saved weekday. + +The public contract is compared with the read-only Gen1Recomp reference; +the implementation is native fork code. No VASC implementation, private +assets, game text or ROM-derived clock data is included. diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 6c92886cf..ff6316b05 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1087,6 +1087,8 @@ function GameVersion.set(id) if previous~=GameVersion.current then local palette=package.loaded['src.render.PaletteFX'] if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end end return GameVersion.current end diff --git a/src/core/Gen2Clock.lua b/src/core/Gen2Clock.lua new file mode 100644 index 000000000..fc02c2953 --- /dev/null +++ b/src/core/Gen2Clock.lua @@ -0,0 +1,81 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +-- Shared native GSC RTC reads. The public Clock spelling follows the +-- reference contract; no private mod implementation or clock is embedded. +local Clock = { MINUTES_PER_DAY = 1440, DAYS = 7, DEFAULT_HOUR = 10, DEFAULT_MINUTE = 0 } +local Strings = require("src.core.Strings") +Clock.DAY_NAMES = { "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY" } +local observed = setmetatable({}, { __mode = "k" }) +local function integer(value) + local n = tonumber(value) + if n and n == n and n ~= math.huge and n ~= -math.huge then return math.floor(n) end +end +local function rtc(save) + return type(save) == "table" and type(save.rtc) == "table" and save.rtc or nil +end +function Clock.hostMinutes() + return ((integer(os.date("%H")) or 0) * 60 + (integer(os.date("%M")) or 0)) % 1440 +end +function Clock.hostWeekday(now) + return (integer(os.date("%w", now)) or 0) % 7 +end +function Clock.minutes(save) + local state = rtc(save) + return (Clock.hostMinutes() + (state and integer(state.startMinute) or 0)) % 1440 +end +function Clock.hour(save) return math.floor(Clock.minutes(save) / 60) end +function Clock.minute(save) return Clock.minutes(save) % 60 end +function Clock.isSet(save) + local state = rtc(save) + return state ~= nil and integer(state.startMinute) ~= nil +end +function Clock.setTime(save, hour, minute) + if type(save) ~= "table" then return false end + hour, minute = integer(hour or 0), integer(minute or 0) + if not hour or not minute then return false end + save.rtc = rtc(save) or {} + save.rtc.startMinute = ((hour % 24) * 60 + minute % 60 - Clock.hostMinutes()) % 1440 + return true +end +local function note(save, day, reason) + if type(save) ~= "table" then return day end + local Runtime = require("src.mods.Runtime") + if not require("src.core.GameVersion").isGen2() or not Runtime.wants("clock.day_changed") then + observed[save] = nil + return day + end + local previous = observed[save] + observed[save] = day -- Commit before dispatch: reentrant reads cannot emit twice. + if previous ~= nil and previous ~= day then + Runtime.emit("clock.day_changed", { day = day, previous = previous, reason = reason }) + end + return day +end +function Clock.weekday(save, now) + local state = rtc(save) + local offset = state and integer(state.startDay) + if offset == nil then offset = type(save) == "table" and integer(save.g2DayOffset) or 0 end + return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover") +end +function Clock.setWeekday(save, day) + day = integer(day) + if type(save) ~= "table" or not day then return false end + save.rtc = rtc(save) or {} + day = day % 7 + save.rtc.startDay = (day - Clock.hostWeekday()) % 7 + save.rtc.dayOfWeek = day + -- Keep the existing native field in sync for older native consumers/saves. + save.g2DayOffset = save.rtc.startDay + note(save, day, "set") + return true +end +function Clock.weekdayName(day) + local name = Clock.DAY_NAMES[day] + return name and Strings(name) +end +function Clock.daytimeLabel(hour) + hour = integer(hour) or Clock.hour() + hour = hour % 24 + return Strings(hour < 4 and "NITE" or hour < 10 and "MORN" or hour < 18 and "DAY" or "NITE") +end +function Clock.resetObservations() observed = setmetatable({}, { __mode = "k" }) end +return Clock diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..2e92443a1 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.core.gen2.Clock"] = "src.core.Gen2Clock", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index cd3ab6876..5aa2d364e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1312,6 +1312,8 @@ end function Loader:load(data) local palette=package.loaded['src.render.PaletteFX'] if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the @@ -1657,6 +1659,8 @@ end function Loader.teardown() local palette=package.loaded['src.render.PaletteFX'] if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 321387210..e9506843f 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -89,7 +89,7 @@ Evolution.METHODS = { return false end if not evo.timeOfDay then return true end - local night = Evolution.isNight() + local night = Evolution.isNight(game.save) if evo.timeOfDay == "NITE" then return night end return not night -- TR_MORNDAY: morn or day end, @@ -223,10 +223,11 @@ Evolution.HAPPINESS_TO_EVOLVE = 220 Evolution.BASE_HAPPINESS = 70 -- GSC time-of-day bands (constants/time_constants.asm): MORN 4-9, DAY --- 10-17, NITE 18-3. The port has no in-game clock, so the wall clock is --- what GSC's RTC would have been. -function Evolution.isNight() - local hour = tonumber(os.date("%H")) or 12 +-- 10-17, NITE 18-3. GSC reads the shared saved RTC offset; other editions +-- retain their existing host-clock behavior. +function Evolution.isNight(save) + local hour = require("src.core.GameVersion").isGen2() + and require("src.core.Gen2Clock").hour(save) or tonumber(os.date("%H")) or 12 return hour >= 18 or hour < 4 end diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index c95d9d31e..a0260e00b 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -561,6 +561,10 @@ function PaletteFX.gen2Tod() return gen2Tod end -- It must not inherit the live map override, darkness, or atlas bake state. function PaletteFX.clockDaytime(hour) hour = tonumber(hour) + if not hour and GameVersion.isGen2() then + local game = package.loaded["src.core.Game"] + hour = require("src.core.Gen2Clock").hour(game and game.save) + end if not hour then hour = tonumber(os.date("%H")) end hour = math.floor(hour or 12) % 24 if hour < 4 then return "NITE" end diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..d41e01d32 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2628,12 +2628,9 @@ function Commands.g2_readvar(ctx, var) elseif var == 6 then ctx.g2Var = countKeys(ctx.save.pokedex and ctx.save.pokedex.seen) elseif var == 10 then - ctx.g2Var = tonumber(os.date("%H")) or 0 + ctx.g2Var = require("src.core.Gen2Clock").hour(ctx.save) elseif var == 11 then - -- g2DayOffset is what mom's SetDayOfWeek picker answered, as a shift off - -- the host day, so Arthur still shows up on a real Thursday. - ctx.g2Var = ((tonumber(os.date("%w")) or 0) - + (ctx.save.g2DayOffset or 0)) % 7 + ctx.g2Var = require("src.core.Gen2Clock").weekday(ctx.save) elseif var == 16 then -- BoxFreeSpace counts only the current box, not all available PC slots. local Boxes = require("src.pokemon.Boxes") @@ -2898,7 +2895,8 @@ local WEEKDAYS = { -- either way the day still rolls over at real midnight instead of freezing. function Commands.g2_set_day_of_week(ctx) local game, runner = ctx.game, ctx.runner - local host = tonumber(os.date("%w")) or 0 + local Clock = require("src.core.Gen2Clock") + local current = Clock.weekday(ctx.save) local picked repeat Commands.show_text(ctx, "SetDayOfWeek.OakTimeWhatDayIsItText") @@ -2912,14 +2910,14 @@ function Commands.g2_set_day_of_week(ctx) -- no cancel: the ROM's loop watches A only, and onCancel is the only -- other path back to runner:resume() local menu = require("src.ui.Menu").new(game, items, { cancelable = false }) - menu.index = host + 1 + menu.index = current + 1 menu:clampScroll() game.stack:push(menu) runner:yield() Commands.ask(ctx, Strings(WEEKDAYS[picked + 1]) .. resolvedText(ctx, "SetDayOfWeek.OakTimeIsItText")) until ctx.lastCheck - ctx.save.g2DayOffset = (picked - host) % 7 + Clock.setWeekday(ctx.save, picked) end -- UnownPuzzle (SpecialsPointers row 41). The chamber scripts run diff --git a/src/script/Gen2Daily.lua b/src/script/Gen2Daily.lua index 96e54c666..6337f5cd9 100644 --- a/src/script/Gen2Daily.lua +++ b/src/script/Gen2Daily.lua @@ -45,7 +45,7 @@ end local function nextFriday(save, now) local date = os.date("*t", now) - local weekday = (date.wday - 1 + (tonumber(save.g2DayOffset) or 0)) % 7 + local weekday = require("src.core.Gen2Clock").weekday(save, now) local days = (5 - weekday) % 7 -- Match the weekday chosen at Mom's clock. if days == 0 then days = 7 end -- Calendar arithmetic, rather than seconds, also handles daylight saving. diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..7d5cb0473 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -690,10 +690,10 @@ function PokegearMenu:drawClock() self:text("SWITCH", 13, 1) local save = self.game.save - local t = math.floor(require("src.core.SaveData").playSeconds(save)) - local hour = math.floor(t / 3600) % 24 - local minute = math.floor(t / 60) % 60 - local day = DAYS[(math.floor(t / 86400) % 7) + 1] + local Clock = require("src.core.Gen2Clock") + local minutes = Clock.minutes(save) + local hour, minute = math.floor(minutes / 60), minutes % 60 + local day = Clock.weekdayName(Clock.weekday(save) + 1) local display = hour % 12 if display == 0 then display = 12 end self:text(day, 6, 6) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 48fa02855..d996d5ce2 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -28,6 +28,7 @@ local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") local GameVersion = require("src.core.GameVersion") +local Gen2Clock = require("src.core.Gen2Clock") local Tilt = require("src.render.Tilt") local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") @@ -292,7 +293,7 @@ local function objectTodBit() local ow = Game and Game.overworld local bit = ow and tonumber(ow.objectTodBit) if bit then return bit end - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) if period and tonumber(period.bit) then return tonumber(period.bit) end return TOD_BITS[objectTimeOfDay()] or TOD_BITS.DAY end @@ -2275,7 +2276,7 @@ local function sameTod(tod) return tod end -- < 4 NITE, < 10 MORN, < 18 DAY, else NITE. Gen 1 has no clock at all, so -- only Gen 2 consults the host one. local function clockTimeOfDay() - local hour = tonumber(os.date("%H")) or 12 + local hour = Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) if hour < 4 then return "NITE" end if hour < 10 then return "MORNING" end if hour < 18 then return "DAY" end @@ -2288,7 +2289,7 @@ OverworldState.clockTimeOfDay = clockTimeOfDay -- The cartridge's MAPOBJECT_TIMEOFDAY bit for the hour on the clock right now. function OverworldState.clockTimeOfDayBit() - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) return (period and tonumber(period.bit)) or TOD_BITS.DAY end @@ -2314,6 +2315,7 @@ function OverworldState:syncGen2Tod(tod) end function OverworldState:timeOfDay() + if GameVersion.isGen2() then Gen2Clock.weekday(Game and Game.save) end local tod = GameVersion.isGen2() and clockTimeOfDay() or (self.tod or "DAY") if not Runtime.wantsHook("world.tod") then if tod ~= self.tod then @@ -2350,6 +2352,11 @@ function OverworldState:timeOfDay() return self.tod end +-- Shared native saved RTC views; no second clock state on World. +function OverworldState.hour() return Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.minute() return Gen2Clock.minute(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.weekday() return Gen2Clock.weekday(GameVersion.isGen2() and Game and Game.save or nil) end + function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") -- Gen2's PALETTE_* identity is not a Gen1/SGB colour-pack name. From d2cfa4dd52c9b89bb037db70bb799f50580f50c7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:01:57 +0100 Subject: [PATCH 05/64] Include native RTC contract regression in upstream test archive --- tests/engine/gen2_clock.lua | 119 ++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/engine/gen2_clock.lua diff --git a/tests/engine/gen2_clock.lua b/tests/engine/gen2_clock.lua new file mode 100644 index 000000000..e3085943c --- /dev/null +++ b/tests/engine/gen2_clock.lua @@ -0,0 +1,119 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local V = require('src.core.GameVersion');local version = V.get() +require('src.core.ModCompat').install() +local Clock = require('src.core.gen2.Clock') +local P = require('src.render.PaletteFX');local W = require('src.world.OverworldController') +require('src.script.Gen2Commands');local Commands = require('src.script.Commands');local Loader = require('src.mods.Loader') +local Json = require('src.link.Json');local Save = require('src.core.SaveData') +local date = os.date;local host = { hour=13, minute=20, day=2 } +os.date = function(format, now) + if now then return date(format,now) end + if format=='%H' then return tostring(host.hour) end + if format=='%M' then return tostring(host.minute) end + if format=='%w' then return tostring(host.day) end + return date(format,now) +end +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.timeOfDay,i) + if name=='Game' then slot=i;oldGame=value;break end + if not name then break end +end +assert(slot) +local nativeGame=require('src.core.Game');local oldSave=nativeGame.save +T.eq(Clock,require('src.core.Gen2Clock'),'public name shares actual native clock') +T.eq(Clock.minutes(),800,'no save reads host RTC') +T.eq(Clock.isSet({}),false,'old save has no fabricated clock anchor') +local malformed={rtc='invalid'} +T.eq(Clock.hour(malformed),13,'malformed optional clock falls back to host') +T.eq(Clock.setTime(malformed,0/0,10),false,'NaN cannot poison saved clock') +T.eq(malformed.rtc,'invalid','refused write is atomic') +T.eq(Clock.setTime(false,10,0),false,'no saved state means no write') +T.eq(Clock.setWeekday({},math.huge),false,'infinite weekday rejected') +T.eq(Clock.weekdayName(0),nil,'weekday label API is one-based') +for _,edition in ipairs({'gold','silver','crystal'}) do + V.set(edition);host.hour=13;host.minute=20;host.day=2 + local save={playTime=0,g2DayOffset=3};local untouched=Json.encode(save) + T.eq(Clock.hour(save),13,edition..' legacy hour retains host behavior') + T.eq(Clock.weekday(save),5,edition..' legacy native weekday offset retained') + T.eq(Json.encode(save),untouched,edition..' clock reads do not seed saved state') + T.eq(Clock.setTime(save,23,55),true,edition..' clock setting anchors native RTC') + T.eq(Clock.setWeekday(save,6),true,edition..' day picker stores native RTC base') + T.eq(Clock.hour(save),23,edition..' saved hour is observed immediately') + T.eq(Clock.minute(save),55,edition..' saved minute is observed immediately') + T.eq(Clock.weekday(save),6,edition..' weekday is observed immediately') + T.eq(Clock.isSet(save),true,edition..' explicit clock setting is distinguishable') + T.eq(save.g2DayOffset,save.rtc.startDay,edition..' old native day readers retain shared offset') + host.minute=30 + T.eq(Clock.minutes(save),5,edition..' RTC keeps ticking and wraps at midnight') + host.day=3 + T.eq(Clock.weekday(save),0,edition..' saved weekday follows RTC weekly rollover') + local round=assert(Save.decode(Save.encode(save))) + T.eq(Clock.minutes(round),5,edition..' save roundtrip retains RTC minute base') + T.eq(Clock.weekday(round),0,edition..' save roundtrip retains RTC weekday base') + local game={data=T.fixtures.fresh(),save=save} + debug.setupvalue(W.timeOfDay,slot,game);nativeGame.save=save + local world=setmetatable({map={id='RTC',def={mapPalette=0}},player={cellX=0,cellY=0}}, {__index=W}) + -- Control only graphics IO; native clock, period selection and commands run. + world.syncGen2Tod=function()end + for _,case in ipairs({{3,59,'NITE',4},{4,0,'MORNING',1},{9,59,'MORNING',1}, + {10,0,'DAY',2},{17,59,'DAY',2},{18,0,'NITE',4}})do + Clock.setTime(save,case[1],case[2]) + T.eq(world:timeOfDay(),case[3],edition..' native world reads saved boundary '..case[1]) + T.eq(W.clockTimeOfDayBit(),case[4],edition..' object-load bit uses same saved boundary '..case[1]) + T.eq(world.hour(),case[1],edition..' legacy world dot hour agrees') + T.eq(world:minute(),case[2],edition..' legacy world colon minute agrees') + T.eq(P.clockDaytime(),case[3]=='MORNING' and 'MORN' or case[3],edition..' scene palette raw RTC agrees') + T.eq(P.clockDaytime(12),'DAY',edition..' explicit scene hour remains authoritative') + local ctx={save=save,overworld=world} + Commands.g2_readvar(ctx,10);T.eq(ctx.g2Var,case[1],edition..' actual VAR_HOUR agrees') + Commands.g2_readvar(ctx,11);T.eq(ctx.g2Var,Clock.weekday(save),edition..' actual VAR_WEEKDAY agrees') + Commands.g2_checktime(ctx,case[4]);T.eq(ctx.lastCheck,true,edition..' actual checktime agrees') + T.eq(require('src.pokemon.Evolution').isNight(save),case[4]==4,edition..' happiness time gate agrees') + end + -- Native clock-card composition must show RTC, even with zero play time. + Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local text={};local Gear=require('src.ui.PokegearMenu') + local gear=setmetatable({game=game,blink=0,gfx={cards={clock={}}}}, {__index=Gear}) + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.text=function(_,value,x,y)text[x..':'..y]=value end + gear:drawClock() + T.eq(text['6:6'],'THURSDAY',edition..' actual clock card shows selected weekday') + T.eq(text['6:8'],' 9',edition..' actual clock card shows RTC hour rather than play hours') + T.eq(text['9:8'],'47',edition..' actual clock card shows RTC minute rather than play minutes') + T.eq(text['12:8'],'PM',edition..' actual clock card shows RTC half-day') + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local files={['mods/rtc/manifest.json']=Json.encode({id='rtc',name='Native RTC observer',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/rtc/main.lua']=[[ + local mod=...; + local Clock=require('src.core.gen2.Clock');mod.exports.events={} + mod.exports.stop=mod.events:on("clock.day_changed",function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99;return false + end) + ]]} + local result=T.sdk.loadMods({'mods/rtc'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' actual loader admits public clock module/event') + local e=result.loader.exports.rtc;local own={};e.save=own + Clock.weekday(own);T.eq(#e.events,0,label..' first read is not a fictitious rollover') + host.day=(host.day+1)%7 + Clock.weekday(own);T.eq(#e.events,1,label..' real read observes rollover exactly once') + T.eq(e.events[1].reason,'rollover',label..' rollover reason supplied') + Clock.weekday(own);T.eq(#e.events,1,label..' reentrant/repeated reads do not repeat event') + Clock.weekday({});T.eq(#e.events,1,label..' another profile starts silently') + local selected=(Clock.weekday(own)+2)%7 + Clock.setWeekday(own,selected) + T.eq(#e.events,2,label..' native day setter observes one change') + T.eq(e.events[2].reason,'set',label..' explicit setter reason supplied') + T.eq(Clock.weekday(own),selected,label..' listener mutation cannot corrupt native weekday') + e.stop();host.day=(host.day+1)%7;Clock.weekday(own) + T.eq(#e.events,2,label..' unsubscribe retires actual observation') + Loader.teardown();result.release() + end +end +nativeGame.save=oldSave;debug.setupvalue(W.timeOfDay,slot,oldGame) +Clock.resetObservations();os.date=date;V.set(version) +T.finish('shared native Gen2 saved RTC, actual world/scripts/clock-card and public event') From 5cd6128762e71ec87574a50743567990d12a7804 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:06:35 +0100 Subject: [PATCH 06/64] Resolve AUTO neighbour palettes from shared RTC outside pinned scenes --- docs/native-gen2-clock.md | 4 ++++ docs/native-gen2-palette-context.md | 7 ++++--- src/render/PaletteFX.lua | 14 ++++++++++---- tests/engine/gen2_palette_context.lua | 11 +++++++++-- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index bda4ee37a..ae52c27d1 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -19,6 +19,10 @@ the normal object-load freeze still applies. An explicit palette-query hour retains precedence. The existing `world.tod` hook remains a presentation override and does not rewrite the saved RTC. A raw scene-clock query uses the current native save, independently of a room's forced palette. +AUTO neighbours share the effective clock presentation while the entered +map also follows AUTO. From a pinned room/cave, an AUTO neighbour instead +uses the saved RTC; that room's unused world-TOD hook is not a neighbour +palette override. This keeps explicit native/external hour queries aligned. `clock.day_changed` is produced by actual weekday reads, including the live world clock poll. It reports `day`, `previous` and `reason` (`rollover` or diff --git a/docs/native-gen2-palette-context.md b/docs/native-gen2-palette-context.md index 68450c9e8..bda03ba07 100644 --- a/docs/native-gen2-palette-context.md +++ b/docs/native-gen2-palette-context.md @@ -22,9 +22,10 @@ queries retain their prior live-state fallback. The native renderer uses neighbour's own header and the active clock/Flash presentation state. Native map entry and Flash update that state; atlas cache keys include the selected row. Animated overdraw receives the same selected palette as the static bake. -An omitted hour uses the entered world's native clock row, including its -`world.tod` selection; outside an entered context it keeps the host-clock -fallback. An explicit hour remains authoritative. Mod load/unload and edition +An omitted hour shares the entered world's `world.tod` selection when +that scene follows AUTO. From a pinned room/cave, AUTO neighbours instead +read the saved RTC. Outside an entered context the query reads that RTC +without inheriting a map presentation override. An explicit hour remains authoritative. Mod load/unload and edition changes retire the entered context, so returning to the launcher cannot revive a previous world's clock selection. diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index a0260e00b..52c994215 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -497,14 +497,20 @@ function PaletteFX.bindGen2Context(def,flashUsed) end function PaletteFX.setGen2FlashUsed(on)gen2FlashUsed=on==true end function PaletteFX.effectiveGen2Tod()return darkWorld and 'DARK'or gen2Tod end --- A neighbour uses its own header, the native hooked clock and the same Flash --- presentation state. It must not inherit the current map's forced DAY row. +-- AUTO neighbours share the entered AUTO scene's presentation override. +-- A pinned current room/cave has no outdoor clock presentation to share: +-- neighbouring AUTO maps read the saved RTC instead of its unused hook row. +local function gen2NeighbourClock() + local id=require('src.render.Gen2PaletteContext').id(gen2CurrentDef) + if GEN2_MAP_PALETTE_ROW[id] then return PaletteFX.clockDaytime() end + return gen2ClockTod +end function PaletteFX.gen2RowFor(def) if def~=nil and def==gen2CurrentDef then return PaletteFX.effectiveGen2Tod()end local id=require('src.render.Gen2PaletteContext').id(def) if id==nil then return PaletteFX.effectiveGen2Tod() end if id==4 then return gen2FlashUsed and 'NITE'or 'DARK'end - return GEN2_MAP_PALETTE_ROW[id]or gen2ClockTod + return GEN2_MAP_PALETTE_ROW[id]or gen2NeighbourClock() end local function gen2RecomputeTod() @@ -587,7 +593,7 @@ function PaletteFX.daytimeFor(mapDef, hour, flashUsed) local id=require('src.render.Gen2PaletteContext').id(mapDef) if id~=nil then if id==4 then return flashUsed==true and 'NITE'or 'DARK'end - return GEN2_MAP_PALETTE_ROW[id]or(hour==nil and gen2CurrentDef and gen2ClockTod)or PaletteFX.clockDaytime(hour) + return GEN2_MAP_PALETTE_ROW[id]or(hour==nil and gen2CurrentDef and gen2NeighbourClock())or PaletteFX.clockDaytime(hour) end -- Darkness wins outright and the clock stops mattering, which is what -- .NeedsFlash does: an unlit cave is the DARKNESS row at noon. diff --git a/tests/engine/gen2_palette_context.lua b/tests/engine/gen2_palette_context.lua index 270e60353..34082161f 100644 --- a/tests/engine/gen2_palette_context.lua +++ b/tests/engine/gen2_palette_context.lua @@ -9,7 +9,8 @@ local W=require('src.world.gen2.World');local TR=require('src.render.TileRendere local Context=require('src.render.Gen2PaletteContext');local Assets=require('src.render.Assets') local Map=require('src.world.Map');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') local saved={mode=P.mode,dark=P.darkWorld(),tod=P.gen2Tod(),palette=P.gen2MapPalette(),map=W.map,game=W.game,worldDark=W.dark, - image=Assets.image,imageData=Assets.imageData,loveImage=love.image,newImage=love.graphics.newImage} + image=Assets.image,imageData=Assets.imageData,loveImage=love.image,newImage=love.graphics.newImage,date=os.date} +os.date=function(format,...)if format=='%H'then return '20'end;return saved.date(format,...)end local slot,oldGame for i=1,100 do local name,value=debug.getupvalue(W.setDark,i);if name=='Game'then slot=i;oldGame=value;break end end assert(slot) @@ -86,7 +87,12 @@ for _,version in ipairs({'gold','silver','crystal'})do P.setGen2MapPalette(4,d.maps.CAVE) T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' current-map query retains actual native presentation overrides') T.eq(P.gen2RowFor(d.maps.ROOM),'DAY',version..' neighbour query still uses own header') - T.eq(P.daytimeFor(d.maps.AUTO,nil,false),'NITE',version..' absent explicit hour follows active native clock even in pinned current map') + T.eq(P.daytimeFor(d.maps.AUTO,nil,false),'NITE',version..' absent hour reads RTC outside a pinned current map') + P.setGen2Tod('MORN');P.setGen2MapPalette(1,d.maps.ROOM) + T.eq(P.gen2RowFor(d.maps.AUTO),'NITE',version..' pinned room cannot pass an unused morning override to outdoor neighbours') + P.setGen2MapPalette(0,d.maps.AUTO) + T.eq(P.gen2RowFor({mapPalette=0}),'MORN',version..' outdoor neighbours still share current AUTO presentation') + P.setGen2Tod('NITE') P.setGen2MapPalette(1) for id,row in pairs({AUTO='NITE',ROOM='DAY',NIGHT='NITE',MORNING='MORN',CAVE='DARK',UNUSED='NITE',CENTER='DAY'})do local def=d.maps[id];local map=Map.new(def,ts) @@ -153,6 +159,7 @@ for _,version in ipairs({'gold','silver','crystal'})do T.eq(P.gen2RowFor(d.maps.CAVE),'DARK',version..' teardown retires Flash presentation state') end Assets.image,Assets.imageData=saved.image,saved.imageData;love.image=saved.loveImage;love.graphics.newImage=saved.newImage +os.date=saved.date W.map=saved.map;W.game=saved.game;W.dark=saved.worldDark;debug.setupvalue(W.setDark,slot,oldGame) P.setMode(saved.mode);P.setGen2MapPalette(saved.palette);P.setGen2Tod(saved.tod);P.setDarkWorld(saved.dark);P.setGen2FlashUsed(false);V.set(original) T.finish('native Gen2 map context, roof/special rows, world views and actual CPU atlas bake') From a11d4e9af1e6210070de49e4e3c35fbb63be5198 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:25:36 +0100 Subject: [PATCH 07/64] Expose native clock card parts to existing external presenters --- docs/native-gen2-clock.md | 5 +++ src/ui/PokegearMenu.lua | 26 +++++++++--- tests/engine/gen2_clock_card.lua | 68 ++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 tests/engine/gen2_clock_card.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index ae52c27d1..0bf1f6f38 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -19,6 +19,11 @@ the normal object-load freeze still applies. An explicit palette-query hour retains precedence. The existing `world.tod` hook remains a presentation override and does not rewrite the saved RTC. A raw scene-clock query uses the current native save, independently of a room's forced palette. +`PokegearMenu:clockParts()` exposes hour/minute/one-based UI weekday from +the live native world or shared saved clock. The built-in card and external +presenters use this same getter, retaining authored card-clock overrides. +This prevents an external presenter from showing its fallback zero clock +while the internal RTC and built-in text are already correct. AUTO neighbours share the effective clock presentation while the entered map also follows AUTO. From a pinned room/cave, an AUTO neighbour instead uses the saved RTC; that room's unused world-TOD hook is not a neighbour diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index 7d5cb0473..a87e3392d 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -370,6 +370,25 @@ function PokegearMenu:current() return self.cards[self.index] end +-- Public clock presentation, shared by the native card and source renderers. +-- Keep the native save/world as the owner; weekday is one-based for UI lists. +function PokegearMenu:clockParts() + if type(self.clock) == "table" then + return self.clock.hour or 0, self.clock.minute or 0, self.clock.weekday or 1 + end + local game = self.game + local world = game and (game.overworld or game.world) + if world and type(world.hour) == "function" and type(world.minute) == "function" + and type(world.weekday) == "function" then + return world:hour(), world:minute(), world:weekday() % 7 + 1 + end + local save = game and game.save or self.save + local Clock = require("src.core.Gen2Clock") + local minutes = Clock.minutes(save) + return math.floor(minutes / 60), minutes % 60, Clock.weekday(save) + 1 +end + + -- The registered phone numbers, newest id last. -- -- Gen2 keeps two halves of this and the card used to read neither: which ids @@ -689,11 +708,8 @@ function PokegearMenu:drawClock() self:drawStrip() self:text("SWITCH", 13, 1) - local save = self.game.save - local Clock = require("src.core.Gen2Clock") - local minutes = Clock.minutes(save) - local hour, minute = math.floor(minutes / 60), minutes % 60 - local day = Clock.weekdayName(Clock.weekday(save) + 1) + local hour, minute, weekday = self:clockParts() + local day = require("src.core.Gen2Clock").weekdayName(weekday) local display = hour % 12 if display == 0 then display = 12 end self:text(day, 6, 6) diff --git a/tests/engine/gen2_clock_card.lua b/tests/engine/gen2_clock_card.lua new file mode 100644 index 000000000..a36d76005 --- /dev/null +++ b/tests/engine/gen2_clock_card.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local Gear=require('src.ui.gen2.Pokegear');local Clock=require('src.core.Gen2Clock') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local date=os.date +os.date=function(format,...)if format=='%H'then return '12'end;if format=='%M'then return '05'end;if format=='%w'then return '1'end;return date(format,...)end +local function parts(screen)return {screen:clockParts()}end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition) + local save={playTime=0};Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local game={save=save,data=T.fixtures.fresh()} + local gear=setmetatable({game=game},{__index=Gear}) + local before=Json.encode(save) + T.same(parts(gear),{21,47,5},edition..' external card reads shared saved RTC, weekday one-based') + T.eq(Json.encode(save),before,edition..' clock presentation does not mutate saved state') + Clock.setTime(save,7,13);Clock.setWeekday(save,0) + T.same(parts(gear),{7,13,1},edition..' native clock edits are immediately visible') + local seen={};local current={hour=22,minute=8,day=6} + game.overworld={hour=function(self)seen.hour=self;return current.hour end, + minute=function(self)seen.minute=self;return current.minute end, + weekday=function(self)seen.weekday=self;return current.day end} + T.same(parts(gear),{22,8,7},edition..' live native world owns presentation') + T.eq(seen.hour,game.overworld,edition..' actual world hour receives its owner') + T.eq(seen.minute,game.overworld,edition..' actual world minute receives its owner') + T.eq(seen.weekday,game.overworld,edition..' actual world weekday receives its owner') + current.hour,current.minute,current.day=4,59,0 + T.same(parts(gear),{4,59,1},edition..' live world changes are not cached as UI state') + game.world=game.overworld;game.overworld=nil + T.same(parts(gear),{4,59,1},edition..' reference game.world spelling uses same read protocol') + game.overworld={hour=function()error('partial clock must not be mixed with saved clock')end} + T.same(parts(gear),{7,13,1},edition..' incomplete world falls back to one complete saved clock') + gear.clock={hour=10,minute=34,weekday=3} + T.same(parts(gear),{10,34,3},edition..' authored card clock override retains precedence') + gear.clock={} + T.same(parts(gear),{0,0,1},edition..' authored empty override retains reference defaults') + gear.clock=nil;game.overworld=nil;game.world=nil + local detached=setmetatable({save=save},{__index=Gear}) + T.same(parts(detached),{7,13,1},edition..' save-only reference card is supported') + -- The native draw and an unchanged external call use the same getter. + local values={};gear.blink=0;gear.gfx={cards={clock={}}} + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.clockParts=function()return 23,56,6 end + gear.text=function(_,value,x,y)values[x..':'..y]=value end + gear:drawClock() + T.eq(values['6:6'],'FRIDAY',edition..' native card uses public weekday projection') + T.eq(values['6:8'],'11',edition..' native card uses public hour projection') + T.eq(values['9:8'],'56',edition..' native card uses public minute projection') + T.eq(values['12:8'],'PM',edition..' native card keeps cartridge half-day display') + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/card/manifest.json']=Json.encode({id='card',name='Native clock card reader',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/card/main.lua']=[[ + local mod=...;local Gear=require('src.ui.gen2.Pokegear') + mod.exports.read=function(game) + local screen=setmetatable({game=game},{__index=Gear}) + return screen:clockParts() + end + ]]} + local result=T.sdk.loadMods({'mods/card'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' public clock-card consumer loads unchanged') + T.same({result.loader.exports.card.read(game)},{7,13,1},label..' real loaded consumer reads native RTC rather than fallback zeroes') + Loader.teardown();result.release() + end +end +Clock.resetObservations();os.date=date;V.set(version) +T.finish('native and external Gen2 clock-card presentation over shared RTC') From ccdf628b6ce21e02c252e0dcf4f1ff7ffe647b45 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:55:56 +0100 Subject: [PATCH 08/64] Add native Pokemon palette queries and typed indexed draw compatibility --- docs/native-gen2-pokemon-palettes.md | 47 ++++++++ src/core/ModCompat.lua | 3 +- src/render/Gen2PaletteDraw.lua | 114 ++++++++++++++++++ src/render/Gen2PokemonPalettes.lua | 61 ++++++++++ src/render/PaletteFX.lua | 10 ++ tests/engine/gen2_palette_draw.lua | 105 ++++++++++++++++ tests/engine/gen2_pokemon_palette_queries.lua | 71 +++++++++++ tests/gpu/gen2_palette_draw.lua | 77 ++++++++++++ 8 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 docs/native-gen2-pokemon-palettes.md create mode 100644 src/render/Gen2PaletteDraw.lua create mode 100644 src/render/Gen2PokemonPalettes.lua create mode 100644 tests/engine/gen2_palette_draw.lua create mode 100644 tests/engine/gen2_pokemon_palette_queries.lua create mode 100644 tests/gpu/gen2_palette_draw.lua diff --git a/docs/native-gen2-pokemon-palettes.md b/docs/native-gen2-pokemon-palettes.md new file mode 100644 index 000000000..98e69c1f8 --- /dev/null +++ b/docs/native-gen2-pokemon-palettes.md @@ -0,0 +1,47 @@ +# Native Gen2 Pokemon palette drawing + +`src.world.gen2.Palettes.monColors(data, species, shiny)` reads the existing +native Pokemon/palette records. It accepts a full native dataset, its current +published `gen2Palettes` view, or reference palette-only normal/shiny pairs. +Native species keys resolve to the same native records. Existing dataset +aliases are honoured; this patch does not install another species catalogue. Four detached 0–255 RGB triples are returned; missing or +invalid colours return `nil`. Retired dataset views cannot revive old data. +The built-in `PaletteFX.monPal` uses the same native selection without copying +its source palette. Transformed Pokemon retain their existing precedence. + +`src.render.GbcPalette` now resolves to `src.render.Gen2PaletteDraw`, rather +than the unrelated LCD post-process `src.render.GBCFX`. Its indexed-image +shader recovers the four source grayscale shades and substitutes the supplied +palette while preserving texture alpha and drawing tint. `use`, `with`, +`useKeyed`, `keyedWith`, `useRaw`, `withRaw`, `shader`, `keyedShader`, +`available`, `resolve`, `color` and `clear` are provided. Keyed drawing makes +source shade zero transparent. Normal drawing resolves native `PaletteFX` +display/register state; raw drawing bypasses that resolution. + +Scoped drawing restores both the previous shader object and managed palette +uniforms, including nested calls, callback failures and partial upload errors. +Public calls upload all four rows because callers can modify exposed shader +objects directly. No private renderer or ROM-derived colour catalogue is +embedded in the implementation. + +## Validation and remaining work + +ROM-free suites cover public Loader consumers, Gold/Silver/Crystal species +queries, native source identity, shiny/normal/transformed precedence, authored +changes, detached results, retired views, invalid values and graphics failure +paths. The GPU probe `tests/gpu/gen2_palette_draw.lua` compares actual canvas +pixels for every shade, alpha, tint, keying, native register remap, nested +success/error and foreign shader restoration on each tested backend. + +Real-ROM validation must additionally compare the native palette producer and +public queries with an independent decoder, using actual imported Pokemon +metadata. Those private inputs and outputs are excluded from the repository. +A successful pixel probe is not proof of every unchanged mod presentation or +full gameplay acceptance. + +The complete reference `GbcPalette` API is not yet implemented: its separate +mode/custom-ramp controls and backwards remapping of already coloured frames +still need integration with native display state. The LCD effects API remains +separate. Full colour art, UI layout, original story admission, broader hardware +and complete Gen1/VASC parity remain independent acceptance work. No VASC code +or assets are changed by this feature. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 2e92443a1..420182efc 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -98,7 +98,8 @@ ModCompat.ALIASES = { -- the Gold port splits its Game class per generation; this one does not ["src.core.Game2"] = "src.core.Game", - ["src.render.GbcPalette"] = "src.render.GBCFX", + -- Indexed image palettes and LCD screen effects are different contracts. + ["src.render.GbcPalette"] = "src.render.Gen2PaletteDraw", } local seen = {} diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua new file mode 100644 index 000000000..b0c271a45 --- /dev/null +++ b/src/render/Gen2PaletteDraw.lua @@ -0,0 +1,114 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Indexed image drawing, separate from the LCD post-process in GBCFX. +-- Palette resolution uses the native display/register state in PaletteFX. +local Palettes = require("src.render.PaletteFX") +local M = {} +local compiler, shaders, failed = nil, {}, {} +local sent = setmetatable({}, {__mode = "k"}) +local names = {"pal0", "pal1", "pal2", "pal3"} +local source = [[ +extern vec3 pal0; extern vec3 pal1; extern vec3 pal2; extern vec3 pal3; +vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { + vec4 pixel = Texel(image, uv); + float shade = floor((1.0 - pixel.r) * 3.0 + 0.5); + vec3 rgb = shade < 0.5 ? pal0 : (shade < 1.5 ? pal1 : (shade < 2.5 ? pal2 : pal3)); + return vec4(rgb, ALPHA) * tint; +} +]] + +local function graphics() return love and love.graphics end +local function shader(keyed) + local g = graphics() + local newShader = g and g.newShader + if type(newShader) ~= "function" then return nil end + if compiler ~= newShader then + compiler, shaders, failed = newShader, {}, {} + end + local key = keyed and "keyed" or "normal" + if failed[key] then return nil end + if not shaders[key] then + local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" + local ok, value = pcall(newShader, (source:gsub("ALPHA", alpha))) + if not ok or not value then failed[key] = true; return nil end + shaders[key] = value + end + return shaders[key] +end +function M.shader() return shader(false) end +function M.keyedShader() return shader(true) end +function M.available() return M.shader() ~= nil end + +local function copy(colors) + if type(colors) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + local color = colors[i] + if type(color) ~= "table" then return nil end + local row = {} + for c = 1, 3 do + local n = color[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + row[c] = n + end + out[i] = row + end + return out +end +function M.resolve(colors) return Palettes.effectiveColors(colors) end +function M.color(colors, index) + local palette = M.resolve(colors) + return palette and palette[index] or nil +end + +local function upload(sh, colors) + -- Public shader objects can be edited externally. Upload every public call; + -- a cached last-send comparison could otherwise trust foreign uniforms. + sent[sh] = nil + for i = 1, 4 do + local row = colors[i] + sh:send(names[i], {row[1]/255, row[2]/255, row[3]/255}) + end + sent[sh] = colors +end +local function use(colors, keyed, raw) + local g = graphics() + if not (g and g.setShader) then return false end + colors = copy(raw and colors or M.resolve(colors)) + if not colors then return false end + local sh = shader(keyed) + if not sh then return false end + upload(sh, colors) + g.setShader(sh) + return true +end +function M.use(colors) return use(colors, false, false) end +function M.useRaw(colors) return use(colors, false, true) end +function M.useKeyed(colors) return use(colors, true, false) end +function M.clear() + local g = graphics() + if g and g.setShader then g.setShader() end +end + +local function scoped(colors, body, apply) + local g = graphics() + local previous = g and g.getShader and g.getShader() or nil + -- Restoring only the shader object is insufficient: nested calls reuse it + -- and change its uniforms. Save the palette values as well as its identity. + local previousColors = previous and sent[previous] + local ok, applied = pcall(function() + local active = apply(colors) + body() + return active + end) + local restored, why = pcall(function() + if previousColors then upload(previous, previousColors) end + if g and g.setShader then g.setShader(previous) end + end) + if not ok then error(applied, 0) end + if not restored then error(why, 0) end + return applied +end +function M.with(colors, body) return scoped(colors, body, M.use) end +function M.withRaw(colors, body) return scoped(colors, body, M.useRaw) end +function M.keyedWith(colors, body) return scoped(colors, body, M.useKeyed) end +return M diff --git a/src/render/Gen2PokemonPalettes.lua b/src/render/Gen2PokemonPalettes.lua new file mode 100644 index 000000000..576e29589 --- /dev/null +++ b/src/render/Gen2PokemonPalettes.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Public Gen2 colour reads over the native palette pack. No duplicate +-- species catalogue, ROM colours or private renderer implementation. +local Context = require("src.render.Gen2PaletteContext") +local M = {} + +-- Return the native source table without allocation for the built-in renderer. +-- Public consumers receive detached RGB values through colors() below. +function M.native(data, species, shiny) + local owner = Context.owner(data) + local pokemon = owner and owner.pokemon + local def = type(pokemon) == "table" and pokemon[species] + local pack = owner and owner.palettes + local palettes = type(pack) == "table" and pack.palettes + if type(def) ~= "table" or type(palettes) ~= "table" then return nil end + if shiny and def.shinyPalette and palettes[def.shinyPalette] then + return palettes[def.shinyPalette] + end + if def.palette and palettes[def.palette] then return palettes[def.palette] end + local names = pack.pokemon + local name = type(names) == "table" and (names[def.id or species] or names[species]) + return name and palettes[name] or nil +end + +local function color(value) + if type(value) ~= "table" then return nil end + local rgb = {} + for i = 1, 3 do + local n = value[i] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + rgb[i] = n + end + return rgb +end +local function detached(palette) + if type(palette) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + out[i] = color(palette[i]) + if not out[i] then return nil end + end + return out +end + +function M.colors(data, species, shiny) + if type(data) ~= "table" or type(species) ~= "string" then return nil end + local owner, bound = Context.owner(data) + if owner then return detached(M.native(data, species, shiny)) end + if bound then return nil end -- Never revive a retired dataset view. + -- Reference palette-only records carry the two ROM middle colours, rather + -- than native names into a four-colour pack. Preserve that public input. + local rows = data.pokemon + local entry = type(rows) == "table" and rows[species] + if type(entry) ~= "table" then return nil end + local pair = shiny and entry.shiny or entry.normal + if type(pair) ~= "table" then return nil end + local first, second = color(pair[1]), color(pair[2]) + if not first or not second then return nil end + return { {255,255,255}, first, second, {0,0,0} } +end +return M diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 52c994215..fd6aee72e 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -839,6 +839,10 @@ function PaletteFX.monPal(data, species, transformed, shiny) return p.palettes.GRAYMON or (data and data.palettes and data.palettes.palettes.GRAYMON) end + if GameVersion.isGen2() then + local native = require("src.render.Gen2PokemonPalettes").native(data, species, shiny) + if native then return native end + end local def = data and data.pokemon and data.pokemon[species] if shiny and def and def.shinyPalette then local pal = p.palettes[def.shinyPalette] @@ -880,6 +884,12 @@ function PaletteFX.monPal(data, species, transformed, shiny) return nil end +-- Legacy external Gen2 consumers ask the published palette view directly. +-- Resolve the same native row as monPal and return fresh RGB values. +function PaletteFX.monColors(data, species, shiny) + return require("src.render.Gen2PokemonPalettes").colors(data, species, shiny) +end + -- palette name a species currently resolves to (for image-cache keys) function PaletteFX.monPalName(data, species, transformed, shiny) if transformed then return "GRAYMON" end diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua new file mode 100644 index 000000000..8bc99a6e6 --- /dev/null +++ b/tests/engine/gen2_palette_draw.lua @@ -0,0 +1,105 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local P=require('src.render.PaletteFX') +local G=require('src.render.GbcPalette') +local LCD=require('src.render.GBCFX') +local V=require('src.core.GameVersion');local edition=V.get() +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local old=love.graphics +local A={{243,241,239},{219,17,31},{19,197,83},{23,29,157}} +local B={{233,229,227},{11,61,211},{173,37,139},{3,7,13}} +local function normalized(p) + local out={};for i=1,4 do local r=p[i];out['pal'..i-1]={r[1]/255,r[2]/255,r[3]/255}end;return out +end +local active, builds, failSend=nil,0,nil +local function fakeShader(code) + builds=builds+1 + return {source=code,values={},send=function(self,name,value) + if name==failSend then failSend=nil;error('uniform upload failed')end + self.values[name]={unpack(value)} + end} +end +love.graphics={newShader=fakeShader,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G,require('src.render.Gen2PaletteDraw'),'public spelling has typed native implementation') +T.neq(G,LCD,'palette drawing must not resolve to LCD screen effect') +T.eq(type(LCD.applyOptions),'function','native LCD options retained') +T.check(G.available(),'indexed shader is available') +local standard,keyed=G.shader(),G.keyedShader() +T.neq(standard,keyed,'keyed drawing has separate alpha policy') +T.eq(builds,2,'one lazy shader per variant') +T.check(standard.source:find('pixel.a',1,true)~=nil,'source alpha retained') +T.check(standard.source:find('* tint',1,true)~=nil,'draw colour retained') +T.check(keyed.source:find('shade < 0.5 ? 0.0',1,true)~=nil,'keyed source shade zero is transparent') +for _,variant in ipairs({'with','withRaw','keyedWith'})do + active={external=true};local external=active + local wanted=variant=='keyedWith'and keyed or standard + T.check(G[variant](A,function() + T.eq(active,wanted,variant..' shader active inside callback') + T.same(active.values,normalized(A),variant..' all four RGB rows normalized') + G.withRaw(B,function()T.same(active.values,normalized(B),variant..' nested different palette active')end) + T.eq(active,wanted,variant..' nested return restores shader') + T.same(active.values,normalized(A),variant..' nested return restores uniforms') + local ok,err=pcall(function()G.withRaw(B,function()error('authored callback failure',0)end)end) + T.check(not ok and err=='authored callback failure',variant..' callback failure propagated') + T.eq(active,wanted,variant..' nested failure restores shader') + T.same(active.values,normalized(A),variant..' nested failure restores outer palette') + end),variant..' returns applied flag') + T.eq(active,external,variant..' restores foreign shader') +end +G.useRaw(A);A[2][1]=111 +G.withRaw(B,function()end) +local saved=normalized(A);saved.pal1[1]=219/255 +T.same(active.values,saved,'restoration does not borrow caller palette rows') +A[2][1]=219 +standard:send('pal1',{0,0,0});G.useRaw(A) +T.same(active.values,normalized(A),'later public call repairs externally edited uniforms') +G.useRaw(A);failSend='pal2' +local ok,err=pcall(function()G.withRaw(B,function()error('body must not run')end)end) +T.check(not ok and tostring(err):find('uniform upload failed',1,true)~=nil,'upload failure is propagated') +T.eq(active,standard,'partial upload restores previous shader') +T.same(active.values,normalized(A),'partial upload restores all previous uniforms') +local nativeMode,nativeMap=P.mode,P.shadeMap() +for _,game in ipairs({'red','gold','silver','crystal'})do + V.set(game) + for _,mode in ipairs(P.MODES)do + P.setMode(mode);P.setShadeMap({[0]=3,[1]=1,[2]=2,[3]=0}) + local resolved=P.effectiveColors(A) + T.same(G.resolve(A),resolved,game..' '..mode..' shares actual native colour/register resolution') + G.with(A,function()T.same(active.values,normalized(resolved),game..' '..mode..' actual use shares resolved colours')end) + G.withRaw(A,function()T.same(active.values,normalized(A),game..' '..mode..' raw use bypasses display/register state')end) + T.same(G.color(A,2),resolved[2],game..' '..mode..' solid colour shares same native row') + end +end +P.setShadeMap(nativeMap);P.setMode(nativeMode);V.set(edition) +for _,bad in ipairs({false,{},{{1,2,3}},{{1,2,3},{1,2,3},{1,2,3},{0/0,2,3}},{{1,2,3},{1,2,3},{1,2,3},{256,2,3}}})do + active={foreign=true};local foreign=active;local called=false + T.eq(G.withRaw(bad,function()called=true;T.eq(active,foreign,'invalid palette retains previous shader in body')end),false,'invalid palette declines colourization') + T.check(called,'invalid palette still executes draw callback') + T.eq(active,foreign,'invalid palette restores previous shader') +end +G.clear();T.eq(active,nil,'clear restores ordinary drawing') +love.graphics={} +T.eq(G.available(),false,'headless renderer declines shader availability') +local called=false;T.eq(G.with(A,function()called=true end),false,'headless with declines colourization') +T.check(called,'headless callback still executes') +love.graphics={newShader=function()error('compiler unavailable')end,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G.available(),false,'compile failure is an unavailable feature') +love.graphics.newShader=fakeShader +T.check(G.available(),'replacement compiler can recover after a failed compile') +for _,api in ipairs({1,2})do + local files={['mods/draw/manifest.json']=Json.encode({id='draw',name='Native indexed drawing consumer',version='1.0.0',api=api,content_format=api==3 and 'unified'or'native',entry='main.lua',affects_link=false}), + ['mods/draw/main.lua']=[[ + local mod=...;local Gbc=require('src.render.GbcPalette') + mod.exports.draw=function(colors,body)return Gbc.with(colors,body)end + ]]} + local r=T.sdk.loadMods({'mods/draw'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,'API'..api..' unchanged indexed consumer loads') + local ran=false + T.check(r.loader.exports.draw.draw(A,function()ran=true end),'API'..api..' loaded consumer applies palette') + T.check(ran,'API'..api..' loaded draw callback executes') + Loader.teardown();r.release() +end +love.graphics=old +T.finish('native indexed palette drawing, nested uniform and shader restoration') diff --git a/tests/engine/gen2_pokemon_palette_queries.lua b/tests/engine/gen2_pokemon_palette_queries.lua new file mode 100644 index 000000000..6ce9ae5ff --- /dev/null +++ b/tests/engine/gen2_pokemon_palette_queries.lua @@ -0,0 +1,71 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes');local Data=require('src.core.Data') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local mode=P.mode +local function palette(seed)return {{255,255,255},{seed,2,3},{4,seed+5,6},{0,0,0}}end +local function fixture() + local d=T.fixtures.fresh();local normal,shiny,custom=palette(20),palette(60),palette(100) + d.tilesets={QUERY={palMap={0},palColorsByTod={DAY={normal}}}} + d.pokemon={SPECIES_133={id='SPECIES_133',index=133,dex=133,name='SPECIES_133',palette='QA_NORMAL',shinyPalette='QA_SHINY'}, + SPECIES_149={id='SPECIES_149',index=149,dex=149,name='DRAGONITE'}, + NO_COLORS={id='NO_COLORS'}} + d.palettes={palettes={QA_NORMAL=normal,QA_SHINY=shiny,QA_CUSTOM=custom,QA_MAPPED=palette(140), + MEWMON=palette(170),GRAYMON=palette(180)},pokemon={SPECIES_149='QA_MAPPED'}} + d.constants.speciesOrder={[133]='SPECIES_133',[149]='SPECIES_149'} + Data.publishGen2Palettes(d) + return d,normal,shiny,custom +end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);local d,normal,shiny,custom=fixture();local view=d.gen2Palettes + for _,colorMode in ipairs(P.MODES)do + P.setMode(colorMode) + T.same(P.monColors(view,'SPECIES_133'),normal,edition..' public normal RGB in '..colorMode) + T.same(P.monColors(view,'SPECIES_133',true),shiny,edition..' public shiny RGB in '..colorMode) + T.same(P.monColors(d,'SPECIES_133'),normal,edition..' full native dataset accepted in '..colorMode) + T.eq(P.monPal(d,'SPECIES_133',false,false),normal,edition..' native renderer keeps source table, without copied palettes') + T.eq(P.monPal(d,'SPECIES_133',false,true),shiny,edition..' native shiny renderer shares source selection') + end + P.setMode('gbc') + T.same(P.monColors(view,'SPECIES_149'),d.palettes.palettes.QA_MAPPED,edition..' native species-to-palette map retained') + T.eq(P.monColors(view,'ABSENT'),nil,edition..' unknown species does not acquire arbitrary fallback colours') + T.eq(P.monColors(view,'NO_COLORS'),nil,edition..' existing species without native palette stays unavailable') + local copy=P.monColors(view,'SPECIES_133');copy[2][1]=0;copy[1]=nil + T.eq(normal[2][1],20,edition..' external recolouring cannot mutate native palette') + T.same(P.monColors(view,'SPECIES_133'),normal,edition..' later read is detached from previous query') + d.pokemon.SPECIES_133.palette='QA_CUSTOM' + T.same(P.monColors(view,'SPECIES_133'),custom,edition..' live authored palette replacement reaches external view') + T.eq(P.monPal(d,'SPECIES_133',false,false),custom,edition..' same replacement reaches native renderer') + d.palettes.palettes.QA_SHINY=nil + T.same(P.monColors(view,'SPECIES_133',true),custom,edition..' unavailable shiny palette falls back to same native normal row') + T.eq(P.monPal(d,'SPECIES_133',true,true),d.palettes.palettes.GRAYMON,edition..' transformed native rendering keeps original precedence') + local bytes=Json.encode(d.palettes) + P.monColors(view,'SPECIES_133');T.eq(Json.encode(d.palettes),bytes,edition..' public read does not publish another palette catalogue') + Data.publishGen2Palettes(d) + T.eq(P.monColors(view,'SPECIES_133'),nil,edition..' retired view cannot revive a previous binding') + local legacy={pokemon={LOCAL={normal={{21,22,23},{31,32,33}},shiny={{41,42,43},{51,52,53}}}}} + T.same(P.monColors(legacy,'LOCAL'),{{255,255,255},{21,22,23},{31,32,33},{0,0,0}},edition..' reference pair input preserved') + T.same(P.monColors(legacy,'LOCAL',true),{{255,255,255},{41,42,43},{51,52,53},{0,0,0}},edition..' reference shiny pair preserved') + legacy.pokemon.LOCAL.shiny=nil + T.same(P.monColors(legacy,'LOCAL',true),P.monColors(legacy,'LOCAL'),edition..' reference absent shiny falls back to normal') + legacy.pokemon.LOCAL.normal[1][2]=0/0 + T.eq(P.monColors(legacy,'LOCAL'),nil,edition..' malformed colour is not handed to external shader') + for _,api in ipairs({1,2})do + local files={['mods/colors/manifest.json']=Json.encode({id='colors',name='Native Pokemon palette query',version='1.0.0',api=api,content_format=api==3 and 'unified' or 'native',entry='main.lua',affects_link=false}), + ['mods/colors/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + mod.exports.colors=function(data,species,shiny)return Palettes.monColors(data.gen2Palettes,species,shiny)end + ]]} + local fresh=fixture();local r=T.sdk.loadMods({'mods/colors'},{data=fresh,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,edition..' API'..api..' unchanged public consumer loads') + T.same(r.loader.exports.colors.colors(fresh,'SPECIES_133'),fresh.palettes.palettes.QA_NORMAL,edition..' API'..api..' loaded public query reads actual native palette') + Loader.teardown();r.release() + end +end +for _,args in ipairs({{}, {false}, {{},false}, {{pokemon={BAD=false}},'BAD'}})do + T.eq(P.monColors(unpack(args)),nil,'missing or invalid colour query declines') +end +P.setMode(mode);V.set(version) +T.finish('native Gen2 Pokemon palette queries and shared native selection') diff --git a/tests/gpu/gen2_palette_draw.lua b/tests/gpu/gen2_palette_draw.lua new file mode 100644 index 000000000..0c84afaf9 --- /dev/null +++ b/tests/gpu/gen2_palette_draw.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Call run() from an isolated LÖVE host, on each graphics backend. +local M = {} +function M.run() + require('src.core.ModCompat').install() + local G = require('src.render.GbcPalette') + local P = require('src.render.PaletteFX') + local V = require('src.core.GameVersion') + local beforeVersion, beforeMode, beforeMap = V.get(), P.mode, P.shadeMap() + V.set('crystal') + local checks = 0 + local function check(ok, message) + checks = checks + 1 + assert(ok, message) + end + local A = {{247,239,229},{213,19,43},{23,193,79},{17,31,151}} + local B = {{233,227,211},{7,59,199},{163,41,137},{3,11,29}} + local shades = love.image.newImageData(4,1,'rgba8') + for x=0,3 do local n=1-x/3;shades:setPixel(x,0,n,n,n,1-x/4) end + local image = love.graphics.newImage(shades);image:setFilter('nearest','nearest') + local canvas = love.graphics.newCanvas(4,12,{format='rgba8',dpiscale=1}) + local expected = {} + local function draw(row, palette, keyed, tint) + love.graphics.setColor(unpack(tint or {1,1,1,1})) + love.graphics.draw(image,0,row) + expected[row] = {palette=palette,keyed=keyed,tint=tint or {1,1,1,1}} + end + love.graphics.push('all') + local ok, why = pcall(function() + love.graphics.setCanvas(canvas);love.graphics.clear(0,0,0,0) + love.graphics.setBlendMode('replace','premultiplied') + check(G.with(A,function()draw(0,A,false,{0.5,0.75,1,0.8})end),'normal draw unavailable') + check(G.keyedWith(A,function()draw(1,A,true)end),'keyed draw unavailable') + G.withRaw(A,function() + draw(2,A) + G.withRaw(B,function()draw(3,B)end) + draw(4,A) + local success,err=pcall(function()G.withRaw(B,function()error('nested failure',0)end)end) + check(not success and err=='nested failure','nested callback error lost') + draw(5,A) + G.keyedWith(B,function()draw(6,B,true)end) + draw(7,A) + end) + P.setShadeMap({[0]=3,[1]=2,[2]=1,[3]=0}) + G.with(A,function()draw(8,{A[4],A[3],A[2],A[1]})end) + G.withRaw(A,function()draw(9,A)end) + local foreign=love.graphics.newShader([[ + vec4 effect(vec4 tint,Image image,vec2 uv,vec2 screen){return vec4(0.2,0.4,0.6,0.8);} + ]]) + love.graphics.setShader(foreign) + G.withRaw(B,function()draw(10,B)end) + check(love.graphics.getShader()==foreign,'foreign shader identity not restored') + love.graphics.setColor(1,1,1,1);love.graphics.draw(image,0,11) + love.graphics.setCanvas();love.graphics.setShader() + local pixels=canvas:newImageData() + for row,expect in pairs(expected)do + for x=0,3 do + local r,g,b,a=pixels:getPixel(x,row) + local rgb=expect.palette[x+1];local tint=expect.tint + local wanted={rgb[1]/255*tint[1],rgb[2]/255*tint[2],rgb[3]/255*tint[3], + expect.keyed and x==0 and 0 or (1-x/4)*tint[4]} + for c,value in ipairs({r,g,b,a})do + check(math.abs(value-wanted[c])<=2/255,'pixel mismatch row '..row..' shade '..x..' channel '..c) + end + end + end + for x=0,3 do + local pixel={pixels:getPixel(x,11)} + for c,n in ipairs({0.2,0.4,0.6,0.8})do check(math.abs(pixel[c]-n)<=2/255,'foreign shader pixel not restored')end + end + end) + love.graphics.pop() + P.setShadeMap(beforeMap);P.setMode(beforeMode);V.set(beforeVersion) + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='GPU pixel equality for shade palette, tint, alpha, keying, nested success/error and foreign shader restoration'},canvas +end +return M From 287f6fabf8ed1fb6e29928b443767db0e7b45217 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:01:21 +0100 Subject: [PATCH 09/64] Expose native palette mode to preserve already-coloured evolution pictures --- docs/native-gen2-pokemon-palettes.md | 3 +++ src/render/Gen2PaletteDraw.lua | 11 +++++++++++ tests/engine/gen2_palette_draw.lua | 4 ++++ 3 files changed, 18 insertions(+) diff --git a/docs/native-gen2-pokemon-palettes.md b/docs/native-gen2-pokemon-palettes.md index 98e69c1f8..d6537cc18 100644 --- a/docs/native-gen2-pokemon-palettes.md +++ b/docs/native-gen2-pokemon-palettes.md @@ -17,6 +17,9 @@ palette while preserving texture alpha and drawing tint. `use`, `with`, `available`, `resolve`, `color` and `clear` are provided. Keyed drawing makes source shade zero transparent. Normal drawing resolves native `PaletteFX` display/register state; raw drawing bypasses that resolution. +The public `mode` read follows that native resolution, so external evolution +presenters can preserve already coloured art instead of passing it through a +grayscale-index shader. It does not create another display option. Scoped drawing restores both the previous shader object and managed palette uniforms, including nested calls, callback failures and partial upload errors. diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua index b0c271a45..8d3b93f2e 100644 --- a/src/render/Gen2PaletteDraw.lua +++ b/src/render/Gen2PaletteDraw.lua @@ -2,7 +2,18 @@ -- Indexed image drawing, separate from the LCD post-process in GBCFX. -- Palette resolution uses the native display/register state in PaletteFX. local Palettes = require("src.render.PaletteFX") +local Version = require("src.core.GameVersion") local M = {} +-- Consumers use this view to leave already-coloured pictures untouched. +-- Gen2 currently resolves native ROM colours regardless of the Gen1 mode. +-- Do not invent a second mutable display setting for the external renderer. +setmetatable(M, {__index = function(_, key) + if key == "mode" then + if Version.isGen2() then return "gbc" end + if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end + return Palettes.mode == "classic" and "classic" or "gbc" + end +end}) local compiler, shaders, failed = nil, {}, {} local sent = setmetatable({}, {__mode = "k"}) local names = {"pal0", "pal1", "pal2", "pal3"} diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua index 8bc99a6e6..76de1532e 100644 --- a/tests/engine/gen2_palette_draw.lua +++ b/tests/engine/gen2_palette_draw.lua @@ -66,6 +66,10 @@ for _,game in ipairs({'red','gold','silver','crystal'})do for _,mode in ipairs(P.MODES)do P.setMode(mode);P.setShadeMap({[0]=3,[1]=1,[2]=2,[3]=0}) local resolved=P.effectiveColors(A) + local display=game~='red'and'gbc'or(mode=='classic'and'classic'or((mode=='og'or mode=='og_inv')and'dmg'or'gbc')) + T.eq(G.mode,display,game..' '..mode..' public mode follows actual native resolution') + local trueColor=true + T.eq(not(trueColor and G.mode=='gbc'),display~='gbc',game..' '..mode..' unchanged evolution colour gate preserves true-colour art') T.same(G.resolve(A),resolved,game..' '..mode..' shares actual native colour/register resolution') G.with(A,function()T.same(active.values,normalized(resolved),game..' '..mode..' actual use shares resolved colours')end) G.withRaw(A,function()T.same(active.values,normalized(A),game..' '..mode..' raw use bypasses display/register state')end) From 529fa592cdce65c1fbce7984bd21894169703961 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:26:36 +0100 Subject: [PATCH 10/64] Keep native Gen2 daily services on the saved RTC midnight --- docs/native-gen2-clock.md | 42 ++++++--- src/core/Gen2Clock.lua | 77 +++++++++++++---- src/script/Gen2Daily.lua | 17 ++-- tests/engine/gen2_clock.lua | 15 ++-- tests/engine/gen2_clock_lifecycle.lua | 119 ++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 tests/engine/gen2_clock_lifecycle.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index 0bf1f6f38..5fc23558a 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -7,12 +7,31 @@ and the native happiness evolution gate. The existing `src.core.gen2.Clock` name resolves to this native module. `world.hour()`, `world.minute()` and `world.weekday()` read that same save. -`setTime(save, hour, minute)` stores `rtc.startMinute`, an offset from the -host RTC, rather than a frozen hour. `setWeekday` stores `rtc.startDay` and -keeps the older native `g2DayOffset` field synchronized. Old saves without -an RTC base retain their host-hour and existing weekday-offset behavior. -Read calls never create clock data. Invalid/non-finite writes are rejected. -The normal native save codec preserves the offsets; play time is separate. +`setTime(save, hour, minute)` anchors the chosen minute, current game +weekday and calendar date to elapsed RTC seconds. Hours, weekdays and daily +calendar identity cross the same in-game midnight, including after a saved +session has been closed for days. Automatic host DST and time-zone changes do +not add another hour to a clock the player already set. Explicit system clock +adjustments still affect the host RTC; this is not a monotonic stopwatch. + +The legacy `rtc.startMinute`, `rtc.startDay` and `g2DayOffset` projections are +retained for older readers. New writes also store `anchorTime`, `anchorMinute`, +`anchorDay` and `anchorDate` in the same `save.rtc` record. Consumers should use +the native setters/readers instead of mutating these projections directly. +`setWeekday` edits the current game weekday without changing elapsed calendar +age. Editing time reanchors the clock without resetting its current date. + +Old saves without the elapsed anchor retain their original host-hour and +weekday-offset semantics, including their old midnight relationship, until +the clock is explicitly set. Read calls never create or migrate clock data. +Invalid/non-finite writes are rejected. The normal native save codec retains +both formats; elapsed play time is separate. + +`calendarDate(save, now)` and `dateKey(save, now)` give native daily and weekly +services the calendar belonging to that RTC. Daily flags/fruit use and the +lottery now expire on game midnight and the selected game Friday instead of +an unrelated host-calendar boundary. The optional timestamp on clock reads +uses the same snapshot for minute, weekday and calendar queries. World palettes, object-load period bits and `VAR_HOUR` share the saved hour; the normal object-load freeze still applies. An explicit palette-query hour @@ -32,7 +51,9 @@ palette override. This keeps explicit native/external hour queries aligned. `clock.day_changed` is produced by actual weekday reads, including the live world clock poll. It reports `day`, `previous` and `reason` (`rollover` or `set`). A save's first observation is silent. Observations are per save, -reentrant-safe, subscription-aware and retired on mod/edition reload. +reentrant-safe, subscription-aware and retired on mod/edition reload. An +observed offline whole week still produces one change, even when the weekday +number is the same; its calendar date is different. Native API 1/2 use the existing event dispatch boundary. The native GSC producer remains generation-scoped. @@ -41,9 +62,10 @@ are exercised directly in synthetic engine tests, including minute wrap, weekly rollover, save roundtrip, RTC-versus-play-time display, the native callback path and actual mod loading. Graphics IO alone is replaced for the clock card. These checks do not establish a complete original-story clock-setting -sequence, DST, battery-backed SRAM RTC conversion, all daily scheduling or -fresh GPU acceptance for this change. Existing daily date keys still use -host calendar dates; lottery weekday selection now shares the saved weekday. +sequence, manual DST-setting/check dialogs, battery-backed SRAM RTC +conversion, all daily services or fresh GPU acceptance for this change. +Automatic host DST isolation is covered; the original story clock-setting +screen and remaining manual DST lifecycle still need native implementation. The public contract is compared with the read-only Gen1Recomp reference; the implementation is native fork code. No VASC implementation, private diff --git a/src/core/Gen2Clock.lua b/src/core/Gen2Clock.lua index fc02c2953..dc7d75d17 100644 --- a/src/core/Gen2Clock.lua +++ b/src/core/Gen2Clock.lua @@ -12,18 +12,31 @@ end local function rtc(save) return type(save) == "table" and type(save.rtc) == "table" and save.rtc or nil end -function Clock.hostMinutes() - return ((integer(os.date("%H")) or 0) * 60 + (integer(os.date("%M")) or 0)) % 1440 +function Clock.hostMinutes(now) + return ((integer(os.date("%H", now)) or 0) * 60 + (integer(os.date("%M", now)) or 0)) % 1440 end function Clock.hostWeekday(now) return (integer(os.date("%w", now)) or 0) % 7 end -function Clock.minutes(save) +-- Newly set clocks advance by elapsed RTC seconds, including while closed. +-- The old host-local offsets remain readable without migrating a save. Host +-- DST/time-zone changes must not silently change an explicitly set game clock. +local function anchored(state, now) + if not state then return nil end + local at, minute, day = integer(state.anchorTime), integer(state.anchorMinute), integer(state.anchorDay) + if not at or not minute or minute < 0 or minute >= 1440 or not day or day < 0 or day >= 7 then return nil end + now = integer(now or os.time()) + if not now then return nil end + return minute + math.floor((now - at) / 60), day +end +function Clock.minutes(save, now) local state = rtc(save) - return (Clock.hostMinutes() + (state and integer(state.startMinute) or 0)) % 1440 + local total = anchored(state, now) + if total then return total % 1440 end + return (Clock.hostMinutes(now) + (state and integer(state.startMinute) or 0)) % 1440 end -function Clock.hour(save) return math.floor(Clock.minutes(save) / 60) end -function Clock.minute(save) return Clock.minutes(save) % 60 end +function Clock.hour(save, now) return math.floor(Clock.minutes(save, now) / 60) end +function Clock.minute(save, now) return Clock.minutes(save, now) % 60 end function Clock.isSet(save) local state = rtc(save) return state ~= nil and integer(state.startMinute) ~= nil @@ -32,42 +45,76 @@ function Clock.setTime(save, hour, minute) if type(save) ~= "table" then return false end hour, minute = integer(hour or 0), integer(minute or 0) if not hour or not minute then return false end + local now = os.time() + local day, date = Clock.weekday(save, now), Clock.dateKey(save, now) + local wanted = (hour % 24) * 60 + minute % 60 save.rtc = rtc(save) or {} - save.rtc.startMinute = ((hour % 24) * 60 + minute % 60 - Clock.hostMinutes()) % 1440 + save.rtc.startMinute = (wanted - Clock.hostMinutes(now)) % 1440 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 + save.g2DayOffset = save.rtc.startDay + save.rtc.anchorTime, save.rtc.anchorMinute = now, wanted + save.rtc.anchorDay, save.rtc.anchorDate = day, date return true end -local function note(save, day, reason) +local function note(save, day, reason, now) if type(save) ~= "table" then return day end local Runtime = require("src.mods.Runtime") if not require("src.core.GameVersion").isGen2() or not Runtime.wants("clock.day_changed") then observed[save] = nil return day end - local previous = observed[save] - observed[save] = day -- Commit before dispatch: reentrant reads cannot emit twice. - if previous ~= nil and previous ~= day then - Runtime.emit("clock.day_changed", { day = day, previous = previous, reason = reason }) + local previous, date = observed[save], Clock.dateKey(save, now) + observed[save] = { day=day, date=date } -- Commit before dispatch; reentrant reads are silent. + if previous ~= nil and (previous.day ~= day or previous.date ~= date) then + Runtime.emit("clock.day_changed", { day = day, previous = previous.day, reason = reason }) end return day end function Clock.weekday(save, now) local state = rtc(save) + local total, day = anchored(state, now) + if total then return note(save, (day + math.floor(total / 1440)) % 7, "rollover", now) end local offset = state and integer(state.startDay) if offset == nil then offset = type(save) == "table" and integer(save.g2DayOffset) or 0 end - return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover") + return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover", now) end function Clock.setWeekday(save, day) day = integer(day) if type(save) ~= "table" or not day then return false end + local now = os.time() save.rtc = rtc(save) or {} day = day % 7 - save.rtc.startDay = (day - Clock.hostWeekday()) % 7 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 save.rtc.dayOfWeek = day + local total = anchored(save.rtc, now) + if total then save.rtc.anchorDay = (day - math.floor(total / 1440)) % 7 end -- Keep the existing native field in sync for older native consumers/saves. save.g2DayOffset = save.rtc.startDay - note(save, day, "set") + note(save, day, "set", now) return true end +-- Calendar identity for daily/weekly timers. Only date arithmetic uses the +-- host calendar library; elapsed game days come from the same RTC as minutes +-- and weekday. Noon normalization avoids missing/duplicated local DST hours. +function Clock.calendarDate(save, now) + local state = rtc(save) + local total = anchored(state, now) + local year, month, day + if total and type(state.anchorDate) == "string" then + year, month, day = state.anchorDate:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") + year, month, day = tonumber(year), tonumber(month), tonumber(day) + end + if year and month >= 1 and month <= 12 and day >= 1 and day <= 31 then + local ok, stamp = pcall(os.time, { year=year, month=month, + day=day + math.floor(total / 1440), hour=12, min=0, sec=0, isdst=nil }) + if ok and stamp then return os.date("*t", stamp) end + end + return os.date("*t", now) +end +function Clock.dateKey(save, now) + local date = Clock.calendarDate(save, now) + return ("%04d-%02d-%02d"):format(date.year, date.month, date.day) +end function Clock.weekdayName(day) local name = Clock.DAY_NAMES[day] return name and Strings(name) diff --git a/src/script/Gen2Daily.lua b/src/script/Gen2Daily.lua index 6337f5cd9..36c041447 100644 --- a/src/script/Gen2Daily.lua +++ b/src/script/Gen2Daily.lua @@ -18,6 +18,9 @@ local Gen2Flags = require("src.script.Gen2Flags") local Gen2Daily = {} +-- Native RTC/calendar integration by Roxas2712; see contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +local Clock = require("src.core.Gen2Clock") local ENGINE_LUCKY_NUMBER_SHOW = 77 local GameVersion = require("src.core.GameVersion") @@ -39,19 +42,15 @@ local INITIAL_HIDDEN_EVENTS = { 1915, -- EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL } -local function todayKey(now) - return os.date("%Y-%m-%d", now) -end - local function nextFriday(save, now) - local date = os.date("*t", now) - local weekday = require("src.core.Gen2Clock").weekday(save, now) + local date = Clock.calendarDate(save, now) + local weekday = Clock.weekday(save, now) local days = (5 - weekday) % 7 -- Match the weekday chosen at Mom's clock. if days == 0 then days = 7 end -- Calendar arithmetic, rather than seconds, also handles daylight saving. date.day, date.hour, date.min, date.sec = date.day + days, 12, 0, 0 date.isdst = nil - return todayKey(os.time(date)) + return os.date("%Y-%m-%d", os.time(date)) end function Gen2Daily.lotteryExpired(save, now) @@ -59,7 +58,7 @@ function Gen2Daily.lotteryExpired(save, now) -- Old saves already have a drawn ID; retain it when adding the timer. save.g2LuckyNextFriday = nextFriday(save, now) end - return todayKey(now) >= save.g2LuckyNextFriday + return Clock.dateKey(save, now) >= save.g2LuckyNextFriday end function Gen2Daily.resetLottery(save, now) @@ -121,7 +120,7 @@ function Gen2Daily.poll(save, now) Gen2Daily.ensureRoam(save, nil) if not save then return end Gen2Daily.seedInitialObjectFlags(save) - local today = todayKey(now) + local today = Clock.dateKey(save, now) if save.g2DailyDay == today then return end save.g2DailyDay = today Gen2Daily.onNewDay(save) diff --git a/tests/engine/gen2_clock.lua b/tests/engine/gen2_clock.lua index e3085943c..ecfe737fe 100644 --- a/tests/engine/gen2_clock.lua +++ b/tests/engine/gen2_clock.lua @@ -7,9 +7,11 @@ local Clock = require('src.core.gen2.Clock') local P = require('src.render.PaletteFX');local W = require('src.world.OverworldController') require('src.script.Gen2Commands');local Commands = require('src.script.Commands');local Loader = require('src.mods.Loader') local Json = require('src.link.Json');local Save = require('src.core.SaveData') -local date = os.date;local host = { hour=13, minute=20, day=2 } +local date,time = os.date,os.time;local elapsed=0;local baseTime=time() +os.time=function(value)if value then return time(value)end;return baseTime+elapsed end +local host = { hour=13, minute=20, day=2 } os.date = function(format, now) - if now then return date(format,now) end + if now and now~=os.time() then return date(format,now) end if format=='%H' then return tostring(host.hour) end if format=='%M' then return tostring(host.minute) end if format=='%w' then return tostring(host.day) end @@ -33,7 +35,7 @@ T.eq(Clock.setTime(false,10,0),false,'no saved state means no write') T.eq(Clock.setWeekday({},math.huge),false,'infinite weekday rejected') T.eq(Clock.weekdayName(0),nil,'weekday label API is one-based') for _,edition in ipairs({'gold','silver','crystal'}) do - V.set(edition);host.hour=13;host.minute=20;host.day=2 + V.set(edition);host.hour=13;host.minute=20;host.day=2;elapsed=0 local save={playTime=0,g2DayOffset=3};local untouched=Json.encode(save) T.eq(Clock.hour(save),13,edition..' legacy hour retains host behavior') T.eq(Clock.weekday(save),5,edition..' legacy native weekday offset retained') @@ -45,10 +47,9 @@ for _,edition in ipairs({'gold','silver','crystal'}) do T.eq(Clock.weekday(save),6,edition..' weekday is observed immediately') T.eq(Clock.isSet(save),true,edition..' explicit clock setting is distinguishable') T.eq(save.g2DayOffset,save.rtc.startDay,edition..' old native day readers retain shared offset') - host.minute=30 + host.minute=30;elapsed=600 T.eq(Clock.minutes(save),5,edition..' RTC keeps ticking and wraps at midnight') - host.day=3 - T.eq(Clock.weekday(save),0,edition..' saved weekday follows RTC weekly rollover') + T.eq(Clock.weekday(save),0,edition..' saved weekday rolls over at game midnight before host midnight') local round=assert(Save.decode(Save.encode(save))) T.eq(Clock.minutes(round),5,edition..' save roundtrip retains RTC minute base') T.eq(Clock.weekday(round),0,edition..' save roundtrip retains RTC weekday base') @@ -115,5 +116,5 @@ for _,edition in ipairs({'gold','silver','crystal'}) do end end nativeGame.save=oldSave;debug.setupvalue(W.timeOfDay,slot,oldGame) -Clock.resetObservations();os.date=date;V.set(version) +Clock.resetObservations();os.date=date;os.time=time;V.set(version) T.finish('shared native Gen2 saved RTC, actual world/scripts/clock-card and public event') diff --git a/tests/engine/gen2_clock_lifecycle.lua b/tests/engine/gen2_clock_lifecycle.lua new file mode 100644 index 000000000..ab38d51c8 --- /dev/null +++ b/tests/engine/gen2_clock_lifecycle.lua @@ -0,0 +1,119 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +local C=require('src.core.Gen2Clock');local D=require('src.script.Gen2Daily') +local Save=require('src.core.SaveData');local Json=require('src.link.Json') +local date,time=os.date,os.time +-- A deterministic host clock. Date-table normalization still uses the OS +-- calendar, including real DST rules; elapsed time is independently advanced. +local now=time({year=2026,month=10,day=20,hour=13,min=20,sec=0}) +local origin=now;local shift=0 +os.time=function(value)if value then return time(value)end;return now end +os.date=function(format,stamp)return date(format,(stamp or now)+shift)end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);now=origin;shift=0 + local save={flags={},g2FruitTrees={used=true}} + T.eq(C.setTime(save,23,59),true,edition..' player can anchor before midnight') + T.eq(C.setWeekday(save,4),true,edition..' player chooses Thursday independently of host') + local startDate=C.dateKey(save);local offset=save.rtc.startMinute + T.eq(C.hour(save),23,edition..' explicit hour visible immediately') + T.eq(C.minute(save),59,edition..' explicit minute visible immediately') + D.poll(save);save.g2FruitTrees.used=true + local before=save.g2DailyDay + now=now+59;D.poll(save) + T.eq(C.minute(save),59,edition..' RTC waits for the full elapsed minute') + T.eq(C.weekday(save),4,edition..' weekday remains before game midnight') + T.eq(save.g2FruitTrees.used,true,edition..' daily use retained before midnight') + now=now+1;D.poll(save) + T.eq(C.hour(save),0,edition..' elapsed RTC crosses midnight') + T.eq(C.minute(save),0,edition..' minute wraps with the day') + T.eq(C.weekday(save),5,edition..' weekday crosses at same game midnight') + T.eq(C.hostWeekday(),C.hostWeekday(origin),edition..' host has not crossed midnight') + T.eq(save.g2FruitTrees.used,nil,edition..' native daily reset occurs at game midnight') + T.eq(save.g2DailyDay~=before,true,edition..' daily calendar uses the game boundary') + T.eq(C.dateKey(save)~=startDate,true,edition..' RTC calendar advances at same boundary') + save.g2FruitTrees.used=true;D.poll(save) + T.eq(save.g2FruitTrees.used,true,edition..' repeated poll cannot repeat daily reset') + local round=assert(Save.decode(Save.encode(save))) + now=now+7*86400+123*60 + T.eq(C.weekday(round),5,edition..' offline week retains proper weekday') + T.eq(C.hour(round),2,edition..' offline elapsed hours retained after save reload') + T.eq(C.minute(round),3,edition..' offline elapsed minutes retained after save reload') + local stale=round.g2DailyDay;D.poll(round) + T.eq(round.g2DailyDay~=stale,true,edition..' offline whole week still resets daily identity') + T.eq(round.g2FruitTrees.used,nil,edition..' offline reset does not depend on changed weekday') + local expected=C.minutes(round);local day=C.weekday(round);local calendar=C.dateKey(round) + shift=3600 + T.eq(C.minutes(round),expected,edition..' automatic host DST jump cannot double-advance game RTC') + T.eq(C.weekday(round),day,edition..' host DST cannot independently shift game weekday') + T.eq(C.dateKey(round),calendar,edition..' host DST cannot expire daily identity') + shift=-3600 + T.eq(C.minutes(round),expected,edition..' backward host DST jump cannot freeze game RTC') + shift=0 + T.eq(C.setTime(round,7,15),true,edition..' explicit edit reanchors current native clock') + T.eq(C.weekday(round),day,edition..' editing time preserves the chosen current weekday') + T.eq(C.dateKey(round),calendar,edition..' editing time preserves current game calendar date') + T.eq(C.hour(round),7,edition..' edited hour visible') + T.eq(C.minute(round),15,edition..' edited minute visible') + now=now+2*86400+45*60 + T.eq(C.weekday(round),(day+2)%7,edition..' future days follow new anchor') + T.eq(C.minutes(round),8*60,edition..' new anchor keeps ticking') + local oldDate=C.dateKey(round);C.setWeekday(round,0) + T.eq(C.weekday(round),0,edition..' weekday editing after offline elapsed days is immediate') + T.eq(C.dateKey(round),oldDate,edition..' weekday wheel does not rewrite calendar age') + C.setTime(round,23,59);now=now+60 + T.eq(C.weekday(round),1,edition..' edited weekday and time cross next midnight together') + -- The weekly service has to expire on the selected in-game Friday, not a + -- host Friday that is several hours out of phase. + now=origin;local lottery={flags={}};C.setTime(lottery,23,59);C.setWeekday(lottery,4) + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' fresh lottery remains active Thursday') + now=now+60 + T.eq(C.weekday(lottery),5,edition..' weekly service reaches selected Friday') + T.eq(D.lotteryExpired(lottery),true,edition..' native lottery expires at game Friday midnight') + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' Friday draw waits for next Friday') + now=now+6*86400 + T.eq(D.lotteryExpired(lottery),false,edition..' lottery does not expire a day early') + now=now+86400 + T.eq(D.lotteryExpired(lottery),true,edition..' next game Friday expires weekly draw') + -- Actual subscription dispatch must observe a game midnight even when + -- the host weekday is unchanged, and an offline full week too. + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/day/manifest.json']=Json.encode({id='day',name='Native midnight observer',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/day/main.lua']=[[ + local mod=...;mod.exports.events={} + local Clock=require('src.core.gen2.Clock') + mod.events:on('clock.day_changed',function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99 + end) + ]]} + local loaded=T.sdk.loadMods({'mods/day'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,edition..' real loader admits midnight observer') + local export=loaded.loader.exports.day;local own={};export.save=own + now=origin;C.setTime(own,23,59);C.setWeekday(own,4) + -- Explicit staging changes may emit; only changes after the first settled + -- observation are under test here. + C.weekday(own);local n=#export.events;now=now+60;C.weekday(own) + T.eq(#export.events,n+1,edition..' midnight event dispatched once through actual runtime') + T.eq(export.events[n+1].day,5,edition..' midnight event carries new game weekday') + T.eq(export.events[n+1].previous,4,edition..' midnight event retains previous game weekday') + C.weekday(own);T.eq(#export.events,n+1,edition..' reentrant/repeated reads cannot repeat midnight') + now=now+7*86400;C.weekday(own) + T.eq(#export.events,n+2,edition..' offline full week still emits one observed day change') + T.eq(export.events[n+2].day,5,edition..' full-week event correctly has same weekday') + require('src.mods.Loader').teardown();loaded.release() + end + -- Old offsets and old saves remain read-only on load, without introducing + -- an epoch guessed from today's time into another player's save. + local old={rtc={startMinute=offset,startDay=2},g2DayOffset=2} + local bytes=Json.encode(old) + T.eq(C.minutes(old),(C.hostMinutes()+offset)%1440,edition..' old minute offset retains exact semantics') + T.eq(C.weekday(old),(C.hostWeekday()+2)%7,edition..' old weekday offset retains exact semantics') + C.dateKey(old);T.eq(Json.encode(old),bytes,edition..' old save reads do not migrate or mutate state') + local malformed={rtc={anchorTime='bad',anchorMinute=0,anchorDay=0,startMinute=0}} + T.eq(C.minutes(malformed),C.hostMinutes(),edition..' malformed optional anchor falls back to old clock') +end +os.date=date;os.time=time;C.resetObservations();V.set(version) +T.finish('native RTC midnight, offline calendar, host DST isolation and actual daily/lottery lifecycle') From 5c1eafa6c51e6a4c4c7dd6cdbcc7b6c0da816ea9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:47:37 +0100 Subject: [PATCH 11/64] Set the Gen2 clock through the native introduction and weekday screen --- docs/native-gen2-clock.md | 4 +- docs/native-gen2-init-clock.md | 37 +++++++ src/core/ModCompat.lua | 1 + src/script/Gen2Commands.lua | 50 +++------ src/ui/Gen2InitClock.lua | 176 +++++++++++++++++++++++++++++++ src/ui/OakSpeech.lua | 25 ++++- tests/engine/gen2_init_clock.lua | 129 ++++++++++++++++++++++ 7 files changed, 380 insertions(+), 42 deletions(-) create mode 100644 docs/native-gen2-init-clock.md create mode 100644 src/ui/Gen2InitClock.lua create mode 100644 tests/engine/gen2_init_clock.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index 5fc23558a..8efd85550 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -65,7 +65,9 @@ card. These checks do not establish a complete original-story clock-setting sequence, manual DST-setting/check dialogs, battery-backed SRAM RTC conversion, all daily services or fresh GPU acceptance for this change. Automatic host DST isolation is covered; the original story clock-setting -screen and remaining manual DST lifecycle still need native implementation. +screen now has a native implementation described in +[native-gen2-init-clock.md](native-gen2-init-clock.md); fresh original-story/GPU +acceptance and the remaining manual DST lifecycle are separately gated. The public contract is compared with the read-only Gen1Recomp reference; the implementation is native fork code. No VASC implementation, private diff --git a/docs/native-gen2-init-clock.md b/docs/native-gen2-init-clock.md new file mode 100644 index 000000000..058717d8a --- /dev/null +++ b/docs/native-gen2-init-clock.md @@ -0,0 +1,37 @@ +# Native Gen2 initial clock and weekday selection + +Gold, Silver and Crystal now open their native Oak speech with an +`init_clock` step. This opens `Gen2InitClock` before the professor's welcome +and music. The required hour and minute pickers wrap in both directions, +retain their selection after NO, read the complete time back, and commit to +the shared saved RTC only after the final acknowledgement. Completion resumes +the original Oak parent once. Other Gen2 introductions keep their existing +step list. + +The native `SetDayOfWeek` special uses the same screen in day mode. It opens +on Sunday and remains suspended through selection and confirmation. NO +returns to the chosen value without writing the save or resuming the script; +YES writes through `Gen2Clock.setWeekday` and resumes the original runner once. + +`src.ui.gen2.InitClock` resolves to this actual native screen. Its constructor +accepts mode, save, hour/minute/day, onDone and autoConfirm options. Common +hour/time/response formatting helpers use the native clock labels. Native +TextBox and ChoiceBox own text paging, typing and YES/NO input; the clock +screen owns picker state, transitions, save commitment and optional fades. + +Dialogue is read from the active imported native text bank. Native wrapper +and underscore text spellings are supported; short engine prompts are used +only if an older cache lacks a line. No ROM dialogue, artwork, palette or +coordinate table is embedded in the implementation. The current panel layout +is engine-owned. This is not a claim of exact reference raster/layout helper +parity; the remaining reference drawing/metadata helpers and original clock +art require separate work. + +ROM-free coverage exercises the actual native widgets and stack, wrapped +selection and NO retries, final acknowledgement, save-codec retention, the +real special compiler/ScriptRunner yield/resume, the real Oak step and loaded +native API1/2 clients. These tests do not establish fresh +GPU or complete natural new-game/story acceptance. Manual DST dialogs/checks, +SRAM RTC conversion, broader story/hardware acceptance and full VASC/Gen1 +parity remain open. Private VASC code and protected Gen1/player instances are +unchanged. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 420182efc..1ccf52cb4 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -68,6 +68,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", ["src.core.gen2.Clock"] = "src.core.Gen2Clock", + ["src.ui.gen2.InitClock"] = "src.ui.Gen2InitClock", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index d41e01d32..4de6372fc 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2879,45 +2879,21 @@ function Commands.g2_clear_dst(ctx) Commands.show_text(ctx, "InitialClearDSTFlag.Text") end --- The ROM spins these with the d-pad on a graphical clock-set background; --- a list menu is the port's equivalent and needs no ROM tiles. The names --- are engine text, not ROM strings: SetDayOfWeek.WeekdayStrings is raw --- charmap data, and the extractor only harvests symbols matching "Text". -local WEEKDAYS = { - "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", -} - --- `special SetDayOfWeek` ($25), the day picker in mom's clock talk. The GB --- has to ask because its RTC has no idea what day it started on; here --- readvar 11 reads the host clock, which already knows. So the answer is --- kept as an OFFSET from the host day rather than as the day itself: the --- cursor opens on today, a truthful answer stores 0 and changes nothing, and --- either way the day still rolls over at real midnight instead of freezing. +-- Native SetDayOfWeek shares the clock-setting screen and RTC owner with +-- the introduction. The script remains suspended through both selection +-- and confirmation; declining does not write the save or resume the script. function Commands.g2_set_day_of_week(ctx) local game, runner = ctx.game, ctx.runner - local Clock = require("src.core.Gen2Clock") - local current = Clock.weekday(ctx.save) - local picked - repeat - Commands.show_text(ctx, "SetDayOfWeek.OakTimeWhatDayIsItText") - local items = {} - for index, day in ipairs(WEEKDAYS) do - items[index] = { - label = Strings(day), - onSelect = function() picked = index - 1 runner:resume() end, - } - end - -- no cancel: the ROM's loop watches A only, and onCancel is the only - -- other path back to runner:resume() - local menu = require("src.ui.Menu").new(game, items, { cancelable = false }) - menu.index = current + 1 - menu:clampScroll() - game.stack:push(menu) - runner:yield() - Commands.ask(ctx, Strings(WEEKDAYS[picked + 1]) - .. resolvedText(ctx, "SetDayOfWeek.OakTimeIsItText")) - until ctx.lastCheck - Clock.setWeekday(ctx.save, picked) + local screen + screen = require("src.ui.Gen2InitClock").new(game, { mode="day", save=ctx.save, + onDone=function() + if game.stack:top() == screen then game.stack:pop() end + ctx.lastCheck = true + runner:resume() + end, + }) + game.stack:push(screen) + runner:yield() end -- UnownPuzzle (SpecialsPointers row 41). The chamber scripts run diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua new file mode 100644 index 000000000..e98d9cc6c --- /dev/null +++ b/src/ui/Gen2InitClock.lua @@ -0,0 +1,176 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +-- Native clock/day selection over the shared saved RTC and native widgets. +-- Dialogue comes from the active ROM cache; this layout is engine-owned. +local Clock = require("src.core.Gen2Clock") +local Font = require("src.render.Font") +local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") +local InitClock = {} +InitClock.__index = InitClock +InitClock.isOpaque = true +local function value(n, fallback, limit) + n = tonumber(n) + if not n or n ~= n or n == math.huge or n == -math.huge then return fallback end + return math.floor(n) % limit +end +local function text(self, key, fallback) + local bank = self.game and self.game.data and self.game.data.text or {} + local found = bank[key] or bank["_" .. key:match("[^.]+$")] + if type(found) == "string" and found ~= "" then return found end + return Strings(fallback) +end +function InitClock.hourString(hour) + hour = value(hour, 0, 24) + local shown = hour % 12 + return ("%s %d"):format(Clock.daytimeLabel(hour), shown == 0 and 12 or shown) +end +function InitClock.oclockString(hour) return Strings("%s o'clock", InitClock.hourString(hour)) end +function InitClock.timeString(hour, minute) + return ("%s:%02d"):format(InitClock.hourString(hour), value(minute, 0, 60)) +end +function InitClock.responseKey(hour) + hour = value(hour, 0, 24) + if hour < 4 or hour >= 18 then return "soDark" end + return hour <= 10 and "overslept" or "yikes" +end +function InitClock.new(game, opts) + opts = opts or {} + local day = opts.mode == "day" + return setmetatable({ game=game, save=opts.save or game.save, + mode=day and "day" or "clock", phase=day and "day" or "intro", + isOpaque=not day, onDone=opts.onDone, autoConfirm=opts.autoConfirm == true, + fades=opts.fades == true and not day and not opts.autoConfirm, + hour=value(opts.hour, Clock.DEFAULT_HOUR, 24), + minute=value(opts.minute, Clock.DEFAULT_MINUTE, 60), day=value(opts.day, 0, 7), + }, InitClock) +end +function InitClock:wantsFillScale() return self.mode ~= "day" end +function InitClock:question() + if self.phase == "intro" then return text(self, "OakTimeWokeUpText", "SET THE CLOCK") end + if self.phase == "hour" then return text(self, "OakTimeWhatTimeIsItText", "CHOOSE HOUR") end + if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", "CHOOSE MINUTE") end + if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", "CHOOSE DAY") end + if self.phase == "confirm-hour" then + return text(self, "OakTimeWhatHoursText", "CONFIRM HOUR") .. "\n" + .. InitClock.oclockString(self.hour) + .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", "?") + end + if self.phase == "confirm-minute" then + return text(self, "OakTimeWhoaMinutesText", "CONFIRM MINUTE") .. "\n" + .. Strings("%d min.", self.minute) + .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", "?") + end + if self.phase == "confirm-day" then + return Clock.weekdayName(self.day + 1) + .. text(self, "SetDayOfWeek.OakTimeIsItText", "?") + end + local response = { soDark="OakTimeSoDarkText", overslept="OakTimeOversleptText", yikes="OakTimeYikesText" } + if self.phase == "response" then + return InitClock.timeString(self.hour, self.minute) + .. text(self, "OakText_ResponseToSetTime." .. response[InitClock.responseKey(self.hour)], "\nCLOCK SET") + end + return "" +end +function InitClock:display() + if self.phase == "hour" then return InitClock.oclockString(self.hour) end + if self.phase == "minute" then return Strings("%d min.", self.minute) end + if self.phase == "day" then return Clock.weekdayName(self.day + 1) end +end +function InitClock:confirming() return self.phase:match("^confirm%-") ~= nil end +function InitClock:showPhase() + if self.autoConfirm or self.done then return end + if self.phase == "intro" or self.phase == "response" then + self.game.stack:push(TextBox.new(self.game, self:question(), function() self:accept() end)) + elseif self:confirming() then + self.game.stack:push(TextBox.new(self.game, self:question(), nil, { + choice=function(yes) if yes then self:accept() else self:decline() end end, + })) + end +end +function InitClock:enter() + if self.fades then self.fade = { out=false, frame=0 } else self:showPhase() end +end +function InitClock:complete() + if self.done then return end + self.done = true + if self.onDone then + if self.mode == "day" then self.onDone(self.day) else self.onDone(self.hour, self.minute) end + elseif self.game.stack:top() == self then + self.game.stack:pop() + end +end +function InitClock:finish() + if self.done or self.finishing then return end + local ok + if self.mode == "day" then ok = Clock.setWeekday(self.save, self.day) + else ok = Clock.setTime(self.save, self.hour, self.minute) end + if not ok then return end + self.finishing = true + if self.fades then self.fade = { out=true, frame=0 } else self:complete() end +end +function InitClock:accept() + if self.done or self.finishing then return end + local nextPhase = { intro="hour", hour="confirm-hour", ["confirm-hour"]="minute", + minute="confirm-minute", ["confirm-minute"]="response", day="confirm-day" } + local next = nextPhase[self.phase] + if next then self.phase=next;self:showPhase() else self:finish() end +end +function InitClock:decline() + if self.done or self.finishing then return end + local picker = self.phase:match("^confirm%-(.+)$") + if picker then self.phase=picker;self:showPhase() end +end +function InitClock:value() + if self.phase == "hour" then return self.hour, 23 end + if self.phase == "minute" then return self.minute, 59 end + return self.day, 6 +end +function InitClock:step(delta) + local current, last = self:value() + local key = self.phase + if key == "hour" or key == "minute" or key == "day" then self[key]=(current+delta)%(last+1) end +end +function InitClock:update() + if self.done then return end + if self.fade then + local fade = self.fade + fade.frame = fade.frame + 1 + if fade.frame >= 8 then + self.fade = nil + if fade.out then self:complete() else self:showPhase() end + end + return + end + if self.finishing then return end + if self.autoConfirm then self:accept();return end + local input = self.game.input + if input:wasPressed("up") then self:step(1) + elseif input:wasPressed("down") then self:step(-1) + elseif input:wasPressed("a") then self:accept() end +end +function InitClock:sgbPalettes() + local P = require("src.render.PaletteFX") + if self.isOpaque then return { P.trueColorZone(0, 0, 19, 17) } end + return { P.trueColorZone(1, 4, 18, 9), P.trueColorZone(0, 12, 19, 17) } +end +function InitClock:draw() + local G = love.graphics + G.setColor(1, 1, 1, 1) + if self.isOpaque then G.rectangle("fill", 0, 0, 160, 144) end + Font.drawBox(1, 4, 18, 6) + local shown = self:display() or InitClock.timeString(self.hour, self.minute) + Font.draw(shown, 16, 48) + Font.draw(Strings("UP/DOWN: CHANGE"), 16, 64) + Font.draw(Strings("A: CONFIRM"), 16, 80) + if self:display() then + Font.drawBox(0, 12, 20, 6) + Font.draw(self:question(), 8, 105) + end + if self.fade then + local alpha = self.fade.frame / 8 + G.setColor(0, 0, 0, self.fade.out and alpha or 1-alpha) + G.rectangle("fill", 0, 0, 160, 144) + end + G.setColor(1, 1, 1, 1) +end +return InitClock diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index ed0f11d16..d2bcda421 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -281,7 +281,7 @@ end -- over a Gen2 save. The keys are the ROM's own text labels, so textOr finds -- them in data.text instead of falling through. function OakSpeech.gen2Steps() - return { + local steps = { { id = "oak_welcome", kind = "say", @@ -384,6 +384,11 @@ function OakSpeech.gen2Steps() kind = "shrink", }, } + local version = GameVersion.get() + if version == "gold" or version == "silver" or version == "crystal" then + table.insert(steps, 1, { id="init_clock", kind="initclock" }) + end + return steps end -- list helpers for intro.oak_speech.build wrappers (also on ModUI) @@ -489,10 +494,12 @@ function OakSpeech:buildSteps() end function OakSpeech:enter() - -- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48) - Music.play(self.game.data, self.cfg.music or "Music_Routes2") self.answers = {} self.steps = self:buildSteps() + -- The retail clock comes before the professor and his music. + if not (self.steps[1] and self.steps[1].kind == "initclock") then + Music.play(self.game.data, self.cfg.music or "Music_Routes2") + end if Runtime.wants("intro.oak_speech.started") then Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps }) end @@ -660,7 +667,17 @@ end function OakSpeech:runStep(step) local kind = step.kind or "say" - if kind == "say" then + if kind == "initclock" then + local screen + screen = require("src.ui.Screens").push(self.game, "Gen2InitClock", { + save=self.game.save, fades=true, + onDone=function() + if self.game.stack:top() == screen then self.game.stack:pop() end + Music.play(self.game.data, self.cfg.music or "Music_Routes2") + self:advance() + end, + }) + elseif kind == "say" then self:applyPic(step) self:afterReveal(step, function() self:runCry(step) diff --git a/tests/engine/gen2_init_clock.lua b/tests/engine/gen2_init_clock.lua new file mode 100644 index 000000000..c8ebbbe0d --- /dev/null +++ b/tests/engine/gen2_init_clock.lua @@ -0,0 +1,129 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local UI=require('src.ui.Gen2InitClock');local Clock=require('src.core.Gen2Clock') +local Stack=require('src.core.StateStack');local Box=require('src.render.TextBox');local Choice=require('src.ui.ChoiceBox') +local Timing=require('src.core.Timing');local Screens=require('src.ui.Screens') +local Json=require('src.link.Json');local Save=require('src.core.SaveData') +local function game() + local g={data=T.fixtures.fresh(),save={player={name='QA'},flags={},options={}},input={queue={}}} + g.input.wasPressed=function(self,k)return self.queue[k]==true end;g.input.isDown=function()return false end + g.stack=setmetatable({}, {__index=Stack});g.stack:init() + g.data.text={OakTimeWokeUpText='QA opening page.\fQA next page.',OakTimeWhatTimeIsItText='QA choose hour.', + OakTimeHowManyMinutesText='QA choose minute.',OakTimeWhatHoursText='QA hour',OakTimeWhoaMinutesText='QA minute', + ['OakTimeWhatHoursText.OakTimeHoursQuestionMarkText']='?', + ['OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText']='?', + ['OakText_ResponseToSetTime.OakTimeSoDarkText']=' QA night response.', + ['SetDayOfWeek.OakTimeWhatDayIsItText']='QA choose weekday.',['SetDayOfWeek.OakTimeIsItText']=' QA confirm?'} + return g +end +local function tick(g,key,n) + g.input.queue=key and {[key]=true}or{} + for _=1,n or 1 do g.stack:update(1/60)end + g.input.queue={} +end +local function untilState(g,fn,label) + for _=1,2500 do if fn()then return end;tick(g,'a')end + error('native widgets did not reach '..label) +end +local function picker(g,screen,phase) + untilState(g,function()return g.stack:top()==screen and screen.phase==phase and not screen.fade end,phase) +end +local function choice(g) + untilState(g,function()return getmetatable(g.stack:top())==Choice end,'YES/NO over native question') + return g.stack:top() +end +local function answer(g,yes) + choice(g);tick(g,yes and 'a'or'b');tick(g,nil,Timing.YES_NO_ANSWER) +end +T.eq(require('src.ui.gen2.InitClock'),UI,'public reference spelling resolves actual native screen') +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);local label=edition..' ' + T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'init_clock',label..' native speech opens with clock') + local g=game();local n=0;local parent={};g.stack:push(parent) + local ui=Screens.push(g,'Gen2InitClock',{fades=true,onDone=function()n=n+1;g.stack:pop()end}) + T.eq(getmetatable(ui),UI,label..' actual native screen factory used') + T.eq(Clock.isSet(g.save),false,label..' opening screen cannot set clock') + picker(g,ui,'hour') + T.eq(ui:question(),'QA choose hour.',label..' question uses imported native text') + T.eq(ui.hour,10,label..' initial picker has native default') + tick(g,'b');T.eq(g.stack:top(),ui,label..' B cannot abandon required picker') + for _=1,10 do tick(g,'down')end + tick(g,'down');T.eq(ui.hour,23,label..' hour wraps backwards through midnight') + tick(g,'up');T.eq(ui.hour,0,label..' hour wraps forward through midnight') + tick(g,'down');tick(g,'a');local c=choice(g) + T.eq(getmetatable(g.stack.states[#g.stack.states-1]),Box,label..' confirmation retains question under choice') + T.eq(Clock.isSet(g.save),false,label..' hour confirmation has not committed clock') + answer(g,false);T.eq(ui.phase,'hour',label..' declining returns to picker') + T.eq(ui.hour,23,label..' declining retains selected hour') + tick(g,'a');answer(g,true);picker(g,ui,'minute') + tick(g,'down');T.eq(ui.minute,59,label..' minute wraps backwards') + tick(g,'up');T.eq(ui.minute,0,label..' minute wraps forwards') + tick(g,'down');tick(g,'a');answer(g,false) + T.eq(ui.phase,'minute',label..' declining minutes returns without saving') + T.eq(Clock.isSet(g.save),false,label..' declining leaves save unchanged') + tick(g,'a');answer(g,true) + T.eq(ui.phase,'response',label..' both confirmed values are read back') + T.eq(ui:question():find('QA night response.',1,true)~=nil,true,label..' response uses imported native fragment') + T.eq(Clock.isSet(g.save),false,label..' response acknowledgement still owns commitment') + untilState(g,function()return n==1 end,'clock completion') + T.eq(g.stack:top(),parent,label..' completion restores exact parent') + T.eq(Clock.hour(g.save),23,label..' confirmed hour reaches native RTC') + T.eq(Clock.minute(g.save),59,label..' confirmed minute reaches native RTC') + local round=assert(Save.decode(Save.encode(g.save))) + T.eq(Clock.minute(round),59,label..' normal save codec preserves confirmed RTC') + ui:finish();ui:complete();T.eq(n,1,label..' duplicate finish cannot repeat callback') + -- Real special lowering and coroutine runner stay suspended through NO. + g=game();local Runner=require('src.script.ScriptRunner');local runner=Runner.new(g,{}) + local rows=assert(require('src.script.Gen2ScriptVM').compile({map_scripts={scripts={qa={ + {'special',37,'SetDayOfWeek'},{'end'}}}}},'qa')) + T.eq(#Runner.validate(rows),0,label..' native special lowers to valid production commands') + local completed=0;runner:run(rows,{onDone=function()completed=completed+1 end}) + local day=g.stack:top();T.eq(getmetatable(day),UI,label..' actual special opens shared native day mode') + T.eq(runner:isRunning(),true,label..' actual script waits for selection') + T.eq(day.day,0,label..' native day picker opens on Sunday') + tick(g,'down');T.eq(day.day,6,label..' weekday wraps backwards') + tick(g,'a');answer(g,false) + T.eq(runner:isRunning(),true,label..' NO cannot resume original script') + T.eq(g.save.rtc,nil,label..' NO cannot write weekday anchor') + tick(g,'up');T.eq(day.day,0,label..' weekday wraps forwards') + tick(g,'a');answer(g,true) + T.eq(Clock.weekday(g.save),0,label..' accepted special writes shared weekday') + T.eq(runner:isRunning(),false,label..' accepted special resumes real runner') + T.eq(completed,1,label..' real runner completes once') + day:finish();T.eq(completed,1,label..' repeated special finish cannot resume twice') + -- The production Oak step must yield to the same child and advance once. + g=game();local advanced=0;local speech={game=g,cfg={},advance=function()advanced=advanced+1 end} + g.stack:push(parent);require('src.ui.OakSpeech').runStep(speech,{kind='initclock'}) + local child=g.stack:top();T.eq(getmetatable(child),UI,label..' real Oak step opens native clock') + picker(g,child,'hour');tick(g,'a');answer(g,true);picker(g,child,'minute') + tick(g,'a');answer(g,true);untilState(g,function()return advanced==1 end,'Oak resumes') + T.eq(g.stack:top(),parent,label..' Oak clock completion preserves parent ownership') + T.eq(Clock.isSet(g.save),true,label..' actual Oak continuation follows committed RTC') + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/setclock/manifest.json']=Json.encode({id='setclock',name='Native clock UI client',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/setclock/main.lua']=[[ + local mod=...;local Screen=require('src.ui.gen2.InitClock') + mod.exports.make=function(game)return Screen.new(game,{autoConfirm=true})end + ]]} + local loaded=T.sdk.loadMods({'mods/setclock'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,label..' actual loader admits native clock UI client') + local own=game();local screen=loaded.loader.exports.setclock.make(own) + T.eq(getmetatable(screen),UI,label..' loaded client uses native owner instead of a stub') + own.stack:push(screen) + untilState(own,function()return screen.done end,'loaded clock UI completion') + T.eq(Clock.isSet(own.save),true,label..' loaded UI client commits native RTC') + T.eq(Clock.hour(own.save),10,label..' loaded UI client keeps native default hour') + require('src.mods.Loader').teardown();loaded.release() + end +end +for _,edition in ipairs({'prism','polishedcrystal'})do + V.set(edition);T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'oak_welcome',edition..' keeps its own introduction') +end +for _,hour in ipairs({0,3,4,10,11,17,18,23})do + local response=UI.responseKey(hour) + T.eq(response,(hour<4 or hour>=18)and'soDark'or(hour<=10 and'overslept'or'yikes'),'hour response boundary '..hour) +end +V.set(version);Clock.resetObservations() +T.finish('native initial clock/day widgets, original Oak admission and actual script continuation') From b2572b8aeb85cdb4a65e302fcd6b00bd0154a87a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:51:55 +0100 Subject: [PATCH 12/64] Register native clock fallback prompts for translation extraction --- src/ui/Gen2InitClock.lua | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua index e98d9cc6c..8c5e9e7a2 100644 --- a/src/ui/Gen2InitClock.lua +++ b/src/ui/Gen2InitClock.lua @@ -8,6 +8,12 @@ local TextBox = require("src.render.TextBox") local InitClock = {} InitClock.__index = InitClock InitClock.isOpaque = true +local TEXT = { + intro=Strings.source("SET THE CLOCK"), hour=Strings.source("CHOOSE HOUR"), + minute=Strings.source("CHOOSE MINUTE"), day=Strings.source("CHOOSE DAY"), + confirmHour=Strings.source("CONFIRM HOUR"), confirmMinute=Strings.source("CONFIRM MINUTE"), + questionMark=Strings.source("?"), response=Strings.source("\nCLOCK SET"), +} local function value(n, fallback, limit) n = tonumber(n) if not n or n ~= n or n == math.huge or n == -math.huge then return fallback end @@ -46,28 +52,28 @@ function InitClock.new(game, opts) end function InitClock:wantsFillScale() return self.mode ~= "day" end function InitClock:question() - if self.phase == "intro" then return text(self, "OakTimeWokeUpText", "SET THE CLOCK") end - if self.phase == "hour" then return text(self, "OakTimeWhatTimeIsItText", "CHOOSE HOUR") end - if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", "CHOOSE MINUTE") end - if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", "CHOOSE DAY") end + if self.phase == "intro" then return text(self, "OakTimeWokeUpText", TEXT.intro) end + if self.phase == "hour" then return text(self, "OakTimeWhatTimeIsItText", TEXT.hour) end + if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", TEXT.minute) end + if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", TEXT.day) end if self.phase == "confirm-hour" then - return text(self, "OakTimeWhatHoursText", "CONFIRM HOUR") .. "\n" + return text(self, "OakTimeWhatHoursText", TEXT.confirmHour) .. "\n" .. InitClock.oclockString(self.hour) - .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", "?") + .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", TEXT.questionMark) end if self.phase == "confirm-minute" then - return text(self, "OakTimeWhoaMinutesText", "CONFIRM MINUTE") .. "\n" + return text(self, "OakTimeWhoaMinutesText", TEXT.confirmMinute) .. "\n" .. Strings("%d min.", self.minute) - .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", "?") + .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", TEXT.questionMark) end if self.phase == "confirm-day" then return Clock.weekdayName(self.day + 1) - .. text(self, "SetDayOfWeek.OakTimeIsItText", "?") + .. text(self, "SetDayOfWeek.OakTimeIsItText", TEXT.questionMark) end local response = { soDark="OakTimeSoDarkText", overslept="OakTimeOversleptText", yikes="OakTimeYikesText" } if self.phase == "response" then return InitClock.timeString(self.hour, self.minute) - .. text(self, "OakText_ResponseToSetTime." .. response[InitClock.responseKey(self.hour)], "\nCLOCK SET") + .. text(self, "OakText_ResponseToSetTime." .. response[InitClock.responseKey(self.hour)], TEXT.response) end return "" end From 5fb4cc1da535b94e235925cbdc470f3d832ae6ef Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:56:58 +0100 Subject: [PATCH 13/64] Keep native clock text separators outside translatable prompts --- src/ui/Gen2InitClock.lua | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua index 8c5e9e7a2..6b89d1a86 100644 --- a/src/ui/Gen2InitClock.lua +++ b/src/ui/Gen2InitClock.lua @@ -57,14 +57,16 @@ function InitClock:question() if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", TEXT.minute) end if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", TEXT.day) end if self.phase == "confirm-hour" then - return text(self, "OakTimeWhatHoursText", TEXT.confirmHour) .. "\n" - .. InitClock.oclockString(self.hour) - .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", TEXT.questionMark) + local lines = { text(self, "OakTimeWhatHoursText", TEXT.confirmHour), + InitClock.oclockString(self.hour) + .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", TEXT.questionMark) } + return table.concat(lines, "\n") end if self.phase == "confirm-minute" then - return text(self, "OakTimeWhoaMinutesText", TEXT.confirmMinute) .. "\n" - .. Strings("%d min.", self.minute) - .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", TEXT.questionMark) + local lines = { text(self, "OakTimeWhoaMinutesText", TEXT.confirmMinute), + Strings("%d min.", self.minute) + .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", TEXT.questionMark) } + return table.concat(lines, "\n") end if self.phase == "confirm-day" then return Clock.weekdayName(self.day + 1) From d4f6aa5043a2409abacccc46ff7e52b4c03f2fad Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:06:59 +0100 Subject: [PATCH 14/64] Preserve other editions intro music and build-hook ordering --- src/ui/OakSpeech.lua | 6 +++++- tests/engine/gen2_init_clock.lua | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index d2bcda421..87fda58a2 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -494,10 +494,14 @@ function OakSpeech:buildSteps() end function OakSpeech:enter() + local version = GameVersion.get() + local clockIntro = version == "gold" or version == "silver" or version == "crystal" + -- Other introductions retain their original music-before-build ordering. + if not clockIntro then Music.play(self.game.data, self.cfg.music or "Music_Routes2") end self.answers = {} self.steps = self:buildSteps() -- The retail clock comes before the professor and his music. - if not (self.steps[1] and self.steps[1].kind == "initclock") then + if clockIntro and not (self.steps[1] and self.steps[1].kind == "initclock") then Music.play(self.game.data, self.cfg.music or "Music_Routes2") end if Runtime.wants("intro.oak_speech.started") then diff --git a/tests/engine/gen2_init_clock.lua b/tests/engine/gen2_init_clock.lua index c8ebbbe0d..15e36e5cb 100644 --- a/tests/engine/gen2_init_clock.lua +++ b/tests/engine/gen2_init_clock.lua @@ -118,6 +118,25 @@ for _,edition in ipairs({'gold','silver','crystal'})do require('src.mods.Loader').teardown();loaded.release() end end +-- The shared Oak class also serves other editions. Preserve their existing +-- music-before-build contract rather than changing Gen1 while adding GSC. +local Music=require('src.core.Music');local play=Music.play;local order +Music.play=function(...)order[#order+1]='music';return play(...)end +for _,edition in ipairs({'red','blue','yellow','prism','polishedcrystal','gold','silver','crystal'})do + V.set(edition);order={} + local native=edition=='gold'or edition=='silver'or edition=='crystal' + local speech={game=game(),cfg={},buildSteps=function() + order[#order+1]='build';return {{kind=native and'initclock'or'say'}} + end,advance=function()order[#order+1]='advance'end} + require('src.ui.OakSpeech').enter(speech) + T.same(order,native and{'build','advance'}or{'music','build','advance'},edition..' preserves appropriate native music/build ordering') + if native then + order={};speech.buildSteps=function()order[#order+1]='build';return {{kind='say'}}end + require('src.ui.OakSpeech').enter(speech) + T.same(order,{'build','music','advance'},edition..' explicit clock removal still starts professor music') + end +end +Music.play=play for _,edition in ipairs({'prism','polishedcrystal'})do V.set(edition);T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'oak_welcome',edition..' keeps its own introduction') end From ae0384d3088d39b1198e0d11daba9fa87c6bf0fc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:08:32 +0100 Subject: [PATCH 15/64] Keep clock picker hints inside their panel and hide inactive controls --- src/ui/Gen2InitClock.lua | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua index 6b89d1a86..bf37c8436 100644 --- a/src/ui/Gen2InitClock.lua +++ b/src/ui/Gen2InitClock.lua @@ -159,18 +159,19 @@ end function InitClock:sgbPalettes() local P = require("src.render.PaletteFX") if self.isOpaque then return { P.trueColorZone(0, 0, 19, 17) } end - return { P.trueColorZone(1, 4, 18, 9), P.trueColorZone(0, 12, 19, 17) } + return { P.trueColorZone(1, 4, 18, 11), P.trueColorZone(0, 12, 19, 17) } end function InitClock:draw() local G = love.graphics G.setColor(1, 1, 1, 1) if self.isOpaque then G.rectangle("fill", 0, 0, 160, 144) end - Font.drawBox(1, 4, 18, 6) - local shown = self:display() or InitClock.timeString(self.hour, self.minute) + Font.drawBox(1, 4, 18, 8) + local picker = self:display() + local shown = picker or InitClock.timeString(self.hour, self.minute) Font.draw(shown, 16, 48) - Font.draw(Strings("UP/DOWN: CHANGE"), 16, 64) - Font.draw(Strings("A: CONFIRM"), 16, 80) - if self:display() then + if picker then + Font.draw(Strings("UP/DOWN: CHANGE"), 16, 64) + Font.draw(Strings("A: CONFIRM"), 16, 80) Font.drawBox(0, 12, 20, 6) Font.draw(self:question(), 8, 105) end From 9db5531ad433f3e297ab84fe33724e44c0af691b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:46:18 +0100 Subject: [PATCH 16/64] Share native BGP ownership and remap already-coloured palette frames --- docs/NATIVE-GEN2-PALETTE-REGISTER.md | 66 +++++++++++ src/render/Gen2PaletteDraw.lua | 154 +++++++++++++++++++++++-- src/render/PaletteFX.lua | 46 ++++++-- tests/engine/gen2_palette_draw.lua | 2 +- tests/engine/gen2_palette_register.lua | 97 ++++++++++++++++ tests/gpu/gen2_palette_register.lua | 89 ++++++++++++++ 6 files changed, 439 insertions(+), 15 deletions(-) create mode 100644 docs/NATIVE-GEN2-PALETTE-REGISTER.md create mode 100644 tests/engine/gen2_palette_register.lua create mode 100644 tests/gpu/gen2_palette_register.lua diff --git a/docs/NATIVE-GEN2-PALETTE-REGISTER.md b/docs/NATIVE-GEN2-PALETTE-REGISTER.md new file mode 100644 index 000000000..10d840c11 --- /dev/null +++ b/docs/NATIVE-GEN2-PALETTE-REGISTER.md @@ -0,0 +1,66 @@ +# Native palette register and frame remapping + +The public `src.render.GbcPalette` module resolves to the engine's +`Gen2PaletteDraw`. Its byte-register API shares `PaletteFX`'s native shade map; +there is no second register owned by a compatibility facade. + +## Forward drawing + +`BGP_IDENTITY` is `0xe4`. `bgpShades(byte)` returns the four two-bit fields, +with colour zero first, in a one-based array. `remap(colors, byte)` applies +those indices to a four-row RGB palette. Nil and identity preserve the original +palette table. + +`setBgp(byte)` returns the previous byte. Identity is represented by nil. +Reading or assigning `GbcPalette.bgp` accesses this same native state; native +`PaletteFX.setShadeMap` writes are immediately visible through it. The existing +native frame reset clears it as well. + +`resolve(colors)` selects the display palette without applying the register. +`color`, `use`, `with` and keyed draws apply the current register once. +`useRaw` and `withRaw` bypass display selection and the register. Native +`PaletteFX.sendColors` consumes the same state. The separate native palette-upload optimization, when +present, consumes that state as well. + +## Already-coloured frames + +`remapTable(bgPalettes, byte, objPalettes, ramp, objRamped)` returns source +colours, destination colours, a live count and an ambiguity count. Tables hold +0–255 RGB triples, padded to `REMAP_MAX` (64) with detached rows. BG palettes +take the explicit byte permutation. OBJ palettes normally retain their colours. +An optional forced ramp supplies BG destinations; selected `objRamped` entries +take its unpermuted colours. + +Shared RGB values can no longer identify the original palette after composition. +The first BG interpretation wins; conflicting destinations increase the ambiguity +count. This is a limitation of remapping RGB frames, not proof of indexed +per-pixel equivalence. Extra entries beyond 64 are omitted with BG slots first. + +`remapUniforms(...)` builds normalized shader inputs, or returns nil for an empty +table. `useRemapUniforms(inputs)` and `useRemap(...)` bind the actual remap shader +and return `true, ambiguityCount` on success. Matching uses squared RGB distance +below `(3/255)^2`; unmatched pixels retain their RGB. Alpha and draw tint are +preserved. Compilation/upload failure returns false, retaining the previous +shader. A failed remap shader can recover when the graphics compiler changes. + +Public uniform uploads are unconditional. Nested `with`, `withRaw` and +`keyedWith` restore both shader identity and prior palette/remap uniforms, +including after a callback or partial upload fails. + +## Verification and remaining display work + +`tests/engine/gen2_palette_register.lua` checks all 256 bytes against independent +field decoding in Red, Gold, Silver and Crystal, native/public register ownership, +BG/OBJ remapping, collisions, forced ramps, bounds, uniform ownership and failures. +`tests/gpu/gen2_palette_register.lua` verifies actual pixels for all 256 bytes +through public and native shaders, including the optimized native upload path, +OBJ preservation, unrelated RGB, alpha and tint. Run `run()` from an isolated +LÖVE host separately on each backend. It requires no cartridge or private mod. + +This change does not implement full Gen1 display parity. Native Gen2 still needs +the DMG/Classic/custom display selections, their cached tile/sprite/battle +invalidation and option persistence. Those require the real rendering owners; +adding setters that only change a public mode string would not implement them. + +The public method protocol follows the Gen1Recomp reference interface. Existing +Gen1Recomp and upstream credits and licences remain applicable; see `NOTICE.md`. diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua index 8d3b93f2e..f84798c13 100644 --- a/src/render/Gen2PaletteDraw.lua +++ b/src/render/Gen2PaletteDraw.lua @@ -13,6 +13,10 @@ setmetatable(M, {__index = function(_, key) if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end return Palettes.mode == "classic" and "classic" or "gbc" end + if key == "bgp" then return Palettes.bgp() end +end, __newindex = function(self, key, value) + if key == "bgp" then Palettes.setBgp(value) + else rawset(self, key, value) end end}) local compiler, shaders, failed = nil, {}, {} local sent = setmetatable({}, {__mode = "k"}) @@ -27,26 +31,59 @@ vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { } ]] +-- A composited CGB frame has lost its palette indices. Match only colours +-- present in its BG/OBJ tables; unrelated RGB art must keep its own colour. +-- Constant loop bounds also work on GLSL ES uniform-array implementations. +M.REMAP_MAX = 64 +M.REMAP_TOLERANCE = (3 / 255) ^ 2 +local remapSource = [[ +extern int remapCount; +extern float remapTol; +extern vec3 remapSrc[64]; +extern vec3 remapDst[64]; +vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { + vec4 pixel = Texel(image, uv); + vec3 rgb = pixel.rgb; + float closest = remapTol; + for (int i = 0; i < 64; i++) { + if (i >= remapCount) break; + vec3 delta = pixel.rgb - remapSrc[i]; + float distanceSquared = dot(delta, delta); + if (distanceSquared < closest) { + closest = distanceSquared; + rgb = remapDst[i]; + } + } + return vec4(rgb, pixel.a) * tint; +} +]] + local function graphics() return love and love.graphics end -local function shader(keyed) +local function compile(key, code, keyed) local g = graphics() local newShader = g and g.newShader if type(newShader) ~= "function" then return nil end if compiler ~= newShader then compiler, shaders, failed = newShader, {}, {} end - local key = keyed and "keyed" or "normal" if failed[key] then return nil end if not shaders[key] then - local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" - local ok, value = pcall(newShader, (source:gsub("ALPHA", alpha))) + if key ~= "remap" then + local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" + code = (code:gsub("ALPHA", alpha)) + end + local ok, value = pcall(newShader, code) if not ok or not value then failed[key] = true; return nil end shaders[key] = value end return shaders[key] end +local function shader(keyed) + return compile(keyed and "keyed" or "normal", source, keyed) +end function M.shader() return shader(false) end function M.keyedShader() return shader(true) end +function M.remapShader() return compile("remap", remapSource) end function M.available() return M.shader() ~= nil end local function copy(colors) @@ -65,9 +102,13 @@ local function copy(colors) end return out end -function M.resolve(colors) return Palettes.effectiveColors(colors) end +M.BGP_IDENTITY = Palettes.BGP_IDENTITY +M.bgpShades = Palettes.bgpShades +M.remap = Palettes.remap +M.setBgp = Palettes.setBgp +function M.resolve(colors) return Palettes.displayColors(colors) end function M.color(colors, index) - local palette = M.resolve(colors) + local palette = Palettes.effectiveColors(colors or Palettes.GRAYS) return palette and palette[index] or nil end @@ -84,7 +125,7 @@ end local function use(colors, keyed, raw) local g = graphics() if not (g and g.setShader) then return false end - colors = copy(raw and colors or M.resolve(colors)) + colors = copy(raw and colors or Palettes.effectiveColors(colors)) if not colors then return false end local sh = shader(keyed) if not sh then return false end @@ -100,12 +141,110 @@ function M.clear() if g and g.setShader then g.setShader() end end +-- The first BG slot wins when the same RGB value has different meanings. +-- Report those collisions rather than pretending an RGB frame retains its +-- original indices. OBJ rows normally map to themselves, protecting sprites. +function M.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) + local src, dst, seen, ambiguous = {}, {}, {}, 0 + local function key(c) + return math.floor(c[1]) .. "," .. math.floor(c[2]) .. "," .. math.floor(c[3]) + end + local function add(colors, mapped) + colors, mapped = copy(colors), copy(mapped) + if not (colors and mapped) then return end + for i = 1, 4 do + local from, to = colors[i], mapped[i] + local k = key(from) + local at = seen[k] + if at then + if key(dst[at]) ~= key(to) then ambiguous = ambiguous + 1 end + elseif #src < M.REMAP_MAX then + src[#src + 1], dst[#dst + 1] = from, to + seen[k] = #src + end + end + end + local forced = ramp and M.resolve(copy(ramp)) + local forcedBg = forced and M.remap(forced, byte) + for _, colors in ipairs(bgPalettes or {}) do + local resolved = M.resolve(copy(colors)) + add(resolved, forcedBg or M.remap(resolved, byte)) + end + if forced then add(forced, forcedBg) end + for i, colors in ipairs(objPalettes or {}) do + local resolved = M.resolve(copy(colors)) + add(resolved, forced and objRamped and objRamped[i] and forced or resolved) + end + local count = #src + for i = count + 1, M.REMAP_MAX do + src[i] = src[1] and {unpack(src[1])} or {0, 0, 0} + dst[i] = dst[1] and {unpack(dst[1])} or {0, 0, 0} + end + return src, dst, count, ambiguous +end +function M.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped) + local src, dst, count, ambiguous = M.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) + if count == 0 then return nil end + for i = 1, M.REMAP_MAX do + for c = 1, 3 do src[i][c], dst[i][c] = src[i][c]/255, dst[i][c]/255 end + end + return {src=src, dst=dst, count=count, ambiguous=ambiguous} +end +local remapSent = setmetatable({}, {__mode = "k"}) +local function uploadRemap(sh, uniforms) + remapSent[sh] = nil + sh:send("remapCount", uniforms.count) + sh:send("remapTol", M.REMAP_TOLERANCE) + sh:send("remapSrc", unpack(uniforms.src)) + sh:send("remapDst", unpack(uniforms.dst)) + remapSent[sh] = uniforms +end +local function copyUniforms(uniforms) + if type(uniforms) ~= "table" or type(uniforms.count) ~= "number" + or uniforms.count < 1 or uniforms.count > M.REMAP_MAX + or uniforms.count % 1 ~= 0 then return nil end + local result = {count=uniforms.count, ambiguous=uniforms.ambiguous, src={}, dst={}} + for _, name in ipairs({"src", "dst"}) do + if type(uniforms[name]) ~= "table" then return nil end + for i = 1, M.REMAP_MAX do + local row = uniforms[name][i] + if type(row) ~= "table" then return nil end + result[name][i] = {} + for c = 1, 3 do + local n = row[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 1 then return nil end + result[name][i][c] = n + end + end + end + return result +end +function M.useRemapUniforms(uniforms) + local g = graphics() + if not (g and g.setShader) then return false end + local sh = M.remapShader() + uniforms = copyUniforms(uniforms) + if not (sh and uniforms) then return false end + local previous = remapSent[sh] + if not pcall(uploadRemap, sh, uniforms) then + if previous then pcall(uploadRemap, sh, previous) end + failed.remap, shaders.remap = true, nil + return false + end + g.setShader(sh) + return true, uniforms.ambiguous +end +function M.useRemap(bgPalettes, byte, objPalettes, ramp, objRamped) + return M.useRemapUniforms(M.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped)) +end + local function scoped(colors, body, apply) local g = graphics() local previous = g and g.getShader and g.getShader() or nil -- Restoring only the shader object is insufficient: nested calls reuse it -- and change its uniforms. Save the palette values as well as its identity. local previousColors = previous and sent[previous] + local previousRemap = previous and remapSent[previous] local ok, applied = pcall(function() local active = apply(colors) body() @@ -113,6 +252,7 @@ local function scoped(colors, body, apply) end) local restored, why = pcall(function() if previousColors then upload(previous, previousColors) end + if previousRemap then uploadRemap(previous, previousRemap) end if g and g.setShader then g.setShader(previous) end end) if not ok then error(applied, 0) end diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index fd6aee72e..ad57bc3ba 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -1129,6 +1129,40 @@ function PaletteFX.shadeMap() return shadeMap end +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Public byte-register access and native frame drawing share one state. +-- Renderer.beginFrame's existing reset therefore also clears a mod's BGP; +-- a byte cannot silently survive a menu, map or battle frame. +PaletteFX.BGP_IDENTITY = 0xe4 +function PaletteFX.bgpShades(byte) + byte = byte or PaletteFX.BGP_IDENTITY + local out = {} + for i = 0, 3 do out[i + 1] = math.floor(byte / 4 ^ i) % 4 end + return out +end +function PaletteFX.bgp() + if not shadeMap then return nil end + local byte = 0 + for i = 0, 3 do byte = byte + shadeMap[i] * 4 ^ i end + return byte ~= PaletteFX.BGP_IDENTITY and byte or nil +end +function PaletteFX.setBgp(byte) + local previous = PaletteFX.bgp() + if byte == nil or byte == PaletteFX.BGP_IDENTITY then + PaletteFX.setShadeMap(nil) + else + local shades = PaletteFX.bgpShades(byte) + PaletteFX.setShadeMap({[0]=shades[1], shades[2], shades[3], shades[4]}) + end + return previous +end +function PaletteFX.remap(colors, byte) + if not colors or not byte or byte == PaletteFX.BGP_IDENTITY then return colors end + local shades, out = PaletteFX.bgpShades(byte), {} + for i = 1, 4 do out[i] = colors[shades[i] + 1] or colors[4] end + return out +end + function PaletteFX.setMode(mode) local prev = PaletteFX.mode local ok = false @@ -1217,7 +1251,7 @@ function PaletteFX.paperShade(data) return c[1] / 255, c[2] / 255, c[3] / 255 end -function PaletteFX.effectiveColors(c) +function PaletteFX.displayColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c @@ -1233,12 +1267,10 @@ function PaletteFX.effectiveColors(c) elseif mode == "gbc_inv" then out = PaletteFX.permute(c, INV_MAP) end - -- The shade map goes on LAST: rBGP is a hardware register write, so it - -- composes on top of whatever colors the display mode settled on -- a dark - -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too - -- (#322). permute() is the identity (and returns `out` itself, which the - -- mod-graphics parity check leans on) while nothing has armed one. - return PaletteFX.permute(out, shadeMap) + return out +end +function PaletteFX.effectiveColors(c) + return PaletteFX.permute(PaletteFX.displayColors(c), shadeMap) end -- send a 4-color (0-255 RGB) palette to the shade-remap shader, after diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua index 76de1532e..c8aeef1d8 100644 --- a/tests/engine/gen2_palette_draw.lua +++ b/tests/engine/gen2_palette_draw.lua @@ -70,7 +70,7 @@ for _,game in ipairs({'red','gold','silver','crystal'})do T.eq(G.mode,display,game..' '..mode..' public mode follows actual native resolution') local trueColor=true T.eq(not(trueColor and G.mode=='gbc'),display~='gbc',game..' '..mode..' unchanged evolution colour gate preserves true-colour art') - T.same(G.resolve(A),resolved,game..' '..mode..' shares actual native colour/register resolution') + T.same(G.resolve(A),P.displayColors(A),game..' '..mode..' resolves mode without consuming the register') G.with(A,function()T.same(active.values,normalized(resolved),game..' '..mode..' actual use shares resolved colours')end) G.withRaw(A,function()T.same(active.values,normalized(A),game..' '..mode..' raw use bypasses display/register state')end) T.same(G.color(A,2),resolved[2],game..' '..mode..' solid colour shares same native row') diff --git a/tests/engine/gen2_palette_register.lua b/tests/engine/gen2_palette_register.lua new file mode 100644 index 000000000..bee18ef2d --- /dev/null +++ b/tests/engine/gen2_palette_register.lua @@ -0,0 +1,97 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local G=require('src.render.GbcPalette') +local P=require('src.render.PaletteFX') +local V=require('src.core.GameVersion') +local A={{251,241,239},{211,37,53},{17,181,89},{29,31,157}} +local B={{233,227,223},{13,67,197},{163,41,131},{3,7,11}} +local R={{229,199,167},{179,127,79},{109,61,23},{47,19,5}} +local active,fail +local function compiler(code) + return {code=code,values={},send=function(self,name,...) + if fail==name then fail=nil;error('driver rejected uniform')end + local args={...};local out={} + for i,v in ipairs(args)do out[i]=type(v)=='table'and{unpack(v)}or v end + self.values[name]=out + end} +end +love.graphics={newShader=compiler,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G.BGP_IDENTITY,0xe4,'public identity byte') +T.same(G.bgpShades(),{0,1,2,3},'omitted byte is identity') +for _,edition in ipairs({'red','gold','silver','crystal'})do + V.set(edition);P.setMode('gbc');P.setShadeMap(nil) + for byte=0,255 do + local fields,expected={},{} + for i=0,3 do fields[i+1]=math.floor(byte/4^i)%4;expected[i+1]=A[fields[i+1]+1]end + local label=edition..' byte '..byte + T.same(G.bgpShades(byte),fields,label..' reads all two-bit fields') + T.same(G.remap(A,byte),expected,label..' explicit palette permutation') + local before=G.bgp + T.eq(G.setBgp(byte),before,label..' returns actual previous register') + T.eq(G.bgp,byte~=0xe4 and byte or nil,label..' public byte reflects native state') + T.same(P.effectiveColors(A),expected,label..' native renderer receives permutation') + T.eq(G.resolve(A),A,label..' inspecting colours does not consume BGP') + T.same(G.color(A,2),expected[2],label..' solid colours consume it exactly once') + G.use(A) + for i=1,4 do T.same(active.values['pal'..i-1][1],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255},label..' indexed draw row '..i)end + local native=compiler('native');P.sendColors(native,A) + for i=1,4 do T.same(native.values['c'..i-1][1],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255},label..' native uniform row '..i)end + end + P.setShadeMap({[0]=2,3,0,1});T.eq(G.bgp,0x4e,edition..' native register write visible publicly') + G.bgp=0xf9;T.eq(P.bgp(),0xf9,edition..' legacy property writes native register') + G.bgp=nil;T.eq(P.shadeMap(),nil,edition..' legacy nil clears native state') + G.setBgp(0);P.setShadeMap(nil);T.eq(G.bgp,nil,edition..' native frame reset clears public register') +end +T.eq(G.remap(A,nil),A,'nil remap retains identity') +T.eq(G.remap(A,0xe4),A,'identity remap retains table identity') +T.eq(G.remap(nil,0xf9),nil,'missing palette stays missing') +V.set('crystal');P.setShadeMap(nil) +local src,dst,count,ambiguous=G.remapTable({A},0xf9,{B}) +T.eq(count,8,'BG and OBJ rows both recognized') +T.eq(ambiguous,0,'disjoint palette mapping exact') +for i=1,4 do T.same(src[i],A[i],'BG source row');T.same(dst[i],A[({2,3,4,4})[i]],'BG byte applies own palette');T.same(dst[i+4],B[i],'OBJ row stays unchanged')end +T.eq(#src,64,'source bounded and padded');T.eq(#dst,64,'destination bounded and padded') +T.neq(src[9],src[1],'padding owns its row');T.same(src[9],src[1],'padding repeats first colour') +local _,collisionDst,_,collisions=G.remapTable({A},0xff,{A}) +T.eq(collisions,3,'overlapping BG/OBJ collision count is explicit') +T.same(collisionDst[1],A[4],'first BG interpretation wins') +local rampSrc,rampDst,rampCount,rampAmbiguous=G.remapTable({A},0xf9,{B},R,{true}) +T.eq(rampCount,12,'forced ramp included as already drawn source') +T.eq(rampAmbiguous,0,'disjoint forced ramp exact') +T.same(rampDst[1],R[2],'forced BG takes explicit byte') +T.same(rampDst[9],R[1],'ramped OBJ uses unpermuted forced ramp') +local _,unramped=G.remapTable({A},0xf9,{B},R) +T.same(unramped[9],B[1],'unramped OBJ retains original colour') +G.setBgp(0xff) +local u=G.remapUniforms({A},0xf9,{B}) +T.same(u.src[1],{A[1][1]/255,A[1][2]/255,A[1][3]/255},'explicit backward byte ignores standing forward register') +T.same(u.dst[1],{A[2][1]/255,A[2][2]/255,A[2][3]/255},'backward byte consumed once') +G.setBgp(nil) +local ok,n=G.useRemapUniforms(u);T.check(ok,'remap shader applies');T.eq(n,0,'successful apply reports ambiguity') +local sh=active;T.eq(sh,G.remapShader(),'actual remap shader active') +T.check(sh.code:find('vec4(rgb, pixel.a) * tint',1,true)~=nil,'backward shader retains alpha and draw tint') +T.same(sh.values.remapSrc,u.src,'all normalized source entries uploaded') +T.same(sh.values.remapDst,u.dst,'all normalized destination entries uploaded') +T.same(sh.values.remapCount,{8},'bounded live count uploaded') +local first=u.src[1][1];u.src[1][1]=0 +G.withRaw(A,function()G.useRemap({B},0xff)end) +T.eq(active,sh,'nested draw restores remap shader') +T.eq(sh.values.remapSrc[1][1],first,'nested draw restores detached original remap uniforms') +local did,why=pcall(function()G.withRaw(A,function()G.useRemap({B},0xff);error('draw failure',0)end)end) +T.check(not did and why=='draw failure','callback failure propagates') +T.eq(active,sh,'error restores remap shader');T.eq(sh.values.remapSrc[1][1],first,'error restores remap uniforms') +T.eq(G.useRemap({}),false,'empty palette leaves shader alone');T.eq(active,sh,'empty palette keeps prior shader') +local rows={};for p=1,20 do rows[p]={{p,1,1},{p,2,2},{p,3,3},{p,4,4}}end +local hugeSrc,hugeDst,hugeCount=G.remapTable(rows,0xff) +T.eq(hugeCount,64,'overfull palette tables bounded');T.eq(#hugeSrc,64,'source cannot overflow shader');T.eq(#hugeDst,64,'destination cannot overflow shader') +T.eq(G.remapUniforms({false,{},{{1,2,3}}},0xf9),nil,'malformed palette rows decline safely') +T.eq(G.useRemapUniforms({count=65,src={},dst={}}),false,'oversized borrowed uniforms rejected') +fail='remapDst';T.eq(G.useRemap({B},0xff),false,'driver upload failure declines effect') +T.eq(active,sh,'driver failure keeps previous shader');T.eq(sh.values.remapSrc[1][1],first,'partial driver failure restores previous uniforms') +T.eq(G.remapShader(),nil,'failed remap shader disabled for current compiler') +love.graphics.newShader=function(code)return compiler(code)end +T.check(G.useRemap({A},0xf9),'new compiler recovers from failed upload') +love.graphics={};T.eq(G.useRemap({A},0xf9),false,'headless remap declines effect') +T.finish('native palette byte register and composited BG/OBJ remapping') diff --git a/tests/gpu/gen2_palette_register.lua b/tests/gpu/gen2_palette_register.lua new file mode 100644 index 000000000..72814580b --- /dev/null +++ b/tests/gpu/gen2_palette_register.lua @@ -0,0 +1,89 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- ROM-free pixel verification in a separate LÖVE host on each backend. +local M = {} +function M.run() + require('src.core.ModCompat').install() + local G = require('src.render.GbcPalette') + local P = require('src.render.PaletteFX') + local V = require('src.core.GameVersion') + local version, mode, map = V.get(), P.mode, P.shadeMap() + local g = love.graphics + local A={{251,241,239},{211,37,53},{17,181,89},{29,31,157}} + local B={{233,227,223},{13,67,197},{163,41,131},{3,7,11}} + local C={107,149,207} + local shades = love.image.newImageData(4,1,'rgba8') + local rgba = love.image.newImageData(9,1,'rgba8') + for i=0,3 do + local n=1-i/3;shades:setPixel(i,0,n,n,n,1-i/5) + for p,colors in ipairs({A,B}) do + local c=colors[i+1];rgba:setPixel((p-1)*4+i,0,c[1]/255,c[2]/255,c[3]/255,1-i/5) + end + end + rgba:setPixel(8,0,C[1]/255,C[2]/255,C[3]/255,0.4) + local indexed, colored = g.newImage(shades), g.newImage(rgba) + indexed:setFilter('nearest','nearest');colored:setFilter('nearest','nearest') + local canvas=g.newCanvas(17,257,{format='rgba8',dpiscale=1}) + local checks=0 + local function check(ok,why) checks=checks+1;assert(ok,why) end + g.push('all') + local ok,why=xpcall(function() + V.set('crystal');P.setMode('gbc') + g.setCanvas(canvas);g.clear(0,0,0,0);g.origin() + g.setBlendMode('replace','premultiplied');g.setColor(0.5,0.75,1,0.8) + local native = assert(P.shader(),'native palette shader unavailable') + for byte=0,255 do + G.setBgp(byte) + check(G.use(A),'forward shader unavailable') + g.draw(indexed,0,byte) + -- Native PaletteFX owns the same register and the real optimized + -- userdata upload path, rather than just a fake public shader object. + P.sendColors(native,A);g.setShader(native);g.draw(indexed,4,byte) + -- The explicit backward byte is independent of the standing register. + G.setBgp(0xff) + local applied,ambiguous=G.useRemap({A},byte,{B}) + check(applied and ambiguous==0,'backward shader unavailable or ambiguous') + g.draw(colored,8,byte) + end + G.setBgp(nil) + local display=P.displayColors + P.displayColors=function()return B end + local changed,changeError=pcall(function() + if P._beginColorBatch then check(P._beginColorBatch(native)==nil,'overridden display resolver entered a trusted native batch') end + P.sendColors(native,A);g.setShader(native);g.draw(indexed,0,256) + end) + P.displayColors=display + if not changed then error(changeError,0) end + g.setCanvas();g.setShader() + local pixels=g.readbackTexture and g.readbackTexture(canvas) or canvas:newImageData() + for byte=0,255 do + for x=0,16 do + local color,alpha + if x<8 then + local shade=x%4;local field=math.floor(byte/4^shade)%4 + color,alpha=A[field+1],1-shade/5 + elseif x<12 then + local shade=x-8;local field=math.floor(byte/4^shade)%4 + color,alpha=A[field+1],1-shade/5 + elseif x<16 then color,alpha=B[x-11],1-(x-12)/5 + else color,alpha=C,0.4 end + local wanted={color[1]/255*0.5,color[2]/255*0.75,color[3]/255,alpha*0.8} + -- PaletteFX's native final-zone shader deliberately ignores the + -- draw tint. The public indexed/remap shaders compose with it. + if x>=4 and x<8 then wanted={color[1]/255,color[2]/255,color[3]/255,alpha} end + local actual={pixels:getPixel(x,byte)} + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255, + 'register '..byte..' pixel '..x..' channel '..c..' got '..actual[c]..' expected '..wanted[c])end + end + end + for x=0,3 do + local actual={pixels:getPixel(x,256)} + local wanted={B[x+1][1]/255,B[x+1][2]/255,B[x+1][3]/255,1-x/5} + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255,'native upload ignored replaced display resolver')end + end + end,debug.traceback) + g.pop();P.setShadeMap(map);P.setMode(mode);V.set(version) + indexed:release();colored:release();shades:release();rgba:release() + if not ok then canvas:release();error(why,0) end + return {passed=true,checks=checks,scope='All 256 BGP bytes: native and public indexed draws, composited BG permutation, OBJ preservation, unrelated RGB preservation, alpha and tint'},canvas +end +return M From ab7c7508f3bb43c7a6156433791c1aab59088690 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:28:25 +0100 Subject: [PATCH 17/64] Apply native Gen2 display selection and complete-frame palette presentation --- docs/NATIVE-GEN2-DISPLAY.md | 64 +++++++++++ docs/NATIVE-GEN2-PALETTE-REGISTER.md | 12 +- src/battle/BattleState.lua | 30 ++++- src/core/Game.lua | 8 +- src/render/Gen2PaletteDraw.lua | 25 ++++- src/render/Gen2Presentation.lua | 79 +++++++++++++ src/render/Palette.lua | 136 +++++++++++++++++++++++ src/render/PaletteFX.lua | 159 ++++++++++++++++++++------- src/render/Renderer.lua | 51 ++++++--- src/render/SpriteRenderer.lua | 5 + src/render/TileRenderer.lua | 11 +- src/ui/OptionsMenu.lua | 15 ++- tests/engine/gen2_display_loader.lua | 45 ++++++++ tests/engine/gen2_display_modes.lua | 138 +++++++++++++++++++++++ tests/engine/gen2_palette_draw.lua | 2 +- tests/engine/palette_library.lua | 50 +++++++++ tests/gpu/gen2_display_modes.lua | 153 ++++++++++++++++++++++++++ 17 files changed, 905 insertions(+), 78 deletions(-) create mode 100644 docs/NATIVE-GEN2-DISPLAY.md create mode 100644 src/render/Gen2Presentation.lua create mode 100644 src/render/Palette.lua create mode 100644 tests/engine/gen2_display_loader.lua create mode 100644 tests/engine/gen2_display_modes.lua create mode 100644 tests/engine/palette_library.lua create mode 100644 tests/gpu/gen2_display_modes.lua diff --git a/docs/NATIVE-GEN2-DISPLAY.md b/docs/NATIVE-GEN2-DISPLAY.md new file mode 100644 index 000000000..05e36f705 --- /dev/null +++ b/docs/NATIVE-GEN2-DISPLAY.md @@ -0,0 +1,64 @@ +# Native Gen2 display selection + +Gen2 display selection belongs to `PaletteFX`, the same owner used by the +native tile, sprite and battle renderers. `GbcPalette` exposes the Gen1Recomp +public vocabulary over that state: `gbc`, `dmg`, `classic`, and `custom`. +The generic native ID for DMG remains `og`, preserving existing option files. + +GBC uses the cartridge's indexed palettes. DMG retains the four original +indexed shades as grayscale. Classic uses that same grayscale intermediate +and maps the completed frame through the green ramp once. A custom ramp +replaces indexed palette rows; already-coloured art keeps its existing +true-colour contract. The unrelated Gen1 colourization packs and Gen3 +display behaviour retain their existing paths. + +`setMode`, `cycle(delta)`, `setCustomRamp`, `applyOptions`, `modeLabel`, and +`presentColors` are available through `GbcPalette`. The mode, custom ramp and +byte BGP all share native state. Raw indexed drawing bypasses display/BGP +selection. A custom ramp takes priority over the selected indexed mode, as +in the reference protocol; Classic's final presentation remains a separate +operation. Missing custom ramps fall back to grayscale. + +`PaletteFX.setDisplay(mode, ramp)` applies a selection atomically. It invalidates +tile and animation textures, OBJ images, battle images and cached maps, then +reloads the entered map. The shared `Assets.register` invalidation bus also +notifies external texture producers, including repeated ramps with the same mode +ID. Retained battle image references resolve through their +original palette and crop on the next draw. Reapplying an edited ramp table +also updates its key and invalidates dependent caches. Transient BGP writes do +not enter static atlas cache keys. Mono/custom world bakes retain cave darkness; +battle palette permutations and blackout apply after display selection. + +Native Gen2 options and hotkey 2 use the GBC → DMG → Classic ladder, with a +backward step supported. Cycling clears a selected custom ramp and saved +palette ID. Native `options.colors` and legacy `options.color` record the same +selection. Applying legacy mode plus `options.palette` performs one rebuild. + +`src.render.Palette` supplies the public palette ID/library operations. Mono IDs +are `m:rrggbb`; named IDs are `p:group/name`. An optional authored +`data.gb_palettes` module supplies named entries; no palette catalogue is +invented or extracted by this library. Categories, labels, lookup, swatches, +ramp caching and invalidation use the reference vocabulary. + +## Completed-frame zones and composition + +For native Gen2 gameplay, `render.zones` receives the final display zones once, +after native drawing has settled its palettes. Classic starts with one green +whole-frame zone; other modes start with nil. Consumers can replace, remove or +extend those zones. Each dispatch gets its own list. This final pass uses raw +palette destinations so Classic cannot resolve its own green ramp back to gray. + +The native Gen2 compositor first combines world and UI into one scene. Its +window-menu callback includes a window-resolution drawer when one is supplied; +this offer does not add the separate native window-menu classes or a palette-picker UI. `render.compose` then receives that completed texture in +`sceneCanvas`, `worldCanvas` and `uiCanvas`, with `generation = 2`, the native +renderer and an explicit `game` owner. The render target is different from the +source texture. A consumer taking composition ownership also owns presentation +of the supplied final zones; `Renderer:blitZones` provides the raw zone blit. +Otherwise the engine applies final zones before present/output effects. HUD and +touch controls retain their screen-space placement outside this game scene. + +This is display work toward Gen1 parity. It does not establish complete UI +raster parity, a full unchanged-mod playthrough, save/link acceptance, hardware +performance or full engine parity. Existing upstream/Gen1 licences and the +[Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md) remain applicable. diff --git a/docs/NATIVE-GEN2-PALETTE-REGISTER.md b/docs/NATIVE-GEN2-PALETTE-REGISTER.md index 10d840c11..9f5188eed 100644 --- a/docs/NATIVE-GEN2-PALETTE-REGISTER.md +++ b/docs/NATIVE-GEN2-PALETTE-REGISTER.md @@ -19,8 +19,8 @@ native frame reset clears it as well. `resolve(colors)` selects the display palette without applying the register. `color`, `use`, `with` and keyed draws apply the current register once. `useRaw` and `withRaw` bypass display selection and the register. Native -`PaletteFX.sendColors` consumes the same state. The separate native palette-upload optimization, when -present, consumes that state as well. +`PaletteFX.sendColors` consumes the same state. The separate palette-upload +optimization uses this owner when present. ## Already-coloured frames @@ -57,10 +57,10 @@ through public and native shaders, including the optimized native upload path, OBJ preservation, unrelated RGB, alpha and tint. Run `run()` from an isolated LÖVE host separately on each backend. It requires no cartridge or private mod. -This change does not implement full Gen1 display parity. Native Gen2 still needs -the DMG/Classic/custom display selections, their cached tile/sprite/battle -invalidation and option persistence. Those require the real rendering owners; -adding setters that only change a public mode string would not implement them. +This register change alone does not establish full Gen1 display parity. See +`NATIVE-GEN2-DISPLAY.md` for the native display-selection and cache/presentation +owners. Full unchanged-mod story/save/link acceptance and wider engine parity +remain separate requirements. The public method protocol follows the Gen1Recomp reference interface. Existing Gen1Recomp and upstream credits and licences remain applicable; see `NOTICE.md`. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 297e84c74..ec68eed25 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -639,7 +639,16 @@ local imageMeta = setmetatable({}, WEAK_KEYS) local function getImage(path, pal, trueColor, crop) if not path then return nil end if trueColor then pal = nil end + local sourcePal, displayKey = pal + if GameVersion.isGen2() and not trueColor and pal then + local display = require("src.render.PaletteFX") + displayKey = display.displayKey() + if not pal.displayResolved then + pal = {name=pal.name, colors=display.displayColors(pal.colors), displayResolved=true} + end + end local key = pal and (path .. "#" .. pal.name) or path + if displayKey then key = key .. "#display:" .. displayKey end if crop then key = key .. "@" .. crop.index end if not imageCache[key] then local img, pad, padL = nil, 0, 0 @@ -693,7 +702,7 @@ local function getImage(path, pal, trueColor, crop) -- crop rides along so fadeImage / grayImage / blackImage re-bake the -- SAME cell of the strip, not the whole strip imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil, - crop = crop } + crop = crop, sourcePal = sourcePal, displayKey = displayKey } end return imageCache[key] end @@ -820,7 +829,7 @@ local function fadeImage(img, bgp) local name = (meta.pal and meta.pal.name or "GB") .. "&" .. bgp[0] .. bgp[1] .. bgp[2] .. bgp[3] return getImage(meta.path, - { name = name, colors = PaletteFX.permute(base, bgp) }, + { name = name, colors = PaletteFX.permute(base, bgp), displayResolved=GameVersion.isGen2() }, nil, meta.crop) end @@ -848,8 +857,12 @@ local function blackImage(data, img) local pack = PaletteFX.pack(data) local colors = pack and pack.palettes and pack.palettes.BLACK if not colors then return img end + if GameVersion.isGen2() then + local ramp = PaletteFX.displayColors(colors) + colors = {ramp[4],ramp[4],ramp[4],ramp[4]} + end local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK" - return getImage(meta.path, { name = name, colors = colors }, nil, meta.crop) + return getImage(meta.path, { name = name, colors = colors, displayResolved=GameVersion.isGen2() }, nil, meta.crop) or img end @@ -864,6 +877,15 @@ end -- the image a battler pic actually draws with this frame function BattleState:picImage(img) local PaletteFX = require("src.render.PaletteFX") + if GameVersion.isGen2() then + local meta = imageMeta[img] + -- Battlers retain image references across a live option change. Dropping + -- imageCache alone does not replace those pictures or animation cells. + if meta and not meta.trueColor and meta.sourcePal + and meta.displayKey ~= PaletteFX.displayKey() then + img = getImage(meta.path, meta.sourcePal, nil, meta.crop) or img + end + end -- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that -- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone -- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the @@ -878,7 +900,7 @@ function BattleState:picImage(img) -- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync. local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" - if self.grayPics or mono then return grayImage(img) end + if self.grayPics or (mono and not GameVersion.isGen2()) then return grayImage(img) end -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go -- dark with the HP bars while the blackout text is up (#292). The intro -- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the diff --git a/src/core/Game.lua b/src/core/Game.lua index 0ebe75e81..60bd2784c 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1171,7 +1171,11 @@ function Game:_draw() end -- 14's render.zones: weather/lighting overlays and custom colorization -- recolor or add zones before the blit - if ModRuntime.wantsHook("render.zones") then + if require("src.core.GameVersion").isGen2() then + Renderer.gen2FrameManaged = true + Renderer.gen2FrameGame = self + Renderer.gen2FrameZones = require("src.render.Gen2Presentation").zones(self) + elseif ModRuntime.wantsHook("render.zones") then zones = ModRuntime.call("render.zones", sameZones, self, zones) end -- Keyed to whether the map actually DREW, not to whether it is the clear's @@ -1408,7 +1412,7 @@ function Game:keypressed(key) or ow.engaging or ow.emote))) if not busy then local PaletteFX = require("src.render.PaletteFX") - self.save.options.colors = PaletteFX.cycleMode() + PaletteFX.writeColorOption(self.save.options, PaletteFX.cycleMode()) self:writeOptions() end return diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua index f84798c13..481165c12 100644 --- a/src/render/Gen2PaletteDraw.lua +++ b/src/render/Gen2PaletteDraw.lua @@ -1,21 +1,23 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md -- Indexed image drawing, separate from the LCD post-process in GBCFX. -- Palette resolution uses the native display/register state in PaletteFX. local Palettes = require("src.render.PaletteFX") local Version = require("src.core.GameVersion") local M = {} --- Consumers use this view to leave already-coloured pictures untouched. --- Gen2 currently resolves native ROM colours regardless of the Gen1 mode. --- Do not invent a second mutable display setting for the external renderer. +-- Display state belongs to the same owner as the native map/OBJ/pic bakes. setmetatable(M, {__index = function(_, key) if key == "mode" then - if Version.isGen2() then return "gbc" end + if Version.isGen2() and Palettes.mode == "custom" then return "custom" end if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end return Palettes.mode == "classic" and "classic" or "gbc" end if key == "bgp" then return Palettes.bgp() end + if key == "customRamp" then return Palettes.customRamp() end end, __newindex = function(self, key, value) if key == "bgp" then Palettes.setBgp(value) + elseif key == "mode" then M.setMode(value) + elseif key == "customRamp" then Palettes.setCustomRamp(value) else rawset(self, key, value) end end}) local compiler, shaders, failed = nil, {}, {} @@ -106,6 +108,19 @@ M.BGP_IDENTITY = Palettes.BGP_IDENTITY M.bgpShades = Palettes.bgpShades M.remap = Palettes.remap M.setBgp = Palettes.setBgp +M.MODES = {"gbc", "dmg", "classic"} +M.MODE_LABELS = {gbc="GEN 2", dmg="DMG", classic="CLASSIC", custom="CUSTOM"} +M.CUSTOM_MODE = "custom" +M.setCustomRamp = Palettes.setCustomRamp +M.presentColors = Palettes.presentColors +function M.setMode(mode) + if mode~="gbc" and mode~="dmg" and mode~="classic" and mode~="custom" then mode="gbc" end + Palettes.setMode(mode) + return M.mode +end +function M.cycle(delta) Palettes.cycleMode(delta); return M.mode end +function M.applyOptions(opts) Palettes.applyOptions(opts); return M.mode end +function M.modeLabel(mode) return M.MODE_LABELS[mode or M.mode] or "GEN 2" end function M.resolve(colors) return Palettes.displayColors(colors) end function M.color(colors, index) local palette = Palettes.effectiveColors(colors or Palettes.GRAYS) diff --git a/src/render/Gen2Presentation.lua b/src/render/Gen2Presentation.lua new file mode 100644 index 000000000..017a877da --- /dev/null +++ b/src/render/Gen2Presentation.lua @@ -0,0 +1,79 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Gen2 display zones belong to the completed colour frame, after indexed +-- native BG/OBJ/UI bakes and before present/output effects. In Classic those +-- bakes are grayscale and this pass supplies the green ramp exactly once. +local P = require("src.render.PaletteFX") +local Gbc = require("src.render.Gen2PaletteDraw") +local Runtime = require("src.mods.Runtime") +local M = {} +local target +local function identity(_, zones) return zones end +function M.zones(game) + local colors = P.presentColors() + local zones = colors and {{x=0,y=0,w=160,h=144,colors=colors}} or nil + if Runtime.wantsHook("render.zones") then + zones = Runtime.call("render.zones", identity, game, zones) + end + return zones +end +local function finite(n) return type(n)=="number" and n==n and math.abs(n)x1 and y2>y1 then + g.setScissor(x1,y1,x2-x1,y2-y1) + if zone.colors==false then g.setShader();g.draw(source,0,0) + else Gbc.withRaw(zone.colors,function()g.draw(source,0,0)end) end + end + end + end,debug.traceback) + g.pop() + if not ok then error(why,0) end + end) +end +function M.apply(source, zones, w, h) + if not (type(zones)=="table" and zones[1]) then return source end + local g=love.graphics + if not (g.newCanvas and Gbc.available()) then return source end + local dpi=source.getDPIScale and source:getDPIScale() or 1 + if not target or target:getWidth()~=w or target:getHeight()~=h + or (target.getDPIScale and target:getDPIScale()~=dpi) then + if target then target:release() end + target=g.newCanvas(w,h,{format="normal",dpiscale=dpi,msaa=0}) + target:setFilter("nearest","nearest") + end + local previous={g.getCanvas()} + Gbc.withRaw(nil,function() + g.push("all") + local ok,why=xpcall(function() + g.setCanvas(target);g.origin();g.setShader();g.setScissor() + g.setColorMask(true,true,true,true);g.setStencilTest();g.setDepthMode() + g.setBlendMode("alpha","premultiplied");g.clear(0,0,0,1) + M.blit(source,zones,w,h) + end,debug.traceback) + g.setCanvas(unpack(previous));g.pop() + if not ok then error(why,0) end + end) + return target +end +function M.reset() + if target then target:release();target=nil end +end +return M diff --git a/src/render/Palette.lua b/src/render/Palette.lua new file mode 100644 index 000000000..6982156b2 --- /dev/null +++ b/src/render/Palette.lua @@ -0,0 +1,136 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Palette IDs and category vocabulary follow Gen1Recomp's public protocol. +-- An optional authored data.gb_palettes library supplies named ramps; this +-- module contains no cartridge palette catalogue or private renderer data. +local P = {} +P.MONO_COLOURS = { + {"GREEN",155,188,15}, {"LIME",168,216,56}, {"OLIVE",166,172,132}, + {"AMBER",255,176,0}, {"ORANGE",255,128,48}, {"RED",255,80,80}, + {"ROSE",255,144,176}, {"MAGENTA",240,96,208}, {"PURPLE",176,112,240}, + {"INDIGO",128,128,240}, {"BLUE",80,160,255}, {"CYAN",64,224,224}, + {"TEAL",64,192,160}, {"MINT",144,224,176}, {"SAND",224,200,144}, + {"BROWN",192,144,80}, {"WHITE",255,255,255}, {"SILVER",208,216,224}, +} +P.GREY_CHROMA, P.HUE_ARC = 12, 50 +P.CATEGORIES = {{key="full",label="FULL COLOUR"},{key="single",label="SINGLE COLOUR"},{key="grey",label="GREYSCALE"}} +local library, index, buckets, lastId, lastRamp +function P.packs() + if library == nil then + local ok, value = pcall(require, "data.gb_palettes") + library = ok and type(value) == "table" and value or false + end + return library or {} +end +function P.packId(group, name) return "p:" .. group .. "/" .. name end +function P.monoId(r,g,b) return ("m:%02x%02x%02x"):format(r,g,b) end +function P.monoRamp(r,g,b) + local out = {} + for i, scale in ipairs({1,0.72,0.38,0.12}) do + out[i] = {math.floor(r*scale+0.5),math.floor(g*scale+0.5),math.floor(b*scale+0.5)} + end + return out +end +function P.unhex(text) + if type(text) ~= "string" or #text ~= 24 or text:find("[^%x]") then return nil end + local out = {} + for i=1,4 do + out[i] = {} + for c=1,3 do local at=(i-1)*6+(c-1)*2+1;out[i][c]=tonumber(text:sub(at,at+1),16) end + end + return out +end +function P.hueChroma(color) + local r,g,b=color[1] or 0,color[2] or 0,color[3] or 0 + local hi,lo=math.max(r,g,b),math.min(r,g,b) + local c=hi-lo + if c<=0 then return 0,0 end + local h + if hi==r then h=((g-b)/c)%6 elseif hi==g then h=(b-r)/c+2 else h=(r-g)/c+4 end + return h*60,c +end +function P.hueArc(hues) + local span=360 + for _,start in ipairs(hues) do + local width=0 + for _,hue in ipairs(hues) do width=math.max(width,(hue-start)%360) end + span=math.min(span,width) + end + return span +end +function P.categoryOf(ramp) + if type(ramp)~="table" or not ramp[4] then return nil end + local hues={} + for i=1,4 do + local h,c=P.hueChroma(ramp[i]) + if c>P.GREY_CHROMA then hues[#hues+1]=h end + end + if #hues==0 then return "grey" end + return P.hueArc(hues)<=P.HUE_ARC and "single" or "full" +end +local function ensureIndex() + if index then return end + index,buckets={},{full={},single={},grey={}} + for _,pack in ipairs(P.packs()) do + for _,entry in ipairs(pack.palettes or {}) do + if type(entry[1])=="string" then + local id=P.packId(pack.name,entry[1]);index[id]=entry + local ramp=P.unhex(entry[2]);local category=ramp and P.categoryOf(ramp) + if category then + local bucket=buckets[category] + bucket[#bucket+1]={name=entry[1],pack=pack.name,id=id} + end + end + end + end +end +function P.findPack(group,name) ensureIndex();return index[P.packId(group,name)] end +function P.category(key) + ensureIndex() + for _,category in ipairs(P.CATEGORIES) do + if category.key==key then return {key=key,name=category.label,palettes=buckets[key]} end + end +end +function P.categories() + local out={} + for _,category in ipairs(P.CATEGORIES) do out[#out+1]=P.category(category.key) end + return out +end +function P.locate(id) + if type(id)~="string" or id=="" then return nil end + ensureIndex() + for _,category in ipairs(P.CATEGORIES) do + for i,palette in ipairs(buckets[category.key]) do if palette.id==id then return category.key,i end end + end +end +function P.label(id) + if type(id)~="string" or id=="" then return nil end + local hex=id:match("^m:(%x+)$") + if hex then + local r,g,b=tonumber(hex:sub(1,2),16),tonumber(hex:sub(3,4),16),tonumber(hex:sub(5,6),16) + for _,color in ipairs(P.MONO_COLOURS) do if r==color[2] and g==color[3] and b==color[4] then return color[1] end end + return "MONO" + end + return id:match("^p:.*/([^/]+)$") or id +end +function P.ramp(id) + if type(id)~="string" or id=="" then return nil end + if id==lastId then return lastRamp end + local ramp + local hex=id:match("^m:(%x%x%x%x%x%x)$") + if hex then ramp=P.monoRamp(tonumber(hex:sub(1,2),16),tonumber(hex:sub(3,4),16),tonumber(hex:sub(5,6),16)) + else + local group,name=id:match("^p:(.*)/([^/]+)$") + local entry=group and P.findPack(group,name) + local raw=entry and P.unhex(entry[2]) + if raw then ramp={raw[4],raw[3],raw[2],raw[1]} end + end + lastId,lastRamp=id,ramp + return ramp +end +function P.swatch(id) + local ramp=P.ramp(id) + if ramp then return {ramp[4],ramp[3],ramp[2],ramp[1]} end +end +function P.invalidate() index,buckets,lastId,lastRamp=nil,nil,nil,nil end +return P diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index ad57bc3ba..5cea28f01 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -44,6 +44,31 @@ PaletteFX.MODE_LABELS = { } PaletteFX.mode = "gbc" +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +PaletteFX.GEN2_MODES = {"gbc", "og", "classic"} +local customRamp, customKey = nil, "" +function PaletteFX.customRamp() return customRamp end +function PaletteFX.displayKey() return tostring(PaletteFX.mode) .. customKey end +function PaletteFX.colorModes() + return GameVersion.isGen2() and PaletteFX.GEN2_MODES or PaletteFX.MODES +end +local function rampKey(ramp) + if ramp == nil then return "" end + if type(ramp) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + local row = ramp[i] + if type(row) ~= "table" then return nil end + for c = 1, 3 do + local n = row[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + out[#out + 1] = ("%.17g"):format(n) + end + end + return "#ramp:" .. table.concat(out, ",") +end + -- ------- dark-cave state (wMapPalOffset) -- -- Unlike the per-frame shadeMap further down, this outlives a frame: ADVANCED @@ -421,6 +446,7 @@ local GEN2_SHADE_MODES = { } function PaletteFX.usesGen2ObjPal(mode) + if GameVersion.isGen2() then return true end mode = mode or PaletteFX.mode return not GEN2_SHADE_MODES[mode] end @@ -1129,7 +1155,8 @@ function PaletteFX.shadeMap() return shadeMap end --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md -- Public byte-register access and native frame drawing share one state. -- Renderer.beginFrame's existing reset therefore also clears a mod's BGP; -- a byte cannot silently survive a menu, map or battle frame. @@ -1163,54 +1190,89 @@ function PaletteFX.remap(colors, byte) return out end -function PaletteFX.setMode(mode) - local prev = PaletteFX.mode - local ok = false - for _, m in ipairs(PaletteFX.MODES) do - if m == mode then - PaletteFX.mode = mode - ok = true - break +local function invalidateDisplay() + -- Register native producers before the shared flush. External producers + -- subscribe to the same Assets.register bus; private cache calls alone + -- leave their atlases stale when a second ramp keeps the same mode ID. + for _, name in ipairs({"src.battle.BattleState","src.render.SpriteRenderer", + "src.render.TileRenderer","src.world.MapLoader"}) do + pcall(require, name) + end + require("src.render.Assets").invalidate() + pcall(function() + local Game = require("src.core.Game") + if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then + Game.overworld:reloadMap(Game.overworld.map.id, "colors") end + end) +end +local function normalizeMode(mode) + if GameVersion.isGen2() then + if mode == "dmg" then return "og" end + if mode == "custom" then return mode end end - if not ok then PaletteFX.mode = "gbc" end - -- battle pics and overworld sprites bake the active pack into ImageData; - -- drop those caches when the pack (or any COLORS mode) changes so the - -- next draw re-tints - if prev ~= PaletteFX.mode then - pcall(function() require("src.battle.BattleState").invalidate() end) - pcall(function() require("src.render.SpriteRenderer").invalidate() end) - -- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once - -- per loaded map, so a mode toggle needs every cached Map/TileRenderer - -- dropped and the currently-visible one rebuilt in place -- otherwise - -- the on-screen map keeps its stale (wrong-mode) atlas until the next - -- map transition happens to reload it. - pcall(function() - require("src.world.MapLoader").invalidateAll() - local Game = require("src.core.Game") - if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then - Game.overworld:reloadMap(Game.overworld.map.id, "colors") - end - end) + for _, m in ipairs(PaletteFX.MODES) do + if m == mode then return mode end end + return "gbc" +end +-- Set a complete selection atomically: applying persisted mode + ramp must +-- rebuild the entered map once, never briefly in the previous custom ramp. +function PaletteFX.setDisplay(mode, ramp) + local key = rampKey(ramp) + if key == nil then ramp, key = nil, "" end + mode = normalizeMode(mode) + local changed = mode ~= PaletteFX.mode or ramp ~= customRamp or key ~= customKey + PaletteFX.mode, customRamp, customKey = mode, ramp, key + if changed then invalidateDisplay() end + return mode +end +function PaletteFX.setMode(mode) + return PaletteFX.setDisplay(mode, customRamp) +end +function PaletteFX.setCustomRamp(ramp) + PaletteFX.setDisplay(PaletteFX.mode, ramp) + return customRamp end -function PaletteFX.cycleMode() +function PaletteFX.cycleMode(delta) local cur = PaletteFX.mode or "gbc" + if GameVersion.isGen2() and cur == "og_inv" then cur = "og" end + local modes = PaletteFX.colorModes() local idx = 1 - for i, m in ipairs(PaletteFX.MODES) do + for i, m in ipairs(modes) do if m == cur then idx = i; break end end - PaletteFX.setMode(PaletteFX.MODES[idx % #PaletteFX.MODES + 1]) + local nextMode = modes[(idx - 1 + (delta or 1)) % #modes + 1] + local ramp = customRamp + if GameVersion.isGen2() then ramp = nil end + PaletteFX.setDisplay(nextMode, ramp) return PaletteFX.mode end function PaletteFX.applyOptions(opts) - PaletteFX.setMode(opts and opts.colors or "gbc") + if GameVersion.isGen2() then + local ramp = opts and opts.palette and require("src.render.Palette").ramp(opts.palette) + return PaletteFX.setDisplay(opts and (opts.color or opts.colors) or "gbc", ramp) + end + return PaletteFX.setMode(opts and opts.colors or "gbc") +end +function PaletteFX.writeColorOption(opts, mode) + opts.colors = mode + if GameVersion.isGen2() then + opts.color = mode == "og" and "dmg" or mode + opts.palette = nil + end +end +function PaletteFX.presentColors() + if GameVersion.isGen2() and PaletteFX.mode == "classic" then return PaletteFX.CLASSIC end end function PaletteFX.modeLabel(mode) mode = mode or PaletteFX.mode + if GameVersion.isGen2() then + return ({gbc="GEN 2", og="DMG", dmg="DMG", classic="CLASSIC", custom="CUSTOM"})[mode] or "GEN 2" + end -- The GBC hardware mode wears the running game's name: OG RED / OG BLUE -- (boot-ROM auto-palette) or OG YELLOW (pokeyellow CGBBasePalettes). if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end @@ -1255,22 +1317,45 @@ function PaletteFX.displayColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c - if gen2Native() then + if GameVersion.isGen3() then mode = "gbc" end - if mode == "og" then + if GameVersion.isGen2() and customRamp then return customRamp end + if mode == "og" or (GameVersion.isGen2() and mode == "custom") then out = PaletteFX.GRAYS elseif mode == "og_inv" then out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) elseif mode == "classic" then - out = PaletteFX.CLASSIC + out = GameVersion.isGen2() and PaletteFX.GRAYS or PaletteFX.CLASSIC elseif mode == "gbc_inv" then out = PaletteFX.permute(c, INV_MAP) end return out end -function PaletteFX.effectiveColors(c) - return PaletteFX.permute(PaletteFX.displayColors(c), shadeMap) +-- Static native BG/OBJ bakes resolve the display without a transient BGP. +-- DARK rows lose their darkness if replaced by a mono/custom ramp; fold the +-- world shift explicitly only for such replacements. Menus stay independent. +function PaletteFX.gen2BakeColors(c, dark) + local out = PaletteFX.displayColors(c) + local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" or PaletteFX.mode == "custom" + if GameVersion.isGen2() and dark and (mono or customRamp) then + out = PaletteFX.permute(out, PaletteFX.DARK_BGP) + end + return out +end +function PaletteFX.effectiveColors(c, invert) + local out = PaletteFX.displayColors(c) + if not out then return nil end + -- The shade map goes on LAST: rBGP is a hardware register write, so it + -- composes on top of whatever colors the display mode settled on -- a dark + -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too + -- (#322). permute() is the identity (and returns `out` itself, which the + -- mod-graphics parity check leans on) while nothing has armed one. + -- An inverted font page reverses its settled display ramp before the + -- hardware shade register permutes it. Keep this on the native path so + -- Gen2 menu chrome cannot carry a second palette mode/register state. + if invert then out = { out[4], out[3], out[2], out[1] } end + return PaletteFX.permute(out, shadeMap) end -- send a 4-color (0-255 RGB) palette to the shade-remap shader, after diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..b972b9d64 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +local Gen2Presentation = require("src.render.Gen2Presentation") -- RESOLVED ONCE, NOT EVERY FRAME. These three were `require`d from inside -- beginFrame and endFrame -- three string hashes into package.loaded on every @@ -109,6 +110,7 @@ local function displayMetrics() end function Renderer:init() + Gen2Presentation.reset() -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- (#208). Every canvas below is sized in framebuffer pixels for the same -- reason -- worldViewSize() already works in drawable pixels. @@ -320,6 +322,8 @@ end -- transparent: the world pass shows through (UI pass draws overlays only) function Renderer:beginFrame(transparent) + self.gen2FrameManaged, self.gen2FrameGame, self.gen2FrameZones = nil, nil, nil + self.gen2WindowDraw = nil -- a frame that ends early -- a state that returns before endFrame, an error -- caught upstream -- must not leak a queued native-resolution draw into the -- next frame's composite @@ -715,6 +719,10 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, love.graphics.setShader() end +function Renderer:blitZones(canvas,zones,w,h) + return Gen2Presentation.blit(canvas,zones,w,h) +end + -- THE BARS, FILLED WITH THE EDGE THEY FRAME. -- -- Asked for directly: "for all gen3 menus take the outside border 1px and @@ -874,7 +882,7 @@ function Renderer:endFrame(zones, worldZones) -- stacked Game Boy screens, or driving one onto a second physical display. -- The mod returns true to take over the whole window; anything else (or no -- mod wrapping the hook) falls through to the normal composite below. - if Runtime.wantsHook("render.compose") then + if not self.gen2FrameManaged and Runtime.wantsHook("render.compose") then local ctx = { renderer = self, worldCanvas = self.worldCanvas, uiCanvas = self.canvas, @@ -931,6 +939,9 @@ function Renderer:endFrame(zones, worldZones) and self.battleWipe.screenDraw local needPresent = GBCFX.active() or Pipelines.wantsPresent() or battleNeedsSource + or (self.gen2FrameManaged and (self.gen2WindowDraw + or (type(self.gen2FrameZones)=="table" and self.gen2FrameZones[1]) + or Runtime.wantsHook("render.compose"))) local present = nil if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww @@ -1351,6 +1362,10 @@ function Renderer:endFrame(zones, worldZones) -- over the finished composite rather than under the UI blit. On hardware -- it is the tilemap being overwritten -- there is nothing it does not cover. local battleSource = nil + if self.gen2FrameManaged and self.gen2WindowDraw then + self.gen2WindowDraw({width=ww,height=wh,gameX=ox,gameY=oy,gameWidth=vpw,gameHeight=vph,scale=Sp,dpiX=dpiX,dpiY=dpiY}) + end + if battleNeedsSource and present then -- Snapshot the untouched finished frame into a second screen-sized canvas. -- `present` is the current render target here, so sampling it directly in @@ -1394,21 +1409,25 @@ function Renderer:endFrame(zones, worldZones) if present then love.graphics.setCanvas() - -- Post-process pipelines run over the finished composite -- world, UI - -- and all -- and before GBC FX, so a blur or colour grade is what the - -- LCD grid is then drawn over rather than something that smears the - -- grid itself. Each pass hands back a canvas; with none registered - -- this returns `present` unchanged and the frame is byte-identical. - local composed = Pipelines.present(present, - { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present - if GBCFX.active() then - -- shader grid/shadow math is in framebuffer pixels - GBCFX.present(composed, Sp) - else - -- the present canvas only existed for the post-process, so put the - -- result on the screen at the same 1:1 unit mapping it was built at - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(composed, 0, 0) + local compositionHandled=false + if self.gen2FrameManaged and Runtime.wantsHook("render.compose") then + local ctx={renderer=self,game=self.gen2FrameGame,worldCanvas=present,uiCanvas=present,sceneCanvas=present, + worldActive=self.worldActive and true or false,generation=2,zones=self.gen2FrameZones,worldZones=nil, + ww=ww,wh=wh,pw=pw,ph=ph,ox=ox,oy=oy,vpw=vpw,vph=vph,uiw=uiw,uih=uih, + scale=Sp,Sx=Sx,Sy=Sy,dpiX=dpiX,dpiY=dpiY,secondScreen=require("src.render.SecondScreen")} + love.graphics.push("all") + local ok,value=xpcall(function() + return Runtime.call("render.compose",function()return false end,self,ctx)==true + end,debug.traceback) + love.graphics.pop() + if not ok then error(value,0)end + compositionHandled=value + end + if not compositionHandled then + local source=self.gen2FrameManaged and Gen2Presentation.apply(present,self.gen2FrameZones,ww,wh)or present + local composed=Pipelines.present(source,{width=ww,height=wh,scale=Sp,dpi=dpiY,dpiX=dpiX,dpiY=dpiY})or source + if GBCFX.active()then GBCFX.present(composed,Sp) + else love.graphics.setColor(1,1,1,1);love.graphics.draw(composed,0,0)end end end self.worldActive = false diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index f22e1907b..bd36be9f2 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -5,6 +5,7 @@ local Assets = require("src.render.Assets") local PaletteFX = require("src.render.PaletteFX") +local GameVersion = require("src.core.GameVersion") local SpriteRenderer = {} SpriteRenderer.__index = SpriteRenderer @@ -46,6 +47,10 @@ end local obpCache = {} local function getObpImage(path, colors, group) + if GameVersion.isGen2() then + colors = PaletteFX.gen2BakeColors(colors, PaletteFX.darkWorld()) + group = tostring(group) .. "#" .. PaletteFX.displayKey() .. PaletteFX.darkKey() + end local key = path .. "#obp" .. group if not obpCache[key] then local img diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index a336f0035..30429842d 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -576,11 +576,18 @@ local function gen2PalColors(tileset, row) return byTod[row] or byTod.DAY or tileset.palColors end +local function gen2DisplayRows(rows, row) + if not (rows and require("src.core.GameVersion").isGen2()) then return rows end + local out = {} + for i, colors in ipairs(rows) do out[i] = PaletteFX.gen2BakeColors(colors, row == "DARK") end + return out +end + -- One atlas per (tileset image, COLORS mode, time of day). Leaving the mode -- out of this key is why switching COLORS did nothing out in the Gen2 -- overworld: the first bake won and every later mode kept being handed it. local function gen2AtlasKey(imagePath, roof, special, row) - return imagePath .. "#gen2pal:" .. tostring(PaletteFX.mode) .. ":" + return imagePath .. "#gen2pal:" .. PaletteFX.displayKey() .. ":" .. tostring(row or PaletteFX.effectiveGen2Tod()) -- TWO MAPS ON ONE TILESET ARE TWO DIFFERENT PICTURES once the roof is in -- it. Violet and Azalea share TilesetJohto1 and wear different roofs, so @@ -744,6 +751,7 @@ function TileRenderer.gen2AtlasFor(tileset) if not (tileset.palMap and #tileset.palMap > 0) then return nil end if not PaletteFX.usesGen2BgPal() then return nil end local colors = gen2PalColors(tileset) + colors = gen2DisplayRows(colors, PaletteFX.effectiveGen2Tod()) if not (colors and #colors > 0) then return nil end local perRow = tileset.tilesPerRow if not perRow or perRow < 1 then return nil end @@ -1343,6 +1351,7 @@ function TileRenderer.new(map, data) -- tileset would otherwise overwrite each other's roof. local roof = gen2RoofFor(map, data,gen2Row) gen2Colors=require('src.render.Gen2PaletteContext').withRoof(gen2Colors,roof) + gen2Colors=gen2DisplayRows(gen2Colors,gen2Row) local key = gen2AtlasKey(map.tileset.image, roof, specialKey,gen2Row) local gen2img = getGen2Atlas(key, map.tileset.image, map.tileset.tilesPerRow, map.tileset.palMap, gen2Colors, diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..92c9de220 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -104,8 +104,8 @@ local function stepVolume(v, dir) end local function colorIndex(opts) - local cur = opts.colors or "gbc" - for i, m in ipairs(PaletteFX.MODES) do + local cur = GameVersion.isGen2() and PaletteFX.mode or opts.colors or "gbc" + for i, m in ipairs(PaletteFX.colorModes()) do if m == cur then return i end end return 1 @@ -370,14 +370,17 @@ local function buildRows(game) end }, { id = "colors", label = Strings("COLORS"), value = function(g) - return PaletteFX.modeLabel(g.save.options.colors or "gbc") + local mode = GameVersion.isGen2() and PaletteFX.mode or g.save.options.colors or "gbc" + return PaletteFX.modeLabel(mode) end, step = function(g, dir) local o = g.save.options local i = colorIndex(o) - i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1 - o.colors = PaletteFX.MODES[i] - PaletteFX.setMode(o.colors) + local modes = PaletteFX.colorModes() + i = wrapIndex(i - 1 + dir, #modes) + 1 + PaletteFX.writeColorOption(o, modes[i]) + if GameVersion.isGen2() then PaletteFX.setDisplay(o.colors,nil) + else PaletteFX.setMode(o.colors) end return true end }, { id = "tilt", label = Strings("TILT"), diff --git a/tests/engine/gen2_display_loader.lua b/tests/engine/gen2_display_loader.lua new file mode 100644 index 000000000..9130c5514 --- /dev/null +++ b/tests/engine/gen2_display_loader.lua @@ -0,0 +1,45 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion') +require('src.core.ModCompat').install() +local P=require('src.render.PaletteFX');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local A={{255,255,255},{193,41,71},{31,139,83},{0,0,0}} +local R={{239,223,197},{173,137,91},{97,59,29},{19,13,7}} +local S={{217,231,243},{137,171,209},{61,103,151},{11,23,41}} +V.set('crystal') +for _,api in ipairs({1,2})do + local files={['mods/palette-cache/manifest.json']=Json.encode({id='palette_cache',name='Registered indexed texture producer',version='1.0.0',api=api,content_format=api==3 and 'unified'or'native',entry='main.lua',affects_link=false}), + ['mods/palette-cache/main.lua']=[[ + local mod=...;local G=require('src.render.GbcPalette');local Assets=require('src.render.Assets') + local cache,flushes={},0 + Assets.register(function()cache={};flushes=flushes+1 end) + mod.exports.palette=function(source) + local key=G.mode + if not cache[key]then + local resolved=G.resolve(source);local rows={} + for i=1,4 do rows[i]={resolved[i][1],resolved[i][2],resolved[i][3]}end + cache[key]=rows + end + return cache[key],flushes,key + end + ]]} + P.setDisplay('gbc',nil) + local loaded=T.sdk.loadMods({'mods/palette-cache'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,'API'..api..' actual texture producer loads') + local consumer=loaded.loader.exports.palette_cache + local initial=consumer.palette(A);T.same(initial,A,'API'..api..' initial native row') + P.setDisplay('custom',R) + local first,count,mode=consumer.palette(A) + T.same(first,R,'API'..api..' first display ramp reaches registered producer') + T.eq(mode,'custom','API'..api..' public mode follows native owner') + P.setCustomRamp(S) + local second,newCount,sameMode=consumer.palette(A) + T.eq(sameMode,mode,'API'..api..' second ramp retains public mode ID') + T.eq(newCount,count+1,'API'..api..' native change flushes registered cache once') + T.neq(second,first,'API'..api..' producer rebuilds baked colour rows') + T.same(second,S,'API'..api..' second ramp replaces stale atlas colours') + Loader.teardown();loaded.release() +end +P.setDisplay('gbc',nil) +T.finish('native Gen2 display changes invalidate loaded API 1/2 texture producers') diff --git a/tests/engine/gen2_display_modes.lua b/tests/engine/gen2_display_modes.lua new file mode 100644 index 000000000..a9b181046 --- /dev/null +++ b/tests/engine/gen2_display_modes.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local V=require('src.core.GameVersion') +local P=require('src.render.PaletteFX');local G=require('src.render.GbcPalette') +local TR=require('src.render.TileRenderer');local SR=require('src.render.SpriteRenderer') +local ML=require('src.world.MapLoader');local Assets=require('src.render.Assets') +local Data=require('src.core.Data');local Game=require('src.core.Game') +local Battle=require('src.battle.BattleState') +local assetChanges=0 +Assets.register(function()assetChanges=assetChanges+1 end) +local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} +local B={{245,239,227},{31,97,193},{167,59,131},{7,13,19}} +local R={{231,219,207},{181,151,111},{101,67,37},{23,17,11}} +local GRAY={{255,255,255},{170,170,170},{85,85,85},{0,0,0}} +local function buffer(w,h,source) + local b={w=w,h=h,pixels={}} + function b:getDimensions()return self.w,self.h end + function b:getWidth()return self.w end;function b:getHeight()return self.h end + function b:getPixel(x,y) + local p=self.pixels[y*self.w+x];if p then return unpack(p)end + local n=source and 1-x%4/3 or 0;return n,n,n,source and 1 or 0 + end + function b:setPixel(x,y,...)self.pixels[y*self.w+x]={...}end + function b:mapPixel(f)for y=0,self.h-1 do for x=0,self.w-1 do self:setPixel(x,y,f(x,y,self:getPixel(x,y)))end end end + function b:setFilter()end;function b:setWrap()end;function b:release()end + return b +end +love.image={newImageData=function(w,h)return buffer(w,h)end} +love.graphics.newImage=function(b)return b end +local function source(path) + if path=='display-tiles' then return buffer(16,8,true)end + if path=='display-roof' then return buffer(8,8,true)end + return buffer(16,96,true) +end +Assets.image=source;Assets.imageData=source +local function pixels(image,colors,start,keyed,label) + for i=1,4 do + local r,g,b,a=image:getPixel((start or 0)+i-1,0) + if keyed and i==1 then T.eq(a,0,label..' OBJ shade zero remains transparent') + else T.same({r,g,b,a},{colors[i][1]/255,colors[i][2]/255,colors[i][3]/255,1},label..' shade '..i)end + end +end +local function fixture() + local d=T.fixtures.fresh() + local ts={id='DISPLAY',image='display-tiles',tilesPerRow=2,palMap={0,1},palColorsByTod={DAY={A,B},NITE={B,A},DARK={A,B}}, + animatedTiles={{tile=0,kind='hshift',offsets={0,1},period=20}},blockCells=2,blocks={{0,1,0,1}},walkable={0},grassTiles={},waterTiles={}} + d.tilesets={DISPLAY=ts} + d.maps={DISPLAY={id='DISPLAY',label='DISPLAY',width=1,height=1,blocks={0},tileset='DISPLAY',environment=1,mapPalette=1,group=1,number=1}} + d.pokemon={TEST={id='TEST',name='TEST',index=1,dex=1,palette='QA_TEST',spriteFront='display-mon'}} + d.palettes={palettes={QA_TEST=A,MEWMON=A,GRAYMON=GRAY,BLACK={{0,0,0},{0,0,0},{0,0,0},{0,0,0}}},pokemon={TEST='QA_TEST'}} + d.field.gen2Roofs={tilesets={DISPLAY=true},byGroup={[1]=0},images={[0]='display-roof'},slot=1,count=1,palIndex=1, + colors={[1]={DAY={{53,63,73},{83,93,103}}}}} + Data.publishGen2Palettes(d) + return d,ts +end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);P.setDisplay('gbc',nil);P.setShadeMap(nil);P.setDarkWorld(false);P.setGen2MapPalette(1);P.setGen2Tod('DAY') + local d,ts=fixture();ML.invalidateAll() + local mon={species='TEST',dvs={attack=1,defense=1,speed=1,special=1}} + local battle=setmetatable({data=d,game={save={party={}}}},Battle) + local factory + for i=1,100 do local name,value=debug.getupvalue(Battle.speciesSprite,i);if name=='getImage'then factory=value;break end end + assert(factory,'native battle image factory unavailable') + local originalPic=factory('display-mon',{name='QA_TEST',colors=A},false) + local sprite=SR.new({id='DISPLAY',image='display-sprite',gen2ObjPal=A}) + local reloads=0 + Game.overworld={map=ML.load(d,'DISPLAY'),reloadMap=function(self,id,reason) + T.eq(reason,'colors',edition..' native map reload identifies display change') + self.map=ML.load(d,id);self.tiles=TR.new(self.map,d);reloads=reloads+1 + end} + for _,choice in ipairs({{'gbc',A},{'dmg',GRAY},{'classic',GRAY},{'custom',R}})do + local name,wanted=choice[1],choice[2] + local oldMap=Game.overworld.map + if name=='custom' then P.setDisplay(name,R)else P.setDisplay(name,nil)end + T.eq(G.mode,name,edition..' native/public mode agrees '..name) + T.eq(G.customRamp,name=='custom'and R or nil,edition..' public ramp is actual native ramp') + if name~='gbc' then T.neq(Game.overworld.map,oldMap,edition..' current map cache replaced '..name)end + local tiles=TR.new(ML.load(d,'DISPLAY'),d) + pixels(tiles.image,wanted,0,false,edition..' actual atlas '..name) + local roof=name=='gbc'and{B[1],{53,63,73},{83,93,103},B[4]}or wanted + pixels(tiles.image,roof,8,false,edition..' actual roof '..name) + T.check(tiles.anims and tiles.anims[1],edition..' native water animation exists') + pixels(tiles.anims[1].textures[1],wanted,0,false,edition..' actual animated tile '..name) + pixels(sprite:resolveImage(),wanted,0,true,edition..' retained native sprite '..name) + pixels(battle:picImage(originalPic),wanted,0,false,edition..' retained native battle picture '..name) + battle.fx={bgp={[0]=3,2,1,0}} + pixels(battle:picImage(originalPic),{wanted[4],wanted[3],wanted[2],wanted[1]},0,false,edition..' battle fade after display '..name) + battle.fx=nil;battle.blackedOut=true + pixels(battle:picImage(originalPic),{wanted[4],wanted[4],wanted[4],wanted[4]},0,false,edition..' battle blackout '..name) + battle.blackedOut=false + if name=='classic' then T.eq(G.presentColors(),P.CLASSIC,edition..' Classic has one final green ramp') + else T.eq(G.presentColors(),nil,edition..' mode has no extra green pass '..name)end + end + local count=reloads;local assetCount=assetChanges;G.setCustomRamp(R);T.eq(reloads,count,edition..' identical ramp avoids reload') + T.eq(assetChanges,assetCount,edition..' identical ramp avoids shared asset churn') + local oldKey=P.displayKey();R[2][1]=R[2][1]+1;G.setCustomRamp(R) + T.neq(P.displayKey(),oldKey,edition..' edited ramp changes native cache key') + T.eq(reloads,count+1,edition..' reapplying edited ramp refreshes current map once') + T.eq(assetChanges,assetCount+1,edition..' edited ramp notifies registered external caches once') + pixels(sprite:resolveImage(),R,0,true,edition..' sprite rebakes edited ramp') + G.setBgp(0xff) + pixels(TR.new(ML.load(d,'DISPLAY'),d).image,R,0,false,edition..' transient BGP never baked into atlas') + G.setBgp(nil);P.setDarkWorld(true) + pixels(sprite:resolveImage(),{R[3],R[4],R[4],R[4]},0,true,edition..' custom world darkness preserved') + P.setDarkWorld(false) + T.eq(G.cycle(), 'dmg',edition..' custom cycle returns to native ladder') + T.eq(G.customRamp,nil,edition..' cycling removes custom ramp') + T.eq(G.cycle(),'classic',edition..' DMG advances to Classic') + T.eq(G.cycle(),'gbc',edition..' Classic advances to GBC') + T.eq(G.cycle(-1),'classic',edition..' backward cycle supported') + count=reloads;assetCount=assetChanges;G.applyOptions({color='custom',palette='m:80a0c0'}) + T.eq(reloads,count+1,edition..' saved mode and palette applied atomically') + T.eq(assetChanges,assetCount+1,edition..' saved mode and palette flush shared assets once') + T.same(G.customRamp,{{128,160,192},{92,115,138},{49,61,73},{15,19,23}},edition..' saved RGB palette reaches native owner') + local opts={color='custom',colors='custom',palette='m:80a0c0'} + P.writeColorOption(opts,P.cycleMode());T.eq(opts.palette,nil,edition..' manual cycle clears saved palette') + T.eq(opts.color,'dmg',edition..' legacy option tracks actual new mode');T.eq(opts.colors,'og',edition..' native option tracks same mode') + G.applyOptions(opts);T.eq(G.mode,'dmg',edition..' reapply cannot revive stale custom selection') + local menuGame={data=d,save={options=opts}} + local menu=require('src.ui.OptionsMenu').new(menuGame) + local row;for _,candidate in ipairs(menu.rows)do if candidate.id=='colors'then row=candidate end end + T.check(row,edition..' actual native options has display row') + T.eq(row.value(menuGame),'DMG',edition..' options reports actual native selection') + row.step(menuGame,1) + T.eq(opts.color,'classic',edition..' options persists legacy Classic selection') + T.eq(opts.colors,'classic',edition..' options persists same native selection') + T.eq(G.mode,'classic',edition..' actual options changes native renderer mode') + T.eq(row.value(menuGame),'CLASSIC',edition..' options reports switched mode') + row.step(menuGame,-1);G.applyOptions(opts) + T.eq(G.mode,'dmg',edition..' backwards options choice survives apply') + Game.overworld=nil +end +V.set('emerald');P.setDisplay('classic',R);T.eq(P.displayColors(A),A,'Gen3 palette behavior preserved');T.eq(P.presentColors(),nil,'Gen3 receives no Classic pass') +V.set('red');P.setDisplay('classic',nil);T.eq(P.displayColors(A),P.CLASSIC,'Gen1 Classic remains its original palette path') +T.finish('native Gen2 modes, cached atlas/roof/animation/OBJ/battle bakes and saved options') diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua index c8aeef1d8..c60ec63a5 100644 --- a/tests/engine/gen2_palette_draw.lua +++ b/tests/engine/gen2_palette_draw.lua @@ -66,7 +66,7 @@ for _,game in ipairs({'red','gold','silver','crystal'})do for _,mode in ipairs(P.MODES)do P.setMode(mode);P.setShadeMap({[0]=3,[1]=1,[2]=2,[3]=0}) local resolved=P.effectiveColors(A) - local display=game~='red'and'gbc'or(mode=='classic'and'classic'or((mode=='og'or mode=='og_inv')and'dmg'or'gbc')) + local display=mode=='classic'and'classic'or((mode=='og'or mode=='og_inv')and'dmg'or'gbc') T.eq(G.mode,display,game..' '..mode..' public mode follows actual native resolution') local trueColor=true T.eq(not(trueColor and G.mode=='gbc'),display~='gbc',game..' '..mode..' unchanged evolution colour gate preserves true-colour art') diff --git a/tests/engine/palette_library.lua b/tests/engine/palette_library.lua new file mode 100644 index 000000000..3ee468219 --- /dev/null +++ b/tests/engine/palette_library.lua @@ -0,0 +1,50 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local library={{name='Authored',palettes={ + {'BLUE','00112233445566778899aabb'}, + {'GRAY','000000555555aaaaaaffffff'}, + {'MIX','ff000000ff000000ffffffff'}, + {'INVALID','not a ramp'}, +}}} +package.loaded['data.gb_palettes']=library +local P=require('src.render.Palette') +T.eq(P.packs(),library,'named ramps come from actual optional authored library') +T.eq(P.packId('Authored','BLUE'),'p:Authored/BLUE','named palette ID') +T.eq(P.monoId(128,160,192),'m:80a0c0','RGB palette ID') +T.same(P.ramp('m:80a0c0'),{{128,160,192},{92,115,138},{49,61,73},{15,19,23}},'mono ramp uses public four-step intensities') +T.same(P.unhex('00112233445566778899aabb'),{{0,17,34},{51,68,85},{102,119,136},{153,170,187}},'hex row order') +T.same(P.ramp('p:Authored/BLUE'),{{153,170,187},{102,119,136},{51,68,85},{0,17,34}},'named library order reversed into light-to-dark drawing ramp') +T.same(P.swatch('p:Authored/BLUE'),{{0,17,34},{51,68,85},{102,119,136},{153,170,187}},'swatch reverses drawing ramp') +T.eq(P.findPack('Authored','BLUE'),library[1].palettes[1],'lookup keeps library ownership') +T.eq(P.label('p:Authored/BLUE'),'BLUE','named display label') +T.eq(P.label('m:9bbc0f'),'GREEN','known mono colour label') +T.eq(P.label('m:123456'),'MONO','authored mono colour label') +T.eq(P.label('unknown'),'unknown','unknown ID label remains observable') +T.eq(P.label(''),nil,'empty label absent') +for _,id in ipairs({'m:bad','m:1234567','m:xxxxxx','p:missing/entry','p:Authored/INVALID',''})do + T.eq(P.ramp(id),nil,'invalid/missing palette not invented: '..id) +end +for _,value in ipairs({false,'','00112233445566778899aabx','00112233445566778899aab'})do + T.eq(P.unhex(value),nil,'invalid hex refused') +end +T.eq(P.categoryOf(P.unhex('000000555555aaaaaaffffff')),'grey','gray category') +T.eq(P.categoryOf(P.unhex('00112233445566778899aabb')),'single','single hue category') +T.eq(P.categoryOf(P.unhex('ff000000ff000000ffffffff')),'full','full hue category') +T.eq(P.categoryOf({}),nil,'incomplete ramp has no category') +T.eq(P.hueArc({350,10}),20,'hue arc crosses zero') +T.eq(P.hueArc({}),360,'empty hue-arc protocol') +local hue,chroma=P.hueChroma({255,0,0});T.eq(hue,0,'red hue');T.eq(chroma,255,'red chroma') +local categories=P.categories();T.eq(#categories,3,'three public categories') +T.eq(categories[1].key,'full','category order preserved');T.eq(#categories[1].palettes,1,'invalid ramp omitted') +local key,at=P.locate('p:Authored/BLUE');T.eq(key,'single','locate category');T.eq(at,1,'locate index') +T.eq(P.category('single').palettes[1].pack,'Authored','category entry keeps source pack') +T.eq(P.category('unknown'),nil,'unknown category absent') +T.eq(P.locate('m:80a0c0'),nil,'mono ramp is not a named-library row') +local old=P.ramp('p:Authored/BLUE');T.eq(P.ramp('p:Authored/BLUE'),old,'same ramp ID retains cached table') +library[1].palettes[1][2]='112233445566778899aabbcc';P.invalidate() +local updated=P.ramp('p:Authored/BLUE');T.neq(updated,old,'invalidate rebuilds ramps') +T.same(updated[1],{170,187,204},'updated authored library reaches drawing ramp') +T.eq(P.packs(),library,'invalidating derived indices preserves library owner') +T.finish('native public palette IDs, authored libraries and colour categories') diff --git a/tests/gpu/gen2_display_modes.lua b/tests/gpu/gen2_display_modes.lua new file mode 100644 index 000000000..798caa694 --- /dev/null +++ b/tests/gpu/gen2_display_modes.lua @@ -0,0 +1,153 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- ROM-free native GPU composition, including final CGB zones and window UI. +local M={} +function M.run() + require('src.core.ModCompat').install() + local P=require('src.render.PaletteFX');local G=require('src.render.GbcPalette') + local V=require('src.core.GameVersion');local Renderer=require('src.render.Renderer') + local Present=require('src.render.Gen2Presentation');local haveViewport,Viewport=pcall(require,'src.render.GameViewport') + if not haveViewport then Viewport={setTarget=function()love.graphics.setCanvas()end}end + local Runtime=require('src.mods.Runtime');local Hooks=require('src.mods.Hooks') + local g=love.graphics + local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} + local R={{231,219,207},{181,151,111},{101,67,37},{23,17,11}} + local GRAY={{255,255,255},{170,170,170},{85,85,85},{0,0,0}} + local original={version=V.get(),mode=P.mode,ramp=P.customRamp(),shadeMap=P.shadeMap(),setTarget=Viewport.setTarget,setCanvas=g.setCanvas, + events=Runtime.events,hooks=Runtime.hooks,errors=Runtime.errors} + local w,h=g.getDimensions() + local output=g.newCanvas(w,h,{format='rgba8',dpiscale=1}) + local sheet=g.newCanvas(w,h*4,{format='rgba8',dpiscale=1}) + M.image=sheet + local data=love.image.newImageData(4,1,'rgba8') + for x=0,3 do local n=1-x/3;data:setPixel(x,0,n,n,n,1)end + local image=g.newImage(data);image:setFilter('nearest','nearest') + local checks=0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local function same(actual,wanted,label) + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255,label..' channel '..c..' got '..actual[c]..' expected '..wanted[c])end + end + local function color(c)return {c[1]/255,c[2]/255,c[3]/255,1}end + local function sample(pixels,x,y)return {pixels:getPixel(math.floor(x),math.floor(y))}end + local game={nativeGpuOwner=true} + local bus=Hooks.new();local zoneCalls,composeCalls=0,0 + local nativeViewport + if haveViewport then Viewport.setTarget=function()g.setCanvas(output)end + else local setCanvas=g.setCanvas;g.setCanvas=function(canvas,...)return setCanvas(canvas or output,...)end end -- bounded output IO only + g.push('all') + local ok,why=xpcall(function() + V.set('crystal');P.setShadeMap(nil);P.setDarkWorld(false) + local haveCorrection,Correction=pcall(require,'src.render.GbcColorCorrection');if haveCorrection then Correction.setMode('off')end + require('src.render.GBCFX').setLevel(0) + local havePresets,Presets=pcall(require,'src.render.ShaderPresets');if havePresets then Presets:off()end + Runtime.install(original.events,bus,{}) + Renderer:init() + local function scene(windowMenu) + g.setCanvas(output);g.origin();g.clear(0,0,0,1) + Renderer:beginFrame() + g.setColor(1,1,1,1) + G.with(A,function()g.draw(image,0,0,0,40,72)end) + local native=P.shader() + local cached=require('src.core.Performance').paletteZoneCache + if cached~=nil then check(cached,'native optimized upload enabled')end + P.sendColors(native,A);g.setShader(native);g.draw(image,0,72,0,40,72);g.setShader() + Renderer.gen2FrameManaged=true;Renderer.gen2FrameGame=game + Renderer.gen2FrameZones=Present.zones(game) + if windowMenu then Renderer.gen2WindowDraw=function() + g.setColor(1,1,1,1);g.rectangle('fill',0,0,w,12) + end end + nativeViewport=Renderer:endFrame(nil,nil) + g.setCanvas();g.setShader() + original.setCanvas() -- leave the bounded legacy output target before readback + return g.readbackTexture(output) + end + for i,choice in ipairs({{'gbc',A},{'dmg',GRAY},{'classic',P.CLASSIC},{'custom',R}})do + local mode,wanted=choice[1],choice[2] + P.setDisplay(mode,mode=='custom' and R or nil) + local pixels=scene(false) + for _,y in ipairs({36,108})do for x=0,3 do + same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+y*nativeViewport.scale),color(wanted[x+1]),'native/public composite '..mode..' shade '..x..' row '..y) + end end + g.setCanvas(sheet);g.origin();g.setShader();g.setColor(1,1,1,1);g.setBlendMode('replace','premultiplied');g.draw(output,0,(i-1)*h) + g.setCanvas();pixels:release() + end + P.setDisplay('classic',nil) + local pixels=scene(true) + same(sample(pixels,w-4,4),color(P.CLASSIC[1]),'native window-space menu included before Classic at top right') + same(sample(pixels,w-4,h-4),color(P.CLASSIC[4]),'native final image keeps bottom-right orientation') + pixels:release() + local detachZones=bus:wrap('render.zones',function(next,owner,zones) + zoneCalls=zoneCalls+1 + check(owner==game,'zone hook receives actual game owner') + check(zones[1].colors==P.CLASSIC,'zone hook observes final green ramp') + -- Override final Classic as the public seam permits. Resolve must not + -- turn this authored destination back into grayscale. + zones[1].colors=R + return next(owner,zones) + end,0,'gpu-palette-consumer') + pixels=scene(false) + check(zoneCalls==1,'zone hook called once for completed frame') + for x=0,3 do same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+72*nativeViewport.scale),color(R[x+1]),'authored final zone shade '..x)end + pixels:release();detachZones() + local detachCompose=bus:wrap('render.compose',function(_,renderer,ctx) + composeCalls=composeCalls+1 + check(renderer==Renderer and ctx.renderer==Renderer,'native renderer remains composition owner') + check(ctx.game==game and ctx.generation==2,'composition identifies actual game/generation') + check(ctx.worldCanvas==ctx.sceneCanvas and ctx.uiCanvas==ctx.sceneCanvas,'composition receives completed Gen2 scene') + check(ctx.zones[1].colors==P.CLASSIC,'composition receives resolved final palette') + check(g.getCanvas()~=ctx.sceneCanvas,'composition never reads its active render target') + -- Taking ownership bypasses the ordinary tint and post-process, so a + -- reference-style consumer can present the supplied zones itself. + renderer:blitZones(ctx.sceneCanvas,ctx.zones,ctx.ww,ctx.wh) + return true + end,0,'gpu-frame-consumer') + pixels=scene(false) + check(composeCalls==1,'compose invoked once after native scene') + for x=0,3 do same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+72*nativeViewport.scale),color(P.CLASSIC[x+1]),'composition-owned final shade '..x)end + pixels:release();detachCompose() + -- Empty/backwards/outside rectangles are valid no-op draws. A false + -- palette is a raw RGB opt-out, and scoped state must be restored. + local raw=g.newCanvas(4,1,{format='rgba8',dpiscale=1}) + g.setCanvas(raw);g.clear(0,0,0,1) + local external=G.shader();G.useRaw(A) + Present.blit(image,{{x=0,y=0,w=-1,h=144,colors=R},{x=200,w=5,colors=R},{x=0,y=0,w=160,h=144,colors=false}},4,1) + check(g.getShader()==external,'public final blit restores existing shader') + g.setCanvas();g.setShader();local plain=g.readbackTexture(raw) + for x=0,3 do same(sample(plain,x,0),color(GRAY[x+1]),'false-zone raw RGB '..x)end + g.setCanvas(raw);G.useRaw(A) + Present.blit(image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + check(g.getShader()==external,'authored final blit restores shader identity') + g.draw(image,0,0);g.setCanvas();g.setShader() + local restored=g.readbackTexture(raw) + for x=0,3 do same(sample(restored,x,0),color(A[x+1]),'authored final blit restores managed uniforms '..x)end + g.setCanvas(raw);G.useRaw(A) + Present.apply(image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + check(g.getCanvas()==raw and g.getShader()==external,'offscreen final pass restores target and shader') + g.draw(image,0,0);g.setCanvas();g.setShader() + local applied=g.readbackTexture(raw) + for x=0,3 do same(sample(applied,x,0),color(A[x+1]),'offscreen final pass restores managed uniforms '..x)end + local draw=g.draw + g.draw=function(source,...) + if source==image then error('authored final draw failure',0)end + return draw(source,...) + end + g.setCanvas(raw);G.useRaw(A) + local failed,reason=pcall(Present.apply,image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + g.draw=draw + check(not failed and tostring(reason):find('authored final draw failure',1,true),'final draw error propagates') + check(g.getCanvas()==raw and g.getShader()==external,'failed final pass restores target and shader') + g.draw(image,0,0);g.setCanvas();g.setShader() + local recovered=g.readbackTexture(raw) + for x=0,3 do same(sample(recovered,x,0),color(A[x+1]),'failed final pass restores managed uniforms '..x)end + recovered:release() + restored:release();applied:release() + raw:release();plain:release() + end,debug.traceback) + g.setCanvas();g.pop() + g.setCanvas=original.setCanvas;g.setCanvas();Viewport.setTarget=original.setTarget;Runtime.install(original.events,original.hooks,original.errors) + V.set(original.version);P.setDisplay(original.mode,original.ramp);P.setShadeMap(original.shadeMap) + output:release();image:release();data:release() + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='Native Gen2 GPU composition in GBC/DMG/Classic/custom, final Classic/zone hooks, completed-scene composition ownership, window-space menus, scoped raw blits'},sheet +end +return M From 2383d847bea75fe07c520ae9ceabbb89d73e6109 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:21:00 +0100 Subject: [PATCH 18/64] Draw native Gen2 menu owners at output resolution --- src/core/Game.lua | 30 ++++++++++- src/render/ScreenSpaceUI.lua | 93 ++++++++++++++++++++++++++++++++ tests/engine/screen_space_ui.lua | 68 +++++++++++++++++++++++ tests/gpu/gen2_display_modes.lua | 8 ++- tests/gpu/gen2_native_stack.lua | 41 ++++++++++++++ 5 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 src/render/ScreenSpaceUI.lua create mode 100644 tests/engine/screen_space_ui.lua create mode 100644 tests/gpu/gen2_native_stack.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 60bd2784c..4a3db7a15 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1013,6 +1013,13 @@ function Game:_draw() -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) + local ScreenSpaceUI=require('src.render.ScreenSpaceUI') + local scene=self.stack + local windowUI,windowUIIndex=ScreenSpaceUI.select(self.stack,scene,drawFrom) + local windowUIOverlay=false + local nativeWindowOverlays=windowUI and require('src.core.GameVersion').isGen2() + local nativeWindowStack=not windowUI and require('src.core.GameVersion').isGen2() + and ScreenSpaceUI.hasNativeStack(self.stack,scene,drawFrom,self.overworld) local worldDrawn = self.stack.states[drawFrom] == self.overworld -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred @@ -1095,7 +1102,13 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if nativeWindowOverlays and windowUIIndex and i>windowUIIndex then + if state and state.draw then windowUIOverlay=true end + elseif nativeWindowStack and state~=self.overworld then + -- Native controllers draw once on the full output surface below. + elseif state==windowUI then + love.graphics.clear(0,0,0,0) + elseif state and state.draw then -- ...and it is centred by ITS OWN size, not by the Game Boy's (see -- Game.homeOffset). classicOffset still gates it: a surface that is -- the Game Boy's centres nothing, which is every frame outside a wide @@ -1117,7 +1130,7 @@ function Game:_draw() end local baseState = self.stack.states[drawFrom] local SuperSample = require("src.render.SuperSample") - if not worldDrawn and baseState and require("src.core.GameVersion").isNds() + if not windowUI and not worldDrawn and baseState and require("src.core.GameVersion").isNds() and SuperSample.singleScreen(self) and not SuperSample.active then SuperSample.draw(self, self.stack:top(), drawStates) else @@ -1187,7 +1200,20 @@ function Game:_draw() if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end + if Renderer.gen2FrameManaged and (windowUI or nativeWindowStack) then + Renderer.gen2WindowDraw=function(viewport) + if windowUI then + ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,false) + if windowUIOverlay then ScreenSpaceUI.drawNativeStack(self.stack,scene,windowUIIndex+1, + self.overworld,Renderer,viewport,false)end + else ScreenSpaceUI.drawNativeStack(self.stack,scene,drawFrom,self.overworld, + Renderer,viewport,not worldDrawn)end + end + end local viewport = Renderer:endFrame(zones, worldZones) + if windowUI and not Renderer.gen2FrameManaged then + ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,windowUIOverlay) + end -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. diff --git a/src/render/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua new file mode 100644 index 000000000..0f04a3bc7 --- /dev/null +++ b/src/render/ScreenSpaceUI.lua @@ -0,0 +1,93 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Optional window-resolution UI. Simulation/input remain native; only an +-- explicitly advertised drawer leaves the cartridge-sized UI canvas. +local M = {} +local function visible(stack, state) + return not stack.renderVisible or stack:renderVisible(state) +end +function M.select(stack, scene, first) + for i = #scene.states, first, -1 do + local state = scene.states[i] + if visible(stack, state) and type(state.drawsWidescreen) == 'function' + and type(state.drawWidescreen) == 'function' and state:drawsWidescreen() then + return state, i + end + end +end +function M.classic(draw, renderer, width, height) + local G = love.graphics + local uw, uh = renderer:uiSize() + local p = renderer.uiPresentation + if not p then + local scale = math.max(1, math.floor(math.min(width / uw, height / uh))) + p = { x=(width-uw*scale)/2, y=(height-uh*scale)/2, scaleX=scale, scaleY=scale } + end + G.push('all') + G.origin() + G.translate(p.x+(uw-renderer.WIDTH)*p.scaleX/2, p.y+(uh-renderer.HEIGHT)*p.scaleY/2) + G.scale(p.scaleX,p.scaleY) + local ok, err = pcall(draw) + G.pop() + if not ok then error(err,0) end +end +-- Gen2's reference renderer paints native overlays on its full-resolution +-- scene, under a cartridge-grid transform. A drawer that resets origin() +-- therefore gets real window pixels, not a 160x144 intermediate texture. +function M.hasNativeStack(stack, scene, first, world) + for i=first,#scene.states do + local state=scene.states[i] + if state and state~=world and visible(stack,state) and type(state.draw)=='function' then return true end + end + return false +end +function M.drawNativeStack(stack, scene, first, world, renderer, viewport, opaque) + local G=love.graphics + G.push('all');G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok,err=pcall(function() + if opaque then G.rectangle('fill',0,0,viewport.width,viewport.height) end + M.classic(function() + for i=first,#scene.states do + local state=scene.states[i] + if state and state~=world and visible(stack,state) and type(state.draw)=='function' then + G.push('all') + -- Declared larger native surfaces retain their own origin. Plain + -- GB states stay centred in a parent's expanded surface. + if type(state.uiSize)=='function' then + local sized,w,h=pcall(state.uiSize,state) + if sized and type(w)=='number' and w>0 and type(h)=='number' and h>0 then + G.translate((renderer.WIDTH-w)/2,(renderer.HEIGHT-h)/2) + end + end + local drawn,why=pcall(state.draw,state) + G.pop() + if not drawn then error(why,0) end + end + end + end,renderer,viewport.width,viewport.height) + end) + G.pop() + if not ok then error(err,0) end +end +function M.draw(owner, renderer, viewport, zones, overlay) + local G = love.graphics + G.push('all') + G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok, err = pcall(owner.drawWidescreen, owner, viewport.width, viewport.height) + G.pop() + if not ok then error(err,0) end + -- The classic canvas contains only states above this owner. Reapply it + -- after the high-resolution drawer so a native textbox keeps its parent. + if overlay then + local p = renderer.uiPresentation + if not p then + p = {x=viewport.gameX,y=viewport.gameY,scaleX=viewport.scale/(viewport.dpiX or 1),scaleY=viewport.scale/(viewport.dpiY or 1)} + end + G.push('all');G.origin();G.setShader();G.setColor(1,1,1,1);G.setBlendMode('alpha','premultiplied') + local overlayOK, overlayError = pcall(renderer.blitCanvas,renderer,renderer.canvas,p.scaleX,p.scaleY,zones,p.scaleX,p.scaleY, + p.x,p.y,0,0,viewport.width,viewport.height,viewport.dpiX or 1,viewport.dpiY or 1) + G.pop() + if not overlayOK then error(overlayError,0) end + end +end +return M diff --git a/tests/engine/screen_space_ui.lua b/tests/engine/screen_space_ui.lua new file mode 100644 index 000000000..c983542a4 --- /dev/null +++ b/tests/engine/screen_space_ui.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local UI=require('src.render.ScreenSpaceUI') +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local scene={states={}} +T.eq(UI.select(stack,scene,1),nil,'ordinary empty frame requests no window-resolution UI') +local trace={} +local parent={drawsWidescreen=function()return true end,drawWidescreen=function(_,w,h) + trace[#trace+1]='wide';T.eq(w,800,'window width reaches the advertised drawer');T.eq(h,720,'window height reaches the advertised drawer') + love.graphics.setColor(1,0,0,1) +end} +local overlay={draw=function()end} +scene.states={parent,overlay} +local owner,index=UI.select(stack,scene,1) +T.eq(owner,parent,'native overlay retains the visible wide parent') +T.eq(index,1,'wide owner position is preserved for overlay separation') +parent.drawsWidescreen=function()return false end +T.eq(UI.select(stack,scene,1),nil,'drawer alone does not opt in') +parent.drawsWidescreen=function()return true end +T.eq(UI.select(stack,scene,2),nil,'hidden below draw boundary is not resurrected') +local renderer={canvas={},WIDTH=160,HEIGHT=144,uiPresentation={x=0,y=0,scaleX=5,scaleY=5}} +function renderer:uiSize()return 160,144 end +function renderer:blitCanvas(canvas,sx,sy) + trace[#trace+1]='overlay';T.eq(canvas,self.canvas,'overlay reuses native canvas');T.eq(sx,5,'native overlay horizontal scale');T.eq(sy,5,'native overlay vertical scale') +end +local viewport={width=800,height=720,gameX=0,gameY=0,scale=5,dpiX=1,dpiY=1} +love.graphics.setColor(.2,.3,.4,1) +UI.draw(parent,renderer,viewport,nil,true) +T.same(trace,{'wide','overlay'},'native prompt is composited after its window-resolution parent') +local r,g,b=love.graphics.getColor();T.eq(r,.2,'drawer graphics state restored');T.eq(g,.3,'drawer cannot leak color');T.eq(b,.4,'overlay graphics state restored') +trace={};UI.draw(parent,renderer,viewport,nil,false) +T.same(trace,{'wide'},'frame without overlay does not duplicate the cartridge canvas') +parent.drawWidescreen=function()love.graphics.setColor(0,0,0,1);error('deliberate wide drawer failure')end +T.raises(function()UI.draw(parent,renderer,viewport,nil,false)end,'deliberate wide drawer failure','drawer errors remain observable') +T.eq(select(1,love.graphics.getColor()),.2,'failed drawer still restores graphics state') +local drew=0;UI.classic(function()drew=drew+1 end,renderer,800,720) +T.eq(drew,1,'classic fallback executes native draw exactly once') +-- Native Gen2 controllers can draw on the full-size output without a +-- low-resolution intermediate, while retaining their native stack lifetime. +trace={};local world={draw=function()trace[#trace+1]='world' end} +local hidden={draw=function()error('hidden native state drawn')end} +local output={width=800,height=720} +local native={draw=function(self) + T.eq(love.graphics.getCanvas(),output,'native drawer receives output canvas directly') + trace[#trace+1]='native';love.graphics.setColor(0,1,0,1) +end} +local front={draw=function() + local r=love.graphics.getColor();T.eq(r,1,'preceding native drawer cannot leak its graphics state') + trace[#trace+1]='front' +end} +scene.states={world,hidden,native,front} +local filtered={renderVisible=function(_,s)return s~=hidden end} +T.eq(UI.hasNativeStack(filtered,scene,1,world),true,'visible native stack requests output-resolution drawing') +T.eq(UI.hasNativeStack(filtered,{states={world,hidden}},1,world),false,'world and mirrored states alone request no UI pass') +love.graphics.setCanvas(output);love.graphics.setColor(.2,.3,.4,1) +UI.drawNativeStack(filtered,scene,1,world,renderer,viewport,false) +T.same(trace,{'native','front'},'world stays single-pass and native stack draw order is retained') +T.eq(love.graphics.getCanvas(),output,'native pass restores output target') +T.eq(select(1,love.graphics.getColor()),.2,'native pass restores caller color') +trace={};UI.drawNativeStack(filtered,scene,4,world,renderer,viewport,false) +T.same(trace,{'front'},'native overlays above wide owner exclude that owner') +native.draw=function()love.graphics.setCanvas({});error('native drawer failure')end +T.raises(function()UI.drawNativeStack(filtered,scene,3,world,renderer,viewport,false)end,'native drawer failure','native errors remain visible') +T.eq(love.graphics.getCanvas(),output,'failed native drawer restores output target') +love.graphics.setCanvas() +T.finish('screen-space UI owner, native overlay ordering and graphics isolation') diff --git a/tests/gpu/gen2_display_modes.lua b/tests/gpu/gen2_display_modes.lua index 798caa694..71826794c 100644 --- a/tests/gpu/gen2_display_modes.lua +++ b/tests/gpu/gen2_display_modes.lua @@ -53,8 +53,12 @@ function M.run() P.sendColors(native,A);g.setShader(native);g.draw(image,0,72,0,40,72);g.setShader() Renderer.gen2FrameManaged=true;Renderer.gen2FrameGame=game Renderer.gen2FrameZones=Present.zones(game) - if windowMenu then Renderer.gen2WindowDraw=function() - g.setColor(1,1,1,1);g.rectangle('fill',0,0,w,12) + if windowMenu then Renderer.gen2WindowDraw=function(viewport) + local vector={draw=function() + g.origin();g.setColor(1,1,1,1);g.rectangle('fill',0,0,w,12) + end} + require('src.render.ScreenSpaceUI').drawNativeStack({}, + {states={vector}},1,nil,Renderer,viewport,false) end end nativeViewport=Renderer:endFrame(nil,nil) g.setCanvas();g.setShader() diff --git a/tests/gpu/gen2_native_stack.lua b/tests/gpu/gen2_native_stack.lua new file mode 100644 index 000000000..755993335 --- /dev/null +++ b/tests/gpu/gen2_native_stack.lua @@ -0,0 +1,41 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Output pixels for the native Gen2 menu layer, independently of screen providers. +local M={} +function M.run() + local g=love.graphics;local Renderer=require('src.render.Renderer') + local oldSize,oldPresentation=Renderer.uiSize,Renderer.uiPresentation + local canvas=g.newCanvas(800,720,{format='rgba8',dpiscale=1});M.image=canvas + local checks=0;local function check(ok,why)checks=checks+1;assert(ok,why)end + g.push('all') + local ok,why=xpcall(function() + -- Bitmap coordinates keep integer cartridge scaling; a vector drawer that + -- resets origin() paints one actual output pixel, without enlarged texels. + Renderer.uiSize=function()return 160,144 end + Renderer.uiPresentation={x=0,y=0,scaleX=5,scaleY=5} + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.clear(0,0,0,1) + local native={draw=function() + check(g.getCanvas()==canvas,'native stack left the full-size output target') + g.setColor(1,1,1,1);g.rectangle('fill',2,4,1,1) + end} + local vector={draw=function() + g.origin();g.setColor(1,0,0,1);g.rectangle('fill',23,31,1,1) + end} + require('src.render.ScreenSpaceUI').drawNativeStack({}, + {states={native,vector}},1,nil,Renderer,{width=800,height=720},false) + g.setCanvas();local pixels=g.readbackTexture(canvas) + local r,green,b=pixels:getPixel(10,20) + check(r>.99 and green>.99 and b>.99,'native bitmap moved from cartridge pixel grid') + r,green,b=pixels:getPixel(14,24) + check(r>.99 and green>.99 and b>.99,'native bitmap scale changed') + r,green,b=pixels:getPixel(23,31) + check(r>.99 and green<.01 and b<.01,'vector drawer lost its output pixel coordinates') + r,green,b=pixels:getPixel(24,31) + check(r<.01 and green<.01 and b<.01,'one vector pixel enlarged through a low-resolution intermediate') + pixels:release() + end,debug.traceback) + g.pop();Renderer.uiSize=oldSize;Renderer.uiPresentation=oldPresentation + if not ok then error(why,0)end + return{passed=true,checks=checks,scope='Real GPU direct native/vector menu output pixels'},canvas +end +return M From ad00d3cf7db57746ea31cca7aae27777f433e3d1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:24:54 +0100 Subject: [PATCH 19/64] Draw native Gen2 animation objects through their actual palette view owner --- LICENSE-ROXAS2712.md | 88 +++++++++++++++++++ src/battle/BattleState.lua | 20 +++++ src/battle/Gen2AnimPlayer.lua | 67 +++++++------- src/battle/Gen2AnimView.lua | 108 +++++++++++++++++++++++ src/battle/WideBattle.lua | 2 + src/core/ModCompat.lua | 2 +- tests/engine/gen2_animation_objects.lua | 111 ++++++++++++++++++++++++ tests/gpu/gen2_animation_objects.lua | 80 +++++++++++++++++ 8 files changed, 444 insertions(+), 34 deletions(-) create mode 100644 LICENSE-ROXAS2712.md create mode 100644 src/battle/Gen2AnimView.lua create mode 100644 tests/engine/gen2_animation_objects.lua create mode 100644 tests/gpu/gen2_animation_objects.lua 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/battle/BattleState.lua b/src/battle/BattleState.lua index 1bebafb9d..752860978 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1241,6 +1241,8 @@ local function newBattle(game) local ok, Gen2AnimPlayer = pcall(require, "src.battle.Gen2AnimPlayer") if ok then self.animPlayer = Gen2AnimPlayer.new(game.data) + self.animPlayer.battle = self.battle + self.animView = self.animPlayer.view end else local ok, AnimPlayer = pcall(require, "src.battle.AnimPlayer") @@ -2875,6 +2877,9 @@ function BattleState:updateQueue() ballIndex = item.ball and (self:itemDef(item.ball) or {}).index or nil, + ballPalette = GameVersion.isGen2() and item.ball + and self:ballPalette((self:itemDef(item.ball) or {}).key or item.ball) + or nil, ballFlicker = item.ball and self:ballFlicker(item.ball) or nil } or nil) @@ -6778,6 +6783,21 @@ function BattleState:animationsOn() return not o or o.animations ~= false end +-- GetBallAnimPal / BallColors. Native item IDs retain their registry key; +-- the public item spelling selects the palette on the actual queued throw. +-- Names/order follow Gen1Recomp's Gen2 BattleState and cartridge BallColors. +local GEN2_BALL_PALETTES = { + MASTER_BALL='PAL_BATTLE_OB_GREEN', ULTRA_BALL='PAL_BATTLE_OB_YELLOW', + GREAT_BALL='PAL_BATTLE_OB_BLUE', POKE_BALL='PAL_BATTLE_OB_RED', + HEAVY_BALL='PAL_BATTLE_OB_GRAY', LEVEL_BALL='PAL_BATTLE_OB_BROWN', + LURE_BALL='PAL_BATTLE_OB_BLUE', FAST_BALL='PAL_BATTLE_OB_BLUE', + FRIEND_BALL='PAL_BATTLE_OB_YELLOW', MOON_BALL='PAL_BATTLE_OB_GRAY', + LOVE_BALL='PAL_BATTLE_OB_RED', +} +function BattleState:ballPalette(itemId) + return GEN2_BALL_PALETTES[itemId] or 'PAL_BATTLE_OB_GRAY' +end + -- Drop the announcement-time move-anim row. Gen 1 queues PlayMoveAnimation -- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips -- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index 2a5075488..f2e82d662 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -133,7 +133,7 @@ end function Gen2AnimPlayer.new(data) local anims = data and data.battle_anims or {} - return setmetatable({ + local self = setmetatable({ data = data, anims = anims, images = {}, @@ -141,14 +141,19 @@ function Gen2AnimPlayer.new(data) warned = {}, objects = {}, effects = {}, + frames = 0, + tail = 0, -- WideBattle reads these two off the Gen 1 player; keep them defined steps = {}, stepIndex = 1, done = true, }, Gen2AnimPlayer) + self.view = require('src.battle.Gen2AnimView').new(anims, data) + return self end function Gen2AnimPlayer:release() + if self.view then self.view:release(); self.view = nil end for _, img in pairs(self.images) do if img and img.release then pcall(img.release, img) end end @@ -237,13 +242,16 @@ local function ballParam(self, opts) end function Gen2AnimPlayer:start(moveId, attackerIsPlayer, opts) + if self.presentation then self.presentation.retire() end + self.presentation = nil self.objects = {} self.effects = {} self.callStack = {} - self.vars = { var = 0, param = 0, loop = nil } + self.vars = { var = 0, param = opts and opts.param or 0, loop = nil } self.wait = 0 self.tail = 0 self.ranFrames = 0 + self.frames = 0 self.wobbles = 0 self.keepSprites = false self.keptSprites = nil @@ -818,6 +826,7 @@ function Gen2AnimPlayer:playCry() end function Gen2AnimPlayer:update() + self.frames = (self.frames or 0) + 1 if self.done then if self.tail > 0 then self.tail = self.tail - 1 end if self.tail <= 0 and not self.keepSprites then self.objects = {} end @@ -926,50 +935,42 @@ function Gen2AnimPlayer:finalSprites() return self.keptSprites end -function Gen2AnimPlayer:drawSprites(objects) - local g = love and love.graphics - if not g or not objects then return end - local shader = require("src.render.PaletteFX").shader() - for _, obj in ipairs(objects) do +-- Project the actual current OAM rows. This is also the input to the native +-- object renderer; readers never advance frames or own the VM's objects. +function Gen2AnimPlayer:oam(objects) + local out = {} + for _, obj in ipairs(objects or self.objects) do local set = frameset(self, obj) local frame = set and set.frames[obj.frame] - -- an oamwait row holds the object in place with nothing drawn - local row = frame and frame.oam and self.anims.oam - and self.anims.oam[frame.oam] + local row = frame and frame.oam and self.anims.oam and self.anims.oam[frame.oam] if row then - local img = self:sheetImage(obj.def.gfx) - local pal = self:paletteFor(obj.def.palette or 0) - if img and shader and g.setShader then - g.setShader(shader) - shader:send("c0", pal[1]) - shader:send("c1", pal[2]) - shader:send("c2", pal[3]) - shader:send("c3", pal[4]) - end - -- BattleAnimOAMUpdate keeps one flip state per object per frame - -- ($CA19 = frameFlags XOR obj.oamFlags, masked $E0); it negates the - -- sprite offsets and is then XORed into each sprite's own attribute. local flip = band(bxor((frame.xflip and 0x20 or 0) - + (frame.yflip and 0x40 or 0), - obj.def.oamFlags or 0), 0xE0) + + (frame.yflip and 0x40 or 0), + obj.def.oamFlags or 0), 0xE0) local xf = math.floor(flip / 32) % 2 == 1 local yf = math.floor(flip / 64) % 2 == 1 + local palette = obj.def.palette or 0 + if (obj.fn == 'PokeBall' or obj.fn == 'PokeBallBlocked') + and self.opts and self.opts.ballPalette then palette = self.opts.ballPalette end for _, sprite in ipairs(row.sprites) do local ox = obj.x + obj.xOffset + (xf and -sprite.x - 8 or sprite.x) local oy = obj.y + obj.yOffset + (yf and -sprite.y - 8 or sprite.y) - local quad = img and self:tileQuad(obj.def.gfx, row.tile + sprite.tile) - if quad and ox > 0 and ox < 168 and oy > 0 and oy < 160 then - local attr = bxor(sprite.attr, flip) - local sx = math.floor(attr / 32) % 2 == 1 - local sy = math.floor(attr / 64) % 2 == 1 - g.draw(img, quad, - ox - 8 + (sx and 8 or 0), oy - 16 + (sy and 8 or 0), 0, - sx and -1 or 1, sy and -1 or 1) + if ox > 0 and ox < 168 and oy > 0 and oy < 160 then + out[#out + 1] = {x=ox, y=oy, tile=row.tile + sprite.tile, + attr=bxor(sprite.attr, flip), palette=palette, gfx=obj.def.gfx} end end - if img and shader and g.setShader then g.setShader() end end end + return out +end + +function Gen2AnimPlayer:drawSprites(objects) + local View = require('src.battle.Gen2AnimView') + self.view = self.view or View.new(self.anims, self.data) + self.presentation = self.presentation or View.runner(self) + self.presentation.selectObjects(objects) + return self.view:drawObjects(self.presentation, self.battle) end return Gen2AnimPlayer diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua new file mode 100644 index 000000000..667c23c68 --- /dev/null +++ b/src/battle/Gen2AnimView.lua @@ -0,0 +1,108 @@ +-- Native object drawing over Gen2AnimPlayer's actual OAM projection. +-- Object layout/flip math retains Gen2AnimPlayer provenance (Cedric); +-- public owner integration by Roxas2712, see NOTICE.md and contribution licence. +local Palette = require('src.render.Gen2PaletteDraw') +local PaletteFX = require('src.render.PaletteFX') +local View = {} +View.__index = View +local slots = { + PAL_BATTLE_OB_ENEMY=0, PAL_BATTLE_OB_PLAYER=1, PAL_BATTLE_OB_GRAY=2, + PAL_BATTLE_OB_YELLOW=3, PAL_BATTLE_OB_RED=4, PAL_BATTLE_OB_GREEN=5, + PAL_BATTLE_OB_BLUE=6, PAL_BATTLE_OB_BROWN=7, +} +function View.new(anims, data) + return setmetatable({data=anims or {}, gameData=data or {}, images={}, quads={}}, View) +end +function View:image(path) + if not path then return nil end + if self.images[path] == nil then + local ok, image = pcall(love.graphics.newImage, path) + if ok and image and image.setFilter then image:setFilter('nearest','nearest') end + self.images[path] = ok and image or false + end + return self.images[path] or nil +end +function View:release() + for _, image in pairs(self.images) do + if image and image.release then image:release() end + end + self.images, self.quads = {}, {} +end +function View:objPalette(name, battle) + local index = slots[name] or tonumber(name) + if index == 0 or index == 1 then + local mon = battle and battle[index == 0 and 'enemy' or 'player'] + mon = mon and (mon.mon or mon) + if mon then + return PaletteFX.monPal(self.gameData, mon.species, nil, + require('src.pokemon.Pokemon').isShiny(mon)) or PaletteFX.GRAYS + end + end + return self.data.palettes and self.data.palettes[(index or 2)-2] or PaletteFX.GRAYS +end +function View:quad(gfx, tile, image) + local sheet = self.data.gfx and self.data.gfx[gfx] + if not sheet or tile < 0 or tile >= sheet.tiles then return nil end + local bySheet = self.quads[gfx] or {}; self.quads[gfx] = bySheet + if not bySheet[tile] then + local w,h = image:getDimensions() + local cols = sheet.cols or 16 + bySheet[tile] = love.graphics.newQuad((tile%cols)*8, math.floor(tile/cols)*8, 8,8,w,h) + end + return bySheet[tile] +end +function View:drawObjects(runner, battle) + local g = love and love.graphics + if not g then return end + g.push('all') + local ok, err = pcall(function() + g.setColor(1,1,1,1) + local active + for _, obj in ipairs(runner:oam()) do + local sheet = self.data.gfx and self.data.gfx[obj.gfx] + local image = sheet and self:image(sheet.image) + local quad = image and self:quad(obj.gfx, obj.tile, image) + if quad then + local colors = self:objPalette(obj.palette, battle) + if colors ~= active then + if not Palette.useRaw(Palette.resolve(colors)) then g.setShader() end + active = colors + end + local attr = obj.attr or 0 + local sx = math.floor(attr/32)%2 == 1 and -1 or 1 + local sy = math.floor(attr/64)%2 == 1 and -1 or 1 + g.draw(image,quad,obj.x-8+(sx<0 and 8 or 0),obj.y-16+(sy<0 and 8 or 0),0,sx,sy) + end + end + end) + g.pop() + if not ok then error(err,0) end +end + +-- Stable per-animation observations of the native VM, including its real +-- object state machine and frame clock. Filtered OAM proxies still delegate +-- to this same draw owner. Restarting the VM retires the old observation. +function View.runner(player) + local selected, frozen + local function state() + local structs = {} + for _, obj in ipairs(selected or player.objects) do + local fn = obj.fn and ('BATTLE_ANIM_FUNC_'..obj.fn:gsub('(%l)(%u)','%1_%2'):upper()) + if obj.fn == 'PokeBall' then fn = 'BATTLE_ANIM_FUNC_POKEBALL' end + if obj.fn == 'PokeBallBlocked' then fn = 'BATTLE_ANIM_FUNC_POKEBALL_BLOCKED' end + structs[#structs+1] = {index=obj.index,func=fn,jt=obj.state} + end + return {frames=player.frames or 0,var=player.vars and player.vars.var or 0, + stopped=player:isDone(),keepSprites=player.keepSprites, + objects={structs=structs}, oam=player:oam(selected)} + end + local runner = {} + function runner:oam() return frozen and frozen.oam or player:oam(selected) end + function runner.selectObjects(objects) selected=objects end + function runner.retire() frozen=state(); player=nil end + return setmetatable(runner, { + __index=function(_,key) return (frozen or state())[key] end, + __newindex=function() error('native Gen2 animation observation is read-only',2) end, + }) +end +return View diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index b8a4a4ca5..76e31c71f 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -430,10 +430,12 @@ end local function currentAnimationSprites(battle) if battle.animPlaying and battle.animPlayer then + if battle.animPlayer.oam then return battle.animPlayer:oam() end local step = battle.animPlayer.steps[battle.animPlayer.stepIndex] return step and step.sprites end if battle.lockedBall and battle.animPlayer then + if battle.animPlayer.oam then return battle.animPlayer:oam(battle.lockedBall) end return battle.lockedBall end end diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 1ccf52cb4..d651dc3c7 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -34,7 +34,6 @@ -- party follower -- src.ui.gen2.Chrome no shared window-chrome module -- src.ui.gen2.SaveMenu the save flow is part of StartMenu here --- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent -- src.ui.PartyPanel no equivalent -- src.battle.gen2.Battle AMBIGUOUS, which is the same as absent here. @@ -62,6 +61,7 @@ ModCompat.ALIASES = { ["src.world.gen2.Player"] = "src.world.Player", ["src.world.gen2.World"] = "src.world.OverworldController", ["src.world.gen2.Map"] = "src.world.Map", + ["src.ui.gen2.BattleAnimView"] = "src.battle.Gen2AnimView", ["src.world.gen2.Permissions"] = "src.world.Collision", ["src.world.gen2.Palettes"] = "src.render.PaletteFX", diff --git a/tests/engine/gen2_animation_objects.lua b/tests/engine/gen2_animation_objects.lua new file mode 100644 index 000000000..e314757a9 --- /dev/null +++ b/tests/engine/gen2_animation_objects.lua @@ -0,0 +1,111 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Player=require('src.battle.Gen2AnimPlayer') +local View=require('src.battle.Gen2AnimView') +local Battle=require('src.battle.BattleState') +local Wide=require('src.battle.WideBattle') +local oldVersion=Version.get() +local function fixture() + return {battle_anims={gen2=true,moveAnims={FIX_MOVE=1},misc={throwPokeBall=1}, + scripts={[1]={entry=1,code={{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, + objects={[0]={oamFlags=0,fixY=255,frameset=0,fn=0,palette=4,gfx=2}},functions={[0]='Null'}, + framesets={[0]={frames={{oam=0,duration=20}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0},{x=8,y=0,tile=1,attr=32}}}}, + gfx={[2]={tiles=2,cols=2,image='fixture.png'}},palettes={[2]={{255,255,255},{200,100,50},{100,30,10},{0,0,0}}}}} +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data=fixture();local p=Player.new(data) + T.eq(p:isDone(),true,'new native player has a terminal idle state') + T.eq(getmetatable(p.view),View,'VM owns the actual public drawing class') + T.eq(p:start('FIX_MOVE',true,{param=17}),true,'native bytecode starts normally') + T.eq(p.vars.param,17,'native script receives its caller parameter') + p:update() + local native=p.objects[1] + local rows=p:oam() + T.eq(#rows,2,'actual frame OAM contains both object tiles') + T.same(rows[1],{x=40,y=40,tile=0,attr=0,palette=4,gfx=2},'first native sprite projection') + T.same(rows[2],{x=48,y=40,tile=1,attr=32,palette=4,gfx=2},'second native sprite retains tile and flip') + local runner=View.runner(p) + T.eq(runner.frames,1,'runner observes actual update clock') + T.eq(runner.objects.structs[1].index,native.index,'runner observes actual native object identity') + T.eq(runner.objects.structs[1].jt,native.state,'runner observes actual object state machine') + local age,frame=native.age,p.frames + runner:oam();runner:oam() + T.eq(native.age,age,'OAM reads cannot tick object motion') + T.eq(p.frames,frame,'OAM reads cannot tick script time') + p:update();T.eq(runner.frames,2,'delay frames count on actual native timeline') + data.battle_anims.framesets[0].frames[1].xflip=true + rows=p:oam() + T.eq(rows[1].x,32,'object X flip negates offset and eight-pixel cell width') + T.eq(rows[1].attr,32,'object X flip reaches sprite attributes') + T.eq(rows[2].attr,0,'object flip XORs a sprite already flipped') + data.battle_anims.framesets[0].frames[1].yflip=true + T.eq(p:oam()[1].y,32,'object Y flip negates native row offset') + native.fn,native.state='PokeBall',5 + p.opts.ballPalette='TEST_BALL' + T.eq(runner.objects.structs[1].func,'BATTLE_ANIM_FUNC_POKEBALL','source reads the actual native ball function') + T.eq(runner.objects.structs[1].jt,5,'source reads real landed ball state') + T.eq(p:oam()[1].palette,'TEST_BALL','typed native ball palette follows actual object') + p.vars.var,p.keepSprites=1,true + T.eq(runner.var,1,'capture result observation uses VM variable') + T.eq(runner.keepSprites,true,'capture retention observation uses VM flag') + p.presentation=runner + local ticks=runner.frames + p:start('FIX_MOVE',false) + T.eq(runner.frames,ticks,'restarting VM cannot rebind previous animation observation') + T.eq(runner:oam()[1].palette,'TEST_BALL','retired OAM retains its own throw') + T.eq(p.frames,0,'new animation starts with its own actual clock') + p:update() + local g=love.graphics;local draw=g.draw;local calls={} + g.draw=function(...)calls[#calls+1]={...}end + local shader={};g.setShader(shader);g.setColor(.2,.3,.4,.5) + p:draw() + T.eq(#calls,2,'native VM draws through the actual object-view owner') + T.eq(g.getShader(),shader,'object pass restores caller shader') + T.same({g.getColor()},{.2,.3,.4,.5},'object pass restores caller tint') + g.draw=function()error('actual object draw failure')end + T.raises(function()p:draw()end,'actual object draw failure','render error remains observable') + T.eq(g.getShader(),shader,'failed object pass restores caller state') + g.draw=draw;g.setShader();g.setColor(1,1,1,1) + p:release() +end +require('src.core.ModCompat').install() +T.eq(require('src.ui.gen2.BattleAnimView'),View,'compat spelling resolves actual native renderer class') +do + Version.set('crystal') + local data=T.fixtures.fresh();data.constants.gen=2 + data.battle_anims=fixture().battle_anims + data.items.ITEM_FIX_BALL={id='ITEM_FIX_BALL',key='POKE_BALL',index=5} + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + T.eq(s.animView,s.animPlayer.view,'screen exposes the same actual drawing owner') + local item + s.ballPalette=function(self,id) item=id;return 'TEST_QUEUED_BALL'end + s.queue={{anim='TOSS_ANIM',attackerIsPlayer=true,ball='ITEM_FIX_BALL',caught=true,shakes=3,animDelayed=true}} + s.current,s.waitFrames=nil,nil + s:updateQueue() + T.eq(item,'POKE_BALL','actual queued throw calls public palette seam with source item spelling') + T.eq(s.animPlayer.opts.ballPalette,'TEST_QUEUED_BALL','native bytecode owns the selected thrown palette') + T.eq(s.animPlayer.opts.ball,'ITEM_FIX_BALL','native inventory item remains unchanged') + T.eq(s.animPlayer.battle,s.battle,'renderer sees live native battle model') + local PaletteFX=require('src.render.PaletteFX') + local nativePal=PaletteFX.monPal + local observed + PaletteFX.monPal=function(_,species)observed=species;return PaletteFX.GRAYS end + s.animView:objPalette('PAL_BATTLE_OB_ENEMY',s) + T.eq(observed,s.enemy.mon.species,'native battler record resolves its actual Pokemon palette') + s.animView:objPalette('PAL_BATTLE_OB_PLAYER',{player=s.player.mon}) + T.eq(observed,s.player.mon.species,'direct native Pokemon model resolves the same palette owner') + PaletteFX.monPal=nativePal + s.animPlayer:update() + local layer=s.drawAnimLayer;local layers=0 + s.drawAnimLayer=function(self,...)layers=layers+1;return layer(self,...)end + Wide.draw(s) + T.eq(layers,1,'wide native composition draws actual Gen2 OAM without Gen1 frame blocks') +end +Version.set(oldVersion) +T.finish('native Gen2 animation object owner, observation and queued ball palette') diff --git a/tests/gpu/gen2_animation_objects.lua b/tests/gpu/gen2_animation_objects.lua new file mode 100644 index 000000000..e33ff7961 --- /dev/null +++ b/tests/gpu/gen2_animation_objects.lua @@ -0,0 +1,80 @@ +-- Native Gen2 OAM/view pixels. All texture bytes below are generated test art. +local M={} +function M.run() + local Version=require('src.core.GameVersion') + local Palette=require('src.render.PaletteFX') + local Player=require('src.battle.Gen2AnimPlayer') + local g=love.graphics + local before={version=Version.get(),mode=Palette.mode,ramp=Palette.customRamp()} + local checks,frames=0,0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local colors={{255,255,255},{220,40,80},{70,160,30},{0,0,0}} + local custom={{240,230,220},{180,150,120},{100,70,40},{20,10,5}} + local atlas=love.image.newImageData(16,8,'rgba8') + for y=0,7 do for x=0,15 do + local shade=(x+2*y)%4 + local gray=(3-shade)/3 + atlas:setPixel(x,y,gray,gray,gray,shade==0 and 0 or 1) + end end + local path='qa-native-oam.png';atlas:encode('png',path);atlas:release() + local canvas=g.newCanvas(160,144,{format='rgba8',dpiscale=1});M.image=canvas + local sentinel=g.newShader([[vec4 effect(vec4 tint,Image image,vec2 uv,vec2 screen){return Texel(image,uv)*tint;}]]) + g.push('all') + local ok,why=xpcall(function() + for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,mode in ipairs({'gbc','dmg','custom'})do + -- Each case owns both settings: setMode intentionally retains a saved + -- custom ramp, which would leak the previous edition's custom case. + Palette.setDisplay(mode,mode=='custom'and custom or nil) + local expected=mode=='gbc'and colors or mode=='custom'and custom or Palette.GRAYS + for _,enemy in ipairs({false,true})do for _,flip in ipairs({0,32,64,96})do + local xf,yf=flip%64>=32,flip>=64 + local data={battle_anims={gen2=true,moveAnims={FIX_MOVE=1}, + scripts={[1]={entry=1,code={{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, + objects={[0]={oamFlags=1,fixY=255,frameset=0,fn=0,palette=4,gfx=2}},functions={[0]='Null'}, + framesets={[0]={frames={{oam=0,duration=20,xflip=xf,yflip=yf}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0},{x=8,y=0,tile=1,attr=32}}}}, + gfx={[2]={tiles=2,cols=2,image=path}},palettes={[2]=colors}}} + local player=Player.new(data);check(player:start('FIX_MOVE',not enemy),'native VM start failed') + player:update();frames=frames+1 + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.setBlendMode('alpha') + g.clear(.125,.25,.5,1);g.setColor(.1,.2,.3,.4);g.setShader(sentinel) + local clock,age=player.frames,player.objects[1].age + player:draw() + check(g.getShader()==sentinel,'native object pass lost caller shader') + local tint={g.getColor()};check(math.abs(tint[1]-.1)<.001 and math.abs(tint[4]-.4)<.001,'object pass lost caller tint') + check(player.frames==clock and player.objects[1].age==age,'object drawing advanced the native animation') + g.setCanvas();g.setShader() + local image=g.readbackTexture(canvas) + local ax,ay=enemy and 140 or 40,enemy and 80 or 40 + for tile=0,1 do + local left=ax+(xf and -tile*8-8 or tile*8)-8 + local top=ay+(yf and -8 or 0)-16 + local spriteXFlip=xf~=(tile==1) + for y=0,7 do for x=0,7 do + local sourceX=tile*8+(spriteXFlip and 7-x or x) + local sourceY=yf and 7-y or y + local shade=(sourceX+2*sourceY)%4 + local got={image:getPixel(left+x,top+y)} + local wanted=shade==0 and {.125,.25,.5}or expected[shade+1] + for channel=1,3 do + local value=shade==0 and wanted[channel]or wanted[channel]/255 + check(math.abs(got[channel]-value)<2/255, + edition..' '..mode..' native OAM flip/position/palette mismatch') + end + check(math.abs(got[4]-1)<2/255,'native OAM lost destination alpha') + end end + end + image:release();player:release() + end end + end + end + end,debug.traceback) + g.setCanvas();g.pop();sentinel:release() + Version.set(before.version);Palette.setDisplay(before.mode,before.ramp) + if not ok then error(why,0)end + return {passed=true,checks=checks,frames=frames, + scope='Generated ROM-free native VM/OAM/object renderer pixels: Gold/Silver/Crystal, both sides, all object flip combinations, per-sprite XOR, GBC/DMG/custom palettes, alpha and caller graphics state. Not whole-mod or OS-window acceptance.'},canvas +end +return M From 198e08865a5829c66062ccb53ba877d433140d57 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:42:52 +0100 Subject: [PATCH 20/64] Expose actual native Gen2 move and scene presentation entry points --- src/battle/BattleState.lua | 46 ++++++-- src/battle/Gen2AnimPlayer.lua | 2 + src/battle/Gen2AnimView.lua | 2 +- src/battle/Gen2AnimationIds.lua | 27 +++++ src/battle/Gen2BattleScene.lua | 134 ++++++++++++++++++++++++ src/battle/Gen2MovePresentation.lua | 66 ++++++++++++ src/battle/WideBattle.lua | 15 ++- src/import/RomExtractorGen2.lua | 1 + tests/engine/gen2_battle_scene.lua | 65 ++++++++++++ tests/engine/gen2_move_presentation.lua | 96 +++++++++++++++++ tests/gpu/gen2_battle_scene.lua | 68 ++++++++++++ 11 files changed, 509 insertions(+), 13 deletions(-) create mode 100644 src/battle/Gen2AnimationIds.lua create mode 100644 src/battle/Gen2BattleScene.lua create mode 100644 src/battle/Gen2MovePresentation.lua create mode 100644 tests/engine/gen2_battle_scene.lua create mode 100644 tests/engine/gen2_move_presentation.lua create mode 100644 tests/gpu/gen2_battle_scene.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 752860978..acbd5556b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1243,6 +1243,7 @@ local function newBattle(game) self.animPlayer = Gen2AnimPlayer.new(game.data) self.animPlayer.battle = self.battle self.animView = self.animPlayer.view + self.anims = self.animPlayer.anims end else local ok, AnimPlayer = pcall(require, "src.battle.AnimPlayer") @@ -2741,12 +2742,19 @@ function BattleState:updateQueue() -- again. Dig/Fly's charge hide and a genuinely invulnerable battler -- are the two that must survive, same as in resetPicFx. self:restoreHiddenPics() + if GameVersion.isGen2() then + if self:startPendingAfterAnim() then return true end + if not self.animPlayer.keepSprites then self.anim = nil end + end -- the target's hit blink + damage sound follow the animation -- (pokered plays them after PlayMoveAnimation returns) if self.pendingHit then - self:applyHitFx(self.pendingHit) + if not (GameVersion.isGen2() and self.afterAnimPlayed) then + self:applyHitFx(self.pendingHit) + end self.pendingHit = nil end + if GameVersion.isGen2() then self.afterAnimPlayed = nil end end return true end @@ -2867,9 +2875,7 @@ function BattleState:updateQueue() self.waitFrames = math.max(self.waitFrames or 0, self.gen3Anim.total or 0) elseif self.animPlayer then - local ok, started = pcall(self.animPlayer.start, self.animPlayer, - item.anim, item.attackerIsPlayer, - (item.shakes or item.ball) + local opts = (item.shakes or item.ball) and { shakes = item.shakes, ball = item.ball, caught = item.caught, -- Gen 2's toss script branches on the @@ -2882,7 +2888,21 @@ function BattleState:updateQueue() or nil, ballFlicker = item.ball and self:ballFlicker(item.ball) or nil } - or nil) + or nil + local ok, started + if GameVersion.isGen2() then + if mdef and not BALL_ANIMS[item.anim] then + ok, started = pcall(self.animForMove, self, item.anim, + item.attackerIsPlayer and 'player' or 'enemy', + item.animParam, item.effectiveness, item.afterAnim) + else + ok, started = pcall(require('src.battle.Gen2MovePresentation').start, + self, item.anim, item.attackerIsPlayer, opts) + end + else + ok, started = pcall(self.animPlayer.start, self.animPlayer, + item.anim, item.attackerIsPlayer, opts) + end -- a player that reports no data for the move (Gen 2 returns -- false) falls through to the single-sound path below self.animPlaying = ok and started ~= false @@ -2951,11 +2971,13 @@ function BattleState:updateQueue() -- plays each row's MoveSoundTable entry with its pitch/tempo -- modifiers); which side the pic effects target follows the -- attacker (hWhoseTurn) - self.animName = item.anim - self.animAttackerIsPlayer = item.attackerIsPlayer - self:resetPicFx() - for _, ev in ipairs(self.animPlayer:pollEffects()) do - self:applyAnimEffect(ev) -- frame-0 rows (first sound/effect) + if not GameVersion.isGen2() then + self.animName = item.anim + self.animAttackerIsPlayer = item.attackerIsPlayer + self:resetPicFx() + for _, ev in ipairs(self.animPlayer:pollEffects()) do + self:applyAnimEffect(ev) -- frame-0 rows (first sound/effect) + end end self.pendingHit = item.hit else @@ -12892,6 +12914,7 @@ function BattleState:drawBattleField() end function BattleState:drawClassic() + if GameVersion.isGen2() then return self:drawScene() end -- AskName: ClearSprites + wild ClearScreenArea -- white field under the -- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top. if self.blankForAskName then @@ -13005,4 +13028,7 @@ function BattleState:drawClassic() end end +require('src.battle.Gen2MovePresentation').install(BattleState) +require('src.battle.Gen2BattleScene').install(BattleState) + return BattleState diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index f2e82d662..bc19c78cc 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -133,6 +133,7 @@ end function Gen2AnimPlayer.new(data) local anims = data and data.battle_anims or {} + require('src.battle.Gen2AnimationIds').install(anims) local self = setmetatable({ data = data, anims = anims, @@ -196,6 +197,7 @@ local GEN1_BALL_ANIMS = { function Gen2AnimPlayer:scriptFor(moveId) local anims = self.anims local id = anims.moveAnims and anims.moveAnims[moveId] + or anims.ids and anims.ids[moveId] if not id then local alias = GEN1_BALL_ANIMS[moveId] if alias == false then return nil end diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index 667c23c68..2c45a074b 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -92,7 +92,7 @@ function View.runner(player) if obj.fn == 'PokeBallBlocked' then fn = 'BATTLE_ANIM_FUNC_POKEBALL_BLOCKED' end structs[#structs+1] = {index=obj.index,func=fn,jt=obj.state} end - return {frames=player.frames or 0,var=player.vars and player.vars.var or 0, + return {animId=player.moveId,frames=player.frames or 0,var=player.vars and player.vars.var or 0, stopped=player:isDone(),keepSprites=player.keepSprites, objects={structs=structs}, oam=player:oam(selected)} end diff --git a/src/battle/Gen2AnimationIds.lua b/src/battle/Gen2AnimationIds.lua new file mode 100644 index 000000000..9d3c15190 --- /dev/null +++ b/src/battle/Gen2AnimationIds.lua @@ -0,0 +1,27 @@ +-- GSC constants/move_constants.asm, also documented by Gen1Recomp's +-- RomExtractorGen2 BATTLE_ANIM_IDS. These name existing native ROM programs; +-- they neither synthesize scripts nor replace cartridge data. +local Ids = { + ANIM_SWEET_SCENT_2=0xff, ANIM_THROW_POKE_BALL=0x100, + ANIM_SEND_OUT_MON=0x101, ANIM_RETURN_MON=0x102, + ANIM_CONFUSED=0x103, ANIM_SLP=0x104, ANIM_BRN=0x105, + ANIM_PSN=0x106, ANIM_SAP=0x107, ANIM_FRZ=0x108, + ANIM_PAR=0x109, ANIM_IN_LOVE=0x10a, ANIM_IN_SANDSTORM=0x10b, + ANIM_IN_NIGHTMARE=0x10c, ANIM_IN_WHIRLPOOL=0x10d, + ANIM_MISS=0x10e, ANIM_ENEMY_DAMAGE=0x10f, + ANIM_ENEMY_STAT_DOWN=0x110, ANIM_PLAYER_STAT_DOWN=0x111, + ANIM_PLAYER_DAMAGE=0x112, ANIM_WOBBLE=0x113, + ANIM_SHAKE=0x114, ANIM_HIT_CONFUSION=0x115, +} +local M={} +function M.install(anims) + if not (anims and anims.gen2) then return anims end + local ids=anims.ids or {};anims.ids=ids + for name,index in pairs(Ids) do + if ids[name]==nil and anims.scripts and anims.scripts[index] then + ids[name]=index + end + end + return anims +end +return M diff --git a/src/battle/Gen2BattleScene.lua b/src/battle/Gen2BattleScene.lua new file mode 100644 index 000000000..9f93267c5 --- /dev/null +++ b/src/battle/Gen2BattleScene.lua @@ -0,0 +1,134 @@ +-- Native classic composition retained from src/battle/BattleState.lua. +-- The Gen2 scene-owner split reuses its real panels, FX and OAM; it does not +-- own battle decisions or animation time. Existing source provenance remains. +local Timing=require('src.core.Timing') +local Runtime=require('src.mods.Runtime') +local M={} +function M.install(Screen) + function Screen:drawPanel() + -- AskName: ClearSprites + wild ClearScreenArea -- white field under the + -- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top. + if self.blankForAskName then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + return + end + local fx = self.fx + -- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake); + -- the animations-off fallback keeps the old +-2 alternation + local sx = (fx and fx.shakeX) or 0 + local sy = (fx and fx.shakeY) or 0 + if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then + sx = self.frame % 4 < 2 and 2 or -2 + end + -- intro slide-in offset: 2 px per frame, so 144 px over 72 frames + local slide = (self.introSlide or 0) * Timing.BATTLE_SLIDE_PX_PER_FRAME + + if self:colorMode() then + -- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the + -- BGP fade -> mon pics -> OAM anim sprites (never BGP-faded) + local g = love.graphics + local wavy = fx and fx.wavy + -- push("all")/pop restores the bound canvas, a mod's depth set included + g.push("all") + g.setCanvas(self.bgCanvas) + -- The white paper the whole battle is drawn on -- unless something has + -- put a WORLD where the paper goes, in which case painting it is what + -- hides the world. Clearing to transparent instead leaves only what is + -- actually drawn (HUDs, boxes, pics) and lets the 3D frame underneath + -- show between them. The zone shader passes alpha through unchanged + -- (PaletteFX.shader: `vec4(mapped, p.a)`), so transparency survives the + -- colour pass rather than being remapped to a palette shade. + if self:worldBackdrop() then + g.clear(0, 0, 0, 0) + else + g.setColor(1, 1, 1, 1) + g.rectangle("fill", 0, 0, 160, 144) + end + g.setColor(1, 1, 1, 1) + -- the party ball rows are OBJ sprites on hardware (see drawBallRow): + -- collect them here and draw them over the finished zone pass instead + local balls = self:gen2BallColors() and {} or nil + self.deferBalls = balls + self:drawHUDs(slide) + self.deferBalls = nil + self:drawTextArea() + if wavy then + -- the mon pics are BG tiles on the GB, so SE_WAVY_SCREEN bends + -- them too: bake them into the canvas as DMG grays and let the + -- zone pass color them by region (exactly what the SGB did) + self.grayPics = true + g.setScissor(0, 0, 160, 96) -- BG pics live above the text box + self:drawPicsLayer(slide, 0, 0) + g.setScissor() + self.grayPics = nil + end + g.pop() + self:drawZonePass(self:applyWavy(self.bgCanvas), sx, sy) + if balls and balls[1] then + -- they ride the window shake with the HUD chrome they sit in + local shifted = sx ~= 0 or sy ~= 0 + if shifted then g.push() g.translate(sx, sy) end + g.setColor(1, 1, 1, 1) + for _, row in ipairs(balls) do + self:drawBallRow(row[1], row[2], row[3], row[4]) + end + if shifted then g.pop() end + end + if not wavy then + -- the pics are BG tiles in rows 0-11 on the GB: they can never + -- cover the text box, whatever the SE offsets do (a vertical + -- window shake moves the box down with everything else) + g.setScissor(0, 0, 160, 96 + math.max(0, sy)) + self:drawPicsLayer(slide, sx, sy) + g.setScissor() + end + else + -- flat fallback (headless / no shader support): pre-colorized pics + -- on white, no palette fades -- and the same exception as above, so the + -- two paths agree about when there is paper to paint + love.graphics.setColor(1, 1, 1, 1) + if not self:worldBackdrop() then + love.graphics.rectangle("fill", 0, 0, 160, 144) + end + local shaking = sx ~= 0 or sy ~= 0 + if shaking then + love.graphics.push() + love.graphics.translate(sx, sy) + end + self:drawPicsLayer(slide, 0, 0) + self:drawHUDs(slide) + self:drawTextArea() + if shaking then + love.graphics.pop() + end + end + end + + -- panelFn replaces the BG panel; native wide layout also supplies its + -- translated OAM pass and surface dimensions through the same real site. + function Screen:drawSceneBody(panelFn,objectsFn,width,height) + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + if panelFn then panelFn()else self:drawPanel()end + if self.blankForAskName then return end + if objectsFn then objectsFn()else self:drawAnimLayer(self:colorMode())end + local fx=self.fx + if fx and fx.flash and fx.flash>0 and self.frame%4<2 then + g.setShader();g.setColor(1,1,1,.85) + g.rectangle('fill',0,0,width or 160,height or 144) + end + end) + g.pop() + if not ok then error(why,0)end + end + function Screen:drawScene(bodyFn) + if bodyFn then bodyFn()else self:drawSceneBody()end + love.graphics.setColor(1,1,1,1) + if Runtime.wantsHook('battle.overlay')then + Runtime.call('battle.overlay',function()end,self) + end + end +end +return M diff --git a/src/battle/Gen2MovePresentation.lua b/src/battle/Gen2MovePresentation.lua new file mode 100644 index 000000000..bd3ef9087 --- /dev/null +++ b/src/battle/Gen2MovePresentation.lua @@ -0,0 +1,66 @@ +-- Native Gen2 presentation entry points over the existing battle queue/VM. +-- Public side/after-animation names follow Gen1Recomp's Gen2 BattleState; +-- battle decisions, damage, PP, queue consumption and time stay native. +local Version=require('src.core.GameVersion') +local View=require('src.battle.Gen2AnimView') +local M={} + +function M.start(screen,id,isPlayer,opts) + local player=screen.animPlayer + if not (Version.isGen2() and player and player.anims.gen2 + and player:scriptFor(id)) then return false end + if player:start(id,isPlayer,opts)==false then return false end + screen.animPlaying=true + screen.animName,screen.animAttackerIsPlayer=id,isPlayer + screen:resetPicFx() + player.presentation=View.runner(player) + screen.anim=player.presentation + for _,event in ipairs(player:pollEffects())do screen:applyAnimEffect(event)end + return true +end + +function M.install(Screen) + function Screen:turnFor(side) return side=='enemy' and 1 or 0 end + function Screen:afterAnimFor(side,kind) + if kind=='statdown'then + return side=='player'and 'ANIM_ENEMY_STAT_DOWN'or 'ANIM_WOBBLE' + end + if kind=='damage'then + return side=='player'and 'ANIM_ENEMY_DAMAGE'or 'ANIM_PLAYER_DAMAGE' + end + end + function Screen:playHitSound(effectiveness) + if not effectiveness or effectiveness==0 then return end + local name=effectiveness>10 and 'Super_Effective' + or effectiveness<10 and 'Not_Very_Effective'or 'Damage' + require('src.core.Sound').play(self.data,name) + end + function Screen:animForMove(moveId,side,param,effectiveness,afterAnim) + local player=self.animPlayer + if not (Version.isGen2() and self:animationsOn() and player + and player.anims.moveAnims and player.anims.moveAnims[moveId]) then return false end + local started=M.start(self,moveId,side~='enemy',{param=param or 0}) + if started then + local name=self:afterAnimFor(side,afterAnim) + self.pendingAfterAnim=name and {name=name,side=side, + effectiveness=afterAnim=='damage'and effectiveness or nil}or nil + self.afterAnimPlayed=nil + end + return started + end + function Screen:animForId(id,side,param) + return M.start(self,id,side~='enemy',{param=param or 0}) + end + function Screen:startPendingAfterAnim() + local pending=self.pendingAfterAnim + if not pending then return false end + self.pendingAfterAnim=nil + if self:animForId(pending.name,pending.side)then + self:playHitSound(pending.effectiveness) + self.afterAnimPlayed=true + return true + end + return false + end +end +return M diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 76e31c71f..0b3b50889 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -449,7 +449,7 @@ local function drawAnimationLayer(battle) end -- The whole 304x144 composition for one frame. -function WideBattle.draw(battle) +local function drawPanel(battle,withObjects) local g = love.graphics -- The field is the display mode's paper. Under a forced-mono mode the -- whole surface is remapped downstream (WideBattle.zones), so the field @@ -505,9 +505,20 @@ function WideBattle.draw(battle) g.pop() end shaken(function() drawHUDs(battle, slide) end) - drawAnimationLayer(battle) + if withObjects then drawAnimationLayer(battle) end shaken(function() drawTextArea(battle) end) +end + +function WideBattle.draw(battle) + if require('src.core.GameVersion').isGen2() then + return battle:drawScene(function() + battle:drawSceneBody(function()drawPanel(battle,false)end, + function()drawAnimationLayer(battle)end,WideBattle.WIDTH,WideBattle.HEIGHT) + end) + end + drawPanel(battle,true) + local g,fx=love.graphics,battle.fx if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then g.setColor(1, 1, 1, 0.85) g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..487c5add3 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -15175,6 +15175,7 @@ function RomExtractorGen2:gen2BattleAnims() -- (constants/move_constants.asm: ANIM_THROW_POKE_BALL $100, -- ANIM_SEND_OUT_MON $101, ANIM_IN_SANDSTORM $10b) misc = { throwPokeBall = 256, sendOutMon = 257, inSandstorm = 267 }, + ids = require('src.battle.Gen2AnimationIds').install({gen2=true,scripts=scripts}).ids, move = {}, effects = {}, } end diff --git a/tests/engine/gen2_battle_scene.lua b/tests/engine/gen2_battle_scene.lua new file mode 100644 index 000000000..5cf2d0ae4 --- /dev/null +++ b/tests/engine/gen2_battle_scene.lua @@ -0,0 +1,65 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Wide=require('src.battle.WideBattle') +local Runtime=require('src.mods.Runtime') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local before=Version.get() +local wants,call=Runtime.wantsHook,Runtime.call +local overlays,expected=0 +Runtime.wantsHook=function(name)if name=='battle.overlay'then return true end;return wants(name)end +Runtime.call=function(name,vanilla,screen,...) + if name=='battle.overlay'then overlays=overlays+1;T.eq(screen,expected,'overlay receives actual native screen');return vanilla(screen)end + return call(name,vanilla,screen,...) +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data=T.fixtures.fresh();require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + expected=s + local layers={};local scene,body=s.drawScene,s.drawSceneBody + s.drawScene=function(self,...)layers[#layers+1]='scene';return scene(self,...)end + s.drawSceneBody=function(self,...)layers[#layers+1]='body';return body(self,...)end + s.drawPicsLayer=function()layers[#layers+1]='pics'end + s.drawHUDs=function()layers[#layers+1]='hud'end + s.drawTextArea=function()layers[#layers+1]='text'end + s.drawAnimLayer=function()layers[#layers+1]='objects'end + s.colorMode=function()return false end + local count=overlays;s:draw() + T.same(layers,{'scene','body','pics','hud','text','objects'},'actual classic draw reaches native scene/BG/OAM owners once') + T.eq(overlays,count+1,'actual scene emits one final overlay') + layers={};count=overlays + s:drawSceneBody(function()layers[#layers+1]='custom'end) + T.same(layers,{'body','custom','objects'},'replacement panel retains native object pass') + T.eq(overlays,count,'scene body does not emit duplicate final overlay') + layers={};s:drawScene(function()s:drawSceneBody(function()layers[#layers+1]='supplied'end)end) + T.same(layers,{'scene','body','supplied','objects'},'supplied scene body stays inside actual outer owner') + layers={};s.blankForAskName=true;s:drawSceneBody() + T.same(layers,{'body'},'native naming blank suppresses battle pictures and objects') + s.blankForAskName=nil + local g=love.graphics;local sentinel={};g.setShader(sentinel);g.setColor(.2,.3,.4,.5) + T.raises(function()s:drawSceneBody(function()g.setShader();g.setColor(0,0,0,1);error('panel draw failed')end)end,'panel draw failed','native scene propagates panel error') + T.eq(g.getShader(),sentinel,'failed panel restores graphics owner') + T.same({g.getColor()},{.2,.3,.4,.5},'failed panel restores caller tint') + g.setShader();g.setColor(1,1,1,1) + local nativeBody=s.drawSceneBody;local widths={};layers={};count=overlays + s.drawSceneBody=function(self,panel,objects,w,h) + widths={w,h};return nativeBody(self,panel,objects,w,h) + end + Wide.draw(s) + T.same(widths,{304,144},'actual native widescreen enters same composition owner with real surface') + T.eq(layers[1],'scene','wide layout enters native outer scene') + T.eq(layers[2],'body','wide layout enters native BG/OAM scene') + T.eq(overlays,count+1,'wide layout emits one final overlay') + -- Red keeps its existing layout route. Presence of a Gen2 method must not + -- redirect other games simply because they share the native screen class. + Version.set('red');layers={};s:drawClassic() + T.check(layers[1]~='scene'and layers[1]~='body','Gen1 classic route remains native original route') + layers={};Wide.draw(s) + T.check(layers[1]~='scene'and layers[1]~='body','Gen1 wide route remains native original route') +end +Runtime.wantsHook,Runtime.call=wants,call;Version.set(before) +T.finish('native Gen2 classic/wide scene owner, panel replacement and layer lifecycle') diff --git a/tests/engine/gen2_move_presentation.lua b/tests/engine/gen2_move_presentation.lua new file mode 100644 index 000000000..c05c8850e --- /dev/null +++ b/tests/engine/gen2_move_presentation.lua @@ -0,0 +1,96 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Sound=require('src.core.Sound') +local previous=Version.get() +local function make() + local data=T.fixtures.fresh() + data.moves.FIX_MOVE={id='FIX_MOVE',name='MOVE',power=20,type='NORMAL',pp=20} + data.battle_anims={gen2=true,moveAnims={FIX_MOVE=1},scripts={ + [1]={entry=1,code={{op='delay',frames=2},{op='ret'}}}, + [0x10f]={entry=1,code={{op='delay',frames=3},{op='ret'}}}, + [0x110]={entry=1,code={{op='delay',frames=4},{op='ret'}}}, + [0x112]={entry=1,code={{op='delay',frames=5},{op='ret'}}}, + [0x113]={entry=1,code={{op='delay',frames=6},{op='ret'}}}, + }} + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local game={data=data,save=save,stack={top=function()end}} + local s=Battle.newWild(game,'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + return s,game +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,side in ipairs({'player','enemy'})do + local s=make();local starts,ticks,entryCalls,tail,hits=0,0,0,0,0 + local start,update,entry=s.animPlayer.start,s.animPlayer.update,s.animForMove + s.animPlayer.start=function(self,...)starts=starts+1;return start(self,...)end + s.animPlayer.update=function(self,...)ticks=ticks+1;return update(self,...)end + s.animForMove=function(self,id,who,param,...) + entryCalls=entryCalls+1;T.eq(id,'FIX_MOVE','actual queued move reaches public owner') + T.eq(who,side,'queued side remains native');T.eq(param,19,'queued param reaches real VM') + return entry(self,id,who,param,...) + end + s.applyHitFx=function()hits=hits+1 end + s.queue={{anim='FIX_MOVE',attackerIsPlayer=side=='player',animParam=19,animDelayed=true,hit={sfx='Damage'}}, + {fn=function()tail=tail+1 end}} + local hp,pp=s.player.mon.hp,s.player.curMoves[1].pp + s:updateQueue() + T.eq(starts,1,'one native start per queue row');T.eq(entryCalls,1,'real queue calls source owner exactly once') + T.eq(ticks,0,'starting does not invent a native logic frame') + T.eq(s.animPlayer.vars.param,19,'actual native script parameter') + T.eq(s.animId,nil,'presentation observation does not replace screen state') + T.eq(s.anim.animId,'FIX_MOVE','screen observes actual running animation') + T.eq(s.anims,s.data.battle_anims,'screen exposes actual native animation dataset') + T.eq(s.animPlayer.enemySide,side=='enemy','native VM still owns mirroring') + local frames=0 + while s.animPlaying and frames<200 do + local before=ticks;s:updateQueue();frames=frames+1 + T.eq(ticks,before+1,'one native VM tick per held queue update') + T.eq(tail,0,'pending command remains behind actual animation') + end + T.check(frames<200,'native move terminates') + T.eq(hits,1,'native hit applies once after animation completes') + T.eq(s.anim,nil,'finished animation releases live observation') + s:updateQueue();T.eq(tail,1,'queue releases next command after animation') + T.eq(s.player.mon.hp,hp,'presentation cannot change party HP') + T.eq(s.player.curMoves[1].pp,pp,'presentation cannot spend PP') + T.eq(starts,1,'draw/read completion never restarts VM') + end + for _,side in ipairs({'player','enemy'})do for _,kind in ipairs({'damage','statdown'})do + local s=make();local sounds={};local play=Sound.play + Sound.play=function(_,name)sounds[#sounds+1]=name end + T.eq(s:animForMove('FIX_MOVE',side,7,20,kind),true,'native move with after-program starts') + local name=s:afterAnimFor(side,kind) + local observed=s.anim;local hops=0 + while s.animPlayer.moveId=='FIX_MOVE'and hops<200 do s:updateQueue();hops=hops+1 end + T.check(hops<200,'after program starts when native first program ends') + T.eq(s.animPlayer.moveId,name,'actual named ROM after-program runs') + T.eq(s.animPlayer.enemySide,side=='enemy','after-program keeps native attacker side') + T.eq(observed.animId,'FIX_MOVE','old observation retains original program identity') + T.eq(s.anim.animId,name,'current observation follows actual new program') + T.eq(s.pendingAfterAnim,nil,'after-program is consumed once') + T.eq(#sounds,kind=='damage'and 1 or 0,'only damaging after-program plays hit sound') + if kind=='damage'then T.eq(sounds[1],'Super_Effective','native sound spelling matches effectiveness')end + while s.animPlaying and hops<400 do s:updateQueue();hops=hops+1 end + T.check(hops<400,'after program terminates on native VM clock') + T.eq(s.animPlaying,false,'after-chain releases native hold') + Sound.play=play + end end + do + local s,game=make();game.save.options.animations=false + T.eq(s:animForMove('FIX_MOVE','player'),false,'native option skips real move scripts') + T.eq(s:animForId('ANIM_ENEMY_DAMAGE','player'),true,'non-move animation keeps its native option exemption') + local running=s.anim + T.eq(s:animForId('ANIM_MISSING','player'),false,'unavailable ROM programs remain unavailable') + T.eq(s.anim,running,'failed lookup cannot retire a real running observation') + T.eq(s.anims.ids.ANIM_THROW_POKE_BALL,nil,'name metadata does not fabricate absent programs') + T.eq(s.anims.ids.ANIM_ENEMY_DAMAGE,0x10f,'old native caches expose existing named ROM row') + end +end +Version.set(previous) +T.finish('native Gen2 queued move owner and actual after-animation programs') diff --git a/tests/gpu/gen2_battle_scene.lua b/tests/gpu/gen2_battle_scene.lua new file mode 100644 index 000000000..866d0ee47 --- /dev/null +++ b/tests/gpu/gen2_battle_scene.lua @@ -0,0 +1,68 @@ +-- Generated art, actual native BG/OAM scene composition and GPU readbacks. +local M={} +function M.run() + local Version=require('src.core.GameVersion') + local Palette=require('src.render.PaletteFX') + local Player=require('src.battle.Gen2AnimPlayer') + local Battle=require('src.battle.BattleState') + local g=love.graphics + local previous={version=Version.get(),mode=Palette.mode,ramp=Palette.customRamp()} + local checks,frames=0,0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local function pixel(image,x,y,wanted,label) + local got={image:getPixel(x,y)} + for n=1,4 do check(math.abs(got[n]-wanted[n])<2/255,label..' channel '..n)end + end + local art=love.image.newImageData(8,8,'rgba8') + for y=0,7 do for x=0,7 do art:setPixel(x,y,2/3,2/3,2/3,1)end end + local path='qa-native-scene-obj.png';art:encode('png',path);art:release() + local canvas=g.newCanvas(304,144,{format='rgba8',dpiscale=1});M.image=canvas + local sentinel=g.newShader([[vec4 effect(vec4 c,Image t,vec2 uv,vec2 s){return Texel(t,uv)*c;}]]) + g.push('all') + local ok,why=xpcall(function() + for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition);Palette.setDisplay('gbc',nil) + local player=Player.new({battle_anims={gen2=true,moveAnims={FIX_MOVE=1}, + scripts={[1]={entry=1,code={{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, + objects={[0]={oamFlags=0,fixY=255,frameset=0,fn=0,palette=4,gfx=0}},functions={[0]='Null'}, + framesets={[0]={frames={{oam=0,duration=20}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[0]={tiles=1,cols=1,image=path}},palettes={[2]={{255,255,255},{220,40,80},{70,160,30},{0,0,0}}}}}) + check(player:start('FIX_MOVE',true),'native scene animation did not start');player:update() + local screen=setmetatable({frame=0,introSlide=0,fx={shakeX=3,shakeY=2},animPlayer=player,animPlaying=true, + colorMode=function()return false end,worldBackdrop=function()return false end, + drawPicsLayer=function()g.setColor(0,0,1,1);g.rectangle('fill',8,16,8,8)end, + drawHUDs=function()g.setColor(1,0,0,1);g.rectangle('fill',0,0,8,8)end, + drawTextArea=function()g.setColor(0,0,0,1);g.rectangle('fill',0,96,160,48)end},Battle) + for _,case in ipairs({'native','replacement','wide','flash'})do + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.setBlendMode('alpha');g.clear(0,0,0,1) + g.setShader(sentinel);g.setColor(.2,.3,.4,.5) + local ticks,age=player.frames,player.objects[1].age + local function panel()g.setShader();g.setColor(0,0,0,1);g.rectangle('fill',0,0,304,144)end + if case=='native'then screen:drawSceneBody() + elseif case=='replacement'then screen:drawSceneBody(panel) + else + screen.fx.flash=case=='flash'and 2 or nil + screen:drawSceneBody(panel,function()g.push();g.translate(136,0);screen:drawAnimLayer(false);g.pop()end,304,144) + end + frames=frames+1 + check(g.getShader()==sentinel,'native scene lost caller shader') + check(math.abs(select(4,g.getColor())-.5)<.001,'native scene lost caller tint') + check(player.frames==ticks and player.objects[1].age==age,'scene rendering advanced native battle time') + g.setCanvas();g.setShader();local image=g.readbackTexture(canvas) + if case=='native'then pixel(image,12,19,{0,0,1,1},'native BG shake')end + if case~='flash'then + pixel(image,(case=='wide'and 168 or 32)+2,26,{220/255,40/255,80/255,1},'native OAM independent of BG shake') + else pixel(image,300,100,{.85,.85,.85,1},'wide flash covers actual native surface')end + image:release() + end + player:release() + end + end,debug.traceback) + g.setCanvas();g.pop();sentinel:release() + Version.set(previous.version);Palette.setDisplay(previous.mode,previous.ramp) + if not ok then error(why,0)end + return {passed=true,checks=checks,frames=frames, + scope='Generated ROM-free Gold/Silver/Crystal native classic panel and real VM/OAM composition, replacement panel, translated wide OAM, full-width flash, state restoration and draw clock stability. Not full UI/art, scanline-effects, whole-mod or OS-window acceptance.'},canvas +end +return M From e340c6673f1b575b5fd3dae3273b0a530522ea27 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:22:40 +0100 Subject: [PATCH 21/64] Observe resolved Gen2 ball programs without repeated OAM allocation --- src/battle/Gen2AnimPlayer.lua | 10 ++++++++++ src/battle/Gen2AnimView.lua | 23 ++++++++++++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index bc19c78cc..fede60029 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -274,6 +274,16 @@ function Gen2AnimPlayer:start(moveId, attackerIsPlayer, opts) self.loops = {} self.bgSides = {} self.script = self:scriptFor(moveId) + -- Keep the queue's original ID for ball parameters/side handling, while + -- observers identify the actual GSC program (as Gen1Recomp's runner does). + -- A mod-defined alias is canonical only if it resolves to that same script. + self.presentationId = moveId + local alias = GEN1_BALL_ANIMS[moveId] + local canonical = alias == "throwPokeBall" and "ANIM_THROW_POKE_BALL" + or alias == "sendOutMon" and "ANIM_SEND_OUT_MON" + if self.script and canonical and self:scriptFor(canonical) == self.script then + self.presentationId = canonical + end if not self.script then if GEN1_BALL_ANIMS[moveId] == nil then self:warnOnce("anim:" .. tostring(moveId), diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index 2c45a074b..f2f8d3930 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -84,7 +84,7 @@ end -- to this same draw owner. Restarting the VM retires the old observation. function View.runner(player) local selected, frozen - local function state() + local function objects() local structs = {} for _, obj in ipairs(selected or player.objects) do local fn = obj.fn and ('BATTLE_ANIM_FUNC_'..obj.fn:gsub('(%l)(%u)','%1_%2'):upper()) @@ -92,16 +92,29 @@ function View.runner(player) if obj.fn == 'PokeBallBlocked' then fn = 'BATTLE_ANIM_FUNC_POKEBALL_BLOCKED' end structs[#structs+1] = {index=obj.index,func=fn,jt=obj.state} end - return {animId=player.moveId,frames=player.frames or 0,var=player.vars and player.vars.var or 0, + return {structs=structs} + end + local function state() + return {animId=player.presentationId or player.moveId,frames=player.frames or 0,var=player.vars and player.vars.var or 0, stopped=player:isDone(),keepSprites=player.keepSprites, - objects={structs=structs}, oam=player:oam(selected)} + objects=objects(), oam=player:oam(selected)} end local runner = {} function runner:oam() return frozen and frozen.oam or player:oam(selected) end function runner.selectObjects(objects) selected=objects end - function runner.retire() frozen=state(); player=nil end + function runner.retire() if not frozen then frozen=state(); player=nil end end return setmetatable(runner, { - __index=function(_,key) return (frozen or state())[key] end, + -- Scalar observations are hot in render/camera callbacks. Do not project + -- OAM and allocate all object records just to read a name or frame count. + __index=function(_,key) + if frozen then return frozen[key] end + if key=='animId'then return player.presentationId or player.moveId end + if key=='frames'then return player.frames or 0 end + if key=='var'then return player.vars and player.vars.var or 0 end + if key=='stopped'then return player:isDone()end + if key=='keepSprites'then return player.keepSprites end + if key=='objects'then return objects()end + end, __newindex=function() error('native Gen2 animation observation is read-only',2) end, }) end From b1b8f4b5ded98a65fd762c56d2d525ddbd7ecabe Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:22:54 +0100 Subject: [PATCH 22/64] Cover native Gen2 ball identity and lazy animation observations --- tests/engine/gen2_animation_identity.lua | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/engine/gen2_animation_identity.lua diff --git a/tests/engine/gen2_animation_identity.lua b/tests/engine/gen2_animation_identity.lua new file mode 100644 index 000000000..c8aa0519d --- /dev/null +++ b/tests/engine/gen2_animation_identity.lua @@ -0,0 +1,49 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Player=require('src.battle.Gen2AnimPlayer') +local View=require('src.battle.Gen2AnimView') +local previous=Version.get() +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data={battle_anims={gen2=true,moveAnims={FIX_MOVE=1},misc={throwPokeBall=0x100,sendOutMon=0x101}, + scripts={[1]={entry=1,code={{op='delay',frames=1},{op='ret'}}}, + [0x100]={entry=1,code={{op='delay',frames=2},{op='ret'}}}, + [0x101]={entry=1,code={{op='delay',frames=3},{op='ret'}}}}}} + for _,id in ipairs({'TOSS_ANIM','GREATTOSS_ANIM','ULTRATOSS_ANIM','BLOCKBALL_ANIM','POOF_ANIM','SHINY_ANIM','FIX_MOVE'})do + local p=Player.new(data);p:start(id,true,{ballIndex=4}) + local projections=0;local project=p.oam + p.oam=function(self,...)projections=projections+1;return project(self,...)end + local r=View.runner(p);p.presentation=r + local expected=id=='FIX_MOVE'and id or(id=='POOF_ANIM'or id=='SHINY_ANIM')and'ANIM_SEND_OUT_MON'or'ANIM_THROW_POKE_BALL' + T.eq(r.animId,expected,'observation identifies the actual native ROM program') + T.eq(p.moveId,id,'native queue/ball parameter identity is preserved') + T.eq(p.enemySide,id=='POOF_ANIM'or id=='SHINY_ANIM','native send-out side correction is preserved') + if id=='SHINY_ANIM'then T.eq(p.vars.param,1,'native shiny parameter is preserved') + elseif id=='BLOCKBALL_ANIM'then T.eq(p.vars.param,0,'native trainer block parameter is preserved') + elseif id~='POOF_ANIM'and id~='FIX_MOVE'then T.eq(p.vars.param,4,'native ball item parameter is preserved')end + for _=1,100 do + local name,frames,var,stopped,kept=r.animId,r.frames,r.var,r.stopped,r.keepSprites + end + T.eq(r.frames,p.frames,'repeated scalar reads observe native time') + T.eq(projections,0,'scalar queries never project OAM') + T.eq(#r.objects.structs,0,'object inspection remains separate from OAM projection') + T.eq(projections,0,'object metadata queries never project OAM') + r:oam();T.eq(projections,1,'explicit sprite drawing projects actual OAM once') + local before=p.frames;p:update();T.eq(r.frames,p.frames,'live frame observation advances with VM') + T.eq(p.frames,before+1,'observation does not advance the clock') + p:start('FIX_MOVE',false) + T.eq(r.animId,expected,'retired observation preserves original resolved program') + T.eq(projections,2,'retiring captures OAM only once') + r.retire();T.eq(projections,2,'repeated retirement does not rebuild sprites') + end + local p=Player.new(data) + data.battle_anims.moveAnims.TOSS_ANIM=1 + p:start('TOSS_ANIM',true) + T.eq(View.runner(p).animId,'TOSS_ANIM','different mod script cannot acquire native ball identity') + data.battle_anims.scripts[0x100]=nil + p:start('FIX_MOVE',true) + T.eq(View.runner(p).animId,'FIX_MOVE','absent special programs never acquire invented identities') +end +Version.set(previous) +T.finish('native Gen2 animation identity and lazy observation') From 29990c07668253629556f7808b6c0764045a1450 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:34:53 +0100 Subject: [PATCH 23/64] Restore licensed GSC BG backend API and edition-specific cartridge identifiers --- README.md | 6 + compat/gen1-gsc/LICENSE.MD | 759 ++++++++++++++ compat/gen1-gsc/provenance.json | 20 + src/battle/Gen2AnimMath.lua | 30 + src/battle/Gen2BgEffectIds.lua | 73 ++ src/battle/gen2/BgEffects.lua | 1377 ++++++++++++++++++++++++++ src/import/RomImporter.lua | 8 +- tests/engine/gen2_native_bg_pool.lua | 72 ++ 8 files changed, 2343 insertions(+), 2 deletions(-) create mode 100644 compat/gen1-gsc/LICENSE.MD create mode 100644 compat/gen1-gsc/provenance.json create mode 100644 src/battle/Gen2AnimMath.lua create mode 100644 src/battle/Gen2BgEffectIds.lua create mode 100644 src/battle/gen2/BgEffects.lua create mode 100644 tests/engine/gen2_native_bg_pool.lua diff --git a/README.md b/README.md index 4cd31e7c0..86a86f35c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC +(https://github.com/bryanthaboi/gen1recomp). + +GSC BG-effect source provenance and its retained licence are recorded in +[compat/gen1-gsc/provenance.json](compat/gen1-gsc/provenance.json). + # gen2recomp

diff --git a/compat/gen1-gsc/LICENSE.MD b/compat/gen1-gsc/LICENSE.MD new file mode 100644 index 000000000..806424f26 --- /dev/null +++ b/compat/gen1-gsc/LICENSE.MD @@ -0,0 +1,759 @@ +# License + +Copyright (C) 2026 BOIS CLUB GAMES, LLC + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, version 3 of the License, with the Additional Terms below, which +are added under Section 7 of that License. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +## Additional Terms (GPLv3 Section 7) + +### 1. Attribution (Section 7(b)) + +Any copy, fork, modified version, or work based on this program, in source or +object form, and for any purpose, must: + +- keep this license file and every copyright and author notice in the source; +- give clear, visible credit to the original project in its README (or + equivalent project page) and in any in-app credits, "about", or title + screen, reading at minimum: + "Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC + (https://github.com/bryanthaboi/gen1recomp)"; +- show that same credit, visibly, inside any launcher, front end, menu shell, + or other program used to start or select the game, including any launcher + written to replace the one excluded in term 2. + +Using this program, in whole or in part, for other projects is permitted, as +long as this credit is always given. + +### 2. The Launcher is not licensed under the GPL + +The project launcher (the "Launcher") is NOT part of the program licensed +above. It is proprietary: Copyright (C) 2026 BOIS CLUB GAMES, LLC, all rights +reserved. The Launcher may not be copied, modified, redistributed, or used in +any fork, modified version, or other project, in whole or in part. A fork or +derivative must provide its own launcher, which must carry the credit +required by term 1. + +The Launcher consists of the following files, and any later version of them: + +- `src/import/LauncherView.lua` +- `src/import/LauncherSettings.lua` +- `src/import/OnlinePanel.lua` +- `src/import/CartLabelArt.lua` +- `src/import/CartShape.lua` +- `src/mods/LauncherMods.lua` +- the launcher user interface code in `src/import/RomImporter.lua` (the + ROM import, verification, and cache extraction logic in that file remains + under the GPL) +- `assets/launcher/` and `assets/labels/` + +Distributing the Launcher together with this program is permitted only in +unmodified official releases from BOIS CLUB GAMES, LLC. + +### PortMaster distribution permission + +As an exception to the Launcher distribution restriction above, BOIS CLUB +GAMES, LLC grants permission to distribute the Launcher with this program +through PortMaster, including the PortMaster-MV repository and its release +packages. This permission covers PortMaster packaging and launch scripts; +it does not grant a general right to reuse or modify the proprietary Launcher +in other projects. Retain this license and the attribution required above. + +This exception records the explicit PortMaster permission granted by project +owner bryanthaboi on 6 October 2026. All other terms remain unchanged. + +### 3. Names and marks (Section 7(e)) + +This license grants no rights to use the names, logos, or trademarks of BOIS +CLUB GAMES, LLC or of the project, except as needed to give the credit +required by term 1. + +### 4. Third-party rights + +This project contains no ROM data and no extracted game content. Pokemon and +all related names and content are the property of Nintendo, Creatures Inc., +and GAME FREAK inc. This license covers only the original code and assets of +this project. + +--- + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json new file mode 100644 index 000000000..d9ee9f74a --- /dev/null +++ b/compat/gen1-gsc/provenance.json @@ -0,0 +1,20 @@ +{ + "repository": "https://github.com/bryanthaboi/gen1recomp", + "commit": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "files": { + "src/battle/gen2/BgEffects.lua": "e50650aa3315a2545cf0044fbf226076be0086a63b097ef5d682e2beefb5491e", + "src/battle/gen2/AnimObjects.lua": "7713be1ee3b88284a3399baeb9e81d5c0a98c421990336bec3ebb31e832982f6", + "src/ui/gen2/SpriteAnims.lua": "31c213a712edab13d03f925d3f4cd4f487b2cbd724ac50692f576cd04261ce65", + "tools/rom_manifest_gold.json": "ebfd906ef715c634c12ca168dbf711161385720f02c6e0a63d9481a815550a1e", + "tools/rom_manifest_silver.json": "7575919b843c2addf096ed3db7b37dedb9f4b9c8129d32eb0d9ed6c9e5333f52", + "tools/rom_manifest_crystal.json": "be373e361090d79f413ad4d1e57d28c5ac1aa00650d4fa361289a78ef8397b31", + "LICENSE.MD": "b369ce45656b3d87453541816fac6982b13e09ea0734ab9cfe61cf3faf5e95ed" + }, + "scope": "BgEffects implementation, arithmetic subset and BG-effect identifier lists only. No launcher, game assets, ROM bytes or player state copied.", + "modifications": [ + "BgEffects arithmetic dependency uses Gen2AnimMath instead of the full reference object pool.", + "A missing jump-table list resolves through edition-specific Gen2BgEffectIds for older native caches." + ], + "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", + "qualification": "Standalone native BG API and CPU state-machine traces only. Native animation VM / drawing integration and actual client GPU acceptance remain required before merge." +} diff --git a/src/battle/Gen2AnimMath.lua b/src/battle/Gen2AnimMath.lua new file mode 100644 index 000000000..aa4d7f846 --- /dev/null +++ b/src/battle/Gen2AnimMath.lua @@ -0,0 +1,30 @@ +-- Gen1Recomp-derived code: GPLv3 plus Additional Terms; see +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Existing rights retained. +-- GSC byte arithmetic and sine from Gen1Recomp's AnimObjects/SpriteAnims at +-- 535a5ec0. Existing source rights/licences remain under compat/gen1-gsc/LICENSE.MD. +-- This is arithmetic only; the native VM still owns its objects and clock. +local bit = require('bit') +local M = {} +function M.u8(value) return value % 256 end +local function s8(value) + value = value % 256 + return value < 0x80 and value or value - 256 +end +function M.sra(value) return M.u8(math.floor(s8(value) / 2)) end +function M.swap(value) + value = M.u8(value) + return bit.bor(bit.rshift(value,4), bit.band(bit.lshift(value,4),0xf0)) +end +local sine = {} +for i=0,31 do sine[i] = math.floor(math.sin(i*math.pi/32)*256+0.5) end +function M.sine(angle, amplitude) + angle = angle % 64 + local negative = angle >= 32 + if negative then angle = angle - 32 end + local product = (amplitude % 256) * sine[angle] % 0x10000 + local result = math.floor(product / 256) + if negative then result = -result end + return result % 256 +end +function M.cosine(angle, amplitude) return M.sine(angle+0x10,amplitude) end +return M diff --git a/src/battle/Gen2BgEffectIds.lua b/src/battle/Gen2BgEffectIds.lua new file mode 100644 index 000000000..21f34e0fc --- /dev/null +++ b/src/battle/Gen2BgEffectIds.lua @@ -0,0 +1,73 @@ +-- Gen1Recomp-derived code: GPLv3 plus Additional Terms; see +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Existing rights retained. +-- GSC BG-effect jump-table identifiers from Gen1Recomp's Gold/Silver/Crystal +-- ROM manifests at535a5ec0, constants.battleBgEffectOrder. Numeric command +-- arguments retain the cartridge's order; no program or frame is synthesized. +local base = { + "BATTLE_BG_EFFECT_END", + "BATTLE_BG_EFFECT_FLASH_INVERTED", + "BATTLE_BG_EFFECT_FLASH_WHITE", + "BATTLE_BG_EFFECT_WHITE_HUES", + "BATTLE_BG_EFFECT_BLACK_HUES", + "BATTLE_BG_EFFECT_ALTERNATE_HUES", + "BATTLE_BG_EFFECT_CYCLE_OBPALS_GRAY_AND_YELLOW", + "BATTLE_BG_EFFECT_CYCLE_MID_OBPALS_GRAY_AND_YELLOW", + "BATTLE_BG_EFFECT_CYCLE_BGPALS_INVERTED", + "BATTLE_BG_EFFECT_HIDE_MON", + "BATTLE_BG_EFFECT_SHOW_MON", + "BATTLE_BG_EFFECT_ENTER_MON", + "BATTLE_BG_EFFECT_RETURN_MON", + "BATTLE_BG_EFFECT_SURF", + "BATTLE_BG_EFFECT_WHIRLPOOL", + "BATTLE_BG_EFFECT_TELEPORT", + "BATTLE_BG_EFFECT_NIGHT_SHADE", + "BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", + "BATTLE_BG_EFFECT_BATTLEROBJ_2ROW", + "BATTLE_BG_EFFECT_DOUBLE_TEAM", + "BATTLE_BG_EFFECT_ACID_ARMOR", + "BATTLE_BG_EFFECT_RAPID_FLASH", + "BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT", + "BATTLE_BG_EFFECT_FADE_MON_TO_BLACK", + "BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT_REPEATING", + "BATTLE_BG_EFFECT_FADE_MON_TO_BLACK_REPEATING", + "BATTLE_BG_EFFECT_CYCLE_MON_LIGHT_DARK_REPEATING", + "BATTLE_BG_EFFECT_FLASH_MON_REPEATING", + "BATTLE_BG_EFFECT_FADE_MONS_TO_BLACK_REPEATING", + "BATTLE_BG_EFFECT_FADE_MON_TO_WHITE_WAIT_FADE_BACK", + "BATTLE_BG_EFFECT_FADE_MON_FROM_WHITE", + "BATTLE_BG_EFFECT_SHAKE_SCREEN_X", + "BATTLE_BG_EFFECT_SHAKE_SCREEN_Y", + "BATTLE_BG_EFFECT_WITHDRAW", + "BATTLE_BG_EFFECT_BOUNCE_DOWN", + "BATTLE_BG_EFFECT_DIG", + "BATTLE_BG_EFFECT_TACKLE", + "BATTLE_BG_EFFECT_WOBBLE_MON", + "BATTLE_BG_EFFECT_REMOVE_MON", + "BATTLE_BG_EFFECT_WAVE_DEFORM_MON", + "BATTLE_BG_EFFECT_PSYCHIC", + "BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1", + "BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2", + "BATTLE_BG_EFFECT_FLAIL", + "BATTLE_BG_EFFECT_BETA_PURSUIT", + "BATTLE_BG_EFFECT_ROLLOUT", + "BATTLE_BG_EFFECT_VITAL_THROW", + "BATTLE_BG_EFFECT_START_WATER", + "BATTLE_BG_EFFECT_WATER", + "BATTLE_BG_EFFECT_END_WATER", + "BATTLE_BG_EFFECT_VIBRATE_MON", + "BATTLE_BG_EFFECT_WOBBLE_PLAYER", + "BATTLE_BG_EFFECT_WOBBLE_SCREEN", +} +local M = {} +function M.forVersion(version) + version = version or require('src.core.GameVersion').get() + local order = {} + for i,name in ipairs(base) do + if i==38 and version~='gold' and version~='silver' then + order[#order+1] = 'BATTLE_BG_EFFECT_BODY_SLAM' + end + order[#order+1] = name + end + return order +end +return M diff --git a/src/battle/gen2/BgEffects.lua b/src/battle/gen2/BgEffects.lua new file mode 100644 index 000000000..be080a185 --- /dev/null +++ b/src/battle/gen2/BgEffects.lua @@ -0,0 +1,1377 @@ +-- Gen1Recomp-derived code: GPLv3 plus Additional Terms; see +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Existing rights retained. +-- Gen1Recomp source: src/battle/gen2/BgEffects.lua at535a5ec0. +-- Its existing copyrights/licences remain effective (compat/gen1-gsc/LICENSE.MD). +-- Native dependency integration: Roxas2712, under the same applicable terms. +-- The Gen 2 battle-animation BACKGROUND effects. +-- +-- pokegold engine/battle_anims/bg_effects.asm: five concurrent structs, each +-- of {function, jumptable index, battle turn, param}, and a jumptable of 53 +-- effects that between them do all the shaking, flashing and sinking a Gen 2 +-- battle animation is made of. Objects (src/battle/gen2/AnimObjects.lua) are +-- OBJs; these are the BG layer. +-- +-- The one mechanism worth understanding before reading any of it: almost +-- nothing here moves a sprite. It writes wLYOverridesBackup, a per-scanline +-- value the LCD STAT interrupt copies into rSCX, rSCY or rBGP as the beam +-- passes. BattleBGEffect_SetLCDStatCustoms1 picks the window -- scanlines +-- $00-$36 for the enemy's pic, $2f-$5e for the player's -- so "shake the +-- attacker" is "write the same SCX to every scanline the attacker occupies". +-- +-- The port keeps that literally: `lyBackup` is 144 bytes, `lcdc` names the +-- register they land in, and src/ui/gen2/BattleAnimView.lua draws the BG layer +-- one scanline band at a time. Modelling it as "move the mon pic" instead +-- would work for Tackle and then fall apart on Withdraw and Dig, which push a +-- GROWING number of rows off and leave the rest where they are. +-- +-- Love-free, like AnimObjects. + +local bit = require("bit") +-- Arithmetic shared with the reference object engine; the fork keeps its +-- existing OAM owner instead of starting a second object pool. +local AnimObjects = require("src.battle.Gen2AnimMath") + +local u8, sra = AnimObjects.u8, AnimObjects.sra +local swap = AnimObjects.swap +local sine, cosine = AnimObjects.sine, AnimObjects.cosine + +local BgEffects = {} + +local NUM_EFFECTS = 5 -- NUM_BG_EFFECTS +local SCREEN_ROWS = 0x90 -- wLYOverridesBackup is $91 bytes + +-- `dc a, b, c, d` packs four 2-bit shades into a DMG palette byte, high pair +-- first: `dc 3, 2, 1, 0` is %11100100 = $e4, the identity ramp. +local function dc(a, b, c, d) return a * 64 + b * 16 + c * 4 + d end + +local NORMAL_PAL = dc(3, 2, 1, 0) + +-------------------------------------------------------------------------- + +local Pool = {} +Pool.__index = Pool + +local function newEffect() + return { func = nil, jt = 0, turn = 0, param = 0, side = nil } +end + +-- `env` is shared with the object pool: env.battleTurn is hBattleTurn, and +-- env.flying tells BGEffect_CheckFlyDigStatus whether the battler in question +-- is mid-Fly or mid-Dig (in which case ShowMon and the battler-pic objects +-- decline to draw a mon that is not on the field). +function BgEffects.new(constants, env) + local self = setmetatable({}, Pool) + self.env = env or {} + self.effects = {} + for slot = 1, NUM_EFFECTS do self.effects[slot] = newEffect() end + self.order = (constants or {}).battleBgEffectOrder or require("src.battle.Gen2BgEffectIds").forVersion() + self:reset() + return self +end + +function Pool:reset() + for slot = 1, NUM_EFFECTS do self.effects[slot] = newEffect() end + -- hSCX / hSCY: a whole-screen scroll, which is what the screen shakes use. + self.scx, self.scy = 0, 0 + -- hLCDCPointer plus its window, and the per-scanline values themselves. + self.lcdc, self.lyStart, self.lyEnd = nil, 0, 0 + self.lyBackup = {} + for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end + -- wBGP / wOBP0 / wOBP1, as DMG palette bytes. + self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL + -- Per-battler state the CGB paths write instead of touching wBGP: shade + -- byte, hidden flag, lifted tile rows, and which BG square it is drawn at. + self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL } + self.hidden = { player = false, enemy = false } + self.liftedRows = { player = nil, enemy = nil } + self.picSize = { player = nil, enemy = nil } + self.slide = { player = 0, enemy = 0 } + -- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the + -- overrides. nil until InitSurfWaves lays one down. + self.surfWave = nil + -- The objects a BG effect asks the object pool to spawn, drained by the + -- runner after each frame. + self.spawns = {} +end + +function Pool:activeCount() + local count = 0 + for slot = 1, NUM_EFFECTS do + if self.effects[slot].func then count = count + 1 end + end + return count +end + +-- QueueBGEffect: first free struct wins; a full pool silently drops the +-- request, which is exactly what the carry return means to the caller. +function Pool:queue(effectId, jumptableIndex, turn, param) + local name = effectId + if type(effectId) == "number" then + name = self.order[effectId + 1] or effectId + end + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if not st.func then + st.func = name + st.jt = u8(jumptableIndex or 0) + st.turn = u8(turn or 0) + st.param = u8(param or 0) + st.side = nil + return st + end + end + return nil +end + +-- BattleAnimCmd_IncBGEffect: bump the jumptable index of the first struct +-- running this effect. +function Pool:incEffect(effectId) + local name = effectId + if type(effectId) == "number" then + name = self.order[effectId + 1] or effectId + end + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if st.func == name then + st.jt = u8(st.jt + 1) + return st + end + end + return nil +end + +-------------------------------------------------------------------------- +-- Shared helpers +-------------------------------------------------------------------------- + +local function endEffect(st) st.func = nil end +local function incJt(st) st.jt = u8(st.jt + 1) end + +-- BGEffect_CheckBattleTurn: non-zero means "the side this effect is aimed at +-- is the player's". A struct's `turn` is BG_EFFECT_USER / BG_EFFECT_TARGET, +-- so the same effect id follows whichever battler is attacking. +function Pool:playerSide(st) + return bit.bxor(bit.band(self.env.battleTurn or 0, 1), st.turn) ~= 0 +end + +function Pool:sideKey(st) + return self:playerSide(st) and "player" or "enemy" +end + +-- BGEffect_CheckFlyDigStatus: zero means "on the field". +function Pool:flyDig(st) + local flying = self.env.flying or {} + return flying[self:sideKey(st)] and true or false +end + +function Pool:clearLYOverrides(value) + value = value or 0 + for row = 0, SCREEN_ROWS do self.lyBackup[row] = value end +end + +-- BattleBGEffect_SetLCDStatCustoms1: the window is the attacker's pic rows. +function Pool:setLCDStatCustoms1(register, st) + self.lcdc = register + if self:playerSide(st) then + self.lyStart, self.lyEnd = 0x2f, 0x5e + else + self.lyStart, self.lyEnd = 0x00, 0x36 + end +end + +-- engine/battle_anims/bg_effects.asm:2671 +function Pool:setLCDStatCustoms2(register, st) + self.lcdc = register + if self:playerSide(st) then + self.lyStart, self.lyEnd = 0x2d, 0x5e + else + self.lyStart, self.lyEnd = 0x00, 0x36 + end +end + +function Pool:resetLCDStatCustom(st) + self.lyStart, self.lyEnd = 0, 0 + self:clearLYOverrides(0) + self.lcdc = nil + endEffect(st) +end + +function Pool:resetVideoHRAM() + self.lcdc = nil + self.bgp, self.obp1 = NORMAL_PAL, NORMAL_PAL + self.lyStart, self.lyEnd = 0, 0 + self:clearLYOverrides(0) +end + +-- BGEffect_FillLYOverridesBackup: the same value on every scanline in the +-- window. `dec d; jr nz` after the first store, so a zero-width window would +-- run 256 times; the port refuses instead of wrapping the array. +function Pool:fillLY(value) + local count = u8(self.lyEnd - self.lyStart) + if count == 0 then count = 256 end + for i = 0, count - 1 do + local row = self.lyStart + i + if row > SCREEN_ROWS then break end + self.lyBackup[row] = u8(value) + end +end + +-- BGEffect_DisplaceLYOverridesBackup: the first `a` scanlines of the window +-- are scrolled to a blank part of the map ($90) and the rest are pushed down +-- by a + 1. That is what makes Withdraw and Dig look like the mon sinking +-- rather than sliding. +function Pool:displaceLY(a) + a = u8(a) + local span = u8(self.lyEnd - self.lyStart) + local rest = u8(span - a) + local row = self.lyStart + for _ = 1, (a == 0 and 256 or a) do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = 0x90 + row = row + 1 + end + local pushed = u8(0xff - a) + for _ = 1, (rest == 0 and 256 or rest) do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = pushed + row = row + 1 + end +end + +-- DeformScreen: a standing sine wave down the window. It walks the FIRST +-- $80 entries of wLYOverridesBackup by their low address byte and writes only +-- the ones inside the window -- `cp c / jr nc` skips while lyStart >= c and +-- `cp c / jr c` skips once lyEnd < c, so the row written is strictly +-- lyStart < row <= lyEnd -- but the phase advances on EVERY iteration, window +-- or not. So where the window sits decides which part of the wave lands on +-- it, and two effects with the same amplitude and offset but different +-- windows do not look alike. +-- +-- `lb de, d, e` puts the AMPLITUDE in d and the phase step in e. +function Pool:deformScreen(amplitude, offset) + local progress = 0 + for row = 0, 0x7f do + if self.lyStart < row and row <= self.lyEnd and row <= SCREEN_ROWS then + self.lyBackup[row] = sine(progress, amplitude) + end + progress = u8(progress + offset) + end +end + +-- InitSurfWaves: the same wave, into the $40-byte wSurfWaveBGEffect ring +-- rather than the overrides themselves. Surf rotates that ring a step a frame +-- and copies it out, which is what makes the water ROLL instead of standing +-- still the way DeformScreen's does. +Pool.SURF_WAVE_LENGTH = 0x40 + +function Pool:initSurfWaves(amplitude, offset) + local progress = 0 + self.surfWave = {} + for index = 0, Pool.SURF_WAVE_LENGTH - 1 do + self.surfWave[index] = sine(progress, amplitude) + progress = u8(progress + offset) + end +end + +-- BattleBGEffect_Surf's `.RotatewSurfWaveBGEffect`: rotate the ring left one, +-- then paint scanlines $00-$5e from it -- zero at and below lyStart, the ring +-- (wrapping every $40 rows) above it. The ring index advances on every +-- scanline including the zeroed ones, so the wave keeps its phase across the +-- boundary. +function Pool:rotateSurfWave() + local wave = self.surfWave + if not wave then return end + local first = wave[0] + for index = 0, Pool.SURF_WAVE_LENGTH - 2 do + wave[index] = wave[index + 1] + end + wave[Pool.SURF_WAVE_LENGTH - 1] = first + local ring = 0 + for row = 0, 0x5e do + local value = 0 + if self.lyStart < row then value = wave[ring] end + if row <= SCREEN_ROWS then self.lyBackup[row] = u8(value) end + ring = bit.band(ring + 1, Pool.SURF_WAVE_LENGTH - 1) + end +end + +-- DeformWater: `count` PAIRS of scanlines either side of a centre at +-- lyStart + `progress`, each pair taking the next step of a sine whose angle +-- climbs by 4 a pair. Both walkers start on the centre row, so it is written +-- twice and the figure is symmetric about it. The two bounds checks are not +-- the same test: the downward walker stops once lyEnd < its row, the upward +-- one once lyStart >= its row. +function Pool:deformWater(count, amplitude, offset, progress) + local down = self.lyStart + (progress or 0) + local up = down + local angle = u8(offset) + for _ = 1, u8(count) do + local value = sine(angle, amplitude) + if self.lyEnd >= down then + if down >= 0 and down <= SCREEN_ROWS then self.lyBackup[down] = value end + down = down + 1 + end + if self.lyStart < up then + if up >= 0 and up <= SCREEN_ROWS then self.lyBackup[up] = value end + up = up - 1 + end + angle = u8(angle + 4) + end +end + +-- BattleBGEffect_WavyScreenFX: rotate the window's overrides up one row, the +-- old top row wrapping around to the bottom. Every wobble effect is +-- DeformScreen once to lay the wave down and then this, once a frame, to make +-- it travel. +function Pool:wavyScreenFX() + local span = u8(self.lyEnd - self.lyStart) + if span == 0 then return end + local first = self.lyBackup[self.lyStart] or 0 + for i = 0, span - 1 do + local row = self.lyStart + i + if row > SCREEN_ROWS then break end + self.lyBackup[row] = self.lyBackup[row + 1] or 0 + end + local last = self.lyStart + span + if last <= SCREEN_ROWS then self.lyBackup[last] = first end +end + +-- BattleBGEffect_GetFirstDMGPal / GetNextDMGPal walking a `dc` list. +-- $ff ends the effect (returns nil); $fe restarts the list from the top. +local function nextPal(st, pals) + local index = st.param + st.param = u8(st.param + 1) + local value = pals[index + 1] + if value == nil or value == 0xff then return nil end + if value == 0xfe then + -- Rewind and hand back the list's first entry. + st.param = 0 + value = pals[1] + end + return value +end + +-- BattleBGEffect_GetNthDMGPal: JT doubles as a per-step frame counter, and it +-- is reloaded from the struct's `turn` -- so the SAME field is the flash speed +-- here and the battler side everywhere else. +local function nthPal(st, pals) + if st.jt ~= 0 then + st.jt = st.jt - 1 + local index = st.param + local value = pals[index + 1] + if value == nil or value == 0xff then return nil end + if value == 0xfe then + st.param = 0 + value = pals[1] + end + return value + end + st.jt = st.turn + return nextPal(st, pals) +end + +-------------------------------------------------------------------------- +-- The effects (BattleBGEffects jumptable) +-------------------------------------------------------------------------- + +local E = {} + +E.BATTLE_BG_EFFECT_END = function(_, st) endEffect(st) end + +-- BattleBGEffect_FlashContinue: `turn` is the flash duration, `param` the +-- number of flashes left, and the two palettes alternate. +local function flash(self, st, pals) + if st.jt ~= 0 then + st.jt = st.jt - 1 + return + end + st.jt = st.turn + if st.param == 0 then + endEffect(st) + return + end + st.param = u8(st.param - 1) + self.bgp = pals[bit.band(st.param, 1) + 1] +end + +E.BATTLE_BG_EFFECT_FLASH_INVERTED = function(self, st) + flash(self, st, { dc(3, 2, 1, 0), dc(0, 1, 2, 3) }) +end + +E.BATTLE_BG_EFFECT_FLASH_WHITE = function(self, st) + flash(self, st, { dc(3, 2, 1, 0), dc(0, 0, 0, 0) }) +end + +local WHITE_HUES = { dc(3, 2, 1, 0), dc(3, 2, 0, 0), dc(3, 1, 0, 0), 0xff } +local BLACK_HUES = { dc(3, 2, 1, 0), dc(3, 3, 1, 0), dc(3, 3, 2, 0), 0xff } +local ALTERNATE_HUES = { + dc(3, 2, 1, 0), dc(3, 3, 2, 0), dc(3, 3, 3, 0), dc(3, 3, 2, 0), + dc(3, 2, 1, 0), dc(2, 1, 0, 0), dc(1, 0, 0, 0), dc(2, 1, 0, 0), 0xfe, +} + +E.BATTLE_BG_EFFECT_WHITE_HUES = function(self, st) + local value = nthPal(st, WHITE_HUES) + if not value then + endEffect(st) + return + end + self.bgp = value +end + +E.BATTLE_BG_EFFECT_BLACK_HUES = function(self, st) + local value = nthPal(st, BLACK_HUES) + if not value then + endEffect(st) + return + end + self.bgp = value +end + +E.BATTLE_BG_EFFECT_ALTERNATE_HUES = function(self, st) + local value = nthPal(st, ALTERNATE_HUES) + if not value then + endEffect(st) + return + end + self.bgp, self.obp1 = value, value +end + +local OB_GRAY_YELLOW = { dc(3, 2, 1, 0), dc(2, 1, 0, 0), 0xfe } +local OB_MID_GRAY_YELLOW = { dc(3, 2, 1, 0), dc(3, 1, 2, 0), 0xfe } +local BG_INVERTED = { dc(0, 1, 2, 3), dc(1, 2, 0, 3), dc(2, 0, 1, 3), 0xfe } + +E.BATTLE_BG_EFFECT_CYCLE_OBPALS_GRAY_AND_YELLOW = function(self, st) + local value = nthPal(st, OB_GRAY_YELLOW) + if value then self.obp0 = value end +end + +E.BATTLE_BG_EFFECT_CYCLE_MID_OBPALS_GRAY_AND_YELLOW = function(self, st) + local value = nthPal(st, OB_MID_GRAY_YELLOW) + if value then self.obp0 = value end +end + +E.BATTLE_BG_EFFECT_CYCLE_BGPALS_INVERTED = function(self, st) + local value = nthPal(st, BG_INVERTED) + if value then self.bgp = value end +end + +-- engine/battle_anims/bg_effects.asm:343 +E.BATTLE_BG_EFFECT_HIDE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self.hidden[self:sideKey(st)] = true + elseif jt >= 1 and jt <= 3 then + incJt(st) + elseif jt == 4 then + endEffect(st) + end +end + +-- BattleBGEffect_RunPicResizeScript: rows of {size, base tile, coord slot}, +-- $ff ending, $fe clearing a box, $fd skipping the draw. The sizes are the +-- six BG squares (6x6, 4x4, 2x2 for the player; 7x7, 5x5, 3x3 for the enemy), +-- which is how a mon grows into or shrinks out of the field. Only the size +-- matters to this port -- the tile ids and the coord slot are the same pic in +-- the same box -- so the script is followed for its TIMING and its scale. +local PIC_RESIZE = { + -- BattleBGEffect_ShowMon + showPlayer = { 0 }, + showEnemy = { 3 }, + -- BattleBGEffect_EnterMon + enterPlayer = { 2, 1, 0 }, + enterEnemy = { 5, 4, 3 }, + -- BattleBGEffect_ReturnMon: each step is preceded by a box clear, which is + -- the -2 row, and the last -3 row leaves the field empty. + returnPlayer = { 0, 1, 2, false }, + returnEnemy = { 3, 4, 5, false }, +} + +local function runPicResize(self, st, script) + local side = self:sideKey(st) + local jt = st.jt + if jt == 0 then + local step = script[st.param + 1] + st.param = u8(st.param + 1) + if step == nil then + self.picSize[side] = nil + endEffect(st) + return + end + if step == false then + self.picSize[side] = nil + self.hidden[side] = true + else + self.picSize[side] = step + self.hidden[side] = false + end + self.liftedRows[side] = nil + incJt(st) + elseif jt >= 1 and jt <= 2 then + incJt(st) + elseif jt == 3 then + st.jt = 0 + elseif jt == 4 then + self.picSize[side] = nil + endEffect(st) + end +end + +E.BATTLE_BG_EFFECT_SHOW_MON = function(self, st) + if self:flyDig(st) then + endEffect(st) + return + end + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.showPlayer or PIC_RESIZE.showEnemy) +end + +E.BATTLE_BG_EFFECT_ENTER_MON = function(self, st) + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.enterPlayer or PIC_RESIZE.enterEnemy) +end + +E.BATTLE_BG_EFFECT_RETURN_MON = function(self, st) + runPicResize(self, st, self:playerSide(st) + and PIC_RESIZE.returnPlayer or PIC_RESIZE.returnEnemy) +end + +-- BattleBGEffect_RemoveMon slides the pic's tilemap one column a frame +-- towards the edge it came from, eight or nine columns' worth. +E.BATTLE_BG_EFFECT_REMOVE_MON = function(self, st) + local side = self:sideKey(st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = self:playerSide(st) and 9 or 8 + elseif jt == 1 then + self.slide[side] = self.slide[side] + (self:playerSide(st) and -8 or 8) + incJt(st) + st.param = u8(st.param - 1) + elseif jt == 2 or jt == 3 then + incJt(st) + elseif jt == 4 then + if st.param == 0 then + self.slide[side] = 0 + self.hidden[side] = true + endEffect(st) + return + end + st.jt = 1 + end +end + +-- The two battler-pic objects: the animation borrows the mon's own tiles as +-- an OBJ so it can be moved without touching the tilemap. +local function battlerObj(self, st, objectPlayer, objectEnemy, rows) + local jt = st.jt + if jt == 0 then + if self:flyDig(st) then + endEffect(st) + return + end + incJt(st) + local player = self:playerSide(st) + self.spawns[#self.spawns + 1] = { + object = player and objectPlayer or objectEnemy, + x = player and (6 * 8) or (16 * 8 + 4), + y = 8 * 8, + param = 0, + } + elseif jt == 1 then + incJt(st) + -- engine/battle_anims/bg_effects.asm:448-465: the rows the OBJ now covers + -- come out of the tilemap, and .five never puts them back. + self.liftedRows[self:sideKey(st)] = rows[self:sideKey(st)] + elseif jt >= 2 and jt <= 4 then + incJt(st) + elseif jt == 5 then + endEffect(st) + end +end + +E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st) + battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + { player = { 0, 1 }, enemy = { 6, 1 } }) +end + +E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st) + battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", + { player = { 0, 2 }, enemy = { 5, 2 } }) +end + +-- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler +-- (the struct's side) rather than to the whole background, which is what the +-- per-mon fades want; the port keeps that and leaves wBGP alone. +local function rapidCyclePals(self, st, pals) + local jt = st.jt + if jt == 0 then + -- engine/battle_anims/bg_effects.asm:2480-2494 + incJt(st) + st.side = self:sideKey(st) + st.turn = st.param + st.param = 0 + return + end + local side = st.side or self:sideKey(st) + if jt == 1 then + if bit.band(st.turn, 0xf) ~= 0 then + st.turn = u8(st.turn - 1) + return + end + -- The low nybble is reloaded from the high one, which is the step delay. + st.turn = bit.bor(swap(st.turn), st.turn) + local value = nextPal(st, pals) + if value == nil then + -- engine/battle_anims/bg_effects.asm:2515-2519 + st.param = u8(st.param - 1) + return + end + self.monShade[side] = value + return + end + self.monShade[side] = NORMAL_PAL + endEffect(st) +end + +local RAPID_PALS = { + BATTLE_BG_EFFECT_RAPID_FLASH = { 0xe4, 0x6c, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT = { 0xe4, 0x90, 0x40, 0xff }, + BATTLE_BG_EFFECT_FADE_MON_TO_BLACK = { 0xe4, 0xf8, 0xfc, 0xff }, + BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT_REPEATING = { 0xe4, 0x90, 0x40, 0x90, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_BLACK_REPEATING = { 0xe4, 0xf8, 0xfc, 0xf8, 0xfe }, + BATTLE_BG_EFFECT_CYCLE_MON_LIGHT_DARK_REPEATING = + { 0xe4, 0xf8, 0xfc, 0xf8, 0xe4, 0x90, 0x40, 0x90, 0xfe }, + BATTLE_BG_EFFECT_FLASH_MON_REPEATING = { 0xe4, 0xfc, 0xe4, 0x00, 0xfe }, + BATTLE_BG_EFFECT_FADE_MON_TO_WHITE_WAIT_FADE_BACK = { + 0xe4, 0x90, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, 0x90, 0xe4, 0xff, + }, + BATTLE_BG_EFFECT_FADE_MON_FROM_WHITE = { 0x00, 0x40, 0x90, 0xe4, 0xff }, +} + +for name, pals in pairs(RAPID_PALS) do + E[name] = function(self, st) rapidCyclePals(self, st, pals) end +end + +-- BattleBGEffect_FadeMonsToBlackRepeating fades BOTH battlers, on opposite +-- halves of the same four-step ramp. +local FADE_BOTH = { 0xe4, 0xe4, 0xf8, 0x90, 0xfc, 0x40, 0xf8, 0x90 } + +E.BATTLE_BG_EFFECT_FADE_MONS_TO_BLACK_REPEATING = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = 0 + return + end + if jt == 1 then + local age = st.param + st.param = u8(st.param + 1) + if bit.band(age, 7) ~= 0 then return end + -- Bits 3-4 pick the pair, doubled into a row index. + local index = bit.rshift(bit.band(age, 0x18), 3) * 2 + local first = FADE_BOTH[index + 1] or NORMAL_PAL + local second = FADE_BOTH[index + 2] or NORMAL_PAL + if self:playerSide(st) then + self.monShade.player, self.monShade.enemy = first, second + else + self.monShade.enemy, self.monShade.player = first, second + end + return + end + self.monShade.player, self.monShade.enemy = NORMAL_PAL, NORMAL_PAL + endEffect(st) +end + +-- BattleBGEffects_GetShakeAmount. JT is the total frame count, PARAM's low +-- nybble the countdown to the next flip (reloaded from its high nybble) and +-- `turn` the amplitude, negated on every flip. Returns nil once it is done. +local function shakeAmount(self, st) + if st.jt == 0 then + endEffect(st) + return nil + end + st.jt = st.jt - 1 + if bit.band(st.param, 0xf) ~= 0 then + st.param = u8(st.param - 1) + return st.turn + end + st.param = bit.bor(swap(st.param), st.param) + st.turn = u8(-st.turn) + return st.turn +end + +E.BATTLE_BG_EFFECT_SHAKE_SCREEN_X = function(self, st) + self.scx = shakeAmount(self, st) or 0 +end + +E.BATTLE_BG_EFFECT_SHAKE_SCREEN_Y = function(self, st) + self.scy = shakeAmount(self, st) or 0 +end + +-- Rollout shakes vertically and hands the negated amount to the first anim +-- object's Y offset, so the boulder rides the shake instead of floating over +-- it. The cart's extra DelayFrame here is what makes Rollout's animation run +-- at half speed; RunBattleAnimScript skips its own frame delay to compensate. +E.BATTLE_BG_EFFECT_ROLLOUT = function(self, st) + local amount = shakeAmount(self, st) + if amount == nil or bit.band(amount, 0x80) ~= 0 then amount = 0 end + self.scy = amount + self.rolloutYOffset = u8(-amount) +end + +E.BATTLE_BG_EFFECT_WOBBLE_SCREEN = function(self, st) + if st.param >= 0x40 then + self.scx = 0 + return + end + self.scx = sine(st.param, 6) + st.param = u8(st.param + 2) +end + +-- Withdraw: a growing number of the attacker's scanlines are pushed off, so +-- the mon appears to pull into its shell. PARAM's low six bits are how far +-- to go and its top two the step. +E.BATTLE_BG_EFFECT_WITHDRAW = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + elseif jt == 1 then + local limit = bit.band(st.param, 0x3f) + if st.turn >= limit then return end + self:displaceLY(st.turn) + local step = bit.band(bit.rshift(st.param, 6), 3) + st.turn = u8(st.turn + step) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Dig: the same displacement, but it pauses and then eats the pic two +-- scanlines at a time until the whole window is gone. +E.BATTLE_BG_EFFECT_DIG = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 2 + st.param = 0 + elseif jt == 1 then + if st.param ~= 0 then + st.param = u8(st.param - 1) + return + end + st.param = 0x10 + incJt(st) + elseif jt == 2 then + local span = u8(self.lyEnd - self.lyStart) - 1 + if span < st.turn then return end + -- Every eighth scanline the effect steps back a state, which is the + -- pause between digs. + if bit.band(st.turn, 7) == 0 then st.jt = u8(st.jt - 1) end + self:displaceLY(st.turn) + st.turn = u8(st.turn + 2) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +-- Tackle: the attacker's rows slide eight pixels towards the target and back. +-- `turn` is the signed step and `param` the distance travelled so far. +local function tackleMoveForward(self, st) + if st.param == u8(-8) or st.param == 8 then incJt(st) end + self:fillLY(st.param) + st.param = u8(st.param + st.turn) +end + +local function tackleReturn(self, st) + if st.param == 0 then incJt(st) end + self:fillLY(st.param) + st.param = u8(st.param + u8(-st.turn)) +end + +local function tackleInit(self, st, backwards) + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + -- SCX scrolls the BACKGROUND, so a negative value moves the mon RIGHT: the + -- player's back pic steps towards the enemy on -2, not +2. + local forward = self:playerSide(st) and u8(-2) or 2 + if backwards then forward = self:playerSide(st) and 2 or u8(-2) end + st.param = 0 + st.turn = forward +end + +E.BATTLE_BG_EFFECT_TACKLE = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, false) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_VITAL_THROW = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, true) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 3 then + tackleReturn(self, st) + elseif jt == 4 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_BETA_PURSUIT = function(self, st) + local jt = st.jt + if jt == 0 then + tackleInit(self, st, true) + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +-- engine/battle_anims/bg_effects.asm:1444 +E.BATTLE_BG_EFFECT_BODY_SLAM = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms2("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.param = 0 + st.turn = self:playerSide(st) and u8(-2) or 2 + elseif jt == 1 then + tackleMoveForward(self, st) + elseif jt == 2 then + tackleReturn(self, st) + elseif jt == 3 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_WOBBLE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.param = 0 + elseif jt == 1 then + self:fillLY(sine(st.param, 8)) + st.param = u8(st.param + 4) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Always the player's rows, and the window is written directly rather than +-- through SetLCDStatCustoms1 -- this is the wobble the player's own mon does +-- when it is confused, whoever is attacking. +E.BATTLE_BG_EFFECT_WOBBLE_PLAYER = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCX" + self.lyStart, self.lyEnd = 0, 0x37 + st.param = 0 + elseif jt == 1 then + if st.param >= 0x40 then + self:resetLCDStatCustom(st) + return + end + self:fillLY(sine(st.param, 6)) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Two sines an octave apart, which is what makes Flail read as thrashing +-- rather than swaying. +E.BATTLE_BG_EFFECT_FLAIL = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn, st.param = 0, 0 + elseif jt == 1 then + local wide = sine(st.param, 6) + local narrow = sine(st.turn, 2) + self:fillLY(u8(wide + narrow)) + st.turn = u8(st.turn + 8) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +E.BATTLE_BG_EFFECT_VIBRATE_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + st.param = 0x20 + elseif jt == 1 then + if st.param == 0 then + self:resetLCDStatCustom(st) + return + end + st.param = u8(st.param - 1) + -- Flips on the even frames only, so it buzzes at 30 Hz rather than 60. + if bit.band(st.param, 1) ~= 0 then return end + st.turn = u8(-st.turn) + self:fillLY(st.turn) + end +end + +-- BounceDown: the attacker drops in on a cosine and settles, using the same +-- scanline displacement Withdraw does. +E.BATTLE_BG_EFFECT_BOUNCE_DOWN = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms2("SCY", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 1 + st.param = 0x20 + elseif jt == 1 then + if st.turn >= 0x38 then return end + local height = u8(cosine(st.param, 0x10) + 0x10) + self:displaceLY(u8(st.turn + height)) + st.param = u8(st.param + 2) + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-------------------------------------------------------------------------- +-- The screen-wide deformations +-------------------------------------------------------------------------- +-- +-- These thirteen are the ones that write a DIFFERENT value to every scanline +-- rather than the same one to a band, so they all sit on DeformScreen, +-- DeformWater or the surf ring above. The shape of each is the ASM's; what +-- the port cannot reproduce is the CGB writing rSCX mid-frame at sub-pixel +-- timing, and none of these depend on that -- they depend on the ARRAY, which +-- is modelled exactly. + +-- Surf. `.zero` lays a 2-amplitude wave into the ring and falls through to +-- `.one` on the same frame (ASM fallthrough, not a jumptable branch), and +-- `.one` waits for hLCDCPointer: engine/battle_anims/functions.asm:1158. +E.BATTLE_BG_EFFECT_SURF = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:initSurfWaves(2, 2) + jt = 1 + end + if jt == 1 then + if not self.lcdc then return end + self:rotateSurfWave() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Whirlpool: the wave covers the WHOLE screen ($00-$5e) rather than one +-- battler's rows, and it scrolls vertically (rSCY), so the water rolls +-- top to bottom behind both mons. +E.BATTLE_BG_EFFECT_WHIRLPOOL = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCY" + self.lyStart, self.lyEnd = 0, 0x5e + self:deformScreen(2, 2) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- The three water effects, which the Surf animation drives as a set: START +-- opens the window and ends itself immediately, WATER does the work, END puts +-- the registers back. +E.BATTLE_BG_EFFECT_START_WATER = function(self, st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + endEffect(st) +end + +-- WATER is the one effect whose three struct fields are all something else: +-- PARAM is the sine phase (climbing 4 a frame), BATTLE_TURN is a frame +-- counter that doubles as the amplitude, and JT_INDEX is the Y position the +-- deformation is centred on. +-- +-- `ld a, [hl]` then `inc [hl]` twice leaves `a` holding the PRE-increment +-- turn, and that is the count DeformWater is called with -- so the figure +-- grows two scanlines a frame from nothing until the counter passes $20. +E.BATTLE_BG_EFFECT_WATER = function(self, st) + local offset = st.param + st.param = u8(st.param + 4) + -- (0xff XOR the high nibble) + 4: the amplitude SHRINKS as the counter + -- climbs, so the wave is widest when it first appears. + local amplitude = u8(bit.bxor(bit.rshift(bit.band(st.turn, 0xf0), 4), 0xff) + 4) + local progress = st.jt + local count = st.turn + if count >= 0x20 then + self:clearLYOverrides(0) + endEffect(st) + return + end + st.turn = u8(st.turn + 2) + self:deformWater(count, amplitude, offset, progress) +end + +E.BATTLE_BG_EFFECT_END_WATER = function(self, st) + self:resetLCDStatCustom(st) +end + +-- Psychic is hardcoded to the whole screen ($00-$5f) whichever side used it, +-- and only travels every FOURTH frame (`and $3 / ret nz`), which is what makes +-- it a slow ripple rather than Teleport's shimmer. +E.BATTLE_BG_EFFECT_PSYCHIC = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self.lcdc = "SCX" + self.lyStart, self.lyEnd = 0, 0x5f + self:deformScreen(6, 5) + st.param = 0 + elseif jt == 1 then + local counter = st.param + st.param = u8(st.param + 1) + if bit.band(counter, 3) ~= 0 then return end + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Teleport: the same wave as Psychic but only over the user's own rows, and +-- travelling every frame. +E.BATTLE_BG_EFFECT_TELEPORT = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self:deformScreen(6, 5) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Night Shade takes its phase step from the struct's PARAM, so the same +-- effect id gives a long slow roll or a tight ripple depending on what the +-- script queued it with. +E.BATTLE_BG_EFFECT_NIGHT_SHADE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self:deformScreen(2, st.param) + elseif jt == 1 then + self:wavyScreenFX() + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Double Team's afterimage: alternate scanlines are pushed +n and -n, so the +-- pic reads as two copies of itself a few pixels apart. `.UpdateLYOverrides` +-- writes the pair (e, -e) down the window and, on an odd-height window, +-- repeats `e` on the last row -- `srl a` leaves the odd bit in carry and +-- `ret nc` is what skips that store on an even one. +local function doubleTeamOverrides(self, value) + local e = u8(value) + local d = u8(-e) + local span = u8(self.lyEnd - self.lyStart) + local pairs_ = bit.rshift(span, 1) + local odd = bit.band(span, 1) ~= 0 + local row = self.lyStart + for _ = 1, pairs_ do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = e + row = row + 1 + if row > SCREEN_ROWS then return end + self.lyBackup[row] = d + row = row + 1 + end + if odd and row <= SCREEN_ROWS then self.lyBackup[row] = e end +end + +E.BATTLE_BG_EFFECT_DOUBLE_TEAM = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + self.lyEnd = u8(self.lyEnd + 1) + st.turn = 0 + elseif jt == 1 then + -- Split apart, a pixel a frame, to $10. + if st.param >= 0x10 then + incJt(st) + return + end + local value = st.param + st.param = u8(st.param + 1) + doubleTeamOverrides(self, value) + elseif jt == 2 then + -- Hold, wobbling about the current separation. This state never advances + -- itself; the script's own `incbgeffect` is what moves it on. + local wobble = u8(sine(st.turn, 2) + st.param) + doubleTeamOverrides(self, wobble) + st.turn = u8(st.turn + 4) + elseif jt == 3 then + -- Come back together. The test is `cp $ff`, so a PARAM that started at 0 + -- underflows to $ff and stops there rather than at zero. + if st.param == 0xff then + incJt(st) + return + end + local value = st.param + st.param = u8(st.param - 1) + doubleTeamOverrides(self, value) + elseif jt == 5 then + self:resetLCDStatCustom(st) + end + -- jt 4 is a bare `ret`: the gap the script sits in between the two halves. +end + +-- Acid Armor: the wave is laid down once and then the whole window is scrolled +-- DOWN one scanline a frame, with a blank row ($90) fed in at the top -- so +-- the mon melts into the floor instead of wobbling in place. The two +-- fix-ups at the bottom clear the last two rows once their values are large +-- enough to be showing the pic's own bottom edge. +E.BATTLE_BG_EFFECT_ACID_ARMOR = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCY", st) + self:deformScreen(2, st.param) + self.lyBackup[self.lyEnd] = 0 + self.lyBackup[self.lyEnd - 1] = 0 + elseif jt == 1 then + for row = self.lyEnd, self.lyStart + 1, -1 do + self.lyBackup[row] = self.lyBackup[row - 1] or 0 + end + self.lyBackup[self.lyStart] = 0x90 + local last = self.lyBackup[self.lyEnd] or 0 + if last >= 1 and last ~= 0x90 then self.lyBackup[self.lyEnd] = 0 end + local penultimate = self.lyBackup[self.lyEnd - 1] or 0 + if penultimate >= 2 and penultimate ~= 0x90 then + self.lyBackup[self.lyEnd - 1] = 0 + end + elseif jt == 2 then + self:resetLCDStatCustom(st) + end +end + +-- Wave Deform: the amplitude ramps up to $20 in state 1 and back down to 0 in +-- state 2, at a fixed phase step of 4. Neither ramp advances the state on its +-- own -- the script does -- so how far it gets is the script's business. +E.BATTLE_BG_EFFECT_WAVE_DEFORM_MON = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + elseif jt == 1 then + if st.param >= 0x20 then return end + local amplitude = st.param + st.param = u8(st.param + 1) + self:deformScreen(amplitude, 4) + elseif jt == 2 then + if st.param == 0 then + self:resetLCDStatCustom(st) + return + end + local amplitude = st.param + st.param = u8(st.param - 1) + self:deformScreen(amplitude, 4) + end +end + +-- The two beta send-outs are `; unused` on the cart -- nothing queues them -- +-- but they are in the jumptable, so a mod or a hand-written script can, and +-- an unimplemented entry would sit in the pool forever. +-- +-- MON1 writes rBGP per scanline rather than a scroll register: every other +-- row of the window steps through $00 (all white), $40, $90 and $e4 (normal), +-- eight frames apart, so the pic fades in through a venetian blind. +local BETA_SEND_OUT_PALS = { 0x00, 0x40, 0x90, 0xe4 } + +-- `.SetLYOverridesBackup`: every SECOND scanline, (lyEnd - lyStart) / 2 times. +local function betaBlind(self, value) + local count = bit.rshift(u8(self.lyEnd - self.lyStart), 1) + local row = self.lyStart + for _ = 1, count do + if row > SCREEN_ROWS then return end + self.lyBackup[row] = u8(value) + row = row + 2 + end +end + +-- `.GetLYOverride`: PARAM counts up and its top bits index the palette list, +-- so each entry is held eight frames. Past the end it returns nil, which is +-- the `cp $ff` the caller branches on. +local function betaPal(st) + local index = bit.rshift(st.param, 3) + st.param = u8(st.param + 1) + return BETA_SEND_OUT_PALS[index + 1] +end + +E.BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1 = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0xe4) + self:setLCDStatCustoms1("BGP", st) + self.lyEnd = u8(self.lyEnd + 1) + for row = self.lyStart, self.lyEnd - 1 do + if row > SCREEN_ROWS then break end + self.lyBackup[row] = 0 + end + st.param = 0 + -- `.zero` falls into `.one`, which is a bare ret. + elseif jt == 2 then + local value = betaPal(st) + if value then + betaBlind(self, value) + return + end + st.param = 0 + self.lyStart = u8(self.lyStart + 1) + incJt(st) + elseif jt == 3 then + local value = betaPal(st) + if not value then + incJt(st) + return + end + betaBlind(self, value) + -- The second pass also fills the row the blind skipped at the bottom. + self.lyBackup[self.lyEnd - 1] = u8(value) + elseif jt == 5 then + self:resetVideoHRAM() + endEffect(st) + end + -- jt 1 and 4 are bare rets. +end + +-- MON2 is a plain DeformScreen whose amplitude and phase step are the SAME +-- value, counted down from $40 in eighths -- so the wobble starts at 8 and +-- unwinds to nothing. +E.BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2 = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + self:clearLYOverrides(0) + self:setLCDStatCustoms1("SCX", st) + st.turn = 0x40 + elseif jt == 1 then + if st.turn == 0 then + self:resetLCDStatCustom(st) + return + end + local value = st.turn + st.turn = u8(st.turn - 1) + -- `ld a, [hl] / dec [hl] / srl a x3`: the PRE-decrement value, shifted. + local amount = bit.band(bit.rshift(value, 3), 0x0f) + self:deformScreen(amount, amount) + end +end + +-- Nothing is left unmodelled. The name stays so a caller (and the tests) can +-- still ask, and so the answer is checkable rather than a claim in a comment. +local UNMODELLED = {} +local UNMODELLED_SET = {} +for _, name in ipairs(UNMODELLED) do UNMODELLED_SET[name] = true end + +local DROPPED = {} +BgEffects.DROPPED = DROPPED +BgEffects.strict = os.getenv("POKEPORT_DEV") == "1" + +local function dropUnknown(st) + local name = st.func + endEffect(st) + if UNMODELLED_SET[name] then return end + if not DROPPED[name] then + DROPPED[name] = true + DROPPED[#DROPPED + 1] = name + end + if BgEffects.strict then + error("BgEffects: no entry for " .. tostring(name)) + end +end + +-------------------------------------------------------------------------- + +-- ExecuteBGEffects: one pass over the five structs. +function Pool:playFrame() + for slot = 1, NUM_EFFECTS do + local st = self.effects[slot] + if st.func then + local fn = E[st.func] + if fn then + fn(self, st) + else + -- An id with no entry would otherwise sit in the pool forever and + -- keep the animation from ending. + dropUnknown(st) + end + end + end +end + +function Pool:takeSpawns() + local spawns = self.spawns + self.spawns = {} + return spawns +end + +BgEffects.EFFECTS = E +BgEffects.NUM_EFFECTS = NUM_EFFECTS +BgEffects.NORMAL_PAL = NORMAL_PAL +BgEffects.SCREEN_ROWS = SCREEN_ROWS +BgEffects.UNMODELLED = UNMODELLED + +return BgEffects diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bbd959cfd..975e9022b 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -5705,7 +5705,11 @@ function RomImporter:draw() if link.url==DONATE_URL then paypalLink=link else footerLinks[#footerLinks+1]=link end end - local creditH = self.hintFont:getHeight() + local creditText = "Created by " .. credit.by + .. "\nBased on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC" + .. "\n(https://github.com/bryanthaboi/gen1recomp)" + local _, creditLines = self.hintFont:getWrap(creditText, appW) + local creditH = math.max(1, #creditLines) * self.hintFont:getHeight() local donateH = self.hintFont:getHeight() + 10 * s local chipsH = (#footerLinks > 0) and (8 * s + donateH) or 0 local footerH = 10 * s + bcgDH + 6 * s + warningH + 8 * s + creditH + chipsH + 12 * s @@ -6240,7 +6244,7 @@ function RomImporter:draw() love.graphics.setFont(self.hintFont) local creditY = warningY + warningH + 8 * s col(PAL.warning) - love.graphics.printf("Created by " .. credit.by, appX, creditY, appW, "center") + love.graphics.printf(creditText, appX, creditY, appW, "center") self.donateButtons = {} self.donateButton = nil if #footerLinks > 0 then diff --git a/tests/engine/gen2_native_bg_pool.lua b/tests/engine/gen2_native_bg_pool.lua new file mode 100644 index 000000000..ada94a3af --- /dev/null +++ b/tests/engine/gen2_native_bg_pool.lua @@ -0,0 +1,72 @@ +-- GSC register/picture traces against the native BgEffects API. No graphics, +-- game state, ROM, synthetic animation runner or replacement clock is used. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Bg=require('src.battle.gen2.BgEffects') +local Ids=require('src.battle.Gen2BgEffectIds') +local Math=require('src.battle.Gen2AnimMath') +T.eq(Math.sine(16,32),32,'Q8.8 quarter-wave reaches its exact positive amplitude') +T.eq(Math.sine(48,32),224,'negative amplitude wraps as a hardware byte') +T.eq(Math.cosine(0,32),32,'cosine follows the cartridge quarter-wave shift') +T.eq(Math.sra(0x80),0xc0,'arithmetic right shift preserves a negative byte sign') +T.eq(Math.sra(0xff),0xff,'minus one remains minus one after arithmetic shift') +T.eq(Math.sra(0x7f),0x3f,'positive arithmetic shift does not invent sign bits') +T.eq(Math.swap(0xab),0xba,'nibble swap preserves byte ordering') +for value=0,255 do T.eq(Math.swap(Math.swap(value)),value,'two nibble swaps recover the hardware byte')end + +for _,edition in ipairs({'gold','silver','crystal'})do + local order=Ids.forVersion(edition) + T.eq(#order,edition=='crystal'and 54 or 53,'edition owns its cartridge BG jump table') + T.eq(order[38],edition=='crystal'and'BATTLE_BG_EFFECT_BODY_SLAM'or'BATTLE_BG_EFFECT_WOBBLE_MON', + 'Crystal insertion does not misinterpret the Gold/Silver effect id') + T.eq(order[#order],'BATTLE_BG_EFFECT_WOBBLE_SCREEN','edition table terminates at the proper final effect') + local altered=Ids.forVersion(edition);altered[1]='changed' + T.eq(Ids.forVersion(edition)[1],'BATTLE_BG_EFFECT_END','one consumer cannot mutate another edition registry') + for index,name in ipairs(order)do + T.eq(type(Bg.EFFECTS[name]),'function','every cartridge BG id has an actual native state machine') + local pool=Bg.new({battleBgEffectOrder=order},{battleTurn=0}) + local entry=pool:queue(index-1,0,0,0) + T.eq(entry and entry.func,name,'raw cartridge id selects its actual edition state machine') + local before=#Bg.DROPPED + T.check(pcall(function()for _=1,8 do pool:playFrame()end end), + 'known native effect executes its initial state transitions') + T.eq(#Bg.DROPPED,before,'known cartridge effects never take the unknown-id fallback') + end +end + +-- BattleBGEffect_EnterMon's three four-frame squares. Player uses 2x2, +-- 4x4,6x6; enemy uses 3x3,5x5,7x7 (pic-size selectors2/1/0 and5/4/3). +for _,turn in ipairs({0,1})do + for _,side in ipairs({'player','enemy'})do + local sideFlag=side=='player'and(1-turn)or turn + local pool=Bg.new({}, {battleTurn=turn}) + pool:queue('BATTLE_BG_EFFECT_ENTER_MON',0,sideFlag,0) + for frame=1,13 do + pool:playFrame() + local stage=math.floor((frame-1)/4) + local expected=stage<3 and((side=='player'and 2 or 5)-stage)or nil + T.eq(pool.picSize[side],expected,'native entry follows its own picture-size trace') + T.eq(pool.hidden[side],false,'entry reveals the side selected by battle-turn XOR') + end + T.eq(pool:activeCount(),0,'entry terminates after its native final picture state') + end +end + +-- BODY_SLAM is Crystal's inserted $25 entry. The trace retains the exact +-- LCD window and byte displacement cited by Gen1Recomp bug2102. +local slam=Bg.new({battleBgEffectOrder=Ids.forVersion('crystal')},{battleTurn=0}) +slam:queue(0x25,0,1,0);slam:playFrame() +T.eq(slam.lcdc,'SCX','BODY_SLAM displaces the selected band horizontally') +T.eq(slam.lyStart,0x2d,'player band begins at the cartridge LCD boundary') +T.eq(slam.lyEnd,0x5f,'player band ends one row beyond the picture') +slam:playFrame();slam:playFrame() +T.eq(slam.lyBackup[0x30],0xfe,'native lunge writes the signed two-pixel displacement') + +local pool=Bg.new({}, {battleTurn=0}) +for _=1,5 do T.check(pool:queue('BATTLE_BG_EFFECT_FLASH_WHITE',0,0,0)~=nil,'native pool admits five hardware effect structs')end +T.eq(pool:queue('BATTLE_BG_EFFECT_FLASH_WHITE',0,0,0),nil,'full native pool refuses a sixth struct') +pool:reset();T.eq(pool:activeCount(),0,'native reset retires all effect owners') +T.eq(pool.bgp,0xe4,'native reset restores identity BG palette register') +T.eq(pool.scx,0,'native reset restores horizontal register') +T.eq(pool.scy,0,'native reset restores vertical register') +T.finish('native GSC BG registry, byte arithmetic and register/picture state machines') From 006eb84e8728a34f4dd3ef3e434cd2aeace4d80a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:35:56 +0100 Subject: [PATCH 24/64] Verify native footer retains original project credit and wrapped control spacing --- tests/engine/gen1_source_credit_footer.lua | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/engine/gen1_source_credit_footer.lua diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua new file mode 100644 index 000000000..5ea342577 --- /dev/null +++ b/tests/engine/gen1_source_credit_footer.lua @@ -0,0 +1,39 @@ +-- Native launcher draw and scroll owners; only the graphics device is stubbed. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local run=T.sdk.loadNone({data=T.fixtures.fresh(),fs=T.sdk.memfs({})}) +local Importer=require('src.import.RomImporter') +local g=love.graphics +local dimensions,printf,setFont=g.getDimensions,g.printf,g.setFont +local width,height=960,720 +g.getDimensions=function()return width,height end +local printed,currentFont={},nil +g.setFont=function(font)currentFont=font;return setFont(font)end +g.printf=function(text,x,y,w,...) + if type(text)=='string'and text:find('Based on the Pokemon Gen 1',1,true)then + printed[#printed+1]={text=text,x=x,y=y,width=w,font=currentFont} + end + return printf(text,x,y,w,...) +end +local ui=Importer.new(function()end) +for _,size in ipairs({{960,720},{480,360},{1280,960}})do + width,height=size[1],size[2] + for _,tab in ipairs({'red','gold','crystal','emerald'})do + ui.tab=tab;printed={} + T.check(pcall(ui.draw,ui),'actual native launcher still draws with wrapped attribution') + local line=printed[#printed] + T.check(line~=nil,'native launcher renders the original project credit') + if line then + T.check(line.text:find('by BOIS CLUB GAMES, LLC',1,true)~=nil,'credit retains original rights holder') + T.check(line.text:find('https://github.com/bryanthaboi/gen1recomp',1,true)~=nil,'credit retains source project link') + local _,lines=line.font:getWrap(line.text,line.width) + local bottom=line.y+math.max(1,#lines)*line.font:getHeight() + for _,button in ipairs(ui.donateButtons or {})do + T.check(button.y>=bottom,'native footer reserves the full wrapped credit before donation controls') + end + end + end +end +g.getDimensions,g.printf,g.setFont=dimensions,printf,setFont +run.release() +T.finish('Gen1 source attribution through native launcher footer') From 5611ee645361eca2aa560d8bfe8c373b892e709e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:38:03 +0100 Subject: [PATCH 25/64] Provide the external platform boundary for native footer draw and check real SDK entry --- tests/engine/gen1_source_credit_footer.lua | 2 ++ tests/engine/gen2_bg_sdk.lua | 35 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/engine/gen2_bg_sdk.lua diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 5ea342577..85ded0eda 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -2,6 +2,8 @@ package.path='./?.lua;./?/init.lua;'..package.path local T=require('tests.modkit') local run=T.sdk.loadNone({data=T.fixtures.fresh(),fs=T.sdk.memfs({})}) +love.system=love.system or {} +love.system.getOS=function()return 'Unknown'end local Importer=require('src.import.RomImporter') local g=love.graphics local dimensions,printf,setFont=g.getDimensions,g.printf,g.setFont diff --git a/tests/engine/gen2_bg_sdk.lua b/tests/engine/gen2_bg_sdk.lua new file mode 100644 index 000000000..845f97eee --- /dev/null +++ b/tests/engine/gen2_bg_sdk.lua @@ -0,0 +1,35 @@ +-- Native BG API first import from a real SDK entry, with no ROM or renderer. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local before=Version.get() +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + local run=T.sdk.loadMods({'mods/bg_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/bg_probe/manifest.json']=[[{"id":"bg_probe","name":"BG probe","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/bg_probe/main.lua']=[[ + local mod=... + local Bg=require('src.battle.gen2.BgEffects') + local pool=Bg.new({}, {battleTurn=0}) + mod.exports.pool=pool + mod.exports.enter=function() + pool:queue('BATTLE_BG_EFFECT_ENTER_MON',0,1,0) + pool:playFrame() + return pool.picSize.player + end + ]], + })}) + T.eq(#run.errors,0,'actual SDK entry can import the real engine BG module') + local api=run.loader.exports.bg_probe + T.check(api and api.pool~=nil,'native BG owner reaches the unmodified SDK export path') + if api then + T.eq(api.enter(),2,'SDK consumer runs the actual native entry state machine') + local row=api.pool:queue(0x25,0,1,0) + T.eq(row.func,edition=='crystal'and'BATTLE_BG_EFFECT_BODY_SLAM'or'BATTLE_BG_EFFECT_WOBBLE_MON', + 'actual native host edition resolves raw BG ids inside a mod entry') + end + require('src.mods.Loader').teardown();run.release() +end +Version.set(before) +T.finish('GSC background state machines through actual SDK module lookup') From 841951ab26d43e2cc6043989cecfb03c1a0b4c3c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:39:59 +0100 Subject: [PATCH 26/64] Retain the actual native draw error in footer test diagnostics --- tests/engine/gen1_source_credit_footer.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 85ded0eda..ec439adfb 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -22,7 +22,8 @@ for _,size in ipairs({{960,720},{480,360},{1280,960}})do width,height=size[1],size[2] for _,tab in ipairs({'red','gold','crystal','emerald'})do ui.tab=tab;printed={} - T.check(pcall(ui.draw,ui),'actual native launcher still draws with wrapped attribution') + local ok,why=pcall(ui.draw,ui) + T.check(ok,'actual native launcher still draws with wrapped attribution: '..tostring(why)) local line=printed[#printed] T.check(line~=nil,'native launcher renders the original project credit') if line then From 8e825446d0817504b3c1e59f3168213ee22f313c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:43:28 +0100 Subject: [PATCH 27/64] Exercise native attribution layout with measurable headless graphics devices --- tests/engine/gen1_source_credit_footer.lua | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index ec439adfb..96f0195bf 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -7,8 +7,41 @@ love.system.getOS=function()return 'Unknown'end local Importer=require('src.import.RomImporter') local g=love.graphics local dimensions,printf,setFont=g.getDimensions,g.printf,g.setFont +local widthFn,heightFn,safeArea=g.getWidth,g.getHeight,love.window.getSafeArea +local mesh,shader,image,newFont=g.newMesh,g.newShader,g.newImage,g.newFont +local oldImage=love.image +local function noop()end +g.newMesh=function()return {release=noop}end +g.newShader=function()return {send=noop,release=noop}end +love.image={newImageData=function(w,h)return {w=w,h=h,setPixel=noop}end} +g.newImage=function(source) + local obj=type(source)=='table'and {w=source.w,h=source.h}or image(source) + obj.setWrap,obj.setFilter=noop,noop + obj.getDimensions=function(self)return self.w,self.h end + return obj +end +g.newFont=function(a,b) + local font=newFont(type(a)=='number'and a or b) + function font:getWrap(text,limit) + local lines={} + for line in (tostring(text)..'\n'):gmatch('(.-)\n')do + local current='' + for word in line:gmatch('%S+')do + local nextLine=current==''and word or current..' '..word + if current~=''and self:getWidth(nextLine)>limit then lines[#lines+1]=current;current=word + else current=nextLine end + end + lines[#lines+1]=current + end + return limit,lines + end + return font +end local width,height=960,720 g.getDimensions=function()return width,height end +g.getWidth=function()return width end +g.getHeight=function()return height end +love.window.getSafeArea=function()return 0,0,width,height end local printed,currentFont={},nil g.setFont=function(font)currentFont=font;return setFont(font)end g.printf=function(text,x,y,w,...) @@ -38,5 +71,8 @@ for _,size in ipairs({{960,720},{480,360},{1280,960}})do end end g.getDimensions,g.printf,g.setFont=dimensions,printf,setFont +g.getWidth,g.getHeight,love.window.getSafeArea=widthFn,heightFn,safeArea +g.newMesh,g.newShader,g.newImage,g.newFont=mesh,shader,image,newFont +love.image=oldImage run.release() T.finish('Gen1 source attribution through native launcher footer') From 6b263705d9ab342a9f708cee40ace78b5ef16cf7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:44:11 +0100 Subject: [PATCH 28/64] Supply the native launcher stencil device boundary in layout test --- tests/engine/gen1_source_credit_footer.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 96f0195bf..96084ff55 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -11,6 +11,9 @@ local widthFn,heightFn,safeArea=g.getWidth,g.getHeight,love.window.getSafeArea local mesh,shader,image,newFont=g.newMesh,g.newShader,g.newImage,g.newFont local oldImage=love.image local function noop()end +local stencil,stencilTest=g.stencil,g.setStencilTest +g.stencil=function(fn)fn()end +g.setStencilTest=noop g.newMesh=function()return {release=noop}end g.newShader=function()return {send=noop,release=noop}end love.image={newImageData=function(w,h)return {w=w,h=h,setPixel=noop}end} @@ -74,5 +77,6 @@ g.getDimensions,g.printf,g.setFont=dimensions,printf,setFont g.getWidth,g.getHeight,love.window.getSafeArea=widthFn,heightFn,safeArea g.newMesh,g.newShader,g.newImage,g.newFont=mesh,shader,image,newFont love.image=oldImage +g.stencil,g.setStencilTest=stencil,stencilTest run.release() T.finish('Gen1 source attribution through native launcher footer') From 08579a58e21adc3be3f504714a2aa8f54eee04f6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:44:55 +0100 Subject: [PATCH 29/64] Complete mesh update support in headless launcher graphics fixture --- tests/engine/gen1_source_credit_footer.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 96084ff55..4d1cddbed 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -14,7 +14,7 @@ local function noop()end local stencil,stencilTest=g.stencil,g.setStencilTest g.stencil=function(fn)fn()end g.setStencilTest=noop -g.newMesh=function()return {release=noop}end +g.newMesh=function()return {release=noop,setVertices=noop}end g.newShader=function()return {send=noop,release=noop}end love.image={newImageData=function(w,h)return {w=w,h=h,setPixel=noop}end} g.newImage=function(source) From 80fde07f4b0c30fa64f98ca23c36c4de28589a51 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:45:52 +0100 Subject: [PATCH 30/64] Support remaining sidebar shape primitives in the headless draw fixture --- tests/engine/gen1_source_credit_footer.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 4d1cddbed..24bf4998e 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -11,6 +11,8 @@ local widthFn,heightFn,safeArea=g.getWidth,g.getHeight,love.window.getSafeArea local mesh,shader,image,newFont=g.newMesh,g.newShader,g.newImage,g.newFont local oldImage=love.image local function noop()end +local ellipse,arc,polygon=g.ellipse,g.arc,g.polygon +g.ellipse,g.arc,g.polygon=noop,noop,noop local stencil,stencilTest=g.stencil,g.setStencilTest g.stencil=function(fn)fn()end g.setStencilTest=noop @@ -78,5 +80,6 @@ g.getWidth,g.getHeight,love.window.getSafeArea=widthFn,heightFn,safeArea g.newMesh,g.newShader,g.newImage,g.newFont=mesh,shader,image,newFont love.image=oldImage g.stencil,g.setStencilTest=stencil,stencilTest +g.ellipse,g.arc,g.polygon=ellipse,arc,polygon run.release() T.finish('Gen1 source attribution through native launcher footer') From c0ee510adab9d06bec5775c207342c65ded64bb1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:46:50 +0100 Subject: [PATCH 31/64] Check footer controls independently from the existing pinned header donation chip --- tests/engine/gen1_source_credit_footer.lua | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/engine/gen1_source_credit_footer.lua b/tests/engine/gen1_source_credit_footer.lua index 24bf4998e..feede9939 100644 --- a/tests/engine/gen1_source_credit_footer.lua +++ b/tests/engine/gen1_source_credit_footer.lua @@ -10,6 +10,8 @@ local dimensions,printf,setFont=g.getDimensions,g.printf,g.setFont local widthFn,heightFn,safeArea=g.getWidth,g.getHeight,love.window.getSafeArea local mesh,shader,image,newFont=g.newMesh,g.newShader,g.newImage,g.newFont local oldImage=love.image +local mouseDown=love.mouse.isDown +love.mouse.isDown=function()return false end local function noop()end local ellipse,arc,polygon=g.ellipse,g.arc,g.polygon g.ellipse,g.arc,g.polygon=noop,noop,noop @@ -21,7 +23,7 @@ g.newShader=function()return {send=noop,release=noop}end love.image={newImageData=function(w,h)return {w=w,h=h,setPixel=noop}end} g.newImage=function(source) local obj=type(source)=='table'and {w=source.w,h=source.h}or image(source) - obj.setWrap,obj.setFilter=noop,noop + obj.setWrap,obj.setFilter,obj.setMipmapFilter=noop,noop,noop obj.getDimensions=function(self)return self.w,self.h end return obj end @@ -70,7 +72,9 @@ for _,size in ipairs({{960,720},{480,360},{1280,960}})do local _,lines=line.font:getWrap(line.text,line.width) local bottom=line.y+math.max(1,#lines)*line.font:getHeight() for _,button in ipairs(ui.donateButtons or {})do - T.check(button.y>=bottom,'native footer reserves the full wrapped credit before donation controls') + if not button.pinned then + T.check(button.y>=bottom,'native footer reserves the full wrapped credit before its donation controls') + end end end end @@ -79,6 +83,7 @@ g.getDimensions,g.printf,g.setFont=dimensions,printf,setFont g.getWidth,g.getHeight,love.window.getSafeArea=widthFn,heightFn,safeArea g.newMesh,g.newShader,g.newImage,g.newFont=mesh,shader,image,newFont love.image=oldImage +love.mouse.isDown=mouseDown g.stencil,g.setStencilTest=stencil,stencilTest g.ellipse,g.arc,g.polygon=ellipse,arc,polygon run.release() From e7188e00f14b43e17a12ccd8be49999bbacc9278 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:49:48 +0100 Subject: [PATCH 32/64] Connect cartridge BG commands to the existing native animation update clock --- src/battle/Gen2AnimPlayer.lua | 35 +++++++++++- tests/engine/gen2_native_bg_clock.lua | 78 +++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 tests/engine/gen2_native_bg_clock.lua diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index 2a5075488..2fdb0f1df 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -35,6 +35,9 @@ -- y' = fixY == $FF and 40 + y or fixY - y -- xOffset' = -xOffset +local BgEffects = require('src.battle.gen2.BgEffects') +local BgIds = require('src.battle.Gen2BgEffectIds') + local Gen2AnimPlayer = {} Gen2AnimPlayer.__index = Gen2AnimPlayer @@ -141,6 +144,7 @@ function Gen2AnimPlayer.new(data) warned = {}, objects = {}, effects = {}, + bg = BgEffects.new(data and data.constants, {battleTurn=0}), -- WideBattle reads these two off the Gen 1 player; keep them defined steps = {}, stepIndex = 1, @@ -259,6 +263,11 @@ function Gen2AnimPlayer:start(moveId, attackerIsPlayer, opts) attackerIsPlayer = not attackerIsPlayer end self.enemySide = not attackerIsPlayer + -- Existing VM updates own this pool's time; a restart retires the old block. + self.bg = BgEffects.new(self.data and self.data.constants, { + battleTurn = self.enemySide and 1 or 0, + flying = opts and opts.flying or {}, + }) self.opts = opts self.moveId = moveId self.loops = {} @@ -388,6 +397,19 @@ local BG_EFFECTS_END = { } Gen2AnimPlayer.BG_EFFECTS_END = BG_EFFECTS_END +-- The legacy visual adapter is keyed by the Gold/Silver table. Cartridge +-- commands must resolve by NAME first: Crystal inserts BODY_SLAM at $25. +-- Keep this adapter until the native register/scanline draw owner is wired; +-- the actual pool below is authoritative for public BG state. +local legacyBgIds = {} +for index,name in ipairs(BgIds.forVersion('gold')) do legacyBgIds[name]=index-1 end +local function legacyBgId(self,id) + local name=type(id)=='number'and self.bg.order[id+1]or id + if name=='BATTLE_BG_EFFECT_BODY_SLAM'then return 0x24 end + return legacyBgIds[name] +end + + -- BGEffect_CheckBattleTurn: BG_EFFECT_STRUCT_BATTLE_TURN is 1 for the move's -- user and 0 for its target. Anything else (the flash routines reuse the -- field as a repeat count) only ever drives screen-wide effects, which @@ -681,17 +703,23 @@ function Gen2AnimPlayer:step(inst) -- side the queue entry was made with self.bgSides = self.bgSides or {} self.bgSides[id] = side - local se = BG_EFFECTS[id] + self.bg:queue(id, inst.args[2], inst.args[3], inst.args[4]) + local se = BG_EFFECTS[legacyBgId(self,id)] if se then self.effects[#self.effects + 1] = { effect = se, side = side, gen2 = true } end elseif op == "incbgeffect" then local id = inst.args[1] - local se = BG_EFFECTS_END[id] + self.bg:incEffect(id) + local se = BG_EFFECTS_END[legacyBgId(self,id)] if se then local side = self.bgSides and self.bgSides[id] or "target" self.effects[#self.effects + 1] = { effect = se, side = side, gen2 = true } end + elseif op == 'bgp' or op == 'obp0' or op == 'obp1' then + self.bg[op] = (inst.args[1] or 0) % 256 + elseif op == 'resetobp0' then + self.bg.obp0 = self.opts and self.opts.sgb and 0xf0 or 0xe0 elseif op == "checkpokeball" then -- BattleAnimCmd_CheckPokeball calls GetPokeBallWobble and stores its `c` -- into wBattleAnimVar, which the toss script then compares against 1 and @@ -826,6 +854,7 @@ function Gen2AnimPlayer:update() end if self.wait > 0 then self.wait = self.wait - 1 + self.bg:playFrame() self:tickObjects() return end @@ -836,6 +865,7 @@ function Gen2AnimPlayer:update() if self.ranFrames > 180 then self.done = true self.tail = TAIL_FRAMES + self.bg:playFrame() self:tickObjects() return end @@ -850,6 +880,7 @@ function Gen2AnimPlayer:update() if self.done or self.wait > 0 then break end end if self.wait > 0 then self.wait = self.wait - 1 end + self.bg:playFrame() self:tickObjects() end diff --git a/tests/engine/gen2_native_bg_clock.lua b/tests/engine/gen2_native_bg_clock.lua new file mode 100644 index 000000000..c8cb51d07 --- /dev/null +++ b/tests/engine/gen2_native_bg_clock.lua @@ -0,0 +1,78 @@ +-- Exercise real native VM commands and its actual BG register owner. +-- Official main has no Gen2AnimView observation; no substitute runner is installed. +-- Expected picture and LCD traces come from the pinned GSC BG implementation. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Player=require('src.battle.Gen2AnimPlayer') +local Version=require('src.core.GameVersion') +local previous=Version.get() +local function data(code,constants) + return {constants=constants,battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code=code}},objects={},framesets={},oam={},gfx={}}} +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,playerTurn in ipairs({true,false})do + local p=Player.new(data({ + {op='bgeffect',args={0x0b,0,1,0}}, + {op='delay',frames=13},{op='ret'}, + })) + T.check(p:start('PROBE',playerTurn),'native VM starts the real test program') + local r=p + T.eq(r.bg,p.bg,'observation exposes the actual register owner') + local ticks=0;local playFrame=p.bg.playFrame + p.bg.playFrame=function(self,...)ticks=ticks+1;return playFrame(self,...)end + local side=playerTurn and 'player'or'enemy' + for frame=1,13 do + p:update() + local stage=math.floor((frame-1)/4) + local expected=stage<3 and((playerTurn and 2 or 5)-stage)or nil + T.eq(r.bg.picSize[side],expected,'native command/wait path follows four-frame picture stages') + T.eq(ticks,frame,'one existing VM update executes exactly one BG pass') + for _=1,20 do local bg,frames=r.bg,r.frames end + T.eq(ticks,frame,'repeated consumer observations never run the effect') + end + p:update();T.eq(ticks,14,'the final command frame executes one BG pass') + for _=1,20 do p:update()end + T.eq(ticks,14,'legacy object-tail drain cannot keep advancing a stopped BG program') + local old=r.bg + p:start('PROBE',not playerTurn) + T.eq(old.picSize[side],nil,'previous register block keeps its completed picture trace') + T.check(p.bg~=old,'restart owns a fresh block rather than mutating retired state') + T.eq(p.bg.env.battleTurn,playerTurn and 1 or 0,'restart binds the current attacker side') + T.eq(p.bg.picSize.player,nil,'restart clears player picture stages') + T.eq(p.bg.picSize.enemy,nil,'restart clears enemy picture stages') + p:release() + end + local p=Player.new(data({{op='bgeffect',args={0x25,0,1,0}}, + {op='delay',frames=4},{op='ret'}})) + p:start('PROBE',true);p:update() + local entry=p.bg.effects[1] + T.eq(entry.func,edition=='crystal'and'BATTLE_BG_EFFECT_BODY_SLAM'or'BATTLE_BG_EFFECT_WOBBLE_MON', + 'raw command resolves through the actual cartridge table') + local effect=p:pollEffects()[1] + T.eq(effect and effect.effect,edition=='crystal'and'SE_MOVE_MON_HORIZONTALLY'or'SE_SHAKE_BACK_AND_FORTH', + 'existing visual adapter also resolves Crystal insertion by effect name') + p:update();p:update() + if edition=='crystal'then T.eq(p.bg.lyBackup[0x30],0xfe,'real VM drives the signed BODY_SLAM LCD trace')end + p:release() + p=Player.new(data({{op='bgeffect',args={0x0f,0,0,0}}, + {op='delay',frames=1},{op='incbgeffect',args={0x0f}}, + {op='bgp',args={0x1ff}},{op='obp0',args={0x18c}}, + {op='obp1',args={0x124}},{op='delay',frames=1},{op='ret'}})) + p:start('PROBE',true);p:update() + local active=p.bg.effects[1] + T.eq(active.jt,1,'native Teleport initializes its running state') + p:update() + T.eq(active.func,nil,'increment advances Teleport into its native terminal state') + T.eq(p.bg.lcdc,nil,'native terminal state retires its LCD window') + T.eq(p.bg.obp0,0x8c,'native OBJ0 palette command preserves byte semantics') + T.eq(p.bg.obp1,0x24,'native OBJ1 palette command preserves byte semantics') + T.eq(p.bg.bgp,0xff,'BG palette write survives a non-palette terminal effect') + p:release() + p=Player.new(data({{op='bgp',args={0x1fc}},{op='delay',frames=1},{op='ret'}})) + p:start('PROBE',true);p:update();T.eq(p.bg.bgp,0xfc,'native BG palette write reaches the register block') + p:release() +end +Version.set(previous) +T.finish('native GSC BG commands, one-clock stepping and retired observations') From 1ba98f0f7af771ba76182b526b87d675ac09e77d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:51:37 +0100 Subject: [PATCH 33/64] Record native clock integration and outstanding BG rendering work --- compat/gen1-gsc/provenance.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json index d9ee9f74a..2ab447685 100644 --- a/compat/gen1-gsc/provenance.json +++ b/compat/gen1-gsc/provenance.json @@ -13,8 +13,9 @@ "scope": "BgEffects implementation, arithmetic subset and BG-effect identifier lists only. No launcher, game assets, ROM bytes or player state copied.", "modifications": [ "BgEffects arithmetic dependency uses Gen2AnimMath instead of the full reference object pool.", - "A missing jump-table list resolves through edition-specific Gen2BgEffectIds for older native caches." + "A missing jump-table list resolves through edition-specific Gen2BgEffectIds for older native caches.", + "Existing Gen2AnimPlayer dispatches BG/palette commands and steps the pool from its current VM update; its legacy visual adapter resolves edition-specific IDs by names." ], "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", - "qualification": "Standalone native BG API and CPU state-machine traces only. Native animation VM / drawing integration and actual client GPU acceptance remain required before merge." + "qualification": "CPU native pool, actual SDK entry and native VM clock integration tested. Scanline drawing and battler-picture OAM handoff are not integrated; no GPU/client/release acceptance." } From 58838e6b1305111cb45d2178ee11d23ae1629cb6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:16:43 +0100 Subject: [PATCH 34/64] Present genuine GSC BG scanlines and picture states through native classic and wide battle owners --- compat/gen1-gsc/provenance.json | 14 +- src/battle/BattleState.lua | 1 + src/battle/Gen2AnimPlayer.lua | 131 +-------- src/battle/Gen2AnimView.lua | 6 +- src/battle/Gen2BattleScene.lua | 6 +- src/battle/Gen2BgPaint.lua | 27 ++ src/battle/Gen2BgPictures.lua | 121 +++++++++ src/battle/Gen2BgView.lua | 364 ++++++++++++++++++++++++++ src/battle/WideBattle.lua | 29 +- tests/engine/gen2_bg_presenter.lua | 159 +++++++++++ tests/engine/gen2_native_bg_clock.lua | 13 +- tests/love_stub.lua | 10 +- 12 files changed, 739 insertions(+), 142 deletions(-) create mode 100644 src/battle/Gen2BgPaint.lua create mode 100644 src/battle/Gen2BgPictures.lua create mode 100644 src/battle/Gen2BgView.lua create mode 100644 tests/engine/gen2_bg_presenter.lua diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json index 2ab447685..33c3e99c5 100644 --- a/compat/gen1-gsc/provenance.json +++ b/compat/gen1-gsc/provenance.json @@ -8,14 +8,20 @@ "tools/rom_manifest_gold.json": "ebfd906ef715c634c12ca168dbf711161385720f02c6e0a63d9481a815550a1e", "tools/rom_manifest_silver.json": "7575919b843c2addf096ed3db7b37dedb9f4b9c8129d32eb0d9ed6c9e5333f52", "tools/rom_manifest_crystal.json": "be373e361090d79f413ad4d1e57d28c5ac1aa00650d4fa361289a78ef8397b31", - "LICENSE.MD": "b369ce45656b3d87453541816fac6982b13e09ea0734ab9cfe61cf3faf5e95ed" + "LICENSE.MD": "b369ce45656b3d87453541816fac6982b13e09ea0734ab9cfe61cf3faf5e95ed", + "src/ui/gen2/BattleAnimView.lua": "c67613148f14987bdc76d01ace2cd90e89f7d742e4c4a0e95889707f5a9d0e61", + "src/ui/gen2/BattleState.lua": "6789c83e5124701d7ebfb1bdc74389102fa85c02f5ecdb812baf0905bded723c", + "src/ui/gen2/Chrome.lua": "a81231beac5334eae58df0936fed1f4fec90b3d986e8caf8b31b5e522180dc52" }, - "scope": "BgEffects implementation, arithmetic subset and BG-effect identifier lists only. No launcher, game assets, ROM bytes or player state copied.", + "scope": "BgEffects, BG presenter, arithmetic subset and BG-effect identifier lists. No launcher, game assets, ROM bytes or player state copied.", "modifications": [ "BgEffects arithmetic dependency uses Gen2AnimMath instead of the full reference object pool.", "A missing jump-table list resolves through edition-specific Gen2BgEffectIds for older native caches.", - "Existing Gen2AnimPlayer dispatches BG/palette commands and steps the pool from its current VM update; its legacy visual adapter resolves edition-specific IDs by names." + "Existing Gen2AnimPlayer dispatches BG/palette commands and steps the pool from its current VM update. Native scene and picture owners consume these registers directly, replacing the legacy SE approximations.", + "BG presenter uses native palette/chrome/canvas owners, supports the real wide surface, preserves transparency and restores graphics state on supplied-panel errors.", + "Battler resize, hide, palette and lifted-row drawing remain attached to native image owners. Object function motion and complete send-out event sequencing remain separate work.", + "The BG-only paper and clipping subset uses the native indexed palette owner, without requiring the full menu Chrome module on older engines." ], "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", - "qualification": "CPU native pool, actual SDK entry and native VM clock integration tested. Scanline drawing and battler-picture OAM handoff are not integrated; no GPU/client/release acceptance." + "qualification": "CPU pool, SDK entry, native VM and classic/wide BG rendering contracts tested. One unchanged-VASC Crystal Metal battle reached a native faint, win, EXP award and world return with real window captures on ce12c485. Full object motion, send-out sequencing, all effects GPU frames, other platforms and release qualification remain required." } diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index acbd5556b..a4eb11706 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -13030,5 +13030,6 @@ end require('src.battle.Gen2MovePresentation').install(BattleState) require('src.battle.Gen2BattleScene').install(BattleState) +require('src.battle.Gen2BgPictures').install(BattleState) return BattleState diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index 1fda64cf2..06d3d7ca5 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -36,7 +36,6 @@ -- xOffset' = -xOffset local BgEffects = require('src.battle.gen2.BgEffects') -local BgIds = require('src.battle.Gen2BgEffectIds') local Gen2AnimPlayer = {} Gen2AnimPlayer.__index = Gen2AnimPlayer @@ -146,8 +145,8 @@ function Gen2AnimPlayer.new(data) objects = {}, effects = {}, frames = 0, - tail = 0, bg = BgEffects.new(data and data.constants, {battleTurn=0}), + tail = 0, -- WideBattle reads these two off the Gen 1 player; keep them defined steps = {}, stepIndex = 1, @@ -273,7 +272,8 @@ function Gen2AnimPlayer:start(moveId, attackerIsPlayer, opts) attackerIsPlayer = not attackerIsPlayer end self.enemySide = not attackerIsPlayer - -- Existing VM updates own this pool's time; a restart retires the old block. + -- The real command VM owns this pool's clock. Each restart gets a fresh + -- register block, so retired animation observations keep their own state. self.bg = BgEffects.new(self.data and self.data.constants, { battleTurn = self.enemySide and 1 or 0, flying = opts and opts.flying or {}, @@ -329,68 +329,9 @@ function Gen2AnimPlayer:isDone() return self.done and self.tail <= 0 end --- `anim_bgeffect` queues one of BattleBGEffects' asm routines --- (BATTLE_BG_EFFECT_*, constants/battle_anim_constants.asm). Those are the --- screen shakes, palette flashes and mon-pic moves -- everything an --- animation does outside its own objects -- so without them a Gen 2 battle --- showed the sprites and none of the impact. BattleState already implements --- Gen 1's SE_* vocabulary for exactly these effects, so each id routes onto --- its closest counterpart rather than a second fx layer. --- --- The side each one acts on is NOT baked into the id: every routine resolves --- it through BGEffect_CheckBattleTurn, which is `hBattleTurn XOR --- BG_EFFECT_STRUCT_BATTLE_TURN` -- the third `anim_bgeffect` argument, 1 for --- the user and 0 for the target. So the names below are all the unflipped --- SE_* row and `side` on the queued event picks the battler. -local BG_EFFECTS = { - [0x01] = "SE_DARK_SCREEN_FLASH", -- FLASH_INVERTED - [0x02] = "SE_DARK_SCREEN_FLASH", -- FLASH_WHITE - [0x03] = "SE_LIGHT_SCREEN_PALETTE",-- WHITE_HUES - [0x04] = "SE_DARK_SCREEN_PALETTE", -- BLACK_HUES - [0x05] = "SE_FLASH_SCREEN_LONG", -- ALTERNATE_HUES - [0x08] = "SE_FLASH_SCREEN_LONG", -- CYCLE_BGPALS_INVERTED - [0x09] = "SE_HIDE_MON_PIC", -- HIDE_MON - [0x0A] = "SE_SHOW_MON_PIC", -- SHOW_MON - [0x0B] = "SE_SHOW_MON_PIC", -- ENTER_MON - [0x0C] = "SE_HIDE_MON_PIC", -- RETURN_MON - [0x0F] = "SE_SLIDE_MON_UP", -- TELEPORT - [0x13] = "SE_BLINK_MON", -- DOUBLE_TEAM - [0x14] = "SE_HIDE_MON_PIC", -- ACID_ARMOR - [0x15] = "SE_FLASH_SCREEN_LONG", -- RAPID_FLASH - [0x16] = "SE_LIGHT_SCREEN_PALETTE",-- FADE_MON_TO_LIGHT - [0x17] = "SE_DARKEN_MON_PALETTE", -- FADE_MON_TO_BLACK - [0x18] = "SE_LIGHT_SCREEN_PALETTE",-- FADE_MON_TO_LIGHT_REPEATING - [0x19] = "SE_DARKEN_MON_PALETTE", -- FADE_MON_TO_BLACK_REPEATING - [0x1A] = "SE_FLASH_SCREEN_LONG", -- CYCLE_MON_LIGHT_DARK_REPEATING - [0x1B] = "SE_BLINK_MON", -- FLASH_MON_REPEATING - [0x1C] = "SE_DARKEN_MON_PALETTE", -- FADE_MONS_TO_BLACK_REPEATING - [0x1D] = "SE_LIGHT_SCREEN_PALETTE",-- FADE_MON_TO_WHITE_WAIT_FADE_BACK - [0x1E] = "SE_RESET_SCREEN_PALETTE",-- FADE_MON_FROM_WHITE - [0x1F] = "SE_SHAKE_SCREEN", -- SHAKE_SCREEN_X - [0x20] = "SE_ROCK_SLIDE_SHAKE", -- SHAKE_SCREEN_Y - [0x21] = "SE_SQUISH_MON_PIC", -- WITHDRAW - [0x22] = "SE_BOUNCE_UP_AND_DOWN", -- BOUNCE_DOWN - [0x23] = "SE_SLIDE_MON_DOWN_AND_HIDE", -- DIG - [0x24] = "SE_MOVE_MON_HORIZONTALLY", -- TACKLE - [0x25] = "SE_SHAKE_BACK_AND_FORTH",-- WOBBLE_MON - [0x26] = "SE_HIDE_MON_PIC", -- REMOVE_MON - [0x27] = "SE_WAVY_SCREEN", -- WAVE_DEFORM_MON - [0x28] = "SE_WAVY_SCREEN", -- PSYCHIC - [0x2B] = "SE_SHAKE_BACK_AND_FORTH",-- FLAIL - [0x2D] = "SE_SHAKE_SCREEN", -- ROLLOUT - [0x2E] = "SE_SHAKE_SCREEN", -- VITAL_THROW - [0x32] = "SE_SHAKE_BACK_AND_FORTH",-- VIBRATE_MON - [0x33] = "SE_SHAKE_BACK_AND_FORTH",-- WOBBLE_PLAYER - [0x34] = "SE_WAVY_SCREEN", -- WOBBLE_SCREEN -} - --- `anim_incbgeffect id` bumps that effect's BG_EFFECT_STRUCT_JT_INDEX, and --- for every routine a script ends this way the state it lands on is the one --- that puts back what the effect changed and calls EndBattleBGEffect: --- BGEffect_RapidCyclePals' .two_dmg reloads rBGP with $e4, the deformations' --- last state is BattleAnim_ResetLCDStatCustom. The port ran none of it, so --- Growl's FADE_MON_TO_BLACK_REPEATING left its darkened palette up for the --- rest of the battle and Tail Whip's WOBBLE_MON never put the mon back. +-- The historical SE-reset lookup remains available to existing consumers. +-- The native VM no longer emits these substitutes: Gen2BgView and native +-- picture drawing consume the genuine BG owner directly. local BG_EFFECTS_END = { [0x03] = "SE_RESET_SCREEN_PALETTE", -- WHITE_HUES [0x04] = "SE_RESET_SCREEN_PALETTE", -- BLACK_HUES @@ -417,45 +358,10 @@ local BG_EFFECTS_END = { } Gen2AnimPlayer.BG_EFFECTS_END = BG_EFFECTS_END --- The legacy visual adapter is keyed by the Gold/Silver table. Cartridge --- commands must resolve by NAME first: Crystal inserts BODY_SLAM at $25. --- Keep this adapter until the native register/scanline draw owner is wired; --- the actual pool below is authoritative for public BG state. -local legacyBgIds = {} -for index,name in ipairs(BgIds.forVersion('gold')) do legacyBgIds[name]=index-1 end -local function legacyBgId(self,id) - local name=type(id)=='number'and self.bg.order[id+1]or id - if name=='BATTLE_BG_EFFECT_BODY_SLAM'then return 0x24 end - return legacyBgIds[name] -end - - --- BGEffect_CheckBattleTurn: BG_EFFECT_STRUCT_BATTLE_TURN is 1 for the move's --- user and 0 for its target. Anything else (the flash routines reuse the --- field as a repeat count) only ever drives screen-wide effects, which --- ignore the side. -local function bgSide(turn) - return turn == 1 and "user" or "target" -end - --- The effects that are deliberately dropped rather than unhandled, so the --- coverage test can tell the two apart. -Gen2AnimPlayer.BG_EFFECTS_DROPPED = { - -- object palette cycling; the port draws each object with its own - -- BattleObjectPals entry and has no OBP timer to cycle - [0x06] = true, [0x07] = true, - -- per-scanline BG raster (Surf/Whirlpool/the water backdrop) - [0x0D] = true, [0x0E] = true, [0x2F] = true, [0x30] = true, [0x31] = true, - -- BG tilemap dissolve - [0x10] = true, - -- BATTLEROBJ_1ROW/2ROW copy rows of the battler's own picture into the - -- object tiles. With no VRAM to copy into, the objects that depend on it - -- would draw the raw PLAYERHEAD/ENEMYFEET sheets instead -- the stray foot - -- that turned up in half the physical moves -- so spawn drops them. - [0x11] = true, [0x12] = true, - -- unused beta routines - [0x29] = true, [0x2A] = true, [0x2C] = true, -} +-- BG commands now execute their genuine register/picture state machines. +-- Battler-picture requests are drawn by the native lifted-row pass, while +-- the remaining approximate object runtime is replaced independently. +Gen2AnimPlayer.BG_EFFECTS_DROPPED = {} -- BATTLE_ANIM_GFX_PLAYERHEAD / _ENEMYFEET: these two sheets are placeholders -- that BATTLE_BG_EFFECT_BATTLEROBJ_*ROW overwrites with a slice of the @@ -718,24 +624,9 @@ function Gen2AnimPlayer:step(inst) end elseif op == "bgeffect" then local id = inst.args[1] - local side = bgSide(inst.args[3]) - -- anim_incbgeffect addresses a running effect by id alone, so keep the - -- side the queue entry was made with - self.bgSides = self.bgSides or {} - self.bgSides[id] = side self.bg:queue(id, inst.args[2], inst.args[3], inst.args[4]) - local se = BG_EFFECTS[legacyBgId(self,id)] - if se then - self.effects[#self.effects + 1] = { effect = se, side = side, gen2 = true } - end elseif op == "incbgeffect" then - local id = inst.args[1] - self.bg:incEffect(id) - local se = BG_EFFECTS_END[legacyBgId(self,id)] - if se then - local side = self.bgSides and self.bgSides[id] or "target" - self.effects[#self.effects + 1] = { effect = se, side = side, gen2 = true } - end + self.bg:incEffect(inst.args[1]) elseif op == 'bgp' or op == 'obp0' or op == 'obp1' then self.bg[op] = (inst.args[1] or 0) % 256 elseif op == 'resetobp0' then diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index f2f8d3930..ff21a0533 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -27,6 +27,7 @@ function View:release() if image and image.release then image:release() end end self.images, self.quads = {}, {} + self:releaseBg() end function View:objPalette(name, battle) local index = slots[name] or tonumber(name) @@ -97,7 +98,7 @@ function View.runner(player) local function state() return {animId=player.presentationId or player.moveId,frames=player.frames or 0,var=player.vars and player.vars.var or 0, stopped=player:isDone(),keepSprites=player.keepSprites, - objects=objects(), oam=player:oam(selected)} + bg=player.bg, objects=objects(), oam=player:oam(selected)} end local runner = {} function runner:oam() return frozen and frozen.oam or player:oam(selected) end @@ -114,8 +115,11 @@ function View.runner(player) if key=='stopped'then return player:isDone()end if key=='keepSprites'then return player.keepSprites end if key=='objects'then return objects()end + if key=='bg'then return player.bg end end, __newindex=function() error('native Gen2 animation observation is read-only',2) end, }) end +-- BG methods use the same concrete view object as native OAM drawing. +for name,method in pairs(require('src.battle.Gen2BgView'))do View[name]=method end return View diff --git a/src/battle/Gen2BattleScene.lua b/src/battle/Gen2BattleScene.lua index 9f93267c5..53d1b1ece 100644 --- a/src/battle/Gen2BattleScene.lua +++ b/src/battle/Gen2BattleScene.lua @@ -111,8 +111,12 @@ function M.install(Screen) local g=love.graphics g.push('all') local ok,why=pcall(function() - if panelFn then panelFn()else self:drawPanel()end + local panel=panelFn or function()self:drawPanel()end + if self.anim and self.anim.bg and self.animView then + self.animView:present(self.anim,panel,self,width or 160) + else panel()end if self.blankForAskName then return end + self:drawLiftedRows(width) if objectsFn then objectsFn()else self:drawAnimLayer(self:colorMode())end local fx=self.fx if fx and fx.flash and fx.flash>0 and self.frame%4<2 then diff --git a/src/battle/Gen2BgPaint.lua b/src/battle/Gen2BgPaint.lua new file mode 100644 index 000000000..60190fe1a --- /dev/null +++ b/src/battle/Gen2BgPaint.lua @@ -0,0 +1,27 @@ +-- Gen1Recomp-derived BG paper/clip drawing. GPLv3 plus Additional Terms: +-- compat/gen1-gsc/LICENSE.MD and provenance.json. Uses native indexed colour. +local Palette=require('src.render.Gen2PaletteDraw') +local M={} +-- The GSC battle/menu paper palette from the pinned Gen1 Chrome owner. +local BOX={{255,255,255},{255,255,255},{255,255,255},{0,0,0}} +function M.paletteFill(x,y,w,h) + local g=love.graphics + local previous=g.getShader and g.getShader() + g.setColor(1,1,1,1) + if Palette.available()then Palette.use(BOX)end + g.rectangle('fill',x,y,w,h) + g.setShader(previous) + g.setColor(0,0,0,1) +end +function M.clipTo(x,y,w,h) + local g=love.graphics + if g.transformPoint then + local x1,y1=g.transformPoint(x,y) + local x2,y2=g.transformPoint(x+w,y+h) + x,y=math.floor(math.min(x1,x2)),math.floor(math.min(y1,y2)) + w,h=math.ceil(math.abs(x2-x1)),math.ceil(math.abs(y2-y1)) + end + if g.intersectScissor then g.intersectScissor(x,y,w,h) + else g.setScissor(x,y,w,h)end +end +return M diff --git a/src/battle/Gen2BgPictures.lua b/src/battle/Gen2BgPictures.lua new file mode 100644 index 000000000..fb2651ff6 --- /dev/null +++ b/src/battle/Gen2BgPictures.lua @@ -0,0 +1,121 @@ +-- Gen1Recomp-derived picture/register integration. GPLv3 plus Additional +-- Terms, compat/gen1-gsc/LICENSE.MD. See pinned provenance.json. +-- The existing native screen still owns images, battlers and all clocks. +local Version=require('src.core.GameVersion') +local Palette=require('src.render.Gen2PaletteDraw') +local Chrome=require('src.battle.Gen2BgPaint') +local M={} +local SIZES={[0]=6,[1]=4,[2]=2,[3]=7,[4]=5,[5]=3} + +-- Per-mon CopyPals first, whole-BG BGP second. Compose indices before the +-- existing image owner bakes them; never guess shades from finished RGB. +function M.paletteByte(monByte,bgByte) + local mon=Palette.bgpShades(monByte) + local bg=Palette.bgpShades(bgByte) + local out=0 + for i=0,3 do out=out+mon[bg[i+1]+1]*4^i end + return out +end +local function withPalette(screen,side,body) + if screen._gen2PicPaletteSide==side then return body()end + local state=screen:animPicState(side) + local previous=Palette.bgp + local owner=screen._gen2PicPaletteSide + screen._gen2PicPaletteSide=side + Palette.setBgp(M.paletteByte(state and state.shade,previous)) + local ok,why=pcall(body) + Palette.setBgp(previous) + screen._gen2PicPaletteSide=owner + if not ok then error(why,0)end +end +function M.install(Screen) + -- Older native screens need the same live observation; this reads their + -- actual battler and BG owners rather than constructing another model. + if not Screen.animPicState then + function Screen:animPicState(side) + local bg=self.anim and self.anim.bg + if not bg then return nil end + return {hidden=bg.hidden[side],size=bg.picSize[side], + shade=bg.monShade[side],slide=bg.slide[side]or 0, + lifted=bg.liftedRows[side]} + end + end + local draw=Screen.drawBattlerPic + function Screen:drawBattlerPic(b,x,y,scale) + if not Version.isGen2() or not(self.anim and self.anim.bg) then + return draw(self,b,x,y,scale) + end + local side=b.isPlayer and 'player'or'enemy' + local state=self:animPicState(side) + if state and state.hidden then return end + if self.liftedPass and not(state and state.lifted)then return end + if not self.liftedPass then x=x+(self.anim.bg.slide[side] or 0)end + local size=state and SIZES[state.size] + if size then + local image=self:battlerPic(b) + if image then + local factor=size/(b.isPlayer and 6 or 7) + x=x+math.floor(image:getWidth()*scale*(1-factor)/2) + y=y+math.floor(image:getHeight()*scale*(1-factor)) + scale=scale*factor + end + end + local function paint()withPalette(self,side,function()draw(self,b,x,y,scale)end)end + local lifted=state and state.lifted + if not lifted then return paint()end + local bandY=(b.isPlayer and 48 or 0)+lifted[1]*8 + local bandH=lifted[2]*8 + local function band(top,height) + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + Chrome.clipTo(0,top,160,height) + paint() + end) + g.pop() + if not ok then error(why,0)end + end + if self.liftedPass then band(bandY,bandH) + else + if bandY>0 then band(0,bandY)end + local below=144-bandY-bandH + if below>0 then band(bandY+bandH,below)end + end + end + local drawPic=Screen.drawPic + if drawPic then + function Screen:drawPic(mon,back) + if not Version.isGen2() or not(self.anim and self.anim.bg)then + return drawPic(self,mon,back) + end + local side=self.player and self.player.mon==mon and 'player' + or self.enemy and self.enemy.mon==mon and 'enemy' + or (back and 'player'or'enemy') + return withPalette(self,side,function()drawPic(self,mon,back)end) + end + end + function Screen:drawLiftedRows(width) + local bg=self.anim and self.anim.bg + local rows=bg and bg.liftedRows + if not(rows and(rows.player or rows.enemy))then return end + local before=self.liftedPass + self.liftedPass=true + local previous=Palette.setBgp(bg.bgp) + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + local slide=(self.introSlide or 0)*require('src.core.Timing').BATTLE_SLIDE_PX_PER_FRAME + if width and width>160 then + require('src.battle.WideBattle').drawPicRegions(self,slide) + else + if rows.enemy then self:drawPicsLayer(slide,0,0,'enemy',true)end + if rows.player then self:drawPicsLayer(slide,0,0,'player',true)end + end + end) + g.pop() + Palette.setBgp(previous) + self.liftedPass=before + if not ok then error(why,0)end + end +end +return M diff --git a/src/battle/Gen2BgView.lua b/src/battle/Gen2BgView.lua new file mode 100644 index 000000000..7d835ea37 --- /dev/null +++ b/src/battle/Gen2BgView.lua @@ -0,0 +1,364 @@ +-- Gen1Recomp-derived BG presenter, pinned at535a5ec0. GPLv3 plus +-- Additional Terms: compat/gen1-gsc/LICENSE.MD and provenance.json. +-- Native integration retains the real BG scanline pipeline and uses this +-- engine's palette and pixel-canvas owners. OAM is composited afterwards. +local PixelCanvas=require('src.render.PixelCanvas') +local Chrome=require('src.battle.Gen2BgPaint') +local GbcPalette=require('src.render.Gen2PaletteDraw') +local BgView={} +local SCREEN_W,SCREEN_H=160,144 +local function signed(value) + value=(value or 0)%256 + return value<0x80 and value or value-256 +end +local function needsCanvas(runner) + local bg = runner.bg + -- engine/battle_anims/bg_effects.asm:448-465: a lifted battler row stays + -- out of the BG until the next pic redraw, so those frames stay baked too. + local lifted = bg.liftedRows + if lifted and (lifted.player or lifted.enemy) then return true end + if bg.scx ~= 0 or bg.scy ~= 0 then return true end + if not bg.lcdc then return false end + if bg.lyEnd <= bg.lyStart then return false end + for row = bg.lyStart, math.min(bg.lyEnd, SCREEN_H) - 1 do + if (bg.lyBackup[row] or 0) ~= 0 then return true end + end + return false +end + +-- The battle background is BG colour 0 everywhere the two pic boxes and the +function BgView:fillBackground(palByte) + local SCREEN_W=self.bgWidth or 160 + if self.bgTransparent then return end + local previousBgp = GbcPalette.setBgp(palByte) + local ok, err = pcall(Chrome.paletteFill, 0, 0, SCREEN_W, SCREEN_H) + GbcPalette.setBgp(previousBgp) + if not ok then error(err, 0) end +end + +-- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn +-- CLIPPED to the 160-pixel screen rather than allowed to hang over the edge: +-- the cart's BG map wraps, so a scrolled scanline never spills past the LCD. +function BgView:blitRowAt(srcRow, destRow, dx) + local SCREEN_W=self.bgWidth or 160 + local canvas = self.canvas + if not self.blitQuad then + self.blitQuad = love.graphics.newQuad(0, 0, SCREEN_W, 1, SCREEN_W, SCREEN_H) + end + local srcX, width, destX = 0, SCREEN_W, dx + if dx > 0 then + width = SCREEN_W - dx + elseif dx < 0 then + srcX, width, destX = -dx, SCREEN_W + dx, 0 + end + if width <= 0 then return end + self.blitQuad:setViewport(srcX, srcRow, width, 1, SCREEN_W, SCREEN_H) + love.graphics.draw(canvas, self.blitQuad, destX, destRow) +end + +function BgView:blitRow(row, dx, dy) + self:blitRowAt(row, row + dy, dx) +end + +-- Draw the battle panel into the blit canvas, optionally with an rBGP byte +-- folded into every palette on the way in. +-- +-- The byte is set on GbcPalette rather than applied afterwards, which is what +-- makes it exact: the panel is still being DRAWN, so its palettes can take +-- CopyPals' permutation before they ever reach the shader. That is bit for bit +-- what DmgToCgbBGPals does, and it is why nothing here has to guess which +-- palette entry produced a finished pixel. +-- +-- pcall so a drawBg that throws cannot leave the byte standing on GbcPalette +-- for the rest of the frame. +function BgView:bake(drawBg, palByte) + local SCREEN_W=self.bgWidth or 160 + local G = love.graphics + if not self.canvas then + self.canvas = PixelCanvas.new(SCREEN_W, SCREEN_H) + self.canvas:setFilter("nearest", "nearest") + end + local previousBgp = GbcPalette.setBgp(palByte) + G.push('all') + local ok, err = pcall(function() + G.setCanvas(self.canvas) + G.setScissor() + G.clear(0,0,0,0) + G.origin() + drawBg() + end) + G.pop() + GbcPalette.setBgp(previousBgp) + if not ok then error(err,0) end +end + +-- The rBGP window's scanlines grouped by the byte they hold, identity first. +-- +-- `.SetLYOverridesBackup` writes one value on every SECOND row and leaves the +-- rest on whatever ClearLYOverrides put there, so a beta send-out frame holds +-- two bytes and FadeMonsToBlackRepeating's DMG path holds two bands of one +-- each. Grouping means the panel is re-baked twice a frame rather than 144 +-- times, and the grouping is by VALUE so a table that happens to repeat costs +-- nothing extra. +local function bgpBands(bg) + local base = bg.bgp or GbcPalette.BGP_IDENTITY + local order, bands = {}, {} + for row = 0, SCREEN_H - 1 do + -- home/lcd.asm:12 + local inWindow = row > bg.lyStart and row <= bg.lyEnd + -- Outside the window the register still reads whatever wBGP holds. + local byte = inWindow and (bg.lyBackup[row - 1] or base) or base + local band = bands[byte] + if not band then + band = { byte = byte, rows = {} } + bands[byte] = band + order[#order + 1] = band + end + band.rows[#band.rows + 1] = row + end + -- The base band first so the fillBackground below it happens before any blit + -- and the common band is the one drawn from the first bake. + table.sort(order, function(a, b) + if a.byte == b.byte then return false end + if a.byte == base then return true end + if b.byte == base then return false end + return a.rows[1] < b.rows[1] + end) + return order +end +BgView.bgpBands = bgpBands + +-- engine/battle_anims/bg_effects.asm:2638 +-- +-- One scanline's source row and x shift, or nil when the row shows nothing. +local function scanline(bg, scy, row) + local dx, src = 0, row + scy + -- home/lcd.asm:12 + local inWindow = bg.lcdc and bg.lcdc ~= "BGP" + and row > bg.lyStart and row <= bg.lyEnd + local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0 + if inWindow then + local value = signed(byte) + if bg.lcdc == "SCX" then dx = -value else src = src + value end + -- home/lcd.asm:3 + if byte ~= 0x90 and scy == 0 then + if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end + end + end + if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then + return src, dx + end + return nil +end + +function BgView.scanlines(bg) + local lines = {} + local scy = signed(bg.scy) + for row = 0, SCREEN_H - 1 do + local src, dx = scanline(bg, scy, row) + if src then + lines[#lines + 1] = { src = src, dest = row, dx = dx } + end + end + return lines +end + +-- Runs `drawBg` (the battle panel) and puts it on screen through the +-- animation's BG registers; skips the canvas when nothing needs one. +function BgView:present(runner, drawBg, battle) + local SCREEN_W=self.bgWidth or 160 + if not (love and love.graphics) then return end + local bg = runner.bg + -- engine/battle_anims/anim_commands.asm:1293 + local byte = bg.bgp ~= GbcPalette.BGP_IDENTITY and bg.bgp or nil + if not byte and not needsCanvas(runner) then + drawBg() + return + end + local G = love.graphics + + -- hLCDCPointer can also aim at rBGP, in which case each scanline gets its own + -- PALETTE rather than its own scroll (the beta send-out effects, and + -- FadeMonsToBlackRepeating on the DMG path). Nothing scrolls on that path, + -- so the whole of it is: bake the panel once per distinct byte and blit that + -- byte's rows out of it. + if bg.lcdc == "BGP" and GbcPalette.available() then + local baseX, baseY = -signed(bg.scx), -signed(bg.scy) + local bands = bgpBands(bg) + local filled = false + for _, band in ipairs(bands) do + self:bake(drawBg, band.byte) + G.setShader() + if not filled then + self:fillBackground(band.byte) + filled = true + end + G.setColor(1, 1, 1, 1) + for _, row in ipairs(band.rows) do + self:blitRow(row, baseX, baseY) + end + end + return + end + + self:bake(drawBg, byte) + G.setShader() + + -- A shifted scanline exposes the blank tile beside the pic boxes; without + -- this the exposed strip is the canvas's own transparency. + self:fillBackground(byte) + G.setColor(1, 1, 1, 1) + -- hSCX / hSCY move the whole background; the per-scanline overrides only + -- apply inside the effect's own window. + local baseX = -signed(bg.scx) + -- BgView.scanlines row by row, without a table per scanline. + local scy = signed(bg.scy) + for row = 0, SCREEN_H - 1 do + local src, dx = scanline(bg, scy, row) + if src then self:blitRowAt(src, row, baseX + dx) end + end + -- Shaderless boot: the panel is raw grayscale, so there are no palettes to + -- permute and the entry's BRIGHTNESS is the only thing left to reproduce. + if bg.lcdc == "BGP" then + for row = bg.lyStart + 1, math.min(bg.lyEnd, SCREEN_H - 1) do + local veil = BgView.palVeil(bg.lyBackup[row - 1]) + if veil ~= 0 then + local shade = veil > 0 and 0 or 1 + G.setColor(shade, shade, shade, math.min(1, math.abs(veil))) + G.rectangle("fill", 0, row, SCREEN_W, 1) + end + end + G.setColor(1, 1, 1, 1) + end +end + +-- A DMG palette byte's mean shade against the identity %11100100, signed: +-- +1 is solid black ($ff), -1 solid white ($00), 0 the identity. The +-- approximation, kept for the shaderless path in `present` above. +function BgView.palVeil(palByte) + if not palByte then return 0 end + local sum = 0 + for index = 0, 3 do + sum = sum + math.floor(palByte / (4 ^ index)) % 4 + end + return (sum - 6) / 6 +end + +-------------------------------------------------------------------------- +-- The battle intro slide (engine/battle/sliding_intro.asm) +-------------------------------------------------------------------------- + +-- BattleIntroSlidingPics runs 72 frames. It holds three SCX values at once: +-- scanlines 0-$3f take `c`, which starts at $90 and falls by 2 a frame, so +-- the enemy's half enters from the RIGHT; $40-$5f take `b`, which starts at +-- $70 and RISES by 2, wrapping to 0 on the last frame, so the player's half +-- enters from the LEFT; everything from $60 down is already in place. +BgView.SLIDE_FRAMES = 72 + +function BgView.slideOffsets(frame) + local step = math.max(0, math.min(BgView.SLIDE_FRAMES, frame)) + local top = (0x90 - step * 2) % 256 + local middle = (0x70 + step * 2) % 256 + return top, middle +end + +-- The pixels the cart's OAM back-pic copy still has to travel at `frame`: +-- CopyBackpic parks 18 sprites just off the RIGHT edge (x = 168 in OAM +-- terms) and the slide's `.subfunction1` walks every one left 2px a frame, +-- so the whole pic crosses 144px to its resting column in the 72 frames. +function BgView.slideBackpicOffset(frame) + local step = math.max(0, math.min(BgView.SLIDE_FRAMES, frame)) + return (BgView.SLIDE_FRAMES - step) * 2 +end + +-- The same band-at-a-time blit `present` uses, with the intro's own offsets. +-- +-- `drawBackpic(offset)`, when given, draws the player's back pic OVER the +-- bands the way the cart's OAM copy rides over them: InitBattleDisplay clears +-- the pic's top rows out of the BG before the slide (the hlcoord 1, 5 +-- ClearBox) precisely because the pic straddles the $40 scanline where the +-- two bands part ways -- baked into the bands it tears in half there, its top +-- rows riding the enemy's offset and its bottom rows the player's. The +-- caller must leave the pic OUT of drawBg and hand it here instead. +function BgView:presentSlide(frame, drawBg, drawBackpic) + local SCREEN_W=self.bgWidth or 160 + if not (love and love.graphics) then return end + local top, middle = BgView.slideOffsets(frame) + if top == 0 and middle == 0 then + drawBg() + if drawBackpic then drawBackpic(0) end + return + end + local G = love.graphics + self:bake(drawBg, nil) + G.setShader() + self:fillBackground() + G.setColor(1, 1, 1, 1) + for row = 0, SCREEN_H - 1 do + local scx = 0 + if row < 0x40 then + scx = top + elseif row < 0x60 then + scx = middle + end + -- SCX is unsigned and the map wraps at 256, so a value over half the + -- screen reads as "coming in from the other side" rather than as a jump. + local dx = -scx + if scx > 128 then dx = 256 - scx end + self:blitRow(row, dx, 0) + end + if drawBackpic then + drawBackpic(BgView.slideBackpicOffset(frame)) + end +end + +-- A DMG palette byte read as a shade remap, so a BG effect that fades a mon +-- through $f8/$fc can be applied to that mon's four CGB colours. The same +-- CopyPals permutation `present` now folds into the whole panel, which is why +-- it lives in GbcPalette and this is a name for it rather than a second copy. +function BgView.shadeColors(colors, palByte) + return GbcPalette.remap(colors, palByte) +end + +BgView.SCREEN_W = SCREEN_W +BgView.SCREEN_H = SCREEN_H +BgView.needsCanvas = needsCanvas + +function BgView:setBgSize(width) + width=width or 160 + assert(type(width)=='number' and width>0 and width%1==0,'invalid native battle surface width') + if self.bgWidth==width then return end + self:releaseBg() + self.bgWidth=width +end +function BgView:releaseBg() + if self.canvas and self.canvas.release then self.canvas:release() end + self.canvas,self.blitQuad=nil,nil +end + +-- Public draw seams restore the caller's graphics owner; no animation time +-- runs here. The real native scene supplies its transparency and surface. +local present=BgView.present +function BgView:present(runner,drawBg,battle,width) + if not(love and love.graphics)then return end + self:setBgSize(width) + local transparent=self.bgTransparent + self.bgTransparent=battle and type(battle.worldBackdrop)=='function' and battle:worldBackdrop() or false + local g=love.graphics + g.push('all') + local ok,why=pcall(present,self,runner,drawBg,battle) + g.pop() + self.bgTransparent=transparent + if not ok then error(why,0) end +end +local presentSlide=BgView.presentSlide +function BgView:presentSlide(frame,drawBg,drawBackpic,width) + if not(love and love.graphics)then return end + self:setBgSize(width) + local g=love.graphics + g.push('all') + local ok,why=pcall(presentSlide,self,frame,drawBg,drawBackpic) + g.pop() + if not ok then error(why,0) end +end +return BgView diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 0b3b50889..282ff03bb 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -78,9 +78,10 @@ local function inRegion(x, y, w, h, dx, dy, fn) g.setScissor(x, y, w, h) g.push() g.translate(dx, dy) - fn() + local ok,why=pcall(fn) g.pop() restoreScissor(saved) + if not ok then error(why,0)end end local function levelAt(battle, battler, x, y) @@ -449,6 +450,21 @@ local function drawAnimationLayer(battle) end -- The whole 304x144 composition for one frame. +-- Native picture regions are shared by the BG and lifted-OBJ passes. +function WideBattle.drawPicRegions(battle,slide,sx,sy) + sx,sy=sx or 0,sy or 0 + local previous=battle.wideRegion + battle.wideRegion = true + local ok,why=pcall(function() + inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy, + function() battle:drawPicsLayer(slide, 0, 0, "player", true) end) + inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy, + function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end) + end) + battle.wideRegion = previous + if not ok then error(why,0)end +end + local function drawPanel(battle,withObjects) local g = love.graphics -- The field is the display mode's paper. Under a forced-mono mode the @@ -461,7 +477,9 @@ local function drawPanel(battle,withObjects) else g.setColor(PaletteFX.paperShade(battle.data)) end - g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) + if not battle:worldBackdrop()then + g.rectangle("fill",0,0,WideBattle.WIDTH,WideBattle.HEIGHT) + end -- AskName clears the field the same way the classic layout does if battle.blankForAskName then return end @@ -482,12 +500,7 @@ local function drawPanel(battle,withObjects) -- each region's clip with its contents, so a pic pushed toward a region -- edge is not sheared off it (a vertical shake used to clip the player's -- feet at FIELD_BOTTOM). - battle.wideRegion = true - inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy, - function() battle:drawPicsLayer(slide, 0, 0, "player", true) end) - inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy, - function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end) - battle.wideRegion = nil + WideBattle.drawPicRegions(battle,slide,sx,sy) -- A battle sets rWY to 0 (engine/battle/core.asm), so the window the -- shakes move IS the whole screen: PredefShakeScreenHorizontally, diff --git a/tests/engine/gen2_bg_presenter.lua b/tests/engine/gen2_bg_presenter.lua new file mode 100644 index 000000000..1a243e48f --- /dev/null +++ b/tests/engine/gen2_bg_presenter.lua @@ -0,0 +1,159 @@ +-- Actual native VM -> scene -> BG presenter. Older upstream uses its +-- native battler draw where the separate public portrait API is absent. Only graphics device calls +-- are recorded. These checks do not claim rendered GPU pixel acceptance. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local View=require('src.battle.Gen2AnimView') +local Bg=require('src.battle.gen2.BgEffects') +local Palette=require('src.render.Gen2PaletteDraw') +local Pictures=require('src.battle.Gen2BgPictures') +local Battle=require('src.battle.BattleState') +local Wide=require('src.battle.WideBattle') +local Pokemon=require('src.pokemon.Pokemon') +local g=love.graphics +local draw,newCanvas,rectangle=g.draw,g.newCanvas,g.rectangle +local calls,rects,allocations,released={},{},0,0 +local previous=Version.get() +g.draw=function(image,quad,x,y,...) + calls[#calls+1]={image=image,quad=quad,x=x,y=y,shader=g.getShader(),byte=Palette.bgp, + viewport=type(quad)=='table'and quad.getViewport and {quad:getViewport()}or nil} +end +g.rectangle=function(...)rects[#rects+1]={...};return rectangle(...)end +g.newCanvas=function(...) + allocations=allocations+1 + local canvas=newCanvas(...) + canvas.release=function()released=released+1 end + return canvas +end +local function native(edition,effect) + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op='bgeffect',args={effect or 'BATTLE_BG_EFFECT_BODY_SLAM',0,1,0}}, + {op='delay',frames=16},{op='ret'}}}},objects={},framesets={},oam={},gfx={}} + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.introSlide=0;s.showPlayerBack=false;s.showEnemyTrainer=false + s.sendingOut=false;s.enemySendingOut=false;s.enemyHidden=false + T.check(s:animForId('PROBE','player'),'actual native screen starts its existing animation player') + for _=1,3 do s.animPlayer:update()end + return s +end +for _,edition in ipairs({'gold','silver','crystal'})do + local s=native(edition) + for _,width in ipairs({160,304,160})do + calls,rects={},{} + local external={};g.setShader(external);g.setColor(.2,.3,.4,.5) + local frame=s.anim.frames + if width==304 then Wide.draw(s)else s:drawSceneBody()end + local blits={} + for _,row in ipairs(calls)do + if row.image==s.animView.canvas then blits[#blits+1]=row end + end + T.eq(#blits,144,'native composition blits one actual background row per LCD row') + T.eq(s.animView.canvas:getWidth(),width,'presenter uses the actual classic or wide surface') + T.eq(blits[49].x,2,'native BODY_SLAM shifts the selected LCD band by signed two pixels') + T.eq(blits[49].viewport[3],width-2,'shifted row clips to its own surface width') + T.eq(blits[1].x,0,'row above the LCD window remains fixed') + T.eq(blits[144].x,0,'text rows remain outside the BODY_SLAM window') + for _,row in ipairs(blits)do T.eq(row.shader,nil,'already-coloured canvas rows bypass a caller shader')end + T.eq(s.anim.frames,frame,'scene rendering never advances the native VM') + T.eq(g.getShader(),external,'native composition restores caller shader') + if width==304 then + T.same({g.getColor()},{1,1,1,1},'outer native scene sets neutral tint for final overlays') + else + T.same({g.getColor()},{.2,.3,.4,.5},'native scene body restores caller tint') + end + end + s.animView:release();s.animPlayer:release() +end +-- Paint the actual native battler under picture-size, hide, shade and lifted +-- states. These are graphics-device records, not fabricated gameplay saves. +for _,edition in ipairs({'gold','silver','crystal'})do + local s=native(edition,'BATTLE_BG_EFFECT_ENTER_MON') + local b=s.player + local image=s:battlerPic(b);local w,h=image:getDimensions() + calls={} + s:drawBattlerPic(b,10,20,1) + local row=calls[#calls] + T.check(row~=nil,'native entry still paints its actual battler') + T.eq(row.quad,10+math.floor(w*(1-1/3)/2),'entry picture compensation stays on integer native pixels') + T.eq(row.x,20+math.floor(h*(1-1/3)),'entry keeps its bottom edge pinned to the native box') + local bg=s.anim.bg;bg.picSize.player=nil + bg.monShade.player=0xf8;bg.monShade.enemy=0xfc + Palette.setBgp(0x1b);calls={} + if s.drawPic then s:drawPic(b.mon,false)else s:drawBattlerPic(b,10,20,1)end + T.check(#calls>0,'front capture of the actual player remains drawable') + for _,paint in ipairs(calls)do + T.eq(paint.byte,Pictures.paletteByte(0xf8,0x1b),'player front capture uses player shade and composes it once') + end + T.eq(Palette.bgp,0x1b,'picture capture restores the surrounding BG register') + Palette.setBgp(nil) + bg.hidden.player=true;calls={} + s:drawBattlerPic(b,10,20,1) + T.eq(#calls,0,'actual BG hide state suppresses the native battler draw') + bg.hidden.player=false;bg.liftedRows.player={0,1} + local scissor=g.setScissor;local clips={} + g.setScissor=function(...)clips[#clips+1]={...};return scissor(...)end + calls={};s:drawBattlerPic(b,10,20,1) + T.same(clips[1],{0,0,160,48},'native BG paints only above the lifted player row') + T.same(clips[2],{0,56,160,88},'native BG paints only below the lifted player row') + T.eq(#calls,2,'lifted row is excluded from the actual BG picture pass') + clips={};calls={};s.liftedPass=true;s:drawBattlerPic(b,10,20,1);s.liftedPass=nil + T.same(clips[1],{0,48,160,8},'lifted OBJ pass selects the original player tile row') + T.eq(#calls,1,'lifted row is restored exactly once outside BG distortion') + g.setScissor=scissor + local external={};g.setShader(external);Palette.setBgp(0x1b) + local record=g.draw;g.draw=function()error('native portrait failure',0)end + T.raises(function() + if s.drawPic then s:drawPic(b.mon,false)else s:drawBattlerPic(b,10,20,1)end + end,'native portrait failure','picture error remains visible') + T.eq(Palette.bgp,0x1b,'failed portrait restores its BG palette owner') + T.eq(s._gen2PicPaletteSide,nil,'failed portrait retires its per-side palette context') + T.eq(g.getShader(),external,'failed portrait restores caller shader') + g.draw=record;g.setShader();Palette.setBgp(nil);s.animPlayer:release() +end +T.check(released>=9,'changing surface and releasing native views retires owned canvases') +Version.set('crystal') +do + local v=View.new() + local pool=Bg.new({},{});pool.scx=2 + local screen={worldBackdrop=function()return true end} + calls,rects={},{} + local seen=0 + v:present({bg=pool},function()seen=seen+1 end,screen) + T.eq(seen,1,'transparent scene still draws its actual supplied panel') + T.eq(#rects,0,'exposed scanlines do not paint paper over a live world') + local external={};local target={};g.setShader(external);g.setCanvas(target) + g.setColor(.2,.4,.6,.8);Palette.setBgp(0x1b) + T.raises(function()v:present({bg=pool},function() + g.setShader();g.setColor(1,0,0,1);error('native panel failure',0) + end)end,'native panel failure','actual panel failure remains visible') + T.eq(g.getShader(),external,'failed BG bake restores caller shader') + T.eq(g.getCanvas(),target,'failed BG bake restores caller canvas') + T.same({g.getColor()},{.2,.4,.6,.8},'failed BG bake restores caller tint') + T.eq(Palette.bgp,0x1b,'failed BG bake restores native palette register') + g.setCanvas();g.setShader();Palette.setBgp(nil) + local bg=Bg.new({},{});bg.lcdc='BGP';bg.lyStart=0;bg.lyEnd=4 + for i=0,3 do bg.lyBackup[i]=0xff end + local bands=View.bgpBands(bg) + T.eq(#bands,2,'LCD palette bands are grouped by their actual byte') + T.eq(bands[1].byte,0xe4,'identity palette band is baked first') + T.eq(bands[2].rows[1],1,'LCD applies the prior backup byte starting one row after LY start') + T.eq(bands[2].rows[4],4,'LCD window includes its final scanline') + v:release() +end +for _,monByte in ipairs({0xe4,0x00,0xff,0xf8,0xfc,0x1b})do + for _,bgByte in ipairs({0xe4,0x00,0xff,0x1b})do + local colors={{0},{1},{2},{3}} + local expected=Palette.remap(Palette.remap(colors,monByte),bgByte) + local actual=Palette.bgpShades(Pictures.paletteByte(monByte,bgByte)) + for i=1,4 do T.eq(actual[i],expected[i][1],'native mon and BG palette composition retains index ordering')end + end +end +g.draw,g.newCanvas,g.rectangle=draw,newCanvas,rectangle +g.setShader();g.setCanvas();g.setColor(1,1,1,1);Palette.setBgp(nil);Version.set(previous) +T.finish('native GSC BG row presentation, wide composition, transparency and state restoration') diff --git a/tests/engine/gen2_native_bg_clock.lua b/tests/engine/gen2_native_bg_clock.lua index c8cb51d07..e60a10a37 100644 --- a/tests/engine/gen2_native_bg_clock.lua +++ b/tests/engine/gen2_native_bg_clock.lua @@ -1,9 +1,9 @@ --- Exercise real native VM commands and its actual BG register owner. --- Official main has no Gen2AnimView observation; no substitute runner is installed. +-- Exercise real native VM commands and the public animation observation. -- Expected picture and LCD traces come from the pinned GSC BG implementation. package.path='./?.lua;./?/init.lua;'..package.path local T=require('tests.modkit') local Player=require('src.battle.Gen2AnimPlayer') +local View=require('src.battle.Gen2AnimView') local Version=require('src.core.GameVersion') local previous=Version.get() local function data(code,constants) @@ -18,7 +18,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do {op='delay',frames=13},{op='ret'}, })) T.check(p:start('PROBE',playerTurn),'native VM starts the real test program') - local r=p + local r=View.runner(p);p.presentation=r T.eq(r.bg,p.bg,'observation exposes the actual register owner') local ticks=0;local playFrame=p.bg.playFrame p.bg.playFrame=function(self,...)ticks=ticks+1;return playFrame(self,...)end @@ -29,6 +29,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do local expected=stage<3 and((playerTurn and 2 or 5)-stage)or nil T.eq(r.bg.picSize[side],expected,'native command/wait path follows four-frame picture stages') T.eq(ticks,frame,'one existing VM update executes exactly one BG pass') + T.eq(r.frames,frame,'public time is the native VM time') for _=1,20 do local bg,frames=r.bg,r.frames end T.eq(ticks,frame,'repeated consumer observations never run the effect') end @@ -37,7 +38,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do T.eq(ticks,14,'legacy object-tail drain cannot keep advancing a stopped BG program') local old=r.bg p:start('PROBE',not playerTurn) - T.eq(old.picSize[side],nil,'previous register block keeps its completed picture trace') + T.eq(r.bg,old,'retired consumer retains the previous register block') T.check(p.bg~=old,'restart owns a fresh block rather than mutating retired state') T.eq(p.bg.env.battleTurn,playerTurn and 1 or 0,'restart binds the current attacker side') T.eq(p.bg.picSize.player,nil,'restart clears player picture stages') @@ -50,9 +51,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do local entry=p.bg.effects[1] T.eq(entry.func,edition=='crystal'and'BATTLE_BG_EFFECT_BODY_SLAM'or'BATTLE_BG_EFFECT_WOBBLE_MON', 'raw command resolves through the actual cartridge table') - local effect=p:pollEffects()[1] - T.eq(effect and effect.effect,edition=='crystal'and'SE_MOVE_MON_HORIZONTALLY'or'SE_SHAKE_BACK_AND_FORTH', - 'existing visual adapter also resolves Crystal insertion by effect name') + T.eq(#p:pollEffects(),0,'actual BG owner does not duplicate its motion through legacy SE effects') p:update();p:update() if edition=='crystal'then T.eq(p.bg.lyBackup[0x30],0xfe,'real VM drives the signed BODY_SLAM LCD trace')end p:release() diff --git a/tests/love_stub.lua b/tests/love_stub.lua index b7fc25fa9..7b61f0737 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -44,7 +44,15 @@ stub.graphics = { local w, h = pngSize(path) return setmetatable({ w = w, h = h, path = path }, Image) end, - newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end, + newQuad = function(x,y,w,h,sw,sh) + local quad={x=x,y=y,w=w,h=h,sw=sw,sh=sh} + function quad:setViewport(a,b,c,d,e,f) + self.x,self.y,self.w,self.h=a,b,c,d + self.sw,self.sh=e or self.sw,f or self.sh + end + function quad:getViewport()return self.x,self.y,self.w,self.h end + return quad + end, newCanvas = function(w, h) return setmetatable({ w = w, h = h, setFilter = noop }, Image) end, From 72f0152d36cfb1e6b134d43be04894f291ba44f6 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:18:42 +0100 Subject: [PATCH 35/64] Retain wide-panel fallback for native consumers without a backdrop provider --- src/battle/WideBattle.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 282ff03bb..71b4bcafc 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -477,7 +477,7 @@ local function drawPanel(battle,withObjects) else g.setColor(PaletteFX.paperShade(battle.data)) end - if not battle:worldBackdrop()then + if not(type(battle.worldBackdrop)=='function'and battle:worldBackdrop())then g.rectangle("fill",0,0,WideBattle.WIDTH,WideBattle.HEIGHT) end -- AskName clears the field the same way the classic layout does From bfa3c5bd268b7d5c04c1c335812e6482cec7528f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:21:30 +0100 Subject: [PATCH 36/64] Fold transient GSC BGP into actual native picture pixels with bounded register maps --- src/battle/BattleState.lua | 19 ++++++++++++++++++- tests/engine/gen2_display_modes.lua | 7 +++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a4eb11706..5b0558af5 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -875,6 +875,10 @@ local function imagePathOf(img) end -- the image a battler pic actually draws with this frame +-- The native image cache keeps static display bakes separate from transient +-- hardware BGP. Resolve that byte at the actual battle picture draw boundary. +-- At most 256 immutable index maps; no per-picture register-map allocation. +local gen2PicRegisterMaps={} function BattleState:picImage(img) local PaletteFX = require("src.render.PaletteFX") if GameVersion.isGen2() then @@ -913,7 +917,20 @@ function BattleState:picImage(img) if self.blackedOut or (self.introSlide or 0) > 0 then return blackImage(self.data, img) end - return fadeImage(img, self:activeBgp()) + img=fadeImage(img,self:activeBgp()) + if GameVersion.isGen2()then + local byte=PaletteFX.bgp() + if byte then + local map=gen2PicRegisterMaps[byte] + if not map then + local shades=PaletteFX.bgpShades(byte) + map={[0]=shades[1],shades[2],shades[3],shades[4]} + gen2PicRegisterMaps[byte]=map + end + img=fadeImage(img,map) + end + end + return img end -- Gen 1 trainer Pokémon have fixed DVs (engine/battle/core.asm); diff --git a/tests/engine/gen2_display_modes.lua b/tests/engine/gen2_display_modes.lua index a9b181046..05aef3eec 100644 --- a/tests/engine/gen2_display_modes.lua +++ b/tests/engine/gen2_display_modes.lua @@ -86,6 +86,13 @@ for _,edition in ipairs({'gold','silver','crystal'})do pixels(tiles.anims[1].textures[1],wanted,0,false,edition..' actual animated tile '..name) pixels(sprite:resolveImage(),wanted,0,true,edition..' retained native sprite '..name) pixels(battle:picImage(originalPic),wanted,0,false,edition..' retained native battle picture '..name) + G.setBgp(0x1b) + pixels(battle:picImage(originalPic),{wanted[4],wanted[3],wanted[2],wanted[1]},0,false, + edition..' actual transient BG register reaches baked battle pixels '..name) + battle.fx={bgp={[0]=3,2,1,0}} + pixels(battle:picImage(originalPic),wanted,0,false, + edition..' actual BG register composes after legacy picture fade '..name) + battle.fx=nil;G.setBgp(nil) battle.fx={bgp={[0]=3,2,1,0}} pixels(battle:picImage(originalPic),{wanted[4],wanted[3],wanted[2],wanted[1]},0,false,edition..' battle fade after display '..name) battle.fx=nil;battle.blackedOut=true From 7fc2adcf0fe120be24808acc1976d88da623af68 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:24:24 +0100 Subject: [PATCH 37/64] Consume native OBP0 in the actual GSC gray and yellow OAM palette path --- src/battle/Gen2AnimView.lua | 12 ++++-- tests/engine/gen2_bg_object_palette.lua | 51 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 tests/engine/gen2_bg_object_palette.lua diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index ff21a0533..d1a0f7d5c 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -1,6 +1,8 @@ -- Native object drawing over Gen2AnimPlayer's actual OAM projection. -- Object layout/flip math retains Gen2AnimPlayer provenance (Cedric); -- public owner integration by Roxas2712, see NOTICE.md and contribution licence. +-- GSC BG/OBJ register integration follows Gen1Recomp535a5ec0 under +-- compat/gen1-gsc/LICENSE.MD; inherited object-drawing rights remain intact. local Palette = require('src.render.Gen2PaletteDraw') local PaletteFX = require('src.render.PaletteFX') local View = {} @@ -58,16 +60,18 @@ function View:drawObjects(runner, battle) g.push('all') local ok, err = pcall(function() g.setColor(1,1,1,1) - local active + local active,activeByte for _, obj in ipairs(runner:oam()) do local sheet = self.data.gfx and self.data.gfx[obj.gfx] local image = sheet and self:image(sheet.image) local quad = image and self:quad(obj.gfx, obj.tile, image) if quad then local colors = self:objPalette(obj.palette, battle) - if colors ~= active then - if not Palette.useRaw(Palette.resolve(colors)) then g.setShader() end - active = colors + local slot=slots[obj.palette]or tonumber(obj.palette) + local byte=(slot==2 or slot==3)and runner.bg and runner.bg.obp0 or nil + if colors~=active or byte~=activeByte then + if not Palette.useRaw(Palette.remap(Palette.resolve(colors),byte))then g.setShader()end + active,activeByte=colors,byte end local attr = obj.attr or 0 local sx = math.floor(attr/32)%2 == 1 and -1 or 1 diff --git a/tests/engine/gen2_bg_object_palette.lua b/tests/engine/gen2_bg_object_palette.lua new file mode 100644 index 000000000..c9cc8b774 --- /dev/null +++ b/tests/engine/gen2_bg_object_palette.lua @@ -0,0 +1,51 @@ +-- Real native OBP command -> OAM -> indexed shader uniforms. Device records +-- observe the native draw without replacing animation or palette owners. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Player=require('src.battle.Gen2AnimPlayer') +local Palette=require('src.render.Gen2PaletteDraw') +local Version=require('src.core.GameVersion') +local g=love.graphics +local shader,draw=g.newShader,g.draw +local previous=Version.get() +g.newShader=function() + return {values={},send=function(self,name,value)self.values[name]=value end} +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} + local B={{245,239,227},{31,97,193},{167,59,131},{7,13,19}} + local C={{221,231,241},{71,81,91},{101,111,121},{11,21,31}} + local data={battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code={{op='obp0',args={0x1b}}, + {op='obj',args={0,64,48,0}},{op='obj',args={1,72,48,0}}, + {op='obj',args={2,80,48,0}},{op='delay',frames=2},{op='ret'}}}}, + objects={},functions={[0]='Null'},framesets={[0]={frames={{oam=0,duration=8}},loop='hold'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[1]={tiles=1,cols=1,image='native-obj'}},palettes={[0]=A,[1]=B,[2]=C}}} + for id=0,2 do data.battle_anims.objects[id]={fn=0,frameset=0,palette=id+2,gfx=1}end + local p=Player.new(data);T.check(p:start('PROBE',true),'native object-palette program starts') + p:update();T.eq(p.bg.obp0,0x1b,'native command owns the actual OBP0 register') + local records={} + g.draw=function() + local active=g.getShader();local row={} + for i=0,3 do row[i+1]={unpack(active.values['pal'..i])}end + records[#records+1]=row + end + local external={};g.setShader(external) + Palette.setBgp(0x00) -- the already-coloured BG register must not fade OBJs + local frame=p.frames;p:draw() + T.eq(#records,3,'all three actual native OAM objects are drawn') + for index,colors in ipairs({A,B,C})do + local expected=Palette.remap(Palette.resolve(colors),index<=2 and 0x1b or nil) + for i=1,4 do + T.same(records[index][i],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255}, + 'only native gray/yellow OBJ slots consume OBP0, with exact RGB uniforms') + end + end + T.eq(p.frames,frame,'OBJ palette drawing never advances the VM') + T.eq(g.getShader(),external,'OBJ pass restores caller shader') + Palette.setBgp(nil);g.setShader();p:release() +end +g.newShader,g.draw=shader,draw;Version.set(previous) +T.finish('native GSC OBP command, OAM palette selection and exact shader uniforms') From 7d8cd141ea808648bfc5e5d8d11fe63563f59639 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:38:46 +0100 Subject: [PATCH 38/64] Compose public picture-state overrides with the actual native BG observation --- src/battle/Gen2BgPictures.lua | 24 +++++++++++++----------- tests/engine/gen2_bg_presenter.lua | 7 +++++++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/battle/Gen2BgPictures.lua b/src/battle/Gen2BgPictures.lua index fb2651ff6..c06a956a7 100644 --- a/src/battle/Gen2BgPictures.lua +++ b/src/battle/Gen2BgPictures.lua @@ -29,16 +29,18 @@ local function withPalette(screen,side,body) if not ok then error(why,0)end end function M.install(Screen) - -- Older native screens need the same live observation; this reads their - -- actual battler and BG owners rather than constructing another model. - if not Screen.animPicState then - function Screen:animPicState(side) - local bg=self.anim and self.anim.bg - if not bg then return nil end - return {hidden=bg.hidden[side],size=bg.picSize[side], - shade=bg.monShade[side],slide=bg.slide[side]or 0, - lifted=bg.liftedRows[side]} - end + -- Compose the native picture observation with the real BG owner. A later + -- public method override remains visible to the actual picture drawer. + local read=Screen.animPicState + function Screen:animPicState(side) + local state=read and read(self,side) + local bg=self.anim and self.anim.bg + if not bg then return state end + state=state or {} + state.hidden=state.hidden or bg.hidden[side] + state.size,state.shade=bg.picSize[side],bg.monShade[side] + state.slide,state.lifted=bg.slide[side]or 0,bg.liftedRows[side] + return state end local draw=Screen.drawBattlerPic function Screen:drawBattlerPic(b,x,y,scale) @@ -49,7 +51,7 @@ function M.install(Screen) local state=self:animPicState(side) if state and state.hidden then return end if self.liftedPass and not(state and state.lifted)then return end - if not self.liftedPass then x=x+(self.anim.bg.slide[side] or 0)end + if not self.liftedPass then x=x+(state and state.slide or 0)end local size=state and SIZES[state.size] if size then local image=self:battlerPic(b) diff --git a/tests/engine/gen2_bg_presenter.lua b/tests/engine/gen2_bg_presenter.lua index 1a243e48f..147e85ea7 100644 --- a/tests/engine/gen2_bg_presenter.lua +++ b/tests/engine/gen2_bg_presenter.lua @@ -83,6 +83,13 @@ for _,edition in ipairs({'gold','silver','crystal'})do T.eq(row.quad,10+math.floor(w*(1-1/3)/2),'entry picture compensation stays on integer native pixels') T.eq(row.x,20+math.floor(h*(1-1/3)),'entry keeps its bottom edge pinned to the native box') local bg=s.anim.bg;bg.picSize.player=nil + local read=s.animPicState + s.animPicState=function(self,side) + local state=read(self,side);state.slide=3;return state + end + calls={};s:drawBattlerPic(b,10,20,1) + T.eq(calls[#calls].quad,13,'public picture-state slide override reaches the actual native draw') + s.animPicState=read bg.monShade.player=0xf8;bg.monShade.enemy=0xfc Palette.setBgp(0x1b);calls={} if s.drawPic then s:drawPic(b.mon,false)else s:drawBattlerPic(b,10,20,1)end From b38daddde51b4eafa1b37524683bf5c8ea1f4cd1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:47:10 +0100 Subject: [PATCH 39/64] Keep GSC picture hooks composed after native view installation --- src/battle/BattleState.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 5b0558af5..690b5ceac 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -13047,6 +13047,8 @@ end require('src.battle.Gen2MovePresentation').install(BattleState) require('src.battle.Gen2BattleScene').install(BattleState) + +-- Compose after native/source view installers, retaining their real draw owners. require('src.battle.Gen2BgPictures').install(BattleState) return BattleState From ec40dddc6f820032ea4b30c4d49a65f66bcef931 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:20:06 +0100 Subject: [PATCH 40/64] Execute native GSC commands and object state machines through one real VM --- compat/gen1-gsc/provenance.json | 14 +- src/battle/Gen2AnimData.lua | 83 + src/battle/Gen2AnimObjectIds.lua | 600 +++++++ src/battle/Gen2AnimPlayer.lua | 901 ++-------- src/battle/Gen2AnimView.lua | 61 +- src/battle/gen2/AnimObjects.lua | 2023 ++++++++++++++++++++++ src/battle/gen2/AnimRunner.lua | 485 ++++++ src/import/RomExtractorGen2.lua | 13 +- src/import/RomImporter.lua | 4 + tests/engine/gen2_animation_identity.lua | 10 +- tests/engine/gen2_animation_objects.lua | 28 +- tests/engine/gen2_bg_object_palette.lua | 2 +- tests/engine/gen2_native_bg_clock.lua | 7 +- 13 files changed, 3362 insertions(+), 869 deletions(-) create mode 100644 src/battle/Gen2AnimData.lua create mode 100644 src/battle/Gen2AnimObjectIds.lua create mode 100644 src/battle/gen2/AnimObjects.lua create mode 100644 src/battle/gen2/AnimRunner.lua diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json index 33c3e99c5..d8931f7b6 100644 --- a/compat/gen1-gsc/provenance.json +++ b/compat/gen1-gsc/provenance.json @@ -11,17 +11,21 @@ "LICENSE.MD": "b369ce45656b3d87453541816fac6982b13e09ea0734ab9cfe61cf3faf5e95ed", "src/ui/gen2/BattleAnimView.lua": "c67613148f14987bdc76d01ace2cd90e89f7d742e4c4a0e95889707f5a9d0e61", "src/ui/gen2/BattleState.lua": "6789c83e5124701d7ebfb1bdc74389102fa85c02f5ecdb812baf0905bded723c", - "src/ui/gen2/Chrome.lua": "a81231beac5334eae58df0936fed1f4fec90b3d986e8caf8b31b5e522180dc52" + "src/ui/gen2/Chrome.lua": "a81231beac5334eae58df0936fed1f4fec90b3d986e8caf8b31b5e522180dc52", + "src/battle/gen2/AnimRunner.lua": "4f31c23a94e14d5796a125237ab205342e6b3b63134915cda67373005536ce71" }, - "scope": "BgEffects, BG presenter, arithmetic subset and BG-effect identifier lists. No launcher, game assets, ROM bytes or player state copied.", + "scope": "GSC BG and object state machines, command VM, BG presenter, arithmetic and identifier orders. No launcher, game assets, ROM bytes or player state copied.", "modifications": [ "BgEffects arithmetic dependency uses Gen2AnimMath instead of the full reference object pool.", "A missing jump-table list resolves through edition-specific Gen2BgEffectIds for older native caches.", "Existing Gen2AnimPlayer dispatches BG/palette commands and steps the pool from its current VM update. Native scene and picture owners consume these registers directly, replacing the legacy SE approximations.", "BG presenter uses native palette/chrome/canvas owners, supports the real wide surface, preserves transparency and restores graphics state on supplied-panel errors.", - "Battler resize, hide, palette and lifted-row drawing remain attached to native image owners. Object function motion and complete send-out event sequencing remain separate work.", - "The BG-only paper and clipping subset uses the native indexed palette owner, without requiring the full menu Chrome module on older engines." + "Battler resize, hide, palette and lifted-row drawing remain attached to native image owners. The genuine object pool and command VM now drive native OAM; complete send-out event sequencing remains separate work.", + "The BG-only paper and clipping subset uses the native indexed palette owner, without requiring the full menu Chrome module on older engines.", + "Full object pool uses the already pinned native Gen2AnimMath sine/cosine subset. Native imported numeric animation tables are converted without inventing ROM programs or changing their operands.", + "Existing native animation player now uses the complete command interpreter and object pool as its sole clock. The native view consumes emitted OAM through the actual loaded GFX dictionary; no interpolated motion, 48-frame age cap or 16-frame drain remains.", + "The native importer preserves the crossed battler-row macro/handler names. A Gen2-only cache revision rejects old generated commands and preserves game saves." ], "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", - "qualification": "CPU pool, SDK entry, native VM and classic/wide BG rendering contracts tested. One unchanged-VASC Crystal Metal battle reached a native faint, win, EXP award and world return with real window captures on ce12c485. Full object motion, send-out sequencing, all effects GPU frames, other platforms and release qualification remain required." + "qualification": "ROM-free actual-pixel counterexample fails 366 checks on the preceding native player; replacement passes. Initial actual Crystal ROM comparison matches 251 move and 23 extra-animation programs on both sides with four parameters (2192 cases); six high-parameter cases reach the observation limit equally on both interpreters. Expanded traces, new GPU frames, Windows and packaged release qualification remain pending. Earlier Metal battle ce12c485 predates the object/VM replacement." } diff --git a/src/battle/Gen2AnimData.lua b/src/battle/Gen2AnimData.lua new file mode 100644 index 000000000..c178162bc --- /dev/null +++ b/src/battle/Gen2AnimData.lua @@ -0,0 +1,83 @@ +-- Native imported animation tables -> genuine GSC runtime data. +-- Identifier/command formats derive from Gen1Recomp535a5ec0 under GPLv3 +-- plus Additional Terms; see compat/gen1-gsc/. No ROM program is invented. +local Ids=require('src.battle.Gen2AnimObjectIds') +local M={} +M.CACHE_REVISION=1 +local function order(constants,key) + return constants and constants[key] or Ids[key] +end +function M.tables(anims,constants) + anims=anims or {} + local c={} + for _,key in ipairs({'battleAnimFramesetOrder','battleAnimObjectOrder', + 'battleAnimFuncOrder','battleAnimGfxOrder','battleAnimObPaletteOrder'})do c[key]=order(constants,key)end + c.battleBgEffectOrder=constants and constants.battleBgEffectOrder + or require('src.battle.Gen2BgEffectIds').forVersion() + local data={objects={},framesets={},oamsets={},gfx={},palettes=anims.palettes} + for id,row in pairs(anims.objects or {})do + local name=c.battleAnimObjectOrder[id+1]or id + local native=anims.functions and anims.functions[row.fn] + local fn=constants and constants.battleAnimFuncOrder + and constants.battleAnimFuncOrder[(row.fn or 0)+1] + or Ids.nativeFunctions[native] or native + or c.battleAnimFuncOrder[(row.fn or 0)+1] + data.objects[name]={flags=row.oamFlags,fixY=row.fixY, + frameset=c.battleAnimFramesetOrder[row.frameset+1]or row.frameset, + func=fn,palette=c.battleAnimObPaletteOrder[(row.palette or 0)+1]or row.palette, + gfx=c.battleAnimGfxOrder[(row.gfx or 0)+1]or row.gfx} + end + for id,set in pairs(anims.framesets or {})do + local rows={} + for _,frame in ipairs(set.frames or {})do + rows[#rows+1]=frame.wait and {'wait',frame.duration or 0} + or {'frame',frame.oam,frame.duration or 0, + (frame.xflip and 0x20 or 0)+(frame.yflip and 0x40 or 0)} + end + rows[#rows+1]={set.loop=='restart'and 'restart' + or set.loop=='delete'and 'delete'or 'end'} + data.framesets[c.battleAnimFramesetOrder[id+1]or id]=rows + end + for id,row in pairs(anims.oam or {})do + data.oamsets[id]={vtile=row.tile,sprites=row.sprites} + end + for id,sheet in pairs(anims.gfx or {})do + data.gfx[c.battleAnimGfxOrder[id+1]or id]=sheet + end + return data,c +end + +local ops={delay='wait',ifvarequal='if_var_equal', + ifparamequal='if_param_equal',ifparamand='if_param_and'} +-- Native scripts already contain all reached subroutines in one flat ROM +-- walk. A target remains that walk's instruction index; suffix lists let the +-- GSC interpreter fetch it without synthesizing addresses or instructions. +function M.program(tables,script) + local data={} + for key,value in pairs(tables)do data[key]=value end + data.scripts={} + if not script then return data end + local rows,targets={},{} + targets[script.entry or 1]=true + for i,inst in ipairs(script.code or {})do + local op=ops[inst.op]or inst.op + local row={op} + for _,value in ipairs(inst.args or {})do row[#row+1]=type(value)=='number'and value%256 or value end + if op=='wait'then row[2]=inst.frames or 0 + elseif op=='gfx'then + row={tostring(#(inst.ids or {}))..'gfx'} + for _,id in ipairs(inst.ids or {})do row[#row+1]=id end + elseif op=='jump'or op=='call'or op=='jumpuntil'then row[2]=inst.to + elseif op=='loop'or op=='if_var_equal'or op=='if_param_equal' + or op=='if_param_and'then row[3]=inst.to end + if inst.to then targets[inst.to]=true end + rows[i]=row + end + for start in pairs(targets)do + local suffix={} + for i=start,#rows do suffix[#suffix+1]=rows[i]end + data.scripts[start]=suffix + end + return data,script.entry or 1 +end +return M diff --git a/src/battle/Gen2AnimObjectIds.lua b/src/battle/Gen2AnimObjectIds.lua new file mode 100644 index 000000000..15c35121b --- /dev/null +++ b/src/battle/Gen2AnimObjectIds.lua @@ -0,0 +1,600 @@ +-- GSC identifier order from pinned Gen1Recomp535a5ec0 manifests. +-- GPLv3 plus Additional Terms; compat/gen1-gsc/LICENSE.MD. +-- Gold, Silver and Crystal have identical orders for these tables. +local M={} +M.battleAnimFramesetOrder={ + "BATTLE_ANIM_FRAMESET_HIT_BIG", + "BATTLE_ANIM_FRAMESET_HIT", + "BATTLE_ANIM_FRAMESET_HIT_SMALL", + "BATTLE_ANIM_FRAMESET_PUNCH", + "BATTLE_ANIM_FRAMESET_KICK", + "BATTLE_ANIM_FRAMESET_PALM", + "BATTLE_ANIM_FRAMESET_FANG", + "BATTLE_ANIM_FRAMESET_PUNCH_SHAKE", + "BATTLE_ANIM_FRAMESET_BALL_POOF", + "BATTLE_ANIM_FRAMESET_POKE_BALL_1", + "BATTLE_ANIM_FRAMESET_POKE_BALL_2", + "BATTLE_ANIM_FRAMESET_POKE_BALL_3", + "BATTLE_ANIM_FRAMESET_POKE_BALL_4", + "BATTLE_ANIM_FRAMESET_POKE_BALL_5", + "BATTLE_ANIM_FRAMESET_DRAGON_RAGE", + "BATTLE_ANIM_FRAMESET_FLAMETHROWER", + "BATTLE_ANIM_FRAMESET_EMBER", + "BATTLE_ANIM_FRAMESET_BURNED", + "BATTLE_ANIM_FRAMESET_BLIZZARD", + "BATTLE_ANIM_FRAMESET_ICE", + "BATTLE_ANIM_FRAMESET_ICE_BEAM", + "BATTLE_ANIM_FRAMESET_POWDER_SNOW", + "BATTLE_ANIM_FRAMESET_RAZOR_LEAF_1", + "BATTLE_ANIM_FRAMESET_RAZOR_LEAF_2", + "BATTLE_ANIM_FRAMESET_EXPLOSION", + "BATTLE_ANIM_FRAMESET_BIG_ROCK", + "BATTLE_ANIM_FRAMESET_SMALL_ROCK", + "BATTLE_ANIM_FRAMESET_STRENGTH", + "BATTLE_ANIM_FRAMESET_SKULL_CROSSBONE", + "BATTLE_ANIM_FRAMESET_ACID", + "BATTLE_ANIM_FRAMESET_POISON_DROPLET_UNUSED", + "BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE", + "BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE_BURST", + "BATTLE_ANIM_FRAMESET_SMALL_BUBBLE", + "BATTLE_ANIM_FRAMESET_PULSING_BUBBLE", + "BATTLE_ANIM_FRAMESET_SURF", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_2", + "BATTLE_ANIM_FRAMESET_MUSIC_NOTE_3", + "BATTLE_ANIM_FRAMESET_WATER_GUN_1", + "BATTLE_ANIM_FRAMESET_WATER_GUN_2", + "BATTLE_ANIM_FRAMESET_WATER_GUN_3", + "BATTLE_ANIM_FRAMESET_HYDRO_PUMP", + "BATTLE_ANIM_FRAMESET_POWDER", + "BATTLE_ANIM_FRAMESET_BEAM", + "BATTLE_ANIM_FRAMESET_BEAM_TIP", + "BATTLE_ANIM_FRAMESET_ICE_BUILDUP", + "BATTLE_ANIM_FRAMESET_FROZEN", + "BATTLE_ANIM_FRAMESET_CIRCLING_SPARKLE", + "BATTLE_ANIM_FRAMESET_THUNDER_CENTER", + "BATTLE_ANIM_FRAMESET_THUNDER_LEFT", + "BATTLE_ANIM_FRAMESET_THUNDER_RIGHT", + "BATTLE_ANIM_FRAMESET_THUNDER_WAVE_DISABLE", + "BATTLE_ANIM_FRAMESET_THUNDER_WAVE_EXTRA", + "BATTLE_ANIM_FRAMESET_THUNDERBOLT_SPARKS", + "BATTLE_ANIM_FRAMESET_THUNDERBOLT_CORE", + "BATTLE_ANIM_FRAMESET_THUNDERSHOCK_SPARKS", + "BATTLE_ANIM_FRAMESET_THUNDERSHOCK_CORE", + "BATTLE_ANIM_FRAMESET_CLAMP", + "BATTLE_ANIM_FRAMESET_CLAMP_FLIPPED", + "BATTLE_ANIM_FRAMESET_BITE_1", + "BATTLE_ANIM_FRAMESET_BITE_2", + "BATTLE_ANIM_FRAMESET_CUT_DOWN_LEFT", + "BATTLE_ANIM_FRAMESET_CUT_DOWN_RIGHT", + "BATTLE_ANIM_FRAMESET_CUT_UP_RIGHT", + "BATTLE_ANIM_FRAMESET_CUT_LONG_DOWN_LEFT", + "BATTLE_ANIM_FRAMESET_CUT_LONG_DOWN_RIGHT", + "BATTLE_ANIM_FRAMESET_CHARGE_ORB_1", + "BATTLE_ANIM_FRAMESET_ABSORB_CENTER", + "BATTLE_ANIM_FRAMESET_GUST", + "BATTLE_ANIM_FRAMESET_VINE_WHIP_1", + "BATTLE_ANIM_FRAMESET_VINE_WHIP_2", + "BATTLE_ANIM_FRAMESET_RAZOR_WIND_1", + "BATTLE_ANIM_FRAMESET_RAZOR_WIND_2", + "BATTLE_ANIM_FRAMESET_SONICBOOM_JP", + "BATTLE_ANIM_FRAMESET_WARP", + "BATTLE_ANIM_FRAMESET_CHARGE_ORB_2", + "BATTLE_ANIM_FRAMESET_EGG", + "BATTLE_ANIM_FRAMESET_EGG_WOBBLE", + "BATTLE_ANIM_FRAMESET_EGG_CRACKED_TOP", + "BATTLE_ANIM_FRAMESET_EGG_CRACKED_BOTTOM", + "BATTLE_ANIM_FRAMESET_FOCUS", + "BATTLE_ANIM_FRAMESET_BIND_1", + "BATTLE_ANIM_FRAMESET_BIND_2", + "BATTLE_ANIM_FRAMESET_BIND_3", + "BATTLE_ANIM_FRAMESET_BIND_4", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_1", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_2", + "BATTLE_ANIM_FRAMESET_LEECH_SEED_3", + "BATTLE_ANIM_FRAMESET_SOUND_1", + "BATTLE_ANIM_FRAMESET_SOUND_2", + "BATTLE_ANIM_FRAMESET_SOUND_3", + "BATTLE_ANIM_FRAMESET_WAVE", + "BATTLE_ANIM_FRAMESET_CONFUSE_RAY_1", + "BATTLE_ANIM_FRAMESET_CONFUSE_RAY_2", + "BATTLE_ANIM_FRAMESET_LEER", + "BATTLE_ANIM_FRAMESET_REFLECT", + "BATTLE_ANIM_FRAMESET_CHICK_1", + "BATTLE_ANIM_FRAMESET_CHICK_2", + "BATTLE_ANIM_FRAMESET_AMNESIA_1", + "BATTLE_ANIM_FRAMESET_AMNESIA_2", + "BATTLE_ANIM_FRAMESET_AMNESIA_3", + "BATTLE_ANIM_FRAMESET_ASLEEP", + "BATTLE_ANIM_FRAMESET_DIG_SAND", + "BATTLE_ANIM_FRAMESET_DIG_PILE", + "BATTLE_ANIM_FRAMESET_SAND", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_1", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_2", + "BATTLE_ANIM_FRAMESET_STRING_SHOT_3", + "BATTLE_ANIM_FRAMESET_PARALYZED", + "BATTLE_ANIM_FRAMESET_PARALYZED_FLIPPED", + "BATTLE_ANIM_FRAMESET_HAZE", + "BATTLE_ANIM_FRAMESET_MIST", + "BATTLE_ANIM_FRAMESET_HORN", + "BATTLE_ANIM_FRAMESET_NEEDLE", + "BATTLE_ANIM_FRAMESET_FLOWER", + "BATTLE_ANIM_FRAMESET_BARRAGE_BALL", + "BATTLE_ANIM_FRAMESET_PAY_DAY", + "BATTLE_ANIM_FRAMESET_HEART", + "BATTLE_ANIM_FRAMESET_SPINNING_BONE", + "BATTLE_ANIM_FRAMESET_STAR", + "BATTLE_ANIM_FRAMESET_SPOON", + "BATTLE_ANIM_FRAMESET_SPARKLE", + "BATTLE_ANIM_FRAMESET_SKY_ATTACK", + "BATTLE_ANIM_FRAMESET_LICK", + "BATTLE_ANIM_FRAMESET_WITHDRAW_SHELL", + "BATTLE_ANIM_FRAMESET_SHRINKING_CHARGE_ORB", + "BATTLE_ANIM_FRAMESET_CONVERSION", + "BATTLE_ANIM_FRAMESET_SWORD", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_1", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_2", + "BATTLE_ANIM_FRAMESET_SPEED_LINE_3", + "BATTLE_ANIM_FRAMESET_SEISMIC_TOSS", + "BATTLE_ANIM_FRAMESET_SHARPEN", + "BATTLE_ANIM_FRAMESET_DEFENSE_CURL", + "BATTLE_ANIM_FRAMESET_METRONOME_HAND", + "BATTLE_ANIM_FRAMESET_AGILITY", + "BATTLE_ANIM_FRAMESET_COTTON", + "BATTLE_ANIM_FRAMESET_MILK_BOTTLE", + "BATTLE_ANIM_FRAMESET_SPIKE", + "BATTLE_ANIM_FRAMESET_ANGER_VEIN", + "BATTLE_ANIM_FRAMESET_HEAL_BELL", + "BATTLE_ANIM_FRAMESET_BATON_PASS", + "BATTLE_ANIM_FRAMESET_LOCK_ON_1", + "BATTLE_ANIM_FRAMESET_LOCK_ON_2", + "BATTLE_ANIM_FRAMESET_LOCK_ON_3", + "BATTLE_ANIM_FRAMESET_LOCK_ON_4", + "BATTLE_ANIM_FRAMESET_MIND_READER_1", + "BATTLE_ANIM_FRAMESET_MIND_READER_2", + "BATTLE_ANIM_FRAMESET_MIND_READER_3", + "BATTLE_ANIM_FRAMESET_MIND_READER_4", + "BATTLE_ANIM_FRAMESET_SAFEGUARD", + "BATTLE_ANIM_FRAMESET_MEGAPHONE_UNUSED", + "BATTLE_ANIM_FRAMESET_ITEM_BAG", + "BATTLE_ANIM_FRAMESET_SPIDER_WEB", + "BATTLE_ANIM_FRAMESET_UNUSED_CAKE", + "BATTLE_ANIM_FRAMESET_IMP", + "BATTLE_ANIM_FRAMESET_IMP_FLIPPED", + "BATTLE_ANIM_FRAMESET_CHERUB", + "BATTLE_ANIM_FRAMESET_PENCIL", + "BATTLE_ANIM_FRAMESET_ENCORE_HAND", + "BATTLE_ANIM_FRAMESET_ENCORE_HAND_FLIPPED", + "BATTLE_ANIM_FRAMESET_DESTINY_BOND", + "BATTLE_ANIM_FRAMESET_MORNING_SUN", + "BATTLE_ANIM_FRAMESET_GLIMMER", + "BATTLE_ANIM_FRAMESET_MOONLIGHT", + "BATTLE_ANIM_FRAMESET_CROSS_CHOP_1", + "BATTLE_ANIM_FRAMESET_CROSS_CHOP_2", + "BATTLE_ANIM_FRAMESET_ZAP_CANNON", + "BATTLE_ANIM_FRAMESET_CURSE_NAIL", + "BATTLE_ANIM_FRAMESET_FORESIGHT_SHINE", + "BATTLE_ANIM_FRAMESET_RAPID_SPIN", + "BATTLE_ANIM_FRAMESET_SWAGGER", + "BATTLE_ANIM_FRAMESET_MEAN_LOOK", + "BATTLE_ANIM_FRAMESET_UPSIDE_DOWN_PUNCH", + "BATTLE_ANIM_FRAMESET_GROWING_SPARKLE", + "BATTLE_ANIM_FRAMESET_RAIN", + "BATTLE_ANIM_FRAMESET_PSYCH_UP", + "BATTLE_ANIM_FRAMESET_SPIRAL_UNUSED", + "BATTLE_ANIM_FRAMESET_AEROBLAST", + "BATTLE_ANIM_FRAMESET_SANDSTORM", + "BATTLE_ANIM_FRAMESET_ENEMYFEET_1ROW", + "BATTLE_ANIM_FRAMESET_PLAYERHEAD_1ROW", + "BATTLE_ANIM_FRAMESET_ENEMYFEET_2ROW", + "BATTLE_ANIM_FRAMESET_PLAYERHEAD_2ROW", +} +M.battleAnimObjectOrder={ + "BATTLE_ANIM_OBJ_HIT_BIG_YFIX", + "BATTLE_ANIM_OBJ_HIT_YFIX", + "BATTLE_ANIM_OBJ_HIT_SMALL_YFIX", + "BATTLE_ANIM_OBJ_HIT_BIG", + "BATTLE_ANIM_OBJ_HIT", + "BATTLE_ANIM_OBJ_HIT_SMALL", + "BATTLE_ANIM_OBJ_PUNCH", + "BATTLE_ANIM_OBJ_KICK", + "BATTLE_ANIM_OBJ_PALM", + "BATTLE_ANIM_OBJ_FANG", + "BATTLE_ANIM_OBJ_PUNCH_SHAKE", + "BATTLE_ANIM_OBJ_EMBER", + "BATTLE_ANIM_OBJ_DRAGON_RAGE", + "BATTLE_ANIM_OBJ_FLAMETHROWER", + "BATTLE_ANIM_OBJ_FIRE_SPIN", + "BATTLE_ANIM_OBJ_FIRE_BLAST", + "BATTLE_ANIM_OBJ_BURNED", + "BATTLE_ANIM_OBJ_BLIZZARD", + "BATTLE_ANIM_OBJ_ICE", + "BATTLE_ANIM_OBJ_ICE_BEAM", + "BATTLE_ANIM_OBJ_RAZOR_LEAF", + "BATTLE_ANIM_OBJ_POKE_BALL", + "BATTLE_ANIM_OBJ_POKE_BALL_BLOCKED", + "BATTLE_ANIM_OBJ_EXPLOSION1", + "BATTLE_ANIM_OBJ_EXPLOSION2", + "BATTLE_ANIM_OBJ_ACID", + "BATTLE_ANIM_OBJ_SLUDGE", + "BATTLE_ANIM_OBJ_BETA_BALL_POOF", + "BATTLE_ANIM_OBJ_BALL_POOF", + "BATTLE_ANIM_OBJ_BIG_ROCK", + "BATTLE_ANIM_OBJ_SMALL_ROCK", + "BATTLE_ANIM_OBJ_STRENGTH", + "BATTLE_ANIM_OBJ_SEISMIC_TOSS", + "BATTLE_ANIM_OBJ_BUBBLE", + "BATTLE_ANIM_OBJ_SURF", + "BATTLE_ANIM_OBJ_SING", + "BATTLE_ANIM_OBJ_WATER_GUN", + "BATTLE_ANIM_OBJ_HYDRO_PUMP", + "BATTLE_ANIM_OBJ_POWDER", + "BATTLE_ANIM_OBJ_BEAM", + "BATTLE_ANIM_OBJ_BEAM_TIP", + "BATTLE_ANIM_OBJ_ICE_BUILDUP", + "BATTLE_ANIM_OBJ_FROZEN", + "BATTLE_ANIM_OBJ_MASTER_BALL_SPARKLE", + "BATTLE_ANIM_OBJ_RECOVER", + "BATTLE_ANIM_OBJ_THUNDER_CENTER", + "BATTLE_ANIM_OBJ_THUNDER_LEFT", + "BATTLE_ANIM_OBJ_THUNDER_RIGHT", + "BATTLE_ANIM_OBJ_THUNDER_WAVE", + "BATTLE_ANIM_OBJ_SPARKS_CIRCLE_BIG", + "BATTLE_ANIM_OBJ_THUNDERBOLT_BALL", + "BATTLE_ANIM_OBJ_SPARKS_CIRCLE", + "BATTLE_ANIM_OBJ_THUNDERSHOCK_BALL", + "BATTLE_ANIM_OBJ_CLAMP", + "BATTLE_ANIM_OBJ_BITE", + "BATTLE_ANIM_OBJ_CUT_DOWN_LEFT", + "BATTLE_ANIM_OBJ_CUT_DOWN_RIGHT", + "BATTLE_ANIM_OBJ_CUT_UP_RIGHT", + "BATTLE_ANIM_OBJ_CUT_LONG_DOWN_LEFT", + "BATTLE_ANIM_OBJ_CUT_LONG_DOWN_RIGHT", + "BATTLE_ANIM_OBJ_SOLAR_BEAM_CHARGE", + "BATTLE_ANIM_OBJ_ABSORB_CENTER", + "BATTLE_ANIM_OBJ_GUST", + "BATTLE_ANIM_OBJ_VINE_WHIP1", + "BATTLE_ANIM_OBJ_VINE_WHIP2", + "BATTLE_ANIM_OBJ_RAZOR_WIND1", + "BATTLE_ANIM_OBJ_RAZOR_WIND2", + "BATTLE_ANIM_OBJ_SONICBOOM_JP", + "BATTLE_ANIM_OBJ_WARP", + "BATTLE_ANIM_OBJ_ABSORB", + "BATTLE_ANIM_OBJ_EGG", + "BATTLE_ANIM_OBJ_FOCUS", + "BATTLE_ANIM_OBJ_BIND1", + "BATTLE_ANIM_OBJ_BIND2", + "BATTLE_ANIM_OBJ_LEECH_SEED", + "BATTLE_ANIM_OBJ_SOUND", + "BATTLE_ANIM_OBJ_WAVE", + "BATTLE_ANIM_OBJ_CONFUSE_RAY", + "BATTLE_ANIM_OBJ_LEER", + "BATTLE_ANIM_OBJ_LEER_TIP", + "BATTLE_ANIM_OBJ_SCREEN", + "BATTLE_ANIM_OBJ_HARDEN", + "BATTLE_ANIM_OBJ_CHICK", + "BATTLE_ANIM_OBJ_AMNESIA", + "BATTLE_ANIM_OBJ_ASLEEP", + "BATTLE_ANIM_OBJ_SKULL", + "BATTLE_ANIM_OBJ_DIG_SAND", + "BATTLE_ANIM_OBJ_DIG_PILE", + "BATTLE_ANIM_OBJ_SAND", + "BATTLE_ANIM_OBJ_PARALYZED", + "BATTLE_ANIM_OBJ_STRING_SHOT", + "BATTLE_ANIM_OBJ_HAZE", + "BATTLE_ANIM_OBJ_MIST", + "BATTLE_ANIM_OBJ_SMOG", + "BATTLE_ANIM_OBJ_POISON_GAS", + "BATTLE_ANIM_OBJ_HORN", + "BATTLE_ANIM_OBJ_NEEDLE", + "BATTLE_ANIM_OBJ_PETAL_DANCE", + "BATTLE_ANIM_OBJ_SLUDGE_BOMB", + "BATTLE_ANIM_OBJ_PAY_DAY", + "BATTLE_ANIM_OBJ_SONICBOOM_JP_UNUSED", + "BATTLE_ANIM_OBJ_MIMIC", + "BATTLE_ANIM_OBJ_ATTRACT", + "BATTLE_ANIM_OBJ_BONEMERANG", + "BATTLE_ANIM_OBJ_BONE_CLUB", + "BATTLE_ANIM_OBJ_BONE_RUSH", + "BATTLE_ANIM_OBJ_SWIFT", + "BATTLE_ANIM_OBJ_KINESIS", + "BATTLE_ANIM_OBJ_FLASH", + "BATTLE_ANIM_OBJ_SHINY", + "BATTLE_ANIM_OBJ_SKY_ATTACK", + "BATTLE_ANIM_OBJ_LICK", + "BATTLE_ANIM_OBJ_WITHDRAW", + "BATTLE_ANIM_OBJ_DRAIN", + "BATTLE_ANIM_OBJ_GROWTH", + "BATTLE_ANIM_OBJ_CONVERSION2", + "BATTLE_ANIM_OBJ_SMOKE", + "BATTLE_ANIM_OBJ_SMOKESCREEN", + "BATTLE_ANIM_OBJ_SWORDS_DANCE", + "BATTLE_ANIM_OBJ_SPEED_LINE", + "BATTLE_ANIM_OBJ_SHARPEN", + "BATTLE_ANIM_OBJ_DEFENSE_CURL", + "BATTLE_ANIM_OBJ_METRONOME_HAND", + "BATTLE_ANIM_OBJ_METRONOME_SPARKLE", + "BATTLE_ANIM_OBJ_DISABLE", + "BATTLE_ANIM_OBJ_AGILITY", + "BATTLE_ANIM_OBJ_HEART", + "BATTLE_ANIM_OBJ_FLAME_WHEEL", + "BATTLE_ANIM_OBJ_SACRED_FIRE", + "BATTLE_ANIM_OBJ_COTTON_SPORE", + "BATTLE_ANIM_OBJ_MILK_DRINK", + "BATTLE_ANIM_OBJ_ANGER", + "BATTLE_ANIM_OBJ_HEAL_BELL", + "BATTLE_ANIM_OBJ_HEAL_BELL_NOTE", + "BATTLE_ANIM_OBJ_BATON_PASS", + "BATTLE_ANIM_OBJ_LOCK_ON", + "BATTLE_ANIM_OBJ_MIND_READER", + "BATTLE_ANIM_OBJ_SAFEGUARD", + "BATTLE_ANIM_OBJ_PROTECT", + "BATTLE_ANIM_OBJ_THIEF", + "BATTLE_ANIM_OBJ_OCTAZOOKA", + "BATTLE_ANIM_OBJ_PRESENT", + "BATTLE_ANIM_OBJ_SPIKES", + "BATTLE_ANIM_OBJ_POWDER_SNOW", + "BATTLE_ANIM_OBJ_DRAGONBREATH", + "BATTLE_ANIM_OBJ_CONVERSION", + "BATTLE_ANIM_OBJ_SPIDER_WEB", + "BATTLE_ANIM_OBJ_CAKE_UNUSED", + "BATTLE_ANIM_OBJ_NIGHTMARE", + "BATTLE_ANIM_OBJ_IN_NIGHTMARE", + "BATTLE_ANIM_OBJ_LOVELY_KISS", + "BATTLE_ANIM_OBJ_SWEET_KISS", + "BATTLE_ANIM_OBJ_SKETCH", + "BATTLE_ANIM_OBJ_ENCORE_HAND", + "BATTLE_ANIM_OBJ_ENCORE_STAR", + "BATTLE_ANIM_OBJ_DESTINY_BOND", + "BATTLE_ANIM_OBJ_MORNING_SUN", + "BATTLE_ANIM_OBJ_GLIMMER", + "BATTLE_ANIM_OBJ_MOONLIGHT", + "BATTLE_ANIM_OBJ_HIDDEN_POWER", + "BATTLE_ANIM_OBJ_CROSS_CHOP1", + "BATTLE_ANIM_OBJ_CROSS_CHOP2", + "BATTLE_ANIM_OBJ_SANDSTORM", + "BATTLE_ANIM_OBJ_ZAP_CANNON", + "BATTLE_ANIM_OBJ_SPITE", + "BATTLE_ANIM_OBJ_CURSE", + "BATTLE_ANIM_OBJ_PERISH_SONG", + "BATTLE_ANIM_OBJ_FORESIGHT", + "BATTLE_ANIM_OBJ_RAPID_SPIN", + "BATTLE_ANIM_OBJ_SWAGGER", + "BATTLE_ANIM_OBJ_BELLY_DRUM_HAND", + "BATTLE_ANIM_OBJ_BELLY_DRUM_NOTE", + "BATTLE_ANIM_OBJ_MEAN_LOOK", + "BATTLE_ANIM_OBJ_BETA_PURSUIT", + "BATTLE_ANIM_OBJ_SHOOTING_SPARKLE", + "BATTLE_ANIM_OBJ_RAIN", + "BATTLE_ANIM_OBJ_B0", + "BATTLE_ANIM_OBJ_PSYCH_UP", + "BATTLE_ANIM_OBJ_ANCIENTPOWER", + "BATTLE_ANIM_OBJ_AEROBLAST", + "BATTLE_ANIM_OBJ_SHADOW_BALL", + "BATTLE_ANIM_OBJ_ROCK_SMASH", + "BATTLE_ANIM_OBJ_FLOWER", + "BATTLE_ANIM_OBJ_COTTON", + "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", + "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", + "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", +} +M.battleAnimFuncOrder={ + "BATTLE_ANIM_FUNC_NULL", + "BATTLE_ANIM_FUNC_USER_TO_TARGET", + "BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR", + "BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE", + "BATTLE_ANIM_FUNC_WAVE_TO_TARGET", + "BATTLE_ANIM_FUNC_THROW_TO_TARGET", + "BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR", + "BATTLE_ANIM_FUNC_DROP", + "BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN", + "BATTLE_ANIM_FUNC_SHAKE", + "BATTLE_ANIM_FUNC_FIRE_BLAST", + "BATTLE_ANIM_FUNC_RAZOR_LEAF", + "BATTLE_ANIM_FUNC_BUBBLE", + "BATTLE_ANIM_FUNC_SURF", + "BATTLE_ANIM_FUNC_SING", + "BATTLE_ANIM_FUNC_WATER_GUN", + "BATTLE_ANIM_FUNC_EMBER", + "BATTLE_ANIM_FUNC_POWDER", + "BATTLE_ANIM_FUNC_POKEBALL", + "BATTLE_ANIM_FUNC_POKEBALL_BLOCKED", + "BATTLE_ANIM_FUNC_RECOVER", + "BATTLE_ANIM_FUNC_THUNDER_WAVE", + "BATTLE_ANIM_FUNC_CLAMP_ENCORE", + "BATTLE_ANIM_FUNC_BITE", + "BATTLE_ANIM_FUNC_SOLAR_BEAM", + "BATTLE_ANIM_FUNC_GUST", + "BATTLE_ANIM_FUNC_RAZOR_WIND", + "BATTLE_ANIM_FUNC_KICK", + "BATTLE_ANIM_FUNC_ABSORB", + "BATTLE_ANIM_FUNC_EGG", + "BATTLE_ANIM_FUNC_MOVE_UP", + "BATTLE_ANIM_FUNC_WRAP", + "BATTLE_ANIM_FUNC_LEECH_SEED", + "BATTLE_ANIM_FUNC_SOUND", + "BATTLE_ANIM_FUNC_CONFUSE_RAY", + "BATTLE_ANIM_FUNC_DIZZY", + "BATTLE_ANIM_FUNC_AMNESIA", + "BATTLE_ANIM_FUNC_FLOAT_UP", + "BATTLE_ANIM_FUNC_DIG", + "BATTLE_ANIM_FUNC_STRING", + "BATTLE_ANIM_FUNC_PARALYZED", + "BATTLE_ANIM_FUNC_SPIRAL_DESCENT", + "BATTLE_ANIM_FUNC_POISON_GAS", + "BATTLE_ANIM_FUNC_HORN", + "BATTLE_ANIM_FUNC_NEEDLE", + "BATTLE_ANIM_FUNC_PETAL_DANCE", + "BATTLE_ANIM_FUNC_THIEF_PAYDAY", + "BATTLE_ANIM_FUNC_ABSORB_CIRCLE", + "BATTLE_ANIM_FUNC_BONEMERANG", + "BATTLE_ANIM_FUNC_SHINY", + "BATTLE_ANIM_FUNC_SKY_ATTACK", + "BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE", + "BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL", + "BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN", + "BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS", + "BATTLE_ANIM_FUNC_SPEED_LINE", + "BATTLE_ANIM_FUNC_SLUDGE", + "BATTLE_ANIM_FUNC_METRONOME_HAND", + "BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH", + "BATTLE_ANIM_FUNC_AGILITY", + "BATTLE_ANIM_FUNC_SACRED_FIRE", + "BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT", + "BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER", + "BATTLE_ANIM_FUNC_SPIKES", + "BATTLE_ANIM_FUNC_HEAL_BELL_NOTES", + "BATTLE_ANIM_FUNC_BATON_PASS", + "BATTLE_ANIM_FUNC_CONVERSION", + "BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM", + "BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN", + "BATTLE_ANIM_FUNC_HIDDEN_POWER", + "BATTLE_ANIM_FUNC_CURSE", + "BATTLE_ANIM_FUNC_PERISH_SONG", + "BATTLE_ANIM_FUNC_RAPID_SPIN", + "BATTLE_ANIM_FUNC_BETA_PURSUIT", + "BATTLE_ANIM_FUNC_RAIN_SANDSTORM", + "BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0", + "BATTLE_ANIM_FUNC_PSYCH_UP", + "BATTLE_ANIM_FUNC_ANCIENT_POWER", + "BATTLE_ANIM_FUNC_ROCK_SMASH", + "BATTLE_ANIM_FUNC_COTTON", +} +M.battleAnimGfxOrder={ + "BATTLE_ANIM_GFX_NONE", + "BATTLE_ANIM_GFX_HIT", + "BATTLE_ANIM_GFX_CUT", + "BATTLE_ANIM_GFX_FIRE", + "BATTLE_ANIM_GFX_WATER", + "BATTLE_ANIM_GFX_LIGHTNING", + "BATTLE_ANIM_GFX_PLANT", + "BATTLE_ANIM_GFX_SMOKE", + "BATTLE_ANIM_GFX_EXPLOSION", + "BATTLE_ANIM_GFX_ROCKS", + "BATTLE_ANIM_GFX_ICE", + "BATTLE_ANIM_GFX_POKE_BALL", + "BATTLE_ANIM_GFX_POISON", + "BATTLE_ANIM_GFX_BUBBLE", + "BATTLE_ANIM_GFX_NOISE", + "BATTLE_ANIM_GFX_POWDER", + "BATTLE_ANIM_GFX_BEAM", + "BATTLE_ANIM_GFX_SPEED", + "BATTLE_ANIM_GFX_CHARGE", + "BATTLE_ANIM_GFX_WIND", + "BATTLE_ANIM_GFX_WHIP", + "BATTLE_ANIM_GFX_EGG", + "BATTLE_ANIM_GFX_ROPE", + "BATTLE_ANIM_GFX_PSYCHIC", + "BATTLE_ANIM_GFX_REFLECT", + "BATTLE_ANIM_GFX_STATUS", + "BATTLE_ANIM_GFX_SAND", + "BATTLE_ANIM_GFX_WEB", + "BATTLE_ANIM_GFX_HAZE", + "BATTLE_ANIM_GFX_HORN", + "BATTLE_ANIM_GFX_FLOWER", + "BATTLE_ANIM_GFX_MISC", + "BATTLE_ANIM_GFX_SKY_ATTACK", + "BATTLE_ANIM_GFX_GLOBE", + "BATTLE_ANIM_GFX_SHAPES", + "BATTLE_ANIM_GFX_OBJECTS", + "BATTLE_ANIM_GFX_SHINE", + "BATTLE_ANIM_GFX_ANGELS", + "BATTLE_ANIM_GFX_WAVE", + "BATTLE_ANIM_GFX_AEROBLAST", + "BATTLE_ANIM_GFX_PLAYERHEAD", + "BATTLE_ANIM_GFX_ENEMYFEET", +} +M.nativeFunctions={ + ["Null"]="BATTLE_ANIM_FUNC_NULL", + ["MoveFromUserToTarget"]="BATTLE_ANIM_FUNC_USER_TO_TARGET", + ["MoveFromUserToTargetAndDisappear"]="BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR", + ["MoveInCircle"]="BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE", + ["MoveWaveToTarget"]="BATTLE_ANIM_FUNC_WAVE_TO_TARGET", + ["ThrowFromUserToTarget"]="BATTLE_ANIM_FUNC_THROW_TO_TARGET", + ["ThrowFromUserToTargetAndDisappear"]="BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR", + ["Drop"]="BATTLE_ANIM_FUNC_DROP", + ["MoveFromUserToTargetSpinAround"]="BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN", + ["Shake"]="BATTLE_ANIM_FUNC_SHAKE", + ["FireBlast"]="BATTLE_ANIM_FUNC_FIRE_BLAST", + ["RazorLeaf"]="BATTLE_ANIM_FUNC_RAZOR_LEAF", + ["Bubble"]="BATTLE_ANIM_FUNC_BUBBLE", + ["Surf"]="BATTLE_ANIM_FUNC_SURF", + ["Sing"]="BATTLE_ANIM_FUNC_SING", + ["WaterGun"]="BATTLE_ANIM_FUNC_WATER_GUN", + ["Ember"]="BATTLE_ANIM_FUNC_EMBER", + ["Powder"]="BATTLE_ANIM_FUNC_POWDER", + ["PokeBall"]="BATTLE_ANIM_FUNC_POKEBALL", + ["PokeBallBlocked"]="BATTLE_ANIM_FUNC_POKEBALL_BLOCKED", + ["Recover"]="BATTLE_ANIM_FUNC_RECOVER", + ["ThunderWave"]="BATTLE_ANIM_FUNC_THUNDER_WAVE", + ["Clamp_Encore"]="BATTLE_ANIM_FUNC_CLAMP_ENCORE", + ["Bite"]="BATTLE_ANIM_FUNC_BITE", + ["SolarBeam"]="BATTLE_ANIM_FUNC_SOLAR_BEAM", + ["Gust"]="BATTLE_ANIM_FUNC_GUST", + ["RazorWind"]="BATTLE_ANIM_FUNC_RAZOR_WIND", + ["Kick"]="BATTLE_ANIM_FUNC_KICK", + ["Absorb"]="BATTLE_ANIM_FUNC_ABSORB", + ["Egg"]="BATTLE_ANIM_FUNC_EGG", + ["MoveUp"]="BATTLE_ANIM_FUNC_MOVE_UP", + ["Wrap"]="BATTLE_ANIM_FUNC_WRAP", + ["LeechSeed"]="BATTLE_ANIM_FUNC_LEECH_SEED", + ["Sound"]="BATTLE_ANIM_FUNC_SOUND", + ["ConfuseRay"]="BATTLE_ANIM_FUNC_CONFUSE_RAY", + ["Dizzy"]="BATTLE_ANIM_FUNC_DIZZY", + ["Amnesia"]="BATTLE_ANIM_FUNC_AMNESIA", + ["FloatUp"]="BATTLE_ANIM_FUNC_FLOAT_UP", + ["Dig"]="BATTLE_ANIM_FUNC_DIG", + ["String"]="BATTLE_ANIM_FUNC_STRING", + ["Paralyzed"]="BATTLE_ANIM_FUNC_PARALYZED", + ["SpiralDescent"]="BATTLE_ANIM_FUNC_SPIRAL_DESCENT", + ["PoisonGas"]="BATTLE_ANIM_FUNC_POISON_GAS", + ["Horn"]="BATTLE_ANIM_FUNC_HORN", + ["Needle"]="BATTLE_ANIM_FUNC_NEEDLE", + ["PetalDance"]="BATTLE_ANIM_FUNC_PETAL_DANCE", + ["ThiefPayday"]="BATTLE_ANIM_FUNC_THIEF_PAYDAY", + ["AbsorbCircle"]="BATTLE_ANIM_FUNC_ABSORB_CIRCLE", + ["Bonemerang"]="BATTLE_ANIM_FUNC_BONEMERANG", + ["Shiny"]="BATTLE_ANIM_FUNC_SHINY", + ["SkyAttack"]="BATTLE_ANIM_FUNC_SKY_ATTACK", + ["GrowthSwordsDance"]="BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE", + ["SmokeFlameWheel"]="BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL", + ["PresentSmokescreen"]="BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN", + ["StrengthSeismicToss"]="BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS", + ["SpeedLine"]="BATTLE_ANIM_FUNC_SPEED_LINE", + ["Sludge"]="BATTLE_ANIM_FUNC_SLUDGE", + ["MetronomeHand"]="BATTLE_ANIM_FUNC_METRONOME_HAND", + ["MetronomeSparkleSketch"]="BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH", + ["Agility"]="BATTLE_ANIM_FUNC_AGILITY", + ["SacredFire"]="BATTLE_ANIM_FUNC_SACRED_FIRE", + ["SafeguardProtect"]="BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT", + ["LockOnMindReader"]="BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER", + ["Spikes"]="BATTLE_ANIM_FUNC_SPIKES", + ["HealBellNotes"]="BATTLE_ANIM_FUNC_HEAL_BELL_NOTES", + ["BatonPass"]="BATTLE_ANIM_FUNC_BATON_PASS", + ["Conversion"]="BATTLE_ANIM_FUNC_CONVERSION", + ["EncoreBellyDrum"]="BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM", + ["SwaggerMorningSun"]="BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN", + ["HiddenPower"]="BATTLE_ANIM_FUNC_HIDDEN_POWER", + ["Curse"]="BATTLE_ANIM_FUNC_CURSE", + ["PerishSong"]="BATTLE_ANIM_FUNC_PERISH_SONG", + ["RapidSpin"]="BATTLE_ANIM_FUNC_RAPID_SPIN", + ["BetaPursuit"]="BATTLE_ANIM_FUNC_BETA_PURSUIT", + ["RainSandstorm"]="BATTLE_ANIM_FUNC_RAIN_SANDSTORM", + ["AnimObjB0"]="BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0", + ["PsychUp"]="BATTLE_ANIM_FUNC_PSYCH_UP", + ["AncientPower"]="BATTLE_ANIM_FUNC_ANCIENT_POWER", + ["RockSmash"]="BATTLE_ANIM_FUNC_ROCK_SMASH", + ["Cotton"]="BATTLE_ANIM_FUNC_COTTON", +} +M.battleAnimObPaletteOrder={ + "PAL_BATTLE_OB_ENEMY", + "PAL_BATTLE_OB_PLAYER", + "PAL_BATTLE_OB_GRAY", + "PAL_BATTLE_OB_YELLOW", + "PAL_BATTLE_OB_RED", + "PAL_BATTLE_OB_GREEN", + "PAL_BATTLE_OB_BLUE", + "PAL_BATTLE_OB_BROWN", +} +return M diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index 06d3d7ca5..f3612b662 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -1,181 +1,38 @@ --- Copyright (c) 2026 Cedric. All rights reserved. --- Source-available under the Gen2Recomped License (see LICENSE.md): you may --- read, build and privately modify this file; you may not redistribute it or --- use it commercially. Cartridge-derived data is excluded and is not the --- copyright holder's to license. - --- Gen 2 battle-animation player. --- --- GSC's animations are a bytecode VM, not Gen 1's subanimation/frame-block --- tables, so this is a separate player from src/battle/AnimPlayer.lua rather --- than another code path inside it. The extractor --- (RomExtractorGen2:gen2BattleAnims) hands over: --- --- scripts[animId] = { entry = , code = { , ... } } --- objects[id] = { oamFlags, fixY, frameset, fn, palette, gfx } --- framesets[id] = { frames = { { oam, duration, xflip, yflip } }, --- loop = "hold" | "restart" } --- oam[id] = { tile = , --- sprites = { { y, x, tile, attr } } } --- gfx[id] = { tiles, cols, image } --- palettes[i] = { {r,g,b}, {r,g,b}, {r,g,b}, {r,g,b} } --- moveAnims[MOVE] = animId --- --- The engine's own object `function`s (~200 hand-written asm routines that --- push objects along arcs, spin them, track the target and eventually --- delete them) are not portable as data. Objects here therefore hold the --- position their script spawned them at and simply run their real frameset, --- which reproduces the correct sprites, palettes, placement and timing for --- every move while motion paths read as static bursts. --- --- Coordinates follow the hardware: an object's x/y are OAM coordinates, so --- screen space is (x - 8, y - 16). InitBattleAnimBuffer (33:$49F9) mirrors --- an object for the enemy's side when its oamFlags bit 0 is set: --- x' = 180 - x --- y' = fixY == $FF and 40 + y or fixY - y --- xOffset' = -xOffset - -local BgEffects = require('src.battle.gen2.BgEffects') - -local Gen2AnimPlayer = {} -Gen2AnimPlayer.__index = Gen2AnimPlayer - -local MAX_OBJECTS = 10 -- wActiveAnimObjects holds ten slots -local MAX_STEPS_PER_FRAME = 256 -- guard against a script looping forever -local TAIL_FRAMES = 16 -- let the last objects finish after `ret` -local MAX_OBJECT_AGE = 48 -- see tickObjects -local TRAVEL_FRAMES = 20 -- how long a *ToTarget function takes - --- DoBattleAnimFrame's 80 object functions are hand-written asm, so the port --- reads their names out of the manifest (BATTLE_ANIM_FUNC_*) and sorts them --- into the motion archetypes below. This is an approximation of the paths, --- not a recompilation of them: what it buys is that every object actually --- travels the way its routine travels instead of sitting where the script --- spawned it. Anchors are OAM coordinates: an animation is written from the --- player's side, with the user around (64, 96) and the target pic around --- (136, 48); InitBattleAnimBuffer's mirror maps those to the enemy's side as --- x' = 180 - x and y' = 40 + y. -local TARGET_ANCHOR = { x = 136, y = 48 } -local TARGET_ANCHOR_MIRRORED = { x = 44, y = 88 } -local USER_ANCHOR = { x = 64, y = 96 } -local USER_ANCHOR_MIRRORED = { x = 116, y = 136 } - --- name -> archetype. "gone" suffixes delete the object when it arrives, --- matching the routines whose names end in Disappear. -local MOTION = { - Null = "static", LockOnMindReader = "static", AnimObjB0 = "static", - Sound = "static", - - MoveFromUserToTarget = "toTarget", - MoveFromUserToTargetSpinAround = "toTarget", - MoveFromUserToTargetAndDisappear = "toTargetGone", - Kick = "toTargetGone", SpeedLine = "toTargetGone", - Bite = "toTarget", Horn = "toTarget", Needle = "toTarget", - WaterGun = "toTarget", Surf = "toTarget", String = "toTarget", - SkyAttack = "toTarget", RockSmash = "toTarget", BetaPursuit = "toTarget", - PresentSmokescreen = "toTarget", Clamp_Encore = "toTarget", - Wrap = "toTarget", Dig = "toTarget", - - MoveWaveToTarget = "wave", RazorLeaf = "wave", Gust = "wave", - RazorWind = "wave", PetalDance = "wave", Sing = "wave", - PoisonGas = "wave", Powder = "wave", Cotton = "wave", - - ThrowFromUserToTarget = "arc", PokeBall = "arc", PokeBallBlocked = "arc", - ThrowFromUserToTargetAndDisappear = "arcGone", - Ember = "arc", FireBlast = "arc", SacredFire = "arc", - SmokeFlameWheel = "arc", Sludge = "arc", Egg = "arc", - LeechSeed = "arc", Spikes = "arc", ThiefPayday = "arc", - Bonemerang = "arc", StrengthSeismicToss = "arc", BatonPass = "arc", - - Absorb = "toUser", AbsorbCircle = "circle", Bubble = "toUser", - - MoveInCircle = "circle", ConfuseRay = "circle", Dizzy = "circle", - RapidSpin = "circle", SafeguardProtect = "circle", Conversion = "circle", - HiddenPower = "circle", SolarBeam = "circle", Shiny = "circle", - MetronomeSparkleSketch = "circle", - - MoveUp = "rise", FloatUp = "rise", Recover = "rise", Amnesia = "rise", - GrowthSwordsDance = "rise", HealBellNotes = "rise", PerishSong = "rise", - PsychUp = "rise", AncientPower = "rise", SwaggerMorningSun = "rise", - Agility = "rise", - - Drop = "fall", Curse = "fall", RainSandstorm = "fall", - SpiralDescent = "spiral", - - Shake = "shake", ThunderWave = "shake", Paralyzed = "shake", - EncoreBellyDrum = "shake", MetronomeHand = "shake", -} - -local function shade(value) - return { value, value, value } -end - -local DEFAULT_PALETTE = { - shade(1), shade(0.66), shade(0.33), shade(0), -} - --- Lua 5.1 has no bitwise operators -local function band(a, b) - local result, bit = 0, 1 - for _ = 1, 8 do - if a % 2 == 1 and b % 2 == 1 then result = result + bit end - a, b, bit = math.floor(a / 2), math.floor(b / 2), bit * 2 - end - return result -end - -local function bxor(a, b) - local result, bit = 0, 1 - for _ = 1, 8 do - if a % 2 ~= b % 2 then result = result + bit end - a, b, bit = math.floor(a / 2), math.floor(b / 2), bit * 2 +-- Copyright (c) 2026 Cedric. Existing source rights retained; see NOTICE.md. +-- Native player integration of the Gen1Recomp GSC runtime under GPLv3 plus +-- Additional Terms; compat/gen1-gsc/LICENSE.MD and provenance.json. +-- One real command VM owns BG, ten object structs and forty OAM entries. +local Runtime=require('src.battle.gen2.AnimRunner') +local Data=require('src.battle.Gen2AnimData') +local Gen2AnimPlayer={} +Gen2AnimPlayer.__index=function(self,key) + local method=Gen2AnimPlayer[key] + if method~=nil then return method end + local runner=rawget(self,'runtime') + if runner then + if key=='done'then return runner.stopped end + if key=='frames'or key=='bg'or key=='objects'or key=='keepSprites'then return runner[key]end end - return result end - function Gen2AnimPlayer.new(data) - local anims = data and data.battle_anims or {} + local anims=data and data.battle_anims or {} require('src.battle.Gen2AnimationIds').install(anims) - local self = setmetatable({ - data = data, - anims = anims, - images = {}, - quads = {}, - warned = {}, - objects = {}, - effects = {}, - frames = 0, - bg = BgEffects.new(data and data.constants, {battleTurn=0}), - tail = 0, - -- WideBattle reads these two off the Gen 1 player; keep them defined - steps = {}, - stepIndex = 1, - done = true, - }, Gen2AnimPlayer) - self.view = require('src.battle.Gen2AnimView').new(anims, data) + local tables,constants=Data.tables(anims,data and data.constants) + local runner=Runtime.new({data=tables,constants=constants}) + runner.stopped=true + runner.retire=function()end + local self=setmetatable({data=data,anims=anims,warned={},effects={},runtime=runner},Gen2AnimPlayer) + self.view=require('src.battle.Gen2AnimView').new(tables,data) return self end - function Gen2AnimPlayer:release() - if self.view then self.view:release(); self.view = nil end - for _, img in pairs(self.images) do - if img and img.release then pcall(img.release, img) end - end - self.images, self.quads = {}, {} + if self.view then self.view:release();self.view=nil end end - -function Gen2AnimPlayer:warnOnce(key, fmt, ...) - if self.warned[key] then return end - self.warned[key] = true - print(string.format(fmt, ...)) +function Gen2AnimPlayer:warnOnce(key,fmt,...) + if self.warned[key]then return end + self.warned[key]=true + print(string.format(fmt,...)) end - --- BattleState drives the ball toss and the send-out with Gen 1's animation --- ids (TossBallAnimation's TOSS/POOF/HIDEPIC/SHAKE chain). Gen 2 keeps the --- whole toss in one script -- ANIM_THROW_POKE_BALL reads wPokeBallAnimData --- through anim_checkpokeball and wobbles or breaks out by itself -- so the --- toss row maps onto it and the chain's remaining rows have nothing of their --- own to play. Without this nothing resolved and no ball was ever thrown. local GEN1_BALL_ANIMS = { TOSS_ANIM = "throwPokeBall", GREATTOSS_ANIM = "throwPokeBall", @@ -246,87 +103,94 @@ local function ballParam(self, opts) or DEFAULT_BALL_ITEM end -function Gen2AnimPlayer:start(moveId, attackerIsPlayer, opts) - if self.presentation then self.presentation.retire() end - self.presentation = nil - self.objects = {} - self.effects = {} - self.callStack = {} - self.vars = { var = 0, param = opts and opts.param or 0, loop = nil } - self.wait = 0 - self.tail = 0 - self.ranFrames = 0 - self.frames = 0 - self.wobbles = 0 - self.keepSprites = false - self.keptSprites = nil - -- Gen 1 queues its send-out poof with attackerIsPlayer = false, because - -- TossBallAnimation's flag names the animation's ATTACKER and the poof has - -- to land on the player's own pic (the toss row passes true, for the ball - -- reaching the foe). Gen 2's BattleAnim_SendOutMon is already authored on - -- the sender's side, so reading that flag straight through mirrored the - -- player's own release onto the wild mon. - -- SHINY_ANIM is the same script on the same side as the send-out it - -- follows, so it takes the same flag flip. - if moveId == "POOF_ANIM" or moveId == "SHINY_ANIM" then - attackerIsPlayer = not attackerIsPlayer - end - self.enemySide = not attackerIsPlayer - -- The real command VM owns this pool's clock. Each restart gets a fresh - -- register block, so retired animation observations keep their own state. - self.bg = BgEffects.new(self.data and self.data.constants, { - battleTurn = self.enemySide and 1 or 0, - flying = opts and opts.flying or {}, +function Gen2AnimPlayer:start(moveId,attackerIsPlayer,opts) + if self.presentation then self.presentation.retire()end + self.presentation=nil + self.effects={} + self.keptSprites=nil + self.opts=opts or {} + self.moveId=moveId + if moveId=='POOF_ANIM'or moveId=='SHINY_ANIM'then attackerIsPlayer=not attackerIsPlayer end + self.enemySide=not attackerIsPlayer + self.script=self:scriptFor(moveId) + self.presentationId=moveId + local alias=GEN1_BALL_ANIMS[moveId] + local canonical=alias=='throwPokeBall'and 'ANIM_THROW_POKE_BALL' + or alias=='sendOutMon'and 'ANIM_SEND_OUT_MON' + if self.script and canonical and self:scriptFor(canonical)==self.script then self.presentationId=canonical end + local param=self.opts.param or 0 + if alias=='throwPokeBall'then param=ballParam(self,self.opts) + elseif moveId=='SHINY_ANIM'then param=1 end + local tables,constants=Data.tables(self.anims,self.data and self.data.constants) + local program,entry=Data.program(tables,self.script) + local data,events,options=self.data,self.effects,self.opts + local wobbles=0 + local runner=Runtime.new({data=program,constants=constants, + battleTurn=self.enemySide and 1 or 0,animId=self.presentationId, + param=param,ballPalette=options.ballPalette,sgb=options.sgb, + flying=options.flying, + hooks={ + pokeballWobble=function() + wobbles=wobbles+1 + return wobbles<=(options.shakes or 0)and 0 or options.caught and 1 or 2 + end, + sound=function(_,panning,duration,id) + local Sound=require('src.core.Sound') + local audio=data and data.audio + local name=audio and audio.sfxIndex and audio.sfxIndex[tostring(id)] + if name and audio.sfx and audio.sfx[name]then return Sound.play(data,name)end + local def=data and data.moves and data.moves[moveId] + if def and def.anim then return Sound.playMove(data,def.anim)end + end, + cry=function(side,pitch,length) + events[#events+1]={cry=true,side=side,pitch=pitch,length=length} + end, + }}) + runner:start(entry) + if not self.script then runner.stopped=true end + -- A restarted native player creates a new VM. Old public runners already + -- own their old BG/objects/OAM and have no pointer back to this player. + runner.retire=function()end + self.runtime=runner + self.view.data=program + self.vars=setmetatable({}, { + __index=function(_,key)return runner[key]end, + __newindex=function(_,key,value)runner[key]=value end, }) - self.opts = opts - self.moveId = moveId - self.loops = {} - self.bgSides = {} - self.script = self:scriptFor(moveId) - -- Keep the queue's original ID for ball parameters/side handling, while - -- observers identify the actual GSC program (as Gen1Recomp's runner does). - -- A mod-defined alias is canonical only if it resolves to that same script. - self.presentationId = moveId - local alias = GEN1_BALL_ANIMS[moveId] - local canonical = alias == "throwPokeBall" and "ANIM_THROW_POKE_BALL" - or alias == "sendOutMon" and "ANIM_SEND_OUT_MON" - if self.script and canonical and self:scriptFor(canonical) == self.script then - self.presentationId = canonical + if not self.script and GEN1_BALL_ANIMS[moveId]==nil then + self:warnOnce('anim:'..tostring(moveId),'Gen2AnimPlayer: no animation data for move %s',tostring(moveId)) end - if not self.script then - if GEN1_BALL_ANIMS[moveId] == nil then - self:warnOnce("anim:" .. tostring(moveId), - "Gen2AnimPlayer: no animation data for move %s", - tostring(moveId)) - end - self.done = true - return false - end - if GEN1_BALL_ANIMS[moveId] == "throwPokeBall" then - self.vars.param = ballParam(self, opts) - elseif moveId == "SHINY_ANIM" then - self.vars.param = 1 -- wBattleAnimParam = 1 -> .Shiny - end - -- objects the script addresses by index outlive the port's usual object - -- bound: on hardware they only retire when their asm function says so, - -- and the ball has to sit through four 48-frame wobbles - self.objIndex = 0 - self.scriptObjs = {} - for _, inst in ipairs(self.script.code or {}) do - if inst.op == "incobj" or inst.op == "setobj" then - self.scriptObjs[inst.args[1]] = true - end - end - self.pc = self.script.entry - self.done = false - return true + return self.script~=nil end - -function Gen2AnimPlayer:isDone() - -- BattleAnimRunScript also waits on wNumActiveBattleAnims, but objects - -- only clear themselves from inside their asm functions, so the port ends - -- on the top-level `ret` plus a short drain instead of hanging forever. - return self.done and self.tail <= 0 +function Gen2AnimPlayer:isDone()return self.runtime.stopped end +function Gen2AnimPlayer:pollEffects() + -- The hook holds this same channel for the life of this real runner. + local out={} + for i,event in ipairs(self.effects)do out[i]=event end + for i=#self.effects,1,-1 do self.effects[i]=nil end + return out +end +function Gen2AnimPlayer:update() + if self.runtime.stopped then return false end + local active=self.runtime:step() + if not active and self.runtime.keepSprites then + local kept={} + for i,row in ipairs(self.runtime:oam())do + local copy={};for key,value in pairs(row)do copy[key]=value end + kept[i]=copy + end + kept.runner=self.runtime + self.keptSprites=#kept>0 and kept or nil + end + return active +end +function Gen2AnimPlayer:oam(objects)return objects or self.runtime:oam()end +function Gen2AnimPlayer:finalSprites()return self.keptSprites end +function Gen2AnimPlayer:draw()return self.view:drawObjects(self.runtime,self.battle)end +function Gen2AnimPlayer:drawSprites(objects) + if not objects then return self:draw()end + local runner=setmetatable({oam=function()return objects end},{__index=objects.runner or self.runtime}) + return self.view:drawObjects(runner,self.battle) end -- The historical SE-reset lookup remains available to existing consumers. @@ -363,548 +227,5 @@ Gen2AnimPlayer.BG_EFFECTS_END = BG_EFFECTS_END -- the remaining approximate object runtime is replaced independently. Gen2AnimPlayer.BG_EFFECTS_DROPPED = {} --- BATTLE_ANIM_GFX_PLAYERHEAD / _ENEMYFEET: these two sheets are placeholders --- that BATTLE_BG_EFFECT_BATTLEROBJ_*ROW overwrites with a slice of the --- battler's pic before the object appears. See BG_EFFECTS_DROPPED. -local BATTLER_PIC_GFX = { [0x28] = true, [0x29] = true } - --- BattleState drains this every frame and routes each row through --- applyAnimEffect, the same path Gen 1's player uses. -function Gen2AnimPlayer:pollEffects() - local out = self.effects or {} - self.effects = {} - return out -end - -function Gen2AnimPlayer:spawn(id, x, y, param) - if #self.objects >= MAX_OBJECTS then return end - local def = self.anims.objects and self.anims.objects[id] - if not def then - self:warnOnce("obj:" .. tostring(id), - "Gen2AnimPlayer: unknown animation object %d", id) - return - end - local ox, oy, xOffset = x, y, 0 - if BATTLER_PIC_GFX[def.gfx] then return end - if self.enemySide and (def.oamFlags or 0) % 2 == 1 then - ox = 180 - x - if (def.fixY or 0) == 0xFF then oy = 40 + y else oy = (def.fixY or 0) - y end - xOffset = -xOffset - end - local set = self.anims.framesets and self.anims.framesets[def.frameset] - local first = set and set.frames[1] - if not first then return end - -- BATTLEANIMSTRUCT_INDEX is wLastAnimObjectIndex, bumped once per queued - -- object, and it is what anim_incobj/anim_setobj address. - local index = (self.objIndex or 0) + 1 - self.objIndex = index - self.objects[#self.objects + 1] = { - def = def, x = ox, y = oy, xOffset = xOffset, yOffset = 0, - fn = self.anims.functions and self.anims.functions[def.fn or 0], - param = param, frame = 1, left = first.duration + 1, cycles = 0, age = 0, - index = index, frameset = def.frameset, - -- BATTLEANIMSTRUCT_JUMPTABLE_INDEX / VAR1 / VAR2 - state = 0, var1 = 0, var2 = 0, - persistent = self.scriptObjs and self.scriptObjs[index] or nil, - } -end - -local function frameset(self, obj) - local sets = self.anims.framesets - return sets and sets[obj.frameset or obj.def.frameset] -end - -local function objectByIndex(self, index) - for _, obj in ipairs(self.objects) do - if obj.index == index then return obj end - end - return nil -end - --- ReinitBattleAnimFrameset: point an object at a frameset and restart it -local function reframe(self, obj, framesetId) - local set = self.anims.framesets and self.anims.framesets[framesetId] - local first = set and set.frames[1] - if not first then return end - obj.frameset = framesetId - obj.frame, obj.cycles, obj.age = 1, 0, 0 - obj.left = first.duration + 1 -end - --- BattleAnim_Sine: a = d * sin(a * pi/32), with `a` a wrapping byte angle -local function animSine(a, d) - return math.floor(d * math.sin((a % 64) * math.pi / 32)) -end - --- BATTLE_ANIM_FRAMESET_POKE_BALL_1..5 -local BALL_FRAMESET_1 = 9 -local BALL_FRAMESET_2 = 10 -local BALL_FRAMESET_3 = 11 -local BALL_FRAMESET_4 = 12 -local BALL_FRAMESET_5 = 13 - --- BattleAnimFunc_PokeBall's twelve-state jumptable. anim_setobj/anim_incobj --- only write BATTLEANIMSTRUCT_JUMPTABLE_INDEX; it is each state that picks --- the next frameset, so the port has to run the machine. Treating --- anim_incobj as "next frameset" instead re-ran ReinitBattleAnimFrameset on --- the thrown ball, which reset its travel and made it look like a fresh ball --- was hurled at the mon once per wobble, and left the resting ball stuck on --- POKE_BALL_2 -- the squat 16x8 tile that reads as half a ball. -local function pokeBallStep(self, obj) - local s = obj.state or 0 - if s == 0 then -- init (GetBallAnimPal) - obj.state = 1 - elseif s == 1 then -- BattleAnimFunc_ThrowFromUserToTarget - if obj.x >= 0x88 then - obj.y = obj.y + obj.yOffset - obj.yOffset = 0 - reframe(self, obj, BALL_FRAMESET_3) - obj.state = 2 - return - end - obj.x = obj.x + 2 - obj.y = obj.y - 1 - obj.yOffset = animSine(obj.var1, obj.param or 0) - obj.var1 = (obj.var1 - 1) % 256 - elseif s == 3 then - obj.state = 4 - reframe(self, obj, BALL_FRAMESET_1) - obj.var1, obj.var2 = 0, 0x10 - elseif s == 4 then - obj.yOffset = animSine(obj.var1, obj.var2) - obj.var1 = (obj.var1 - 1) % 256 - if obj.var1 % 32 == 0 then - obj.var1 = 0 - obj.var2 = obj.var2 - 4 - if obj.var2 <= 0 then - reframe(self, obj, BALL_FRAMESET_4) - obj.state = 5 - end - end - elseif s == 6 then - reframe(self, obj, BALL_FRAMESET_5) - obj.state = 5 -- .six falls through to .five after decrementing the index - elseif s == 7 then - reframe(self, obj, BALL_FRAMESET_2) - obj.state = 8 - obj.var2 = 0x20 - elseif s == 8 or s == 10 then - obj.yOffset = animSine(obj.var1, obj.var2) - obj.var1 = (obj.var1 - 1) % 256 - if obj.var1 % 32 == 0 then - obj.state = 11 - elseif obj.var1 % 16 == 0 then - obj.state = s + 1 - end - elseif s == 11 then - obj.dead = true - end - -- .two/.five/.nine simply hold the object where it is -end - -function Gen2AnimPlayer:moveObject(obj) - if obj.fn == "PokeBall" then return pokeBallStep(self, obj) end - local kind = MOTION[obj.fn or "Null"] - if not kind or kind == "static" then return end - local t = math.min(1, obj.age / TRAVEL_FRAMES) - local age = obj.age - - if kind == "circle" then - local angle = age / 6 - obj.xOffset = math.floor(math.cos(angle) * 16 + 0.5) - obj.yOffset = math.floor(math.sin(angle) * 10 + 0.5) - return - elseif kind == "spiral" then - local angle = age / 6 - obj.xOffset = math.floor(math.cos(angle) * (4 + age / 3) + 0.5) - obj.yOffset = math.floor(math.sin(angle) * 6 + 0.5) + age - return - elseif kind == "shake" then - obj.xOffset = (math.floor(age / 2) % 2 == 0) and 1 or -1 - obj.yOffset = 0 - return - elseif kind == "rise" then - obj.xOffset = 0 - obj.yOffset = -age - return - elseif kind == "fall" then - obj.xOffset = 0 - obj.yOffset = age - return - end - - -- everything left travels between the two battlers - local anchor - if kind == "toUser" then - anchor = self.enemySide and USER_ANCHOR_MIRRORED or USER_ANCHOR - else - anchor = self.enemySide and TARGET_ANCHOR_MIRRORED or TARGET_ANCHOR - end - obj.xOffset = math.floor((anchor.x - obj.x) * t + 0.5) - obj.yOffset = math.floor((anchor.y - obj.y) * t + 0.5) - if kind == "arc" or kind == "arcGone" then - obj.yOffset = obj.yOffset - math.floor(math.sin(t * math.pi) * 16 + 0.5) - elseif kind == "wave" then - obj.yOffset = obj.yOffset + math.floor(math.sin(t * math.pi * 4) * 6 + 0.5) - end - if t >= 1 and (kind == "toTargetGone" or kind == "arcGone") then - obj.dead = true - end -end - -function Gen2AnimPlayer:tickObjects() - local live = {} - for _, obj in ipairs(self.objects) do - local set = frameset(self, obj) - if set then - obj.age = obj.age + 1 - self:moveObject(obj) - obj.left = obj.left - 1 - if obj.left <= 0 then - obj.frame = obj.frame + 1 - -- a $FE-terminated frameset restarts; $FF holds the last frame on - -- hardware until the object's asm function deletes it, and with no - -- function to run the object retires instead so the burst clears - if not set.frames[obj.frame] and set.loop == "restart" - and (obj.persistent or obj.cycles < 8) then - obj.frame, obj.cycles = 1, obj.cycles + 1 - elseif not set.frames[obj.frame] and obj.persistent then - -- ...unless the script is still driving this object by index, in - -- which case it really does hold its last frame until told - -- otherwise (the thrown ball waiting out a wobble) - obj.frame = #set.frames - end - local frame = set.frames[obj.frame] - if frame then obj.left = frame.duration + 1 end - end - -- a $FE frameset restarts forever on hardware until the object's own - -- function retires it, so the port bounds the lifetime instead - if set.frames[obj.frame] and not obj.dead - and (obj.persistent or obj.age < MAX_OBJECT_AGE) then - live[#live + 1] = obj - end - end - end - self.objects = live -end - -function Gen2AnimPlayer:step(inst) - local op = inst.op - if op == "delay" then - self.wait = inst.frames - self.pc = self.pc + 1 - return - elseif op == "obj" then - local a = inst.args - self:spawn(a[1], a[2], a[3], a[4]) - elseif op == "clearobjs" then - self.objects = {} - elseif op == "incobj" then - -- BattleAnimCmd_IncObj bumps the addressed object's - -- BATTLEANIMSTRUCT_JUMPTABLE_INDEX, i.e. it advances that object's asm - -- state machine one step; for every object a script drives this way the - -- state's job is to reinit the frameset to the next one in the run - -- (BattleAnimFunction_PokeBall's wobble, Bind, the Amnesia letters). - -- The port has no asm to run, so it advances the frameset directly -- - -- without it the thrown ball never rocked. - local obj = objectByIndex(self, inst.args[1]) - if obj and obj.fn == "PokeBall" then - obj.state = (obj.state or 0) + 1 - elseif obj then - reframe(self, obj, (obj.frameset or obj.def.frameset) + 1) - end - elseif op == "setobj" then - -- the same field, written outright. Only the Poké Ball's jumptable is - -- modelled, so everything else still just restarts its own frameset. - local obj = objectByIndex(self, inst.args[1]) - if obj and obj.fn == "PokeBall" then - obj.state = inst.args[2] or 0 - elseif obj then - reframe(self, obj, obj.def.frameset) - end - elseif op == "bgeffect" then - local id = inst.args[1] - self.bg:queue(id, inst.args[2], inst.args[3], inst.args[4]) - elseif op == "incbgeffect" then - self.bg:incEffect(inst.args[1]) - elseif op == 'bgp' or op == 'obp0' or op == 'obp1' then - self.bg[op] = (inst.args[1] or 0) % 256 - elseif op == 'resetobp0' then - self.bg.obp0 = self.opts and self.opts.sgb and 0xf0 or 0xe0 - elseif op == "checkpokeball" then - -- BattleAnimCmd_CheckPokeball calls GetPokeBallWobble and stores its `c` - -- into wBattleAnimVar, which the toss script then compares against 1 and - -- 2: 0 = wobble once more, 1 = the click (caught), 2 = it broke free. - -- It is read once per wobble, so count them off against the roll - -- BattleState already made and finish on the branch it decided. - self.wobbles = (self.wobbles or 0) + 1 - local opts = self.opts or {} - if self.wobbles <= (opts.shakes or 0) then - self.vars.var = 0 - else - self.vars.var = opts.caught and 1 or 2 - end - elseif op == "keepsprites" then - -- the capture branch ends on anim_keepsprites: the GB never clears the - -- resting closed ball's OAM, so it stays up through the caught text. - -- Snapshot it here rather than at the end of the script -- the object - -- would otherwise retire itself during the drain. - self.keepSprites = true - local kept = {} - for i, obj in ipairs(self.objects) do kept[i] = obj end - self.keptSprites = #kept > 0 and kept or nil - elseif op == "sound" then - self:playSound(inst) - elseif op == "cry" then - self:playCry() - elseif op == "setvar" then - self.vars.var = inst.args[1] - elseif op == "incvar" then - self.vars.var = (self.vars.var + 1) % 256 - elseif op == "jump" then - if inst.to then self.pc = inst.to end - return - elseif op == "call" then - if inst.to then - self.callStack[#self.callStack + 1] = self.pc + 1 - self.pc = inst.to - return - end - elseif op == "ret" then - local back = table.remove(self.callStack) - if back then - self.pc = back - else - self.done = true - self.tail = TAIL_FRAMES - end - return - elseif op == "loop" then - -- anim_loop count, address: the count lives in the instruction, so the - -- player keeps its own counter keyed by the instruction table itself - self.loops = self.loops or {} - local left = self.loops[inst] - if left == nil then left = inst.args[1] end - left = left - 1 - if left > 0 then - self.loops[inst] = left - if inst.to then self.pc = inst.to; return end - else - self.loops[inst] = nil - end - elseif op == "ifvarequal" then - if self.vars.var == inst.args[1] and inst.to then - self.pc = inst.to - return - end - elseif op == "ifparamequal" then - if self.vars.param == inst.args[1] and inst.to then - self.pc = inst.to - return - end - elseif op == "ifparamand" then - if band(self.vars.param, inst.args[1]) ~= 0 and inst.to then - self.pc = inst.to - return - end - elseif op == "jumpuntil" then - -- BattleAnimCmd_JumpUntil is a countdown, not a wait: it jumps while - -- wBattleAnimParam is nonzero and DECREMENTS it on the way, so a script - -- like Fury Cutter's repeats its slash `param` extra times and then - -- falls through. Spinning on the object list instead never terminated - -- (each pass respawns objects), so those moves only ever ended on the - -- runaway bound below -- the "glitchy and far too long" animations. - if self.vars.param ~= 0 and inst.to then - self.vars.param = self.vars.param - 1 - self.pc = inst.to - return - end - end - self.pc = self.pc + 1 -end - -function Gen2AnimPlayer:playSound(inst) - local Sound = require("src.core.Sound") - -- `anim_sound duration, tracks, id` packs duration/tracks into the first - -- byte and the SFX id into the second. That id is the row of the ROM's - -- SFX pointer table, which is exactly how audio.sfxIndex is keyed, so the - -- animation names its own sample -- moves.lua's anim.sound is only the - -- fallback for moves whose script the importer could not walk. - local audio = self.data and self.data.audio - local id = inst and inst.args and inst.args[2] - local name = id and audio and audio.sfxIndex and audio.sfxIndex[tostring(id)] - if name and audio.sfx and audio.sfx[name] then - return Sound.play(self.data, name) - end - local moves = self.data and self.data.moves - local def = moves and self.moveId and moves[self.moveId] - if def and def.anim then - pcall(Sound.playMove, self.data, def.anim) - end -end - -function Gen2AnimPlayer:playCry() - -- anim_cry plays the ATTACKER's cry -- GROWL and ROAR are the two moves - -- that use it (Gen 1 spelled the same rule IsCryMove). - -- - -- This player does not own the battlers, so it cannot name the species - -- itself; queue the request on the very same effect channel every other op - -- reports through and let BattleState, which does know who is attacking, - -- resolve it. Leaving this an empty stub is why Growl fell silent in Gen 2: - -- the "no subanimation player" fallback in BattleState only runs when there - -- is no player at all, and for a Gen 2 move there always is one. - self.effects[#self.effects + 1] = { cry = true } -end - -function Gen2AnimPlayer:update() - self.frames = (self.frames or 0) + 1 - if self.done then - if self.tail > 0 then self.tail = self.tail - 1 end - if self.tail <= 0 and not self.keepSprites then self.objects = {} end - self:tickObjects() - return - end - if self.wait > 0 then - self.wait = self.wait - 1 - self.bg:playFrame() - self:tickObjects() - return - end - local code = self.script.code - self.ranFrames = (self.ranFrames or 0) + 1 - -- a script whose branches depend on engine state the port does not model - -- can still spin; bound it well past the longest real animation - if self.ranFrames > 180 then - self.done = true - self.tail = TAIL_FRAMES - self.bg:playFrame() - self:tickObjects() - return - end - for _ = 1, MAX_STEPS_PER_FRAME do - local inst = code[self.pc] - if not inst then - self.done = true - self.tail = TAIL_FRAMES - break - end - self:step(inst) - if self.done or self.wait > 0 then break end - end - if self.wait > 0 then self.wait = self.wait - 1 end - self.bg:playFrame() - self:tickObjects() -end - -function Gen2AnimPlayer:sheetImage(gfxId) - local cached = self.images[gfxId] - if cached ~= nil then return cached or nil end - local sheet = self.anims.gfx and self.anims.gfx[gfxId] - local img - if sheet and love and love.graphics and love.graphics.newImage then - local ok, loaded = pcall(love.graphics.newImage, sheet.image) - if ok then img = loaded end - end - if not img then - self:warnOnce("gfx:" .. tostring(gfxId), - "Gen2AnimPlayer: anim sheet %s unavailable", - tostring(sheet and sheet.image or gfxId)) - end - self.images[gfxId] = img or false - return img -end - -function Gen2AnimPlayer:tileQuad(gfxId, tile) - local sheet = self.anims.gfx and self.anims.gfx[gfxId] - if not sheet or tile >= sheet.tiles then return nil end - local perSheet = self.quads[gfxId] - if not perSheet then - perSheet = {} - self.quads[gfxId] = perSheet - end - local q = perSheet[tile] - if q == nil and love and love.graphics and love.graphics.newQuad then - local cols = sheet.cols or 16 - local rows = math.ceil(sheet.tiles / cols) - q = love.graphics.newQuad((tile % cols) * 8, math.floor(tile / cols) * 8, - 8, 8, cols * 8, rows * 8) - perSheet[tile] = q - end - return q -end - --- BattleObjectPals holds six palettes that CGBCopyBattleObjectPals loads --- into OBJ slots 2..7; slots 0 and 1 are the two battlers' own palettes, so --- an object using those falls back to the DMG greys. -local OBJ_PALETTE_BASE = 2 - -function Gen2AnimPlayer:paletteFor(index) - self.palCache = self.palCache or {} - local cached = self.palCache[index] - if cached then return cached end - local pals = self.anims.palettes - local pal = pals and pals[index - OBJ_PALETTE_BASE] - if not pal or #pal < 4 then return DEFAULT_PALETTE end - -- the extractor stores BattleObjectPals as 0-255 channels; the shader - -- takes 0-1 triples - local out = {} - for i = 1, 4 do - out[i] = { pal[i][1] / 255, pal[i][2] / 255, pal[i][3] / 255 } - end - self.palCache[index] = out - return out -end - --- Draw the live objects onto the 160x144 battle canvas. Gen 2 is native --- CGB, so the object palettes come straight from BattleObjectPals and --- BattleState's SGB colorFn is ignored. -function Gen2AnimPlayer:draw() - self:drawSprites(self.objects) -end - --- BattleState keeps the resting closed ball on screen after a capture; Gen 2 --- marks it with anim_keepsprites at the end of the .Click branch, so hand --- back whatever OAM the script deliberately left behind. -function Gen2AnimPlayer:finalSprites() - return self.keptSprites -end - --- Project the actual current OAM rows. This is also the input to the native --- object renderer; readers never advance frames or own the VM's objects. -function Gen2AnimPlayer:oam(objects) - local out = {} - for _, obj in ipairs(objects or self.objects) do - local set = frameset(self, obj) - local frame = set and set.frames[obj.frame] - local row = frame and frame.oam and self.anims.oam and self.anims.oam[frame.oam] - if row then - local flip = band(bxor((frame.xflip and 0x20 or 0) - + (frame.yflip and 0x40 or 0), - obj.def.oamFlags or 0), 0xE0) - local xf = math.floor(flip / 32) % 2 == 1 - local yf = math.floor(flip / 64) % 2 == 1 - local palette = obj.def.palette or 0 - if (obj.fn == 'PokeBall' or obj.fn == 'PokeBallBlocked') - and self.opts and self.opts.ballPalette then palette = self.opts.ballPalette end - for _, sprite in ipairs(row.sprites) do - local ox = obj.x + obj.xOffset + (xf and -sprite.x - 8 or sprite.x) - local oy = obj.y + obj.yOffset + (yf and -sprite.y - 8 or sprite.y) - if ox > 0 and ox < 168 and oy > 0 and oy < 160 then - out[#out + 1] = {x=ox, y=oy, tile=row.tile + sprite.tile, - attr=bxor(sprite.attr, flip), palette=palette, gfx=obj.def.gfx} - end - end - end - end - return out -end - -function Gen2AnimPlayer:drawSprites(objects) - local View = require('src.battle.Gen2AnimView') - self.view = self.view or View.new(self.anims, self.data) - self.presentation = self.presentation or View.runner(self) - self.presentation.selectObjects(objects) - return self.view:drawObjects(self.presentation, self.battle) -end return Gen2AnimPlayer diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index d1a0f7d5c..2225433a6 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -1,4 +1,4 @@ --- Native object drawing over Gen2AnimPlayer's actual OAM projection. +-- Native object drawing over the genuine GSC VM's emitted OAM. -- Object layout/flip math retains Gen2AnimPlayer provenance (Cedric); -- public owner integration by Roxas2712, see NOTICE.md and contribution licence. -- GSC BG/OBJ register integration follows Gen1Recomp535a5ec0 under @@ -49,7 +49,7 @@ function View:quad(gfx, tile, image) local bySheet = self.quads[gfx] or {}; self.quads[gfx] = bySheet if not bySheet[tile] then local w,h = image:getDimensions() - local cols = sheet.cols or 16 + local cols = sheet.cols or sheet.wide or 16 bySheet[tile] = love.graphics.newQuad((tile%cols)*8, math.floor(tile/cols)*8, 8,8,w,h) end return bySheet[tile] @@ -62,9 +62,17 @@ function View:drawObjects(runner, battle) g.setColor(1,1,1,1) local active,activeByte for _, obj in ipairs(runner:oam()) do - local sheet = self.data.gfx and self.data.gfx[obj.gfx] - local image = sheet and self:image(sheet.image) - local quad = image and self:quad(obj.gfx, obj.tile, image) + local entry,index + for i=#runner.loaded,1,-1 do + local loaded=runner.loaded[i] + if obj.tile>=loaded.tile and obj.tile numeric id, because several functions do frameset ARITHMETIC + -- (`ld a, BATTLE_ANIM_FRAMESET_SOUND_1; add [hl]`) while the extractor + -- writes names. Keeping the struct's FRAMESET_ID numeric is what makes + -- those adds mean the same thing they do on the cart. + self.framesetIds = {} + for index, name in ipairs(self.framesetOrder) do + self.framesetIds[name] = index - 1 + end + -- engine/battle_anims/functions.asm:1158 + self.hram = nil + self.obp0 = nil + return self +end + +function Pool:clear() + for slot = 1, NUM_STRUCTS do self.structs[slot] = newStruct() end + self.lastIndex = 0 + self.oam = {} + self.obp0 = nil +end + +-- BattleAnimCmd_ClearObjs. The cart's loop clears $a0 bytes from +-- wActiveAnimObjects and BATTLEANIMSTRUCT_LENGTH is $18, so it reaches six +-- whole structs plus the first sixteen bytes of the seventh -- enough to zero +-- that one's INDEX, and no further. Structs 8-10 keep running: that is the +-- documented bug (docs/bugs_and_glitches.md), and an animation that spawns +-- more than seven objects visibly depends on it. +function Pool:clearObjs() + for slot = 1, 7 do self.structs[slot] = newStruct() end +end + +function Pool:framesetId(name) + local id = self.framesetIds[name] + if not id then error("unknown battle anim frameset: " .. tostring(name)) end + return id +end + +-- ReinitBattleAnimFrameset: swap framesets and restart the frame walk. +local function reinit(st, framesetId) + st.framesetId = u8(framesetId) + st.duration = 0 + st.frame = 0xff -- `ld [hl], -1` +end + +local function deinit(st) st.index = 0 end + +-- InitBattleAnimation. The object row's six bytes land in the struct in +-- order; the seventh field, TILEID, comes from the tile dict instead +-- (GetBattleAnimTileOffset), because where a sheet ended up in VRAM is a +-- property of the running script and not of the object. +function Pool:queue(objectId, x, y, param, tileOffsetFor) + local name = objectId + if type(objectId) == "number" then + name = self.objectOrder[objectId + 1] or objectId + end + local object = (self.data.objects or {})[name] + if not object then return nil end + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index == 0 then + self.lastIndex = u8(self.lastIndex + 1) + st.index = self.lastIndex + st.oamFlags = object.flags or 0 + st.fixY = object.fixY or 0 + st.framesetId = self.framesetIds[object.frameset] or 0 + st.func = object.func or 0 + st.palette = object.palette or 0 + st.tileId = tileOffsetFor and tileOffsetFor(object.gfx) or 0 + st.x, st.y = u8(x), u8(y) + st.xOffset, st.yOffset = 0, 0 + st.param = u8(param or 0) + st.duration = 0 + st.frame = 0xff + st.jt, st.var1, st.var2 = 0, 0, 0 + st.objectId = name + return st + end + end + -- QueueBattleAnimation returns carry when all ten are busy; the script does + -- not look, and neither does anything here. + return nil +end + +function Pool:findByIndex(value) + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index == value then return st end + end + return nil +end + +function Pool:activeCount() + local count = 0 + for slot = 1, NUM_STRUCTS do + if self.structs[slot].index ~= 0 then count = count + 1 end + end + return count +end + +-------------------------------------------------------------------------- +-- GetBattleAnimFrame (engine/battle_anims/helpers.asm) +-------------------------------------------------------------------------- + +-- What a frameset row yields: the OAM set name, or the "wait" / "delete" +-- pseudo-commands BattleAnimOAMUpdate acts on, plus the frame's flip flags. +local function frameYield(row) + local kind = row[1] + if kind == "wait" then return "wait", 0 end + if kind == "delete" then return "delete", 0 end + return row[2], row[4] or 0 +end + +-- `oamwait n` is not skipped here: GetBattleAnimFrame stores n as the struct's +-- duration and hands the command itself back, so the struct genuinely spends +-- n frames drawing nothing. Only BattleAnimOAMUpdate knows what to do with it. +function Pool:getFrame(st) + local frames = (self.data.framesets or {})[self.framesetOrder[st.framesetId + 1]] + if not frames then return nil, 0 end + for _ = 1, 64 do + if st.duration ~= 0 then + st.duration = st.duration - 1 + local row = frames[st.frame + 1] + if not row then return nil, 0 end + return frameYield(row) + end + st.frame = u8(st.frame + 1) + local row = frames[st.frame + 1] + if not row then return nil, 0 end + local kind = row[1] + if kind == "restart" then + st.duration = 0 + st.frame = 0xff + elseif kind == "end" then + -- Step back two so the next pass lands on the frame before this one and + -- then holds it forever. + st.duration = 0 + st.frame = u8(st.frame - 2) + elseif kind == "delete" then + -- `oamdelete` carries no argument; the cart reads the next byte as a + -- duration anyway and then throws the whole struct away, so it does not + -- matter what lands here. + st.duration = 0 + return "delete", 0 + elseif kind == "wait" then + st.duration = u8(row[2]) + return "wait", 0 + else + st.duration = u8(row[3]) + return frameYield(row) + end + end + error("battle anim frameset never yields a frame: " + .. tostring(self.framesetOrder[st.framesetId + 1])) +end + +-------------------------------------------------------------------------- +-- BattleAnimOAMUpdate (engine/battle_anims/core.asm) +-------------------------------------------------------------------------- + +-- AddOrSubtractY / AddOrSubtractX: a flipped entry mirrors around its own +-- 8-pixel cell, which is `-(offset + 8)`. +local function mirror(value, flip) + if not flip then return value end + return u8(-(u8(value) + 8)) +end + +-- InitBattleAnimBuffer. On the enemy's turn the whole object is reflected +-- onto the other side of the field -- but only if its OAMFLAGS ask for it. +function Pool:initBuffer(st) + local buf = { + oamFlags = bit.band(st.oamFlags, OAM_PRIO), + palette = st.palette, + tileId = st.tileId, + x = st.x, y = st.y, + xOffset = st.xOffset, yOffset = st.yOffset, + } + if (self.env.battleTurn or 0) == 0 then return buf end + buf.oamFlags = st.oamFlags + if bit.band(st.oamFlags, FIX_COORDS) == 0 then return buf end + -- x' = (-10 tiles + 4) - x: reflected about the middle of the field. + buf.x = u8((-10 * 8 + 4) - st.x) + if st.fixY == 0xff then + buf.y = u8(5 * 8 + st.y) + else + local y = u8(st.fixY - st.y) + local animId = self.env.animId + -- The three self-targeting animations whose object sits one tile higher + -- on the enemy's side. + if animId == "KINESIS" or animId == "SOFTBOILED" or animId == "MILK_DRINK" then + y = u8(y - 8) + end + buf.y = y + end + buf.xOffset = u8(-st.xOffset) + return buf +end + +-- One struct's OAM entries appended to self.oam. Returns true once the +-- 40-object shadow OAM is full, which is the carry the caller stops on. +function Pool:updateOam(st) + local buf = self:initBuffer(st) + local oamsetName, frameFlags = self:getFrame(st) + if oamsetName == "wait" or oamsetName == nil then return false end + if oamsetName == "delete" then + deinit(st) + return false + end + buf.oamFlags = bit.band(bit.bxor(frameFlags, buf.oamFlags), OAM_FLAG_MASK) + local set = (self.data.oamsets or {})[oamsetName] + if not set then return false end + local tileId = u8(buf.tileId + (set.vtile or 0)) + local yFlip = bit.band(buf.oamFlags, OAM_YFLIP) ~= 0 + local xFlip = bit.band(buf.oamFlags, OAM_XFLIP) ~= 0 + local oam = self.oam + local pool = self.oamPool + for _, entry in ipairs(set.sprites or EMPTY) do + local n = #oam + if n >= OAM_LIMIT then return true end + -- GetSpriteOAMAttr: the frame's flip/priority flags toggle the entry's; + -- OAM_PAL1 passes through from the entry, and the palette slot comes from + -- the struct. + local attr = bit.band(bit.bxor(entry.attr or 0, buf.oamFlags), OAM_FLAG_MASK) + attr = attr + bit.band(entry.attr or 0, OAM_PAL1) + -- Shadow OAM slots are recycled frame to frame (playFrame), the way the + -- cart rewrites wShadowOAM in place: the 40 tables are made once. + local out = pool and pool[n + 1] + if not out then + out = {} + if pool then pool[n + 1] = out end + end + out.y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip)) + out.x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip)) + -- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by + -- every sheet lookup, so the port keeps tiles in sheet-relative space. + out.tile = u8(tileId + (entry.tile or 0)) + out.attr = attr + out.palette = buf.palette + oam[n + 1] = out + end + return false +end + +-- BattleAnim_UpdateOAM_All: run every live struct's function, then let it +-- write its OAM entries. A struct that deinitialises itself inside its +-- function still draws this frame, because the ASM calls BattleAnimOAMUpdate +-- unconditionally. +-- +-- The OAM list and its entries are reused from frame to frame: what a frame +-- returns is valid until the next playFrame, which is all the draw needs. +function Pool:playFrame() + local oam = self.oamList + if not oam then + oam = {} + self.oamList = oam + self.oamPool = {} + end + for i = #oam, 1, -1 do oam[i] = nil end + self.oam = oam + for slot = 1, NUM_STRUCTS do + local st = self.structs[slot] + if st.index ~= 0 then + local fn = AnimObjects.FUNCTIONS[st.func] + if fn then fn(self, st) end + if self:updateOam(st) then break end + end + end + return self.oam +end + +-------------------------------------------------------------------------- +-- engine/battle_anims/functions.asm +-------------------------------------------------------------------------- + +local function incJt(st) st.jt = u8(st.jt + 1) end + +-- BattleAnim_StepToTarget: inches the object toward the opponent's side, half +-- as far vertically as horizontally. The `dec [hl]` loop runs BEFORE `dec e` +-- is tested, so a vertical step of 0 walks the Y coordinate 256 times -- right +-- back where it started, which is the point. +local function stepToTarget(st, speed) + local e = bit.band(speed, 0xf) + st.x = u8(st.x + e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y - (steps == 0 and 256 or steps)) +end + +-- BattleAnim_StepCircle: circular movement whose height is a quarter of its +-- width. +local function stepCircle(st, angle, radius) + st.yOffset = sra(sra(sine(angle, radius))) + st.xOffset = cosine(angle, radius) +end + +-- A 16-bit accumulator spread over two byte fields, which is how every +-- sub-pixel movement here is done: the HIGH byte is the pixel coordinate and +-- the LOW byte the fraction. +local function add16(high, low, delta) + local value = (u8(high) * 256 + u8(low) + delta) % 0x10000 + return math.floor(value / 256), value % 256 +end + +local F = {} + +F.BATTLE_ANIM_FUNC_NULL = function(_, st) + -- anim_incobj is what walks this one to `.one`, which deletes the object. + if st.jt ~= 0 then deinit(st) end +end + +-- BattleAnimFunction_ThrowFromUserToTarget: right 2 and up 1 a frame, with the +-- object's PARAM as the amplitude of a sine on the Y offset. Returns true for +-- "still going", which is the carry the AndDisappear wrapper reads. +local function throwToTarget(st) + if st.x >= 0x88 then return false end + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, st.param) + return true +end + +F.BATTLE_ANIM_FUNC_THROW_TO_TARGET = function(_, st) throwToTarget(st) end + +F.BATTLE_ANIM_FUNC_THROW_TO_TARGET_DISAPPEAR = function(_, st) + if not throwToTarget(st) then deinit(st) end +end + +F.BATTLE_ANIM_FUNC_WAVE_TO_TARGET = function(_, st) + if st.x >= 0x88 then + deinit(st) + return + end + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 4) + st.yOffset = sine(angle, 0x10) + -- The X offset is the cosine divided by sixteen, so the wave is much + -- flatter across than it is up. + st.xOffset = sra(sra(sra(sra(cosine(angle, 0x10))))) +end + +F.BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE = function(_, st) + if st.jt == 0 then + incJt(st) + -- Bit 7 of PARAM starts the object half a turn round; the rest is the + -- radius, so the flag has to come off before it is used as one. + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x20 or 0 + st.param = bit.band(st.param, 0x7f) + end + local angle = st.var1 + st.yOffset = sine(angle, st.param) + st.xOffset = cosine(angle, st.param) + st.var1 = u8(st.var1 + 1) +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET = function(_, st) + if st.jt ~= 0 then + deinit(st) + return + end + if st.x >= 0x84 then return end + stepToTarget(st, st.param) +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET_DISAPPEAR = function(_, st) + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, st.param) +end + +-- GetBallAnimPal: the thrown ball wears the colour of the ball being thrown +-- (data/battle_anims/ball_colors.asm). The battle screen resolves that for +-- wCurItem and hands it over as env.ballPalette. +local function ballPal(self, st) + if self.env.ballPalette then st.palette = self.env.ballPalette end +end + +-- .four: the ball bounces on a shrinking sine while VAR2 steps down by four; +-- when it reaches zero the ball opens. +local function pokeballBounce(self, st) + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 - 1) + if bit.band(st.var1, 0x1f) ~= 0 then return end + -- `ld [hl], a` after the mask: VAR1 is zeroed, not just left on a boundary, + -- so every bounce starts from the same phase. + st.var1 = 0 + st.var2 = u8(st.var2 - 4) + if st.var2 ~= 0 then return end + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_4")) + incJt(st) +end + +-- .eight/.ten: the same sine, but every $10 steps the jumptable advances, so +-- the caught / broke-free branch is only ever reached on a shake boundary. +local function pokeballWobble(_, st) + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 - 1) + if bit.band(st.var1, 0x1f) == 0 then + deinit(st) + return + end + if bit.band(st.var1, 0xf) ~= 0 then return end + incJt(st) +end + +F.BATTLE_ANIM_FUNC_POKEBALL = function(self, st) + local jt = st.jt + if jt == 0 then + ballPal(self, st) + incJt(st) + elseif jt == 1 then + if throwToTarget(st) then return end + -- The arc's Y offset is folded into the coordinate before the ball + -- switches to its opening frameset, so it lands where it fell. + st.y = u8(st.y + st.yOffset) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_3")) + incJt(st) + elseif jt == 3 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_1")) + st.var1, st.var2 = 0, 0x10 + pokeballBounce(self, st) -- .three falls into .four + elseif jt == 4 then + pokeballBounce(self, st) + elseif jt == 6 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_5")) + st.jt = u8(st.jt - 1) + elseif jt == 7 then + ballPal(self, st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_POKE_BALL_2")) + incJt(st) + st.var2 = 0x20 + pokeballWobble(self, st) -- .seven falls into .eight + elseif jt == 8 or jt == 10 then + pokeballWobble(self, st) + elseif jt == 11 then + deinit(st) + end +end + +local function pokeballBlockedFall(_, st) + if st.y >= 0x80 then + deinit(st) + return + end + st.y = u8(st.y + 4) + st.x = u8(st.x - 2) +end + +F.BATTLE_ANIM_FUNC_POKEBALL_BLOCKED = function(self, st) + local jt = st.jt + if jt == 0 then + ballPal(self, st) + incJt(st) + elseif jt == 1 then + if st.x < 0x70 then + throwToTarget(st) + return + end + incJt(st) + pokeballBlockedFall(self, st) -- .next falls into .two + elseif jt == 2 then + pokeballBlockedFall(self, st) + end +end + +F.BATTLE_ANIM_FUNC_EMBER = function(self, st) + local jt = st.jt + if jt == 0 then + -- The upper nybble of PARAM picks which branch this object runs. + st.jt = bit.band(swap(st.param), 0xf) + elseif jt == 1 then + if st.x >= 0x88 then return end + stepToTarget(st, st.param) + elseif jt == 2 then + deinit(st) + elseif jt == 3 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_FLAMETHROWER")) + end +end + +F.BATTLE_ANIM_FUNC_DROP = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x30, 0x48 + end + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + -- Each bounce loses PARAM off the amplitude; once that would go to zero or + -- below, the object is done. + local left = st.var2 - st.param + if left <= 0 then + deinit(st) + return + end + st.var2 = left +end + +F.BATTLE_ANIM_FUNC_USER_TO_TARGET_SPIN = function(_, st) + -- .SetCoords: the lower nybble of PARAM is the horizontal step, half of it + -- the vertical one. + local function setCoords() + local e = bit.band(st.param, 0xf) + st.x = u8(st.x + e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y - (steps == 0 and 256 or steps)) + end + -- .two: a circle whose top is flattened -- the cosine is pulled down by its + -- own radius and halved. + local function orbit() + if st.var1 < 0x40 then + st.yOffset = sra(u8(cosine(st.var1, 0x18) - 0x18)) + st.xOffset = sine(st.var1, 0x18) + st.var1 = u8(st.var1 + bit.band(st.param, 0xf)) + return + end + -- .loop_back: the upper nybble is a lap counter. + local laps = bit.band(st.param, 0xf0) + if laps == 0 then + incJt(st) -- .finish falls into .three + if st.x >= 0xb0 then + deinit(st) + else + setCoords() + end + return + end + st.param = bit.band(st.param, 0xf) + (laps - 0x10) + st.jt = u8(st.jt - 1) + end + local jt = st.jt + if jt == 0 then + if st.x < 0x80 then + setCoords() + return + end + -- .next -> .one -> .two, all on this frame. + incJt(st) + incJt(st) + st.var1 = 0 + orbit() + elseif jt == 1 then + incJt(st) + st.var1 = 0 + orbit() + elseif jt == 2 then + orbit() + elseif jt == 3 then + if st.x >= 0xb0 then + deinit(st) + return + end + setCoords() + end +end + +F.BATTLE_ANIM_FUNC_SHAKE = function(_, st) + -- .done_one: hold for the upper nybble of PARAM, then jump to the other side. + local function flip() + st.var1 = bit.band(swap(st.param), 0xf) + st.xOffset = u8(-st.xOffset) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0 + st.xOffset = bit.band(st.param, 0xf) + flip() -- .zero falls into .one, and VAR1 is 0, so .done_one runs at once + elseif jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + flip() + elseif jt == 2 then + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_FIRE_BLAST = function(self, st) + -- .eight: the travelling flame spirals once it arrives. + local function spin() + local angle = st.var1 + st.yOffset = sine(angle, 0x10) + st.xOffset = cosine(angle, 0x10) + st.var1 = u8(st.var1 + 1) + end + -- .seven: straight across, then hand over to the spiral. + local function travel() + if st.x < 0x88 then + st.x = u8(st.x + 2) + st.y = u8(st.y - 1) + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EMBER")) + spin() -- .set_up_eight falls into .eight + end + local jt = st.jt + if jt == 0 then + -- PARAM picks the branch outright: 7 is the flame that travels, and the + -- rest are the five arms of the blast, which only drift. + st.jt = st.param + if st.param == 7 then + travel() + else + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_BURNED")) + end + elseif jt == 1 then + st.yOffset = u8(st.yOffset - 1) + elseif jt == 2 then + st.xOffset = u8(st.xOffset - 1) + elseif jt == 3 then + st.xOffset = u8(st.xOffset + 1) + elseif jt == 4 then + st.yOffset = u8(st.yOffset + 1) + st.xOffset = u8(st.xOffset - 1) + elseif jt == 5 then + st.yOffset = u8(st.yOffset + 1) + st.xOffset = u8(st.xOffset + 1) + elseif jt == 7 then + travel() + elseif jt == 8 then + spin() + elseif jt == 9 then + deinit(st) + end +end + +-- BattleAnim_ScatterHorizontal: a 16-bit per-frame X step picked from the +-- object's PARAM, so a screenful of leaves fans out instead of moving as one. +local function scatterHorizontal(st) + local param = st.param + if bit.band(param, 0x80) == 0 then + if param >= 0x20 then return 0x100 end + if param >= 0x18 then return 0x180 end + return 0x200 + end + local masked = bit.band(param, 0x3f) + if masked >= 0x20 then return -0x100 end + if masked >= 0x18 then return -0x180 end + return -0x200 +end + +F.BATTLE_ANIM_FUNC_RAZOR_LEAF = function(self, st) + local function arcStep() + local radius = bit.band(st.param, 0x3f) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, radius) + st.x, st.var2 = add16(st.x, st.var2, scatterHorizontal(st)) + end + local function arcOrLand() + if st.var1 >= 0x30 then + arcStep() + return + end + incJt(st) + st.var1, st.var2 = 0, 0 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_RAZOR_LEAF_2")) + -- Bit 6 starts the second frameset six frames in, which is the leaf + -- already half-turned. + if bit.band(st.param, 0x40) ~= 0 then st.frame = 5 end + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0x40 + arcOrLand() -- .zero falls into .one + elseif jt == 1 then + arcOrLand() + elseif jt == 2 then + if st.yOffset == 0x20 then + deinit(st) + return + end + st.xOffset = sine(st.var1, 0x10) + if bit.band(st.param, 0x40) ~= 0 then + st.var1 = u8(st.var1 - 1) + else + st.var1 = u8(st.var1 + 1) + end + st.yOffset, st.var2 = add16(st.yOffset, st.var2, 0x80) + elseif jt == 3 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_RAZOR_LEAF_1")) + st.oamFlags = bit.band(st.oamFlags, 0xff - OAM_XFLIP) + incJt(st) + elseif jt >= 4 and jt <= 7 then + incJt(st) + elseif jt == 8 then + if st.x >= 0xc0 then return end + stepToTarget(st, 8) + end +end + +F.BATTLE_ANIM_FUNC_ROCK_SMASH = function(self, st) + if st.jt == 0 then + -- Bit 6 picks between the two rock framesets. + st.framesetId = u8(rlca(rlca(bit.band(st.param, 0x40))) + + self:framesetId("BATTLE_ANIM_FRAMESET_BIG_ROCK")) + incJt(st) + st.var1 = 0x40 + end + if st.var1 < 0x30 then + deinit(st) + return + end + local radius = bit.band(st.param, 0x3f) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, radius) + st.x, st.var2 = add16(st.x, st.var2, scatterHorizontal(st)) +end + +F.BATTLE_ANIM_FUNC_BUBBLE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0xc + jt = 1 + end + if jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + stepToTarget(st, st.param) + return + end + incJt(st) + st.var1 = 0 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_PULSING_BUBBLE")) + return + end + if jt ~= 2 then return end + if st.x < 0x98 then + st.x, st.var1 = add16(st.x, st.var1, 0x60) + end + if st.y < 0x20 then return end + -- The upper nybble of PARAM is a per-frame rise; `ld d, $ff` is what makes + -- it a NEGATIVE 16-bit step. + st.y, st.var2 = add16(st.y, st.var2, bit.band(st.param, 0xf0) - 0x100) +end + +-- engine/battle_anims/functions.asm:1148 +F.BATTLE_ANIM_FUNC_SURF = function(self, st) + local hram = self.hram + local jt = st.jt + if jt == 0 then + incJt(st) + if hram then + hram.lcdc, hram.lyStart, hram.lyEnd = "SCY", 0x58, 0x5e + end + return + end + if jt == 1 then + if st.y < st.param then + incJt(st) + if hram then hram.lyStart = 0 end + return + end + st.y = u8(st.y - 1) + st.yOffset = sine(st.var1, 0x10) + local top = st.yOffset + st.y - 0x10 + -- `ret c`: the wave stops climbing entirely on the frames the subtraction + -- underflows, offsets and all. + if top < 0 then return end + if hram then hram.lyStart = u8(top) end + st.xOffset = bit.band(st.xOffset + 1, 7) + st.var1 = u8(st.var1 + 2) + return + end + if jt == 3 then + if st.y >= 0x70 then + if hram then + hram.lcdc, hram.lyStart, hram.lyEnd = nil, 0, 0 + end + deinit(st) + return + end + st.y = u8(st.y + 2) + local top = st.y - 0x10 + if top < 0 then return end + if hram then hram.lyStart = u8(top) end + return + end + if jt == 4 then deinit(st) end +end + +F.BATTLE_ANIM_FUNC_SING = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1") + st.param) + end + if st.x >= 0xb8 then + deinit(st) + return + end + stepToTarget(st, 2) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_WATER_GUN = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + jt = 1 + end + if jt == 1 then + if st.y >= 0x30 then + stepToTarget(st, 2) + local angle = st.var1 + st.var1 = u8(st.var1 - 1) + st.yOffset = sine(angle, 8) + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_WATER_GUN_2")) + st.yOffset = 0 + st.y = 0x30 + -- Everything but FIX_COORDS is dropped, so the splash never flips. + st.oamFlags = bit.band(st.oamFlags, FIX_COORDS) + jt = 2 + end + if jt ~= 2 then return end + if st.yOffset >= 0x18 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_WATER_GUN_3")) + return + end + st.yOffset = u8(st.yOffset + 1) +end + +F.BATTLE_ANIM_FUNC_POWDER = function(_, st) + if st.yOffset >= 0x38 then + deinit(st) + return + end + st.yOffset, st.var1 = add16(st.yOffset, st.var1, 0x80) + -- Shakes sixteen pixels either side by toggling one bit. + st.xOffset = bit.bxor(st.xOffset, 0x10) +end + +F.BATTLE_ANIM_FUNC_RECOVER = function(_, st) + if st.jt == 0 then + incJt(st) + st.var2 = bit.band(st.param, 0xf0) -- radius + st.var1 = u8(bit.band(st.param, 0xf) * 8) -- starting angle + st.param = 1 -- reused as an every-other-frame toggle + end + if st.var2 == 0 then + deinit(st) + return + end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = sine(angle, st.var2) + st.xOffset = cosine(angle, st.var2) + st.param = bit.bxor(st.param, 1) + if st.param == 0 then return end + st.var2 = u8(st.var2 - 1) +end + +F.BATTLE_ANIM_FUNC_THUNDER_WAVE = function(self, st) + if st.jt == 1 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_THUNDER_WAVE_EXTRA")) + elseif st.jt == 3 then + deinit(st) + end +end + +-- Clamp/Encore: two halves clap together, twice. The frameset the object +-- switches to is the base or the base + 1 (CLAMP_FLIPPED / ENCORE_HAND_ +-- FLIPPED), picked by the SIGN of the sine, so both halves close together. +F.BATTLE_ANIM_FUNC_CLAMP_ENCORE = function(self, st) + local function step() + local value = sine(st.var1, st.param) + st.xOffset = value + reinit(st, bit.band(value, 0x80) ~= 0 and st.var2 or u8(st.var2 + 1)) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x1f) ~= 0 then return end + incJt(st) -- falls into .two, which is a bare IncAnonJumptableIndex + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = st.framesetId + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x30 or 0x10 + st.param = bit.band(st.param, 0x7f) + step() + elseif jt == 1 then + step() + elseif jt >= 2 and jt <= 5 then + incJt(st) + elseif jt == 6 then + st.jt = 1 + end +end + +F.BATTLE_ANIM_FUNC_BITE = function(self, st) + local function step() + local value = sine(st.var1, st.param) + st.yOffset = value + reinit(st, bit.band(value, 0x80) ~= 0 + and self:framesetId("BATTLE_ANIM_FRAMESET_BITE_1") + or self:framesetId("BATTLE_ANIM_FRAMESET_BITE_2")) + st.var1 = u8(st.var1 + 2) + if bit.band(st.var1, 0x1f) ~= 0 then return end + incJt(st) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = bit.band(st.param, 0x80) ~= 0 and 0x30 or 0x10 + st.param = bit.band(st.param, 0x7f) + step() + elseif jt == 1 then + step() + elseif jt >= 2 and jt <= 5 then + incJt(st) + elseif jt == 6 then + st.jt = 1 + end +end + +F.BATTLE_ANIM_FUNC_SOLAR_BEAM = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x28, 0 + end + local angle = st.param + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + if st.var1 == 0 then + deinit(st) + return + end + -- The radius is a 16-bit value shrinking half a pixel a frame. + st.var1, st.var2 = add16(st.var1, st.var2, -0x80) +end + +-- The gust's radius comes from a nine-entry table, so the whirl pulses rather +-- than turning at a constant width. +local GUST_OFFSETS = { [0] = 8, [1] = 6, [2] = 5, [3] = 4, [4] = 5, [5] = 6, + [6] = 8, [7] = 12, [8] = 16 } + +F.BATTLE_ANIM_FUNC_GUST = function(_, st) + local function wobble() + local radius = GUST_OFFSETS[st.var2] or 8 + local angle = st.var1 + -- Height is a sixteenth of the width, plus PARAM's own drift. + st.yOffset = u8(sra(sra(sra(sra(sine(angle, radius))))) + st.param) + st.xOffset = cosine(angle, radius) + st.var1 = u8(st.var1 - 8) + -- PARAM counts DOWN from 0 through $ff; once it drops below $c2 the whirl + -- settles back to the middle. + if st.param ~= 0 and st.param < 0xc2 then + st.var2, st.param, st.xOffset, st.yOffset = 0, 0, 0, 0 + return + end + st.param = u8(st.param - 1) + if bit.band(st.param, 7) ~= 0 then return end + st.var2 = u8(st.var2 + 1) + end + local function move() + wobble() + st.x = u8(st.x + 1) + if bit.band(st.x, 1) ~= 0 then return end + st.y = u8(st.y - 1) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.param = 0 + wobble() -- .zero falls into .one + elseif jt == 1 or jt == 3 then + wobble() + elseif jt == 2 then + if st.x < 0x88 then + move() + else + incJt(st) + end + elseif jt == 4 then + if st.x < 0xb8 then + move() + else + deinit(st) + end + end +end + +F.BATTLE_ANIM_FUNC_ABSORB = function(_, st) + if st.x < 0x30 then + deinit(st) + return + end + local e = bit.band(st.param, 0xf) + st.x = u8(st.x - e) + local steps = bit.rshift(e, 1) + st.y = u8(st.y + (steps == 0 and 256 or steps)) +end + +F.BATTLE_ANIM_FUNC_WRAP = function(_, st) + -- anim_incobj walks the frameset one step along the BIND_1..4 run. + if st.jt ~= 1 then return end + reinit(st, u8(st.framesetId + 1)) + incJt(st) + st.var1 = 8 +end + +-- BattleAnim_StepThrownToTarget: a parabola whose horizontal step is PARAM's +-- two nybbles read as a 16-bit fixed-point number -- the LOW nybble is the +-- fraction and the HIGH nybble the whole pixels, which is the reverse of how +-- the macro's argument reads. +local function stepThrownToTarget(st) + st.var2 = u8(st.var2 - 1) + st.yOffset = sine(st.var2, 0x20) + st.fixY = u8(st.fixY + 2) + local step = bit.rshift(bit.band(st.param, 0xf0), 4) * 256 + + swap(bit.band(st.param, 0xf)) + st.x, st.var1 = add16(st.x, st.var1, step) + if bit.band(st.var2, 1) ~= 0 then return end + st.y = u8(st.y - 1) +end + +F.BATTLE_ANIM_FUNC_LEECH_SEED = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = 0x40 + elseif jt == 1 then + if st.var2 >= 0x20 then + stepThrownToTarget(st) + return + end + st.var2 = 0x40 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_LEECH_SEED_2")) + incJt(st) + elseif jt == 2 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_LEECH_SEED_3")) + end +end + +F.BATTLE_ANIM_FUNC_SPIKES = function(_, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var2 = 0x40 + elseif jt == 1 then + if st.var2 >= 0x20 then + stepThrownToTarget(st) + return + end + incJt(st) + end +end + +F.BATTLE_ANIM_FUNC_RAZOR_WIND = function(self, st) + F.BATTLE_ANIM_FUNC_MOVE_IN_CIRCLE(self, st) + -- Fifteen extra steps a frame, so the object races round the circle. + st.var1 = u8(st.var1 + 0xf) +end + +local function kickRoll(_, st) + if st.x >= 0x98 then return end + st.x = u8(st.x + 2) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = sine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_KICK = function(_, st) + local jt = st.jt + if jt == 1 then + if st.y < 0x30 then + st.y = u8(st.y + 4) + return + end + st.jt = 0 + elseif jt == 2 then + if st.x >= 0x98 then return end + st.x = u8(st.x + 2) + -- The kick pins itself to the target's side and holds one frame. + st.oamFlags = bit.bor(st.oamFlags, FIX_COORDS) + st.fixY = 0x90 + st.frame = 0 + st.duration = 2 + st.y = u8(st.y - 1) + elseif jt == 3 then + incJt(st) + st.var1 = 0x2c + st.frame = 0 + st.duration = 0x80 + kickRoll(_, st) -- .three falls into .four + elseif jt == 4 then + kickRoll(_, st) + end +end + +F.BATTLE_ANIM_FUNC_EGG = function(self, st) + -- .EggVerticalWaveMotion, shared by both openings. + local function wave() + st.yOffset = sine(st.var1, st.var2) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + st.var2 = u8(st.var2 - 8) + if st.var2 ~= 0 then return end + st.var1, st.var2 = 0, 0 + incJt(st) + end + -- .egg_bomb_step: the egg drifts up half a pixel a frame while it travels. + local function step() + st.x = u8(st.x + 1) + st.y, st.var1 = add16(st.y, st.var1, -0x80) + end + local jt = st.jt + if jt == 0 then + -- The object starts here and then jumps to whichever branch PARAM names, + -- which is how one object serves both Egg Bomb and Softboiled. + st.var1, st.var2 = 0x28, 0x10 + st.jt = st.param + elseif jt == 1 then + if st.x < 0x40 then st.x = u8(st.x + 1) end + wave() + elseif jt == 2 then + if st.x >= 0x88 then + incJt(st) + incJt(st) -- .egg_bomb_done skips straight to .four + return + end + if bit.band(st.x, 0xf) ~= 0 then + step() + return + end + st.var2 = 0x10 + incJt(st) + elseif jt == 3 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + st.jt = u8(st.jt - 1) + step() + elseif jt == 5 then + deinit(st) + elseif jt == 6 then + if st.x < 0x4b then st.x = u8(st.x + 1) end + wave() + elseif jt == 7 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_WOBBLE")) + incJt(st) + elseif jt == 8 then + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.xOffset = sine(angle, 2) + elseif jt == 9 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_CRACKED_BOTTOM")) + st.yOffset = 4 + incJt(st) + elseif jt == 11 then + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_EGG_CRACKED_TOP")) + incJt(st) + st.var1 = 0x40 + elseif jt == 12 then + st.yOffset = sine(st.var1, 0x20) + if st.var1 < 0x30 then + incJt(st) + return + end + st.var1 = u8(st.var1 - 1) + end +end + +F.BATTLE_ANIM_FUNC_MOVE_UP = function(_, st) + -- Runs while the offset is 0 or already past $d8 going negative; anything + -- in between is "far enough up" and ends the object. + if st.yOffset ~= 0 and st.yOffset < 0xd8 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset - st.param) +end + +F.BATTLE_ANIM_FUNC_SOUND = function(self, st) + local function motion() + local angle = st.var2 + st.var2 = u8(st.var2 + 2) + local value = sine(angle, 0x10) + st.xOffset = value + if st.param == 0 then + st.yOffset = u8(-value) + elseif st.param ~= 1 then + st.yOffset = value + end + -- PARAM 1 leaves the Y offset alone: that is the flat sideways wave. + end + if st.jt == 0 then + if (self.env.battleTurn or 0) ~= 0 then + -- `xor $ff; add $3` is 2 - param: the enemy's three angles are the + -- player's three mirrored, 0 <-> 2. + st.param = u8(2 - st.param) + end + incJt(st) + st.var1 = 8 + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SOUND_1") + st.param) + return + end + if st.var1 == 0 then + deinit(st) + return + end + st.var1 = st.var1 - 1 + motion() +end + +F.BATTLE_ANIM_FUNC_CONFUSE_RAY = function(self, st) + if st.jt == 0 then + incJt(st) + st.var2 = bit.band(st.param, 0x3f) + -- Bit 7 becomes both the frameset offset and, once swapped, the radius. + st.param = rlca(bit.band(st.param, 0x80)) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_CONFUSE_RAY_1") + st.param) + return + end + local radius = swap(st.param) + local angle = st.var2 + st.var2 = u8(st.var2 + 1) + st.yOffset = sine(angle, radius) + st.xOffset = cosine(angle, radius) + if st.x >= 0x80 then return end + -- Both tests read the NEW VAR2, and the second `and $1` is applied to what + -- the first `and $3` left, not to the register again. + local phase = bit.band(st.var2, 3) + if phase == 0 then st.y = u8(st.y - 1) end + if bit.band(phase, 1) ~= 0 then return end + st.x = u8(st.x + 1) +end + +F.BATTLE_ANIM_FUNC_DIZZY = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1 = st.framesetId + reinit(st, u8(st.var1 + rlca(bit.band(st.param, 0x80)))) + st.param = bit.band(st.param, 0x7f) + end + local angle = st.param + st.yOffset = sra(sra(sine(angle, 0x10))) + st.xOffset = cosine(angle, 0x10) + -- `ld a, [hl]` then `inc [hl]`: the frameset flip tests the PRE-increment + -- angle, so the two chick frames swap on the same beat as the circle. + st.param = u8(st.param + 1) + local phase = bit.band(angle, 0x3f) + if phase == 0 then + reinit(st, st.var1) + elseif bit.band(phase, 0x1f) == 0 then + reinit(st, u8(st.var1 + 1)) + end +end + +-- Hardcoded Y offsets, one per PARAM. +local AMNESIA_OFFSETS = { [0] = 0xec, [1] = 0xf8, [2] = 0x00 } + +F.BATTLE_ANIM_FUNC_AMNESIA = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_AMNESIA_1") + st.param) + st.yOffset = AMNESIA_OFFSETS[st.param] or 0 + elseif st.jt == 2 then + -- anim_incobj forces the object to deinit; Present is what uses it. + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_FLOAT_UP = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.xOffset = sine(angle, 4) + -- `lb hl, -1, $a0` is the 16-bit constant $ffa0: up 3/8 of a pixel a frame. + st.yOffset, st.var2 = add16(st.yOffset, st.var2, -0x60) +end + +F.BATTLE_ANIM_FUNC_DIG = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 - 2) + st.yOffset = sine(angle, 0x10) + st.x = u8(st.x + 1) +end + +F.BATTLE_ANIM_FUNC_STRING = function(self, st) + if st.jt ~= 0 then return end + incJt(st) + -- PARAM 0 is the one that flips on the enemy's turn. + if st.param == 0 then st.oamFlags = bit.bor(st.oamFlags, OAM_YFLIP) end + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_STRING_SHOT_1") + st.param) +end + +F.BATTLE_ANIM_FUNC_PARALYZED = function(self, st) + if st.jt == 0 then + incJt(st) + st.var1 = 0 + local param = st.param + -- Bits 4-6 become the hold time; bit 7 flips the object, and the low + -- nybble is how far it jitters. + st.param = bit.band(swap(bit.band(param, 0x70)), 0xf) + if bit.band(param, 0x80) == 0 then + st.xOffset = bit.band(param, 0xf) + else + st.xOffset = u8(-bit.band(param, 0xf)) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_PARALYZED_FLIPPED")) + end + return + end + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + st.var1 = st.param + st.xOffset = u8(-st.xOffset) +end + +-- A shared descent: a circle whose height is an eighth of its width, sinking +-- one pixel every few frames until it is $28 down. Spiral Descent checks +-- every eight frames, Petal Dance every four. +local function spiralDescent(st, mask) + local angle = st.var1 + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, mask) ~= 0 then return end + if st.var2 >= 0x28 then + deinit(st) + return + end + st.var2 = u8(st.var2 + 1) +end + +F.BATTLE_ANIM_FUNC_SPIRAL_DESCENT = function(_, st) spiralDescent(st, 7) end +F.BATTLE_ANIM_FUNC_PETAL_DANCE = function(_, st) spiralDescent(st, 3) end + +F.BATTLE_ANIM_FUNC_POISON_GAS = function(_, st) + if st.jt ~= 0 then + spiralDescent(st, 7) + return + end + if st.x >= 0x84 then + incJt(st) + return + end + st.x = u8(st.x + 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x18) + if bit.band(st.x, 1) ~= 0 then return end + st.y = u8(st.y - 1) +end + +F.BATTLE_ANIM_FUNC_SMOKE_FLAME_WHEEL = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.param = u8(st.param + 2) + if bit.band(st.param, 7) ~= 0 then return end + if st.var2 == 0xe8 then + deinit(st) + return + end + st.var2 = u8(st.var2 - 1) +end + +F.BATTLE_ANIM_FUNC_SACRED_FIRE = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.xOffset = cosine(angle, 0x18) + st.param = u8(st.param + 2) + if bit.band(st.param, 3) ~= 0 then return end + if st.var2 == 0xd0 then + deinit(st) + return + end + st.var2 = u8(st.var2 - 2) +end + +F.BATTLE_ANIM_FUNC_PRESENT_SMOKESCREEN = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1, st.var2 = 0x34, 0x10 + elseif st.jt == 2 then + deinit(st) + return + end + if st.jt ~= 1 then return end + if st.x >= 0x6c then return end + stepToTarget(st, 2) + local value = sine(st.var1, st.var2) + -- Only the upper half of the bounce shows: a positive sine is negated, so + -- the puff always sits above its line. + if bit.band(value, 0x80) == 0 then value = u8(-value) end + st.yOffset = value + st.var1 = u8(st.var1 - 4) + -- The halving below is unreachable on the cart: `and $1f` can never leave + -- $20, so Present's puff keeps its height the whole way across. +end + +F.BATTLE_ANIM_FUNC_HORN = function(_, st) + local function spin() + local value = sine(st.var2, 8) + st.xOffset = value + st.y = u8(st.var1 - sra(value)) + st.var2 = u8(st.var2 + 8) + end + local jt = st.jt + if jt == 0 then + st.jt = st.param + st.var1 = st.y + elseif jt == 1 then + if st.x >= 0x58 then return end + stepToTarget(st, 2) + elseif jt == 2 then + if st.var2 >= 0x20 then + deinit(st) + return + end + spin() + elseif jt == 3 then + spin() + end +end + +F.BATTLE_ANIM_FUNC_NEEDLE = function(_, st) + local function line() + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, st.param) + end + local jt = st.jt + if jt == 0 then + -- The upper nybble of PARAM picks straight line or arc. + st.jt = bit.band(swap(st.param), 0xf) + elseif jt == 1 then + line() + elseif jt == 2 then + local value = sine(st.var1, 0x10) + -- Only the negative half is written, so the needle arcs upward only. + if bit.band(value, 0x80) ~= 0 then st.yOffset = value end + st.var1 = u8(st.var1 - 4) + line() -- .two falls into .one + end +end + +F.BATTLE_ANIM_FUNC_THIEF_PAYDAY = function(_, st) + if st.jt == 0 then + incJt(st) + st.var1 = 0x28 + st.var2 = u8(st.y - 0x28) + end + st.yOffset = sine(st.var1, st.var2) + -- PARAM is a MASK, so the coin only drifts left on the frames that clear it. + if bit.band(st.var1, st.param) == 0 then st.x = u8(st.x - 1) end + st.var1 = u8(st.var1 + 1) + if bit.band(st.var1, 0x3f) ~= 0 then return end + st.var1 = 0x20 + st.var2 = bit.rshift(st.var2, 1) +end + +F.BATTLE_ANIM_FUNC_ABSORB_CIRCLE = function(_, st) + local angle = st.param + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + st.param = u8(st.param + 1) + if bit.band(st.param, 1) == 0 then st.x = u8(st.x - 1) end + if bit.band(st.param, 3) == 0 then st.y = u8(st.y + 1) end + if st.x >= 0x5a then + st.var1 = u8(st.var1 + 1) + return + end + if st.var1 == 0 then + deinit(st) + return + end + st.var1 = u8(st.var1 - 1) +end + +F.BATTLE_ANIM_FUNC_CONVERSION = function(_, st) + local angle = st.param + st.param = u8(st.param + 1) + st.yOffset = sine(angle, st.var1) + st.xOffset = cosine(angle, st.var1) + local age = st.var2 + st.var2 = u8(st.var2 + 1) + if age < 0x40 then + st.var1 = u8(st.var1 + 1) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 - 1) + if radius ~= 0 then return end + deinit(st) +end + +F.BATTLE_ANIM_FUNC_BONEMERANG = function(_, st) + if st.jt == 0 then + incJt(st) + st.var2 = st.y + end + st.y = u8(st.var2 + sine(st.param, 0x30)) + -- Eight steps of phase between the two axes is what bends the throw into a + -- boomerang instead of a circle. + st.xOffset = cosine(st.param + 8, 0x30) + st.param = u8(st.param + 1) +end + +F.BATTLE_ANIM_FUNC_SHINY = function(_, st) + if st.jt ~= 0 then return end + incJt(st) + st.yOffset = sine(st.param, 0x10) + st.xOffset = cosine(st.param, 0x10) + st.var2 = 0xf +end + +-- Sky Attack pulses OBP0 rather than moving anything, so the palette write is +-- what the view has to see. +local SKY_ATTACK_GBC = { [0] = 0xff, [1] = 0xaa, [2] = 0x55, [3] = 0xaa } +local SKY_ATTACK_SGB = { [0] = 0xff, [1] = 0xff, [2] = 0x00, [3] = 0x00 } + +F.BATTLE_ANIM_FUNC_SKY_ATTACK = function(self, st) + local function cyclePalette() + local phase = bit.band(st.var2, 7) + st.var2 = u8(st.var2 + 1) + local pals = self.env.sgb and SKY_ATTACK_SGB or SKY_ATTACK_GBC + self.obp0 = bit.band(pals[bit.rshift(phase, 1)] or 0xff, st.var1) + if self.hram then self.hram.obp0 = self.obp0 end + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = (self.env.battleTurn or 0) == 0 and 0xf0 or 0xcc + elseif jt == 1 then + cyclePalette() + elseif jt == 2 then + cyclePalette() + if st.x >= 0x84 then return end + stepToTarget(st, 4) + elseif jt == 3 then + cyclePalette() + if st.x >= 0xd0 then + deinit(st) + return + end + stepToTarget(st, 4) + end +end + +F.BATTLE_ANIM_FUNC_GROWTH_SWORDS_DANCE = function(_, st) + local angle = st.param + st.yOffset = u8(sra(sra(sra(sine(angle, 0x18)))) + st.var2) + st.param = u8(st.param + 1) + st.xOffset = cosine(angle, 0x18) + st.var2 = u8(st.var2 - 2) +end + +F.BATTLE_ANIM_FUNC_STRENGTH_SEISMIC_TOSS = function(_, st) + local jt = st.jt + if jt == 0 then + if st.yOffset == 0xe0 then + incJt(st) + st.var1 = 2 + return + end + st.yOffset, st.var1 = add16(st.yOffset, st.var1, -0x80) + elseif jt == 1 then + if st.var2 ~= 0 then + st.var2 = st.var2 - 1 + return + end + -- Shakes by negating the accumulated step and folding it into the offset + -- every four frames. + st.var2 = 4 + st.var1 = u8(-st.var1) + st.yOffset = u8(st.yOffset + st.var1) + elseif jt == 2 then + if st.x >= 0x84 then + deinit(st) + return + end + stepToTarget(st, 4) + end +end + +F.BATTLE_ANIM_FUNC_SPEED_LINE = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SPEED_LINE_1") + + bit.band(st.param, 0x7f)) + end + if bit.band(st.param, 0x80) ~= 0 then + st.xOffset = u8(st.xOffset - 1) + else + st.xOffset = u8(st.xOffset + 1) + end +end + +F.BATTLE_ANIM_FUNC_SLUDGE = function(self, st) + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0xc + elseif jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + return + end + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_SLUDGE_BUBBLE_BURST")) + st.yOffset = u8(st.yOffset - 1) -- .done falls into .two + elseif jt == 2 then + st.yOffset = u8(st.yOffset - 1) + end +end + +F.BATTLE_ANIM_FUNC_METRONOME_HAND = function(_, st) + local angle = st.var1 + st.var1 = u8(st.var1 + 2) + st.yOffset = sine(angle, 2) + st.xOffset = cosine(angle, 8) +end + +F.BATTLE_ANIM_FUNC_METRONOME_SPARKLE_SKETCH = function(_, st) + if st.yOffset >= 0x20 then + deinit(st) + return + end + st.xOffset = cosine(st.param, 8) + st.param = u8(st.param + 2) + if bit.band(st.param, 7) ~= 0 then return end + st.yOffset = u8(st.yOffset + 1) +end + +F.BATTLE_ANIM_FUNC_AGILITY = function(_, st) + -- anim_incobj is what makes it disappear. + if st.jt ~= 0 then + deinit(st) + return + end + st.x = u8(st.x + st.param) +end + +F.BATTLE_ANIM_FUNC_SAFEGUARD_PROTECT = function(_, st) + local angle = st.param + st.yOffset = sine(angle, 0x18) + st.xOffset = sra(cosine(angle, 0x18)) + st.param = u8(st.param + 1) +end + +F.BATTLE_ANIM_FUNC_LOCK_ON_MIND_READER = function(_, st) + -- .two: hold for $10 frames, then go. + local function hold() + local left = st.var1 + st.var1 = u8(st.var1 - 1) + if left ~= 0 then return end + deinit(st) + end + local jt = st.jt + if jt == 0 then + incJt(st) + st.var1 = 0x28 + -- The low nybble walks along the four LOCK_ON / MIND_READER framesets, + -- and the object's own frameset id is the base of that run. + reinit(st, u8(st.framesetId + bit.band(st.param, 0xf))) + st.param = bit.bor(bit.band(st.param, 0xf0), 8) + jt = 1 + end + if jt == 1 then + if st.var1 ~= 0 then + st.var1 = st.var1 - 1 + -- The radius is the distance still to run, so the ring converges. + local radius = u8(st.var1 + 8) + st.yOffset = sine(st.param, radius) + st.xOffset = cosine(st.param, radius) + return + end + st.var1 = 0x10 + incJt(st) + hold() -- .done falls into .two + return + end + if jt == 2 then hold() end +end + +F.BATTLE_ANIM_FUNC_HEAL_BELL_NOTES = function(self, st) + if st.jt == 0 then + incJt(st) + reinit(st, self:framesetId("BATTLE_ANIM_FRAMESET_MUSIC_NOTE_1") + st.param) + end + if st.yOffset >= 0x38 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset + 1) + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x18) + -- Tests the Y COORDINATE, not the counter: the note drifts left on the + -- frames its row happens to be even. + if bit.band(st.y, 1) ~= 0 then return end + st.x = u8(st.x - 1) +end + +F.BATTLE_ANIM_FUNC_BATON_PASS = function(_, st) + if st.param == 0 then return end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + local value = sine(angle, st.param) + if bit.band(value, 0x80) == 0 then value = u8(-value) end + st.yOffset = value + if bit.band(st.var1, 0x1f) ~= 0 then return end + -- Each bounce is half the last. + st.param = bit.rshift(st.param, 1) +end + +F.BATTLE_ANIM_FUNC_ENCORE_BELLY_DRUM = function(_, st) + if st.var1 >= 0x10 then + deinit(st) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 + 2) + st.yOffset = sine(st.param, radius) + st.xOffset = cosine(st.param, radius) +end + +F.BATTLE_ANIM_FUNC_SWAGGER_MORNING_SUN = function(_, st) + -- The top two bits of PARAM are the speed and the low six the angle; the + -- amplitude is VAR1 as it was BEFORE this frame's speed was added. + local radius = st.var1 + st.var1 = u8(st.var1 + rlca(rlca(bit.band(st.param, 0xc0)))) + local angle = bit.band(st.param, 0x3f) + st.yOffset = sine(angle, radius) + st.xOffset = cosine(angle, radius) +end + +F.BATTLE_ANIM_FUNC_HIDDEN_POWER = function(_, st) + -- .two: the ring expands eight pixels a frame and then vanishes. + local function expand() + if st.var1 >= 0x80 then + deinit(st) + return + end + local radius = st.var1 + st.var1 = u8(st.var1 + 8) + stepCircle(st, st.param, radius) + end + local jt = st.jt + if jt == 0 then + local angle = st.param + st.param = u8(st.param + 1) + stepCircle(st, angle, 0x18) + elseif jt == 1 then + incJt(st) + st.var1 = 0x18 + expand() -- .one falls into .two + elseif jt == 2 then + expand() + end +end + +F.BATTLE_ANIM_FUNC_CURSE = function(_, st) + if st.jt ~= 1 then return end + if st.x < 0x30 then + deinit(st) + return + end + st.x = u8(st.x - 2) + st.y = u8(st.y + 2) +end + +F.BATTLE_ANIM_FUNC_PERISH_SONG = function(_, st) + local angle = st.param + st.param = u8(st.param + 2) + -- VAR1 is both the sink and the counter: the sine is added to it and then + -- it is stepped, so the ring drifts down as it turns. + st.yOffset = u8(sra(sra(sine(angle, 0x50))) + st.var1) + st.var1 = u8(st.var1 + 1) + st.xOffset = cosine(angle, 0x50) +end + +F.BATTLE_ANIM_FUNC_RAPID_SPIN = function(_, st) + if st.yOffset == 0xd0 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset - 4) +end + +F.BATTLE_ANIM_FUNC_BETA_PURSUIT = function(_, st) + local jt = st.jt + if jt == 0 then + if st.param ~= 0 then + incJt(st) + incJt(st) + return + end + incJt(st) + st.yOffset = 0xec + elseif jt == 1 then + if st.yOffset == 4 then + deinit(st) + return + end + st.yOffset = u8(st.yOffset + 4) + elseif jt == 2 then + if st.yOffset == 0xd8 then return end + st.yOffset = u8(st.yOffset - 4) + elseif jt == 3 then + deinit(st) + end +end + +F.BATTLE_ANIM_FUNC_RAIN_SANDSTORM = function(_, st) + -- The Y offset wraps at $70, which is what makes a single object read as a + -- continuous fall of rain. + local function fall(step) + local y = st.yOffset + 4 + st.yOffset = (y < 0x70) and y or 0 + st.xOffset = u8(st.xOffset + step) + end + local jt = st.jt + if jt == 0 then + st.jt = u8(st.param + 1) -- .zero sets the index from PARAM and then incs + elseif jt == 1 then + fall(2) + elseif jt == 2 then + fall(8) + elseif jt == 3 then + fall(4) + end +end + +F.BATTLE_ANIM_FUNC_BATTLE_ANIM_OBJ_B0 = function(_, st) + -- Unused on the cart (nothing names BATTLE_ANIM_OBJ_B0), transcribed + -- because the jumptable slot is real: PARAM's nybbles become a 16-bit step + -- over XCOORD:VAR1, the high nybble duplicated into both halves of the + -- whole-pixel byte. + local high = bit.band(st.param, 0xf0) + high = bit.bor(high, bit.rshift(high, 4)) + st.x, st.var1 = add16(st.x, st.var1, + high * 256 + swap(bit.band(st.param, 0xf))) +end + +F.BATTLE_ANIM_FUNC_PSYCH_UP = function(_, st) + local angle = st.param + st.param = u8(st.param + 1) + stepCircle(st, angle, 0x18) +end + +F.BATTLE_ANIM_FUNC_COTTON = function(_, st) + local phase = st.var2 + st.var2 = u8(st.var2 + 1) + stepCircle(st, u8(bit.rshift(phase, 1) + st.param), 0x18) +end + +F.BATTLE_ANIM_FUNC_ANCIENT_POWER = function(_, st) + if st.var1 >= 0x20 then + deinit(st) + return + end + local angle = st.var1 + st.var1 = u8(st.var1 + 1) + st.yOffset = u8(-sine(angle, st.param)) +end + +AnimObjects.FUNCTIONS = F +AnimObjects.NUM_STRUCTS = NUM_STRUCTS +AnimObjects.OAM_LIMIT = OAM_LIMIT +AnimObjects.OAM_XFLIP = OAM_XFLIP +AnimObjects.OAM_YFLIP = OAM_YFLIP +AnimObjects.OAM_PRIO = OAM_PRIO +AnimObjects.OAM_PAL1 = OAM_PAL1 +AnimObjects.FIX_COORDS = FIX_COORDS +AnimObjects.sine, AnimObjects.cosine = sine, cosine +AnimObjects.u8, AnimObjects.s8, AnimObjects.sra = u8, s8, sra +AnimObjects.swap, AnimObjects.rlca = swap, rlca + +return AnimObjects diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua new file mode 100644 index 000000000..cc2719412 --- /dev/null +++ b/src/battle/gen2/AnimRunner.lua @@ -0,0 +1,485 @@ +-- Gen1Recomp-derived runtime: GPLv3 plus Additional Terms. +-- See compat/gen1-gsc/LICENSE.MD and pinned provenance.json. +-- The Gen 2 battle-animation command interpreter. +-- +-- pokegold engine/battle_anims/anim_commands.asm: RunBattleAnimScript's frame +-- loop and the 48-entry BattleAnimCommands jumptable it dispatches through. +-- The scripts themselves are already disassembled into the cache by +-- RomExtractorGen2 (`data/generated/battle_anims.lua`), keyed by their ROM +-- address because that is what a branch names. +-- +-- One frame of an animation is exactly three things, in this order: +-- +-- RunBattleAnimCommand run script bytes until one asks to wait +-- ExecuteBGEffects one pass over the five BG-effect structs +-- BattleAnim_UpdateOAM_All one pass over the ten object structs +-- +-- so `step()` here is one 60 Hz frame and nothing else needs a clock. The +-- animation is over when a `ret` runs outside a subroutine, which is what +-- BATTLEANIM_STOP_F means. +-- +-- Two things about the script format that are easy to get wrong and are +-- already handled by the extractor, repeated here because this is where they +-- bite: anything under $d0 is `anim_wait ` and carries no arguments, and a +-- branch's target is the LAST two bytes of the command, which the extractor +-- has already rewritten into a pool key. +-- +-- Love-free: sound and cries go out through the `hooks` table the battle +-- screen supplies, so a test can step a whole animation and assert what it +-- asked to play. + +local bit = require("bit") +local AnimObjects = require("src.battle.gen2.AnimObjects") +local BgEffects = require("src.battle.gen2.BgEffects") + +local AnimRunner = {} +local Runner = {} +Runner.__index = Runner + +-- wBattleAnimTileDict is five {gfx id, tile id} pairs. +local NUM_TILEDICT_ENTRIES = 5 +-- BATTLEANIM_BASE_TILE is 7*7; the sheets share the tiles above it up to +-- vTiles1, so the running allocator stops at 128 - 49. +local MAX_ANIM_TILES = 128 - 49 + +-- BattleAnimCmd_BattlerGFX_*: the battlers' own pic tiles are registered in +-- the dict at fixed ids rather than loaded from AnimObjGFX. These are the +-- ASM's `($80 - 6 - 7) - BATTLEANIM_BASE_TILE` and friends. +local BATTLER_TILES = { + oneRow = { player = (0x80 - 6 - 7) - 49, enemy = (0x80 - 6) - 49 }, + twoRow = { player = (0x80 - 6 * 2 - 7 * 2) - 49, enemy = (0x80 - 6 * 2) - 49 }, +} + +-- BattleAnimCmd_Cry's .CryData: a pitch and a length added to the mon's own +-- cry, indexed by the command's argument masked to NUM_NOISE_CHANS. +local CRY_DATA = { + [0] = { pitch = 0x0000, length = 0x00c0 }, + [1] = { pitch = 0x0000, length = 0x0040 }, + [2] = { pitch = 0x0000, length = 0x0000 }, + [3] = { pitch = 0x0000, length = 0x0000 }, +} + +-- BattleAnimCmd_Sound's .GetPanning, indexed by the cry-track pair. +local PANNING = { [0] = 0xf0, [1] = 0x0f, [2] = 0xf0, [3] = 0x0f } + +-------------------------------------------------------------------------- + +-- opts: +-- data the cache's battle_anims.lua +-- constants the cache's constants.lua +-- battleTurn hBattleTurn -- 0 while the player is attacking +-- param wBattleAnimParam, which the effect layer sets (hit count, +-- stat direction, the Beat Up party slot...) +-- animId the move (or ANIM_* id) whose script this is +-- hooks { sound(name, panning, duration), cry(side, pitch, length) } +-- ballPalette the PAL_BATTLE_OB_* name for the ball being thrown +function AnimRunner.new(opts) + opts = opts or {} + local self = setmetatable({}, Runner) + self.data = opts.data or {} + self.constants = opts.constants or {} + self.hooks = opts.hooks or {} + -- Shared by both pools; the object functions and the BG effects read the + -- same hBattleTurn. + self.env = { + battleTurn = opts.battleTurn or 0, + animId = opts.animId, + ballPalette = opts.ballPalette, + sgb = opts.sgb, + flying = opts.flying or {}, + } + self.objects = AnimObjects.new(self.data, self.constants, self.env) + self.bg = BgEffects.new(self.constants, self.env) + -- engine/battle_anims/functions.asm:1158 + self.objects.hram = self.bg + self.animId = opts.animId -- wFXAnimID + self.gfxOrder = self.constants.battleAnimGfxOrder or {} + self.sfxOrder = opts.sfxOrder or {} + + self.param = opts.param or 0 -- wBattleAnimParam + self.var = 0 -- wBattleAnimVar + self.delay = 0 -- wBattleAnimDelay + self.loops = 0 -- wBattleAnimLoops + self.inSubroutine = false + self.inLoop = false + self.stopped = false + self.keepSprites = false + self.frames = 0 + -- wBattleAnimTileDict, and the sheets it points at. + self.tileDict = {} + self.loaded = {} + -- Set by the substitute / minimize / transform commands, for the view. + self.picOverride = { player = nil, enemy = nil } + self.address = nil + self.parent = nil + return self +end + +-- ClearBattleAnims: the whole animation block, then the entry point. +function Runner:start(scriptKey) + self.objects:clear() + self.bg:reset() + self.var, self.delay, self.loops = 0, 0, 0 + self.inSubroutine, self.inLoop, self.stopped = false, false, false + self.keepSprites = false + self.frames = 0 + self.tileDict = {} + self.loaded = {} + self.picOverride = { player = nil, enemy = nil } + self.address = scriptKey and { key = scriptKey, index = 1 } or nil + self.parent = nil + return self +end + +-- The script for a move, or nil when the cache has none (which is what an +-- unextracted or modded move looks like). +function AnimRunner.scriptForMove(data, moveId) + local moves = (data or {}).moves or {} + return moves[moveId] +end + +function Runner:scriptRows(key) + return (self.data.scripts or {})[key] +end + +-- GetBattleAnimByte, one decoded row at a time. +function Runner:fetch() + local at = self.address + if not at then return nil end + local rows = self:scriptRows(at.key) + if not rows then return nil end + local row = rows[at.index] + if not row then return nil end + at.index = at.index + 1 + return row +end + +-- The three "skip the branch target" tails: a conditional that does not take +-- its branch steps the address past the two address bytes, which in a decoded +-- row list is simply "carry on". +function Runner:jumpTo(key) + self.address = { key = key, index = 1 } +end + +-------------------------------------------------------------------------- +-- The tile dict +-------------------------------------------------------------------------- + +-- GetBattleAnimTileOffset: the dict is scanned for the gfx id and its tile +-- returned; a miss is 0, which is why an object whose sheet the script never +-- loaded draws whatever happens to sit at the base tile. +function Runner:tileOffsetFor(gfxId) + for i = 1, NUM_TILEDICT_ENTRIES do + local entry = self.tileDict[i] + if entry and entry.gfx == gfxId then return entry.tile end + end + return 0 +end + +-- BattleAnimCmd_1GFX..5GFX. The running tile id restarts at 0 for every +-- command and each sheet is laid down after the last, so two animations that +-- load different sheet counts do not agree about where anything is -- which +-- is exactly why the dict exists. Entries past the count are NOT cleared. +function Runner:loadGfx(names) + local tile = 0 + for slot, gfxId in ipairs(names) do + if tile >= MAX_ANIM_TILES then break end + local name = gfxId + if type(gfxId) == "number" then + name = self.gfxOrder[gfxId + 1] or gfxId + end + self.tileDict[slot] = { gfx = name, tile = tile } + local sheet = (self.data.gfx or {})[name] + self.loaded[#self.loaded + 1] = { + gfx = name, tile = tile, tiles = (sheet and sheet.tiles) or 0, + } + tile = tile + ((sheet and sheet.tiles) or 0) + end +end + +-- engine/battle_anims/anim_commands.asm:755. The jumptable crosses the macro +-- names: $d9 (anim_battlergfx_2row) dispatches to _1Row (#1401) +function Runner:loadBattlerGfx(rows) + local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow + local slot = 1 + while slot <= NUM_TILEDICT_ENTRIES and self.tileDict[slot] do + slot = slot + 1 + end + if slot + 1 > NUM_TILEDICT_ENTRIES then return end + self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player } + self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy } + self.loaded[#self.loaded + 1] = + { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 7, + battler = "enemy", rows = rows } + self.loaded[#self.loaded + 1] = + { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6, + battler = "player", rows = rows } +end + +-------------------------------------------------------------------------- +-- BattleAnimCommands +-------------------------------------------------------------------------- + +local C = {} + +C.obj = function(self, row) + self.objects:queue(row[2], row[3], row[4], row[5], function(gfx) + return self:tileOffsetFor(gfx) + end) +end + +for count = 1, 5 do + C[count .. "gfx"] = function(self, row) + local names = {} + for i = 1, count do names[i] = row[i + 1] end + self:loadGfx(names) + end +end + +C.incobj = function(self, row) + local st = self.objects:findByIndex(row[2]) + if st then st.jt = AnimObjects.u8(st.jt + 1) end +end + +C.setobj = function(self, row) + local st = self.objects:findByIndex(row[2]) + if st then st.jt = AnimObjects.u8(row[3]) end +end + +C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end + +-- anim_commands.asm:317: $d9 (anim_battlergfx_2row) dispatches to _1Row +C.battlergfx_1row = function(self) self:loadBattlerGfx(2) end +C.battlergfx_2row = function(self) self:loadBattlerGfx(1) end + +-- GetPokeBallWobble's answer, which the ball's own script then branches on. +C.checkpokeball = function(self) + self.var = self.hooks.pokeballWobble and self.hooks.pokeballWobble() or 0 +end + +-- The commands that swap a battler's pic out for something else. The port +-- records which, and the view draws it. +-- +-- Every one of them branches on hBattleTurn the same way +-- (engine/battle_anims/anim_commands.asm): `and a / jr z, .player`, and the +-- .player arm is the one that writes vTiles2 tile $31, the 6x6 BACKPIC slot. +-- So turn 0, the player attacking, always repaints the PLAYER's own pic, and +-- the fall-through arm (tile $00, the 7x7 frontpic) repaints the enemy's. +C.transform = function(self) + -- BattleAnimCmd_Transform: .player loads wTempEnemyMonSpecies into the + -- backpic slot, i.e. the player's sprite becomes what it transformed into. + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = "transform" +end + +C.raisesub = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = "substitute" +end + +C.dropsub = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = false +end + +-- BattleAnimCmd_MinimizeOpp / GetMinimizePic: despite the name it shrinks the +-- ATTACKER, because .player (turn 0) requests the 6x6 block at tile $31. The +-- other minimize opcode, $e9, is one of the dummies below. +C.minimizeopp = function(self) + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = "minimize" +end + +C.beatup = function(self) + -- wBattleAnimParam is the party slot whose pic to show. + local side = self.env.battleTurn == 0 and "player" or "enemy" + self.picOverride[side] = { kind = "beatup", slot = self.param } +end + +C.resetobp0 = function(self) + self.bg.obp0 = self.env.sgb and 0xf0 or 0xe0 +end + +C.sound = function(self, row) + local packed = row[2] or 0 + -- The first byte is BOTH the duration (its top six bits) and the cry-track + -- pair (its bottom two), which is why the same value reads twice here. + local duration = bit.rshift(packed, 2) + local tracks = bit.band(packed, 3) + if self.env.battleTurn ~= 0 then tracks = bit.bxor(tracks, 1) end + local id = row[3] or 0 + local name = self.sfxOrder[id + 1] + if self.hooks.sound then + self.hooks.sound(name, PANNING[tracks] or 0xff, duration, id) + end +end + +C.cry = function(self, row) + local slot = bit.band(row[2] or 0, 3) + local entry = CRY_DATA[slot] or CRY_DATA[0] + local side = self.env.battleTurn == 0 and "player" or "enemy" + if self.hooks.cry then self.hooks.cry(side, entry.pitch, entry.length) end +end + +C.clearobjs = function(self) self.objects:clearObjs() end + +C.oamon = function() end +C.oamoff = function() end +C.updateactorpic = function() end +-- $e7 and $e8-$ed are `ret` on the cart; $f5-$f7 too. $e9 is `minimize`, and +-- it really is one of them: BattleAnimCmd_E8 through BattleAnimCmd_ED are six +-- labels stacked on a single `ret` (engine/battle_anims/anim_commands.asm). +-- The minimize animation that is actually drawn is $e2, minimizeopp above. +C.minimize = function() end +C.unknown_e7 = function() end +C.unknown_ea = function() end +C.unknown_eb = function() end +C.unknown_ec = function() end +C.unknown_ed = function() end +C.unknown_f5 = function() end +C.unknown_f6 = function() end +C.unknown_f7 = function() end + +C.keepsprites = function(self) self.keepSprites = true end + +C.bgp = function(self, row) self.bg.bgp = row[2] end +C.obp0 = function(self, row) self.bg.obp0 = row[2] end +C.obp1 = function(self, row) self.bg.obp1 = row[2] end + +C.bgeffect = function(self, row) + self.bg:queue(row[2], row[3], row[4], row[5]) +end + +C.setvar = function(self, row) self.var = AnimObjects.u8(row[2]) end +C.incvar = function(self) self.var = AnimObjects.u8(self.var + 1) end + +C.if_var_equal = function(self, row) + if row[2] == self.var then self:jumpTo(row[3]) end +end + +C.if_param_equal = function(self, row) + if row[2] == self.param then self:jumpTo(row[3]) end +end + +C.if_param_and = function(self, row) + if bit.band(self.param, row[2] or 0) ~= 0 then self:jumpTo(row[3]) end +end + +-- The one conditional that CONSUMES what it tests: each pass decrements +-- wBattleAnimParam, so `anim_jumpuntil` runs its block param times. +C.jumpuntil = function(self, row) + if self.param == 0 then return end + self.param = AnimObjects.u8(self.param - 1) + self:jumpTo(row[2]) +end + +C.jump = function(self, row) self:jumpTo(row[2]) end + +C.loop = function(self, row) + local count = row[2] or 0 + if not self.inLoop then + -- A count of 0 loops forever and never claims the loop flag. + if count ~= 0 then + self.inLoop = true + self.loops = AnimObjects.u8(count - 1) + end + self:jumpTo(row[3]) + return + end + if self.loops == 0 then + self.inLoop = false + return -- falls through past the target + end + self.loops = self.loops - 1 + self:jumpTo(row[3]) +end + +C.call = function(self, row) + self.parent = { key = self.address.key, index = self.address.index } + self.inSubroutine = true + self:jumpTo(row[2]) +end + +C.ret = function(self) + self.inSubroutine = false + self.address = self.parent + and { key = self.parent.key, index = self.parent.index } or nil +end + +-------------------------------------------------------------------------- + +-- RunBattleAnimCommand: burn the delay, otherwise run script rows until one +-- of them asks to wait or the animation ends. +function Runner:runCommands() + if self.delay ~= 0 then + self.delay = self.delay - 1 + return + end + for _ = 1, 512 do + local row = self:fetch() + if not row then + self.stopped = true + return + end + local cmd = row[1] + if cmd == "ret" then + -- A `ret` outside a subroutine is what ends the whole animation. + if not self.inSubroutine then + self.stopped = true + return + end + C.ret(self, row) + elseif cmd == "wait" then + self.delay = row[2] or 0 + return + else + local fn = C[cmd] + if fn then fn(self, row) end + end + end + -- A script that never waits would hang the battle; stopping is the only + -- honest thing to do with one. + self.stopped = true +end + +-- One frame. Returns false once the animation is over. +function Runner:step() + if self.stopped then return false end + self.frames = self.frames + 1 + self:runCommands() + self.bg:playFrame() + -- A BG effect can ask for an object (the battler-pic ones do), and it has + -- to land before the object pass or it would be a frame late. + for _, spawn in ipairs(self.bg:takeSpawns()) do + self.objects:queue(spawn.object, spawn.x, spawn.y, spawn.param, + function(gfx) return self:tileOffsetFor(gfx) end) + end + self.objects:playFrame() + -- Rollout hands the shake to the first object's Y offset. + if self.bg.rolloutYOffset then + local first = self.objects.structs[1] + if first and first.index ~= 0 then first.yOffset = self.bg.rolloutYOffset end + end + if self.stopped then + -- engine/battle_anims/anim_commands.asm:213 + if not self.keepSprites then + self.objects.oam = {} + else + for _, obj in ipairs(self.objects.oam) do + obj.palette = "PAL_BATTLE_OB_ENEMY" + end + end + return false + end + return true +end + +function Runner:oam() return self.objects.oam end +function Runner:done() return self.stopped end + +AnimRunner.COMMANDS = C +AnimRunner.NUM_TILEDICT_ENTRIES = NUM_TILEDICT_ENTRIES +AnimRunner.MAX_ANIM_TILES = MAX_ANIM_TILES +AnimRunner.BATTLER_TILES = BATTLER_TILES + +return AnimRunner diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 487c5add3..5f0cce32a 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -14787,11 +14787,13 @@ GEN2_ANIM.BY_HANDLER = { IncObj = { "incobj", 1 }, SetObj = { "setobj", 2 }, IncBGEffect = { "incbgeffect", 1 }, - BattlerGFX_1Row = { "battlergfx_1row" }, - BattlerGFX_2Row = { "battlergfx_2row" }, + -- Macro names cross the cartridge handler names: D9's 2row macro + -- dispatches to _1Row. Preserve the decoded macro contract. + BattlerGFX_1Row = { "battlergfx_2row" }, + BattlerGFX_2Row = { "battlergfx_1row" }, -- Prism's names for the same two slots - EnemyFeetObj = { "battlergfx_1row" }, - PlayerHeadObj = { "battlergfx_2row" }, + EnemyFeetObj = { "battlergfx_2row" }, + PlayerHeadObj = { "battlergfx_1row" }, CheckPokeball = { "checkpokeball" }, Transform = { "transform" }, RaiseSub = { "raisesub" }, @@ -15110,7 +15112,7 @@ end -- Name each object `function` slot from DoBattleAnimFrame's jumptable. The -- routines themselves are hand-written asm and cannot be ported as data, but --- the names tell the player which motion to approximate. +-- the names resolve the imported IDs to the genuine GSC object routines. function RomExtractorGen2:gen2AnimFunctions() local out = {} -- Resolved from the routine that dispatches through it, never hardcoded: @@ -15163,6 +15165,7 @@ function RomExtractorGen2:gen2BattleAnims() end return { gen2 = true, + commandFormat = "gsc-macro-v1", scripts = scripts, moveAnims = moveAnims, objects = self:gen2AnimObjects(), diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 975e9022b..8bbfb1772 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1323,6 +1323,10 @@ local function markerFor(version) if version == "red" or version == "blue" or version == "yellow" then revision = "gen1-trade-art-v1:" end + -- Reimport generated animation commands; keep saves and unrelated caches. + if GameVersion.generation(version)==2 then + revision=revision..'gen2-anim-object-v'..require('src.battle.Gen2AnimData').CACHE_REVISION..':' + end return CACHE_FORMAT .. revision .. GameVersion.info(version).sha1 end diff --git a/tests/engine/gen2_animation_identity.lua b/tests/engine/gen2_animation_identity.lua index c8aa0519d..ff6ad4d09 100644 --- a/tests/engine/gen2_animation_identity.lua +++ b/tests/engine/gen2_animation_identity.lua @@ -27,15 +27,17 @@ for _,edition in ipairs({'gold','silver','crystal'})do end T.eq(r.frames,p.frames,'repeated scalar reads observe native time') T.eq(projections,0,'scalar queries never project OAM') - T.eq(#r.objects.structs,0,'object inspection remains separate from OAM projection') + T.eq(#r.objects.structs,10,'real object pool retains all ten hardware slots') + T.eq(r.objects:activeCount(),0,'idle hardware slots are empty') + T.eq(r,p.runtime,'public runner is the actual native interpreter') T.eq(projections,0,'object metadata queries never project OAM') - r:oam();T.eq(projections,1,'explicit sprite drawing projects actual OAM once') + r:oam();T.eq(projections,0,'real OAM is already emitted by the VM, with no draw-time projection') local before=p.frames;p:update();T.eq(r.frames,p.frames,'live frame observation advances with VM') T.eq(p.frames,before+1,'observation does not advance the clock') p:start('FIX_MOVE',false) T.eq(r.animId,expected,'retired observation preserves original resolved program') - T.eq(projections,2,'retiring captures OAM only once') - r.retire();T.eq(projections,2,'repeated retirement does not rebuild sprites') + T.eq(projections,0,'old runner keeps its own OAM without retirement projection') + r.retire();T.eq(projections,0,'repeated retirement does not rebuild sprites') end local p=Player.new(data) data.battle_anims.moveAnims.TOSS_ANIM=1 diff --git a/tests/engine/gen2_animation_objects.lua b/tests/engine/gen2_animation_objects.lua index e314757a9..1c492296f 100644 --- a/tests/engine/gen2_animation_objects.lua +++ b/tests/engine/gen2_animation_objects.lua @@ -8,7 +8,7 @@ local Wide=require('src.battle.WideBattle') local oldVersion=Version.get() local function fixture() return {battle_anims={gen2=true,moveAnims={FIX_MOVE=1},misc={throwPokeBall=1}, - scripts={[1]={entry=1,code={{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, + scripts={[1]={entry=1,code={{op='gfx',ids={2}},{op='obj',args={0,40,40,0}},{op='delay',frames=5},{op='ret'}}}}, objects={[0]={oamFlags=0,fixY=255,frameset=0,fn=0,palette=4,gfx=2}},functions={[0]='Null'}, framesets={[0]={frames={{oam=0,duration=20}},loop='hold'}}, oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0},{x=8,y=0,tile=1,attr=32}}}}, @@ -22,40 +22,36 @@ for _,edition in ipairs({'gold','silver','crystal'})do T.eq(p:start('FIX_MOVE',true,{param=17}),true,'native bytecode starts normally') T.eq(p.vars.param,17,'native script receives its caller parameter') p:update() - local native=p.objects[1] + local native=p.objects.structs[1] local rows=p:oam() T.eq(#rows,2,'actual frame OAM contains both object tiles') - T.same(rows[1],{x=40,y=40,tile=0,attr=0,palette=4,gfx=2},'first native sprite projection') - T.same(rows[2],{x=48,y=40,tile=1,attr=32,palette=4,gfx=2},'second native sprite retains tile and flip') + T.same(rows[1],{x=40,y=40,tile=0,attr=0,palette='PAL_BATTLE_OB_RED'},'first native sprite projection') + T.same(rows[2],{x=48,y=40,tile=1,attr=32,palette='PAL_BATTLE_OB_RED'},'second native sprite retains tile and flip') local runner=View.runner(p) T.eq(runner.frames,1,'runner observes actual update clock') T.eq(runner.objects.structs[1].index,native.index,'runner observes actual native object identity') - T.eq(runner.objects.structs[1].jt,native.state,'runner observes actual object state machine') - local age,frame=native.age,p.frames + T.eq(runner.objects.structs[1].jt,native.jt,'runner observes actual object state machine') + local age,frame=native.duration,p.frames runner:oam();runner:oam() - T.eq(native.age,age,'OAM reads cannot tick object motion') + T.eq(native.duration,age,'OAM reads cannot tick object motion') T.eq(p.frames,frame,'OAM reads cannot tick script time') p:update();T.eq(runner.frames,2,'delay frames count on actual native timeline') - data.battle_anims.framesets[0].frames[1].xflip=true + p.runtime.data.framesets[p.objects.framesetOrder[1]][1][4]=0x20;p:update() rows=p:oam() T.eq(rows[1].x,32,'object X flip negates offset and eight-pixel cell width') T.eq(rows[1].attr,32,'object X flip reaches sprite attributes') T.eq(rows[2].attr,0,'object flip XORs a sprite already flipped') - data.battle_anims.framesets[0].frames[1].yflip=true + p.runtime.data.framesets[p.objects.framesetOrder[1]][1][4]=0x60;p:update() T.eq(p:oam()[1].y,32,'object Y flip negates native row offset') - native.fn,native.state='PokeBall',5 - p.opts.ballPalette='TEST_BALL' - T.eq(runner.objects.structs[1].func,'BATTLE_ANIM_FUNC_POKEBALL','source reads the actual native ball function') - T.eq(runner.objects.structs[1].jt,5,'source reads real landed ball state') - T.eq(p:oam()[1].palette,'TEST_BALL','typed native ball palette follows actual object') - p.vars.var,p.keepSprites=1,true + T.eq(runner.objects,p.objects,'public runner exposes the real mutable struct pool') + p.vars.var,p.runtime.keepSprites=1,true T.eq(runner.var,1,'capture result observation uses VM variable') T.eq(runner.keepSprites,true,'capture retention observation uses VM flag') p.presentation=runner local ticks=runner.frames p:start('FIX_MOVE',false) T.eq(runner.frames,ticks,'restarting VM cannot rebind previous animation observation') - T.eq(runner:oam()[1].palette,'TEST_BALL','retired OAM retains its own throw') + T.eq(runner:oam()[1].palette,'PAL_BATTLE_OB_RED','retired OAM retains its own actual palette') T.eq(p.frames,0,'new animation starts with its own actual clock') p:update() local g=love.graphics;local draw=g.draw;local calls={} diff --git a/tests/engine/gen2_bg_object_palette.lua b/tests/engine/gen2_bg_object_palette.lua index c9cc8b774..d034303f1 100644 --- a/tests/engine/gen2_bg_object_palette.lua +++ b/tests/engine/gen2_bg_object_palette.lua @@ -17,7 +17,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do local B={{245,239,227},{31,97,193},{167,59,131},{7,13,19}} local C={{221,231,241},{71,81,91},{101,111,121},{11,21,31}} local data={battle_anims={gen2=true,moveAnims={PROBE=1}, - scripts={[1]={entry=1,code={{op='obp0',args={0x1b}}, + scripts={[1]={entry=1,code={{op='gfx',ids={1}},{op='obp0',args={0x1b}}, {op='obj',args={0,64,48,0}},{op='obj',args={1,72,48,0}}, {op='obj',args={2,80,48,0}},{op='delay',frames=2},{op='ret'}}}}, objects={},functions={[0]='Null'},framesets={[0]={frames={{oam=0,duration=8}},loop='hold'}}, diff --git a/tests/engine/gen2_native_bg_clock.lua b/tests/engine/gen2_native_bg_clock.lua index e60a10a37..2311bc0dd 100644 --- a/tests/engine/gen2_native_bg_clock.lua +++ b/tests/engine/gen2_native_bg_clock.lua @@ -33,9 +33,10 @@ for _,edition in ipairs({'gold','silver','crystal'})do for _=1,20 do local bg,frames=r.bg,r.frames end T.eq(ticks,frame,'repeated consumer observations never run the effect') end - p:update();T.eq(ticks,14,'the final command frame executes one BG pass') + p:update();T.eq(ticks,14,'the last wait frame executes one BG pass') + p:update();T.eq(ticks,15,'ret executes on the next native command frame') for _=1,20 do p:update()end - T.eq(ticks,14,'legacy object-tail drain cannot keep advancing a stopped BG program') + T.eq(ticks,15,'a stopped VM has no object-tail clock') local old=r.bg p:start('PROBE',not playerTurn) T.eq(r.bg,old,'retired consumer retains the previous register block') @@ -62,7 +63,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do p:start('PROBE',true);p:update() local active=p.bg.effects[1] T.eq(active.jt,1,'native Teleport initializes its running state') - p:update() + p:update();p:update() T.eq(active.func,nil,'increment advances Teleport into its native terminal state') T.eq(p.bg.lcdc,nil,'native terminal state retires its LCD window') T.eq(p.bg.obp0,0x8c,'native OBJ0 palette command preserves byte semantics') From 7ea2eff1a08af510824baccc0cf079a1182b1740 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:20:16 +0100 Subject: [PATCH 41/64] Include ROM-free native GSC motion and import regression fixtures --- .../engine/gen2_animation_import_commands.lua | 61 ++++++++ tests/engine/gen2_object_motion_pixels.lua | 40 ++++++ tests/engine/gen2_object_runtime.lua | 135 ++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 tests/engine/gen2_animation_import_commands.lua create mode 100644 tests/engine/gen2_object_motion_pixels.lua create mode 100644 tests/engine/gen2_object_runtime.lua diff --git a/tests/engine/gen2_animation_import_commands.lua b/tests/engine/gen2_animation_import_commands.lua new file mode 100644 index 000000000..30803ed7b --- /dev/null +++ b/tests/engine/gen2_animation_import_commands.lua @@ -0,0 +1,61 @@ +-- Real ROM-handler dispatch and script decoder, using synthetic device bytes. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local E=require('src.import.RomExtractorGen2') +local Player=require('src.battle.Gen2AnimPlayer') +local Importer=require('src.import.RomImporter') +local Version=require('src.core.GameVersion') +local previous=Version.get() +-- Older engine bases have the same real readiness gate without the newer +-- public cache descriptor. Seed its genuine marker, never a guessed format. +local markerFor +for i=1,100 do + local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end + if not name then break end +end +assert(markerFor) +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local e=E.new(nil,edition,{symbols={BattleAnimCommands={1,0x4000}, + BattleAnimCmd_BattlerGFX_1Row={1,0x4100}, + BattleAnimCmd_BattlerGFX_2Row={1,0x4120}}}) + e.rom={word=function(_,bank,address) + T.eq(bank,1,'decoder reads actual command-table bank') + if address==0x4012 then return 0x4100 end + if address==0x4014 then return 0x4120 end + return 0 + end,byte=function(_,bank,address) + return assert(({[0x5000]=0xd9,[0x5001]=0xda,[0x5002]=0xff})[address]) + end} + local script=e:gen2AnimScript(1,0x5000) + T.eq(script.code[1].op,'battlergfx_2row','D9 handler retains its crossed macro name') + T.eq(script.code[2].op,'battlergfx_1row','DA handler retains its crossed macro name') + local p=Player.new({battle_anims={gen2=true,moveAnims={PROBE=1},scripts={[1]=script}}}) + T.check(p:start('PROBE',true));p:update() + local r=p.runtime + T.eq(r.tileDict[1].tile,66,'D9 loads real one-row player-head tile base') + T.eq(r.tileDict[2].tile,73,'D9 loads real one-row enemy-foot tile base') + T.eq(r.tileDict[3].tile,53,'DA loads real two-row player-head tile base') + T.eq(r.tileDict[4].tile,67,'DA loads real two-row enemy-foot tile base') + T.eq(r.loaded[1].rows,1,'actual loaded descriptor has one row') + T.eq(r.loaded[3].rows,2,'actual loaded descriptor has two rows') + p:release() +end +for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local marker=markerFor(edition) + T.check(marker:find('gen2%-anim%-object%-v1:')~=nil,'generated animation cache has a new contract') + Version.set(edition) + local old=marker:gsub('gen2%-anim%-object%-v1:','') + love.filesystem.write(Version.cachePrefix(edition)..'rom-cache.complete',old) + local report=Importer.readyReport(edition) + T.eq(report.ok,false,'old handler-labelled cache must be rebuilt') + T.check(report.why and report.why:find('another version',1,true)~=nil, + 'real readiness gate refuses the old format before checking file gaps') +end +for _,edition in ipairs({'red','emerald','platinum'})do + T.check(not markerFor(edition):find('gen2%-anim%-object'), + 'unrelated cartridge cache contract is retained') +end +Version.set(previous) +T.finish('native GSC crossed battler-row handler import and legacy-cache refusal') diff --git a/tests/engine/gen2_object_motion_pixels.lua b/tests/engine/gen2_object_motion_pixels.lua new file mode 100644 index 000000000..5cc50ceef --- /dev/null +++ b/tests/engine/gen2_object_motion_pixels.lua @@ -0,0 +1,40 @@ +-- Counterexample through the native player's actual OAM consumer. The old +-- archetype interpolator produces different pixels and retires at 48 frames. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Player=require('src.battle.Gen2AnimPlayer') +local previous=Version.get() +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,player in ipairs({true,false})do + local data={battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code={{op='gfx',ids={1}}, + {op='obj',args={0,64,96,4}},{op='delay',frames=200},{op='ret'}}}}, + objects={[0]={oamFlags=1,fixY=255,frameset=0,fn=0,palette=2,gfx=1}}, + functions={[0]='MoveFromUserToTarget'}, + framesets={[0]={frames={{oam=0,duration=1}},loop='restart'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[1]={tiles=1,cols=1,image='native-motion'}}}} + local p=Player.new(data);T.check(p:start('PROBE',player)) + for frame=1,30 do + p:update() + local x=math.min(132,64+4*frame) + local y=96-math.min(17,frame)*2 + local row=p:oam()[1] + T.eq(row and row.x,player and x or 180-x,'actual cartridge X step and stop threshold') + T.eq(row and row.y,player and y or 40+y,'actual cartridge half-speed Y step') + end + for _=31,60 do p:update()end + T.eq(#p:oam(),1,'restarting object remains visible after 48 frames') + for _=61,202 do p:update()end + T.eq(p:isDone(),true,'full wait followed by return has no synthetic tail') + T.eq(#p:oam(),0,'normal top-level return immediately clears OAM') + local terminal=p.frames + for _=1,20 do p:update()end + T.eq(p.frames,terminal,'no clocks run after terminal return') + p:release() + end +end +Version.set(previous) +T.finish('native GSC actual object pixels, cartridge lifetime and terminal timing') diff --git a/tests/engine/gen2_object_runtime.lua b/tests/engine/gen2_object_runtime.lua new file mode 100644 index 000000000..e3e2e5b42 --- /dev/null +++ b/tests/engine/gen2_object_runtime.lua @@ -0,0 +1,135 @@ +-- Actual imported native commands through the GSC object/VM owner. +-- Expectations name cartridge behaviour, not a second simulated controller. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Player=require('src.battle.Gen2AnimPlayer') +local Version=require('src.core.GameVersion') +local previous=Version.get() +local function fixture(code,fn,param,frames) + return {battle_anims={gen2=true,moveAnims={PROBE=1}, + scripts={[1]={entry=1,code=code}}, + functions={[0]=fn or 'Null'}, + objects={[0]={oamFlags=1,fixY=255,frameset=0,fn=0,palette=2,gfx=1}}, + framesets={[0]=frames or {frames={{oam=0,duration=1}},loop='restart'}}, + oam={[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}}}, + gfx={[1]={tiles=1,cols=1,image='native-object'}}}} +end +local function make(fn,param,player,frames,code) + local p=Player.new(fixture(code or {{op='gfx',ids={1}}, + {op='obj',args={0,64,96,param or 0}}, + {op='delay',frames=200},{op='ret'}},fn,param,frames)) + T.check(p:start('PROBE',player~=false),'actual native imported program starts') + return p +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + for _,player in ipairs({true,false})do + local p=make('MoveFromUserToTarget',4,player) + p:update() + local r=p.runtime + local s=r.objects.structs[1] + T.eq(p.objects,r.objects,'native player exposes actual ten-slot pool') + T.eq(p.bg,r.objects.hram,'object functions and BG share actual HRAM') + T.eq(r.env,p.bg.env,'both pools read the same battle environment') + T.eq(s.func,'BATTLE_ANIM_FUNC_USER_TO_TARGET','imported function name resolves real routine') + T.eq(s.x,68,'target routine advances PARAM pixels on its first frame') + T.eq(s.y,94,'target routine advances half as many vertical pixels') + local row=r:oam()[1] + T.eq(row.x,player and 68 or 112,'enemy mirror is applied at OAM output, after motion') + T.eq(row.y,player and 94 or 134,'enemy Y fix is applied at OAM output') + for _=1,17 do p:update()end + T.eq(s.x,132,'target routine stops at its cartridge threshold') + T.eq(s.y,62,'target movement uses actual coordinate state') + T.eq(s.xOffset,0,'target movement is not an interpolated visual offset') + for _=1,42 do p:update()end + T.eq(s.index,1,'restarting frameset object survives beyond former 48-frame cap') + T.eq(r.frames,60,'only native updates advance the interpreter') + for _=1,30 do r:oam()end + T.eq(r.frames,60,'drawing and observation never step the interpreter') + T.eq(s.index,1,'OAM observation cannot retire an object') + for _=61,201 do p:update()end + T.eq(p:isDone(),false,'real anim_wait200 retains all two hundred wait frames') + p:update() + T.eq(r.frames,202,'top-level return follows command frame and full wait') + T.eq(p:isDone(),true,'top-level return is immediately terminal') + T.eq(#r:oam(),0,'terminal normal animation clears OAM immediately') + for _=1,25 do p:update()end + T.eq(r.frames,202,'terminal animation has no arbitrary drain clock') + local old=r + p:start('PROBE',not player) + T.check(old~=p.runtime,'restart creates a distinct real interpreter') + T.eq(old.frames,202,'retained old interpreter keeps its own time') + T.eq(old.objects.structs[1],s,'retained consumer keeps actual old object identity') + p:release() + end + do + local p=make('Null',0,true,nil,{{op='gfx',ids={1}}, + {op='obj',args={0,64,96,0}},{op='delay',frames=1}, + {op='incobj',args={1}},{op='delay',frames=1},{op='ret'}}) + p:update();local s=p.objects.structs[1] + p:update();T.eq(s.index,1,'wait burns a complete native frame') + p:update() + T.eq(s.jt,1,'incobj changes real jump-table state instead of choosing a frameset') + T.eq(s.index,0,'Null routine deinitializes through its actual state machine') + T.eq(#p:oam(),1,'deinitializing function still emits OAM on that same frame') + p:update();T.eq(#p:oam(),0,'the next OAM pass skips the retired slot') + p:update();T.eq(p:isDone(),true,'no synthetic object-tail delay follows ret') + p:release() + end + do + local p=make('MoveInCircle',0x88) + p:update();local s=p.objects.structs[1] + T.eq(s.param,8,'circle bit7 selects phase, not radius') + T.eq(s.var1,33,'circle init falls through into its first motion on the same frame') + T.eq(s.xOffset,248,'half-turn circle offset uses a signed wrapping byte') + T.eq(s.yOffset,0,'half-turn circle starts on the horizontal diameter') + p:release() + end + do + local code={{op='gfx',ids={1}}} + for _=1,11 do code[#code+1]={op='obj',args={0,64,96,0}}end + code[#code+1]={op='delay',frames=1} + code[#code+1]={op='clearobjs'} + code[#code+1]={op='delay',frames=1} + code[#code+1]={op='ret'} + local p=make('Null',0,true,nil,code) + p:update();T.eq(p.objects:activeCount(),10,'hardware pool drops the eleventh object') + T.eq(p.objects.lastIndex,10,'dropped object does not consume an index') + p:update();p:update() + T.eq(p.objects:activeCount(),3,'cartridge ClearObjs preserves slots eight through ten') + T.eq(p.objects.structs[8].index,8,'retained hardware slot keeps identity') + p:release() + end + do + local p=make('Null',0,true) + local sprites=p.runtime.data.oamsets[0].sprites + for i=2,50 do sprites[i]={x=i-1,y=0,tile=0,attr=0}end + p:update();T.eq(#p:oam(),40,'shadow OAM is bounded by actual forty-entry hardware limit') + p:release() + end + do + local p=make('Null',0,true,{frames={{wait=true,duration=1}, + {oam=0,duration=0}},loop='delete'}) + p:update();T.eq(#p:oam(),0,'oamwait emits no sprite on its command frame') + p:update();T.eq(#p:oam(),0,'oamwait preserves its full duration') + p:update();T.eq(#p:oam(),1,'next actual frameset row emits its sprite') + p:update();T.eq(p.objects:activeCount(),0,'oamdelete deinitializes its actual struct') + p:release() + end + do + local p=make('Null',0,true,nil,{{op='gfx',ids={1}}, + {op='obj',args={0,64,96,0}},{op='delay',frames=1}, + {op='keepsprites'},{op='ret'}}) + p:update();p:update();p:update() + T.eq(p:isDone(),true,'capture terminal is immediate') + T.eq(p:oam()[1].palette,'PAL_BATTLE_OB_ENEMY','retained OAM follows native terminal palette rule') + local kept=p:finalSprites() + T.eq(kept[1].x,64,'captured terminal keeps the actual final OAM') + local old=p.runtime;p:start('PROBE',false) + T.eq(kept.runner,old,'retained ball drawing keeps its genuine runner metadata') + T.eq(kept[1].x,64,'new animation cannot change the retained ball coordinates') + p:release() + end +end +Version.set(previous) +T.finish('native GSC object motion, hardware slots, OAM, waits and terminal ownership') From 91f27b2dea50854a3541cda1a1061eae4b9a2841 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:25:52 +0100 Subject: [PATCH 42/64] Record fork ROM traces separately from upstream GPU qualification --- compat/gen1-gsc/provenance.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json index d8931f7b6..5114142c5 100644 --- a/compat/gen1-gsc/provenance.json +++ b/compat/gen1-gsc/provenance.json @@ -27,5 +27,5 @@ "The native importer preserves the crossed battler-row macro/handler names. A Gen2-only cache revision rejects old generated commands and preserves game saves." ], "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", - "qualification": "ROM-free actual-pixel counterexample fails 366 checks on the preceding native player; replacement passes. Initial actual Crystal ROM comparison matches 251 move and 23 extra-animation programs on both sides with four parameters (2192 cases); six high-parameter cases reach the observation limit equally on both interpreters. Expanded traces, new GPU frames, Windows and packaged release qualification remain pending. Earlier Metal battle ce12c485 predates the object/VM replacement." + "qualification": "Actual Crystal ROM command/object/BG/OAM comparison against pinned Gen1Recomp535a5ec0: 251 moves and 23 extra programs, both sides, five parameters plus ball/shake/catch cases, 2804 cases and355718 compared frames, no mismatch or observation-limit case. Source e96a8edc; later publication changes only comments, provenance and cache-gate fixture access. Fork a0474fcd passes7893 checks in19 ROM-free groups; independent upstream7ea2eff1 passes5313 in14. These are CPU observations; the old ce12c485 Metal battle predates this VM. New unchanged-client GPU, all picture-override rendering, full send-out/event integration, Windows and packaged release qualification remain open." } From afde9aea0ad4a89f34d0f8b54216c1c1e44473c9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:06:21 +0100 Subject: [PATCH 43/64] Apply GSC command-time picture ownership to native controller --- .../workflows/gen2-picture-presentation.yml | 26 +++ src/battle/BattleState.lua | 36 +++- src/battle/Gen2PicturePresentation.lua | 164 ++++++++++++++++++ src/battle/MoveEffects.lua | 5 +- tests/engine/gen2_picture_pixels.lua | 83 +++++++++ tests/engine/gen2_picture_presentation.lua | 160 +++++++++++++++++ 6 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/gen2-picture-presentation.yml create mode 100644 src/battle/Gen2PicturePresentation.lua create mode 100644 tests/engine/gen2_picture_pixels.lua create mode 100644 tests/engine/gen2_picture_presentation.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml new file mode 100644 index 000000000..8c2491985 --- /dev/null +++ b/.github/workflows/gen2-picture-presentation.yml @@ -0,0 +1,26 @@ +name: Native GSC picture and animation owners +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gen2-picture-${{ github.ref }} + cancel-in-progress: true +jobs: + picture-contract: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - name: Install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Native picture commands and emitted images + run: | + luajit tests/engine/gen2_picture_presentation.lua + luajit tests/engine/gen2_picture_pixels.lua + - name: Existing command, object and background owners + run: | + luajit tests/engine/gen2_object_runtime.lua + luajit tests/engine/gen2_object_motion_pixels.lua + luajit tests/engine/gen2_bg_presenter.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 690b5ceac..1d88b45ec 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2737,6 +2737,7 @@ function BattleState:updateQueue() end if self.animPlaying then self.animPlayer:update() + if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').sync(self) end if self.animPlayer.pollEffects and self.applyAnimEffect then for _, ev in ipairs(self.animPlayer:pollEffects()) do self:applyAnimEffect(ev) @@ -2776,6 +2777,7 @@ function BattleState:updateQueue() return true end if not self.current then + if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').sync(self) end local item = table.remove(self.queue, 1) if not item then return false end if item.fn then @@ -3021,6 +3023,10 @@ function BattleState:updateQueue() self:applyHitFx(item.hit) end end + if GameVersion.isGen2() then + require('src.battle.Gen2PicturePresentation').admitted(self,item, + self.animPlaying and self.anim or nil) + end self.current = nil return true end @@ -4175,6 +4181,14 @@ end -- frame if one is up, otherwise the still. Placement is always measured off -- the still (same dimensions, same ground padding), so only the texture -- changes -- see drawBattlerPic and the enemy branch of drawPics. +function BattleState:pictureSprite(battler) + return battler.sprite +end + +function BattleState:pictureStill(battler) + return self:picImage(self:pictureSprite(battler)) +end + function BattleState:battlerPic(battler) local still = self:picImage(battler.sprite) local anim = battler and battler.picAnim @@ -11694,6 +11708,10 @@ function BattleState:drawBattlerPic(battler, x, y, scale) if not ok then error(err) end end +function BattleState:substituteVisible(battler) + return battler and battler.substituteHP +end + function BattleState:drawBattlerPicAt(battler, x, y, scale) local shakeX, shakeY = self:gen3AnimShake(battler) x, y = x + shakeX, y + shakeY @@ -11729,7 +11747,7 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) end end local img = self:battlerPic(battler) - if battler.substituteHP and not self:fxFaintActive(battler) + if self:substituteVisible(battler) and not self:fxFaintActive(battler) and not battler.fainted then self:drawSubstituteDoll(battler) return @@ -12340,7 +12358,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- ball's timeline: it shrinks into the ball and comes back out of it, -- and once it is inside there is nothing to draw at all. and not ((self:gen3BallFor(self.enemy) or {}).monHidden) then - local img = self:picImage(self.enemy.sprite) + local img = self:pictureStill(self.enemy) love.graphics.setColor(1, 1, 1, 1) local ex, ey = enemyPicXY(img, slide, sx, sy) local s = BattleState.resolveBattleScale(self.data, "front", @@ -12467,13 +12485,14 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- air there is no Pokemon on that side to draw; without this it stood -- there at full size waiting for its own ball to arrive. and not ((self:gen3BallFor(self.player) or {}).monHidden) then - local img = self:picImage(self.player.sprite) + local img = self:pictureStill(self.player) love.graphics.setColor(1, 1, 1, 1) -- feet flush on the text box top (y=96), ignoring baked-in padding - local pad = imagePadBottom[self.player.sprite] or 0 - local padL = imagePadLeft[self.player.sprite] or 0 + local handle = self:pictureSprite(self.player) + local pad = imagePadBottom[handle] or 0 + local padL = imagePadLeft[handle] or 0 local s = BattleState.resolveBattleScale(self.data, "back", - imagePathOf(self.player.sprite), + imagePathOf(handle), self.player.mon and self.player.mon.species) local gs = self:growInScale(self.player) local ballOut = self:gen3BallFor(self.player) @@ -12496,7 +12515,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) local dx, dy if self:gen3Layout() then dx, dy = Gen3Battle.picPlacement(self, self.player, img, - imagePathOf(self.player.sprite), s) + imagePathOf(handle), s) else dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(), pad, padL, s) @@ -12516,7 +12535,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) love.graphics.setColor(1, 1, 1, 1) elseif self:gen3Layout() then local dx, dy = Gen3Battle.picPlacement(self, self.player, img, - imagePathOf(self.player.sprite), s) + imagePathOf(handle), s) self:drawBattlerPic(self.player, dx + sx, dy + sy, s) else local dx, dy = BattleState.backPlacement(img:getWidth(), @@ -13050,5 +13069,6 @@ require('src.battle.Gen2BattleScene').install(BattleState) -- Compose after native/source view installers, retaining their real draw owners. require('src.battle.Gen2BgPictures').install(BattleState) +require('src.battle.Gen2PicturePresentation').install(BattleState) return BattleState diff --git a/src/battle/Gen2PicturePresentation.lua b/src/battle/Gen2PicturePresentation.lua new file mode 100644 index 000000000..8ce55b5e0 --- /dev/null +++ b/src/battle/Gen2PicturePresentation.lua @@ -0,0 +1,164 @@ +-- Picture identity follows the existing GSC animation VM, not the earlier +-- native damage/effect calculation. No combat value or saved mon is delayed. +local Version=require('src.core.GameVersion') +local M={} + +local function side(b) return b.isPlayer and 'player' or 'enemy' end +local function current(screen,p) + return screen[side(p.b)]==p.b and p.b.mon==p.mon +end +local function commit(p) + if p.kind=='transform' then + p.b.species=p.species + p.b.picAnim=p.picAnim + end +end +local function remove(screen,index,apply) + local p=table.remove(screen._gen2PictureChanges,index) + if apply and current(screen,p) then commit(p) end +end +local function pending(screen,b,kind) + for _,p in ipairs(screen._gen2PictureChanges or {})do + if p.b==b and p.mon==b.mon and (not kind or p.kind==kind)then return p end + end +end +local function mark(screen,b,kind) + local row=screen.moveAnimRow + local player=screen.animPlayer + if not (Version.isGen2() and row and screen:animationsOn() and player and player.anims.gen2 + and player:scriptFor(row.anim))then return end + local queued=false + for _,item in ipairs(screen.queue or {})do if item==row then queued=true;break end end + if not queued then return end + local p={b=b,mon=b.mon,row=row,kind=kind} + screen._gen2PictureChanges=screen._gen2PictureChanges or {} + table.insert(screen._gen2PictureChanges,p) + return p +end + +function M.transform(screen,b,target) + local function resolved(who) + local species=who.species or who.mon.species + for _,p in ipairs(screen._gen2PictureChanges or {})do + if p.b==who and p.mon==who.mon and p.kind=='transform'then species=p.species end + end + return species + end + local species,before=resolved(target),resolved(b) + -- Front entrance frames belong to this battler, never the target's live + -- runner. A back picture has no front entrance animation to borrow. + local animation=not b.isPlayer and require('src.pokemon.PicAnim').new(screen.data,species) or nil + local p=mark(screen,b,'transform') + if p then + p.beforeSprite,p.beforeSpecies=b.sprite,before + p.species,p.picAnim=species,animation + end + b.sprite=screen:speciesSprite(species,b.isPlayer) or b.sprite + if not p then commit({kind='transform',b=b,species=species,picAnim=animation})end +end + +function M.substitute(screen,b) + if Version.isGen2() then mark(screen,b,'substitute') end +end + +-- Native row admission binds the one actual VM (or selects the normal +-- disabled/missing-data fallback), never a parallel animation owner. +function M.admitted(screen,row,runner) + for i=#(screen._gen2PictureChanges or {}),1,-1 do + local p=screen._gen2PictureChanges[i] + if p.row==row then + if runner then p.runner=runner else remove(screen,i,true) end + end + end +end + +-- Called after the native VM tick, before an after-animation replaces it. +-- Drawing only reads; it cannot advance this owner. +function M.sync(screen) + local i=1 + while i<=#(screen._gen2PictureChanges or {}) do + local p=screen._gen2PictureChanges[i] + local discard,apply=false,true + if not current(screen,p)then discard,apply=true,false + elseif p.runner then + local value=p.runner.picOverride[side(p.b)] + discard=p.runner.stop or value==p.kind + else + local queued=false + for _,row in ipairs(screen.queue or {})do if row==p.row then queued=true;break end end + discard=not queued + end + if discard then remove(screen,i,apply)else i=i+1 end + end +end + +function M.override(screen,b) + local overrides=screen.anim and screen.anim.picOverride + local value=overrides and overrides[side(b)] + -- false is an explicit dropsub, distinct from an unmodified tilemap. + if value~=nil then return value end + if pending(screen,b,'substitute')then return false end + return b.substituteHP and 'substitute' or nil +end + +function M.species(screen,b) + if not Version.isGen2()then return b.species or b.mon.species end + local p=pending(screen,b,'transform') + if p then return p.beforeSpecies end + local value=M.override(screen,b) + if type(value)=='table' and value.kind=='beatup' then + -- The wire parameter is wCurPartySpecies, not an index into the party. + -- Keep the pinned VM's public `slot` field for compatibility. + local catalog=screen.data.pokemon + local cached=screen._gen2BeatUpIdentity + if not(cached and cached.value==value and cached.catalog==catalog and cached.index==value.slot)then + cached={value=value,catalog=catalog,index=value.slot} + for name,def in pairs(catalog or {})do + if type(def)=='table' and def.index==value.slot then cached.species=name;break end + end + screen._gen2BeatUpIdentity=cached + end + if cached.species then return cached.species end + end + return b.species or b.mon.species +end + +function M.sprite(screen,b) + if not Version.isGen2()then return b.sprite end + local p=pending(screen,b,'transform') + if p then return p.beforeSprite end + local species=M.species(screen,b) + if species~=(b.species or b.mon.species)then + return screen:speciesSprite(species,b.isPlayer) or b.sprite + end + return b.sprite +end + +function M.install(Screen) + local sprite=Screen.pictureSprite + function Screen:pictureSprite(b) + if Version.isGen2()then return M.sprite(self,b)end + return sprite(self,b) + end + local pic=Screen.battlerPic + function Screen:battlerPic(b) + if Version.isGen2() and (pending(self,b,'transform') or M.sprite(self,b)~=b.sprite)then + return self:pictureStill(b) + end + return pic(self,b) + end + local visible=Screen.substituteVisible + function Screen:substituteVisible(b) + if Version.isGen2() and b then return M.override(self,b)=='substitute'end + return visible(self,b) + end + local read=Screen.animPicState + function Screen:animPicState(which) + local state=read and read(self,which) + if Version.isGen2() and self[which]then + state=state or {};state.pic=M.override(self,self[which]) + end + return state + end +end +return M diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 5772141ae..783c34151 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -360,6 +360,7 @@ MoveEffects.primary = { return { Strings("Too weak to make\na SUBSTITUTE!") } end user.mon.hp = user.mon.hp - cost + if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').substitute(battle,user) end user.substituteHP = cost + 1 -- _SubstituteText return { Strings("It created a\nSUBSTITUTE!") } @@ -386,7 +387,9 @@ MoveEffects.primary = { -- mosaic and HandleSpeciesGfxDataChange performs it at callback 46. -- Gen3MoveAnim owns that visual midpoint, so changing the FireRed sprite -- here would expose the copied species for the first 45 callbacks. - if GameVersion.get() ~= "firered" then + if GameVersion.isGen2() then + require('src.battle.Gen2PicturePresentation').transform(battle,user,target) + elseif GameVersion.get() ~= "firered" then user.sprite = battle:speciesSprite(target.mon.species, user.isPlayer) or user.sprite -- Black / White draw a live animation over `sprite`, so their picture diff --git a/tests/engine/gen2_picture_pixels.lua b/tests/engine/gen2_picture_pixels.lua new file mode 100644 index 000000000..f05cf15cf --- /dev/null +++ b/tests/engine/gen2_picture_pixels.lua @@ -0,0 +1,83 @@ +-- Actual existing engine entry points and emitted image calls. The old +-- engine can run this fixture without adding any new helper module. +-- This is a CPU draw-stream counterexample, not GPU pixel qualification. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Effects=require('src.battle.MoveEffects').primary +local before=Version.get() +local function make(edition,side,op) + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.PROBE={id='PROBE',power=0,type='NORMAL',pp=10,name='PROBE'} + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op='delay',frames=3},{op=op},{op='delay',frames=4},{op='ret'}}}, + }} + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + local row={anim='PROBE',attackerIsPlayer=side=='player',animDelayed=true} + s.queue[1],s.moveAnimRow=row,row + return s,s[side],s[side=='player'and'enemy'or'player'],row +end +local calls,draw={},love.graphics.draw +love.graphics.draw=function(image,...)calls[#calls+1]=image;return draw(image,...)end +local function capture(s,b,expected,label) + calls={};s:drawPicsLayer(0,0,0,b.isPlayer and 'player'or'enemy',true) + T.eq(#calls,1,label..' emits exactly one image') + T.eq(calls[1],expected,label..' emits correct picture, not metadata alone') + if s.drawPic then + calls={};s:drawPic(b.mon,b.isPlayer) + T.eq(#calls,1,label..' public capture emits exactly one image') + T.eq(calls[1],expected,label..' public capture shares actual image owner') + end +end +local function command(s,side,expected) + local n=0 + while s.anim.picOverride[side]~=expected and n<100 do s:updateQueue();n=n+1 end + T.check(n>0 and n<100,'native queue reaches actual image opcode') +end +for _,edition in ipairs({'gold','silver','crystal'})do for _,side in ipairs({'player','enemy'})do + local s,b,target=make(edition,side,'transform') + local old=s:picImage(b.sprite) + Effects.TRANSFORM_EFFECT(s,b,target) + local replacement=s:picImage(b.sprite) + T.neq(old,replacement,'fixture uses distinct real image owners') + capture(s,b,old,'queued Transform') + s:updateQueue();capture(s,b,old,'pre-command Transform') + command(s,side,'transform') + capture(s,b,replacement,'post-command Transform') + T.eq(b.mon.species,side=='player'and'FIXMON_A'or'FIXMON_C','actual saved identity is retained') + + s,b=make(edition,side,'raisesub');old=s:picImage(b.sprite) + Effects.SUBSTITUTE_EFFECT(s,b) + capture(s,b,old,'queued Substitute') + s:updateQueue();capture(s,b,old,'pre-command Substitute') + command(s,side,'substitute') + calls={};s:drawPicsLayer(0,0,0,side,true) + T.eq(#calls,1,'post-command Substitute emits actual doll') + T.neq(calls[1],old,'doll image is distinct from normal Pokemon') + + s,b=make(edition,side,'dropsub');b.substituteHP=17;old=s:picImage(b.sprite) + s:updateQueue();command(s,side,false) + capture(s,b,old,'explicit dropsub despite retained substitute HP') + T.eq(b.substituteHP,17,'image command retains real combat substitute') + + local row + s,b,target,row=make(edition,side,'beatup') + row.animParam=207;s.data.pokemon[target.mon.species].index=207 + replacement=s:picImage(s:speciesSprite(target.mon.species,b.isPlayer)) + s:updateQueue() + local n=0 + while not s.anim.picOverride[side]and n<100 do s:updateQueue();n=n+1 end + T.check(n>0 and n<100,'native Beat Up opcode actually runs') + capture(s,b,replacement,'Beat Up species 207 outside party slots') +end end +love.graphics.draw=draw +Version.set(before) +T.finish('actual native GSC picture draw-stream parity') diff --git a/tests/engine/gen2_picture_presentation.lua b/tests/engine/gen2_picture_presentation.lua new file mode 100644 index 000000000..1b322ee25 --- /dev/null +++ b/tests/engine/gen2_picture_presentation.lua @@ -0,0 +1,160 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Effects=require('src.battle.MoveEffects').primary +local Pictures=require('src.battle.Gen2PicturePresentation') +local before=Version.get() +local function make(edition,side,op) + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=2 + data.moves.PIC_TEST={id='PIC_TEST',name='PICTURE TEST',type='NORMAL',pp=10,power=0} + data.battle_anims={gen2=true,moveAnims={PIC_TEST=1},scripts={ + [1]={entry=1,code={{op='delay',frames=3},{op=op},{op='delay',frames=3},{op='ret'}}}, + }} + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + local row={anim='PIC_TEST',attackerIsPlayer=side=='player',animDelayed=true} + s.queue[1],s.moveAnimRow=row,row + return s,s[side],s[side=='player'and'enemy'or'player'],row +end +local function endAnim(s) + local count=0 + while s.animPlaying and count<120 do s:updateQueue();count=count+1 end + T.check(count<120,'picture program releases its native queue') +end +for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + for _,side in ipairs({'player','enemy'})do + local s,b,target,row=make(edition,side,'transform') + local sprite,species,hp,pp=b.sprite,b.mon.species,b.mon.hp,b.curMoves[1].pp + local savedStats=b.mon.stats + Effects.TRANSFORM_EFFECT(s,b,target) + local copied=b.sprite + T.neq(copied,sprite,'native effect resolves its replacement immediately') + T.eq(b.curStats.attack,target.curStats.attack,'native copied combat stats are not delayed') + T.eq(b.mon.stats,savedStats,'saved stats retain their owner') + T.eq(s:pictureStill(b),s:picImage(sprite),'queued view retains the actual preceding image') + T.eq(s:battlerPic(b),s:picImage(sprite),'native drawer retains preceding image') + if s.pic then + T.eq(s:pic(b.mon,b.isPlayer),s:picImage(sprite),'same-side public capture retains preceding image') + local front,_,path=s:pic(b.mon,false) + T.check(path:find('fixmon_a',1,true)or path:find('fixmon_c',1,true), + 'opposite capture resolves an existing species path') + end + T.eq(Pictures.species(s,b),species,'queued front identity precedes actual transform command') + s:updateQueue() + T.eq(s.animPlaying,true,'actual native queue starts picture program') + local r=s.anim + T.eq(s._gen2PictureChanges[1].runner,r,'pin is bound to actual command interpreter') + local ticks=0 + while r.picOverride[side]~='transform'and ticks<120 do + T.eq(s:battlerPic(b),s:picImage(sprite),'all pre-command frames show preceding species') + local frames=r.frames + for _=1,3 do + if s.pic then s:pic(b.mon,false)end + s:animPicState(side);s:battlerPic(b) + end + T.eq(r.frames,frames,'drawing never advances the VM') + s:updateQueue();ticks=ticks+1 + end + T.check(ticks>0 and ticks<120,'actual transform opcode is reached') + T.eq(s:battlerPic(b),s:picImage(copied),'the opcode reveals the actual replacement') + T.eq(b.species,target.mon.species,'public species identity commits at that same command') + T.eq(Pictures.species(s,b),target.mon.species,'opposite capture follows transformed identity') + T.eq(#s._gen2PictureChanges,0,'revealed picture releases its transient pin') + endAnim(s) + T.eq(b.mon.species,species,'saved species is never transformed') + T.eq(b.mon.hp,hp,'picture commands do not change HP') + T.eq(b.curMoves[1].pp,5,'native Transform still supplies copied moves at five PP') + T.eq(b.mon.moves[1].pp,pp,'saved PP is not consumed by picture reads') + + s,b,target,row=make(edition,side,'raisesub') + local oldHP=b.mon.hp + Effects.SUBSTITUTE_EFFECT(s,b) + T.check(b.mon.hp0,'native substitute cost resolves before animation') + T.eq(s:substituteVisible(b),false,'new doll is held until real raisesub command') + T.eq(s:animPicState(side).pic,false,'explicit preceding normal picture is observable') + s:updateQueue();r=s.anim;ticks=0 + while r.picOverride[side]~='substitute'and ticks<120 do + T.eq(s:substituteVisible(b),false,'pre-command frames retain normal picture') + s:updateQueue();ticks=ticks+1 + end + T.check(ticks<120,'real raisesub command executes') + T.eq(s:substituteVisible(b),true,'actual raisesub reveals doll') + T.eq(#s._gen2PictureChanges,0,'doll reveal retires transient creation pin') + endAnim(s) + T.eq(s:substituteVisible(b),true,'native substitute survives animation cleanup') + + s,b=make(edition,side,'dropsub');b.substituteHP=17 + s:updateQueue();r=s.anim;ticks=0 + while r.picOverride[side]~=false and ticks<120 do s:updateQueue();ticks=ticks+1 end + T.check(ticks<120,'actual dropsub command executes') + T.eq(s:animPicState(side).pic,false,'false override survives public observation') + T.eq(s:substituteVisible(b),false,'dropsub paints Pokemon despite positive combat substitute HP') + local dolls,draw=0,s.drawSubstituteDoll + s.drawSubstituteDoll=function()dolls=dolls+1 end + if s.drawPic then s:drawPic(b.mon,false) + else s:drawPicsLayer(0,0,0,side,true)end + T.eq(dolls,0,'actual public capture no longer paints hidden doll') + s.drawSubstituteDoll=draw + T.eq(b.substituteHP,17,'temporary drawing never destroys combat substitute') + endAnim(s) + T.eq(s:substituteVisible(b),true,'finishing move restores native retained substitute') + + s,b=make(edition,side,'beatup') + local species=b.mon.species + local chosen=side=='player'and'FIXMON_C'or'FIXMON_A' + s.data.pokemon[chosen].index=207 -- deliberately outside a six-member party + row=s.queue[1];row.animParam=207 + s:updateQueue();r=s.anim;ticks=0 + while not r.picOverride[side]and ticks<120 do s:updateQueue();ticks=ticks+1 end + T.check(ticks<120,'actual Beat Up command executes') + T.eq(Pictures.species(s,b),chosen,'Beat Up wire byte resolves species, not party slot') + T.eq(s:pictureStill(b),s:picImage(s:speciesSprite(chosen,b.isPlayer)), + 'native placement measures actual Beat Up image') + T.eq(s:battlerPic(b),s:pictureStill(b),'native drawer uses actual Beat Up image') + if s.pic then T.eq(s:pic(b.mon,b.isPlayer),s:pictureStill(b),'public same-side capture uses same image')end + T.eq(b.mon.species,species,'Beat Up never replaces the active saved Pokemon') + endAnim(s) + T.eq(Pictures.species(s,b),species,'Beat Up temporary picture is released with native VM') + end +end + +-- Missing/disabled/cancelled programs and a real slot replacement cannot +-- leave a pinned picture hanging or repaint the incoming saved Pokemon. +for _,mode in ipairs({'disabled','missing','cancelled','stopped','replacement'})do + local s,b,target,row=make('crystal','player','transform') + local species=b.mon.species + if mode=='disabled'then s.game.save.options.animations=false end + if mode=='missing'then s.animPlayer.anims.scripts[1]=nil end + Effects.TRANSFORM_EFFECT(s,b,target) + if mode=='cancelled'then s:cancelMoveAnim();Pictures.sync(s) + elseif mode=='replacement'then + local incoming=Pokemon.new(s.data,'FIXMON_B',18) + if s.switchPlayerInto then s:switchPlayerInto(Battle.POS.PLAYER_LEFT,incoming) + else s.player={mon=incoming,isPlayer=true,sprite={}}end + Pictures.sync(s) + T.eq(s.player.mon,incoming,'native switch retains actual incoming mon') + T.eq(Pictures.species(s,s.player),'FIXMON_B','old transform cannot repaint replacement slot') + elseif mode=='stopped'then + s:updateQueue();s.anim.stop=true;s:updateQueue() + else s:updateQueue()end + T.eq(#(s._gen2PictureChanges or {}),0,mode..' releases native picture pin') + if mode~='replacement'then T.eq(b.species,target.mon.species,mode..' commits resolved native visual identity')end + T.eq(b.mon.species,species,mode..' retains saved species') +end +for _,edition in ipairs({'red','emerald','firered'})do + Version.set(edition) + local s,b=make('crystal','player','dropsub');Version.set(edition) + b.substituteHP=17;s.anim={picOverride={player=false}} + T.check(s:substituteVisible(b),edition..' keeps its original substitute owner') + T.eq(s:pictureSprite(b),b.sprite,edition..' keeps native image owner') +end +Version.set(before) +T.finish('native GSC command-time picture identity and substitute visibility') From 44b6122ea51e9f5a902d29335e8ac80eed6fc800 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:09:11 +0100 Subject: [PATCH 44/64] Preserve repeated native picture order and document qualification limits --- docs/native-gen2-picture-presentation.md | 43 ++++++++++++++++++++++ src/battle/Gen2PicturePresentation.lua | 7 ++-- tests/engine/gen2_picture_presentation.lua | 29 +++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 docs/native-gen2-picture-presentation.md diff --git a/docs/native-gen2-picture-presentation.md b/docs/native-gen2-picture-presentation.md new file mode 100644 index 000000000..18f75f80d --- /dev/null +++ b/docs/native-gen2-picture-presentation.md @@ -0,0 +1,43 @@ +# Native GSC picture-command ownership + +The shared native controller resolves a move before its queued animation runs. +Changing the sprite at that earlier point exposed Transform's copied Pokemon +and Substitute's doll before the ROM command. The existing renderer also read +`substituteHP` despite an explicit `dropsub` override, and ignored Beat Up's +temporary species image. + +`Gen2PicturePresentation` holds only presentation identity. A pin belongs to the +actual battler, saved Pokemon and queued row, then to the actual GSC runner. +Native stats, moves, HP, PP and turn resolution retain their existing owners. +The native queue retires each pin at the real image opcode or completion; no +additional update clock, combat model or authored ROM program is introduced. +Missing, disabled and cancelled animations reconcile immediately. Repeated +transforms commit in queue order; a replaced slot cannot inherit an old pin. +Transformed front animations get their own native entrance runner. + +Both the native picture layer and the existing source-facing captures use the +selected native image. Placement and image-specific scale metadata are measured +from that same selected image. An explicit false override hides the doll while +preserving its combat HP. A newly created substitute appears at `raisesub`. +The imported Beat Up parameter selects a species index, rather than a party +slot; the existing VM's public `slot` field is retained. Its catalog lookup is +cached for a stable override and catalog. + +The picture tests exercise Gold, Silver, Crystal, Prism and Polished Crystal; +the emitted-image comparison covers both sides in the three retail editions. +They run the real native effects, queue and GSC interpreter. Only device draw +calls are recorded. The pixel suite is also runnable against the previous +engine without adding the new helper, so its failures are wrong emitted +images rather than missing modules. CI runs these tests alongside the existing +object and background owner tests. + +The upstream controller has no source-facing `pic`/`drawPic` boundary yet. +Its tests therefore exercise the native picture layer; optional public-capture +assertions run on the fork where that boundary already exists. The upstream +adaptation uses its existing inline queue admission, not a copied fork queue. + +This change does not qualify every battle picture. Minimize ROM art, complete +GSC doll placement/palette and resize composition, full per-hit Beat Up combat +sequencing, send-out events, natural-move GPU comparisons, Windows and packaged +release validation remain separate work. No supplied mod, protected instance, +ROM or player save is changed. No new release is implied by these CPU tests. diff --git a/src/battle/Gen2PicturePresentation.lua b/src/battle/Gen2PicturePresentation.lua index 8ce55b5e0..a6fc533a4 100644 --- a/src/battle/Gen2PicturePresentation.lua +++ b/src/battle/Gen2PicturePresentation.lua @@ -64,11 +64,12 @@ end -- Native row admission binds the one actual VM (or selects the normal -- disabled/missing-data fallback), never a parallel animation owner. function M.admitted(screen,row,runner) - for i=#(screen._gen2PictureChanges or {}),1,-1 do + local i=1 + while i<=#(screen._gen2PictureChanges or {})do local p=screen._gen2PictureChanges[i] if p.row==row then - if runner then p.runner=runner else remove(screen,i,true) end - end + if runner then p.runner=runner;i=i+1 else remove(screen,i,true)end + else i=i+1 end end end diff --git a/tests/engine/gen2_picture_presentation.lua b/tests/engine/gen2_picture_presentation.lua index 1b322ee25..952c4e004 100644 --- a/tests/engine/gen2_picture_presentation.lua +++ b/tests/engine/gen2_picture_presentation.lua @@ -149,6 +149,35 @@ for _,mode in ipairs({'disabled','missing','cancelled','stopped','replacement'}) if mode~='replacement'then T.eq(b.species,target.mon.species,mode..' commits resolved native visual identity')end T.eq(b.mon.species,species,mode..' retains saved species') end +for _,disabled in ipairs({false,true})do + local s,b,target,row=make('crystal','player','transform') + local initial=s:picImage(b.sprite) + Effects.TRANSFORM_EFFECT(s,b,target) + local middle=b.sprite + local second={anim='PIC_TEST',attackerIsPlayer=true,animDelayed=true} + if not disabled then s.queue[2]=second;s.moveAnimRow=second end + target.species='FIXMON_B' + Effects.TRANSFORM_EFFECT(s,b,target) + local final=b.sprite + T.eq(s:battlerPic(b),initial,'two resolved transforms still show the original queued picture') + if disabled then + s.game.save.options.animations=false + s:updateQueue() + else + s:updateQueue();local r=s.anim;local n=0 + while r.picOverride.player~='transform'and n<120 do s:updateQueue();n=n+1 end + T.check(n<120,'first native transform opcode completes') + T.eq(s:battlerPic(b),s:picImage(middle),'first command reveals only its own queued identity') + endAnim(s) + s:updateQueue();r=s.anim;n=0 + while r.picOverride.player~='transform'and n<120 do s:updateQueue();n=n+1 end + T.check(n<120,'second native transform opcode completes') + end + T.eq(b.species,'FIXMON_B','native admission commits transforms in original queue order') + T.eq(s:battlerPic(b),s:picImage(final),'last resolved visual remains after repeated transforms') + T.eq(#s._gen2PictureChanges,0,'repeated transform pins retire completely') + T.eq(b.mon.species,'FIXMON_A','repeated transforms cannot change the saved species') +end for _,edition in ipairs({'red','emerald','firered'})do Version.set(edition) local s,b=make('crystal','player','dropsub');Version.set(edition) From e61381e06fb0d549fb21017f36596bf9b0c312e5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:10:29 +0100 Subject: [PATCH 45/64] Bind picture pins to the real VM and release replaced programs --- src/battle/BattleState.lua | 2 +- src/battle/Gen2PicturePresentation.lua | 2 +- tests/engine/gen2_picture_presentation.lua | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 1d88b45ec..af96a4435 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3025,7 +3025,7 @@ function BattleState:updateQueue() end if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').admitted(self,item, - self.animPlaying and self.anim or nil) + self.animPlaying and self.animPlayer.runtime or nil) end self.current = nil return true diff --git a/src/battle/Gen2PicturePresentation.lua b/src/battle/Gen2PicturePresentation.lua index a6fc533a4..f37d8eaf3 100644 --- a/src/battle/Gen2PicturePresentation.lua +++ b/src/battle/Gen2PicturePresentation.lua @@ -83,7 +83,7 @@ function M.sync(screen) if not current(screen,p)then discard,apply=true,false elseif p.runner then local value=p.runner.picOverride[side(p.b)] - discard=p.runner.stop or value==p.kind + discard=p.runner~=screen.animPlayer.runtime or p.runner.stop or value==p.kind else local queued=false for _,row in ipairs(screen.queue or {})do if row==p.row then queued=true;break end end diff --git a/tests/engine/gen2_picture_presentation.lua b/tests/engine/gen2_picture_presentation.lua index 952c4e004..5f79f3fb6 100644 --- a/tests/engine/gen2_picture_presentation.lua +++ b/tests/engine/gen2_picture_presentation.lua @@ -128,7 +128,7 @@ end -- Missing/disabled/cancelled programs and a real slot replacement cannot -- leave a pinned picture hanging or repaint the incoming saved Pokemon. -for _,mode in ipairs({'disabled','missing','cancelled','stopped','replacement'})do +for _,mode in ipairs({'disabled','missing','cancelled','stopped','new-program','replacement'})do local s,b,target,row=make('crystal','player','transform') local species=b.mon.species if mode=='disabled'then s.game.save.options.animations=false end @@ -144,6 +144,13 @@ for _,mode in ipairs({'disabled','missing','cancelled','stopped','replacement'}) T.eq(Pictures.species(s,s.player),'FIXMON_B','old transform cannot repaint replacement slot') elseif mode=='stopped'then s:updateQueue();s.anim.stop=true;s:updateQueue() + elseif mode=='new-program'then + s:updateQueue() + local retired=s.anim + s:animForMove('PIC_TEST','enemy') + T.neq(s.anim,retired,'public start creates a distinct real native runner') + s:updateQueue() + T.eq(retired.picOverride.player,nil,'replacing runner does not invent a retired transform opcode') else s:updateQueue()end T.eq(#(s._gen2PictureChanges or {}),0,mode..' releases native picture pin') if mode~='replacement'then T.eq(b.species,target.mon.species,mode..' commits resolved native visual identity')end From e9ec0554af24ca7ca350e5ec984f7889ce7a9b5f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:22:31 +0100 Subject: [PATCH 46/64] Decode native GSC replacement art and honor Crystal staged actor copies --- .../workflows/gen2-picture-presentation.yml | 2 + src/battle/BattleState.lua | 29 ++- src/battle/Gen2AnimData.lua | 5 +- src/battle/Gen2BgPictures.lua | 6 +- src/battle/Gen2PictureArt.lua | 71 +++++++ src/battle/Gen2PicturePresentation.lua | 2 +- src/battle/gen2/AnimRunner.lua | 28 ++- src/import/Gen2BattlePictureArt.lua | 54 ++++++ src/import/RomExtractorGen2.lua | 1 + .../engine/gen2_animation_import_commands.lua | 16 +- tests/engine/gen2_picture_art_draw.lua | 182 ++++++++++++++++++ tests/engine/gen2_picture_art_import.lua | 82 ++++++++ tests/engine/gen2_picture_presentation.lua | 35 +++- tests/gen2_picture_art/fixture.lua | 65 +++++++ 14 files changed, 550 insertions(+), 28 deletions(-) create mode 100644 src/battle/Gen2PictureArt.lua create mode 100644 src/import/Gen2BattlePictureArt.lua create mode 100644 tests/engine/gen2_picture_art_draw.lua create mode 100644 tests/engine/gen2_picture_art_import.lua create mode 100644 tests/gen2_picture_art/fixture.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index 8c2491985..5a50a9080 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -19,6 +19,8 @@ jobs: run: | luajit tests/engine/gen2_picture_presentation.lua luajit tests/engine/gen2_picture_pixels.lua + luajit tests/engine/gen2_picture_art_import.lua + luajit tests/engine/gen2_picture_art_draw.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index af96a4435..a16de4671 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4185,6 +4185,10 @@ function BattleState:pictureSprite(battler) return battler.sprite end +function BattleState:pictureScalePath(battler, path) + return path +end + function BattleState:pictureStill(battler) return self:picImage(self:pictureSprite(battler)) end @@ -11747,7 +11751,8 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) end end local img = self:battlerPic(battler) - if self:substituteVisible(battler) and not self:fxFaintActive(battler) + local replacement=GameVersion.isGen2() and require('src.battle.Gen2PictureArt').replacement(self,battler)~=nil + if not replacement and self:substituteVisible(battler) and not self:fxFaintActive(battler) and not battler.fainted then self:drawSubstituteDoll(battler) return @@ -11800,7 +11805,7 @@ function BattleState:drawBattlerPicAt(battler, x, y, scale) end return end - if not battler.gen3Transformed + if not replacement and not battler.gen3Transformed and self:drawStadiumBattlerPic(battler, img, x, y, scale) then return end local tr, tg, tb, tc = self:gen3AnimTint(battler) if tc then @@ -12248,6 +12253,10 @@ end -- top-left x, y and the scale (slide/shake offsets are added by the -- caller). Feet stay at 96 for every scale: y + (h - pad) * scale == 96. function BattleState.backPlacement(w, h, pad, padL, scale) + if GameVersion.isGen2() then + return 16 + math.max(0, math.floor((48-w)/2)) + math.floor(w*(1-scale)/2), + 96-h + math.floor(h*(1-scale)), scale + end return 8 - padL * scale, 96 - (h - pad) * scale, scale end @@ -12362,7 +12371,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) love.graphics.setColor(1, 1, 1, 1) local ex, ey = enemyPicXY(img, slide, sx, sy) local s = BattleState.resolveBattleScale(self.data, "front", - imagePathOf(img), self.enemy.mon and self.enemy.mon.species) + self:pictureScalePath(self.enemy,imagePathOf(img)), self.enemy.mon and self.enemy.mon.species) -- EMERALD PLACES ITS POKEMON ON A PLATFORM, and the 7x7 tile slot above -- is the Game Boy's rule for a 160x144 letterbox that has none. On the -- Gen 3 field it left the foe up in the corner well clear of the ground @@ -12492,7 +12501,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) local pad = imagePadBottom[handle] or 0 local padL = imagePadLeft[handle] or 0 local s = BattleState.resolveBattleScale(self.data, "back", - imagePathOf(handle), + self:pictureScalePath(self.player,imagePathOf(handle)), self.player.mon and self.player.mon.species) local gs = self:growInScale(self.player) local ballOut = self:gen3BallFor(self.player) @@ -12503,9 +12512,14 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- pinned, mod scale composed with the grow stage local eff = s * gs if eff > 0 then - love.graphics.draw(img, - 8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, - 96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff) + if GameVersion.isGen2() then + local dx,dy=BattleState.backPlacement(img:getWidth(),img:getHeight(),pad,padL,eff) + love.graphics.draw(img,dx+sx,dy+sy,0,eff,eff) + else + love.graphics.draw(img, + 8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, + 96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff) + end end elseif ballOut then -- COMING OUT OF THE BALL, which is the absorb run backwards: the pic @@ -13070,5 +13084,6 @@ require('src.battle.Gen2BattleScene').install(BattleState) -- Compose after native/source view installers, retaining their real draw owners. require('src.battle.Gen2BgPictures').install(BattleState) require('src.battle.Gen2PicturePresentation').install(BattleState) +require('src.battle.Gen2PictureArt').install(BattleState,{load=getImage,palette=monPalette,path=imagePathOf}) return BattleState diff --git a/src/battle/Gen2AnimData.lua b/src/battle/Gen2AnimData.lua index c178162bc..abda1f6a6 100644 --- a/src/battle/Gen2AnimData.lua +++ b/src/battle/Gen2AnimData.lua @@ -3,7 +3,7 @@ -- plus Additional Terms; see compat/gen1-gsc/. No ROM program is invented. local Ids=require('src.battle.Gen2AnimObjectIds') local M={} -M.CACHE_REVISION=1 +M.CACHE_REVISION=2 local function order(constants,key) return constants and constants[key] or Ids[key] end @@ -14,7 +14,8 @@ function M.tables(anims,constants) 'battleAnimFuncOrder','battleAnimGfxOrder','battleAnimObPaletteOrder'})do c[key]=order(constants,key)end c.battleBgEffectOrder=constants and constants.battleBgEffectOrder or require('src.battle.Gen2BgEffectIds').forVersion() - local data={objects={},framesets={},oamsets={},gfx={},palettes=anims.palettes} + local data={objects={},framesets={},oamsets={},gfx={},palettes=anims.palettes, + pictureArt=anims.pictureArt} for id,row in pairs(anims.objects or {})do local name=c.battleAnimObjectOrder[id+1]or id local native=anims.functions and anims.functions[row.fn] diff --git a/src/battle/Gen2BgPictures.lua b/src/battle/Gen2BgPictures.lua index c06a956a7..5b94dd5f2 100644 --- a/src/battle/Gen2BgPictures.lua +++ b/src/battle/Gen2BgPictures.lua @@ -48,6 +48,8 @@ function M.install(Screen) return draw(self,b,x,y,scale) end local side=b.isPlayer and 'player'or'enemy' + local back=b.isPlayer + if self._gen2PicBack~=nil then back=self._gen2PicBack end local state=self:animPicState(side) if state and state.hidden then return end if self.liftedPass and not(state and state.lifted)then return end @@ -56,7 +58,7 @@ function M.install(Screen) if size then local image=self:battlerPic(b) if image then - local factor=size/(b.isPlayer and 6 or 7) + local factor=size/(back and 6 or 7) x=x+math.floor(image:getWidth()*scale*(1-factor)/2) y=y+math.floor(image:getHeight()*scale*(1-factor)) scale=scale*factor @@ -65,7 +67,7 @@ function M.install(Screen) local function paint()withPalette(self,side,function()draw(self,b,x,y,scale)end)end local lifted=state and state.lifted if not lifted then return paint()end - local bandY=(b.isPlayer and 48 or 0)+lifted[1]*8 + local bandY=(back and 48 or 0)+lifted[1]*8 local bandH=lifted[2]*8 local function band(top,height) local g=love.graphics diff --git a/src/battle/Gen2PictureArt.lua b/src/battle/Gen2PictureArt.lua new file mode 100644 index 000000000..03a2f488c --- /dev/null +++ b/src/battle/Gen2PictureArt.lua @@ -0,0 +1,71 @@ +-- Native indexed picture replacements. Full six/seven-tile boxes compose +-- with the existing palette, resize, slide and lifted-row draw owners. +local Version=require('src.core.GameVersion') +local Pictures=require('src.battle.Gen2PicturePresentation') +local M={} +function M.back(screen,b) + if screen._gen2PicBack~=nil then return screen._gen2PicBack end + return b.isPlayer +end +local function record(screen) + local anims=screen.data and screen.data.battle_anims + local art=anims and anims.pictureArt + return art and art.version==1 and art or nil +end +function M.replacement(screen,b,back) + if not(Version.isGen2() and b)then return end + -- Keep the native faint picture owner; a combat substitute cannot replace + -- the Pokemon while that owner's cropped sink is running. + if b.fainted or screen:fxFaintActive(b)then return end + local art=record(screen) + local kind=Pictures.override(screen,b) + local row=art and type(kind)=='string' and art[kind] + if not row then return end + if back==nil then back=M.back(screen,b)end + local path=row[back and 'player'or'enemy'] + local images=screen._gen2PictureImages + if not(type(path)=='string' and images)then return end + local pal=images.palette(screen.data,b.mon.species, + require('src.pokemon.Pokemon').isShiny(b.mon)) + return images.load(path,pal,false),kind +end +function M.install(Screen,images) + Screen._gen2PictureImages=images + local sprite=Screen.pictureSprite + function Screen:pictureSprite(b) + return M.replacement(self,b) or sprite(self,b) + end + local pic=Screen.battlerPic + function Screen:battlerPic(b) + local replacement=M.replacement(self,b) + if replacement then return self:picImage(replacement)end + return pic(self,b) + end + local path=Screen.pictureScalePath + function Screen:pictureScalePath(b,value) + if M.replacement(self,b)then + return images.path(Pictures.sprite(self,b)) or value + end + return path(self,b,value) + end + local doll=Screen.substituteDoll + function Screen:substituteDoll(back) + if not Version.isGen2()then return doll and doll(self,back)end + local art=record(self) + local path=art and art.substitute and art.substitute.sheet + if not path then return end + -- Public source callers own their palette operation. Return indexed art, + -- not an already coloured/register-remapped capture. + local image=images.load(path,nil,false) + if not image then return end + local cached=self._gen2DollQuads + if not(cached and cached.image==image)then + local w,h=image:getDimensions() + cached={image=image,front=love.graphics.newQuad(0,0,16,16,w,h), + back=love.graphics.newQuad(0,16,16,16,w,h)} + self._gen2DollQuads=cached + end + return image,back and cached.back or cached.front + end +end +return M diff --git a/src/battle/Gen2PicturePresentation.lua b/src/battle/Gen2PicturePresentation.lua index f37d8eaf3..2f8c4e2d4 100644 --- a/src/battle/Gen2PicturePresentation.lua +++ b/src/battle/Gen2PicturePresentation.lua @@ -83,7 +83,7 @@ function M.sync(screen) if not current(screen,p)then discard,apply=true,false elseif p.runner then local value=p.runner.picOverride[side(p.b)] - discard=p.runner~=screen.animPlayer.runtime or p.runner.stop or value==p.kind + discard=p.runner~=screen.animPlayer.runtime or p.runner.stopped or value==p.kind else local queued=false for _,row in ipairs(screen.queue or {})do if row==p.row then queued=true;break end end diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index cc2719412..24228d0c8 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -110,6 +110,7 @@ function AnimRunner.new(opts) self.loaded = {} -- Set by the substitute / minimize / transform commands, for the view. self.picOverride = { player = nil, enemy = nil } + self.actorPic = {} self.address = nil self.parent = nil return self @@ -126,6 +127,7 @@ function Runner:start(scriptKey) self.tileDict = {} self.loaded = {} self.picOverride = { player = nil, enemy = nil } + self.actorPic = {} self.address = scriptKey and { key = scriptKey, index = 1 } or nil self.parent = nil return self @@ -269,7 +271,9 @@ C.transform = function(self) -- BattleAnimCmd_Transform: .player loads wTempEnemyMonSpecies into the -- backpic slot, i.e. the player's sprite becomes what it transformed into. local side = self.env.battleTurn == 0 and "player" or "enemy" - self.picOverride[side] = "transform" + local art=self.data.pictureArt + if art and art.stagedActorPic then self.actorPic[side]='transform' + else self.picOverride[side] = "transform" end end C.raisesub = function(self) @@ -325,12 +329,22 @@ C.clearobjs = function(self) self.objects:clearObjs() end C.oamon = function() end C.oamoff = function() end -C.updateactorpic = function() end --- $e7 and $e8-$ed are `ret` on the cart; $f5-$f7 too. $e9 is `minimize`, and --- it really is one of them: BattleAnimCmd_E8 through BattleAnimCmd_ED are six --- labels stacked on a single `ret` (engine/battle_anims/anim_commands.asm). --- The minimize animation that is actually drawn is $e2, minimizeopp above. -C.minimize = function() end +C.updateactorpic = function(self) + local side=self.env.battleTurn==0 and 'player'or'enemy' + local art=self.data.pictureArt + if art and art.stagedActorPic and self.actorPic[side]~=nil then + self.picOverride[side]=self.actorPic[side] + end +end +-- GS $e8/$e9 are ret. Crystal's $e9 fills vTiles0 without repainting the +-- actor; $e8 copies those staged tiles into its BG slot after its wait in +-- the actual Minimize program. The ROM importer declares that capability. +C.minimize = function(self) + local art=self.data.pictureArt + if art and art.stagedMinimize then + self.actorPic[self.env.battleTurn==0 and 'player'or'enemy']='minimize' + end +end C.unknown_e7 = function() end C.unknown_ea = function() end C.unknown_eb = function() end diff --git a/src/import/Gen2BattlePictureArt.lua b/src/import/Gen2BattlePictureArt.lua new file mode 100644 index 000000000..727e5a239 --- /dev/null +++ b/src/import/Gen2BattlePictureArt.lua @@ -0,0 +1,54 @@ +-- GSC GetSubstitutePic / GetMinimizePic, decoded from the mounted ROM. +-- Scratch tiles are column-major on the cartridge; PNG tiles are row-major. +-- No ROM image bytes are shipped with the engine. +local Writer=require('src.import.ImageWriter') +local M={} +local function bytes(e,name,count) + local p=e:symbol(name) + if not p or not e.rom then return end + local ok,raw=pcall(e.rom.bytes,e.rom,p.bank,p.address,count) + if not ok or type(raw)~='table' or #raw~=count then return end + for _,value in ipairs(raw)do + if type(value)~='number' or value%1~=0 or value<0 or value>255 then return end + end + return raw +end +local function box(raw,n,placements) + local out={} + for i=1,n*n*16 do out[i]=0 end + for _,p in ipairs(placements)do + local dest=(p[2]*n+p[1])*16 + for j=1,16 do out[dest+j]=raw[p[3]*16+j]end + end + return Writer.decode2bpp(out,n*8,n*8,true) +end +function M.extract(e) + local doll=bytes(e,'MonsterSpriteGFX',8*16) + local mini=bytes(e,'MinimizePic',16) + local staged=e:symbol('BattleAnimCmd_UpdateActorPic')~=nil + if not(doll or mini or staged)then return end + local record={version=1,stagedActorPic=staged} + if doll then + local front='battle/gen2_substitute_front.png' + local back='battle/gen2_substitute_back.png' + local sheet='battle/gen2_substitute_sheet.png' + e:saveImage(box(doll,7,{{2,5,0},{3,5,1},{2,6,2},{3,6,3}}),front) + e:saveImage(box(doll,6,{{2,4,4},{3,4,5},{2,5,6},{3,5,7}}),back) + e:saveImage(Writer.decode2bpp(doll,16,32,true),sheet) + record.substitute={enemy='assets/generated/'..front, + player='assets/generated/'..back,sheet='assets/generated/'..sheet} + end + if mini then + local front='battle/gen2_minimize_front.png' + local back='battle/gen2_minimize_back.png' + e:saveImage(box(mini,7,{{3,5,0}}),front) + e:saveImage(box(mini,6,{{3,4,0}}),back) + record.minimize={enemy='assets/generated/'..front,player='assets/generated/'..back} + -- GS $e8/$e9 are ret. Crystal stages into vTiles0 before copying to the + -- actor's BG slot; positive symbols also admit supported renamed editions. + record.stagedMinimize=e:symbol('BattleAnimCmd_Minimize')~=nil + and e:symbol('BattleAnimCmd_UpdateActorPic')~=nil + end + return record +end +return M diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 5f0cce32a..02c90e8fd 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -15166,6 +15166,7 @@ function RomExtractorGen2:gen2BattleAnims() return { gen2 = true, commandFormat = "gsc-macro-v1", + pictureArt = require('src.import.Gen2BattlePictureArt').extract(self), scripts = scripts, moveAnims = moveAnims, objects = self:gen2AnimObjects(), diff --git a/tests/engine/gen2_animation_import_commands.lua b/tests/engine/gen2_animation_import_commands.lua index 30803ed7b..633863139 100644 --- a/tests/engine/gen2_animation_import_commands.lua +++ b/tests/engine/gen2_animation_import_commands.lua @@ -44,14 +44,16 @@ for _,edition in ipairs({'gold','silver','crystal'})do end for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do local marker=markerFor(edition) - T.check(marker:find('gen2%-anim%-object%-v1:')~=nil,'generated animation cache has a new contract') + T.check(marker:find('gen2%-anim%-object%-v2:')~=nil,'generated animation cache has a new contract') Version.set(edition) - local old=marker:gsub('gen2%-anim%-object%-v1:','') - love.filesystem.write(Version.cachePrefix(edition)..'rom-cache.complete',old) - local report=Importer.readyReport(edition) - T.eq(report.ok,false,'old handler-labelled cache must be rebuilt') - T.check(report.why and report.why:find('another version',1,true)~=nil, - 'real readiness gate refuses the old format before checking file gaps') + for _,old in ipairs({marker:gsub('gen2%-anim%-object%-v2:',''), + (marker:gsub('gen2%-anim%-object%-v2:','gen2-anim-object-v1:'))})do + love.filesystem.write(Version.cachePrefix(edition)..'rom-cache.complete',old) + local report=Importer.readyReport(edition) + T.eq(report.ok,false,'old commands or missing-picture cache must be rebuilt') + T.check(report.why and report.why:find('another version',1,true)~=nil, + 'real readiness gate refuses old format before checking file gaps') + end end for _,edition in ipairs({'red','emerald','platinum'})do T.check(not markerFor(edition):find('gen2%-anim%-object'), diff --git a/tests/engine/gen2_picture_art_draw.lua b/tests/engine/gen2_picture_art_draw.lua new file mode 100644 index 000000000..5f6711e68 --- /dev/null +++ b/tests/engine/gen2_picture_art_draw.lua @@ -0,0 +1,182 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local F=dofile('tests/gen2_picture_art/fixture.lua') +local Import=require('src.import.Gen2BattlePictureArt') +local Battle=require('src.battle.BattleState') +local V=require('src.core.GameVersion') +local P=require('src.render.PaletteFX') +local G=require('src.render.Gen2PaletteDraw') +local Assets=require('src.render.Assets') +local previous=V.get() +local oldImage,oldData,oldNew,oldDraw=love.image,Assets.imageData,love.graphics.newImage,love.graphics.draw +local graphics=love.graphics +local push,pop,setScissor,getScissor,intersect=graphics.push,graphics.pop,graphics.setScissor, + graphics.getScissor,graphics.intersectScissor +local clip,clipStack=nil,{} +graphics.push=function(...) + clipStack[#clipStack+1]=clip or false + return push(...) +end +graphics.pop=function(...) + clip=table.remove(clipStack)or nil + return pop(...) +end +graphics.getScissor=function()if clip then return unpack(clip)end end +graphics.setScissor=function(x,y,w,h) + clip=x and{x,y,w,h}or nil + return setScissor(x,y,w,h) +end +graphics.intersectScissor=function(x,y,w,h) + if clip then + local right,bottom=math.min(x+w,clip[1]+clip[3]),math.min(y+h,clip[2]+clip[4]) + x,y=math.max(x,clip[1]),math.max(y,clip[2]);w,h=math.max(0,right-x),math.max(0,bottom-y) + end + graphics.setScissor(x,y,w,h) +end +love.image={newImageData=F.image} +local images,calls={},{} +Assets.imageData=function(path) + if images[path]then return images[path]:clone()end + local w=path:find('_back',1,true)and 48 or 56 + local out=F.image(w,w) + out:mapPixel(function(x)return (3-x%4)/3,(3-x%4)/3,(3-x%4)/3,1 end) + return out +end +love.graphics.newImage=function(data) + if type(data)=='table'and data.clone then return data:clone()end + if data=='assets/generated/sprites/monster.png'and images[data]then return images[data]:clone()end + return oldNew(data) +end +love.graphics.draw=function(image,x,y,rotation,sx,sy,extra,...) + if type(x)=='table'and x.getViewport then + x,y,rotation,sx,sy=y,rotation,sx,sy,extra + end + calls[#calls+1]={image=image,x=x,y=y,rotation=rotation,sx=sx or 1,sy=sy or 1, + clip={love.graphics.getScissor()},byte=G.bgp} +end +local A={{255,255,255},{211,41,67},{29,173,101},{3,7,13}} +local function make(edition,side,kind) + V.set(edition);P.setDisplay('gbc',nil);G.setBgp(0xe4) + local e;e,images=F.extractor(edition) + local art=assert(Import.extract(e)) + images['assets/generated/sprites/monster.png']=images[art.substitute.sheet] + local data=T.fixtures.fresh();data.constants.gen=2 + for _,def in pairs(data.pokemon)do def.palette='PIC_ART'end + data.palettes={palettes={PIC_ART=A,GRAYMON=A,MEWMON=A}, + pokemon={FIXMON_A='PIC_ART',FIXMON_B='PIC_ART',FIXMON_C='PIC_ART'}} + require('src.core.Data').publishGen2Palettes(data) + data.moves.PROBE={id='PROBE',name='PROBE',power=0,type='NORMAL',pp=10} + data.battle_anims={gen2=true,pictureArt=art,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op=kind=='substitute'and'raisesub'or'minimizeopp'}, + {op='delay',frames=100},{op='ret'}}}, + }} + Battle.invalidate() + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + s:animForMove('PROBE',side);s.animPlayer:update() + return s,s[side],art +end +local function draw(s,b,back) + calls={} + if back==nil or not s.drawPic then s:drawPicsLayer(0,0,0,b.isPlayer and'player'or'enemy',true) + else s:drawPic(b.mon,back)end + return calls +end +local function pixel(image,x,y,color,label) + T.same({image:getPixel(x,y)},{color[1]/255,color[2]/255,color[3]/255,1},label) +end +for _,edition in ipairs({'gold','silver','crystal'})do for _,side in ipairs({'player','enemy'})do + for _,kind in ipairs({'substitute','minimize'})do + local s,b,a=make(edition,side,kind) + local original=b.sprite;local frame=s.anim.frames + local rows=draw(s,b) + T.eq(#rows,1,'native picture layer emits one full ROM box') + local row=rows[1];local n=b.isPlayer and 48 or 56 + T.same({row.image:getDimensions()},{n,n},'real image keeps the six/seven-tile slot') + T.eq(row.x,b.isPlayer and 16 or 96,'actual Gen2 picture box origin') + T.eq(row.y,b.isPlayer and 48 or 0,'actual Gen2 picture box baseline') + local px=kind=='substitute'and 17 or 26 + local py=b.isPlayer and 32 or 40 + pixel(row.image,px,py,A[2],'replacement uses saved Pokemon CGB palette') + local _,_,_,alpha=row.image:getPixel(kind=='substitute'and 16 or 25,py) + T.eq(alpha,0,'background shade remains transparent') + T.eq(b.sprite,original,'replacement does not write native Pokemon image') + T.eq(s.anim.frames,frame,'native drawing does not advance command VM') + if s.pic then + local direct=s:pic(b.mon,b.isPlayer) + T.neq(direct,row.image,'public indexed Pokemon getter retains its original art') + end + if s.drawPic then for _,back in ipairs({false,true})do + rows=draw(s,b,back);T.eq(#rows,1,'public capture emits actual ROM replacement') + row=rows[1];local size=back and 48 or 56 + T.same({row.image:getDimensions()},{size,size},'capture selects requested front/back art') + T.eq(row.x,back and 16 or 96,'public capture correct requested box x') + T.eq(row.y,back and 48 or 0,'public capture correct requested box y') + pixel(row.image,px,back and 32 or 40,A[2],'opposite-side capture retains actual owner palette') + end end + s.anim.bg.slide[side]=5;s.anim.bg.picSize[side]=b.isPlayer and 1 or 4 + rows=draw(s,b);row=rows[1] + local ratio=b.isPlayer and 4/6 or 5/7 + T.eq(row.sx,ratio,'actual resize register scales whole native box') + T.eq(row.x,(b.isPlayer and 16 or 96)+5+math.floor(n*(1-ratio)/2),'slide and resize share selected footprint') + T.eq(row.y,(b.isPlayer and 48 or 0)+math.floor(n*(1-ratio)),'resize retains full picture baseline') + if s.drawPic then + s.anim.bg.picSize[side]=0 + rows=draw(s,b,false);row=rows[1] + T.eq(row.sx,6/7,'front capture uses seven-tile resize denominator even for native player') + T.eq(row.y,math.floor(56*(1-6/7)),'opposite front capture resize pins front baseline') + end + s.anim.bg.picSize[side]=nil;s.anim.bg.slide[side]=0 + s.anim.bg.monShade[side]=0xfc + rows=draw(s,b) + pixel(rows[1].image,px,py,A[4],'per-mon shade register reaches real replacement bake') + T.eq(G.bgp,nil,'native draw restores caller identity-register representation') + s.anim.bg.monShade[side]=nil;G.setBgp(0x1b) + rows=draw(s,b) + pixel(rows[1].image,px,py,A[3],'whole-background BGP reaches real replacement bake') + T.eq(G.bgp,0x1b,'replacement restores caller BGP even when nonidentity') + G.setBgp(0xe4) + do + local base + if s.pic then local _,_,path=s:pic(b.mon,b.isPlayer);base=path + else base=require('src.pokemon.Sprites').path(s.data,b.mon.species,b.isPlayer and 'back'or'front',{mon=b.mon,kind='battle'})end + s.data.battle_sprite_scales={OWNED={path=base,scale=2}} + row=draw(s,b)[1] + T.eq(row.sx,2,'generated picture retains underlying per-image scale override') + s.data.battle_sprite_scales=nil + end + if s.substituteDoll then + local first,quad=s:substituteDoll(true) + T.same({first:getDimensions()},{16,32},'public doll getter exposes genuine indexed ROM sheet') + T.same({quad:getViewport()},{0,16,16,16},'back doll quad selects facing-up frame') + local nextImage,nextQuad=s:substituteDoll(true) + T.eq(nextImage,first,'public doll getter shares native cached image') + T.eq(nextQuad,quad,'public doll getter reuses owned quad') + end + if s.drawPic then + s.anim.bg.liftedRows[side]={4,1} + rows=draw(s,b,false) + T.eq(#rows,2,'normal front capture splits around actual lifted band') + T.eq(rows[1].clip[2],0,'opposite capture lifted band is in front coordinates') + T.eq(rows[1].clip[4],32,'front rows above lifted band remain visible') + T.eq(rows[2].clip[2],40,'front rows below lifted band resume correctly') + s.anim.bg.liftedRows[side]=nil + end + local realDraw=love.graphics.draw + love.graphics.draw=function()error('picture device failure',0)end + T.raises(function()draw(s,b,false)end,'picture device failure','device draw failure remains visible') + T.eq(G.bgp,nil,'failed replacement paint restores caller identity register') + T.eq(s._gen2PicPaletteSide,nil,'failed replacement paint releases palette owner') + love.graphics.draw=realDraw + end +end end +V.set(previous);love.image=oldImage;Assets.imageData=oldData +love.graphics.newImage,love.graphics.draw=oldNew,oldDraw +graphics.push,graphics.pop,graphics.setScissor,graphics.getScissor,graphics.intersectScissor= + push,pop,setScissor,getScissor,intersect +T.finish('native GSC replacement pixels, actual drawing, capture geometry and palette lifetime') diff --git a/tests/engine/gen2_picture_art_import.lua b/tests/engine/gen2_picture_art_import.lua new file mode 100644 index 000000000..88a0a5035 --- /dev/null +++ b/tests/engine/gen2_picture_art_import.lua @@ -0,0 +1,82 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local F=dofile('tests/gen2_picture_art/fixture.lua') +local Art=require('src.import.Gen2BattlePictureArt') +local Data=require('src.battle.Gen2AnimData') +local Player=require('src.battle.Gen2AnimPlayer') +local image=love.image;love.image={newImageData=F.image} +local shades={1,2/3,1/3,0} +for _,edition in ipairs({'gold','silver','crystal','prism'})do + local e,images=F.extractor(edition) + local a=assert(Art.extract(e)) + T.eq(a.version,1,'import advertises actual decoded picture format') + T.eq(a.stagedActorPic,edition=='crystal'or edition=='prism','only positive staged-copy handler is advertised') + local count=0;for _ in pairs(images)do count=count+1 end + T.eq(count,5,'actual importer emits four full boxes and public doll sheet') + for _,back in ipairs({false,true})do + local side=back and 'player'or'enemy' + local n=back and 6 or 7 + local doll=images[a.substitute[side]] + T.same({doll:getDimensions()},{n*8,n*8},'doll uses native full tile box') + for y=0,n*8-1 do for x=0,n*8-1 do + local inside=x>=16 and x<32 and y>=(back and 32 or 40)and y<(back and 48 or 56) + local expected=0 + if inside then + local tile=(back and 4 or 0)+math.floor((y-(back and 32 or 40))/8)*2+math.floor((x-16)/8) + expected=(tile+x%8+2*(y%8))%4 + end + local r,g,b,alpha=doll:getPixel(x,y) + T.same({r,g,b,alpha},{shades[expected+1],shades[expected+1],shades[expected+1],expected==0 and 0 or 1}, + 'ROM doll tile identity, row/column order, shade and transparency') + end end + local mini=images[a.minimize[side]] + for y=0,n*8-1 do for x=0,n*8-1 do + local inside=x>=24 and x<32 and y>=(back and 32 or 40)and y<(back and 40 or 48) + local expected=inside and (3+x%8+2*(y%8))%4 or 0 + local r,g,b,alpha=mini:getPixel(x,y) + T.same({r,g,b,alpha},{shades[expected+1],shades[expected+1],shades[expected+1],expected==0 and 0 or 1}, + 'ROM Minimize tile is copied once into its own native column/row') + end end + end + local tables=Data.tables({pictureArt=a}) + T.eq(tables.pictureArt,a,'native adapter retains actual imported capability and paths') + for _,back in ipairs({false,true})do + for _,kind in ipairs({'minimize','transform'})do + local native={gen2=true,pictureArt=a,moveAnims={PROBE=1},scripts={ + [1]={entry=1,code={{op=kind},{op='delay',frames=3},{op='updateactorpic'}, + {op='delay',frames=3},{op='ret'}}}, + }} + local p=Player.new({battle_anims=native}) + p:start('PROBE',back);local r=p.runtime;local side=back and 'player'or'enemy' + p:update() + local initial + if not a.stagedActorPic and kind=='transform'then initial='transform'end + T.eq(r.picOverride[side],initial, + 'staging cannot repaint actor before update command') + if a.stagedActorPic then T.eq(r.actorPic[side],kind,'real VM owns staged tile identity')end + local steps=0 + while r.frames<5 and steps<100 do p:update();steps=steps+1 end + T.eq(r.picOverride[side],a.stagedActorPic and kind or (kind=='transform'and'transform'or nil), + 'real copy command publishes the staged identity only on capable ROMs') + p:start('PROBE',not back) + T.eq(next(p.runtime.actorPic),nil,'new VM starts with empty staged tiles') + p:release() + end + end +end +for _,change in ipairs({ + function(s)s.MonsterSpriteGFX=nil;s.MinimizePic=nil end, + function(s,r)r[0x5000]=300;r[0x6000]=-1 end, + function(s,r)r[0x5000]=nil;r[0x6000]=nil end, +})do + local e,images=F.extractor('gold',change) + T.eq(Art.extract(e),nil,'missing/malformed ROM bytes do not invent picture art') + T.eq(next(images),nil,'unavailable art cannot leave advertised partial images') +end +local e,images=F.extractor('crystal',function(s)s.MonsterSpriteGFX=nil;s.MinimizePic=nil end) +local a=Art.extract(e) +T.eq(a.stagedActorPic,true,'independent actor-copy capability survives unavailable optional graphics') +T.eq(a.minimize,nil,'missing Minimize symbol does not fabricate a replacement') +T.eq(next(images),nil,'metadata alone emits no invented art') +love.image=image +T.finish('native GSC picture import pixels, staged copy and missing-ROM-data boundaries') diff --git a/tests/engine/gen2_picture_presentation.lua b/tests/engine/gen2_picture_presentation.lua index 5f79f3fb6..9964e74fe 100644 --- a/tests/engine/gen2_picture_presentation.lua +++ b/tests/engine/gen2_picture_presentation.lua @@ -128,11 +128,14 @@ end -- Missing/disabled/cancelled programs and a real slot replacement cannot -- leave a pinned picture hanging or repaint the incoming saved Pokemon. -for _,mode in ipairs({'disabled','missing','cancelled','stopped','new-program','replacement'})do +for _,mode in ipairs({'disabled','missing','cancelled','stopped','no-picture-op','new-program','replacement'})do local s,b,target,row=make('crystal','player','transform') local species=b.mon.species if mode=='disabled'then s.game.save.options.animations=false end if mode=='missing'then s.animPlayer.anims.scripts[1]=nil end + if mode=='no-picture-op'then + s.data.battle_anims.scripts[1].code={{op='delay',frames=2},{op='ret'}} + end Effects.TRANSFORM_EFFECT(s,b,target) if mode=='cancelled'then s:cancelMoveAnim();Pictures.sync(s) elseif mode=='replacement'then @@ -143,7 +146,7 @@ for _,mode in ipairs({'disabled','missing','cancelled','stopped','new-program',' T.eq(s.player.mon,incoming,'native switch retains actual incoming mon') T.eq(Pictures.species(s,s.player),'FIXMON_B','old transform cannot repaint replacement slot') elseif mode=='stopped'then - s:updateQueue();s.anim.stop=true;s:updateQueue() + s:updateQueue();s.anim.stopped=true;s:updateQueue() elseif mode=='new-program'then s:updateQueue() local retired=s.anim @@ -151,11 +154,39 @@ for _,mode in ipairs({'disabled','missing','cancelled','stopped','new-program',' T.neq(s.anim,retired,'public start creates a distinct real native runner') s:updateQueue() T.eq(retired.picOverride.player,nil,'replacing runner does not invent a retired transform opcode') + elseif mode=='no-picture-op'then s:updateQueue();endAnim(s) else s:updateQueue()end T.eq(#(s._gen2PictureChanges or {}),0,mode..' releases native picture pin') if mode~='replacement'then T.eq(b.species,target.mon.species,mode..' commits resolved native visual identity')end T.eq(b.mon.species,species,mode..' retains saved species') end +-- Crystal loads a transformed actor into vTiles0 first; updateactorpic later +-- publishes it. Synthetic old caches/GS keep the previous direct command. +for _,side in ipairs({'player','enemy'})do + local s,b,target,row=make('crystal',side,'transform') + s.data.battle_anims.pictureArt={version=1,stagedActorPic=true} + s.data.battle_anims.scripts[1].code={ + {op='transform'},{op='delay',frames=3},{op='updateactorpic'}, + {op='delay',frames=3},{op='ret'}, + } + local old=s:picImage(b.sprite) + Effects.TRANSFORM_EFFECT(s,b,target) + local replacement=b.sprite + s:updateQueue();local r=s.anim + s:updateQueue() + T.eq(r.actorPic[side],'transform','Crystal stages actual transformed actor tiles') + T.eq(r.picOverride[side],nil,'staging has not repainted the real BG slot') + T.eq(s:battlerPic(b),old,'native picture pin survives the staging command') + local frames=0 + while r.picOverride[side]~='transform'and frames<100 do + T.eq(s:battlerPic(b),old,'all waiting frames retain previous native picture') + s:updateQueue();frames=frames+1 + end + T.check(frames>0 and frames<100,'native copy command is reached') + T.eq(s:battlerPic(b),s:picImage(replacement),'copy command reveals actual transformed actor') + T.eq(b.species,target.mon.species,'shown species commits at actual copy command') + T.eq(#s._gen2PictureChanges,0,'copy command retires the pin exactly once') +end for _,disabled in ipairs({false,true})do local s,b,target,row=make('crystal','player','transform') local initial=s:picImage(b.sprite) diff --git a/tests/gen2_picture_art/fixture.lua b/tests/gen2_picture_art/fixture.lua new file mode 100644 index 000000000..6da1e20d3 --- /dev/null +++ b/tests/gen2_picture_art/fixture.lua @@ -0,0 +1,65 @@ +-- CPU image storage for genuine import/bake pixels, not a drawing substitute. +local F={} +function F.image(w,h) + local out={w=w,h=h,pixels={}} + function out:getWidth()return self.w end + function out:getHeight()return self.h end + function out:getDimensions()return self.w,self.h end + function out:setPixel(x,y,...) + assert(x>=0 and x=0 and y Date: Wed, 28 Oct 2026 04:27:51 +0100 Subject: [PATCH 47/64] Record primary GSC picture-copy behavior and remaining renderer boundaries --- compat/gen1-gsc/provenance.json | 8 +++- docs/native-gen2-picture-art.md | 55 ++++++++++++++++++++++++ docs/native-gen2-picture-presentation.md | 7 +-- src/battle/gen2/AnimRunner.lua | 10 ++--- 4 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 docs/native-gen2-picture-art.md diff --git a/compat/gen1-gsc/provenance.json b/compat/gen1-gsc/provenance.json index 5114142c5..596614a59 100644 --- a/compat/gen1-gsc/provenance.json +++ b/compat/gen1-gsc/provenance.json @@ -24,8 +24,12 @@ "The BG-only paper and clipping subset uses the native indexed palette owner, without requiring the full menu Chrome module on older engines.", "Full object pool uses the already pinned native Gen2AnimMath sine/cosine subset. Native imported numeric animation tables are converted without inventing ROM programs or changing their operands.", "Existing native animation player now uses the complete command interpreter and object pool as its sole clock. The native view consumes emitted OAM through the actual loaded GFX dictionary; no interpolated motion, 48-frame age cap or 16-frame drain remains.", - "The native importer preserves the crossed battler-row macro/handler names. A Gen2-only cache revision rejects old generated commands and preserves game saves." + "The native importer preserves the crossed battler-row macro/handler names. A Gen2-only cache revision rejects old generated commands and preserves game saves.", + "Crystal actor copies now distinguish vTiles0 staging from BG publication. Positive imported symbol capabilities preserve GS dummy commands; this intentionally corrects the pinned reference for Transform and Minimize.", + "ROM-derived full Substitute/Minimize boxes use native indexed image/palette/geometry owners. Opposite captures retain actual battler identity with requested box size. No ROM bytes/images are distributed.", + "Picture pins reconcile the actual interpreter stopped field; programs returning without an image opcode cannot retain stale visuals." ], "licence": "Gen1Recomp GPLv3 with its Additional Terms, retained in LICENSE.MD. Existing authors retain their rights; Roxas2712 integration changes in these derived files are offered on those applicable terms.", - "qualification": "Actual Crystal ROM command/object/BG/OAM comparison against pinned Gen1Recomp535a5ec0: 251 moves and 23 extra programs, both sides, five parameters plus ball/shake/catch cases, 2804 cases and355718 compared frames, no mismatch or observation-limit case. Source e96a8edc; later publication changes only comments, provenance and cache-gate fixture access. Fork a0474fcd passes7893 checks in19 ROM-free groups; independent upstream7ea2eff1 passes5313 in14. These are CPU observations; the old ce12c485 Metal battle predates this VM. New unchanged-client GPU, all picture-override rendering, full send-out/event integration, Windows and packaged release qualification remain open." + "qualification": "Crystal ROM source f5572ee1: fresh native move IDs/GFX/palette/art import,10880 picture pixels independently checked,2804 cases and355718 frames. Exact primary actor staging/publication at frames8/57 replaces picOverride equality only for two known pinned-reference bugs (Transform/Minimize); all other command/OAM/BG observations remain compared. No generated-cache input. Fork CPU source c50da691 passes27 selected groups including shared native picture/Gen3 Substitute regressions; independent upstream e9ec0554 passes10 targeted groups. Publication changes after these sources are documentation/comments; new broad upstream qualification and any GPU/client/Windows/release acceptance remain separate.", + "historicalObjectQualification": "Actual Crystal ROM command/object/BG/OAM comparison against pinned Gen1Recomp535a5ec0: 251 moves and 23 extra programs, both sides, five parameters plus ball/shake/catch cases, 2804 cases and355718 compared frames, no mismatch or observation-limit case. Source e96a8edc; later publication changes only comments, provenance and cache-gate fixture access. Fork a0474fcd passes7893 checks in19 ROM-free groups; independent upstream7ea2eff1 passes5313 in14. These are CPU observations; the old ce12c485 Metal battle predates this VM. New unchanged-client GPU, all picture-override rendering, full send-out/event integration, Windows and packaged release qualification remain open." } diff --git a/docs/native-gen2-picture-art.md b/docs/native-gen2-picture-art.md new file mode 100644 index 000000000..44179a867 --- /dev/null +++ b/docs/native-gen2-picture-art.md @@ -0,0 +1,55 @@ +# GSC replacement art and Crystal actor copies + +The native importer reads `MonsterSpriteGFX` and `MinimizePic` from the mounted +ROM. It writes the two Substitute and two Minimize pictures in full 6x6/7x7 +tile boxes, plus the indexed Substitute sheet for the public getter. Cartridge +scratch tiles are column-major; generated PNG tiles are row-major. No ROM +image is committed or published with this contribution. Missing symbols or +invalid byte ranges do not fabricate art. + +Native drawing uses these images through its existing image/palette cache. +The full picture footprint composes with slide, resize and lifted-row owners; +Substitute no longer bypasses those operations through a fixed Gen1 doll draw. +Minimize cannot turn into a Stadium model. The native faint picture retains +its owner. Gen2 backs use the 6x6 box at (16,48), including trainer and grow-in +placement, rather than Gen1's left-padding compensation. Image-specific scale +settings still refer to the underlying Pokemon image. Opposite-orientation +captures use the requested box dimensions with the real battler's palette. + +The public `pic` getter keeps its original indexed-Pokemon contract. Its +`drawPic` capture paints the current replacement. `substituteDoll` exposes +the actual indexed 16x32 ROM sheet with cached front/back quads, allowing the +source caller to own its palette operation. + +Crystal differs from Gold/Silver: Transform and Minimize stage actor tiles in +vTiles0; `updateactorpic` later copies them into the actor's BG slot. The pinned +Gen1 reference's immediate Transform and empty Minimize/updateactorpic handlers +missed this distinction. Positive ROM symbols advertise these capabilities; +Gold/Silver retain their direct Transform and dummy $e8/$e9 commands. The same +existing VM owns staging, delays and publication. The native pin reconciler +also reads that VM's actual `stopped` field, including a program returning +without an image opcode. No additional animation or combat clock is added. + +The primary definitions are in [Crystal's commands](https://github.com/pret/pokecrystal/blob/master/engine/battle_anims/anim_commands.asm), +[Crystal's programs](https://github.com/pret/pokecrystal/blob/master/data/moves/animations.asm) +and [Gold/Silver's commands](https://github.com/pret/pokegold/blob/master/engine/battle_anims/anim_commands.asm). +The Gen2-only animation cache revision is now 2 so an existing v1 cache gets +the new art/capabilities on import; other generations and saves retain their +existing contracts. Renamed editions advertise only data their symbols expose. + +ROM-free tests check every decoded pixel, real native drawing, palettes, +requested capture geometry, resize/lifted composition, image-scale settings, +error restoration and native terminal ownership. CPU checks with the supplied +Crystal ROM independently validate 10,880 picture pixels against its bitplanes, +then exercise 2,804 cases and 355,718 frames. Crystal's actor staging is checked +at VM frame 8 and publication at frame 57 against the actual imported programs. +For Transform/Minimize only, picture-override equality with the buggy pinned +reference is replaced by that primary-source oracle; all other command/object, +OAM and BG observations remain compared. No old generated-cache input is used. + +These checks do not establish GPU, unchanged-mod, Windows or release parity. +The native OAM drawer still skips battler-row pseudo-sheets, as does the pinned +reference; that missing renderer path needs separate work. Complete send-out, +identity/EXP/capture/menu events, model/download options, the full native battle +factory/turn API and broader release qualification also remain open. Mods, +protected instances, ROM originals and player saves are unchanged. diff --git a/docs/native-gen2-picture-presentation.md b/docs/native-gen2-picture-presentation.md index 18f75f80d..a3d13ea1d 100644 --- a/docs/native-gen2-picture-presentation.md +++ b/docs/native-gen2-picture-presentation.md @@ -36,8 +36,9 @@ Its tests therefore exercise the native picture layer; optional public-capture assertions run on the fork where that boundary already exists. The upstream adaptation uses its existing inline queue admission, not a copied fork queue. -This change does not qualify every battle picture. Minimize ROM art, complete -GSC doll placement/palette and resize composition, full per-hit Beat Up combat -sequencing, send-out events, natural-move GPU comparisons, Windows and packaged +The follow-up [native picture-art implementation](native-gen2-picture-art.md) +adds Minimize/Substitute ROM art, GSC placement/palette/resize composition and +Crystal's staged actor copy. Full per-hit Beat Up combat sequencing, battler-row +OAM drawing, send-out events, natural-move GPU comparisons, Windows and packaged release validation remain separate work. No supplied mod, protected instance, ROM or player save is changed. No new release is implied by these CPU tests. diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index 24228d0c8..446022039 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -262,11 +262,9 @@ end -- The commands that swap a battler's pic out for something else. The port -- records which, and the view draws it. -- --- Every one of them branches on hBattleTurn the same way --- (engine/battle_anims/anim_commands.asm): `and a / jr z, .player`, and the --- .player arm is the one that writes vTiles2 tile $31, the 6x6 BACKPIC slot. --- So turn 0, the player attacking, always repaints the PLAYER's own pic, and --- the fall-through arm (tile $00, the 7x7 frontpic) repaints the enemy's. +-- hBattleTurn selects the actor's own 6x6 back or 7x7 front slot. GS writes +-- Transform directly to BG; Crystal stages it in vTiles0 and copies it at +-- updateactorpic. Keep that distinction at the command owner. C.transform = function(self) -- BattleAnimCmd_Transform: .player loads wTempEnemyMonSpecies into the -- backpic slot, i.e. the player's sprite becomes what it transformed into. @@ -295,7 +293,7 @@ C.minimizeopp = function(self) end C.beatup = function(self) - -- wBattleAnimParam is the party slot whose pic to show. + -- wBattleAnimParam is the species byte; `slot` is the retained public key. local side = self.env.battleTurn == 0 and "player" or "enemy" self.picOverride[side] = { kind = "beatup", slot = self.param } end From 80a33201e6d4e7f08366970912a870fe7ca2c708 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:29:07 +0100 Subject: [PATCH 48/64] Clarify which public capture boundaries are provided by upstream --- docs/native-gen2-picture-art.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/native-gen2-picture-art.md b/docs/native-gen2-picture-art.md index 44179a867..24513d8b4 100644 --- a/docs/native-gen2-picture-art.md +++ b/docs/native-gen2-picture-art.md @@ -16,8 +16,9 @@ placement, rather than Gen1's left-padding compensation. Image-specific scale settings still refer to the underlying Pokemon image. Opposite-orientation captures use the requested box dimensions with the real battler's palette. -The public `pic` getter keeps its original indexed-Pokemon contract. Its -`drawPic` capture paints the current replacement. `substituteDoll` exposes +Where the public `pic`/`drawPic` boundary already exists, the getter keeps its +original indexed-Pokemon contract and the capture paints the current replacement. +The upstream adaptation does not invent those missing methods. `substituteDoll` exposes the actual indexed 16x32 ROM sheet with cached front/back quads, allowing the source caller to own its palette operation. From cead22a6fdeca109fce60a36e4905f8be643b281 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:54:33 +0100 Subject: [PATCH 49/64] Render native GSC battler rows from command-time picture snapshots --- .../workflows/gen2-picture-presentation.yml | 1 + docs/native-gen2-battler-rows.md | 43 +++++ src/battle/BattleState.lua | 4 +- src/battle/Gen2AnimPlayer.lua | 3 + src/battle/Gen2AnimView.lua | 28 ++- src/battle/Gen2BattlerRows.lua | 50 ++++++ src/battle/Gen2PictureArt.lua | 13 ++ src/battle/gen2/AnimRunner.lua | 3 + tests/engine/gen2_battler_rows.lua | 164 ++++++++++++++++++ 9 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 docs/native-gen2-battler-rows.md create mode 100644 src/battle/Gen2BattlerRows.lua create mode 100644 tests/engine/gen2_battler_rows.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index 5a50a9080..9a68670e4 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -21,6 +21,7 @@ jobs: luajit tests/engine/gen2_picture_pixels.lua luajit tests/engine/gen2_picture_art_import.lua luajit tests/engine/gen2_picture_art_draw.lua + luajit tests/engine/gen2_battler_rows.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/docs/native-gen2-battler-rows.md b/docs/native-gen2-battler-rows.md new file mode 100644 index 000000000..0ade5a64d --- /dev/null +++ b/docs/native-gen2-battler-rows.md @@ -0,0 +1,43 @@ +# Native GSC battler rows + +Contributions by Roxas2712. Existing GSC interpreter and object-drawing +provenance and licences remain in `compat/gen1-gsc/` and `NOTICE.md`. + +The real interpreter already registers the player-head/enemy-feet pseudo +sheets. The renderer previously rejected every loaded entry carrying a +`battler` field, so its genuine OAM entries had no pixels to draw. The frozen +Gen1 reference also skips these entries; VM-state equality alone does not +qualify this drawing path. + +At the existing `battlergfx` command the native player now snapshots the +actual screen-owned indexed picture into one/two tile rows. Enemy feet come +from the bottom of the seven-tile front slot; player head comes from the top +of the six-tile back slot. Five/six-tile native fronts have the cartridge's +one-tile left padding. The VM retains its actual command timing, descriptors, +OAM positions, flips and palettes. No second interpreter or battle state is +introduced. Source image data is shared/read-only; copying performs no GPU +readback. Repeated drawing does not decode or upload images. + +OBJ colour zero is removed before palette application, independently of BG +register changes. Custom RGB art keeps its alpha and opts out of indexed +recolouring. Crystal crop descriptors select a single frame. Replacement +art and queued Transform use the existing picture presentation authority; +reading the source never reveals a queued future picture. + +Copied images belong to the native view, keyed weakly by the actual loaded +descriptor. Retaining an old runner retains its copied picture; a restarted +animation cannot substitute its current image into old OAM. View retirement +releases remaining images. No mod, ROM, player save or Gen1 installation is +changed. No extracted ROM pixels are committed. + +Primary source: + +- [Crystal battlergfx copy commands](https://github.com/pret/pokecrystal/blob/master/engine/battle_anims/anim_commands.asm) +- [Native front-picture padding](https://github.com/pret/pokecrystal/blob/master/engine/gfx/load_pics.asm) + +`tests/engine/gen2_battler_rows.lua` drives actual native battles, commands +and OAM, checking copied pixel values, column-major strip addressing, +attacker mirroring, both flips, palette uploads, read-only drawing, retained +runners and failure cleanup in Gold/Silver/Crystal. It also checks native +front sizes and custom RGB/crop input. This is CPU/device-record validation, +not a new GPU, full unchanged-client, Windows or release qualification. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a16de4671..2ea25e83c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1259,6 +1259,7 @@ local function newBattle(game) if ok then self.animPlayer = Gen2AnimPlayer.new(game.data) self.animPlayer.battle = self.battle + self.animPlayer.screen = self self.animView = self.animPlayer.view self.anims = self.animPlayer.anims end @@ -13084,6 +13085,7 @@ require('src.battle.Gen2BattleScene').install(BattleState) -- Compose after native/source view installers, retaining their real draw owners. require('src.battle.Gen2BgPictures').install(BattleState) require('src.battle.Gen2PicturePresentation').install(BattleState) -require('src.battle.Gen2PictureArt').install(BattleState,{load=getImage,palette=monPalette,path=imagePathOf}) +require('src.battle.Gen2PictureArt').install(BattleState,{load=getImage,palette=monPalette,path=imagePathOf, + source=function(image)return imageMeta[image]end}) return BattleState diff --git a/src/battle/Gen2AnimPlayer.lua b/src/battle/Gen2AnimPlayer.lua index f3612b662..4d89cc977 100644 --- a/src/battle/Gen2AnimPlayer.lua +++ b/src/battle/Gen2AnimPlayer.lua @@ -130,6 +130,9 @@ function Gen2AnimPlayer:start(moveId,attackerIsPlayer,opts) param=param,ballPalette=options.ballPalette,sgb=options.sgb, flying=options.flying, hooks={ + battlerGfx=function(runner,front,back) + self.view:loadBattlerRows(self.screen,front,back) + end, pokeballWobble=function() wobbles=wobbles+1 return wobbles<=(options.shakes or 0)and 0 or options.caught and 1 or 2 diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index 2225433a6..25dfd3dde 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -5,6 +5,7 @@ -- compat/gen1-gsc/LICENSE.MD; inherited object-drawing rights remain intact. local Palette = require('src.render.Gen2PaletteDraw') local PaletteFX = require('src.render.PaletteFX') +local BattlerRows = require('src.battle.Gen2BattlerRows') local View = {} View.__index = View local slots = { @@ -13,7 +14,14 @@ local slots = { PAL_BATTLE_OB_BLUE=6, PAL_BATTLE_OB_BROWN=7, } function View.new(anims, data) - return setmetatable({data=anims or {}, gameData=data or {}, images={}, quads={}}, View) + return setmetatable({data=anims or {}, gameData=data or {}, images={}, quads={}, + battlerRows=setmetatable({},{__mode='k'})}, View) +end +function View:loadBattlerRows(screen,...) + if not(screen and screen.gen2ObjectPictureSource)then return end + for _,entry in ipairs({...})do + self.battlerRows[entry]=BattlerRows.snapshot(screen:gen2ObjectPictureSource(entry.battler),entry) + end end function View:image(path) if not path then return nil end @@ -29,6 +37,10 @@ function View:release() if image and image.release then image:release() end end self.images, self.quads = {}, {} + for _,snapshot in pairs(self.battlerRows)do + if snapshot.image.release then snapshot.image:release()end + end + self.battlerRows=setmetatable({},{__mode='k'}) self:releaseBg() end function View:objPalette(name, battle) @@ -60,7 +72,7 @@ function View:drawObjects(runner, battle) g.push('all') local ok, err = pcall(function() g.setColor(1,1,1,1) - local active,activeByte + local active,activeByte,activeRaw for _, obj in ipairs(runner:oam()) do local entry,index for i=#runner.loaded,1,-1 do @@ -71,15 +83,17 @@ function View:drawObjects(runner, battle) end end local sheet=entry and not entry.battler and self.data.gfx and self.data.gfx[entry.gfx] - local image=sheet and self:image(sheet.image) - local quad=image and self:quad(entry.gfx,index,image) + local snapshot=entry and self.battlerRows[entry] + local image=snapshot and snapshot.image or sheet and self:image(sheet.image) + local quad=snapshot and BattlerRows.quad(snapshot,index)or image and self:quad(entry.gfx,index,image) if quad then local colors = self:objPalette(obj.palette, battle) local slot=slots[obj.palette]or tonumber(obj.palette) local byte=(slot==2 or slot==3)and runner.bg and runner.bg.obp0 or nil - if colors~=active or byte~=activeByte then - if not Palette.useRaw(Palette.remap(Palette.resolve(colors),byte))then g.setShader()end - active,activeByte=colors,byte + local raw=snapshot and snapshot.trueColor or false + if colors~=active or byte~=activeByte or raw~=activeRaw then + if raw or not Palette.useRaw(Palette.remap(Palette.resolve(colors),byte))then g.setShader()end + active,activeByte,activeRaw=colors,byte,raw end local attr = obj.attr or 0 local sx = math.floor(attr/32)%2 == 1 and -1 or 1 diff --git a/src/battle/Gen2BattlerRows.lua b/src/battle/Gen2BattlerRows.lua new file mode 100644 index 000000000..9128b7692 --- /dev/null +++ b/src/battle/Gen2BattlerRows.lua @@ -0,0 +1,50 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC copies enemy feet / player head from BG picture tiles into OBJ VRAM. +-- Snapshot at the real battlergfx command; drawing never rereads live art. +local Assets=require('src.render.Assets') +local M={} +function M.snapshot(source,entry) + if not(source and source.path and love.image and love.image.newImageData)then return end + local raw=Assets.imageDataShared(source.path) + local w,h=raw:getDimensions() + local x0=0 + if source.crop then w=source.crop.width;x0=(source.crop.index-1)*w end + if w<=0 or h<=0 then return end + local back=entry.battler=='player' + local size=back and 48 or 56 + -- Native fronts are 5/6/7 tiles. PadFrontpic puts both smaller sizes + -- one tile from the left and bottom-aligns them in the seven-tile box. + -- Replacement sheets already contain their full box. Custom larger art + -- is sampled into the same logical OBJ cells, preserving its own alpha. + local lw,lh=size,size + if not back and (w==40 or w==48)then lw=w end + if not back and (h==40 or h==48)then lh=h end + local left=lw=0 and py>=0 and px0.83 then a=0 end + out:setPixel(x,y,r,g,b,a) + end + end end + local image=love.graphics.newImage(out) + if out.release then out:release()end + image:setFilter('nearest','nearest') + return {image=image,rows=rows,cols=cols,trueColor=source.trueColor,quads={}} +end +function M.quad(snapshot,index) + if index<0 or index>=snapshot.rows*snapshot.cols then return end + if not snapshot.quads[index]then + snapshot.quads[index]=love.graphics.newQuad(math.floor(index/snapshot.rows)*8, + (index%snapshot.rows)*8,8,8,snapshot.cols*8,snapshot.rows*8) + end + return snapshot.quads[index] +end +return M diff --git a/src/battle/Gen2PictureArt.lua b/src/battle/Gen2PictureArt.lua index 03a2f488c..40aa9e4e9 100644 --- a/src/battle/Gen2PictureArt.lua +++ b/src/battle/Gen2PictureArt.lua @@ -31,6 +31,19 @@ function M.replacement(screen,b,back) end function M.install(Screen,images) Screen._gen2PictureImages=images + function Screen:gen2ObjectPictureSource(side) + if not Version.isGen2()then return end + local b=self[side] + if not b then return end + local art=record(self) + local kind=Pictures.override(self,b) + local row=art and type(kind)=='string'and art[kind] + local path=row and row[side] + if path then return {path=path}end + local image=Pictures.sprite(self,b) + local meta=images.source(image) + if meta then return {path=meta.path,crop=meta.crop,trueColor=meta.trueColor}end + end local sprite=Screen.pictureSprite function Screen:pictureSprite(b) return M.replacement(self,b) or sprite(self,b) diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index 446022039..800f01964 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -216,6 +216,9 @@ function Runner:loadBattlerGfx(rows) self.loaded[#self.loaded + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6, battler = "player", rows = rows } + if self.hooks.battlerGfx then + self.hooks.battlerGfx(self,self.loaded[#self.loaded-1],self.loaded[#self.loaded]) + end end -------------------------------------------------------------------------- diff --git a/tests/engine/gen2_battler_rows.lua b/tests/engine/gen2_battler_rows.lua new file mode 100644 index 000000000..93c3358c0 --- /dev/null +++ b/tests/engine/gen2_battler_rows.lua @@ -0,0 +1,164 @@ +-- Actual native command -> copied picture cells -> emitted OBJ draw calls. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local F=dofile('tests/gen2_picture_art/fixture.lua') +local V=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Assets=require('src.render.Assets') +local P=require('src.render.Gen2PaletteDraw') +local Rows=require('src.battle.Gen2BattlerRows') +local oldVersion=V.get() +local g=love.graphics +local oldData,oldImage,oldNew,oldDraw,oldShader=Assets.imageData,love.image,g.newImage,g.draw,g.newShader +local sources,draws,decodes,uploads={},{},0,0 +love.image={newImageData=F.image} +local function picture(w,h,seed) + local image=F.image(w,h) + image:mapPixel(function(x,y) + local shade=(math.floor(x/8)*3+math.floor(y/8)*2+x+2*y+seed)%4 + return (3-shade)/3,(3-shade)/3,(3-shade)/3,1 + end) + return image +end +Assets.imageData=function(path) + decodes=decodes+1 + if not sources[path]then sources[path]=picture(path:find('/back/',1,true)and 48 or 40, + path:find('/back/',1,true)and 48 or 40,0)end + return sources[path]:clone() +end +g.newImage=function(data) + if type(data)=='table'and data.clone then + uploads=uploads+1 + local image=data:clone() + function image:release()self.released=true end + return image + end + return oldNew(data) +end +g.newShader=function()return {values={},send=function(self,k,v)self.values[k]=v end}end +g.draw=function(image,quad,x,y,angle,sx,sy) + local shader=g.getShader();local colors={} + if shader and shader.values then for k,v in pairs(shader.values)do colors[k]=v end end + draws[#draws+1]={image=image,quad=quad,x=x,y=y,sx=sx,sy=sy,colors=colors} +end +local function make(edition,rows,attacker) + V.set(edition);Assets.invalidate();Battle.invalidate();sources={} + local data=T.fixtures.fresh();data.constants.gen=2 + data.constants.battleAnimGfxOrder={'BATTLE_ANIM_GFX_PLAYERHEAD','BATTLE_ANIM_GFX_ENEMYFEET'} + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={[1]={entry=1,code={ + {op=rows==2 and 'battlergfx_1row'or'battlergfx_2row'}, + {op='obj',args={0,64,80,0}},{op='obj',args={1,88,88,0}}, + {op='delay',frames=100},{op='ret'}}}}, + functions={[0]='MoveFromUserToTarget'},objects={},framesets={},oam={}} + for id=0,1 do + data.battle_anims.objects[id]={oamFlags=1,fixY=255,fn=0,frameset=id,palette=id,gfx=id} + data.battle_anims.framesets[id]={frames={{oam=id,duration=1}},loop='restart'} + local sprites={} + for tile=0,(id==0 and 7 or 6)*rows-1 do + sprites[#sprites+1]={x=0,y=0,tile=tile,attr=(tile%4)*32} + end + data.battle_anims.oam[id]={tile=0,sprites=sprites} + end + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s:animForMove('PROBE',attacker) + local p=s.animPlayer + T.eq(p.screen,s,'existing native player owns the real picture source') + p:update() + return s,p +end +for _,edition in ipairs{'gold','silver','crystal'}do + for _,rows in ipairs{1,2}do for _,attacker in ipairs{'player','enemy'}do + local s,p=make(edition,rows,attacker) + local runner=p.runtime + T.eq(#runner.loaded,2) + for _,entry in ipairs(runner.loaded)do + local snapshot=p.view.battlerRows[entry] + T.check(snapshot~=nil,'real battlergfx command uploads its native battler rows') + local back=entry.battler=='player' + local n=back and 48 or 56 + T.same({snapshot.image:getDimensions()},{n,rows*8}) + local raw=sources[s:gen2ObjectPictureSource(entry.battler).path] + for y=0,rows*8-1 do for x=0,n-1 do + local px,py=x,back and y or 56-rows*8+y + if not back then px=px-8;py=py-(56-raw.h)end + local expected={0,0,0,0} + if px>=0 and py>=0 and px.83 then expected[4]=0 end + end + T.same({snapshot.image:getPixel(x,y)},expected,'OBJ pixels follow native BG-to-OBJ copy and colour-zero key') + end end + end + local before,frame,random=decodes,p.frames,s.rng + local images=uploads + draws={};local outside={};g.setShader(outside);P.setBgp(0) + p:draw();local once=draws + T.eq(#once,13*rows,'both real picture strips reach the native OAM renderer') + T.eq(g.getShader(),outside,'caller shader restored') + local oam=runner:oam() + for index,draw in ipairs(once)do + local obj=oam[index] + local entry=runner.loaded[index<=7*rows and 1 or 2] + local localTile=obj.tile-entry.tile + T.same({draw.quad:getViewport()},{math.floor(localTile/rows)*8,(localTile%rows)*8,8,8}, + 'copied strip is addressed column-major, not as a regular OBJ atlas') + local sx=math.floor(obj.attr/32)%2==1 and -1 or 1 + local sy=math.floor(obj.attr/64)%2==1 and -1 or 1 + T.same({draw.x,draw.y,draw.sx,draw.sy},{obj.x-8+(sx<0 and 8 or 0),obj.y-16+(sy<0 and 8 or 0),sx,sy}, + 'actual native OAM retains both flips and attacker mirroring') + local colors=p.view:objPalette(obj.palette,p.battle) + local expected=P.resolve(colors) + for i=1,4 do T.same(draw.colors['pal'..(i-1)],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255}, + 'OBJ colours retain the real battler palette and ignore BGP')end + end + -- Mutating the picture provider cannot repaint already copied OBJ VRAM. + local source=s:gen2ObjectPictureSource('enemy') + sources[source.path]=picture(40,40,1) + for _=1,4 do p:draw()end + T.eq(decodes,before,'drawing does no image decode or GPU readback') + T.eq(uploads,images,'drawing does not upload images again') + T.eq(p.frames,frame,'drawing never runs the VM') + T.eq(s.rng,random) + local first=runner.loaded[1];local snapshot=p.view.battlerRows[first] + p:start('PROBE',attacker=='player');p:update() + T.check(p.runtime~=runner,'restart still creates the existing distinct runner') + T.eq(p.view.battlerRows[first],snapshot,'retained old runner keeps its own copied art') + local retained=runner:oam();retained.runner=runner;draws={};p:drawSprites(retained) + T.eq(#draws,13*rows,'retained real OAM draws without rebinding to the current runner') + T.eq(draws[1].image,snapshot.image) + local draw=g.draw;g.draw=function()g.setShader({});error('row draw failure')end + T.raises(function()p:drawSprites(retained)end,'row draw failure') + T.eq(g.getShader(),outside,'failed drawing also restores caller graphics state') + g.draw=draw + p:release();T.eq(snapshot.image.released,true,'view retirement releases retained snapshot images') + P.setBgp(nil);g.setShader() + end end +end +-- Smaller 6x6 fronts still start one TILE from the left, as PadFrontpic does. +for _,n in ipairs{40,48,56}do + sources['sized-'..n]=picture(n,n,0);Assets.invalidate() + local row=Rows.snapshot({path='sized-'..n},{battler='enemy',rows=1}) + local left=n<56 and 8 or 0 + for x=0,55 do + local expected={0,0,0,0} + if x>=left and x.83 then expected[4]=0 end + end + T.same({row.image:getPixel(x,7)},expected,'declared native 5/6/7-tile front padding') + end +end +-- Custom RGB art opts out of indexed colour-zero removal; crop keeps the +-- selected Crystal frame instead of sampling an entire animation strip. +Assets.invalidate();sources.rgb=F.image(96,48) +sources.rgb:mapPixel(function(x)return x<48 and 0 or 1,.4,.2,.75 end) +local rgb=Rows.snapshot({path='rgb',trueColor=true,crop={index=2,width=48}},{battler='player',rows=2}) +T.same({rgb.image:getPixel(0,0)},{1,.4,.2,.75});T.eq(rgb.trueColor,true) +T.eq(Rows.quad(rgb,12),nil,'out-of-range OAM cells are not invented') +Assets.imageData,love.image,g.newImage,g.draw,g.newShader=oldData,oldImage,oldNew,oldDraw,oldShader +Assets.invalidate();V.set(oldVersion) +T.finish('native GSC battler-row copy pixels, actual OAM, palettes, snapshots and lifetime') From 36e4d7ca37b49c4d927a46d976d27b0f9d4901df Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:56:09 +0100 Subject: [PATCH 50/64] Use explicit native front and back image geometry in row fixtures --- tests/engine/gen2_battler_rows.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/engine/gen2_battler_rows.lua b/tests/engine/gen2_battler_rows.lua index 93c3358c0..2aad63ce6 100644 --- a/tests/engine/gen2_battler_rows.lua +++ b/tests/engine/gen2_battler_rows.lua @@ -44,6 +44,10 @@ end local function make(edition,rows,attacker) V.set(edition);Assets.invalidate();Battle.invalidate();sources={} local data=T.fixtures.fresh();data.constants.gen=2 + for name,def in pairs(data.pokemon)do + def.spriteFront='qa/front/'..name..'.png' + def.spriteBack='qa/back/'..name..'.png' + end data.constants.battleAnimGfxOrder={'BATTLE_ANIM_GFX_PLAYERHEAD','BATTLE_ANIM_GFX_ENEMYFEET'} data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={[1]={entry=1,code={ {op=rows==2 and 'battlergfx_1row'or'battlergfx_2row'}, From 81a46477b12fd2ac855250041b467d04ad665eed Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:57:33 +0100 Subject: [PATCH 51/64] Verify queued and replacement art through real native row copies --- docs/native-gen2-battler-rows.md | 6 +++-- src/battle/Gen2BattlerRows.lua | 4 ++- tests/engine/gen2_battler_rows.lua | 41 ++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/native-gen2-battler-rows.md b/docs/native-gen2-battler-rows.md index 0ade5a64d..38ed54048 100644 --- a/docs/native-gen2-battler-rows.md +++ b/docs/native-gen2-battler-rows.md @@ -15,8 +15,10 @@ from the bottom of the seven-tile front slot; player head comes from the top of the six-tile back slot. Five/six-tile native fronts have the cartridge's one-tile left padding. The VM retains its actual command timing, descriptors, OAM positions, flips and palettes. No second interpreter or battle state is -introduced. Source image data is shared/read-only; copying performs no GPU -readback. Repeated drawing does not decode or upload images. +introduced. Source image data is shared/read-only where the native shared +decoder is available; older upstream uses its existing owned decoder once +per copy. Copying performs no GPU readback. Repeated drawing does not decode +or upload images. OBJ colour zero is removed before palette application, independently of BG register changes. Custom RGB art keeps its alpha and opts out of indexed diff --git a/src/battle/Gen2BattlerRows.lua b/src/battle/Gen2BattlerRows.lua index 9128b7692..da2ccc89e 100644 --- a/src/battle/Gen2BattlerRows.lua +++ b/src/battle/Gen2BattlerRows.lua @@ -5,7 +5,8 @@ local Assets=require('src.render.Assets') local M={} function M.snapshot(source,entry) if not(source and source.path and love.image and love.image.newImageData)then return end - local raw=Assets.imageDataShared(source.path) + local shared=Assets.imageDataShared + local raw=(shared or Assets.imageData)(source.path) local w,h=raw:getDimensions() local x0=0 if source.crop then w=source.crop.width;x0=(source.crop.index-1)*w end @@ -36,6 +37,7 @@ function M.snapshot(source,entry) end end local image=love.graphics.newImage(out) if out.release then out:release()end + if not shared and raw.release then raw:release()end image:setFilter('nearest','nearest') return {image=image,rows=rows,cols=cols,trueColor=source.trueColor,quads={}} end diff --git a/tests/engine/gen2_battler_rows.lua b/tests/engine/gen2_battler_rows.lua index 2aad63ce6..1c4c79f8c 100644 --- a/tests/engine/gen2_battler_rows.lua +++ b/tests/engine/gen2_battler_rows.lua @@ -163,6 +163,47 @@ sources.rgb:mapPixel(function(x)return x<48 and 0 or 1,.4,.2,.75 end) local rgb=Rows.snapshot({path='rgb',trueColor=true,crop={index=2,width=48}},{battler='player',rows=2}) T.same({rgb.image:getPixel(0,0)},{1,.4,.2,.75});T.eq(rgb.trueColor,true) T.eq(Rows.quad(rgb,12),nil,'out-of-range OAM cells are not invented') +do + local s,p=make('crystal',2,'player') + local original=s:gen2ObjectPictureSource('player').path + local row={anim='PROBE'};s.moveAnimRow=row;s.queue={row} + require('src.battle.Gen2PicturePresentation').transform(s,s.player,s.enemy) + T.eq(s:gen2ObjectPictureSource('player').path,original, + 'queued Transform reads the pinned old picture, not its future resolved sprite') + p:start('PROBE',true);p:update() + local entry=p.runtime.loaded[2] + T.check(p.view.battlerRows[entry]~=nil,'pending picture snapshot still uses actual command') + local originalProvider=s.gen2ObjectPictureSource + sources['rgb-live']=F.image(96,48) + sources['rgb-live']:mapPixel(function(x)return x<48 and 0 or 1,.4,.2,.75 end) + function s:gen2ObjectPictureSource(side) + if side=='player'then return {path='rgb-live',trueColor=true,crop={index=2,width=48}}end + return originalProvider(self,side) + end + p:start('PROBE',true);p:update();draws={};p:draw() + T.eq(#draws,26) + for i=15,26 do + T.same(draws[i].colors,{},'actual custom RGB OAM clears preceding indexed shader') + T.same({draws[i].image:getPixel(0,0)},{1,.4,.2,.75},'actual OBJ copy retains selected RGB frame') + end + s.gen2ObjectPictureSource=originalProvider + -- A real raisesub command precedes the copy, and therefore the doll is + -- the current BG source. A later dropsub cannot alter these copied cells. + local code=p.anims.scripts[1].code + table.insert(code,1,{op='raisesub'}) + p.anims.pictureArt={version=1,substitute={player='qa-doll-back',enemy='qa-doll-front'}} + sources['qa-doll-back']=picture(48,48,2);sources['qa-doll-front']=picture(56,56,1) + p:start('PROBE',true);p:update() + T.eq(s:gen2ObjectPictureSource('player').path,'qa-doll-back','source follows real VM replacement state') + local copied=p.view.battlerRows[p.runtime.loaded[2]].image + local expected={sources['qa-doll-back']:getPixel(0,0)} + if expected[1]>.83 then expected[4]=0 end + T.same({copied:getPixel(0,0)},expected,'real native copy uses replacement pixels') + p.runtime.picOverride.player=false + T.eq(s:gen2ObjectPictureSource('player').path,original,'drop reveals native pinned Pokemon source') + T.same({copied:getPixel(0,0)},expected,'later visible picture change cannot mutate earlier OBJ copy') + p:release() +end Assets.imageData,love.image,g.newImage,g.draw,g.newShader=oldData,oldImage,oldNew,oldDraw,oldShader Assets.invalidate();V.set(oldVersion) T.finish('native GSC battler-row copy pixels, actual OAM, palettes, snapshots and lifetime') From b5b4eac77a3702ccd3e6446f1838f7163efc96a2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:58:24 +0100 Subject: [PATCH 52/64] Exercise replacement copies through native screen admission --- tests/engine/gen2_battler_rows.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/engine/gen2_battler_rows.lua b/tests/engine/gen2_battler_rows.lua index 1c4c79f8c..acd866edf 100644 --- a/tests/engine/gen2_battler_rows.lua +++ b/tests/engine/gen2_battler_rows.lua @@ -170,7 +170,7 @@ do require('src.battle.Gen2PicturePresentation').transform(s,s.player,s.enemy) T.eq(s:gen2ObjectPictureSource('player').path,original, 'queued Transform reads the pinned old picture, not its future resolved sprite') - p:start('PROBE',true);p:update() + s:animForMove('PROBE','player');p:update() local entry=p.runtime.loaded[2] T.check(p.view.battlerRows[entry]~=nil,'pending picture snapshot still uses actual command') local originalProvider=s.gen2ObjectPictureSource @@ -180,7 +180,7 @@ do if side=='player'then return {path='rgb-live',trueColor=true,crop={index=2,width=48}}end return originalProvider(self,side) end - p:start('PROBE',true);p:update();draws={};p:draw() + s:animForMove('PROBE','player');p:update();draws={};p:draw() T.eq(#draws,26) for i=15,26 do T.same(draws[i].colors,{},'actual custom RGB OAM clears preceding indexed shader') @@ -193,7 +193,7 @@ do table.insert(code,1,{op='raisesub'}) p.anims.pictureArt={version=1,substitute={player='qa-doll-back',enemy='qa-doll-front'}} sources['qa-doll-back']=picture(48,48,2);sources['qa-doll-front']=picture(56,56,1) - p:start('PROBE',true);p:update() + s:animForMove('PROBE','player');p:update() T.eq(s:gen2ObjectPictureSource('player').path,'qa-doll-back','source follows real VM replacement state') local copied=p.view.battlerRows[p.runtime.loaded[2]].image local expected={sources['qa-doll-back']:getPixel(0,0)} From b488a84ae350ded136330e8ddf0fe41992839598 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:02:25 +0100 Subject: [PATCH 53/64] Check outer palette uniforms around the actual native OAM renderer --- tests/engine/gen2_battler_rows.lua | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/engine/gen2_battler_rows.lua b/tests/engine/gen2_battler_rows.lua index acd866edf..8b445c18e 100644 --- a/tests/engine/gen2_battler_rows.lua +++ b/tests/engine/gen2_battler_rows.lua @@ -99,10 +99,16 @@ for _,edition in ipairs{'gold','silver','crystal'}do end local before,frame,random=decodes,p.frames,s.rng local images=uploads - draws={};local outside={};g.setShader(outside);P.setBgp(0) + draws={} + local outerColors={{251,239,227},{193,151,109},{83,61,47},{17,11,5}} + P.useRaw(outerColors);local outside=g.getShader();P.setBgp(0) p:draw();local once=draws T.eq(#once,13*rows,'both real picture strips reach the native OAM renderer') T.eq(g.getShader(),outside,'caller shader restored') + for i=1,4 do + T.same(outside.values['pal'..(i-1)],{outerColors[i][1]/255,outerColors[i][2]/255,outerColors[i][3]/255}, + 'caller palette uniforms survive reuse of the same indexed shader') + end local oam=runner:oam() for index,draw in ipairs(once)do local obj=oam[index] @@ -137,6 +143,10 @@ for _,edition in ipairs{'gold','silver','crystal'}do local draw=g.draw;g.draw=function()g.setShader({});error('row draw failure')end T.raises(function()p:drawSprites(retained)end,'row draw failure') T.eq(g.getShader(),outside,'failed drawing also restores caller graphics state') + for i=1,4 do + T.same(outside.values['pal'..(i-1)],{outerColors[i][1]/255,outerColors[i][2]/255,outerColors[i][3]/255}, + 'failed drawing restores caller palette uniforms as well as shader identity') + end g.draw=draw p:release();T.eq(snapshot.image.released,true,'view retirement releases retained snapshot images') P.setBgp(nil);g.setShader() From 74be6ecd25ed77eefda9fb94fabd0d3e74a7ed24 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:03:31 +0100 Subject: [PATCH 54/64] Restore outer indexed palette uniforms after native OAM drawing --- docs/native-gen2-battler-rows.md | 5 +++++ src/battle/Gen2AnimView.lua | 2 +- src/render/Gen2PaletteDraw.lua | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/native-gen2-battler-rows.md b/docs/native-gen2-battler-rows.md index 38ed54048..2a636ace5 100644 --- a/docs/native-gen2-battler-rows.md +++ b/docs/native-gen2-battler-rows.md @@ -26,6 +26,11 @@ recolouring. Crystal crop descriptors select a single frame. Replacement art and queued Transform use the existing picture presentation authority; reading the source never reveals a queued future picture. +The OAM pass also restores the caller's indexed palette uniforms, including +when drawing throws. Graphics push/pop alone restores the shader reference +but cannot undo uploads to the same shader object. The stronger actual-draw +fixture reproduces 96 uniform mismatches before this palette-scope fix. + Copied images belong to the native view, keyed weakly by the actual loaded descriptor. Retaining an old runner retains its copied picture; a restarted animation cannot substitute its current image into old OAM. View retirement diff --git a/src/battle/Gen2AnimView.lua b/src/battle/Gen2AnimView.lua index 25dfd3dde..7e235a8dd 100644 --- a/src/battle/Gen2AnimView.lua +++ b/src/battle/Gen2AnimView.lua @@ -70,7 +70,7 @@ function View:drawObjects(runner, battle) local g = love and love.graphics if not g then return end g.push('all') - local ok, err = pcall(function() + local ok, err = pcall(Palette.preserving,function() g.setColor(1,1,1,1) local active,activeByte,activeRaw for _, obj in ipairs(runner:oam()) do diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua index 481165c12..f2727e194 100644 --- a/src/render/Gen2PaletteDraw.lua +++ b/src/render/Gen2PaletteDraw.lua @@ -277,4 +277,8 @@ end function M.with(colors, body) return scoped(colors, body, M.use) end function M.withRaw(colors, body) return scoped(colors, body, M.useRaw) end function M.keyedWith(colors, body) return scoped(colors, body, M.useKeyed) end +-- Graphics push/pop retains a shader object, not its mutable uniforms. An +-- OAM pass selects several palettes itself, so preserve the outer indexed +-- palette without imposing an extra palette on the first object. +function M.preserving(body) return scoped(nil, body, function()return false end) end return M From 9ef86ed9dc661f5bf754a5b2122759fdb00e67bd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:11:45 +0100 Subject: [PATCH 55/64] Add native GSC battler-row and outer-palette GPU pixel cases --- tests/gpu/gen2_battler_rows.lua | 183 ++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/gpu/gen2_battler_rows.lua diff --git a/tests/gpu/gen2_battler_rows.lua b/tests/gpu/gen2_battler_rows.lua new file mode 100644 index 000000000..978d64407 --- /dev/null +++ b/tests/gpu/gen2_battler_rows.lua @@ -0,0 +1,183 @@ +-- Generated ROM-free art, real native BattleState -> VM -> OBJ GPU pixels. +local M={} +function M.run() + local Version=require('src.core.GameVersion') + local Palette=require('src.render.PaletteFX') + local Registers=require('src.render.Gen2PaletteDraw') + local Battle=require('src.battle.BattleState') + local Assets=require('src.render.Assets') + local Data=require('src.core.Data') + local g=love.graphics + local previous={version=Version.get(),mode=Palette.mode,ramp=Palette.customRamp()} + local checks,cases=0,0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local bg={32/255,64/255,128/255,1} + local A={{255,255,255},{211,41,67},{29,173,101},{3,7,13}} + local B={{255,255,255},{47,91,223},{193,131,53},{11,17,31}} + local outer={{249,237,225},{191,149,107},{81,59,43},{19,13,7}} + local custom={{247,233,219},{181,151,121},{103,73,43},{23,13,7}} + local canvas=g.newCanvas(160,144,{format='rgba8',dpiscale=1});M.image=canvas + local paths={} + local function sample(kind,back,n,x,y) + if kind=='rgb'then return {x%2==0 and 1 or .2,.4,y%2==0 and .7 or .1,.75}end + if kind=='substitute'then + local top=back and 32 or 40 + if x<16 or x>=32 or y=top+16 then return {1,1,1,1}end + elseif kind=='minimize'then + local left,top=back and 24 or 24,back and 32 or 40 + if x=left+8 or y=top+8 then return {1,1,1,1}end + end + local shade=(math.floor(x/8)*3+math.floor(y/8)*2+x+2*y+(back and 1 or 0))%4 + local value=(3-shade)/3 + return {value,value,value,1},shade + end + for _,kind in ipairs{'normal','rgb','substitute','minimize'}do + paths[kind]={} + for _,back in ipairs{false,true}do + for _,frontSize in ipairs{40,48,56}do + local n=back and 48 or kind=='normal'and frontSize or 56 + local path='qa-row-'..kind..'-'..(back and 'back'or tostring(frontSize))..'.png' + local pixels=love.image.newImageData(n,n,'rgba8') + for y=0,n-1 do for x=0,n-1 do pixels:setPixel(x,y,unpack(sample(kind,back,n,x,y)))end end + pixels:encode('png',path);pixels:release() + paths[kind][back and 'back'or frontSize]=path + end + end + end + local function make(edition,rows,attacker,kind,frontSize) + Version.set(edition);Assets.invalidate();Battle.invalidate() + local data=require('tests.modkit.fixtures').fresh();data.constants.gen=2 + for name,def in pairs(data.pokemon)do + def.palette=name=='FIXMON_A'and 'ROW_BACK'or 'ROW_FRONT' + def.spriteFront=paths[kind][frontSize];def.spriteBack=paths[kind].back + def.trueColor=kind=='rgb'or nil + end + data.palettes={palettes={ROW_BACK=B,ROW_FRONT=A,GRAYMON=A,MEWMON=A}, + pokemon={FIXMON_A='ROW_BACK',FIXMON_B='ROW_FRONT',FIXMON_C='ROW_FRONT'}} + Data.publishGen2Palettes(data) + data.constants.battleAnimGfxOrder={'BATTLE_ANIM_GFX_PLAYERHEAD','BATTLE_ANIM_GFX_ENEMYFEET'} + local code={} + if kind=='substitute'or kind=='minimize'then + code[#code+1]={op=kind=='substitute'and 'raisesub'or 'minimizeopp'} + end + code[#code+1]={op=rows==2 and 'battlergfx_1row'or 'battlergfx_2row'} + code[#code+1]={op='obj',args={0,76,40,0}} + code[#code+1]={op='obj',args={1,40,100,0}} + code[#code+1]={op='delay',frames=100};code[#code+1]={op='ret'} + data.battle_anims={gen2=true,moveAnims={PROBE=1},scripts={[1]={entry=1,code=code}}, + functions={[0]='MoveFromUserToTarget'},objects={},framesets={},oam={}} + if kind=='substitute'or kind=='minimize'then + data.battle_anims.pictureArt={version=1,[kind]={ + enemy=paths[kind][frontSize],player=paths[kind].back}} + -- The opposite battler remains a regular Pokemon. Give it generated + -- native art too; the real command decides which picture is replaced. + for _,def in pairs(data.pokemon)do + def.spriteFront=paths.normal[frontSize];def.spriteBack=paths.normal.back + end + end + for id=0,1 do + data.battle_anims.objects[id]={oamFlags=1,fixY=255,fn=0,frameset=id,palette=id,gfx=id} + data.battle_anims.framesets[id]={frames={{oam=id,duration=1}},loop='restart'} + local sprites={} + for tile=0,(id==0 and 7 or 6)*rows-1 do + sprites[#sprites+1]={x=math.floor(tile/rows)*8,y=(tile%rows)*8, + tile=tile,attr=(tile%4)*32} + end + data.battle_anims.oam[id]={tile=0,sprites=sprites} + end + require('src.render.Font').load(data) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} + local screen=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + screen.queue,screen.current,screen.waitFrames={},nil,nil + check(screen:animForMove('PROBE',attacker),'native screen did not admit row animation') + screen.animPlayer:update() + return screen + end + g.push('all') + local ok,why=xpcall(function() + for ei,edition in ipairs{'gold','silver','crystal'}do + local frontSize=32+ei*8 + for _,kind in ipairs{'normal','rgb','substitute','minimize'}do + if kind=='normal'or kind=='rgb'or edition=='crystal'then + for _,mode in ipairs(kind=='rgb'and {'gbc'}or {'gbc','dmg','custom'})do + Palette.setDisplay(mode,mode=='custom'and custom or nil) + for _,rows in ipairs{1,2}do for _,attacker in ipairs{'player','enemy'}do + local screen=make(edition,rows,attacker,kind,frontSize) + local player=screen.animPlayer;local runner=player.runtime + local expected={} + for _,obj in ipairs(runner:oam())do + local entry + for i=#runner.loaded,1,-1 do + local item=runner.loaded[i] + if obj.tile>=item.tile and obj.tile=0 and yy>=0 and xx=0 and dx<160 and dy>=0 and dy<144 and raw then + local alpha=currentKind=='rgb'and raw[4]or (shade and shade>0 and 1 or 0) + if alpha>0 then + local rgb=currentKind=='rgb'and raw or palette[shade+1] + local value={} + for channel=1,3 do + local component=currentKind=='rgb'and rgb[channel]or rgb[channel]/255 + value[channel]=component*alpha+bg[channel]*(1-alpha) + end + value[4]=1;expected[dy*160+dx]=value + end + end + end end + end + g.setCanvas(canvas);g.origin();g.setScissor();g.setBlendMode('alpha','alphamultiply') + g.setShader();g.clear(unpack(bg));g.setColor(.2,.3,.4,.5) + Registers.useRaw(outer);local external=g.getShader() + Registers.setBgp(0) -- a BG fade cannot fade copied OBJ pixels + local clock=player.frames + player:draw() + check(player.frames==clock,'GPU drawing advanced the VM') + check(g.getShader()==external,'GPU OAM pass lost outer shader identity') + check(math.abs(select(4,g.getColor())-.5)<1/255,'GPU OAM pass lost caller tint') + -- Draw a four-pixel outer-palette probe after the actual OAM + -- pass. This proves uniforms, not merely shader identity. + local probe=love.image.newImageData(4,1,'rgba8') + for x=0,3 do local gray=1-x/3;probe:setPixel(x,0,gray,gray,gray,1)end + local image=g.newImage(probe);image:setFilter('nearest','nearest');probe:release() + g.setColor(1,1,1,1);g.draw(image,0,140);image:release() + for x=0,3 do expected[140*160+x]={outer[x+1][1]/255,outer[x+1][2]/255,outer[x+1][3]/255,1}end + g.setCanvas();g.setShader();Registers.setBgp(nil) + local pixels=g.readbackTexture(canvas) + for y=0,143 do for x=0,159 do + local got={pixels:getPixel(x,y)};local wanted=expected[y*160+x]or bg + for channel=1,4 do check(math.abs(got[channel]-wanted[channel])<2/255, + edition..' '..kind..' '..mode..' rows'..rows..' '..attacker..' pixel '..x..','..y..' channel'..channel)end + end end + pixels:release();player:release();cases=cases+1 + end end + end + end + end + end + end,debug.traceback) + g.setCanvas();g.pop();Registers.setBgp(nil) + Version.set(previous.version);Palette.setDisplay(previous.mode,previous.ramp) + if not ok then error(why,0)end + return {passed=true,checks=checks,cases=cases, + scope='ROM-free real native BattleState/VM/OBJ GPU pixel equality: GSC, native 5/6/7-tile fronts, both attackers, one/two copied rows, flips, GBC/DMG/custom, RGB alpha, substitute/minimize, outer palette probe and stable draw clock. Generated test art; not unchanged-mod, OS-window, performance or release acceptance.'},canvas +end +return M From 0626de1cde7b03991d4811f0ff907ca131bbebc3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:14:40 +0100 Subject: [PATCH 56/64] Pass only RGBA components when generating GPU fixture pixels --- tests/gpu/gen2_battler_rows.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/gpu/gen2_battler_rows.lua b/tests/gpu/gen2_battler_rows.lua index 978d64407..601e7d9ca 100644 --- a/tests/gpu/gen2_battler_rows.lua +++ b/tests/gpu/gen2_battler_rows.lua @@ -38,7 +38,10 @@ function M.run() local n=back and 48 or kind=='normal'and frontSize or 56 local path='qa-row-'..kind..'-'..(back and 'back'or tostring(frontSize))..'.png' local pixels=love.image.newImageData(n,n,'rgba8') - for y=0,n-1 do for x=0,n-1 do pixels:setPixel(x,y,unpack(sample(kind,back,n,x,y)))end end + for y=0,n-1 do for x=0,n-1 do + local rgba=sample(kind,back,n,x,y) + pixels:setPixel(x,y,unpack(rgba)) + end end pixels:encode('png',path);pixels:release() paths[kind][back and 'back'or frontSize]=path end From 95b104a149d8fab836795ea57e5b90f8d411b614 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:30:34 +0100 Subject: [PATCH 57/64] Use the complete native GSC ROM animation pool in the actual VM --- .../workflows/gen2-picture-presentation.yml | 1 + docs/native-gen2-animation-catalog.md | 39 +++++++++ src/battle/Gen2AnimCatalog.lua | 81 ++++++++++++++++++ src/battle/Gen2AnimData.lua | 36 +++++--- src/battle/Gen2AnimPlayer.lua | 14 +++- src/import/RomExtractorGen2.lua | 9 +- tests/engine/gen2_animation_catalog.lua | 84 +++++++++++++++++++ .../engine/gen2_animation_import_commands.lua | 7 +- tests/gpu/gen2_battler_rows.lua | 1 + 9 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 docs/native-gen2-animation-catalog.md create mode 100644 src/battle/Gen2AnimCatalog.lua create mode 100644 tests/engine/gen2_animation_catalog.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index 9a68670e4..97b8f6bf7 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -22,6 +22,7 @@ jobs: luajit tests/engine/gen2_picture_art_import.lua luajit tests/engine/gen2_picture_art_draw.lua luajit tests/engine/gen2_battler_rows.lua + luajit tests/engine/gen2_animation_catalog.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/docs/native-gen2-animation-catalog.md b/docs/native-gen2-animation-catalog.md new file mode 100644 index 000000000..9030766df --- /dev/null +++ b/docs/native-gen2-animation-catalog.md @@ -0,0 +1,39 @@ +# Complete native GSC animation data + +Contributions by Roxas2712. Existing interpreter and identifier provenance +and applicable licences remain in `compat/gen1-gsc/` and `NOTICE.md`. + +The importer previously stored a private flat program per animation index. +The player converted only its selected program into VM rows. Consequently +`AnimRunner.scriptForMove(data.battle_anims, move)` returned nothing and a +runner's data omitted every unselected move and shared call-only program. + +The ROM decoder now retains actual instruction addresses. The importer +publishes the complete native interpreter schema: `moves`, named `ids`, +`scripts`, `scriptOrder`, objects, framesets, OAM sets, GFX and palettes. +Programs use their genuine hexadecimal ROM address, including shared +subroutines and the dummy table entry. Each program appears once; different +bytes claiming the same address are rejected. Branches contain one resolved +target key, without leftover pointer bytes. Registered non-ROM flat test +programs use an explicit `program::` namespace rather than +pretending to have cartridge addresses. + +Native `Gen2AnimPlayer` and its actual runtime/view use that complete imported +table directly. Changing moves changes the entry point and VM state, not the +data owner. Standalone source calls and source edits observe the same pool. +The previous flat input remains accepted for existing program registrations; +there is still one real interpreter, object pool and battle clock. + +Gen2 cache revision 3 requests reimport of earlier generated animation data. +Saves and unrelated generation cache markers retain their existing contracts. +The import edition supplies the BG-function order independently of whatever +cartridge happens to be selected by a test or loader. + +`tests/engine/gen2_animation_catalog.lua` reads generated ROM bytes through +the actual extractor and player, checks shared and call-only programs, +unselected moves, numeric/dummy entries, source edits and native wait/return +time. The GPU row fixture now also enters through the complete native data +schema. Actual ROM differential and GPU acceptance results belong to their +recorded source revisions; adding this path alone is not full unchanged-mod, +Windows or packaged-release acceptance. No mod or ROM program is edited or +committed. diff --git a/src/battle/Gen2AnimCatalog.lua b/src/battle/Gen2AnimCatalog.lua new file mode 100644 index 000000000..84c9298e0 --- /dev/null +++ b/src/battle/Gen2AnimCatalog.lua @@ -0,0 +1,81 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native GSC ROM programs in the actual interpreter's complete data schema. +-- Command/identifier provenance: compat/gen1-gsc; no ROM scripts authored here. +local Data=require('src.battle.Gen2AnimData') +local M={} +local function same(a,b) + if type(a)~=type(b)then return false end + if type(a)~='table'then return a==b end + for k,v in pairs(a)do if not same(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +function M.compile(imported,constants,version) + if Data.isNative(imported)then return imported end + local catalog=Data.tables(imported,constants,version) + catalog.gen2,catalog.generation,catalog.format=true,2,'gsc-native-v1' + catalog.commandFormat=imported.commandFormat + catalog.source='ROM:BattleAnimations + data/battle_anims/*' + catalog.bank,catalog.gameVersion=imported.bank,version + catalog.scripts,catalog.scriptOrder,catalog.moves,catalog.ids,catalog.indices={},{},{},{},{} + catalog.misc,catalog.moveAnims,catalog.move,catalog.effects={},{},{},{} + local ids={};for id in pairs(imported.scripts or {})do ids[#ids+1]=id end + table.sort(ids,function(a,b)if type(a)==type(b)then return a complete native pool -> actual shared VM. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local E=require('src.import.RomExtractorGen2') +local Data=require('src.battle.Gen2AnimData') +local Catalog=require('src.battle.Gen2AnimCatalog') +local Runtime=require('src.battle.gen2.AnimRunner') +local Player=require('src.battle.Gen2AnimPlayer') +local V=require('src.core.GameVersion') +local previous=V.get() +for _,edition in ipairs{'gold','silver','crystal'}do + V.set(edition) + local constants={moveOrder={'PROBE','OTHER','ALIAS'}} + local e=E.new(nil,edition,{symbols={BattleAnimations={1,0x4000}}}) + e.constants=function()return constants end + local pointers={[0x4000]=0x4008,[0x4002]=0x5000,[0x4004]=0x5010,[0x4006]=0x5000} + local bytes={ + [0x4008]=0xff, + [0x5000]=0xfe,[0x5001]=0x00,[0x5002]=0x51,[0x5003]=3,[0x5004]=0xff, + [0x5010]=1,[0x5011]=0xff, + [0x5100]=2,[0x5101]=0xff, + } + e.rom={word=function(_,bank,pc)T.eq(bank,1);return assert(pointers[pc])end, + byte=function(_,bank,pc)T.eq(bank,1);return assert(bytes[pc])end} + local c=e:gen2BattleAnims() + T.eq(Data.isNative(c),true,'actual ROM importer publishes native interpreter schema') + T.eq(c.bank,1);T.eq(c.gameVersion,edition) + T.eq(c.moves.PROBE,'5000');T.eq(c.moves.OTHER,'5010');T.eq(c.moves.ALIAS,'5000') + T.eq(c.indices[0],'4008','cartridge dummy entry is part of the complete pool') + T.same(c.scriptOrder,{'4008','5000','5100','5010'},'pool includes call-only blocks and unselected moves once') + T.same(c.scripts['5000'],{{'call','5100'},{'wait',3},{'ret'}}) + T.same(c.scripts['5100'],{{'wait',2},{'ret'}}) + T.same(c.scripts['5010'],{{'wait',1},{'ret'}}) + T.eq(c.scripts['5000'],c.scripts[c.moves.ALIAS],'same ROM address has one real command owner') + T.eq(Runtime.scriptForMove(c,'PROBE'),'5000','standalone native API uses the actual imported dataset') + T.eq(Runtime.scriptForMove(c,'ABSENT'),nil,'absent move gets no invented animation') + local view,orders=Data.tables(c,constants) + T.eq(view,c,'native data does not get copied into a selected-program facade') + local bodySlam=false + for _,name in ipairs(orders.battleBgEffectOrder)do if name=='BATTLE_BG_EFFECT_BODY_SLAM'then bodySlam=true end end + T.eq(bodySlam,edition=='crystal','import edition owns numeric BG-function order') + local p=Player.new({battle_anims=c,constants=constants}) + T.eq(p.runtime.data,c,'idle native player already exposes all imported programs') + T.check(p:start('PROBE',true));local first=p.runtime + T.eq(first.data,c);T.eq(p.view.data,c,'the real OAM view reads the same complete native pool') + T.eq(first.data.scripts['5010'],c.scripts['5010'],'unselected program stays available during another move') + local steps=0 + repeat p:update();steps=steps+1;T.check(steps<20)until p:isDone() + T.eq(p.frames,8,'real call, full waits, parent resume and ret preserve cartridge time') + T.eq(p:scriptFor('ALIAS'),p:scriptFor('PROBE')) + T.check(p:start('OTHER',false));T.eq(p.runtime.data,c,'next move does not replace the shared data owner') + repeat p:update()until p:isDone() + T.eq(p.frames,3);T.eq(first.frames,8,'retained runner keeps its original native clock') + -- Native clients can edit an actual canonical program. The next start sees + -- that same owner; it must not silently rebuild it from stale legacy data. + c.scripts['5010'][1]={'wait',4} + T.check(p:start('OTHER',false)) + repeat p:update()until p:isDone() + T.eq(p.frames,6,'actual native source-program edit reaches the genuine VM') + p:release() + local missing=E.new(nil,edition,{symbols={}}):gen2BattleAnims() + T.eq(Data.isNative(missing),false,'missing ROM animation table remains an explicit unsupported result') +end +do + local c=Catalog.compile({gen2=true,scripts={[1]={entry=1,code={ + {op='ifparamequal',args={5,0,80},to=3},{op='ret'},{op='delay',frames=2},{op='ret'}}}}, + moveAnims={PROBE=1},objects={},framesets={},oam={},gfx={}},nil,'crystal') + T.eq(c.moves.PROBE,'program:1:1','non-ROM registration has an explicit program namespace') + T.same(c.scripts['program:1:1'],{{'if_param_equal',5,'program:1:3'},{'ret'}}, + 'canonical branch has one target key, not leftover cartridge pointer bytes') + T.same(c.scripts['program:1:3'],{{'wait',2},{'ret'}}) + local loop=Catalog.compile({scripts={[1]={entry=1,addresses={0x5000},code={{op='jump',to=1}}}}, + moveAnims={PROBE=1}},nil,'gold') + T.same(loop.scripts['5000'],{{'jump','5000'}},'self branch neither recurses forever nor duplicates the pool') + T.same(loop.scriptOrder,{'5000'}) + T.raises(function()Catalog.compile({scripts={ + [1]={entry=1,addresses={0x5000,0x5001},code={{op='delay',frames=1},{op='ret'}}}, + [2]={entry=1,addresses={0x5000,0x5001},code={{op='delay',frames=2},{op='ret'}}}}},nil,'gold')end, + 'conflicting GSC program','different bytes cannot silently share a ROM program owner') + T.same(Data.row({op='call',args={0,80},to=1},'5000'),{'call','5000'}) + T.same(Data.row({op='loop',args={3,0,80},to=1},'5000'),{'loop',3,'5000'}) +end +V.set(previous) +T.finish('complete native GSC ROM animation pool, source API, actual VM and shared ownership') diff --git a/tests/engine/gen2_animation_import_commands.lua b/tests/engine/gen2_animation_import_commands.lua index 633863139..f26c2fd06 100644 --- a/tests/engine/gen2_animation_import_commands.lua +++ b/tests/engine/gen2_animation_import_commands.lua @@ -44,10 +44,11 @@ for _,edition in ipairs({'gold','silver','crystal'})do end for _,edition in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do local marker=markerFor(edition) - T.check(marker:find('gen2%-anim%-object%-v2:')~=nil,'generated animation cache has a new contract') + T.check(marker:find('gen2%-anim%-object%-v3:')~=nil,'generated animation cache has a new contract') Version.set(edition) - for _,old in ipairs({marker:gsub('gen2%-anim%-object%-v2:',''), - (marker:gsub('gen2%-anim%-object%-v2:','gen2-anim-object-v1:'))})do + for _,old in ipairs({marker:gsub('gen2%-anim%-object%-v3:',''), + (marker:gsub('gen2%-anim%-object%-v3:','gen2-anim-object-v1:')), + (marker:gsub('gen2%-anim%-object%-v3:','gen2-anim-object-v2:'))})do love.filesystem.write(Version.cachePrefix(edition)..'rom-cache.complete',old) local report=Importer.readyReport(edition) T.eq(report.ok,false,'old commands or missing-picture cache must be rebuilt') diff --git a/tests/gpu/gen2_battler_rows.lua b/tests/gpu/gen2_battler_rows.lua index 601e7d9ca..1e2be2ef2 100644 --- a/tests/gpu/gen2_battler_rows.lua +++ b/tests/gpu/gen2_battler_rows.lua @@ -88,6 +88,7 @@ function M.run() end data.battle_anims.oam[id]={tile=0,sprites=sprites} end + data.battle_anims=require('src.battle.Gen2AnimCatalog').compile(data.battle_anims,data.constants,edition) require('src.render.Font').load(data) local save=require('src.core.SaveData').newGame() save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} From b676bbc3650cfca7db81697582714260102642db Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:33:12 +0100 Subject: [PATCH 58/64] Admit canonical source programs and preserve their generated-cache ownership --- src/battle/Gen2MovePresentation.lua | 2 +- tests/engine/gen2_animation_catalog.lua | 16 ++++++++++++++++ tests/gpu/gen2_battler_rows.lua | 3 ++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/battle/Gen2MovePresentation.lua b/src/battle/Gen2MovePresentation.lua index bd3ef9087..a09020c17 100644 --- a/src/battle/Gen2MovePresentation.lua +++ b/src/battle/Gen2MovePresentation.lua @@ -38,7 +38,7 @@ function M.install(Screen) function Screen:animForMove(moveId,side,param,effectiveness,afterAnim) local player=self.animPlayer if not (Version.isGen2() and self:animationsOn() and player - and player.anims.moveAnims and player.anims.moveAnims[moveId]) then return false end + and player.anims.gen2 and player:scriptFor(moveId)) then return false end local started=M.start(self,moveId,side~='enemy',{param=param or 0}) if started then local name=self:afterAnimFor(side,afterAnim) diff --git a/tests/engine/gen2_animation_catalog.lua b/tests/engine/gen2_animation_catalog.lua index c25b600fb..e077723c6 100644 --- a/tests/engine/gen2_animation_catalog.lua +++ b/tests/engine/gen2_animation_catalog.lua @@ -58,6 +58,22 @@ for _,edition in ipairs{'gold','silver','crystal'}do repeat p:update()until p:isDone() T.eq(p.frames,6,'actual native source-program edit reaches the genuine VM') p:release() + local source=require('src.import.LuaWriter').encodeSplit(c) + local restored=assert(loadstring(source))() + T.same(restored,c,'actual generated-cache writer preserves the complete native schema') + T.eq(Data.tables(restored,constants),restored,'restored data remains the native owner') + local fixtures=T.fixtures.fresh();fixtures.constants.gen=2;fixtures.battle_anims=restored + require('src.render.Font').load(fixtures) + local save=require('src.core.SaveData').newGame() + save.party={require('src.pokemon.Pokemon').new(fixtures,'FIXMON_A',20)} + local screen=require('src.battle.BattleState').newWild({data=fixtures,save=save, + stack={top=function()end}},'FIXMON_C',15) + restored.moves.ADDED='5010' + T.eq(restored.moveAnims.ADDED,nil,'new native registration does not need a stale legacy index map') + T.check(screen:animForMove('ADDED','player'),'actual native screen admits the canonical source registration') + T.eq(screen.animPlayer.runtime.data,restored,'real screen, public data and VM share the restored owner') + T.eq(screen.anims,restored,'real screen exposes the complete native dataset') + screen.animPlayer:release() local missing=E.new(nil,edition,{symbols={}}):gen2BattleAnims() T.eq(Data.isNative(missing),false,'missing ROM animation table remains an explicit unsupported result') end diff --git a/tests/gpu/gen2_battler_rows.lua b/tests/gpu/gen2_battler_rows.lua index 1e2be2ef2..429e6f705 100644 --- a/tests/gpu/gen2_battler_rows.lua +++ b/tests/gpu/gen2_battler_rows.lua @@ -88,7 +88,8 @@ function M.run() end data.battle_anims.oam[id]={tile=0,sprites=sprites} end - data.battle_anims=require('src.battle.Gen2AnimCatalog').compile(data.battle_anims,data.constants,edition) + local catalog=require('src.battle.Gen2AnimCatalog').compile(data.battle_anims,data.constants,edition) + data.battle_anims=assert(loadstring(require('src.import.LuaWriter').encodeSplit(catalog)))() require('src.render.Font').load(data) local save=require('src.core.SaveData').newGame() save.party={require('src.pokemon.Pokemon').new(data,'FIXMON_A',30)} From 529a4b5a448161953243cfd6ed2ffd78ac1b8a96 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:42:31 +0100 Subject: [PATCH 59/64] Accept the genuine source-native GSC dataset without private fork flags --- docs/native-gen2-animation-catalog.md | 2 ++ src/battle/BattleState.lua | 10 +++++----- src/battle/Gen2AnimData.lua | 6 +++++- src/battle/Gen2MovePresentation.lua | 5 +++-- src/battle/Gen2PicturePresentation.lua | 3 ++- tests/engine/gen2_animation_catalog.lua | 10 ++++++++++ tests/gpu/gen2_battler_rows.lua | 1 + 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/native-gen2-animation-catalog.md b/docs/native-gen2-animation-catalog.md index 9030766df..aa00c41ef 100644 --- a/docs/native-gen2-animation-catalog.md +++ b/docs/native-gen2-animation-catalog.md @@ -21,6 +21,8 @@ pretending to have cartridge addresses. Native `Gen2AnimPlayer` and its actual runtime/view use that complete imported table directly. Changing moves changes the entry point and VM state, not the data owner. Standalone source calls and source edits observe the same pool. +The original source-native `generation=2` schema is accepted directly, +without requiring the fork's private format or legacy index flags. The previous flat input remains accepted for existing program registrations; there is still one real interpreter, object pool and battle clock. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2ea25e83c..702c0a97a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1254,7 +1254,7 @@ local function newBattle(game) -- rather than Gen 1's subanimation/frame-block tables), so they get -- their own player. if game.data.battle_anims then - if game.data.battle_anims.gen2 then + if require('src.battle.Gen2AnimData').isGen2(game.data.battle_anims) then local ok, Gen2AnimPlayer = pcall(require, "src.battle.Gen2AnimPlayer") if ok then self.animPlayer = Gen2AnimPlayer.new(game.data) @@ -2342,7 +2342,7 @@ end -- `isPlayer` follows the poof's convention (Gen2AnimPlayer flips it): false -- for the player's own mon, true for the foe's. function BattleState:shinyAnim(battler, isPlayer) - if not (self.data and self.data.battle_anims and self.data.battle_anims.gen2) then + if not (self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims)) then return false end local mon = battler and battler.mon @@ -2355,7 +2355,7 @@ end -- before the queue starts running, where animNext's insert point does not -- apply. function BattleState:shinyAnimAppend(battler, isPlayer) - if not (self.data and self.data.battle_anims and self.data.battle_anims.gen2) then + if not (self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims)) then return false end local mon = battler and battler.mon @@ -10388,7 +10388,7 @@ end function BattleState:gen3ShinyBurst(battler) if not battler then return false end if not self:gen3ThrowsSendOut() then return false end - if self.data and self.data.battle_anims and self.data.battle_anims.gen2 then + if self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims) then return false -- Johto has its own SHINY_ANIM; this is the Gen 3 dataset end local mon = battler.mon @@ -10532,7 +10532,7 @@ function BattleState:ballChain(tossAnim, caught, shakes, ball) -- (anim_checkpokeball -> GetPokeBallWobble) and finally the click or the -- break-out, all from one script. Replaying Gen 1's POOF/HIDEPIC/SHAKE -- rows on top of it would only double up pieces of what it just did. - if self.data and self.data.battle_anims and self.data.battle_anims.gen2 then + if self.data and require('src.battle.Gen2AnimData').isGen2(self.data.battle_anims) then self:animNext(tossAnim, true, shakes, ball, caught) if caught then self:actNext(function() diff --git a/src/battle/Gen2AnimData.lua b/src/battle/Gen2AnimData.lua index 0fac83125..9ad540a77 100644 --- a/src/battle/Gen2AnimData.lua +++ b/src/battle/Gen2AnimData.lua @@ -5,7 +5,11 @@ local Ids=require('src.battle.Gen2AnimObjectIds') local M={} M.CACHE_REVISION=3 function M.isNative(anims) - return anims and anims.generation==2 and anims.format=='gsc-native-v1' + return anims and anims.generation==2 and type(anims.scripts)=='table' + and type(anims.moves)=='table'and type(anims.oamsets)=='table' +end +function M.isGen2(anims) + return anims and (anims.gen2==true or M.isNative(anims)) end local function order(constants,key) return constants and constants[key] or Ids[key] diff --git a/src/battle/Gen2MovePresentation.lua b/src/battle/Gen2MovePresentation.lua index a09020c17..3a66fcf76 100644 --- a/src/battle/Gen2MovePresentation.lua +++ b/src/battle/Gen2MovePresentation.lua @@ -3,11 +3,12 @@ -- battle decisions, damage, PP, queue consumption and time stay native. local Version=require('src.core.GameVersion') local View=require('src.battle.Gen2AnimView') +local Data=require('src.battle.Gen2AnimData') local M={} function M.start(screen,id,isPlayer,opts) local player=screen.animPlayer - if not (Version.isGen2() and player and player.anims.gen2 + if not (Version.isGen2() and player and Data.isGen2(player.anims) and player:scriptFor(id)) then return false end if player:start(id,isPlayer,opts)==false then return false end screen.animPlaying=true @@ -38,7 +39,7 @@ function M.install(Screen) function Screen:animForMove(moveId,side,param,effectiveness,afterAnim) local player=self.animPlayer if not (Version.isGen2() and self:animationsOn() and player - and player.anims.gen2 and player:scriptFor(moveId)) then return false end + and Data.isGen2(player.anims) and player:scriptFor(moveId)) then return false end local started=M.start(self,moveId,side~='enemy',{param=param or 0}) if started then local name=self:afterAnimFor(side,afterAnim) diff --git a/src/battle/Gen2PicturePresentation.lua b/src/battle/Gen2PicturePresentation.lua index 2f8c4e2d4..8d6a0e2b6 100644 --- a/src/battle/Gen2PicturePresentation.lua +++ b/src/battle/Gen2PicturePresentation.lua @@ -1,6 +1,7 @@ -- Picture identity follows the existing GSC animation VM, not the earlier -- native damage/effect calculation. No combat value or saved mon is delayed. local Version=require('src.core.GameVersion') +local Data=require('src.battle.Gen2AnimData') local M={} local function side(b) return b.isPlayer and 'player' or 'enemy' end @@ -25,7 +26,7 @@ end local function mark(screen,b,kind) local row=screen.moveAnimRow local player=screen.animPlayer - if not (Version.isGen2() and row and screen:animationsOn() and player and player.anims.gen2 + if not (Version.isGen2() and row and screen:animationsOn() and player and Data.isGen2(player.anims) and player:scriptFor(row.anim))then return end local queued=false for _,item in ipairs(screen.queue or {})do if item==row then queued=true;break end end diff --git a/tests/engine/gen2_animation_catalog.lua b/tests/engine/gen2_animation_catalog.lua index e077723c6..187d96342 100644 --- a/tests/engine/gen2_animation_catalog.lua +++ b/tests/engine/gen2_animation_catalog.lua @@ -74,6 +74,16 @@ for _,edition in ipairs{'gold','silver','crystal'}do T.eq(screen.animPlayer.runtime.data,restored,'real screen, public data and VM share the restored owner') T.eq(screen.anims,restored,'real screen exposes the complete native dataset') screen.animPlayer:release() + -- Gen1Recomp's native GSC schema has generation=2 and these real tables, + -- without the old fork's private gen2/format/moveAnims/index markers. + restored.gen2,restored.format,restored.moveAnims,restored.indices,restored.misc=nil,nil,nil,nil,nil + T.eq(Data.isNative(restored),true,'source-native data needs no fork-only format flag') + local native=require('src.battle.BattleState').newWild({data=fixtures,save=save, + stack={top=function()end}},'FIXMON_C',15) + T.check(native:animForMove('ADDED','player'),'real screen accepts the original source-native schema') + T.eq(native.animPlayer.runtime.data,restored) + T.eq(Runtime.scriptForMove(restored,'PROBE'),'5000') + native.animPlayer:release() local missing=E.new(nil,edition,{symbols={}}):gen2BattleAnims() T.eq(Data.isNative(missing),false,'missing ROM animation table remains an explicit unsupported result') end diff --git a/tests/gpu/gen2_battler_rows.lua b/tests/gpu/gen2_battler_rows.lua index 429e6f705..f9bf43000 100644 --- a/tests/gpu/gen2_battler_rows.lua +++ b/tests/gpu/gen2_battler_rows.lua @@ -89,6 +89,7 @@ function M.run() data.battle_anims.oam[id]={tile=0,sprites=sprites} end local catalog=require('src.battle.Gen2AnimCatalog').compile(data.battle_anims,data.constants,edition) + catalog.gen2,catalog.format,catalog.moveAnims,catalog.indices,catalog.misc=nil,nil,nil,nil,nil data.battle_anims=assert(loadstring(require('src.import.LuaWriter').encodeSplit(catalog)))() require('src.render.Font').load(data) local save=require('src.core.SaveData').newGame() From fa50c0eb85ee5abc17259abc38bb21523ebd9ffb Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:00:59 +0100 Subject: [PATCH 60/64] Keep registered animation provenance distinct from actual ROM imports --- src/battle/Gen2AnimCatalog.lua | 3 ++- tests/engine/gen2_animation_catalog.lua | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/battle/Gen2AnimCatalog.lua b/src/battle/Gen2AnimCatalog.lua index 84c9298e0..a0579ae50 100644 --- a/src/battle/Gen2AnimCatalog.lua +++ b/src/battle/Gen2AnimCatalog.lua @@ -15,7 +15,8 @@ function M.compile(imported,constants,version) local catalog=Data.tables(imported,constants,version) catalog.gen2,catalog.generation,catalog.format=true,2,'gsc-native-v1' catalog.commandFormat=imported.commandFormat - catalog.source='ROM:BattleAnimations + data/battle_anims/*' + catalog.source=imported.source or (imported.bank + and 'ROM:BattleAnimations + data/battle_anims/*'or 'Registered GSC animation programs') catalog.bank,catalog.gameVersion=imported.bank,version catalog.scripts,catalog.scriptOrder,catalog.moves,catalog.ids,catalog.indices={},{},{},{},{} catalog.misc,catalog.moveAnims,catalog.move,catalog.effects={},{},{},{} diff --git a/tests/engine/gen2_animation_catalog.lua b/tests/engine/gen2_animation_catalog.lua index 187d96342..d2646ad4f 100644 --- a/tests/engine/gen2_animation_catalog.lua +++ b/tests/engine/gen2_animation_catalog.lua @@ -25,6 +25,7 @@ for _,edition in ipairs{'gold','silver','crystal'}do local c=e:gen2BattleAnims() T.eq(Data.isNative(c),true,'actual ROM importer publishes native interpreter schema') T.eq(c.bank,1);T.eq(c.gameVersion,edition) + T.eq(c.source,'ROM:BattleAnimations + data/battle_anims/*','ROM provenance remains explicit') T.eq(c.moves.PROBE,'5000');T.eq(c.moves.OTHER,'5010');T.eq(c.moves.ALIAS,'5000') T.eq(c.indices[0],'4008','cartridge dummy entry is part of the complete pool') T.same(c.scriptOrder,{'4008','5000','5100','5010'},'pool includes call-only blocks and unselected moves once') @@ -92,6 +93,7 @@ do {op='ifparamequal',args={5,0,80},to=3},{op='ret'},{op='delay',frames=2},{op='ret'}}}}, moveAnims={PROBE=1},objects={},framesets={},oam={},gfx={}},nil,'crystal') T.eq(c.moves.PROBE,'program:1:1','non-ROM registration has an explicit program namespace') + T.eq(c.source,'Registered GSC animation programs','registered programs do not claim cartridge provenance') T.same(c.scripts['program:1:1'],{{'if_param_equal',5,'program:1:3'},{'ret'}}, 'canonical branch has one target key, not leftover cartridge pointer bytes') T.same(c.scripts['program:1:3'],{{'wait',2},{'ret'}}) From 793035db88219579a732f6c13a61c93364515c03 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:29:46 +0100 Subject: [PATCH 61/64] Bind GSC Fly and Dig visibility to the actual native queue and animation VM --- .../workflows/gen2-picture-presentation.yml | 1 + src/battle/BattleState.lua | 23 +- src/battle/Gen2MovePresentation.lua | 6 +- src/battle/Gen2VanishPresentation.lua | 101 +++++++++ tests/engine/gen2_vanish_presentation.lua | 203 ++++++++++++++++++ 5 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 src/battle/Gen2VanishPresentation.lua create mode 100644 tests/engine/gen2_vanish_presentation.lua diff --git a/.github/workflows/gen2-picture-presentation.yml b/.github/workflows/gen2-picture-presentation.yml index 97b8f6bf7..e935b6660 100644 --- a/.github/workflows/gen2-picture-presentation.yml +++ b/.github/workflows/gen2-picture-presentation.yml @@ -23,6 +23,7 @@ jobs: luajit tests/engine/gen2_picture_art_draw.lua luajit tests/engine/gen2_battler_rows.lua luajit tests/engine/gen2_animation_catalog.lua + luajit tests/engine/gen2_vanish_presentation.lua - name: Existing command, object and background owners run: | luajit tests/engine/gen2_object_runtime.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 702c0a97a..2b90a1ee9 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,7 @@ local WideBattle = require("src.battle.WideBattle") local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") +local Gen2Vanish = require('src.battle.Gen2VanishPresentation') -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -2738,7 +2739,10 @@ function BattleState:updateQueue() end if self.animPlaying then self.animPlayer:update() - if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').sync(self) end + if GameVersion.isGen2() then + require('src.battle.Gen2PicturePresentation').sync(self) + Gen2Vanish.sync(self) + end if self.animPlayer.pollEffects and self.applyAnimEffect then for _, ev in ipairs(self.animPlayer:pollEffects()) do self:applyAnimEffect(ev) @@ -2778,7 +2782,10 @@ function BattleState:updateQueue() return true end if not self.current then - if GameVersion.isGen2() then require('src.battle.Gen2PicturePresentation').sync(self) end + if GameVersion.isGen2() then + require('src.battle.Gen2PicturePresentation').sync(self) + Gen2Vanish.sync(self) + end local item = table.remove(self.queue, 1) if not item then return false end if item.fn then @@ -3025,6 +3032,7 @@ function BattleState:updateQueue() end end if GameVersion.isGen2() then + Gen2Vanish.admitted(self,item,self.animPlaying and self.animPlayer.runtime or nil) require('src.battle.Gen2PicturePresentation').admitted(self,item, self.animPlaying and self.animPlayer.runtime or nil) end @@ -8282,6 +8290,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- charge release? local releasing = user.charging == moveInst and user.chargeReady if releasing then + Gen2Vanish.releasing(self,user) user.charging, user.chargeReady, user.invulnerable = nil, nil, nil user.hiddenAs = nil end @@ -8359,6 +8368,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima attackerPosition = user.position, targetPosition = target and target.position, animContext = animContext } + if GameVersion.isGen2() and releasing then Gen2Vanish.releaseRow(self,user,self.moveAnimRow) end table.insert(self.queue, self.nextInsert, self.moveAnimRow) end end @@ -8417,7 +8427,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- straight past `charge` while the sun is up and fires on the turn it was -- selected. Every other charge move ignores the weather. if record and record.charge and not releasing and not skipCharge then - local branchAware = GameVersion.get() == "firered" + local branchAware = GameVersion.isGen2() or GameVersion.get() == "firered" and hasMoveTurnVariant(move.anim) if not branchAware then self:cancelMoveAnim() end user.charging = moveInst @@ -8434,6 +8444,12 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- (a BW record names its own place: underwater, underground, vanished) user.hiddenAs = move.hiddenAs or ((move.id == "DIG") and "underground" or "air") end + if GameVersion.isGen2() then + -- BattleCommand_Charge runs this move's own param-1 program before + -- DisappearUser; Gen1's TELEPORT/SLIDE_DOWN substitutes do not apply. + if self.moveAnimRow then self.moveAnimRow.animParam=1 end + Gen2Vanish.charge(self,user,self.moveAnimRow) + end if not branchAware then local chargeAnim = record.charge.anim if move.id == "DIG" then @@ -13087,5 +13103,6 @@ require('src.battle.Gen2BgPictures').install(BattleState) require('src.battle.Gen2PicturePresentation').install(BattleState) require('src.battle.Gen2PictureArt').install(BattleState,{load=getImage,palette=monPalette,path=imagePathOf, source=function(image)return imageMeta[image]end}) +Gen2Vanish.install(BattleState) return BattleState diff --git a/src/battle/Gen2MovePresentation.lua b/src/battle/Gen2MovePresentation.lua index 3a66fcf76..a8becf560 100644 --- a/src/battle/Gen2MovePresentation.lua +++ b/src/battle/Gen2MovePresentation.lua @@ -10,7 +10,11 @@ function M.start(screen,id,isPlayer,opts) local player=screen.animPlayer if not (Version.isGen2() and player and Data.isGen2(player.anims) and player:scriptFor(id)) then return false end - if player:start(id,isPlayer,opts)==false then return false end + -- CheckFlyDigStatus reads both actual battlers at animation admission. + -- A retained runner keeps this snapshot when the next move starts. + local options={};for key,value in pairs(opts or {})do options[key]=value end + options.flying={player=screen:isUnderground('player'),enemy=screen:isUnderground('enemy')} + if player:start(id,isPlayer,options)==false then return false end screen.animPlaying=true screen.animName,screen.animAttackerIsPlayer=id,isPlayer screen:resetPicFx() diff --git a/src/battle/Gen2VanishPresentation.lua b/src/battle/Gen2VanishPresentation.lua new file mode 100644 index 000000000..11678c9ef --- /dev/null +++ b/src/battle/Gen2VanishPresentation.lua @@ -0,0 +1,101 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Fly/Dig's cleared picture box follows the native animation queue. Combat +-- owns invulnerable/hiddenAs; this latch owns only when that picture is seen. +local Version=require('src.core.GameVersion') +local M={} +local function side(b)return b.isPlayer and 'player' or 'enemy'end + +local function vanished(b) + return b and b.invulnerable and (b.hiddenAs=='air' or b.hiddenAs=='underground') or false +end +local function current(screen,b,p) + return screen[side(b)]==b and b.mon==p.mon + and not b.fainted and (b.mon.hp or 0)>0 +end +local function queued(screen,row) + for _,item in ipairs(screen.queue or {})do if item==row then return true end end + return false +end +local function states(screen) + screen._gen2VanishPictures=screen._gen2VanishPictures or {} + return screen._gen2VanishPictures +end + +function M.charge(screen,b,row) + if not (Version.isGen2() and vanished(b))then return end + states(screen)[side(b)]={battler=b,mon=b.mon,row=row,phase=row and 'charge' or 'hidden',hidden=not row} +end + +-- Native resolution clears the charge flags before announcing the release. +-- Keep an already-cleared box until that row's SHOW_MON, not until its text. +function M.releasing(screen,b) + if not Version.isGen2()then return end + local all=screen._gen2VanishPictures + local p=all and all[side(b)] + if not p then return end + if not (p.battler==b and current(screen,b,p) and p.hidden)then all[side(b)]=nil;return end + p.phase,p.row,p.runner='release',nil,nil +end +function M.releaseRow(screen,b,row) + local p=screen._gen2VanishPictures and screen._gen2VanishPictures[side(b)] + if p and p.battler==b and p.phase=='release'then p.row=row end +end + +function M.admitted(screen,row,runner) + for key,p in pairs(screen._gen2VanishPictures or {})do + local b=p.battler + if p.row==row then + p.row,p.runner=nil,runner + if not runner then + if p.phase=='charge' and vanished(b)then p.phase,p.hidden='hidden',true + else screen._gen2VanishPictures[key]=nil end + end + end + end +end + +-- One observation after the native VM tick; reads and draws never call this. +function M.sync(screen) + local all=screen._gen2VanishPictures + for key,p in pairs(all or {})do + local b=p.battler + local remove=not current(screen,b,p) + if p.phase=='hidden'then remove=remove or not vanished(b) + elseif p.runner then + local r=p.runner + local ended=r~=screen.animPlayer.runtime or r.stopped + if p.phase=='charge'then + if ended then + if vanished(b)then p.phase,p.hidden,p.runner='hidden',true,nil + else remove=true end + end + elseif ended or r.bg.picSize[key]~=nil then remove=true end + elseif not (p.row and queued(screen,p.row))then remove=true end + if remove then all[key]=nil end + end +end + +function M.hidden(screen,b) + local p=b and screen._gen2VanishPictures and screen._gen2VanishPictures[side(b)] + return p and p.battler==b and current(screen,b,p) and p.hidden + and (p.phase=='release' or vanished(b)) or false +end +function M.install(Screen) + function Screen:isUnderground(side,mon) + local b=(side=='player' or side=='enemy')and self[side] + if not b or mon and mon~=b and mon~=b.mon then return false end + return vanished(b) and M.hidden(self,b) or false + end + local hidden=Screen.fxHidden + function Screen:fxHidden(b) + return Version.isGen2() and M.hidden(self,b) or hidden(self,b) + end + local read=Screen.animPicState + function Screen:animPicState(side) + local state=read and read(self,side) + local b=(side=='player' or side=='enemy')and self[side] + if Version.isGen2() and M.hidden(self,b)then state=state or {};state.hidden=true end + return state + end +end +return M diff --git a/tests/engine/gen2_vanish_presentation.lua b/tests/engine/gen2_vanish_presentation.lua new file mode 100644 index 000000000..0fec31e8f --- /dev/null +++ b/tests/engine/gen2_vanish_presentation.lua @@ -0,0 +1,203 @@ +-- Real native move resolution -> queue -> VM -> picture/BG observations. +-- Programs are generated fixtures, not cartridge data or modified mods. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Catalog=require('src.battle.Gen2AnimCatalog') +local previous=Version.get() +local function make(edition,format) + Version.set(edition) + local data=T.fixtures.fresh();data.constants.gen=Version.generation() + for _,id in ipairs{'FLY','DIG','SOLARBEAM','PROBE'}do + data.moves[id]={id=id,name=id,type='NORMAL',power=1,accuracy=100,accuracyRaw=255,pp=10, + effect=id=='PROBE'and 'NO_ADDITIONAL_EFFECT' or id=='SOLARBEAM'and 'CHARGE_EFFECT' or 'FLY_EFFECT'} + end + local main={entry=1,code={ + {op='ifparamequal',args={1},to=7}, + {op='delay',frames=3}, + {op='bgeffect',args={'BATTLE_BG_EFFECT_SHOW_MON',0,1,0}}, + {op='delay',frames=8},{op='ret'},{op='ret'}, + {op='delay',frames=3}, + {op='bgeffect',args={'BATTLE_BG_EFFECT_REMOVE_MON',0,1,0}}, + {op='delay',frames=45},{op='ret'}, + }} + local probe={entry=1,code={ + {op='bgeffect',args={'BATTLE_BG_EFFECT_SHOW_MON',0,0,0}}, + {op='bgeffect',args={'BATTLE_BG_EFFECT_BATTLEROBJ_1ROW',0,0,0}}, + {op='delay',frames=8},{op='ret'}, + }} + local anims={gen2=true,moveAnims={FLY=1,DIG=1,SOLARBEAM=1,PROBE=2},scripts={[1]=main,[2]=probe}, + objects={},framesets={},oam={},gfx={}} + anims.functions={[0]='Null'} + anims.framesets[0]={frames={{oam=0,duration=1}},loop='restart'} + anims.oam[0]={tile=0,sprites={{x=0,y=0,tile=0,attr=0}}} + for index,name in ipairs(require('src.battle.Gen2AnimObjectIds').battleAnimObjectOrder)do + if name=='BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW' or name=='BATTLE_ANIM_OBJ_ENEMYFEET_1ROW'then + anims.objects[index-1]={oamFlags=0,fixY=255,fn=0,frameset=0,palette=0,gfx=0} + end + end + if format=='native'then + anims=Catalog.compile(anims,nil,edition) + anims.gen2,anims.format,anims.moveAnims,anims.indices,anims.misc=nil,nil,nil,nil,nil + end + data.battle_anims=anims + require('src.render.Font').load(data) + local save=Save.newGame();save.options.textSpeed=1 + save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',18)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}, + input={wasPressed=function()return true end,isDown=function()return true end}},'FIXMON_C',23) + s.queue,s.current,s.waitFrames={},nil,nil + s.introSlide,s.sendingOut,s.enemySendingOut=0,false,false + s.showPlayerBack,s.showEnemyTrainer=false,false + s.rngCalls=0;s.rng=function()s.rngCalls=s.rngCalls+1;return 0 end + return s +end +local function untilState(s,predicate,label) + local frames=0 + while not predicate() and frames<1500 do s:updateQueue();frames=frames+1 end + T.check(frames<1500,label) + return frames +end +local function drained(s) + return #s.queue==0 and not s.current and not s.animPlaying and (s.waitFrames or 0)<=0 + and not s.draining +end +local function finish(s)untilState(s,function()return drained(s)end,'native queue and VM release')end +local function underground(s,side,mon) + return s.isUnderground and s:isUnderground(side,mon) or false +end +local function release(s,id,side,move) + local p=s._gen2VanishPictures and s._gen2VanishPictures[side] + s:act(function() + s:performMove(s[side],s[side=='player'and'enemy'or'player'],move) + if id~='SOLARBEAM'then + T.eq(p and p.phase,'release','actual release retains the preceding cleared box') + T.eq(p and p.row,s.moveAnimRow,'release latch binds the real native row') + end + end) + untilState(s,function()return s.animPlaying end,'actual release program starts') + if id~='SOLARBEAM'then + T.eq(s._gen2VanishPictures[side],p,'release admission retains its original picture latch') + T.eq(p and p.runner,s.animPlayer.runtime,'release admission binds its actual VM') + end + T.eq(s.animPlayer.moveId,id,'release uses the actual move') + T.eq(s.animPlayer.vars.param,0,'release uses cartridge param zero') +end +for _,edition in ipairs{'gold','silver','crystal'}do + for _,format in ipairs{'flat','native'}do for _,side in ipairs{'player','enemy'}do + for _,id in ipairs{'FLY','DIG','SOLARBEAM'}do + local s=make(edition,format) + local b,target=s[side],s[side=='player'and'enemy'or'player'] + local move={id=id,pp=10};b.curMoves={move} + local hp=b.mon.hp + s:performMove(b,target,move) + T.eq(b.charging,move,'native first charge retains the original instance') + T.eq(move.pp,9,'native first charge spends exactly one PP') + T.eq(b.invulnerable==true,id~='SOLARBEAM','only real Fly/Dig charge hides the battler') + local rows={};for _,row in ipairs(s.queue)do if row.anim then rows[#rows+1]=row end end + T.eq(#rows,1,'charge has one animation, no legacy extra clock') + T.eq(rows[1].anim,id,'charge uses its own ROM program, never a Gen1 substitute') + T.eq(rows[1].animParam,1,'first charge selects the real param-1 arm') + untilState(s,function()return s.animPlaying end,'actual queued charge program starts') + T.eq(s.animPlayer.moveId,id,'native queue starts the move itself') + T.eq(s.animPlayer.vars.param,1,'actual interpreter sees charge parameter') + T.eq(underground(s,side),false,'takeoff/burrow still starts with a visible battler') + T.eq(s.animPlayer.runtime.env.flying[side],false,'first charge SHOW_MON can still use the battler') + local r=s.anim + finish(s) + local vanished=id~='SOLARBEAM' + T.eq(underground(s,side),vanished,'picture disappears only after native charge animation') + T.eq(s:animPicState(side).hidden,vanished,'actual public picture state follows the native latch') + local calls=s.rngCalls + for _=1,3 do underground(s,side);s:animPicState(side);if s.pic then s:pic(b.mon,false)endend + T.eq(s.rngCalls,calls,'picture reads never consume battle RNG') + T.eq(b.mon.hp,hp,'charge/picture reads retain native HP') + T.eq(move.pp,9,'picture reads never spend additional PP') + T.eq(r.env.flying[side],false,'retained first runner keeps its admission snapshot') + release(s,id,side,move) + T.eq(b.charging,nil,'native release clears its original charge instance') + T.eq(b.invulnerable,nil,'native release clears invulnerability before animation') + T.eq(move.pp,9,'native second turn spends no extra PP') + T.eq(s:animPicState(side).hidden,vanished,'release stays absent during pre-SHOW_MON wait') + T.eq(s.animPlayer.runtime.env.flying[side],false,'release SHOW_MON is admitted after real flag clears') + local current=s.anim + untilState(s,function()return current.bg.picSize[side]~=nil end,'real native SHOW_MON executes') + T.eq(s:animPicState(side).hidden,false,'SHOW_MON reveals the same native battler') + T.eq(current.frames,5,'reveal uses the VM wait, no invented presentation clock') + finish(s) + T.eq(underground(s,side),false,'release retires cleared picture latch') + T.eq(next(s._gen2VanishPictures or {}),nil,'completed native release retains no stale battler owner') + s.animPlayer:release() + end + -- A move aimed at a genuinely vanished target must not copy its battler + -- rows into OBJ or reveal the target's BG box (both read the same flags). + local s=make(edition,format) + local b=s[side];local other=side=='player'and'enemy'or'player' + local move={id='DIG',pp=10};b.curMoves={move} + s:performMove(b,s[other],move);finish(s) + T.check(underground(s,side,b.mon),'optional source mon resolves actual active slot') + T.eq(underground(s,side,{}),false,'an unrelated mon cannot borrow active volatile state') + T.check(s:animForMove('PROBE',other),'actual opposing native move starts') + local r=s.anim + T.eq(r.env.flying[side],true,'the real opposing VM sees the hidden target') + s:updateQueue();s:updateQueue();s:updateQueue() + T.eq(r.bg.picSize[side],nil,'CheckFlyDigStatus suppresses actual SHOW_MON') + T.eq(r.bg.liftedRows[side],nil,'hidden target contributes no lifted BG rows') + T.eq(r.objects:activeCount(),0,'hidden target contributes no battler-row OBJ') + finish(s) + b.invulnerable=nil;b.hiddenAs=nil;s:updateQueue() + T.eq(underground(s,side),false,'native cancellation removes visibility flag') + T.eq(r.env.flying[side],true,'retained VM snapshot never follows future combat flags') + T.check(s:animForMove('PROBE',other),'next opposing program has a fresh native runner') + T.eq(s.anim.env.flying[side],false,'fresh runner reads the cleared actual flag') + s:updateQueue();s:updateQueue() + T.check(s.anim.bg.picSize[side]~=nil,'on-field target admits actual SHOW_MON') + T.check(s.anim.objects:activeCount()>0,'on-field target admits real battler OBJ') + T.eq(r.env.flying[side],true,'starting next runner cannot rebind retained runner') + s.animPlayer:release() + end end +end +-- Disabled/missing programs, skipped/cancelled rows and replacement/faint +-- release only their own picture state; the move's combat result is native. +for _,mode in ipairs{'disabled','missing','suppressed','cancelled','switch','faint','replaced','missed-release'}do + local s=make('crystal','native');local b=s.player;local move={id='DIG',pp=10};b.curMoves={move} + if mode=='disabled'then s.game.save.options.animations=false end + if mode=='missing'then s.data.battle_anims.scripts={}end + s:performMove(b,s.enemy,move,false,mode=='suppressed') + if mode=='cancelled'then s:cancelMoveAnim()end + if mode=='replaced'then + untilState(s,function()return s.animPlaying end,'charge to replace starts') + s:animForMove('PROBE','enemy') + end + finish(s) + T.eq(underground(s,'player'),mode~='cancelled',mode..' follows its actual charge admission') + if mode=='switch'then + s:switchPlayerInto(Battle.POS.PLAYER_LEFT,s.game.save.party[2]);s:updateQueue() + T.eq(underground(s,'player'),false,'incoming battler never inherits old cleared box') + T.eq(next(s._gen2VanishPictures or {}),nil,'actual switch releases old native picture owner') + elseif mode=='faint'then + b.mon.hp=0;b.fainted=true;s:updateQueue() + T.eq(next(s._gen2VanishPictures or {}),nil,'actual faint releases its cleared picture owner') + else + if mode=='disabled'then s.game.save.options.animations=true end + if mode=='missed-release'then s.data.moves.DIG.accuracyRaw=0 end + s:act(function()s:performMove(b,s.enemy,move)end);finish(s) + T.eq(underground(s,'player'),false,mode..' releases the hidden box after the actual second turn') + T.eq(next(s._gen2VanishPictures or {}),nil,mode..' retains no stale presentation owner') + end + T.eq(move.pp,9,mode..' presentation preserves native PP continuation') + s.animPlayer:release() +end +-- Existing non-GSC charge animation selection is unchanged. +for _,edition in ipairs{'red','firered','emerald'}do + local s=make(edition,'flat');local move={id='DIG',pp=10} + s:performMove(s.player,s.enemy,move) + local animation;for _,row in ipairs(s.queue)do if row.anim then animation=row.anim end end + if edition=='red'then T.eq(animation,'SLIDE_DOWN_ANIM','Gen1 keeps original charge animation')end + T.eq(s._gen2VanishPictures,nil,edition..' never creates GSC presentation state') +end +Version.set(previous) +T.finish('native GSC Fly/Dig charge, release, VM status snapshots and picture ownership') From 9962abb5423f17283fb11cc9f46af80faea1ffa7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:31:05 +0100 Subject: [PATCH 62/64] Adapt charge checks to upstream optional idle picture observations --- tests/engine/gen2_vanish_presentation.lua | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/engine/gen2_vanish_presentation.lua b/tests/engine/gen2_vanish_presentation.lua index 0fec31e8f..8a20c0e96 100644 --- a/tests/engine/gen2_vanish_presentation.lua +++ b/tests/engine/gen2_vanish_presentation.lua @@ -66,6 +66,9 @@ local function drained(s) and not s.draining end local function finish(s)untilState(s,function()return drained(s)end,'native queue and VM release')end +local function pictureHidden(s,side) + local state=s:animPicState(side);return state and state.hidden or false +end local function underground(s,side,mon) return s.isUnderground and s:isUnderground(side,mon) or false end @@ -110,9 +113,9 @@ for _,edition in ipairs{'gold','silver','crystal'}do finish(s) local vanished=id~='SOLARBEAM' T.eq(underground(s,side),vanished,'picture disappears only after native charge animation') - T.eq(s:animPicState(side).hidden,vanished,'actual public picture state follows the native latch') + T.eq(pictureHidden(s,side),vanished,'actual public picture state follows the native latch') local calls=s.rngCalls - for _=1,3 do underground(s,side);s:animPicState(side);if s.pic then s:pic(b.mon,false)endend + for _=1,3 do underground(s,side);s:animPicState(side);if s.pic then s:pic(b.mon,false)end end T.eq(s.rngCalls,calls,'picture reads never consume battle RNG') T.eq(b.mon.hp,hp,'charge/picture reads retain native HP') T.eq(move.pp,9,'picture reads never spend additional PP') @@ -121,11 +124,11 @@ for _,edition in ipairs{'gold','silver','crystal'}do T.eq(b.charging,nil,'native release clears its original charge instance') T.eq(b.invulnerable,nil,'native release clears invulnerability before animation') T.eq(move.pp,9,'native second turn spends no extra PP') - T.eq(s:animPicState(side).hidden,vanished,'release stays absent during pre-SHOW_MON wait') + T.eq(pictureHidden(s,side),vanished,'release stays absent during pre-SHOW_MON wait') T.eq(s.animPlayer.runtime.env.flying[side],false,'release SHOW_MON is admitted after real flag clears') local current=s.anim untilState(s,function()return current.bg.picSize[side]~=nil end,'real native SHOW_MON executes') - T.eq(s:animPicState(side).hidden,false,'SHOW_MON reveals the same native battler') + T.eq(pictureHidden(s,side),false,'SHOW_MON reveals the same native battler') T.eq(current.frames,5,'reveal uses the VM wait, no invented presentation clock') finish(s) T.eq(underground(s,side),false,'release retires cleared picture latch') From 6339b7dcaf476777bd427c87673c9b9d1d8ba1a4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:31:31 +0100 Subject: [PATCH 63/64] Document actual upstream queue integration and validation limits --- docs/native-gen2-vanish-presentation.md | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/native-gen2-vanish-presentation.md diff --git a/docs/native-gen2-vanish-presentation.md b/docs/native-gen2-vanish-presentation.md new file mode 100644 index 000000000..dd2070905 --- /dev/null +++ b/docs/native-gen2-vanish-presentation.md @@ -0,0 +1,65 @@ +# Native GSC Fly/Dig presentation + +The shared controller previously replaced a first charge with Gen1's +`TELEPORT` or `SLIDE_DOWN_ANIM`. Its genuine GSC interpreter accepted +`env.flying`, but actual move admission never bound either native battler. +`SHOW_MON` and the two battler-row OBJ effects could therefore treat an absent +Fly/Dig target as present. A cleared picture box also had no persistent native +owner between moves. + +## Result + +GSC charge moves retain their own native ROM animation row with parameter 1; +the release uses parameter 0. Existing damage, accuracy, PP, charge flags, +charge-required hooks and random draws retain their native owners. Gen1/GBA +charge selection is unchanged. + +`Gen2VanishPresentation` observes the actual native `invulnerable`/`hiddenAs` +state and the admitted row's genuine interpreter. The first takeoff/burrow +starts visible; only completion of that animation latches the cleared box. +Each new VM receives its own snapshot of both battlers through the native +`isUnderground(side, mon)` reader. A retained runner keeps its original flags. +The release holds an already-cleared box until its actual BG `SHOW_MON` +produces `picSize`, or until normal completion/fallback releases it. + +Cancelled/missing/disabled animations, replacement, switch and faint retire +only their own picture relationship. Native picture-state readers and the existing native drawer share the same +visibility. Reads never step an interpreter, spend PP +or consume RNG. The latch has at most one record per active side and does not +allocate work per draw. + +This upstream adaptation uses the existing native updateQueue owner. That +source tree has no semantic checkpoint service; none is invented here. The +fork has an additional checked integration with its existing checkpoint graph. + +## Reference and verification + +The timing follows the frozen Gen1Recomp reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, especially +`src/ui/gen2/BattleState.lua` (`isUnderground`, `startAnim`, `revealVanished`) +and its documented GSC `BattleCommand_Charge` / `CheckFlyDigStatus` behavior. +Existing Gen1Recomp/Cedric rights and interpreter provenance remain intact; +see `compat/gen1-gsc/` and `LICENSE-ROXAS2712.md`. Original contributions are +offered to Cedric / UNDERdecoded under the latter permission, including +visible credit; earlier grants and other authors' terms remain effective. + +The ROM-free `gen2_vanish_presentation` suite drives the actual shared native +controller, queue, command VM and BG/object pools for Gold, Silver and Crystal, +legacy and original source-native schemas, both attacking sides, both charge +moves and an ordinary visible charge. It also covers missing/disabled data, +cancel/replacement/switch/faint/missed release, retained runners, PP/RNG reads +and non-GSC isolation. `gen2_vanish_checkpoint` drives the actual public +semantic capture/restore and the restored second turn. + +A separate local actual-Crystal-ROM check runs both Fly/Dig halves through the +same controller with ROM-free test battlers. Actual ROM timings on both sides: +Fly charge 66 VM frames, Dig charge 179; release SHOW_MON at frame 34 and +completion at frame 67. A hidden-target Growl runs 170 VM frames with complete +command/object/OAM/BG state equal to the frozen reference receiving the same +flags. No ROM bytes, mod changes, saves or machine paths are committed. + +These are engine CPU/ROM-data checks. They do not qualify unchanged VASC, +visible OS windows, GPU output, Windows, performance or a packaged release. +The upstream adaptation uses its existing native queue; its source tree has +no semantic checkpoint service, so that fork-only service is neither invented +nor claimed there. From 14d1c98cc2982fd3d46366cd5251c7be6a83a3c8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:32:02 +0100 Subject: [PATCH 64/64] Verify upstream enemy PP policy without importing fork battle rules --- tests/engine/gen2_vanish_presentation.lua | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/engine/gen2_vanish_presentation.lua b/tests/engine/gen2_vanish_presentation.lua index 8a20c0e96..1ee5787ce 100644 --- a/tests/engine/gen2_vanish_presentation.lua +++ b/tests/engine/gen2_vanish_presentation.lua @@ -95,10 +95,12 @@ for _,edition in ipairs{'gold','silver','crystal'}do local s=make(edition,format) local b,target=s[side],s[side=='player'and'enemy'or'player'] local move={id=id,pp=10};b.curMoves={move} + local expectedPP=not b.isPlayer and s.kind~='link' and s.ruleset + and s.ruleset.enemyUnlimitedPP and 10 or 9 local hp=b.mon.hp s:performMove(b,target,move) T.eq(b.charging,move,'native first charge retains the original instance') - T.eq(move.pp,9,'native first charge spends exactly one PP') + T.eq(move.pp,expectedPP,'first charge keeps the native side PP policy') T.eq(b.invulnerable==true,id~='SOLARBEAM','only real Fly/Dig charge hides the battler') local rows={};for _,row in ipairs(s.queue)do if row.anim then rows[#rows+1]=row end end T.eq(#rows,1,'charge has one animation, no legacy extra clock') @@ -118,12 +120,12 @@ for _,edition in ipairs{'gold','silver','crystal'}do for _=1,3 do underground(s,side);s:animPicState(side);if s.pic then s:pic(b.mon,false)end end T.eq(s.rngCalls,calls,'picture reads never consume battle RNG') T.eq(b.mon.hp,hp,'charge/picture reads retain native HP') - T.eq(move.pp,9,'picture reads never spend additional PP') + T.eq(move.pp,expectedPP,'picture reads never spend additional PP') T.eq(r.env.flying[side],false,'retained first runner keeps its admission snapshot') release(s,id,side,move) T.eq(b.charging,nil,'native release clears its original charge instance') T.eq(b.invulnerable,nil,'native release clears invulnerability before animation') - T.eq(move.pp,9,'native second turn spends no extra PP') + T.eq(move.pp,expectedPP,'native second turn spends no extra PP') T.eq(pictureHidden(s,side),vanished,'release stays absent during pre-SHOW_MON wait') T.eq(s.animPlayer.runtime.env.flying[side],false,'release SHOW_MON is admitted after real flag clears') local current=s.anim