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/docs/native-companion-terrain.md b/docs/native-companion-terrain.md new file mode 100644 index 000000000..ab1bbf0a9 --- /dev/null +++ b/docs/native-companion-terrain.md @@ -0,0 +1,31 @@ +# Native companion terrain observations + +Contributions by Roxas2712 — . +Existing upstream rights and notices remain applicable to the shared native machinery. + +The GBA source namespace now reads grass, next elevation and ledge landing +from the native map owner. Elevation zero remains a real height; bridge +markers preserve the incoming height. Native movement and source queries +share the same transition and ledge classifier. Dataset overrides, older +standing-cell ledges, later-edition jump distances and callback arity remain +native. Queries never move actors, execute movement hooks or trigger warps. + +`src.core.game3.encounters` provides `ensureLoaded`, `terrainAt` and `tableFor` +(`table_for` alias). Tables expose detached land/water/rocks/fishing areas, +with cartridge species indices and source level-range field names. Map +aliases resolve through the checked native map binding. Native header and +script-variable changes are visible on the next query; unknown species or +retired maps are refused instead of silently substituting another monster. +Altering Cave header selection is shared with native encounter generation. +These reads do not consume RNG or install another import cache. + +This is a partial interface. Encounter rolling, encounter identity transfer +into battles, companion actor collection, follower commands and UI hooks +still need their own integrations. No complete companion or presentation +capability version is advertised, and unchanged Origami 1.0.9 initialization +is not yet expected to succeed. + +Validation uses synthetic native owners for all five GBA editions, actual +native hop dispatch, height/bridge transitions, cave/underwater terrain, +sparse species indices, alternate headers, retired bindings, detached +records and real SDK failure/teardown alongside a native peer. diff --git a/docs/native-field-collision.md b/docs/native-field-collision.md new file mode 100644 index 000000000..da94d2fbc --- /dev/null +++ b/docs/native-field-collision.md @@ -0,0 +1,52 @@ +# Compatibility collision reads and native entry permission + +The collision module reads the live native GBA session. Classified COLL bytes +remain detached source-format observations, while `canEnter` calls the existing +native permission owner. No collision grid, actor state, warp event, script or +save is changed by a query. Hypothetical origins, elevations and surf states +use an observational mover; its real native actor and connected-map aliases +are excluded from self-collision. + +Source behavior comes from original bank attributes and the existing semantic +translator, including neighboring banks and authored borders. The existing +map graph determines which metatile/pair a world coordinate samples. Native +grid revisions and source-bank replacements remain visible. Classified arrays +are cached by owned layout/revision; mutating such an observation cannot +change native entry permission. Native extents remain authoritative for entry +and bounds even where the source art layout has padding. + +The module exposes current-map/behavior/elevation/cell/grid observations, +native walkable/water queries, source-format active warp observations and +`canEnter`. Header-only warps on non-warp terrain stay inactive. Loader teardown +retires readers and arrays after wrapper unwind. Held detached cells do not +retain native maps or source banks. Sessions whose world belongs to an earlier +dataset cannot grant movement. + +## Verification and integration + +`luajit tools/field-collision/check.lua` runs 486 ROM-free checks across all five +editions using real native map and collision implementations. It covers native +permission/reason agreement, source-bank semantics, connected/border sampling, +grid changes, hypothetical requests, active/dead warps, detached overrides, +dataset replacement, real loader teardown and weak-reference retirement. +The player phase's 261 checks also pass with this session guard. + +The composed fork's full unchanged 1.0.9 source loader probe now reaches the +missing `src.core.game3.tileset_native` module for all five editions. Full +initialization, GPU rendering and gameplay remain unaccepted. These probes +use fixture game data and read the full source package without modifying it. + +The standalone upstream diff requires player/session PR #254 and map-read +PR #251 with its terrain/identity dependencies. All-five testing also requires +cartridge PRs #183, #186 and #187. Its focused fixture runs in the composed +fork; it cannot independently exercise missing prerequisite interfaces on +pristine upstream main. + +## Remaining work + +Custom source-authored collision providers, binding/loading a different map, +typed script writes and the rest of the source collision/field service +surface still need integration. They must enter native commands rather than +turning the detached presentation grid into a second simulation. The atlas, +distinct session facade, field/UI/battle services and end-to-end acceptance +remain separate workstreams. diff --git a/src/compat/gen1/CollisionRuntime.lua b/src/compat/gen1/CollisionRuntime.lua new file mode 100644 index 000000000..32b719ebe --- /dev/null +++ b/src/compat/gen1/CollisionRuntime.lua @@ -0,0 +1,94 @@ +-- Source observations and native permission queries have different alphabets: +-- classified COLL bytes are views; actual entry permission stays native. +local Session=require('src.compat.gen1.NativeSession') +local Map=require('src.core.game3.map') +local Tiles=require('src.render.Gen3Tiles') +local Behavior=require('src.compat.gen1.MetatileBehavior') +local Version=require('src.core.GameVersion') +local Native=require('src.world.Collision') +local M={} +local function integer(v)return type(v)=='number'and v==v and math.abs(v)=0 and mid<1024)then return nil end + local g,_,_,maps=context(game);if not g or not maps:isOwned(def)then return nil end + local native=g.data.maps[def.nativeId] + local pair=native and g.data.tilesets[native.tileset] + local sets=g.data.map_tilesets + local a=pair and sets and sets[pair.primaryKey] + local b=pair and sets and sets[pair.secondaryKey] + local geometry=g.data.constants and g.data.constants.gen3Layout + if not(a and b and geometry)then return nil end + local cached=readers[pair] + if not cached or cached.primary~=a or cached.secondary~=b or cached.geometry~=geometry + or cached.split~=geometry.metatilesInPrimary then + cached={primary=a,secondary=b,geometry=geometry,split=geometry.metatilesInPrimary, + reader=Tiles.new({primary=a,secondary=b},geometry)} + readers[pair]=cached + end + local raw=cached.reader:sourceBehavior(mid) + return raw~=nil and Behavior.fromRaw(Version.get(),raw)or nil + end + function runtime:behavior(def,x,y,game) + if not(integer(x)and integer(y))then return nil end + local layout=def and def.midLayout + if not layout or x<0 or y<0 or x>=layout.width or y>=layout.height then return nil end + return self:behaviorMid(def,layout:midAt(x,y),game) + end + function runtime:grid(game) + local def=self:current(game);local layout=def and def.midLayout + if not layout then return nil end + local cached=grids[layout] + if not cached or cached.revision~=layout._revision then + cached={revision=layout._revision,cells=layout:collArray()};grids[layout]=cached + end + return cached.cells + end + function runtime:canEnter(game,x,y,opts) + if not(integer(x)and integer(y))then return false,'bounds'end + local g,w,p=Session.resolve(game);if not g then return false,'bounds'end + opts=opts or {} + local fx,fy=opts.fromX or p.cellX,opts.fromY or p.cellY + if not(integer(fx)and integer(fy))then return false,'bounds'end + local direction=opts.dir + if direction~=nil and not directions[direction]then return false,'direction'end + if not direction then + if fx==x and fy~=y then direction=y>fy and'down'or'up' + elseif fy==y and fx~=x then direction=x>fx and'right'or'left'end + end + local elevation=opts.elevation + if elevation==nil then elevation=p.elevation end + local surfing=opts.surfing + if surfing==nil then surfing=p.surfing end + local cast=w.cast or w.entities or {} + local mover=p + if fx~=p.cellX or fy~=p.cellY or elevation~=p.elevation or surfing~=p.surfing then + mover={};for k,v in pairs(p)do mover[k]=v end + mover.cellX,mover.cellY,mover.elevation,mover.surfing=fx,fy,elevation,surfing + local filtered={} + for _,entity in ipairs(cast)do + if entity~=p and entity.of~=p then filtered[#filtered+1]=entity end + end + cast=filtered + end + return Native.mayEnter(w.map,cast,mover,x,y,direction) + end + function runtime:release() + readers=setmetatable({},{__mode='k'});grids=setmetatable({},{__mode='k'}) + end + return runtime +end +return M diff --git a/src/compat/gen1/DecodedLayout.lua b/src/compat/gen1/DecodedLayout.lua new file mode 100644 index 000000000..7619a9585 --- /dev/null +++ b/src/compat/gen1/DecodedLayout.lua @@ -0,0 +1,134 @@ +-- Legacy decoded 16px layout contract. These are mod-owned layouts, +-- not aliases to the shared native Map or its two-bit collision grid. +-- coll here is the old classified COLL_* byte; it is not raw GBA collision. +local Layout = {} +Layout.__index = Layout + +function Layout.fromDecoded(decoded, mapId, pair, blob) + local layout = setmetatable({ + mapId = mapId, pair = pair or "sevii_outdoor", + width = decoded.width or 0, height = decoded.height or 0, + trueWidth = decoded.trueWidth or decoded.width or 0, + trueHeight = decoded.trueHeight or decoded.height or 0, + borderWidth = decoded.borderWidth or 1, borderHeight = decoded.borderHeight or 1, + borderMids = decoded.borderMids or { 0 }, cells = decoded.cells or {}, + overrides = {}, _revision = 0, + }, Layout) + if blob then + layout._workerSource = { + blob = blob, cells = decoded.cells, width = decoded.width, height = decoded.height, + trueWidth = decoded.trueWidth or decoded.width, trueHeight = decoded.trueHeight or decoded.height, + } + end + return layout +end + +local function outside(layout, x, y) + return x < 0 or y < 0 or x >= (layout.trueWidth or layout.width) + or y >= (layout.trueHeight or layout.height) +end +local function borderMid(layout, x, y) + return layout.borderMids[(y % layout.borderHeight) * layout.borderWidth + x % layout.borderWidth + 1] or 0 +end +function Layout:cellAt(x, y) + local row = self.overrides[y * 1024 + x] + if row then return row end + if outside(self, x, y) then return { mid = borderMid(self, x, y), coll = 255, elev = 0 } end + return self.cells[y * self.width + x + 1] or { mid = 0, coll = 255, elev = 0 } +end +local baseCellAt = Layout.cellAt +local function field(layout, x, y, key, missing, border) + if layout.cellAt ~= baseCellAt then return layout:cellAt(x, y)[key] end + local row = layout.overrides[y * 1024 + x] + if row then return row[key] end + if outside(layout, x, y) then + if border ~= nil then return border end + return borderMid(layout, x, y) + end + row = layout.cells[y * layout.width + x + 1] + if row then return row[key] end + return missing +end +function Layout:midAt(x, y) return field(self, x, y, "mid", 0) end +function Layout:collAt(x, y) return field(self, x, y, "coll", 255, 255) end +function Layout:elevAt(x, y) return field(self, x, y, "elev", 0, 0) end + +function Layout:collArray() + if self.width * self.height <= 0 then return nil end + local out = {} + for y = 0, self.height - 1 do + for x = 0, self.width - 1 do + local i = y * self.width + x + 1 + local override, row = self.overrides[y * 1024 + x], self.cells[i] + -- Padding stays blocked even if a caller stored an outside override. + out[i] = not outside(self, x, y) and (override and override.coll or row and row.coll or 255) or 255 + end + end + return out +end + +local function invalidate(layout, coordinates) + local view = package.loaded["src.core.game3.field_view"] + if not view then return end + if coordinates and #coordinates <= 128 and view.invalidateLayoutCell then + for i = 1, #coordinates, 2 do view.invalidateLayoutCell(layout, coordinates[i], coordinates[i + 1]) end + else view._nativeDirty = true end +end +function Layout:applyOverride(x, y, mid, coll, elev) + self._revision = (self._revision or 0) + 1 + x, y = tonumber(x) or 0, tonumber(y) or 0 + self.overrides[y * 1024 + x] = { mid = tonumber(mid) or 0, coll = coll ~= nil and coll or 255, elev = elev or 0 } + invalidate(self, { x, y }) +end +function Layout:setMetatiles(rows) + local coordinates = {} + for _, row in ipairs(rows or {}) do + local x, y = tonumber(row.x) or 0, tonumber(row.y) or 0 + local before = self:cellAt(x, y) + self.overrides[y * 1024 + x] = { + mid = tonumber(row.mid) or before.mid or 0, + coll = row.coll ~= nil and row.coll or before.coll, + elev = row.elev ~= nil and row.elev or before.elev or 0, + } + coordinates[#coordinates + 1] = x; coordinates[#coordinates + 1] = y + end + if #coordinates > 0 then self._revision = (self._revision or 0) + 1; invalidate(self, coordinates) end + return #coordinates / 2 +end +function Layout:stamp(source, ox, oy) + if type(source) ~= "table" or type(source.cells) ~= "table" then return 0 end + ox, oy = tonumber(ox) or 0, tonumber(oy) or 0 + local count = 0 + for y = 0, (source.trueHeight or source.height or 0) - 1 do + for x = 0, (source.trueWidth or source.width or 0) - 1 do + local row = source.cells[y * source.width + x + 1] + if row then + self.overrides[(oy + y) * 1024 + ox + x] = { mid = row.mid or 0, coll = row.coll ~= nil and row.coll or 255, elev = row.elev or 0 } + count = count + 1 + end + end + end + invalidate(self) + if count > 0 then self._revision = (self._revision or 0) + 1 end + return count +end +function Layout:clearOverrides() + if next(self.overrides) then self.overrides = {}; self._revision = (self._revision or 0) + 1 end + invalidate(self) +end + +function Layout:workerPacked() + local source = self._workerSource + if not source or self.cells ~= source.cells or self.width ~= source.width or self.height ~= source.height + or self.trueWidth ~= source.trueWidth or self.trueHeight ~= source.trueHeight + or self.cellAt ~= baseCellAt or self.midAt ~= Layout.midAt then return nil end + local assets = package.loaded["src.render.Assets"] + if assets and assets.loader and #assets.loader:overrideOrder() > 0 then return nil end + local overrides, borders = {}, {} + for key, row in pairs(self.overrides) do overrides[key] = { mid = row.mid } end + for i = 1, self.borderWidth * self.borderHeight do borders[i] = self.borderMids[i] end + return { blob = source.blob, off = 17 + source.blob:byte(15) * source.blob:byte(16) * 2, + width = self.width, trueWidth = self.trueWidth, trueHeight = self.trueHeight, + borderWidth = self.borderWidth, borderHeight = self.borderHeight, borderMids = borders, overrides = overrides } +end +return Layout diff --git a/src/compat/gen1/MapRuntime.lua b/src/compat/gen1/MapRuntime.lua new file mode 100644 index 000000000..1d28a9588 --- /dev/null +++ b/src/compat/gen1/MapRuntime.lua @@ -0,0 +1,203 @@ +-- Compatibility map observations over one native session. Never loads a map, +-- runs a script/player, or lends native definitions to compatibility writers. +local Identity = require('src.compat.gen1.MapIdentity') +local PairIdentity = require('src.compat.gen1.PairIdentity') +local Views = require('src.compat.gen1.MapViews') +local Version = require('src.core.GameVersion') +local Connections = require('src.core.game3.connections') +local M = {} +local mapTypes = {NONE=0,TOWN=1,CITY=2,ROUTE=3,UNDERGROUND=4,UNDERWATER=5,OCEAN_ROUTE=6,UNKNOWN=7,INDOOR=8,SECRET_BASE=9} +local function numericBoolean(v) if v == nil then return nil end; return (v == true or v == 1) and 1 or 0 end +local function copy(v) + if type(v) ~= 'table' then return v end + local out = {}; for k, value in pairs(v) do out[k] = copy(value) end; return out +end +local overworld=require('src.compat.gen1.NativeSession').world +function M.new(getGame, getEdition) + assert(type(getGame)=='function' and type(getEdition)=='function','native session resolvers required') + local handle = {} + local views = Views.new(getGame,getEdition) + local observing=false + local owner,data,nativeMaps,edition,binding,pairBinding,epoch + local maps,known,published,loaded = {},setmetatable({},{__mode='k'}),{},{} + local ended,allIndexed = false,false + local function clear() + if observing or ended then + views:release();views=not ended and Views.new(getGame,getEdition)or nil;observing=false + end + if pairBinding then pairBinding:release() end + owner,data,nativeMaps,edition,binding,pairBinding,epoch=nil,nil,nil,nil,nil,nil,nil + maps,known,published,loaded={},setmetatable({},{__mode='k'}),{},{} + allIndexed=false + end + local function session() + if ended then return nil,'map runtime released' end + local game,id=getGame(),getEdition() + local info=Version.info(id) + if type(game)~='table' or not info or info.generation~=3 or not Identity.lookup(id,0,0) + or type(game.data)~='table' or type(game.data.maps)~='table' then + clear();return nil,'no native GBA session' + end + if game~=owner or data~=game.data or nativeMaps~=game.data.maps or edition~=id then + clear();owner,data,nativeMaps,edition=game,game.data,game.data.maps,id + binding=Identity.bind(nativeMaps,id);epoch={} + if type(data.tilesets)=='table' and type(data.map_tilesets)=='table' then + pairBinding=PairIdentity.bind(data.tilesets,data.map_tilesets,id) + end + end + return game + end + local function resolve(id) + local native,def=binding:nativeId(id) + if not native then + local canonical=binding:legacyId(id) + if canonical then native,def=binding:nativeId(canonical) end + end + if native then return native,def,binding:legacyId(native) end + return nil,'map identity unavailable or retired' + end + local function destination(id) + local canonical=binding:legacyId(id) + if canonical then return canonical end + if type(id)=='string' then + local g,n=id:match('^MAP_G(%d+)_N(%d+)$') + if g then return Identity.lookup(edition,tonumber(g),tonumber(n)) end + return Identity.lookup(edition,id) + end + end + function handle:definition(id) + local game,why=session();if not game then return nil,why end + local native,def,canonical=resolve(id);if not native then return nil,def end + local prior=maps[canonical] + if prior then return prior end + local pair=pairBinding and pairBinding:descriptor(def.tileset) + if not pair or type(pair.tileset)~='string'then return nil,'source tileset pair identity unavailable' end + local kind=mapTypes[def.mapType] or tonumber(def.mapType) or 0 + local indoor=kind==4 or kind==8 or kind==9 + local rows={} + for _,row in ipairs(def.sourceConnections or {})do + local target=destination(row.map) + if not target then return nil,'connection identity unavailable' end + rows[#rows+1]={dir=row.dir,map=target,offset=row.offset} + end + if def.connections and next(def.connections) and not def.sourceConnections then + return nil,'original connection order unavailable' + end + local warps={} + for _,row in ipairs(def.warps or {})do + local converted=copy(row);converted.nativeDestination=row.destMap;converted.destMap=destination(row.destMap) + if not converted.destMap and type(row.destMap)=='string' then + local g,n=row.destMap:match('^MAP_G(%d+)_N(%d+)$') + converted.mapGroup,converted.mapNum=tonumber(g),tonumber(n) + end + warps[#warps+1]=converted + end + local cycling,escaping,running=numericBoolean(def.allowCycling),numericBoolean(def.allowEscaping),numericBoolean(def.allowRunning) + local show=numericBoolean(def.showMapName)or 0 + local flags=tonumber(def.flags) + if edition=='ruby'or edition=='sapphire'then + -- RS policies are type-based; byte 26 is the source name-popup value, + -- not Emerald's permission bit field. Preserve native state separately. + cycling=(kind~=8 and kind~=9 and kind~=5)and 1 or 0 + local name=canonical:sub(4) + if name=='ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE'or name=='ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE'then cycling=1 end + if name=='SEAFLOOR_CAVERN_ROOM9'or name=='CAVE_OF_ORIGIN_B4F'then cycling=0 end + escaping=kind==4 and 1 or 0;running=kind==8 and 0 or 1 + if flags then show=flags==1 and 1 or 0 end + elseif flags then + local shift=(edition=='firered'or edition=='leafgreen')and 4 or 8 + show=math.floor(flags/shift)==1 and 1 or 0 + end + local view={id=canonical,name=canonical,nativeId=native,nativePair=def.tileset, + width=def.width,height=def.height,kind=indoor and 'indoor' or (kind==3 and 'route' or 'town'), + environment=indoor and 'INDOOR' or (kind==3 and 'ROUTE' or 'TOWN'),pair=pair.id,tileset=pair.tileset, + connections=rows,warps=warps,regionMapSectionId=def.regionMapSection, + showMapName=show,floorNum=def.floorNum or 0, + weather=def.weather or 0,mapType=kind,cave=numericBoolean(def.cave), + allowEscaping=escaping,allowRunning=running, + bikingAllowed=cycling,battleType=def.battleScene,music=def.music, + borderWidth=def.sourceBorderWidth,borderHeight=def.sourceBorderHeight,native=true} + for _,row in ipairs(rows)do + if row.dir=='dive'or row.dir=='emerge'then view[row.dir]={map=row.map,offset=tonumber(row.offset)or 0}end + end + local weak=setmetatable({handle},{__mode='v'});local generation=epoch + setmetatable(view,{__index=function(_,key) + local runtime=weak[1] + if key=='midLayout' and runtime then return runtime:layout(canonical,view,generation) end + end}) + known[view]=generation;maps[canonical]=view + return view + end + function handle:layout(id,def,generation) + local game,why=session();if not game then return nil,why end + if def and (known[def]~=epoch or (generation and generation~=epoch))then + return nil,'owned map definition retired' + end + if not def then def,why=self:definition(id);if not def then return nil,why end end + observing=true + local view;view,why=views:map(id);if not view then return nil,why end + local prior=published[def.id] + if prior~=view then + local previous=prior and prior.midLayout + view.midLayout.pair=def.pair + view.midLayout._revision=math.max(view.revision,(previous and previous._revision or 0)+1) + published[def.id]=view + end + loaded[def.id]=true + return view.midLayout + end + function handle:definitions() + local game,why=session();if not game then return nil,why end + if allIndexed then return maps end + for key in pairs(nativeMaps)do + local canonical=binding:legacyId(key) + if canonical then + local def,err=self:definition(canonical);if not def then return nil,err end + end + end + allIndexed=true + return maps + end + function handle:current() + local game,why=session();if not game then return nil,why end + local ow=overworld(game) + if not (ow and ow.map and nativeMaps[ow.map.id]==ow.map.def)then + allIndexed=false;views:current();return nil,'no active native map' + end + local def;def,why=self:definition(ow.map.id);if not def then return nil,why end + local layout;layout,why=self:layout(def.id,def);if not layout then return nil,why end + return def + end + function handle:neighbors(def) + def=def or self:current() + if not def or known[def]~=epoch then return {},{} end + local list,byDir={},{} + for _,row in ipairs(def.connections or {})do + local target=self:definition(row.map) + if target and Connections.cardinal(row.dir) then + local layout=self:layout(row.map,target) + if layout then + local record={dir=row.dir,map=row.map,mapId=row.map,offset=row.offset,def=target} + list[#list+1]=record;byDir[row.dir]=byDir[row.dir]or record + end + end + end + return byDir,list + end + function handle:loadedCount() + local n=0;for _ in pairs(loaded)do n=n+1 end;return n + end + function handle:invalidate(id) + local game=session();if not game then return end + views:invalidate(id) + if id==nil then clear() end + end + function handle:isOwned(def) session();return epoch~=nil and known[def]==epoch end + function handle:loadedLayouts() session();return loaded end + function handle:release() + if ended then return end + ended=true;clear();getGame,getEdition=nil,nil + end + return handle +end +return M diff --git a/src/compat/gen1/MapViews.lua b/src/compat/gen1/MapViews.lua new file mode 100644 index 000000000..cae5d712b --- /dev/null +++ b/src/compat/gen1/MapViews.lua @@ -0,0 +1,215 @@ +-- Owned compatibility snapshots over the native map owner. No map loads, +-- renderer construction, shared-definition writes or save access. +local Views = {} +local editions = { firered = true, leafgreen = true, emerald = true, ruby = true, sapphire = true } +local directions = {north=true,south=true,east=true,west=true,dive=true,emerge=true} +local function uint(n, max) + return type(n) == "number" and n >= 0 and n <= max and n % 1 == 0 +end +local function same(a, b) + if not a or #a ~= #b then return false end + for i = 1, #a do if a[i] ~= b[i] then return false end end + return true +end +local world=require('src.compat.gen1.NativeSession').world +local function kind(mapType) + local numbers = {NONE=0,TOWN=1,CITY=2,ROUTE=3,UNDERGROUND=4,UNDERWATER=5,OCEAN_ROUTE=6,UNKNOWN=7,INDOOR=8,SECRET_BASE=9} + mapType = numbers[mapType] or tonumber(mapType) or 0 + if mapType == 4 or mapType == 8 or mapType == 9 then return "indoor", "INDOOR" end + -- Match the public census registration, including its ocean/underwater + -- classification, rather than silently changing imported mod semantics. + if mapType == 3 then return "route", "ROUTE" end + return "town", "TOWN" +end + +function Views.new(getGame, getEdition) + assert(type(getGame) == "function" and type(getEdition) == "function", "native map view resolvers required") + -- Companion contracts are loaded on construction, never by a native boot + -- that does not request compatibility observations. + local Identity = require("src.compat.gen1.MapIdentity") + local Behavior = require("src.compat.gen1.MetatileBehavior") + local Classes = require("src.compat.gen1.CollisionClass") + local Layout = require("src.compat.gen1.DecodedLayout") + local Tiles = require("src.render.Gen3Tiles") + local Versions = require("src.core.GameVersion") + local function weakCache() return setmetatable({}, {__mode = "v"}) end + local owner, data, maps, edition, binding, cache, ended = nil, nil, nil, nil, nil, weakCache(), false + local serial, published = 0, setmetatable({}, {__mode = "k"}) + local ids = setmetatable({}, {__mode = "k"}) + local function token(value) + if type(value) ~= "table" then return value or false end + if not ids[value] then ids[value] = {} end + return ids[value] + end + local handle = {} + local function reset() cache = weakCache(); binding = nil; owner, data, maps, edition = nil, nil, nil, nil end + local function currentOwner() + if ended then return nil, "map view owner released" end + local game, id = getGame(), getEdition() + if type(game) ~= "table" or not editions[id] or not Versions.info(id) + or not game.data or type(game.data.maps) ~= "table" then + reset(); return nil, "no native GBA session" + end + local ow = world(game) + if not (ow and ow.map and game.data.maps[ow.map.id] == ow.map.def) then + reset(); return nil, "no active native map" + end + if game ~= owner or game.data ~= data or game.data.maps ~= maps or id ~= edition then + reset(); owner, data, maps, edition = game, game.data, game.data.maps, id + binding = Identity.bind(maps, edition) + end + return ow + end + local function loaded(ow, id, def) + if ow.map.id == id or ow.map.def == def then return ow.map end + for _, row in ipairs(ow.neighbors or {}) do + if row.map and (row.map.id == id or row.map.def == def) then return row.map end + end + end + local function build(id) + local ow, why = currentOwner(); if not ow then return nil, why end + local nativeId, def = binding:nativeId(id) + if not nativeId and maps[id] then + local alias = binding:legacyId(id) + if alias then nativeId, def = binding:nativeId(alias) end + end + if not nativeId then return nil, "map identity unavailable" end + local canonical = binding:legacyId(nativeId) + local live = loaded(ow, nativeId, def) + if live and live.def ~= def then return nil, "resident map definition changed" end + local w, h = def.width, def.height + if not uint(w, 1024) or w == 0 or not uint(h, 1024) or h == 0 or def.blockPx ~= 16 then + return nil, "native layout dimensions unavailable" + end + local sets, constants = data.map_tilesets, data.constants + local pair = data.tilesets and data.tilesets[def.tileset] + local a = pair and sets and sets[pair.primaryKey] + local b = pair and sets and sets[pair.secondaryKey] + local geometry = constants and constants.gen3Layout + if not (a and b and geometry and Tiles.sourceBehavior + and uint(geometry.metatilesInPrimary,1023) and geometry.metatilesInPrimary > 0 + and uint(geometry.tilesInPrimary,1023) and geometry.tilesInPrimary > 0 + and uint(geometry.palettesInPrimary,15) and geometry.palettesInPrimary > 0 + and geometry.metatileLayers == 2) then return nil, "original tileset attributes unavailable" end + local rawFlags = constants.gen3SourceBehaviorFlags + local frlg = edition == "firered" or edition == "leafgreen" + if not frlg and not (rawFlags and rawFlags.version == 1 and type(rawFlags.raw) == "string" + and #rawFlags.raw == 240 and rawFlags.romSha1 == Versions.info(edition).sha1) then + return nil, "original terrain flags unavailable" + end + local bw, bh, border = def.sourceBorderWidth, def.sourceBorderHeight, def.sourceBorder + if not uint(bw, 255) or bw == 0 or not uint(bh, 255) or bh == 0 + or type(border) ~= "string" or #border ~= bw * bh * 2 then return nil, "original border unavailable" end + if def.connections and next(def.connections) and not def.sourceConnections then + return nil, "original connection order unavailable" + end + local decodedBlocks = def._blockArraySource == def.blocks and def._blockArray + local stamp = {token(owner),token(maps),edition,token(def), token(live), live and live.gridRevision or 0, + token(def.blocks), token(def.collisionCells), token(def.elevationCells), token(def.sourceConnections), token(def.warps), w, h, + token(decodedBlocks), + token(a), token(b), a.attributes or false, b.attributes or false, a.sourceAttributes or false, b.sourceAttributes or false, + a.sourceAttributeBytes or false, b.sourceAttributeBytes or false, + a.sourceBehaviorMask or false, b.sourceBehaviorMask or false, + rawFlags and rawFlags.raw or false, border, bw, bh, token(geometry), geometry.metatilesInPrimary, + geometry.tilesInPrimary,geometry.palettesInPrimary,geometry.metatileLayers,def.mapType or 0} + local cached = cache[nativeId] + if cached and same(published[cached].stamp, stamp) then return cached end + local terrain = Tiles.new({primary = a, secondary = b}, geometry) + local flags = {} + if not frlg then + for raw = 0, 239 do + local bits = rawFlags.raw:byte(raw + 1) + if bits > 7 then return nil, "invalid original terrain flags" end + flags[Behavior.fromRaw(edition, raw)] = bits + end + end + local classify = Classes.new(frlg and "frlg" or "rse", flags) + local mapKind, environment = kind(def.mapType) + local warps = {} + for _, row in ipairs(def.warps or {}) do + if uint(row.x, w - 1) and uint(row.y, h - 1) then warps[row.y * w + row.x + 1] = true end + end + local paddedW, paddedH = w + w % 2, h + h % 2 + local cells = {} + for y = 0, paddedH - 1 do for x = 0, paddedW - 1 do + local sx, sy = math.min(x, w - 1), math.min(y, h - 1) + local index = sy * w + sx + 1 + local mid = live and live.blockPatch and live.blockPatch[index] + if mid == nil then + local blocks = decodedBlocks or def.blocks + if type(blocks) == "table" then mid = blocks[index] + elseif type(blocks) == "string" and #blocks >= index * 2 then + mid = (blocks:byte(index * 2 - 1) + blocks:byte(index * 2) * 256) % 1024 + end + end + local collision = live and live.collisionPatch and live.collisionPatch[index] + if collision == nil then collision = def.collisionCells and def.collisionCells[index] end + local elevation = live and live.elevationPatch and live.elevationPatch[index] + if elevation == nil then elevation = def.elevationCells and def.elevationCells[index] end + if not uint(mid, 1023) or not uint(collision, 3) or not uint(elevation, 15) then return nil, "native cell source unavailable" end + if mapKind == "indoor" and (x >= w or y >= h) then mid, collision, elevation = 0, 1, 0 end + local raw = terrain:sourceBehavior(mid) + if raw == nil then return nil, "original cell behavior unavailable" end + local behavior = Behavior.fromRaw(edition, raw) + local coll = classify.fromCell(mid, collision, behavior, mapKind) + -- Resolve the native pack's final blocking guard after classification. + -- These are the land classes emitted by our classifier; water, walls + -- and directional ledges retain their own categories. + if collision ~= 0 and (coll == 0 or coll == 0x18 or coll == 0x2b + or ((coll == 0x71 or coll == 0x72) and not (x < w and y < h and warps[index]))) then coll = 7 end + cells[#cells + 1] = {mid = mid, coll = coll, elev = elevation} + end end + local borders = {}; for i = 1, bw * bh do borders[i] = (border:byte(i * 2 - 1) + border:byte(i * 2) * 256) % 1024 end + local converted = {} + for _, row in ipairs(def.sourceConnections or {}) do + if not directions[row.dir] + or type(row.offset) ~= "number" or row.offset % 1 ~= 0 then return nil,"invalid source connection" end + local destination = binding:legacyId(row.map) + if not destination then return nil, "connection map identity unavailable" end + converted[#converted + 1] = {dir = row.dir, map = destination, offset = row.offset} + end + local convertedWarps = {} + for _, row in ipairs(def.warps or {}) do + local destination = row.destMap + local canonicalDestination = binding:legacyId(destination) + local group, number + if not canonicalDestination and type(destination) == "string" then + group, number = destination:match("^MAP_G(%d+)_N(%d+)$") + group, number = tonumber(group), tonumber(number) + canonicalDestination = group and Identity.lookup(edition, group, number) or Identity.lookup(edition, destination) + end + convertedWarps[#convertedWarps+1]={x=row.x,y=row.y,elevation=row.elevation,destWarp=row.destWarp, + destMap=canonicalDestination,nativeDestination=destination, + mapGroup=not canonicalDestination and group or nil,mapNum=not canonicalDestination and number or nil} + end + local snapshot = {id = canonical, nativeId = nativeId, nativePair = def.tileset, + width = w, height = h, kind = mapKind, environment = environment, + connections = converted, warps = convertedWarps} + -- The native pair key is explicit. Atlas-name compatibility is a separate + -- provider and this observation is not installed as the old map module. + snapshot.midLayout = Layout.fromDecoded({width = paddedW, height = paddedH, trueWidth = w, trueHeight = h, + cells = cells, borderWidth = bw, borderHeight = bh, borderMids = borders}, canonical, def.tileset) + serial = serial + 1; snapshot.revision = serial + cache[nativeId], published[snapshot] = snapshot, {stamp = stamp,id = nativeId} + return snapshot + end + function handle:current() + local ow, why = currentOwner(); if not ow then return nil, why end + return build(ow.map.id) + end + function handle:map(id) return build(id) end + function handle:isCurrent(view) + local record = published[view]; if not record then return false end + local fresh = build(record.id) + return fresh == view + end + function handle:invalidate(id) + if id == nil then cache = weakCache(); return end + local _, why = currentOwner(); if why then return end + local key = binding:nativeId(id) + cache[key or id] = nil + end + function handle:release() ended = true; reset(); getGame, getEdition = nil, nil end + return handle +end +return Views diff --git a/src/compat/gen1/NativeSession.lua b/src/compat/gen1/NativeSession.lua new file mode 100644 index 000000000..b98db4b08 --- /dev/null +++ b/src/compat/gen1/NativeSession.lua @@ -0,0 +1,79 @@ +-- Resolve the live owner without bootstrapping Game or keeping a retired world. +local Version = require('src.core.GameVersion') +local M = {} +local entering=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +function M.withWorld(game,world,fn,...) + local owner,why=M.game(game);assert(owner,why) + assert(type(world)=='table'and world.isOverworld and world.map and world.player + and owner.data and owner.data.maps and owner.data.maps[world.map.id]==world.map.def,'foreign native world scope') + local previous=entering[owner];entering[owner]=world + local result=pack(pcall(fn,...));entering[owner]=previous + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function M.game(game) + local explicit = game ~= nil + local facade = package.loaded['src.core.Game3'] + if type(facade) == 'table' and type(facade.native) == 'function' then + game = facade.native(game) + if explicit and game == nil then return nil, 'native Game3 facade retired' end + end + game = game or package.loaded['src.core.Game'] + local info = Version.info(Version.get()) + if type(game) ~= 'table' or not info or info.generation ~= 3 then + return nil, 'no native GBA session' + end + return game +end +function M.data(game) + local owner,why=M.game(game);if not owner then return nil,why end + local data=owner.data + if type(data)~='table'or type(data.tilesets)~='table'or type(data.map_tilesets)~='table' + or type(data.constants)~='table'or type(data.constants.gen3Layout)~='table'then + return nil,'native GBA tileset data unavailable' + end + return owner,data +end +-- Admission lookup is also used by map readers that can run before a player +-- is constructed. Edition/data validation stays with each typed consumer. +function M.world(game) + if type(game)~='table'then return nil end + local states = game.stack and game.stack.states + local world=entering[game] + if not world and type(states) == 'table' then + for i = #states, 1, -1 do + if states[i].isOverworld then world = states[i]; break end + end + -- The physical list can contain overlays only. Its persistent world is + -- still a real native admission, independently of the convenience pointer. + if not world then world=game.stack.worldState end + elseif not world then + world = game.overworld + end + return world and world.isOverworld and world or nil +end +function M.resolve(game) + local why;game,why=M.game(game);if not game then return nil,why end + local world=M.world(game) + if not (world and world.isOverworld and world.player and world.map) then + return nil, 'no active native player' + end + if not (game.data and game.data.maps and world.map.def + and game.data.maps[world.map.id] == world.map.def) then + return nil, 'native world belongs to a retired dataset' + end + return game, world, world.player +end +function M.acceptsInput(game, world, player) + local owner, current, actor = M.resolve(game) + if owner ~= game or current ~= world or actor ~= player then return false end + local states = game.stack and game.stack.states + if type(states)=='table' then + local active=type(game.stack.active)=='function' and game.stack:active() or states[#states] + if active~=world then return false end + end + return not world.battleActive and not (world.busy and world:busy()) +end +return M diff --git a/src/core/game3/collision.lua b/src/core/game3/collision.lua new file mode 100644 index 000000000..9cb6e5470 --- /dev/null +++ b/src/core/game3/collision.lua @@ -0,0 +1,103 @@ +-- Compatibility reads over owned map views; permissions use the live native +-- collision owner and never mutate it or run player/warp/script events. +local Session=require('src.compat.gen1.NativeSession') +local Map=require('src.core.game3.map') +local MB=require('src.compat.gen1.MetatileBehavior') +local runtime=require('src.compat.gen1.CollisionRuntime').new() +local Collision={} +local warpBehaviors={} +for _,name in ipairs({'WATER_DOOR','DEEP_SOUTH_WARP','LAVARIDGE_GYM_B1F_WARP','LAVARIDGE_GYM_1F_WARP', + 'AQUA_HIDEOUT_WARP','MT_PYRE_HOLE','MOSSDEEP_GYM_WARP','BRIDGE_OVER_OCEAN', + 'WATER_SOUTH_ARROW_WARP','SHOAL_CAVE_ENTRANCE','STAIRS_OUTSIDE_ABANDONED_SHIP'})do + warpBehaviors[MB.require(name)]=true +end +local function integer(v)return type(v)=='number'and v==v and math.abs(v)=layout.width or y>=layout.height then return nil end + return layout:elevAt(x,y) +end +function Collision.elevationAt(x,y) + local g,w=Session.resolve() + if g and integer(x)and integer(y)and w.map:inBounds(x,y)then return w.map:cellElevation(x,y)end +end +function Collision.canEnter(game,x,y,opts)return runtime:canEnter(game,x,y,opts)end +function Collision.isWalkable(x,y) + local g,w=Session.resolve() + return g~=nil and integer(x)and integer(y)and w.map:inBounds(x,y)and w.map:isWalkableCell(x,y)or false +end +function Collision.isWater(x,y) + local g,w=Session.resolve() + return g~=nil and integer(x)and integer(y)and w.map:inBounds(x,y)and w.map:isWaterCell(x,y)or false +end +function Collision.isGrass(x,y) + local g,w=Session.resolve() + return g~=nil and integer(x)and integer(y)and w.map:inBounds(x,y)and w.map:isGrassCell(x,y)or false +end +function Collision.nextElevation(def,at,x,y,px,py) + local g=Session.resolve();local maps=g and Map._context(g) + if not(maps and maps:isOwned(def)and maps:definition(def.id)==def and integer(x)and integer(y))then + return at,nil + end + return require('src.world.Map').elevationTransition(at, + Collision.elevationOn(def,x,y),Collision.elevationOn(def,px,py)) +end +function Collision.ledgeLanding(game,x,y,dir) + local g,w=Session.resolve(game) + if not(g and integer(x)and integer(y)and w.map:inBounds(x,y))then return nil end + local _,fx,fy,distance=require('src.world.Ledge').approach(w.map,g.data,x,y,dir) + if not fx then return nil end + local delta=require('src.world.Collision').DELTA[dir] + local lx,ly=x+delta[1]*(distance or 2),y+delta[2]*(distance or 2) + if w.map:inBounds(lx,ly)then return lx,ly end +end +function Collision.isWarpMetatileBehavior(behavior) + return type(behavior)=='number'and((behavior>=0x60 and behavior<=0x6f)or behavior==0x71 or warpBehaviors[behavior]==true)or false +end +function Collision.warpAt(x,y) + local def=current();if not(def and Collision.inBounds(x,y))then return nil end + local cell=Collision.cell(x,y);local behavior=Collision.behaviorOn(def,x,y) + if not((cell>=0x60 and cell<=0x7f)or warpBehaviors[behavior])then return nil end + for _,warp in ipairs(def.warps or{})do if warp.x==x and warp.y==y then return warp end end +end +function Collision.clear()runtime:release()end +Collision.release=Collision.clear +setmetatable(Collision,{__index=function(_,key) + if key=='_grid'then return runtime:grid()end + local def=current();if not def then return nil end + if key=='_mapDef'then return def end + if key=='_mapId'then return def.id end + local layout=def.midLayout + if key=='_widthCells'then return layout and layout.width or 0 end + if key=='_heightCells'then return layout and layout.height or 0 end + if key=='_warps'then + local out={};for _,warp in ipairs(def.warps or{})do + local active=Collision.warpAt(warp.x,warp.y);if active then out[warp.y*1024+warp.x]=active end + end + return out + end +end}) +return Collision diff --git a/src/core/game3/encounters.lua b/src/core/game3/encounters.lua new file mode 100644 index 000000000..e5a7dca7d --- /dev/null +++ b/src/core/game3/encounters.lua @@ -0,0 +1,53 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Detached, numeric-ID observations of native encounter tables. No random +-- draws, map loading, private cache installation or independent step rolls. +local Native=require('src.compat.gen1.NativeSession') +local Map=require('src.core.game3.map') +local Ids=require('src.compat.gen1.BattleIds') +local Encounter=require('src.world.Encounter') +local M={} +local function context() + local g=Native.game() + if g and type(g.data)=='table'and type(g.data.encounters)=='table'then return g end +end +function M.ensureLoaded() + local g=context() + return g~=nil and next(g.data.encounters)~=nil or false +end +function M.terrainAt(x,y) + local g,w=Native.resolve() + if not(g and type(x)=='number'and type(y)=='number'and x%1==0 and y%1==0 + and w.map:inBounds(x,y))then return nil end + if require('src.world.Gen3FieldRules').isUnderwaterEncounter(w.map,x,y) + or w.map:isWaterCell(x,y)then return 'water'end + if w.map:isEncounterCell(x,y)then return 'land'end +end +local function area(value,ids) + if value==nil then return nil end + if type(value)~='table'or type(value.slots)~='table'then return nil,'invalid native encounter area'end + local out={rate=value.rate,slots={}} + for i,slot in ipairs(value.slots)do + local species=type(slot)=='table'and ids:read('pokemon',slot.species) + if not species then return nil,'native encounter species has no cartridge index'end + out.slots[i]={species=species,level=slot.level,minLevel=slot.min or slot.minLevel, + maxLevel=slot.max or slot.maxLevel} + end + return out +end +function M.tableFor(id) + local g=context();if not(g and type(id)=='string')then return nil end + local maps=Map._context(g);local def=maps and maps:definition(id) + local raw=def and g.data.maps[def.nativeId] + if not raw then return nil end + local native=Encounter.forMap(g.data,raw,def.nativeId,nil,g.save) + if not native then return nil end + local ids=Ids.new(g.data);local out={} + for _,names in ipairs{{'land','grass'},{'water','water'},{'rocks','rock'},{'fishing','fish'}}do + local row,why=area(native[names[2]],ids) + if why then return nil,why end + out[names[1]]=row + end + return out +end +M.table_for=M.tableFor +return M diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..01a09ee8a 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1309,12 +1309,20 @@ local function stampAudioOwners(data, name, registry) owners[key] = map end +-- Optional collision observations retire after wrapper unwind, without +-- loading compatibility modules on an ordinary native boot. +local function retireCompatibilityCollision() + local module = package.loaded["src.core.game3.collision"] + if type(module) == "table" and type(module.release) == "function" then module.release() end +end + function Loader:load(data) -- 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 -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() + retireCompatibilityCollision() self.baseData = data -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later @@ -1654,6 +1662,7 @@ end -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() local undone = select(1, ModSeams.unwindAll()) + retireCompatibilityCollision() pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) Runtime.currentMod = nil diff --git a/src/world/Encounter.lua b/src/world/Encounter.lua index f9cd5686a..596e15328 100644 --- a/src/world/Encounter.lua +++ b/src/world/Encounter.lua @@ -286,10 +286,24 @@ local gen4Views = setmetatable({}, { __mode = "k" }) -- and what shape they arrive in. Gen 1, 2 and 3 fall through it unchanged: -- they have no `map.encounters`, so the id is still `mapId`, and no -- `grassRate`, so the table is returned exactly as it was. +local gen3WildSetVar={firered=0x4024,leafgreen=0x4024,emerald=0x403e} +-- Alternate GBA wild headers use the live native script variable, not RNG. +-- Header zero is the base table; an out-of-range script value resets to it. +function Encounter.gen3Variant(def,save,edition) + if not(type(def)=='table'and type(def.alternates)=='table')then return def end + local key=gen3WildSetVar[edition] + local n=key and save and save.gen3Vars and save.gen3Vars[key] + if type(n)=='number'and n%1==0 and n>=1 and n<=#def.alternates then return def.alternates[n]end + return def +end + function Encounter.forMap(data, mapDef, mapId, hour, save) local all = data and data.encounters if not all then return nil end local def = all[(mapDef and mapDef.encounters) or mapId] + if data.constants and data.constants.gen3Layout then + def=Encounter.gen3Variant(def,save,require('src.core.GameVersion').get()) + end if data.field and data.field.gen2Swarms then def=require("src.world.Gen2Swarms").forMap(data,save,mapDef,mapId,def) end diff --git a/src/world/Ledge.lua b/src/world/Ledge.lua new file mode 100644 index 000000000..44ea4fad6 --- /dev/null +++ b/src/world/Ledge.lua @@ -0,0 +1,37 @@ +-- 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. +-- Native classification extracted from OverworldController; original notices retained. + +-- Shared native ledge approach classification. No movement or collision hooks. +-- Existing dataset overrides win, including the later-edition jump distances. +local M={} +local directions={north='up',south='down',west='left',east='right', + up='up',down='down',left='left',right='right'} +local gba={[0x38]='right',[0x39]='left',[0x3a]='up',[0x3b]='down', + [0x3c]='up',[0x3d]='up',[0x3e]='down',[0x3f]='down'} +local bw={[0x72]='right',[0x73]='left',[0x74]='up',[0x75]='down'} +function M.approach(map,data,x,y,dir) + local delta=require('src.world.Collision').DELTA[dir] + if not(map and delta)then return true end + local fx,fy=x+delta[1],y+delta[2] + if not map:inBounds(fx,fy)then return true end + local ts=map.tileset + local rules=ts and ts.ledgeBehaviours or data and data.field and data.field.ledgeBehaviours + if map.def and map.def.gen5 then rules=bw end + if not rules and ts and tonumber(ts.blockTiles)==2 and tonumber(ts.blockCells)==1 and ts.collision then + rules=gba + end + if not rules then return false end -- older pair/standing-cell rules remain native + local behavior=map:cellBehaviour(fx,fy) + if behavior and directions[rules[behavior]]==dir then + if map.def and map.def.generation==4 and behavior>=0x5a and behavior<=0x5d then + return true,fx,fy,3,true + end + return true,fx,fy + end + return true +end +return M diff --git a/src/world/Map.lua b/src/world/Map.lua index f8282c8bd..f21c864a4 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -801,11 +801,16 @@ end -- carries you between levels. Reported from play: "unable to walk down the -- stairs into the beach's low water" -- Mossdeep's stairs are 0 and the -- shallow water 3; keeping the upper ground's 4 on the stairs walled it off. +function Map.elevationTransition(at, there, previous) + if there == nil or there == ELEVATION_UNDER_BRIDGE or previous == ELEVATION_UNDER_BRIDGE then + return at, nil + end + return there, there ~= ELEVATION_ANY and there or nil +end function Map:elevationAfter(at, cx, cy, px, py) - local there = self:cellElevation(cx, cy) - if there == nil or there == ELEVATION_UNDER_BRIDGE then return at end - if px and py and self:cellElevation(px, py) == ELEVATION_UNDER_BRIDGE then return at end - return there + local previous = px and py and self:cellElevation(px, py) or nil + local elevation = Map.elevationTransition(at, self:cellElevation(cx, cy), previous) + return elevation end -- A CELL A SCRIPT SHUT, as opposed to one the shipped map ships shut. diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..13ec12e71 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -5565,48 +5565,6 @@ function OverworldState:startWaterAnim(cx, cy, onDone) self.waterAnim = { x = cx, y = cy, frames = 40, total = 40, onDone = onDone } end --- TWO VOCABULARIES FOR THE SAME FOUR DIRECTIONS, and they never met. --- --- The tileset's ledge table names its directions the way the CARTRIDGE does: --- MB_JUMP_EAST, MB_JUMP_SOUTH -- so the extracted table reads --- `[0x3B] = "south"`. Movement here names them the way the PLAYER'S INPUT --- does: up, down, left, right. The test below compared one against the other --- and, of course, never matched -- and then took the early return that says --- "a dataset that names its ledges by behaviour has said all it has to say", --- so it did not fall through to anything either. --- --- The result was the exact bug the table was added to fix, still standing --- with the table in place: every ledge in Hoenn a wall, no route down off any --- terrace in the region. Both halves were right on their own and the join --- was never driven. --- --- So the join is one function, and it accepts either word. A dataset or a --- mod may say "north" or "up" and mean the same thing. -local LEDGE_DIRECTIONS = { - north = "up", south = "down", west = "left", east = "right", - up = "up", down = "down", left = "left", right = "right", -} - -local function ledgeDirection(word) - return word and LEDGE_DIRECTIONS[word] or nil -end - --- The eight Gen 3 ledge behaviours, by the direction you must be walking to --- take them (metatile_behaviors.h). The four diagonals are listed under the --- axis a cardinal input can satisfy: Emerald lets a diagonal ledge be entered --- from either of its two components, and up/down is the one this engine's --- movement can express. -local GEN3_LEDGE_BEHAVIOURS = { - [0x38] = "right", -- MB_JUMP_EAST - [0x39] = "left", -- MB_JUMP_WEST - [0x3A] = "up", -- MB_JUMP_NORTH - [0x3B] = "down", -- MB_JUMP_SOUTH - [0x3C] = "up", -- MB_JUMP_NORTHEAST - [0x3D] = "up", -- MB_JUMP_NORTHWEST - [0x3E] = "down", -- MB_JUMP_SOUTHEAST - [0x3F] = "down", -- MB_JUMP_SOUTHWEST -} - -- Ledge hops (data/tilesets/ledge_tiles.asm): standing tile + ledge tile -- in front + matching input direction -> jump two cells. function OverworldState:checkLedgeHop(dir) @@ -5617,57 +5575,13 @@ function OverworldState:checkLedgeHop(dir) if not self.map:inBounds(fx, fy) then return false end local front = self.map:cellTile(fx, fy) - -- A GEN 3 LEDGE IS A PROPERTY OF THE TILE IN FRONT, not of a pair. - -- - -- Gen 1 and Gen 2 decide a hop from (tile you stand on, tile in front, - -- direction) because their tilesets have no room to say more. Gen 3 says - -- it outright: the metatile's behaviour byte IS "you jump this, eastward". - -- Nothing derived a pair table for Hoenn, so field.ledges was empty and - -- every ledge in the region was simply a wall -- there was no route down - -- off any terrace in the game. - -- - -- cellBehaviour rather than cellTile because a ledge is blocked, and - -- cellTile answers $FF for anything blocked. - -- - -- ...AND NOTHING EVER SUPPLIED THE TABLE. `field.ledgeBehaviours` is read - -- here and written nowhere: no importer produces it and no dataset ships - -- one, so `byBehaviour` was nil on every map in the game and Hoenn fell - -- through to the pair tables below -- which are Gen 1 and Gen 2 rows keyed - -- by tile id, and match nothing on a Gen 3 pair. Every ledge in Hoenn was - -- a wall you could not hop. - -- - -- The cartridge's own answer is a fixed run of eight behaviour bytes - -- (metatile_behaviors.h, MB_JUMP_EAST through MB_JUMP_SOUTHWEST), so it is - -- stated here rather than derived. A dataset that ships its own table - -- still wins; this only fills the gap. Gated on the pair shape -- 2x2 - -- tiles to a metatile, one collision cell -- because those byte values - -- mean something else entirely in Kanto and Johto. - local byBehaviour = self.map.tileset and self.map.tileset.ledgeBehaviours - or Game.data.field and Game.data.field.ledgeBehaviours - if not byBehaviour then - local ts = self.map.tileset - if ts and tonumber(ts.blockTiles) == 2 and tonumber(ts.blockCells) == 1 - and ts.collision then - byBehaviour = GEN3_LEDGE_BEHAVIOURS - end - end - if byBehaviour then - local behaviour = self.map:cellBehaviour(fx, fy) - if behaviour and ledgeDirection(byBehaviour[behaviour]) == dir then - -- PLATINUM'S DOUBLE JUMP, the Distortion World's: `JUMP_*_TWICE` - -- (0x5A..0x5D) plays MOVEMENT_ACTION_JUMP_DISTORTION_WORLD_*, which - -- moves 2 units a frame for 24 frames -- THREE tiles, against an - -- ordinary ledge's 16 frames and two -- and passes SEQ_NONE, so it is - -- silent (pokeplatinum src/unk_020655F4.c). All 224 such tiles are in - -- the D34 rooms; taken as ordinary ledges they landed a tile short. - if (self.map.def and self.map.def.generation) == 4 - and behaviour >= 0x5A and behaviour <= 0x5D then - return self:startLedgeHop(dir, fx, fy, 3, true) - end - return self:startLedgeHop(dir, fx, fy) + local handled, x, y, distance, silent = require("src.world.Ledge").approach( + self.map, Game.data, p.cellX, p.cellY, dir) + if handled then + if x then + if distance then return self:startLedgeHop(dir, x, y, distance, silent) end + return self:startLedgeHop(dir, x, y) end - -- a dataset that names its ledges by behaviour has said all it has to - -- say; the pair tables below are the older generations' answer return false end if self:gen2LedgeAllows(standing, dir) then diff --git a/tests/engine/native_companion_terrain.lua b/tests/engine/native_companion_terrain.lua new file mode 100644 index 000000000..f6603db48 --- /dev/null +++ b/tests/engine/native_companion_terrain.lua @@ -0,0 +1,164 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local C=require('src.core.game3.collision') +local E=require('src.core.game3.encounters') +local Map=require('src.core.game3.map') +local Ledge=require('src.world.Ledge') +local NativeMap=require('src.world.Map') +local previous=package.loaded['src.core.Game'] +T.eq(previous,nil,'imports do not create a game') +T.eq(E.ensureLoaded(),false);T.eq(E.tableFor('unknown'),nil);T.eq(E.terrainAt(0,0),nil) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.map-runtime.fixture').new(edition) + local g,w,m=f.game,f.world,f.map + w.player={cellX=0,cellY=0,elevation=6};w.entities={w.player} + g.save={player={},flags={},gen3Vars={}} + package.loaded['src.core.Game']=g + local def=assert(Map.currentDef()) + for _,from in ipairs{0,3,6,15}do for _,to in ipairs{0,1,3,6,15}do + m.elevationPatch[1]=from;m.elevationPatch[2]=to;m:noteGridChange() + local first,second=C.nextElevation(def,6,1,0,0,0) + local keep=from==15 or to==15 + T.eq(first,keep and 6 or to,'height follows stairs and bridge transitions') + local expected + if not keep and to~=0 then expected=to end + T.eq(second,expected,'secondary sprite elevation omits zero and bridge markers') + T.eq(select('#',C.nextElevation(def,6,1,0,0,0)),2,'source query keeps both return slots') + T.eq(m:elevationAfter(6,1,0,0,0),first,'native mover shares the transition') + T.eq(select('#',m:elevationAfter(6,1,0,0,0)),1,'native method keeps its return arity') + end end + m.elevationPatch[2]=3;m:noteGridChange() + T.eq(C.nextElevation(def,6,1,0),3,'an omitted previous cell still accepts the destination height') + T.eq(C.nextElevation({},6,1,0,0,0),6,'foreign map is not admitted') + T.eq(C.nextElevation(def,6,0/0,0,0,0),6) + local directions={'right','left','up','down','up','up','down','down'} + m.tileset.collision={};m.def.behaviorCells=nil + local W=require('src.world.OverworldController') + local gameSlot,oldGame + for i=1,100 do + local name,value=debug.getupvalue(W.checkLedgeHop,i) + if name=='Game'then gameSlot,oldGame=i,value;break end + end + assert(gameSlot);debug.setupvalue(W.checkLedgeHop,gameSlot,g) + local call + w.startLedgeHop=function(_,...)call={n=select('#',...),...};return true end + w.gen2LedgeAllows=function()return false end + for n=1,8 do + for _,dir in ipairs{'up','down','left','right'}do + -- Center is (1,0); only horizontal hops fit this tiny real map. + m.tileset.collision[m:blockAt(1,0)+1]=0x37+n + local originX=dir=='left'and 2 or 0 + local lx,ly=C.ledgeLanding(g,originX,0,dir) + local matching=(dir=='right'or dir=='left')and directions[n]==dir + T.eq(lx,matching and (dir=='right'and 2 or 0)or nil,'ledge facing and landing bounds') + T.eq(ly,matching and 0 or nil) + w.player.cellX,w.player.cellY=originX,0 + call=nil + T.eq(W.checkLedgeHop(w,dir),matching==true,'actual native input dispatch keeps its hop decision') + if matching then + T.eq(call[1],dir);T.eq(call[2],1);T.eq(call[3],0) + T.eq(call.n,3,'ordinary native hop callback preserves its argument count') + else T.eq(call,nil)end + end + end + debug.setupvalue(W.checkLedgeHop,gameSlot,oldGame) + m.tileset.ledgeBehaviours={[0x38]='west'} + m.tileset.collision[m:blockAt(1,0)+1]=0x38 + T.eq(C.ledgeLanding(g,0,0,'right'),nil,'native dataset direction override wins') + T.eq(C.ledgeLanding(g,2,0,'left'),0) + T.eq(C.ledgeLanding(g,0,0,'bad'),nil) + T.eq(C.ledgeLanding(g,0/0,0,'right'),nil) + T.eq(C.ledgeLanding(g,1,0,'right'),nil,'never returns an off-map landing') + -- Pure source observations include current native patches and grass differs + -- from cave/desert encounter terrain. They do not execute movement hooks. + m.tileset.ledgeBehaviours=nil + m.grassTiles={[0]=true};m.encounterTiles={[0]=true,[1]=true} + m.collisionPatch={};m.elevationPatch={} + for y=0,1 do for x=0,2 do + T.eq(C.isGrass(x,y),m:isGrassCell(x,y)) + local water=m:isWaterCell(x,y) + local expected=water and 'water'or m:isEncounterCell(x,y)and 'land'or nil + T.eq(E.terrainAt(x,y),expected) + end end + T.eq(C.isGrass(3,0),false);T.eq(E.terrainAt(3,0),nil) + T.eq(E.terrainAt(math.huge,0),nil);T.eq(E.terrainAt(1.5,0),nil) + m.tileset.collision[1],m.tileset.collision[2],m.tileset.collision[3]=2,3,0x15 + m.grassTiles={[2]=true};m.encounterTiles={[2]=true,[3]=true} + m:setGen3Cells({{x=0,y=0,mid=0,coll=0,elev=3},{x=1,y=0,mid=1,coll=0,elev=3}, + {x=2,y=0,mid=2,coll=0,elev=1}}) + T.eq(C.isGrass(0,0),true);T.eq(C.isGrass(1,0),false) + T.eq(E.terrainAt(0,0),'land');T.eq(E.terrainAt(1,0),'land','caves need encounters without grass cover') + T.eq(E.terrainAt(2,0),'water') + m:setGen3Cells({{x=0,y=0,coll=1}}) + T.eq(E.terrainAt(0,0),nil,'blocked grass is not encounter ground') + m.def.mapType='UNDERWATER';m.tileset.collision[1]=0x2a + m:setGen3Cells({{x=0,y=0,coll=0}}) + T.eq(E.terrainAt(0,0),'water','seaweed uses native underwater rules, even without surfing') + m.tileset.collision[1]=0 + T.eq(E.terrainAt(0,0),nil,'bare sea floor has no encounter terrain') + m.def.mapType='ROUTE' + g.data.pokemon={COMMON={index=1},SPARSE={index=277,dex=252}} + g.data.constants.speciesOrder={[1]='COMMON',[277]='SPARSE'} + local base={grass={rate=21,slots={{species='SPARSE',min=2,max=9}}}, + water={rate=15,slots={{species='COMMON',level=12}}}, + rock={rate=10,slots={{species='COMMON',min=3,max=4}}}, + fish={rate=25,slots={{species='SPARSE',min=5,max=6}}}} + local alternate={grass={rate=9,slots={{species='COMMON',min=7,max=8}}}} + base.alternates={alternate} + g.data.encounters={NATIVE_A=base} + T.eq(E.ensureLoaded(),true) + local view=assert(E.tableFor(f.mapId)) + T.eq(view.land.slots[1].species,277,'species uses cartridge slot, never National Dex') + T.eq(view.land.slots[1].minLevel,2);T.eq(view.land.slots[1].maxLevel,9) + T.eq(view.water.slots[1].level,12);T.eq(view.rocks.slots[1].minLevel,3) + T.eq(view.fishing.slots[1].species,277) + T.eq(E.tableFor('NATIVE_A').land.rate,21,'native map names resolve to the same owned map') + T.eq(E.tableFor(m.def.group..':'..m.def.number).land.rate,21,'ROM map slot aliases resolve') + T.eq(E.tableFor('unknown'),nil) + view.land.slots[1].species=1;view.land.rate=0 + T.eq(base.grass.slots[1].species,'SPARSE');T.eq(base.grass.rate,21) + local variable=({firered=0x4024,leafgreen=0x4024,emerald=0x403e})[edition] + if variable then + g.save.gen3Vars[variable]=1 + T.eq(E.tableFor(f.mapId).land.rate,9,'live script state selects alternate header') + T.eq(require('src.world.Encounter').forMap(g.data,m.def,m.id,nil,g.save),alternate, + 'native wild encounters use the same effective table') + for _,invalid in ipairs{-1,2,65535,0/0,math.huge,1.5}do + g.save.gen3Vars[variable]=invalid + T.eq(E.tableFor(f.mapId).land.rate,21,'invalid alternate resets to base') + end + g.save.gen3Vars[variable]=0 + end + local oldRandom=love.math.random + love.math.random=function()error('observation consumed native RNG')end + T.check(pcall(function()E.ensureLoaded();E.tableFor(f.mapId);E.terrainAt(0,0)end)) + love.math.random=oldRandom + base.grass.slots[1].species='MISSING' + local missing,why=E.tableFor(f.mapId) + T.eq(missing,nil);T.check(why and why:find('cartridge index',1,true)~=nil) + g.data.encounters={NATIVE_A={grass={rate=3,slots={{species='COMMON',min=1,max=1}}}}} + T.eq(E.tableFor(f.mapId).land.rate,3,'native table replacement is observed immediately') + local raw=g.data.maps.NATIVE_A;g.data.maps.NATIVE_A={} + T.eq(E.tableFor(f.mapId),nil,'retired map identity cannot expose old encounters') + T.eq(C.nextElevation(def,6,1,0,0,0),6) + g.data.maps.NATIVE_A=raw + g.stack.states={{isTitle=true}} + T.eq(C.isGrass(0,0),false);T.eq(E.terrainAt(0,0),nil) + C.release();Map.release();f.views:release() +end +-- The native classifier still preserves pair rules, explicit overrides and +-- the later-edition Distortion World and BW behavior spaces. +do + local map={def={},tileset={},inBounds=function()return true end,cellBehaviour=function()return 0x5a end} + T.eq(Ledge.approach(map,{},0,0,'right'),false,'older pair rules retain native fallback') + map.def.generation=4;map.tileset.ledgeBehaviours={[0x5a]='east'} + local handled,x,y,distance,silent=Ledge.approach(map,{},0,0,'right') + T.eq(handled,true);T.eq(x,1);T.eq(y,0);T.eq(distance,3);T.eq(silent,true) + map.def.gen5=true;map.cellBehaviour=function()return 0x75 end + local _,bx,by=Ledge.approach(map,{},0,0,'down') + T.eq(bx,0);T.eq(by,1,'BW rules take precedence over imported stand-in tileset') +end +package.loaded['src.core.Game']=previous +T.finish('native companion terrain and encounter observations') diff --git a/tests/engine/native_companion_terrain_lifecycle.lua b/tests/engine/native_companion_terrain_lifecycle.lua new file mode 100644 index 000000000..cddcc7019 --- /dev/null +++ b/tests/engine/native_companion_terrain_lifecycle.lua @@ -0,0 +1 @@ +dofile('tools/companion-terrain/lifecycle-check.lua') diff --git a/tests/engine/native_field_collision.lua b/tests/engine/native_field_collision.lua new file mode 100644 index 000000000..86c6f0863 --- /dev/null +++ b/tests/engine/native_field_collision.lua @@ -0,0 +1 @@ +dofile('tools/field-collision/check.lua') diff --git a/tools/companion-terrain/lifecycle-check.lua b/tools/companion-terrain/lifecycle-check.lua new file mode 100644 index 000000000..fe5331eaf --- /dev/null +++ b/tools/companion-terrain/lifecycle-check.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Json=require('src.link.Json') +local api=tonumber(os.getenv('SESSION_RUNTIME_API'))or require('src.core.Version').modApi +require('src.core.GameVersion').set('emerald') +local files={} +for _,id in ipairs{'terrain_owner','native_peer','failed_terrain_owner'}do + files['mods/'..id..'/manifest.json']=Json.encode({id=id,name=id,version='1.0.0',api=api, + entry='main.lua',games={'gen3'},permissions={'engine_internals'}}) +end +files['mods/terrain_owner/main.lua']=[[ + local mod=...;local C=require('src.core.game3.collision');local E=require('src.core.game3.encounters') + mod.exports.grass=C.isGrass;mod.exports.tableFor=E.tableFor + C.isGrass=function(...)return mod.exports.grass(...)end +]] +files['mods/native_peer/main.lua']=[[ + local mod=...;mod.events:on('game.ready',function(p)mod.exports.game=p.game end) +]] +files['mods/failed_terrain_owner/main.lua']=[[ + local E=require('src.core.game3.encounters') + E.tableFor=function()error('failed owner survived')end + error('intentional terrain entry failure') +]] +local run=T.sdk.loadMods({'mods/terrain_owner','mods/native_peer','mods/failed_terrain_owner'}, + {fs=T.sdk.memfs(files),data=T.fixtures.fresh()}) +T.eq(#run.errors,1) +local f=require('tools.map-runtime.fixture').new('emerald') +local g,w=f.game,f.world;g.save={player={},flags={}};g.mods=run.loader +w.player={cellX=0,cellY=0,elevation=6};w.entities={w.player} +g.data.pokemon={COMMON={index=1}};g.data.constants.speciesOrder={[1]='COMMON'} +g.data.encounters={NATIVE_A={grass={rate=21,slots={{species='COMMON',min=3,max=5}}}}} +package.loaded['src.core.Game']=g;run.loader.game=g +local E=require('src.core.game3.encounters');local C=require('src.core.game3.collision') +local owner=run.loader.exports.terrain_owner +T.eq(E.tableFor,owner.tableFor,'failed entry rolls back the query it replaced') +T.eq(E.tableFor(f.mapId).land.slots[1].species,1) +T.eq(C.isGrass(0,0),f.map:isGrassCell(0,0)) +run.loader.events:emit('game.ready',{game=g}) +T.eq(run.loader.exports.native_peer.game,g,'native peer retains the actual game identity') +require('src.mods.Loader').teardown() +T.eq(C.isGrass,owner.grass,'teardown restores the successful owner wrapper') +T.eq(E.tableFor(f.mapId).land.rate,21,'native encounter data outlives source wrapper retirement') +T.eq(g.overworld,w);T.eq(g.data.maps.NATIVE_A,f.map.def) +require('src.core.game3.map').release();C.release();f.views:release();run.release() +package.loaded['src.core.Game']=nil +T.finish('terrain SDK rollback and native peer API '..api) diff --git a/tools/field-collision/check.lua b/tools/field-collision/check.lua new file mode 100644 index 000000000..8d0d4cfbe --- /dev/null +++ b/tools/field-collision/check.lua @@ -0,0 +1,101 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local C=require('src.core.game3.collision') +local Map=require('src.core.game3.map') +local Native=require('src.world.Collision') +local Tiles=require('src.render.Gen3Tiles') +local Behavior=require('src.compat.gen1.MetatileBehavior') +T.eq(package.loaded['src.core.Game'],nil,'collision import does not bootstrap a game') +T.eq(C.behavior(0,0),nil);T.eq(C.canEnter(nil,0,0),false) +local previous=package.loaded['src.core.Game'] +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + local f=require('tools.map-runtime.fixture').new(edition) + local g,w,field=f.game,f.world,f.map + w.player={cellX=0,cellY=0,px=0,py=0,elevation=6,surfing=false} + w.entities={w.player};g.save={player={},flags={}} + package.loaded['src.core.Game']=g + local def=assert(Map.currentDef()) + T.eq(C._mapId,def.id);T.eq(C._mapDef,def);T.eq(C._widthCells,4);T.eq(C._heightCells,2) + T.eq(C.inBounds(3,0),false,'art padding is not native walkable space') + T.eq(C.cell(3,0),255);T.eq(C.elevationAt(3,0),nil) + T.eq(C._grid,C._grid,'classified array is cached by owned layout revision') + local sets=g.data.map_tilesets;local pair=g.data.tilesets[field.def.tileset] + local tiles=Tiles.new({primary=sets[pair.primaryKey],secondary=sets[pair.secondaryKey]},g.data.constants.gen3Layout) + for y=0,1 do for x=0,2 do + local raw=tiles:sourceBehavior(field:blockAt(x,y)) + T.eq(C.behavior(x,y),Behavior.fromRaw(edition,raw),'source behavior stays distinct from native numbering') + T.eq(C.elevationAt(x,y),field:cellElevation(x,y)) + for _,dir in ipairs({'up','down','left','right'})do + local actual,why=C.canEnter(g,x,y,{dir=dir}) + local expected,reason=Native.mayEnter(field,w.entities,w.player,x,y,dir) + T.eq(actual,expected);T.eq(why,reason) + end + end end + T.eq(C.warpAt(0,1),nil,'header-only warps on ordinary ground remain inactive') + local bank=sets[pair.primaryKey] + local key=bank.sourceAttributeBytes==4 and 'sourceAttributes'or'attributes' + local originalBytes=bank[key];local offset=bank.sourceAttributeBytes*2 + bank[key]=originalBytes:sub(1,offset)..string.char(105)..originalBytes:sub(offset+2) + local warp=assert(C.warpAt(0,1)) + T.eq(warp.destMap,f.neighborId,'active observations preserve canonical warp destinations') + T.eq(C._warps[1024],warp) + bank[key]=originalBytes;T.eq(C.warpAt(0,1),nil,'behavior changes retire a formerly live warp observation') + local old=C._grid;field:setGen3Cells({{x=0,y=0,mid=1,coll=0,elev=3}}) + T.eq(C.elevationAt(0,0),3);T.check(C._grid~=old,'native terrain edits invalidate classified observations') + T.eq(C.behavior(0,0),Behavior.fromRaw(edition,tiles:sourceBehavior(1))) + local mid,pairId=Map.worldMidAt(3,0,def) + local neighbor=Map.neighborList[1].def + T.eq(pairId,neighbor.pair) + T.eq(C.worldBehavior(3,0),C.behaviorOn(neighbor,0,1),'connected behavior follows native extent and offset') + T.eq(C.worldBehavior(-1,-1),C.behaviorOn(def,-1,-1)or C.behaviorOn(def,0,1), + 'border observations use the current source bank') + local actor=w.player;local x,y,elev,surf=actor.cellX,actor.cellY,actor.elevation,actor.surfing + C.canEnter(g,0,1,{fromX=1,fromY=1,elevation=3,surfing=true,dir='left'}) + T.eq(actor.cellX,x);T.eq(actor.cellY,y);T.eq(actor.elevation,elev);T.eq(actor.surfing,surf) + T.eq(C.canEnter(g,0/0,0),false);T.eq(C.canEnter(g,1.5,0),false) + T.eq(C.behavior(0/0,0),nil) + -- Observed layout edits remain detached from native collision permissions. + local layout=Map.currentDef().midLayout + local nativeAllowed=Native.mayEnter(field,w.entities,actor,0,1,'down') + layout:applyOverride(0,1,0,7,0) + T.eq(C.cell(0,1),7) + T.eq(C.canEnter(g,0,1,{dir='down'}),nativeAllowed,'observational overrides cannot rewrite native permissions') + T.eq(C._grid[5],7,'classified array tracks owned override revisions') + -- A live native actor must not collide with itself when the request asks + -- about a hypothetical origin/surf/elevation instead of its current one. + local allowed,reason=C.canEnter(g,0,1,{fromX=1,fromY=1,elevation=6,surfing=true,dir='left'}) + local hypothetical={cellX=1,cellY=1,elevation=6,surfing=true} + local expected,why=Native.mayEnter(field,{},hypothetical,0,1,'left') + T.eq(allowed,expected);T.eq(reason,why) + -- Source readers see replaced bank records, not old renderer/cached bytes. + local original=sets[pair.primaryKey];local replacement={} + for k,v in pairs(original)do replacement[k]=v end + replacement.attributes=string.rep('\0',#original.attributes) + if replacement.sourceAttributes then replacement.sourceAttributes=string.rep('\0',#replacement.sourceAttributes)end + sets[pair.primaryKey]=replacement + T.eq(C.behavior(0,0),Behavior.fromRaw(edition,0)) + local activeDef=g.data.maps[field.id];g.data.maps[field.id]={} + T.eq(C.canEnter(g,0,1),false,'a world from a retired dataset cannot grant movement') + T.eq(C.elevationAt(0,0),nil) + g.data.maps[field.id]=activeDef + w.player={cellX=0,cellY=0,elevation=3};w.entities={w.player} + g.stack.states={{isTitle=true}} + T.eq(C._mapDef,nil);T.eq(C.behavior(0,0),nil);T.eq(C.canEnter(g,0,1),false) + C.release();Map.release();f.views:release() +end +do + local f=require('tools.map-runtime.fixture').new('firered') + f.world.player={cellX=0,cellY=0,elevation=6};f.world.entities={f.world.player} + package.loaded['src.core.Game']=f.game + local held=C._grid;C.behavior(0,0) + local weak=setmetatable({f.map,f.game.data.map_tilesets.P},{__mode='v'}) + require('src.mods.Loader').teardown() + f.views:release();f=nil;package.loaded['src.core.Game']=nil + collectgarbage('collect');collectgarbage('collect') + T.eq(weak[1],nil,'held classified cells cannot retain a native map after teardown') + T.eq(weak[2],nil,'retired behavior readers cannot retain a native source bank') + T.check(type(held[1])=='number','detached observations remain readable after retirement') +end +package.loaded['src.core.Game']=previous +T.finish('native field collision interface')