diff --git a/.github/workflows/native-cartridge-views.yml b/.github/workflows/native-cartridge-views.yml new file mode 100644 index 000000000..5ca95c26a --- /dev/null +++ b/.github/workflows/native-cartridge-views.yml @@ -0,0 +1,41 @@ +name: Native cartridge observation views +on: + pull_request: + paths: + - 'src/battle/Gen3SafariText.lua' + - 'src/battle/Gen3Battle.lua' + - 'src/core/game3/mb.lua' + - 'src/core/game3/constants.lua' + - 'src/import/gba/field_label_facts.lua' + - 'tests/engine/native_constant_queries.lua' + - 'src/core/game3/rom_text.lua' + - 'src/core/game3/battle/profile.lua' + - 'src/core/game3/battle/healthbox.lua' + - 'tests/engine/native_safari_text_views.lua' + - 'tests/engine/native_rom_text_ir.lua' + - 'tests/engine/compatibility_cell_semantics.lua' + - '.github/workflows/native-cartridge-views.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: native-cartridge-views-${{ github.ref }} + cancel-in-progress: true +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit + - name: Native authority and retired-view boundaries + run: | + luajit tests/engine/native_safari_text_views.lua + luajit tests/engine/native_constant_queries.lua + luajit tests/engine/compatibility_cell_semantics.lua + luajit tests/engine/native_rom_text_ir.lua + luajit tests/engine/native_battle_chrome.lua + - name: Existing native Gen2 presentation + run: | + luajit tests/engine/gen2_battle_window.lua + luajit tests/engine/gen2_battle_scene.lua diff --git a/.github/workflows/native-file-picker.yml b/.github/workflows/native-file-picker.yml new file mode 100644 index 000000000..454070b8d --- /dev/null +++ b/.github/workflows/native-file-picker.yml @@ -0,0 +1,31 @@ +name: Native desktop file selection +on: + pull_request: + paths: + - 'src/core/FilePicker.lua' + - 'src/core/HostShell.lua' + - 'src/import/RomImporter.lua' + - 'tests/engine/native_file_picker.lua' + - 'tests/*picker*' + - 'tests/rom_importer_android_mod_pick_test.lua' + - '.github/workflows/native-file-picker.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: native-file-picker-${{ github.ref }} + cancel-in-progress: true +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit + - name: Model desktop commands without opening dialogs + run: luajit tests/engine/native_file_picker.lua + - name: Preserve native pointer release and mobile fallback + run: | + luajit tests/parity_picker_pointer_grab.lua + luajit tests/rom_importer_no_picker_test.lua + luajit tests/rom_importer_android_mod_pick_test.lua diff --git a/.github/workflows/native-flight.yml b/.github/workflows/native-flight.yml new file mode 100644 index 000000000..63a600113 --- /dev/null +++ b/.github/workflows/native-flight.yml @@ -0,0 +1,41 @@ +name: Native region picker and flight boundary +on: + pull_request: + paths: + - 'src/compat/gen1/NativeRegionMap.lua' + - 'src/world/Gen3FlyTargets.lua' + - 'src/world/Gen3FlyArrival.lua' + - 'src/core/game3/field_effects.lua' + - 'src/render/Transition.lua' + - 'tests/engine/native_fly_arrival.lua' + - 'src/ui/Gen3RegionMap.lua' + - 'src/ui/Gen3RsRegionMap.lua' + - 'src/ui/game3/region_map.lua' + - 'src/ui/game3/rse/region_map.lua' + - 'src/core/game3/field.lua' + - 'src/world/OverworldController.lua' + - 'tests/engine/native_region_map_observations.lua' + - 'tests/engine/native_fly_boundary.lua' + - '.github/workflows/native-flight.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: native-flight-${{ github.ref }} + cancel-in-progress: true +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit + - name: Native observations and once-only departure + run: | + luajit tests/engine/native_region_map_observations.lua + luajit tests/engine/native_fly_boundary.lua + luajit tests/engine/native_fly_arrival.lua + luajit tests/engine/native_field_observations.lua + - name: Existing native admission and other generations + run: | + luajit tests/engine/gen2_battle_scene.lua diff --git a/.github/workflows/native-phase-scopes.yml b/.github/workflows/native-phase-scopes.yml new file mode 100644 index 000000000..778065b59 --- /dev/null +++ b/.github/workflows/native-phase-scopes.yml @@ -0,0 +1,15 @@ +name: Native coroutine phase ownership +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit + - run: luajit tests/engine/native_coroutine_phase_scopes.lua + - run: luajit tests/engine/native_game_coroutine_scopes.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/docs/native-battle-animation-runtime.md b/docs/native-battle-animation-runtime.md new file mode 100644 index 000000000..5baca515a --- /dev/null +++ b/docs/native-battle-animation-runtime.md @@ -0,0 +1,72 @@ +# Native battle animation bridge + +`src.core.game3.battle.anim` presents the admitted GBA battle through its native +`BattleState`, `Gen3MoveAnim` and `Gen3BallAnim` owners. It does not load another +battle engine or rewrite a mod package. FireRed, LeafGreen, Emerald, Ruby and +Sapphire use the same boundary and their own imported identifiers and records. + +## Shared clock and ownership + +The native update opens one presentation phase. A source `Anim.update()` can +advance that phase while a custom ending or pose holds the combat update. +Repeated source calls and a later native base call consume the same display +steps. The native dt drives sprite animations. Outside that phase, source +updates cannot manufacture frames. + +Move/ball effects, visual waits, HP drain and EXP fill remain native operations. +Only the native combat update consumes command rows, input and outcomes. +Source-launched clips occupy the native animation player and hold the command +queue until that clip finishes. A presentation serial distinguishes a finished +source clip from a newer native animation on the same player. Reset/unload +cancels only a clip still owned by that source admission. + +`present(id)` has stable identity for an admitted battler. It observes native +display HP/maximum/level/status/EXP, visibility, offsets and affine effects. +Source visual overrides compose around the existing native sprite draw; +reading a native effect never applies it a second time. `shownBattler` carries +an owned presentation override without replacing the native combatant. +Replacing the session, save, data or catalogue retires old poses and callbacks. + +`GameVersion.layout(id)` also reports the registered GBA family (`frlg` or +`rse`) so an unchanged field companion probe can recognize the native host. +It supports current-version and colon queries without changing cache/save +selection; an explicit registered layout takes precedence. + +## Geometry and screen effects + +`anim_coords` exposes authored anchors and ordering for singles and doubles. +Current imported cartridge coordinates take precedence over the standard +240x160 anchors. Temporary side bindings restore on success and failure and +preserve trailing nil arguments/results. + +Supported screen effects are inversion, fade to white/black, darkening, +grayscale and blending to a custom colour. Repeated begin calls reapply within +one guarded render seam; nested seams restore their caller's shader. The native +graphics guard restores render targets, transforms, shaders and leaked pushes +after an error. Synthetic asymmetric sprites test orientation as well as colour. + +## Current boundary + +This bridge implements native move launch/observation, source pose tweens, +display ratios, sequence observations, screen effects and coordinate bindings. +It deliberately does not advertise a complete presentation API version. +Authored animation commands, mutable VM/sprite/task/palette callbacks, +particle bands, trainer/intro/switch/catch stage control, source HP/EXP tween +commands and complete custom UI captures still require integration. A module +being importable is not evidence of a complete mod boot or playable campaign. + +## Validation + +- `tests/engine/native_battle_animation.lua`: all five editions; shared clocks, + owned clip completion, held command queues, stale poses/callbacks and 180-frame + native trajectory comparisons per edition with and without the bridge. +- `tools/battle-animation-runtime/lifecycle-check.lua`: SDK entry failure, + teardown and native peer callback identities. +- `tools/battle-animation-runtime/coords-check.lua`: optional local contract + oracle supplied through `BATTLE_COORDS_ORACLE`; no external engine is bundled. +- `tools/battle-animation-runtime/host`: actual Metal/OpenGL sprite and screen + effect rendering, pixel comparisons and graphics error restoration. + +Local qualification runs use the repository's QA storage manager, frozen source +copies and dedicated LOVE identities. They use synthetic imported data and art; +they do not open player profiles, save files or ROM originals. diff --git a/docs/native-battle-chrome.md b/docs/native-battle-chrome.md new file mode 100644 index 000000000..74cd2af70 --- /dev/null +++ b/docs/native-battle-chrome.md @@ -0,0 +1,42 @@ +# Native battle chrome and particle observations + +Battle panel wrappers enter the existing native panel pass. Repeated base +calls paint once, including translucent artwork. Window queries use imported +cartridge templates and the actual native surface width. Healthbox captures +validate the current battle and battler, use native geometry, and apply owned +offsets inside a restored graphics scope. Native shown ailment state stays +separate from queued logical status changes. + +Trainer images and picture offsets resolve from native imported records. +Additional genuine window templates and the native trainer portrait index +are retained by new imports. Existing imports retain their available records; +unavailable windows and artwork are reported rather than invented. + +The particle observation module projects actual native event poses at the +current clip/frame. It cannot advance a clock or create combat particles. +Reversible position, scale, reflection, visibility and custom drawing changes +compose around the same native event painter, including OAM-composited draws. +The ordinary native path avoids these observations until a consumer asks for +the pool. Retired events and unloaded owners leave no visible source rows. +This is not a complete external animation VM or managed-sprite API. + +ROM-free tests cover all five GBA editions. The real Metal/OpenGL host uses +asymmetric synthetic sprites and panels, independent pixel remapping, native +text shaders, multiple logical widths and healthbox offsets. It does not load +the original package or demonstrate complete gameplay. + +The unchanged local package has progressed beyond the previously missing +battle chrome, healthbox, trainer-picture and picture-coordinate imports. +Additional battle/UI contracts remain. The complete battle presentation +protocol is not advertised by this change. No private runtime or cartridge +assets are included. + +## Public-host composition + +The API-2 candidate builds on #327 and its declared prerequisites. It passes +315 chrome/particle, 916 companion, 1,466 terrain, 486 collision and nine +lifecycle checks, plus 3/3 SDK suites. Engine: 95/106 versus reference 94/105, +with the identical eleven inherited failed suites. Actual Metal and OpenGL +runs each pass the same 778 graphics checks, 90 comparisons and 185 readbacks +on the composed public candidate. No full unchanged-package gameplay +acceptance is inferred from those fixtures. diff --git a/docs/native-companion-runtime.md b/docs/native-companion-runtime.md new file mode 100644 index 000000000..0d4738143 --- /dev/null +++ b/docs/native-companion-runtime.md @@ -0,0 +1,77 @@ +# Native companion runtime + +Source consumers on FireRed, LeafGreen, Emerald, Ruby and Sapphire can use +native party residents, encounter admission, field phases and modal screens. +The adapters do not load another game engine or copy an application save. + +## Native ownership + +- Session party members are typed views with cartridge-numbered identifiers. + HP, status, friendship, nickname and held items write to their admitted + resident. Move IDs and maximum PP are read-only; PP edits are validated + together before committing. Retired views reject writes. +- Bag handles use native pocket/capacity rules. Capacity queries run against + detached inventory containers; negative quantities cannot remove items. + Dex and Safari queries inspect the admitted native session. +- Authored wild encounters validate species, level, personality, six IVs, + moves/PP, item, HP and status before construction. Native encounter + admission retains its healthy-party, busy-world and single-pending-request + guards. Native cleanup runs even after the requester is unloaded; its + continuation runs at most once. Roaming identity and wounds remain native. +- Field locks belong to a mod and tag. Interactions, follower updates and + party-menu wrappers consume their native phase once. Short player gestures + advance on the player's fixed step and respect the native pose owner. +- Draw-only actors join native sorting temporarily. They do not become NPCs, + collision objects or independently updated entities. Rendering errors restore + the original entity collection. Follower artwork can use an owned renderer. +- Dialogue uses the native TextBox printer. Choice, evolution and move-reminder + screens use the native StateStack. A UI flow owns its child screens across + pop/push continuations; retirement removes those screens and discards its + callback without removing an explicitly foreign overlay. +- EXP awards share native growth, stats and level-up events. Damage queries + use the native formula/ruleset. Imported moves, item effects, fonts, sounds + and cries remain native resources. Nested two-tone text restores both the + shader binding and the outer ink/shadow uniforms. + +Optional conveniences attach through an owned option provider. Timed outdoor +encounters reorder existing land entries while preserving native position +weights and rates. Ball shortcuts use idle command admission and the ordinary +bag/catch transaction. Repel reuse asks before consuming another item. +Summary details cycle native stats, IVs and EVs without changing the Pokemon. +Disabling the owner disables these options. + +## Qualification and remaining work + +ROM-free fixtures exercise all five editions, including encounter identity, +PP validation, lock isolation, native menu navigation, evolution cancellation +and completion, free-slot learning, and unloading a full-moveset reminder. +Run `luajit tests/engine/native_companion_runtime.lua`, the engine tier and +SDK tier. The companion GPU host additionally checks real text pixels and +nested native shader uniforms on Metal and OpenGL; it extends the native +battle orientation/effect oracle. + +The local unchanged 1.0.9 package now passes its companion-member probe. +Initialization subsequently requires additional battle/UI presentation +modules, beginning with `src.ui.game3.battle_chrome`. This change does not +advertise the complete battle presentation protocol or claim that the whole +package is playable. Effect-specific field combat, additional UI contracts, +full package graphics and end-to-end gameplay require further qualification. + +Local test starters use managed frozen candidates and disposable profiles, +retain an 8 GiB free-space reserve, and do not read or modify player profiles. +No private runtime source or game assets are included in this contribution. + +## Public-host composition + +This draft composes the existing terrain (#318), battle-animation (#317) and +field-move (#304) offers. The local API-2 qualification retains the previously +qualified map, player, session, modal-stack and rendering prerequisites in its +frozen reference; these dependencies must be composed before treating this as +a standalone runnable change. Internal admission/read helpers do not advertise +another public API version. + +The composed public candidate passes 916 companion checks, 1,466 terrain +checks, 486 collision checks, nine API-2 lifecycle checks and three SDK suites. +Its engine result is 94/105 suites versus 93/104 in the frozen reference, with +the identical eleven inherited failures. The initial source-member probe is +not a full package initialization or gameplay acceptance. 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/docs/native-field-effect-sheets.md b/docs/native-field-effect-sheets.md new file mode 100644 index 000000000..7be235b44 --- /dev/null +++ b/docs/native-field-effect-sheets.md @@ -0,0 +1,44 @@ +# Owned field-effect artwork + +`src.core.game3.field_effects` now supplies the resource part of the compatible +field-effect interface: `install`, `manifest`, `manifestObject`, `loadSheet` +(`getSpriteSheet`), `invalidate`, `release` and owner cancellation. It does not +implement effect tasks, timers, start/wait commands, actor lists, reflections +or draw phases. No empty task observations or additional simulation are used +to imply that those contracts have been completed. + +The native catalogue uses identified imported tall-grass, surf-blob, ripple, +sparkle, FRLG bird and independent emote-strip records when present. It does +not claim that every edition imports each record. Each record's own geometry +and frame order remain authoritative. Independent native emote strips are +not renamed into a presumed combined fifteen-frame strip. Additional artwork +and the native effect phase/state bridge remain required. + +A loaded sheet owns its ImageData, Image, full-frame quads and eight-pixel +front crops. Neither Assets' image cache nor an actor SpriteRenderer is lent. +Repeated requests share these owned resources with per-mod leases. Retirement +uses a private allocation list: replacing public sheet fields cannot cause a +borrowed image to be released. Actual generated-asset overrides are resolved +at each request; changed paths/geometry retire the preceding owned sheet. + +An explicitly installed cache may provide the original `field_effects/objects.lua` +and raw RGBA payloads through its own `read` method. No conversion file is +written. Manifests run with an empty environment and bounded instructions, +then validate scalar/table metadata, finite geometry, names, paths and duplicate +identities. Payload length must exactly match the declared geometry. An authored +manifest remains authoritative; a missing/malformed declared asset is not +silently substituted with unrelated native artwork. Public manifest results +are detached copies; ordinary cached sheet requests avoid copying the manifest. + +Providers bind to the current game/dataset/save/mod-set/edition cohort. +Replacement, failed-entry rollback, late validation rejection, asset flushes +and teardown retire owned allocations. Reentrant cache reads revalidate the +cohort before publication. Preparation failures release partial allocations +and allow a later retry. There is a bounded texture-memory budget; exhausting +it reports an unavailable resource instead of returning a partial sheet. + +Validation uses synthetic, asymmetric pixel fixtures for all five GBA editions, +actual SDK owner rollback/teardown and real Metal/OpenGL image/frame/crop +readbacks. These are resource tests, not original effect-task parity, cartridge +artwork extraction parity, unchanged package initialization, gameplay, full +renderer or complete mod-coexistence acceptance. diff --git a/docs/native-field-move-presentation.md b/docs/native-field-move-presentation.md new file mode 100644 index 000000000..a130ad565 --- /dev/null +++ b/docs/native-field-move-presentation.md @@ -0,0 +1,29 @@ +# Native field-move presentation ownership + +`src.core.game3.field_move_show_mon` uses the existing `Gen3FieldMove` +StateStack admission. `start` and the native entry seam use that admission's +Pokemon, optional pose and guarded continuation. `step` consumes only the +current native update scope. Native draw wrappers consume one draw body; +explicit source draws render the current native picture in the caller's +coordinates. The renderer's frame serial prevents the normal UI pass from +painting the same externally rendered cut-in again. Drawing never advances +the streak or player-pose clock. + +The cut-in retains its own 240x160 GBA surface. A mod's dialogue-box theme +cannot shrink it to the Game Boy surface or enlarge it to a DS surface. + +The native admission owns its front image and resolves actual generated-asset +overrides. Completion, explicit reset, failed entry and owner retirement +release it and restore the prior player pose. Covered admissions cannot pop a +foreign screen or advance through direct source ticks. Cancelling a source +owner under another screen retires its callback immediately and leaves that +cover in place. Continuations preserve nil returns/errors and cannot execute +twice or before native completion. Retired callbacks release captured data, +and cohort references are weak so retained wrappers cannot keep a retired +save alive. Observer release leaves engine-owned continuations intact. + +The optional cartridge profile, authenticated strip import and supported cry +options are documented in [native-field-move-runtime.md](native-field-move-runtime.md). +The ordinary native style is retained without an active observer. Full +portrait/form/personality handling, complete field task scheduling and the +unchanged complete renderer/gameplay remain open acceptance work. diff --git a/docs/native-field-move-runtime.md b/docs/native-field-move-runtime.md new file mode 100644 index 000000000..8c9a39d19 --- /dev/null +++ b/docs/native-field-move-runtime.md @@ -0,0 +1,23 @@ +# Imported field-move presentation on the native state + +This extends the native `Gen3FieldMove` admission with a cartridge presentation program. The normal native style remains the default when there is no active compatible observer. Existing native sprite hooks and asset overrides still resolve the front picture. No second world, player or task clock is created. + +The compatible field-move namespace exposes `start`, `isActive`, `step`, `draw`, `reset` and `invalidate`. Entry configures an actual stack state. Only that state's native update advances the presentation. Wrapped base calls share a once-only phase; an arbitrary `step()` cannot drive another frame. Read-only `_fx` observations are detached from the actual state. Rendering an effect through the source world pass suppresses the same admission's native UI draw within the renderer's frame serial. + +The optional program uses separate indoor and outdoor 256×80 strips, a 24-tick optional pose, cartridge-style window reveal/hide, picture motion/hold and one cry. `noDuck` and per-call cry volume feed the existing audio engine; the next ordinary cry restores its normal volume. An unrelated active cry retains ownership of its music attenuation. + +## Imported data and ownership + +The existing field-effects import stage authenticates the actual complete ROM SHA-1 before extracting strips. Bounds and declared tile counts are checked. The compact cache contains indices and palette words; RGBA is expanded only on admission. An older manifest can use the audited exact-digest profile, while discovery writes explicit optional `fieldMoveStreaks` metadata. Unknown revisions decline this artifact. The GBA-only cache revision forces an import refresh without migrating player data. + +Profiles cover FireRed, LeafGreen, Emerald, Ruby and Sapphire. Source locations and presentation facts were checked against the corresponding primary cartridge projects: [FireRed](https://github.com/pret/pokefirered/blob/master/src/field_effect.c), [Emerald](https://github.com/pret/pokeemerald/blob/master/src/field_effect.c), [Ruby/Sapphire](https://github.com/pret/pokeruby/blob/master/src/field_effect.c). No cartridge payloads are bundled. + +A mounted field-art provider can supply the same raw strips directly. Missing or malformed declared provider data fails admission; it cannot silently select another provider. Front images, strips, image data and quads are privately tracked per admission, including failed preparation and reload. Changing a public sprite field never transfers ownership of a borrowed native image. Pose ownership uses an admission token, so an old admission cannot restore over a newer pose. + +Callbacks and states belong to the current game/data/save/mod-set/edition/stack/world. A covered state cannot complete another screen. Source-owner cancellation removes only its own admission, including behind another screen, and discards captured continuations. Observer teardown preserves native continuations. Duplicate, early, reentrant and failed continuations retain their guards. + +## Validation and remaining scope + +ROM-free checks exercise admission, wrappers, cohort changes, resource ownership, actual SDK entry/late-validation/teardown, and the existing music/cry engines. Independent private oracles use immutable original blobs for per-tick state comparison and separately decode both strips from all five ROMs. The GPU host compares the original drawing routine against the native program on Metal/OpenGL, including repeated and externally composed draws. Front artwork in that host is an asymmetric synthetic fixture; it does **not** prove every cartridge portrait, form or personality variant. + +Full special-form/personality picture handling, native field-effect tasks and actor/overlay scheduling, party-slot selection, the unchanged complete renderer and real full-package gameplay are still separate acceptance work. A loadable module or matching cut-in is not full mod compatibility. diff --git a/docs/native-field-runtime.md b/docs/native-field-runtime.md new file mode 100644 index 000000000..a2532f3d3 --- /dev/null +++ b/docs/native-field-runtime.md @@ -0,0 +1,91 @@ +# Native field runtime adapters + +Source fades observe the actual native script, warp, white-flash and battle +return states. Field fade requests share the extracted native FadeOverlay, +its fixed-step clock and whole-window veil. Sixteen palette steps and owned +holds do not replace native script timing or its abandoned-fade watchdog. +Unload cancels only the requesting owner's flow and continuation. + +Source warp requests enter the existing native admission controller. Map +identities, bounds, input ownership, arrival bookkeeping and callback lifetime +remain native. The native transition retains its actual arrival kind while +active. Revoking a source continuation cannot rewind an admitted native warp. + +Weather audio follows the existing native weather and frame clock, without +another weather simulation or gameplay RNG. `field.weather.sound` can claim a +weather playback request. Native weather uses a separate cached voice from +scripted/mod effects of the same sequence; removing the wrapper resumes native +weather on the next field tick. Missing imported sequences have no substitute. + +`dataset.cache()` reads generated artifacts through the native cache filesystem +and current cartridge prefix. Readers retire on import, edition, root or session +replacement. This is read-only access to existing native files, not a second +importer, hydration path, alternate extraction layout or worker format. + +The source START adapter opens/closes the real GBA menu and enters its native +input/draw phases once. Flow cancellation preserves unrelated overlays. Custom +row callbacks and content-template presentation need additional integration. + +Focused checks across FireRed, LeafGreen, Emerald, Ruby and Sapphire pass: +385 fade, 180 warp, 105 weather, 115 artifact-reader and 90 START-menu checks. +An API-2 loader/native-world fixture verifies source warps without requiring +another public API version. The original native warp suite also passes 206 checks. + +On the earlier fork baseline, 485/485 engine suites, all three SDK suites and +B30 pass. The final-main regression is tracked separately. Metal and OpenGL each +pass 1,364 fade checks with 320 pixel comparisons/readbacks; RGBA8 comparisons +allow 1.51 channel quanta for factor quantization and final colour rounding. + +Unchanged 1.0.9 initializes through the actual Metal/LOVE loader in all five +editions on synthetic imported data. This does not prove gameplay: the battle +card still reports a missing palette module, and native field/gameplay, complete +battle presentation and remaining UI contracts require further qualification. + +QA uses disposable profiles, preserved frozen inputs and an 8 GiB reserve. +Player profiles, private runtime files and game assets are not included. + +## Public-host composition + +This API-2 offer builds on #328/#327 and their declared native map, player, +session, modal-stack and rendering prerequisites. The existing native warp +admission helper is reused internally; it does not advertise another public +API version. The composed API-2 candidate passes all five focused suites, +916 companion, 1,466 terrain, 486 collision and nine lifecycle checks plus +all three SDK suites. Engine results are 100/111 versus 95/106 in the frozen +reference, with the identical eleven inherited failed suites. + +The unchanged-package loading result above belongs to the fork; it is not a +claim that this standalone public branch accepts or fully runs that package. + +Additional source observations reuse native ownership: checked cartridge map +aliases share one physical definition, warp forecasts avoid committing a warp, +weather follows the native importer enum and cycle, and previews observe actual +native admission. Door observations currently cover the native Petalburg slide, +not general source-started door tasks. Healing snapshots and native drawing +share the same borrowed ROM-art selection and offsets. + +Source audio borrows a distinct cached playback and retires with that playback, +owner or session. It cannot change another owner's gain or stop its voice. +Unloaded native UI/field classes cannot admit ordinary mod screens through nil +metatables. Resident animation observations build on the separate atlas offer; +they do not implement named RGBA animation banks. + +Detached `Renderer:frameRects()` metrics share the native compositor's viewport +calculation. Source field producers can request the real drawable resolution +and pass an existing canvas directly to the scaling compositor. Private native +scratch is reused and is not exposed to callers. Shader conversion is handled +by the separate orientation offer #193. Complete mod, battle, door and UI +presentation acceptance remains open. + + +The indexed-palette adapter now reuses a factored native shader factory with separate native-background and source banks. Native backdrops retain their continuous texture alpha and replace sixteen individual palette uploads with one array upload. Source RGB555 arithmetic, blending, affine/gray resolution and a real cutout shader share this implementation; owner teardown cannot release the native bank. This does not implement animation-pack adoption, another animation VM, or the complete battle presentation protocol. + +The fork and composed public baseline each pass 7,771 palette checks and 382 native animation checks; battle admission passes 382 on the fork and 397 on the older public baseline. Metal and OpenGL each pass 1,168 RGBA8 GPU comparisons. The deliberately shared-bank Metal counterexample fails at the intended nested native/source palette comparison. A corrected raw-output measurement uses premultiplied replacement blending; the earlier alpha-multiplying measurement failure is preserved. A new unchanged FireRed run reaches the field and START → ASCENDANT with no loader errors; the battle card now reports the next missing contract, `Ui.takeCommand`, and remains unavailable. None of these checks qualifies full unchanged-mod gameplay. + + +Native source move commands are borrowed at the existing selection commit boundary and taken once. Changed slots/targets re-enter native selection and refusal handling; actual source PP transactions are flushed before validation. Covered, replaced or retired admissions cannot commit stale choices. Ordinary/native-only paths and other native controller-owned action classes keep their owners. Live cursor writes update validated native indices instead of shadowing them, and UI busy/dialog observations read the actual native queue. This command interception currently covers move choices; the remaining command classes and complete presentation protocol are not claimed. + +Both baselines pass 390 command/cursor/observation checks, including doubles, changed targets, cancellation, PP refusal, exceptions, native callback ownership and multiple/nil native return values. The fork retains 382 admission and 382 animation checks; the public composition retains 397 admission and 382 animation checks. Its refreshed full engine result is 127/138 with exactly the same eleven inherited failed suites as the preserved reference; Modkit 3/3 passes. The older public input bodies were factored into shared selectors without changing their normalized source bodies or backporting newer battle rules. Unchanged FireRed high/ultra field runs still load with no loader errors; the battle renderer now reaches its next missing contract, `src.core.game3.display`. The original high-quality FXAA card is active and available on the real Metal run; initial pre-preset health snapshots were stale. Ultra supersampling remains a separate optional setting and is being qualified independently. + + +Real unchanged FireRed quality observations now cover Metal and OpenGL: High runs its active FXAA card at 1024x768, while Ultra with the separate supersampling switch enabled renders and returns a 1536x1152 world for that window. The native compositor scales it once and retains upright native UI. A separate real Retina Metal run reports a 2048x1536 drawable for 1024x768 logical units and an upright 3072x2304 Ultra world. These are starting-room render/capture observations, not full gameplay or performance benchmarks. The original mod deliberately bounds field resolution when its optional supersampling setting is off; its own quality policy is preserved. diff --git a/docs/native-help-presentation.md b/docs/native-help-presentation.md new file mode 100644 index 000000000..db1a8cc18 --- /dev/null +++ b/docs/native-help-presentation.md @@ -0,0 +1,38 @@ +# Native cartridge Help presentation + +FR/LG imports now publish verified question/answer references, context lists, +topic order/visibility, original nine-tile decorations and keypad artwork in +`constants.gen3Help`. The structured text catalog remains the only text +decoder and translation authority. Other editions receive no invented Help +resource. Both the resource marker and structured-text revision invalidate +stale GBA imports; Gen2 markers keep their existing behavior. + +`src.ui.Gen3HelpMenu` is a real native stack modal. Its list controller is +the existing `ListMenu`; answers retain original extended glyph cells, +keypad icons, placeholders and explicit text positions. Closing, owner +shutdown and dataset/save replacement retire only the owned modal and its +images, including under an unrelated covering screen. Images are created +once per open modal and released on exit or failed construction. + +`src.ui.game3.help_system` observes that modal and lends its draw body to a +zero-argument source wrapper during the actual native draw only. Repeated +base calls share one paint. Outside-phase queries cannot advance input or +paint another frame. It does not load a second runtime or retain ROM readers. + +Progress filtering reads the real native flag keys, current item registry and +unique caught Kanto dex IDs. HM acquisition flags, map possession and the +post-Brock basic-term order do not mutate any save state. + +This is a bounded presentation prerequisite, not complete retail Help: +callers currently supply an explicit native context (1..34). Automatic L/R +activation, first-use tutorial state, +audio ducking and every native context transition are still unqualified. +No global button interception or gameplay flag writes are introduced before +those owners are integrated. Full map battles, terrariums, OS-window output +and interactive frame rate require separate visual/runtime acceptance. + +Metadata and numeric interface facts were checked against the pinned primary +pret/pokefirered source at `ce6406d8c2eaf46d543c8a2606de0c3cb1951982` +(`help_system.c`, `help_system_util.c`, `text.c`, `charmap.txt`) and actual +verified FR/LG cartridges. No cartridge prose/pixels or private runtime are +published. Existing address-profile provenance and third-party rights remain. diff --git a/docs/native-modal-observations.md b/docs/native-modal-observations.md new file mode 100644 index 000000000..bf1900d39 --- /dev/null +++ b/docs/native-modal-observations.md @@ -0,0 +1,21 @@ +# Native battle modal observations + +The source `bag_menu`, `pokedex`, `party_menu` and `stat_growth` queries report +actual native controllers in the current stack, including covered controllers. +They do not allocate another screen, advance input or close unrelated menus. +The queries distinguish an unopened constructed controller from an open one. + +Native bag, ordinary/FRLG dex, party and stat-window constructors stamp their +actual edition/data/save/stack/mod-loader cohort. A class name alone cannot +admit an unbound screen. Replacement of any owner field rejects the old +observation, including replacement before the first query. Missing nested +classes must never fall back to matching the enclosing battle class. + +The observation registry has weak state keys and weak owner slots. It must +not retain a removed state/world through the value-to-game-to-stack cycle on +LuaJIT. Non-GBA queries remain absent and the native Gen2 stat constructor +keeps its existing state and callbacks. + +This unit supplies real visibility queries needed by unchanged map/terrarium +viewport selection. Full bag/dex source commands, wide battle surfaces, +complete scene visuals and frame rate still need their own acceptance. diff --git a/docs/native-ui-pass.md b/docs/native-ui-pass.md new file mode 100644 index 000000000..f8b025a3e --- /dev/null +++ b/docs/native-ui-pass.md @@ -0,0 +1,32 @@ +# Native GBA UI drawing pass + +`src.ui.game3.ui_pass.drawUi()` borrows the current native modal draw phase. +The function takes no arguments. Calling it between native phases does not +paint a second frame. Repeated base calls inside a wrapper share one native +result, including failures and trailing nils. + +The native Game painter retains its state order, visibility, surface fitting +and centering. World owners, admitted battles and states explicitly marked +`isOverworld` or `isBattleState` retain their own render passes. Contiguous +ordinary overlay groups use the UI boundary; interleaved scene owners split +the groups without reordering them. Existing native UI controllers continue +to own input and logic. Unpatched modules keep the direct native painter. + +A wrapper may redirect its borrowed UI group to an owned canvas. Graphics +scope restoration covers canvas, shader, transforms, blend/color, stack and +physical scissor, including mixed pixel densities and callback failures. +Replacing the Game data, save, loader, stack or any stack member invalidates +the already resolved UI phase. Native drawers run under their native owner; +the source wrapper keeps its own callback attribution. + +This is a drawing contract. It neither allocates HUD targets nor advertises +the complete battle-presentation protocol. Help controllers, full unchanged +mod gameplay, map/terrarium visual acceptance and interactive frame rates +require their own evidence. + +Validation entry points are `tests/engine/native_ui_pass.lua` and +`tools/native-ui-pass-gpu`. The GPU fixture captures the actual compositor +through the existing final-output owner, compares default/wrapped frames, +checks independent UI rectangles at DPI 1/2, rejects a displaced positive +control and checks physical clipping after a source error. It does not +establish OS-window or gameplay acceptance. diff --git a/docs/qa/native-foundation/action-observations.md b/docs/qa/native-foundation/action-observations.md new file mode 100644 index 000000000..f3b8f0c2d --- /dev/null +++ b/docs/qa/native-foundation/action-observations.md @@ -0,0 +1,62 @@ +# Read-only native action order + +The native turn planner already establishes order and queues its action bodies. +Observations now bind that actual order to those actual function rows. They do +not create an executable action queue, resolve a move, change an action or add +a frame. Native queue dispatch marks the executing row and retires it on failure; +the existing native end-of-turn retires the observation ledger. + +The source battle facade exposes a detached `_actions` array with stable, +read-only action records and `_actionI` for its executing/physical head row +(one-based, zero when no mapped row owns that position). The move key and side +come from the actual queued native entry. The records describe selected commands, +not a prediction that the move will pass status, targeting or damage checks. + +Edition, native dataset/save/loader/stack/queue and source admission changes retire +observations. Restoring a prior pointer does not revive the old lease. Unqualified +native act wrappers or replaced pending callbacks retain their behavior while +losing the verified observation; another function is never labelled as the +original native command. The observations retain no native controller through an +external stale proxy. Gen2 keeps its native dispatch and constructs no GBA ledger. + +## Qualification and limits + +Focused qualification passes782 checks, including real native move-used producer +observation, typed pairs/next iteration, detached arrays, stable identities, read-only records, cohort/source +retirement, callback replacement, unknown native wrappers and native failure. +Complete deterministic turns compare HP, PP, every RNG invocation and frame/phase +sequences with observation registration off/on across all five GBA editions. +The first comparison retained a mismatch because wild construction selected +random enemy IVs/personality before binding battle RNG. The owned fixture now +prepares identical mon records and shown HP for both runs; native combat is not +simulated. No player profile or private mod bytes are changed. + +This is an observation prerequisite. `_phase`, `Ui.busy`, native execution and +animation sequence admission are unchanged. No resolver or sequence stub is +installed, and Action Card readiness stays gated. A typed native HP/RNG +continuation and real resolver/sequence ownership remain required before source +contact suspension can be admitted. Whole-source, predecessor and unchanged-mod +qualification follow separately. + + +Frozen source `f0627a735e` (before the additional nil/false loader hardening) +passes all126 selected CPU groups. Exact predecessor comparison `8c498caaf6` +executes the identical owned turn probe against that tree and predecessor +`ddd4035807` without overlaying either native controller. HP, PP, RNG invocations +and every frame/phase record match across all five editions. Latest source and +older-public integration qualification are recorded separately. + +Final current-main code `5eca0614` (main `c4bd3b36`), frozen `f11ba60038`, +passes133 selected CPU groups including782 focused action checks. Exact +predecessor `f9fc881eb2` retains identical full-turn HP/PP/RNG/frame traces +across all five GBA editions. Older-public composition `c9eed01598` passes55 +groups, retaining its older native controller bodies. Raw older-public execution +still requires the declared shared typed-iteration prerequisite; this is not +standalone upstream CI acceptance. The scoped CI workflow belongs to the complete +native fork, not the incomplete older-public prerequisite chain. + +Unchanged package `bddb5181`, Sapphire run `567f801dd0`, completes native +new-game/field entry, paper battle, real RAGE selection (PP20 to19), player +HP63 to46 and enemy HP57 to49, then native/source cleanup without loader errors. +The after-move capture is upright. This owned prepared fresh-profile functional +run is not continuous story, visible-desktop or FPS acceptance. diff --git a/docs/qa/native-foundation/action-sequences.md b/docs/qa/native-foundation/action-sequences.md new file mode 100644 index 000000000..29eb6a3a9 --- /dev/null +++ b/docs/qa/native-foundation/action-sequences.md @@ -0,0 +1,44 @@ +# Native action presentation rows + +`battle.anim_seq.begin` decorates the actual presentation rows created by one +verified native action callback. It receives detached event arrays with immutable +identities. The native used-message and move-animation constructors provide their +provenance; message language and content are never used to identify an event. +Other rows remain opaque native events. The same native queue executes HP, items, +experience, UI and gameplay callbacks. + +A decorator may omit only this admission's qualified move-animation and +move-used message events. Retained events keep their native order. Foreign, +duplicate, reordered or missing mandatory events are rejected. Native row/cohort +changes, covered screens and retired phases invalidate permission. The first +base request owns the once-only choice; filtering commits only when the whole +decorator returns successfully. Errors cannot replay the native action or partly +commit presentation filtering. A row carrying another native protocol keeps its +mandatory identity. Returned event views retain no controller after retirement. + +Only native action-plan rows open this production boundary. Default and no-op +behavior use the original native queue and clock. No additional interpreter, +executable event stream or combat calculation is created. Used-message IDs name +the actual native `_ItemUseText001` producer. Source requires no translated text +comparison. + +This supplies the begin/filter subset, not the complete animation-driver API. +Resolver, pre-dispatch action phase and typed HP/RNG continuation remain open; +Action Card stays gated. Other sequence methods and arbitrary detached event +playback are not advertised. No unchanged-mod contact-cue or performance +acceptance is derived from synthetic native tests. + +The owned native fixture compares complete HP/PP/every-RNG/frame traces for all +five GBA editions with the module absent, default, and no-op decorated. Targeted +cases exercise actual producers, protected rows, phase/cohort loss, callback +attribution, malformed/foreign requests and errors before/after the base call. +Player data and private mod sources are not changed. + +Native code `89cc72df`, frozen `d6e85beb74`, passes all134 selected CPU +groups including168 targeted checks. The later portable test-entry adaptation +changes no production bytes. Older-public code `821e486e`, exact composition +`d4055d8093`, passes all56 groups with the older native controller retained; +only owned constructor provenance is inserted. It has no independent GPU or +standalone dependency-chain acceptance. Previous unchanged-package Sapphire +`567f801dd0` and FireRed `a9906a0866` qualify the predecessor action-observation +packet, not this new sequence filter. Fork CI is pending separately. diff --git a/docs/qa/native-foundation/animation-update-hooks.md b/docs/qa/native-foundation/animation-update-hooks.md new file mode 100644 index 000000000..77f20d78f --- /dev/null +++ b/docs/qa/native-foundation/animation-update-hooks.md @@ -0,0 +1,15 @@ +# Native animation update decorations + +The unchanged mod's settled paper/terrarium captures contain correct native HUD pixels but only folded fragments appear in the completed scene. Its existing unfolding/return effects decorate `Anim.update`; the native path updated internal presentation work directly, so that public decoration never received a native tick. This is a missing native lifecycle seam, not missing HUD visibility. Actual probes `b6a1ff860b`/`3cf74d32ad` confirm correct owned HUD ink, live native visibility, the active end-frame wrapper and expected target/quad state. ROM-free Metal control `7e3ec7cc5d` rejects a proposed logical-quad/DPI cause in12 cases; no DPI workaround is added. + +Native battle phases now notify a decorated animation update at phase end. The callback receives the native delta. Its public base borrows completed once-only presentation work and cannot run another native frame or queue. If source code already called the public base explicitly in that phase, the native owner skips a duplicate notification. Covered/retired admissions cannot notify; errors poison the native phase. Native return tuples are preserved independently of decorator return values. Unwrapped callers and other generations retain the existing native path. + +Frozen source `100abace91` passes syntax and23 suites (24 groups), including755 decoration/owner/delta/failure/coverage checks. The fixture compares complete native command/HP/timer traces for undecorated, automatically notified and explicitly called source cases across five GBA editions; existing animation/VM/task/intro/capture/session/command, UI, Gen2 window/queue/scene/live-view/summary, checkpoint and suspend regressions pass. The first fixture used a nonexistent convenience wait method; the second omitted explicit caller-owner metadata and expected a native frame before an intentional early error. Failed attempts remain retained; native behavior was not weakened to satisfy them. + +Exact older public composition `b2decee4a7` passes22 groups, including the755 decoration checks, without replacing its older native controllers. Actual unchanged-mod run `ab2651195e-firered` completes five settled paper/native/terrarium switches: both3D views are upright and now contain the complete unfolding HUD and menu. A real native EMBER action consumes AP25→24 once, changes player HP63→48/enemy HP60→30, and retires the source presentation to idle with starts1/finishes1. + +The newer fork main `65a70949` is merged into the integration branch at `7162f7dd`. Frozen combined source `aa4e50f0a4` passes29 groups, including the five additional Gen2 party-item/AP/connection-target/battle-PACK/live-party regressions. Actual refreshed-source run `3166061216-firered` also completes five settled upright views with visible HUD/menu, EMBER (AP25→24 once; player HP63→44/enemy HP61→37) and cleanup to idle with starts1/finishes1. + +That refreshed run samples240 wall-frame intervals per view without HUD pixel scans or draw/end-frame diagnostic wrappers. Paper means are16.665/16.663ms (about60FPS); terrarium mean16.808ms (59.49FPS), p95 18.300ms/max28.925ms. These are short starting-room measurements with native vsync1. Optional callback-cost timing was not enabled in this first benchmark; its zero-filled update/draw aggregates are unavailable, not measured zero. Future samples leave absent timing buckets absent. Broader gameplay, other-edition visuals, OS-window and sustained field-voxel60FPS remain unqualified. QA uses isolated profiles and20GiB free reserve; player saves remain untouched. + +Contributions by Roxas2712. Existing native code and third-party rights retain their provenance. See NOTICE.md and LICENSE-ROXAS2712.md for Cedric / UNDERdecoded integration permission and credit requirements. diff --git a/docs/qa/native-foundation/battle-ball-art.md b/docs/qa/native-foundation/battle-ball-art.md new file mode 100644 index 000000000..83776f141 --- /dev/null +++ b/docs/qa/native-foundation/battle-ball-art.md @@ -0,0 +1,43 @@ +# Native battle ball artwork + +`battle.ui.ballQuad(ballId, frame)` reads the existing imported 16×16 ball +atlas. IDs and frames are zero-based. An admitted native GBA battle is +required; unknown cells return no picture. The query cannot start a ball, +advance its timeline, consume RNG or mutate the native battle. + +One source-owned image and at most 36 cached quads cover twelve styles and +three frames. The native asset cache retains its own image: releasing source +art cannot release native painter resources. Battle admission, dataset/save/ +loader/edition replacement, asset invalidation and owner retirement determine +resource lifetime. Covered battles can still observe their own artwork. + +The current composed fork had not integrated the native ball metadata already +offered with edition PRs #186/#187. Its Ruby/Sapphire manifests still used the +Emerald addresses. The existing verified native metadata now selects the +original sheet/palette/shared-open/particle tables, nine send-out opcode sites +and RS sound identities. The native extractor and timeline remain responsible +for decoding and animation. `rs-ball-art-v1` invalidates only the affected +edition caches; it does not migrate saves. The separate public edition offers +already contain these facts; the UI-art query does not replace those offers. + +Address facts were cross-checked against the original cartridge bytes and +[pret/pokeruby](https://github.com/pret/pokeruby/tree/5784633ce4ef7ade1a7f2d2d0c288e3d5e6cdd7f), +`src/pokeball.c` and `src/battle_anim_special.c`. No alternate extractor or +foreign runtime body is included. The existing opcode checks remain strict; +RS retains its 39/30-frame player/opponent send-outs and caught jingle 371. + +`tests/engine/native_battle_ball_art.lua` covers all cells, malformed requests, +sharing and retirement with actual native admissions in all five GBA editions. +`tools/native-ball-art` uses the actual importer and SHA1-verified local ROMs +with graphics disabled; it checks every decoded cell and records RGBA hashes. +Use a fresh owned QA identity and the project's managed storage launcher. + +This is an artwork/query contract. It does not advertise presentation API v2 +or establish map-battle/terrarium visuals, physical-window output or FPS. +The unchanged mod-owned drawing bridge still requires further native sprite, +particle and menu contracts. Keep those failures visible. + +The focused public UI offer depends on the separately offered native edition +profiles for Ruby/Sapphire (#186/#187). Their profile/cache changes are not +duplicated in this UI branch. The all-edition ROM fixture requires those +profiles in the composed source. diff --git a/docs/qa/native-foundation/battle-menu-continuity.md b/docs/qa/native-foundation/battle-menu-continuity.md new file mode 100644 index 000000000..7c5ac2b40 --- /dev/null +++ b/docs/qa/native-foundation/battle-menu-continuity.md @@ -0,0 +1,46 @@ +# Native battle menu continuity and trainer party rows + +The source-facing swap observation is a detached `{cursor = index-1}` record, +matching the zero-based drawing protocol. `_swapIndex` exposes the native +one-based number; the native `moveSwapIndex`, input handling, move/PP records +and command owner are unchanged. Mutating a returned record cannot change the +native selection. + +Public party-bar painting and native HUDs share one existing native row +renderer: icon packing, opposite-edge geometry, arrival timing and first-paint +stamp. Public decorators borrow each native row once; held draws do not start +a clock, native failures cannot be swallowed/replayed, and callbacks restore +graphics state. Positions, opacity, hidden icons and per-icon offsets are +presentation only. Bars and icon sheets may have different texel densities +while keeping their imported logical dimensions. Unsupported extended sheets +are diagnosed rather than advertised. + +Fork source `1830bd24b6`, including main `0f49e5c2` at merge `77a13eeb`, passes +47 CPU groups including491 continuity/density checks. Eight additional groups +on the same frozen bytes pass3014 checks for native Gen2 models, healthboxes, +modal admission, stat growth and level-up drawing. Older-public composition +`d50e49cc03` passes30 CPU groups with the same491 new checks. Its native +controller body receives checked narrow changes, not the newer fork body. + +GPU runs `fb6791647c` (fork) and `d9100003e8` (public) each pass4322 checks +across1440 constructed frames on Metal and on OpenGL: both rows, six arrival +ages, five editions, widths240/320, DPI1/2, independently varied image density, +custom positions/opacity/slots/offsets and unchanged native clocks. + +Actual unchanged Origami1.0.9 run `74566232b9-firered` uses earlier source +`95a8db3301`: real trainer constructor, paper/native/terrarium/native/paper +switches, native move-swap/restore/cancel input, unchanged HP/PP and cleanup. +A separate current-main run `c98bf7d9bc-firered` uses `1830bd24b6` and repeats +the trainer/menu path in one scene with raw HUD/font diagnostics. These are +bounded FireRed checks, not five-edition artwork/full-game/window/FPS acceptance. +An earlier observer failure before the first scene was an owned diagnostic bug; +its failed evidence is preserved and the observer was repaired. + +The apparent mirrored menu text was tested against the imported bitmap: +normal orientation matches with0.162 mean RGB error, while mirrored variants +produce21.97–38.40. Primitive asymmetric font/HD/small-face and canvas-slice +probes also pass. Low contrast and the selected wide/flat menu fit remain +presentation concerns; no global flip or private-mod change was made. + +Managed QA keeps20GiB free and uses separate identities. Player saves and +private reference code are untouched. diff --git a/docs/qa/native-foundation/battle-menu-frames.md b/docs/qa/native-foundation/battle-menu-frames.md new file mode 100644 index 000000000..a784b162a --- /dev/null +++ b/docs/qa/native-foundation/battle-menu-frames.md @@ -0,0 +1,15 @@ +# Native battle menu frames + +Actual unchanged-package run `a5e30e7856-firered` stops in UI drawing at missing `Ui.drawMenuFrames`. The binding now borrows the existing native generic panel/window geometry and the actual native RS panel/chrome owner. Generic imported panels already contain their selected user frame; a one-use receipt prevents painting another frame over that panel. RS retains its separate native user-frame layer and selected style. Wide and Safari paths retain their native fallback. No private renderer body is substituted. + +Receipts are bound to the admitted native view, native frame, compositor serial or active graphics scope, mode and actual render target. Canvas handles remain opaque even when represented by tables; only MRT binding descriptions compare structurally. Cohort/owner retirement invalidates receipts. Frame drawing is graphics-fenced and never advances battle queues, RNG, HP or native clocks. + +This branch includes main `6d2bf2f4` through merge `81e89d2f`. Both native text-reader ABIs remain supported: boolean structured mode and options-table mode, with RS-only NAME_END allowed solely by its explicit native option. Native Wally healthbox behavior is preserved; its shared observation returns a boolean even while hidden. + +Frozen candidate `8e6c8853cd` passes syntax and all24 targeted suites (25 groups):263 frame checks,639 text/import IR checks,571 stage checks, selection/particle/picture/entry/capture/command/session/UI and RS import/map/boot/panel/cache contracts, plus current Gen2 window/queue/scene/live-view/summary regressions. The initial fixture omitted native font passthrough and RS panel metadata; the next attempt exposed a real nil-versus-false visibility regression. Both failed runs remain retained. Source/test hashes identify the final passing tree. + +The actual unchanged-mod map/native/terrarium reprobe is running. No accepted scene frame, physical-window, complete tutorial presentation or60FPS result is claimed by these CPU tests. Tests use isolated QA profiles and20GiB free reserve; player saves remain untouched. + +Contributions by Roxas2712. Existing native code and third-party rights retain their provenance. See NOTICE.md and LICENSE-ROXAS2712.md for Cedric / UNDERdecoded integration permission and credit requirements. + +Public composition: this older public baseline lacks the native RS panel class and Wally controller. The offer adds the shared renderer/query helper and focused scoped UI hooks only, retaining the older native bodies. Its fixture checks optional RS owner delegation with the actual chrome contract shape; it does not qualify the absent native RS controller or fork-only merge fixes. Separate RS offers remain prerequisites for that native default path. Public composition qualification is pending. diff --git a/docs/qa/native-foundation/battle-move-labels.md b/docs/qa/native-foundation/battle-move-labels.md new file mode 100644 index 000000000..ec4baf7fd --- /dev/null +++ b/docs/qa/native-foundation/battle-move-labels.md @@ -0,0 +1,9 @@ +# Native battle move labels + +The unchanged move-menu painter calls `Moves.displayName` for each actual cartridge move slot. This binding now reuses the existing native Pokemon move-name observation and its owned dataset context. It accepts native/source move records and numeric strings, preserves empty-slot markers, reads current native labels and returns explicit absence for unknown imported moves. No second name registry is built. + +Frozen source `e857aa1d18` passes syntax and23 suites (24 groups), including261 native Pokemon/label checks and existing UI/cursor/menu/picture/stage/selection/particle/battle, Gen2, checkpoint and suspend regressions. A stricter actual unchanged-mod run now holds each scene for90 frames, requires reveal readiness, saves owned HUD ink separately, and exercises the real move menu plus a native damaging action/AP commit. Results are pending; no clean visual or60FPS acceptance is claimed. + +The preceding actual `62630d5234-firered` completes five settled view switches with identical native HP/AP. Its paper and terrarium images are upright, but combat HUD/menu is absent despite431525/276176 nontransparent pixels in the owned HUD canvas. This is a confirmed composition problem, not missing native UI state: both healthbox observations and the bottom-UI/status visibility queries are true. Current short FPS badges are not a sustained performance qualification. + +QA enforces20GiB free reserve and uses isolated profiles, never player saves. Contributions by Roxas2712. Existing native code and third-party rights retain their provenance. See NOTICE.md and LICENSE-ROXAS2712.md for Cedric / UNDERdecoded integration permission and credit requirements. diff --git a/docs/qa/native-foundation/battle-palette-manifests.md b/docs/qa/native-foundation/battle-palette-manifests.md new file mode 100644 index 000000000..672d05149 --- /dev/null +++ b/docs/qa/native-foundation/battle-palette-manifests.md @@ -0,0 +1,30 @@ +# Native window and PP palette manifests + +The chrome manifest exposes detached `textboxPal`, `windowTextPal` and +`ppTextPal` arrays from the active native dataset. Full palette validation and +RS edition tags prevent mixed or malformed metadata from reaching the font. +Replacing/mutating imported palettes is visible immediately, while mutations of +returned records cannot rewrite the native import. + +The existing validated textbox stage retains its already decoded menu-palette +bytes. Emerald additionally reads the bounded sixteen-word `gPPTextPalette` +raw table, independently of the compressed window palette. FR/LG never read +Emerald's raw address. RS reuses its native panel/default-font records. The +three affected cache markers advance to `gba-textbox-palette-v2`, superseding +the earlier first-bank-only revision; other editions and save formats are +unchanged. Original resource provenance remains attached. + +Fork source `f3a7211a9b` passes46 CPU groups. Public composition `f9ac06f6ce` +passes29. Both include108 new manifest/ownership checks and expanded extraction +checks covering raw PP bounds and palette separation. The unchanged-adapter +CPU audit with complete synthetic imported records also passes command, move, +target and message drawing for all five editions; synthetic records are not +cartridge visual acceptance. + +Owned-output GPU runs `4b065a5ef2` (fork) and `2a44aeafe5` (public) each pass4044 +checks across576 constructed frames on Metal and on OpenGL. In addition to +cursor density/effects and direct indexed text, the actual two-tone font is +checked through both window and PP manifest palettes for all five editions, +at DPI1/2. The owned fixture hash is recorded separately from the frozen CPU +source. No full-game, real-artwork, OS-window or sustained-FPS claim is made. +Managed QA retains20GiB free and does not access player profiles. diff --git a/docs/qa/native-foundation/battle-selection.md b/docs/qa/native-foundation/battle-selection.md new file mode 100644 index 000000000..e562a0a20 --- /dev/null +++ b/docs/qa/native-foundation/battle-selection.md @@ -0,0 +1,11 @@ +# Native selection presentation + +The confirmed unchanged-mod actor capture in `43b5eb8d59-firered` stops at missing `Ui.targetHidden`. Actor capture also reads selection displacement and a white palette coefficient. These queries now observe the admitted native battle rather than creating a UI clock. + +The native GBA presentation update records the actual choosing actor and target in a weakly scoped native owner. No new controller references enter logical checkpoint/journal fields; diagnostics are detached scalars. Existing native ticks own target changes, eight-frame blink periods and selection bounce origins. Queries never start or advance a controller. Native battler painting and native healthbox placement use the same controller as source UI consumers. Other engine generations do not enter this new update path. The existing native transform-white state supplies the bounded 0..16 coefficient; this does not implement a separate preview/menu state machine. + +Integer selection motion uses imported `gen3BattleAnimSine`, starts at128 for healthboxes and192 for mons, and uses signed fixed-point floor division. Ordinary selection delta is7; target healthbox delta is15. Missing imported sine values yield no verified displacement. No floating sine substitute is introduced. Reference: [pret/pokefirered battle_main.c](https://github.com/pret/pokefirered/blob/ce6406d8c2eaf46d543c8a2606de0c3cb1951982/src/battle_main.c) (`DoBounceEffect`, `SpriteCB_BounceEffect`, `SpriteCB_ShowAsMoveTarget`, `SpriteCB_BlinkVisible`) and [battle_controller_player.c](https://github.com/pret/pokefirered/blob/ce6406d8c2eaf46d543c8a2606de0c3cb1951982/src/battle_controller_player.c) (`HandleInputChooseAction`, `HandleInputChooseTarget`). This reference is not an all-edition cartridge parity claim. + +The new fixture covers five admitted GBA battles, query purity, signed integer motion, continuity through move selection, real additional battlers in doubles, target changes, eight-frame boundaries, side bindings, white coefficients and replacement/retirement. After the free reserve recovered, current-main frozen fork `83b3a9d0c3` passes all19 groups, including758 selection checks,288 particle checks and the current Gen2 window/queue/scene/live-view/summary plus suspend regressions. This qualifies the recorded combined tree, not an independent public composition. The initial particle fixture attempt `7970e2a8aa` omitted imported native coordinates and is retained; adding the missing fixture data leaves the real native fallback unchanged. The actual graphics reprobe is running. No actual graphics fix, clean map/terrarium frame, physical-window result or FPS acceptance is claimed yet. Player saves and original inputs are untouched. + +Contributions by Roxas2712. Applicable third-party notices are preserved. See NOTICE.md and LICENSE-ROXAS2712.md for Cedric / UNDERdecoded integration permission and credit requirements. diff --git a/docs/qa/native-foundation/battle-stage.md b/docs/qa/native-foundation/battle-stage.md new file mode 100644 index 000000000..8fb7511c1 --- /dev/null +++ b/docs/qa/native-foundation/battle-stage.md @@ -0,0 +1,13 @@ +# Native battle stage observations + +The battle presentation reads the stage belonging to the currently admitted native GBA battle. Entry slide/window, trainer portraits, healthbox visibility, party-row animation and the actual ball are observations of existing owners. Queries do not advance the battle, queue, RNG or ball clock. The native painter shares its healthbox/window/party-offset predicates with the observation surface. + +Capture and full-stage consumers share the same retained ball handle. Native ball frame, position, identity and visibility are projected once; replacing the ball, leaving combat, replacing the data/save cohort or retiring the source owner invalidates stale handles. Stage tables are read-only and support the existing source iteration contract. This addition does not advertise a complete presentation protocol or implement absent animation effects. + +Frozen fork candidate `401092536a` passed syntax and all14 targeted suites: 561 stage, 560 entry, 691 capture, 382 session, 382 animation, 390 command, 170 ball-presentation, 432 task, 1917 ball-art, 585 cutout, 210 UI, 60 Gen2 scene, 366 Gen2 live-view and326 Gen2 summary checks. Stage cases cover all five GBA admissions, live native ball movement, catch/stage identity, trainer identity/offsets, window boundaries, observation-only reads, covering and replaced/retired sessions. The first fixture attempt `2bbfe3af73` incorrectly loaded the catch observer after native request dispatch; its failure is retained. + +Actual unchanged private-mod battle entry `b3345e9e75-firered` got past the cutout query and stopped at its wrapper of missing `Anim.stage`. Follow-up `43b5eb8d59-firered` reaches the actual native command phase, then the unchanged actor capture falls back because `Ui.targetHidden` is absent. No scene frame passed acceptance. CPU checks do not establish clean map/terrarium frames, physical-window output, full gameplay or60 FPS. Every test uses an isolated QA profile and20 GiB free-space enforcement; player saves are never used. + +Contributions by Roxas2712. Existing native artwork, battle code and third-party rights retain their original provenance. See NOTICE.md and LICENSE-ROXAS2712.md for the specific Cedric / UNDERdecoded integration permission and credit requirements. + +Public composition `7d6cfc91f4` passes all14 groups against the previously qualified public stack, with exact stage/capture hooks and original older native controller bodies. The public capture projection is offered in #340; the stage is offered in #329. The public branch retains its existing status-HUD policy rather than copying a fork-only provider. diff --git a/docs/qa/native-foundation/battle-text-colors.md b/docs/qa/native-foundation/battle-text-colors.md new file mode 100644 index 000000000..bd4a33755 --- /dev/null +++ b/docs/qa/native-foundation/battle-text-colors.md @@ -0,0 +1,27 @@ +# Native indexed battle text colours + +The public chrome query returns a font colour record (`fg`, `shadow`, transparent +`bg`) for each indexed foreground/shadow pair. Numeric foreground aliases and +the second shadow return preserve existing tuple callers. No-argument queries +retain the native message role. Records are detached and follow live palette, +dataset and edition changes; invalid indices fail before drawing. + +RS uses its already imported, edition-bound palette. The validated FR/LG and +Emerald textbox stage now retains all sixteen first-bank BGR555 words rather +than discarding them after composing images. Only those three import cache +markers gain `gba-textbox-palette-v1`; player save formats are unchanged. Older +role-only data can answer its exact named indices. Unavailable arbitrary pairs +return the existing white font fallback with a diagnostic, never numeric font +options or fabricated palette entries. + +Fork frozen source `a9c30f1ace` passes 43 groups, including 10,490 indexed-colour, +real proportional-font and owner-isolation checks, 78 real extraction/cache +checks and 315 existing chrome checks. The older public composition +`f35174ef21` passes 27 groups, including the same two new contract groups. +Its test uses the existing readiness marker closure where the later pure cache +descriptor is unavailable; no new unrelated production API is added. + +The subsequent unchanged-adapter CPU audit passes the text-colour boundary. +Full game, cartridge artwork, window appearance and sustained FPS acceptance +remain separate. Reference mods and player profiles were untouched; managed QA +retains at least 20 GiB free. diff --git a/docs/qa/native-foundation/battle-voiceover.md b/docs/qa/native-foundation/battle-voiceover.md new file mode 100644 index 000000000..2ee10ecfa --- /dev/null +++ b/docs/qa/native-foundation/battle-voiceover.md @@ -0,0 +1,15 @@ +# Native tutorial cue and menu observations + +Actual unchanged-mod run `9e0c34238d-firered` passes native menu-frame painting, then fails at missing `Ui.voiceoverDim`. UI queries now observe a native tutorial cue owner instead of starting a second renderer or returning a fabricated success state. + +The existing native Teachy-TV queue tags its actual imported messages, including the status lesson's healthbox cue and its final narration. Native ticks own the0..8 coefficient and five-tick palette steps; the native queue holds the caption until fade-in and holds the next action until return fades finish. Spotlight cues also have their own native healthbox coefficient and return stages. Queries never advance the clock, consume input or roll combat RNG. The ordinary native painter consumes the same state. The FR/LG controller is inert on Hoenn and other generations. Cartridge facts: [Pokedude controller](https://github.com/pret/pokefirered/blob/ce6406d8c2eaf46d543c8a2606de0c3cb1951982/src/battle_controller_pokedude.c), target8/delay4 and status-lesson healthbox callback. + +This is not full retail tutorial presentation parity. The ordinary native caption still uses its existing window. Intermediate healthbox palette interpolation, complete Oak/old-man cue scheduling, voiceover window art and audio ducking remain open; the source bridge's boolean spotlight observation cannot describe intermediate palette levels. No complete presentation-version marker is advertised. + +AP state observations reuse the existing native summary thresholds, retaining the separate palette-index ABI. Move-type observations preserve explicit per-slot type identities, including numeric zero. Quick-ball prompts read the actual native availability/inventory owner; they do not launch a second item transaction. Existing native quick-item input/commit remains unchanged. + +Frozen candidate `9a3b3bb1d4` passes syntax and26 suites (27 groups), including5,344 tutorial/query checks,1,387 native checkpoint continuation checks, current battle/menu/picture/selection/particle/entry/capture/UI and Gen2 regressions. Active tutorial fades explicitly reject decision checkpoints; normal checkpoints retain their existing continuation. The first fixture omitted settled native HP observations; it also exposed a real false-versus-nil cross-edition observation error, now fixed and retained as failed proof. The actual graphics run uses preceding candidate `b3aa3aef0c`, with identical rendering/controller source bytes; the final candidate adds only explicit checkpoint rejection and its fixture. Actual unchanged-mod run `78b938448b-firered` passes the previous UI failures and now stops at missing `Window.cursorPx` in the action-menu painter, before any accepted scene frame. No physical-window or60FPS acceptance follows from CPU tests. + +Exact older public composition `8dae697ea3` passes all20 groups. The first two public attempts retained an older caption wait/cursor in the quick-item fixture; the final fixture uses a separate genuine native admission. It offers focused native queue/tick/draw and query hooks while retaining older public native bodies and message-phase spelling. The older public baseline lacks the native checkpoint owner, so that guard remains fork-only. + +QA enforces20GiB free reserve and uses isolated test identities, never player saves. Contributions by Roxas2712. Existing native code and third-party rights retain their provenance. See NOTICE.md and LICENSE-ROXAS2712.md for Cedric / UNDERdecoded integration permission and credit requirements. diff --git a/docs/qa/native-foundation/battler-coordinate-queries.md b/docs/qa/native-foundation/battler-coordinate-queries.md new file mode 100644 index 000000000..ad8a8922d --- /dev/null +++ b/docs/qa/native-foundation/battler-coordinate-queries.md @@ -0,0 +1,11 @@ +# Native base battler coordinates + +The rendering adapter queries `pic_coords.battlerCoords(double, id)` for its untransformed sprite anchors. The native engine already owns the imported singles/doubles layout and its canonical fallback anchors. This query reads those owners instead of adding another coordinate registry or using camera/animation offsets. + +The result has `x/y` and indexed `1/2` fields and is detached from native records. It follows current datasets immediately, retains the explicitly requested layout independently of an animation's double/coordinate overrides, and rejects invalid slots, non-finite numbers and malformed records. Missing variant/slot data uses that variant's existing native anchor; it cannot accidentally select the other variant. No game is bootstrapped for a query, and other generations do not obtain a fabricated GBA owner. + +Frozen fork source `37b9261a00` on main `56d27ead` passes35 groups, including995 coordinate checks across FireRed, LeafGreen, Emerald, Ruby and Sapphire. Tests cover imported/default coordinates, changed and partially absent datasets, invalid records, detached mutations, animation-override isolation and unchanged native HP/AP/frame/queue/RNG. Existing battle/animation, RS healthbox, Gen2 party/AP/map/PACK/live-view, checkpoint/suspend and native process/quit regressions pass. Exact older-public composition `f33d99d2af` passes23 groups including the same995 checks, without replacing its native controllers or adding the newer fork lifecycle/RS bodies. + +This is an observational query contract. It does not qualify the remaining profile, caught-dex, substitute/stat-mask or complete presentation-provider interfaces, or extend previous graphical/performance results to this source. QA uses isolated identities and20GiB free reserve; player profiles and reference mods remain untouched. + +Contributions by Roxas2712. Existing code and third-party rights retain their provenance. See NOTICE.md and LICENSE-ROXAS2712.md for Cedric / UNDERdecoded integration permission and visible-credit requirements. diff --git a/docs/qa/native-foundation/cartridge-semantic-views.md b/docs/qa/native-foundation/cartridge-semantic-views.md new file mode 100644 index 000000000..0b9b52da7 --- /dev/null +++ b/docs/qa/native-foundation/cartridge-semantic-views.md @@ -0,0 +1,54 @@ +# Native Safari text and canonical metatile views + +Contributions by Roxas2712 — https://github.com/Roxas2712. +See LICENSE-ROXAS2712.md for contribution terms and specific integration +permission for Cedric / UNDERdecoded. Existing notices and rights remain intact. + +`core.game3.mb` shares the existing canonical native metatile namespace and +edition translators. It does not maintain another set of behavior IDs, alter +raw cartridge bytes or change collision/classification rules. This spelling +alone does not implement absent named metatile-label constants or door forecasts. + +`Gen3SafariText` projects the current native Safari owner's imported IR through +`native.safari.` keys. It validates the current edition/fingerprint and +native specification, returns detached nodes, and converts only the battle +placeholder representation to the existing formatter's namespace. Native IR, +prose, interpreter operands, rules, HP/AP, balls and RNG remain untouched. +Explicit text/prose edits and tombstones retain priority; translation remains +with the existing text service. There is no cache migration or replacement prose. + +`battle.profile.of(view)` supplies a read-only Safari presentation view for an +actually admitted native Safari battle. Closing/replacing the battle, dataset, +source view or edition retires its fields. It does not advertise rule/action +or theme capabilities. Unsupported native owners expose no fabricated profile. +The source healthbox query delegates the Safari player's panel to the same +native counter painter used by the ordinary native HUD. The opponent panel and +source offsets retain their existing behavior. No second layout or ball counter +is maintained. + +Fork source `cb02fe24a7` on main `4e481a9c` passes 79 CPU groups, including +196 Safari text/profile/HUD checks, 807 existing collision semantics checks, +639 native text IR checks and the current native Safari rules/runtime suites. +Exact older-public composition `30716ff9f5` passes 44 groups: 807 collision, +576 IR checks and 8 import/unsupported-owner/generation boundary checks. It retains +its older native bodies and does not contain the later native Safari owner; +positive Safari controller/HUD qualification is not claimed for that baseline. +The narrow callback and shared helper remain dormant without that native owner. + +The late-text fixture models required native UI stages' text writes rather +than trying to read absent stat/Safari ROM tables from a short synthetic string. +Actual final publication still owns the nativePlain snapshot. The old stages +are restored after the check. CI checks these observations plus existing Gen2 +battle window/scene behavior. + +Unchanged-mod FireRed Safari `549ab98f50` completes its native constructor, +paper view and idle cleanup. Its image exposed the ordinary player HP panel +where Safari balls should appear; that failure is retained as evidence and led +to the shared native-counter delegation. Corrected source `cb02fe24a7` run `69041aad74-firered` exits0 after all five +paper/native/terrarium/native/paper views and idle cleanup. Its inspected paper +frame shows the native Safari counter with30 balls instead of player HP. +An actual Ruby Safari recheck is in progress. Full Safari throw/reaction/capture parity, all-edition/all-map +visual acceptance and sustained60FPS are not inferred. + +QA uses owned profiles, preserves player saves and enforces20GiB free, above +the requested8GiB minimum. Private mods and private runtime files are unchanged. diff --git a/docs/qa/native-foundation/coroutine-phase-scopes.md b/docs/qa/native-foundation/coroutine-phase-scopes.md new file mode 100644 index 000000000..3402df512 --- /dev/null +++ b/docs/qa/native-foundation/coroutine-phase-scopes.md @@ -0,0 +1,56 @@ +# Native coroutine phase ownership + +Native script bodies and Game3 methods remain owned by their existing native +call sites. An actively resumed source child can consume its parent's body once. +A suspended child cannot lend a phase or callback owner to an unrelated caller. + +The shared scope registry selects a coroutine's own active phase before inherited +ones. Nested related phases retain their ordinary order. Ambiguous independent +roots do not lend authority. Parent return retires pending children and borrowed +body claims; their later cleanup cannot restore an expired phase over a newer +one. Retirement also prevents that source coroutine from acquiring another +registry's authority or flushing pending Pokemon writes. Weak heads/locals do +not pin an abandoned coroutine; child registries are allocated only when needed. + +Native failures, including false-valued errors, cannot be swallowed and retried. +Existing source/native return tuples retain their nil positions. The native +controller remains the simulation clock; no action queue or animation sequence +is introduced. + +## Limits and qualification + +This is prerequisite safety work for source-controlled action presentation. It +does not create Engine.resolveMove, an action plan, animation event filtering, +interactive choice policy, or a complete HP/RNG continuation guard. Arbitrary +Lua statements already suspended inside a native body cannot be rolled back or +preempted by a scope registry. The action Card remains gated until its complete +native execution and presentation contracts are qualified. + +Retained baseline `ad7f267d73` reproduces suspended child leakage, consumption of +a newer caller's body and restoration of an expired phase on source `97452fb1`. +Focused source `029e4c5917` passes48 script-phase and30 game/callback tests, plus +existing callback, follower, script, field, battle menu, fly arrival and Gen2 +lifecycle groups. Whole-source and exact older-public composition qualification +follow separately; private mod code and player profiles are not changed. + +The shared script helper is also offered in #282 with identical source bytes. +This offer contains the narrow Game3 phase/attribution integration and both +ROM-free contract cases. Its game-owner fixture uses Emerald, which the older +public tree already admits; it does not claim positive live RS scene acceptance. + +## Frozen combined qualification + +Native code `52a40875` / frozen `22c20c029b` passes all121 selected CPU groups. +Exact older-public composition `27cb70b9bb` passes54 groups, including48 +phase tests,30 callback/game tests and467 real script-space ownership checks. +It applies only the shared once-only helper and published Game3 adapter changes; +older native controller bodies remain unchanged. The first raw script-PR probe +retains a missing NativeSession prerequisite; it is not claimed as an integrated +pass. The raw offered phase helper separately passes48 checks. + +The unchanged private package completes Ruby new-game/naming, real native map +cancel/reopen and flight on this frozen source in run `353d5c5f1d`. Landing setup +is ready under the opaque transition, then the player reaches Littleroot5,9 with +free input,355050 vertices and no mod/renderer errors. Its stationary240-frame +Metal observation averages16.330ms, with p95 24.786ms and max142.098ms. This is +not a stable-sixty-FPS, all-view, continuous-story or visible-desktop claim. diff --git a/docs/qa/native-foundation/frame-widget-presentation.md b/docs/qa/native-foundation/frame-widget-presentation.md new file mode 100644 index 000000000..aeebc6df0 --- /dev/null +++ b/docs/qa/native-foundation/frame-widget-presentation.md @@ -0,0 +1,35 @@ +# Native frame widgets + +Contributions by Roxas2712 — https://github.com/Roxas2712. +See LICENSE-ROXAS2712.md for the contribution terms and specific integration +permission for Cedric / UNDERdecoded. Existing notices and rights remain intact. + +The existing Font user, standard, dialogue and signpost helpers offer `frame` +contexts to the native presentation provider. Pixel bounds come from the same +native record/cap rules used by actual painting, including old caches without +dialogue/sign artwork. The source tile-template helper borrows the native standard +frame rather than the user-selected frame. No new controller, atlas or draw clock +is introduced. + +A provider handles the decoration or falls back to the native helper. Repeated +base requests borrow one paint; native helper-to-helper fallbacks remain native +and cannot recursively re-enter the provider. Native failures remain fatal even +when caught by a decorator. Base return tuples and the native font's colour/font +postconditions survive provider isolation, both inside a base caller and before +the next ordinary label. Provider canvas/scissor/stack changes remain fenced. + +`frameWidgets` is now advertised alongside `nativeUiStack` and `windowLayers`. +The complete PC/bag/naming/summary/battle theme families remain gated. Frames +without an admitted provider, and all other generations, keep their native bodies +without creating frame contexts. + +Frozen fork `5cb5cc460c` passes 62 CPU groups, including 657 frame/provider checks; +independent older-public composition `fd7c59ce81` passes 33 groups with the same +657 checks. Fork GPU `de618dc80c` and public GPU `e710c2c5d9` each pass 4,322 checks +across 480 frames on both Metal and OpenGL. Owned asymmetric frame strips and the +following native label compare against ordinary native output: five editions, +all four frame kinds, imported/fallback artwork, DPI 1/2, scale 1/2 and false, +borrowed-base and failed-provider paths. These synthetic pixel checks do not +establish complete themed-family, cartridge-art, full-game, window or sustained +performance acceptance. QA keeps isolated profiles and at least 20 GiB free; +player saves and private runtime files remain untouched. diff --git a/docs/qa/native-foundation/gba-battle-source-boundary.md b/docs/qa/native-foundation/gba-battle-source-boundary.md new file mode 100644 index 000000000..5f24b3cc6 --- /dev/null +++ b/docs/qa/native-foundation/gba-battle-source-boundary.md @@ -0,0 +1,75 @@ +# Native GBA battle source boundary + +This is an incremental presentation and observation bridge over the existing +`BattleState`. It does not establish complete unchanged-package boot or play. + +## Implemented boundaries + +- The admitted native stack owns battle identity, input, combat, RNG and exit. +- Stable source observations translate cartridge species/move/item/ability/type + identifiers and flattened Pokemon fields. Trainer class and trainer id remain + different fields. HP/status changes and PP transactions reach native Pokemon; + unsupported move replacements fail before an update. +- Native start notifications, update, UI pump, whole-screen drawing and the + background pass have independent scopes. Repeated base calls consume each + native body once. Calls outside an update scope cannot tick combat. +- Every queued native update/draw rechecks the original admission after source + hooks, including the inner UI/background hooks. Save, dataset, loader, stack, + edition and imported catalogue replacement retire the old binding; restoring + an earlier pointer cannot revive that binding. Native method bodies execute + outside a source callback owner. +- Explicit battle/UI captures suppress the repeated native screen draw within + the same renderer frame and render target. A separate preview target cannot + suppress the main screen. Capturing never advances combat. +- LuaJIT `pairs`/`next` expose typed battle fields, preserving nested typed + identity. Iterator registry values are weak, so an unused observation cannot + retain its own weak key through a closure. +- Covered battles remain observable, but cannot update or pop their cover. + Mod retirement invalidates its source views without cancelling native combat. + Completed views keep detached observations and reject further native writes. +- Basic source wild/trainer requests reuse the native encounter admission and + cleanup provider when present. Authored source parties, extended options, + headless simulation and source-controlled link termination remain unsupported. + Constructor-request acceptance is not yet independently qualified here. +- Pokemon observations use native imported records. Height and weight convert + metres/kilograms to cartridge decimetres/hectograms. Static pictures are owned + by the source service; native animation frames remain borrowed. +- Separate presentation players reuse native `MonAnim`/`PicAnim` timing. Their + timing is the existing native implementation, not a proof of exact source + sprite callbacks. They cannot consume combat commands. + +Older native controllers without HUD visibility or move-grid query methods +return an explicit unavailable reason for those observations. This does not +advertise the complete presentation protocol on that host. + +## Qualification + +The focused engine suites cover all five registered GBA editions using +synthetic imported data and actual native constructors, stack admissions and +exit. They exercise repeated base calls, covered states, UI input/draw scopes, +owner/data/edition retirement, numeric identity, PP and detached final views. +View-only collection checks isolate this bridge from the host's global facade +cache; they do not certify the entire host's Game/save collection policy. + +`tools/battle-source-runtime/host` draws the actual native `Gen3Battle` on a +GPU. It compares default and wrapped pixels, replaces only the field pass, +checks that the native command-area pixels remain identical, and checks +graphics restoration after a failing callback. Artwork and battle records are +synthetic; these are not original-package or cartridge gameplay checks. + +## Remaining presentation work + +The UI module deliberately does not advertise the complete sprite/particle +presentation protocol. Native animation VM, effect, healthbox, trainer, ball, +text, catch, switch and intro presentation interfaces still need their own +bindings. So do complete party/save collection schemas and authored source +Pokemon operations. Original-package initialization, real play sequences, +source effect timing/art parity and physical target acceptance remain open. + +The last unchanged-package loader probe advances beyond battle, Pokemon, +motion and UI imports to the missing `src.core.game3.battle.anim` interface. + +Older native hosts also retire already-loaded battle, Pokemon-picture and +motion services at loader unwind, even when their stream service is not yet +driven by loader retirement. This releases observations and owned pictures; +it does not pop or finish the actual native battle. diff --git a/docs/qa/native-foundation/gba-color-effects.md b/docs/qa/native-foundation/gba-color-effects.md new file mode 100644 index 000000000..eed51f9bf --- /dev/null +++ b/docs/qa/native-foundation/gba-color-effects.md @@ -0,0 +1,11 @@ +# Native colour-effect drawing + +`gba_fx.draw/active/withClip` now cover five-bit fade colours, grayscale, brightness bands, object-window masks and clipping. Alpha combines the transformed source with the native underlay snapshot in one pass. The draw callback runs once, including when fade and alpha are combined; two simulated blend passes cannot consume a native drawing receipt twice. + +Each nested draw uses a separate shader bank, so restoring a shader handle also restores the caller's effective uniforms. A bounded pool reuses programs and native alpha surfaces; masks remain borrowed and are detached after painting. Errors restore render target, shader, colour, blend and scissor. Teardown during a painter is deferred until the active draw exits. Object-window/band coordinates use logical target pixels, including DPI2; the existing default native-alpha program retains its integer arithmetic. + +Fork source `929266d044` passes38 groups including93 effect/ownership tests and the earlier profile, coordinate, battle, Gen2, checkpoint/suspend and session regressions. The same frozen bytes also pass the two additional native level-up streak/facade contracts. Exact older-public composition `c72042ec0c` passes25 groups plus those two level-up contracts. The shared alpha helper extension belongs to PR339; the drawing interface belongs to PR329. Small checked helper patches retain the older public native controllers. + +Owned-output GPU probes pass346 checks each on Metal/OpenGL for both fork `3baa88a3d7` and public `da50b66c1f`. Samples cover all17 fade coefficients, brightness, grayscale ordering, combined alpha, object windows, bands, clips, default native alpha and DPI1/2. Each run observes64 painter calls. These are ROM-free pixel/resource tests, not full game, OS-window or sustained performance acceptance. Reference mods and player saves remain unchanged; managed QA enforces20GiB free reserve. + +Contributions by Roxas2712. Existing native code and third-party rights retain their provenance. See NOTICE.md and LICENSE-ROXAS2712.md for Cedric / UNDERdecoded integration permission and visible-credit requirements. diff --git a/docs/qa/native-foundation/hoenn-dex-observations.md b/docs/qa/native-foundation/hoenn-dex-observations.md new file mode 100644 index 000000000..1a05c38fd --- /dev/null +++ b/docs/qa/native-foundation/hoenn-dex-observations.md @@ -0,0 +1,58 @@ +# Native Hoenn dex/search observations and RS menu text + +Contributions by Roxas2712 — https://github.com/Roxas2712. +See LICENSE-ROXAS2712.md for the contribution terms and specific integration +permission for Cedric / UNDERdecoded. Existing notices and rights remain intact. + +`ui.game3.rse.pokedex.active()` observes the actual constructor-admitted Hoenn +listing/search screen. It exposes zero-based selection and mode, national mode, +and detached parameter cursor/scroll records. Closing/replacing the screen, +stack, save, dataset, loader or source view retires retained observations. +Observation cleanup never closes a native menu or mutates native preferences. +The fork binds both the existing RS controller and generic Hoenn controller; +the older-public offer binds the controllers present on its own baseline. +No later RS controller body is copied into that composition. + +RS semantic battle-text reads select the actual cartridge's command prompt and +swap prompt. PP and type each select their own line of the shared native record; +both preserve leading structured controls. The active-Pokemon prompt remains +separate from the player-name/Safari prompt. Explicit canonical prose/record +edits, removals and translation remain authoritative. Queries retain no dataset. +The label relationship is documented by the public +[RS battle strings](https://github.com/pret/pokeruby/blob/5784633ce4ef7ade1a7f2d2d0c288e3d5e6cdd7f/src/data/battle_strings_en.h). +No replacement cartridge lettering or cache migration is introduced. + +`Gen3TextPen` shares measurement and painting of embedded positioning controls +through the native proportional font. Minimum-width cells preserve complete +Unicode/charmap ligatures; clipping skips ink without changing pen position. +Ordinary strings keep the existing path. Unsupported/truncated positioning +controls fail before any glyph is painted, with native face/shader/colour state +restored. This is a bounded synchronous layout adapter, not a dialogue VM. +The current-main Safari composition also paints the RS user frame once while +retaining the native labels/cursor and native battle state. + +Frozen fork `0f4111a361` on main `4e481a9c` passes 72 CPU groups, including +214 Hoenn observation, 56 semantic RS text, 180 pen-layout and 22 RS Safari +frame checks. Exact older-public composition `6248b0294b` passes 37 groups, +including 174 Hoenn, 56 RS text and 180 pen checks. The smaller Hoenn count +reflects the absent later RS controller; its constructor is not claimed tested. + +Owned-output probes `b34e4d27e7` (fork source `f08e55c19c`) and `bbd293c34c` +(public source `a56e42c307`) each pass 402 checks / 160 frames on each Metal +and OpenGL backend: actual native font shaders, asymmetric glyphs, main/small +faces, HD off/on, DPI 1/2, scale 1/2, Unicode/ligatures and rejected malformed +controls. These probe the pen/font paths; later semantic text selection is +covered separately by the newer CPU snapshots and actual runs. + +Unchanged-mod Ruby run `0fe7c55780-ruby` completes the native trainer battle, +paper/native/terrarium/native/paper view changes, move swap/restore/cancel and +idle presentation cleanup with exit 0. Its images exposed the subsequent pen +and prompt-selection fixes; it is not accepted as evidence of those later fixes. +Fresh runs `92d67353a1-ruby` and `1f2a1ba9d3-sapphire` both exit 0 on +source `0f4111a361`: clean AP labels, all five trainer-battle views, native +move swap/restore/cancel, actual RS dex constructors and parameter navigation, +then native close and observation retirement. Those real RS constructors are +qualified separately from the earlier typed-state CPU fixtures. +No complete gameplay, all-map visual or sustained-FPS acceptance is inferred. +QA uses owned disposable profiles, preserves player saves and enforces at least +20 GiB free, exceeding the requested 8 GiB. diff --git a/docs/qa/native-foundation/host-file-picker.md b/docs/qa/native-foundation/host-file-picker.md new file mode 100644 index 000000000..eb62f7e6a --- /dev/null +++ b/docs/qa/native-foundation/host-file-picker.md @@ -0,0 +1,33 @@ +# Shared native desktop file selection + +Contributions by Roxas2712 — https://github.com/Roxas2712. +See LICENSE-ROXAS2712.md for contribution terms and the specific integration +permission for Cedric / UNDERdecoded. Existing rights/notices remain intact. + +Source consumers can use `FilePicker.available/open/commands/matches/read/basename` +and the standard image filter. The native importer's by-extension picker uses +that same command builder. Dialogs still launch only through its existing +pointer-release path; importing or querying the module never creates Game or a +save owner. Reads are binary and read-only, with handles closed on read failure. + +The builder validates filters and preserves UTF-8 titles as data. POSIX shell +and AppleScript quoting are separate. Windows passes dynamic title/filter data +as byte arrays, preserves the existing no-copy fast path for ASCII paths, and +uses unique non-ASCII staging paths with fail-closed copy errors. A canceled +Linux chooser does not open a second dialog; it selects the available zenity +or kdialog backend before launch. Unsupported hosts retain their existing +mobile/no-picker workflows. Failed dialog reads close their pipe and return +no selected path. + +Frozen fork `939f268ed2` passes 76 CPU groups. Exact older-public composition +`d6a78527f9` passes 41 groups. Both include 90 modeled desktop-picker checks, +27 existing pointer-grab checks, 12 no-picker checks and 14 Android picker +checks. The public test's older source-text counter was counting comments as +process calls; it now excludes comments, as the current native test already +does. The actual pointer-release scenarios and single process funnel remain +checked. The public composition retains its existing native shell/importer +bodies and applies only these narrow changes. CI runs the four picker suites. + +No real Windows/Linux dialog, desktop-wide UI acceptance or sprite-pack +installation is inferred from command-model tests. QA uses owned profiles, +preserves player saves and enforces 20 GiB free, above the requested 8 GiB. diff --git a/docs/qa/native-foundation/move-effect-symbols.md b/docs/qa/native-foundation/move-effect-symbols.md new file mode 100644 index 000000000..3aee59fa4 --- /dev/null +++ b/docs/qa/native-foundation/move-effect-symbols.md @@ -0,0 +1,31 @@ +# Canonical native move-effect symbols + +Contributions by Roxas2712 — https://github.com/Roxas2712. +See LICENSE-ROXAS2712.md for the contribution terms and specific integration +permission for Cedric / UNDERdecoded. Existing notices and rights remain intact. + +`Gen3EffectCatalog` provides constant-time ID/name queries and detached snapshots +for all 214 GBA dispatch slots, including unused slots. The canonical identifier +facts come from [the pinned public constant header](https://github.com/pret/pokefirered/blob/ce6406d8c2eaf46d543c8a2606de0c3cb1951982/include/constants/battle_move_effects.h) +(SHA-256 `fa15e285903818cc3cc7ecb2ca32c11d8317b14d261ca2d1caf44e614b53828a`). +The exact header is retained separately as a factual test fixture. Runtime storage +uses one compact ordered list and two lookup tables built once, with no session, +save, importer or combat bootstrap. + +`battle.effect_ids` returns its own snapshot, so decorating source constants +cannot mutate native authority. `battle.moves.get(...).effectSymbol` identifies +the current imported raw byte. Unknown custom bytes remain usable and unnamed; +old records without the byte are never reverse-guessed from native dispatch +names. Native move records, cache formats and effect execution stay unchanged. +Publishing an identifier does not implement an absent effect, action readiness +gate, executor or animation event producer. + +Frozen fork `27e2bc33e1`, refreshed with main `e6dbce6a`, passes 64 CPU groups, +including 4,531 symbol/snapshot/imported-query checks and the merged optional +image failure/retry checks. Independent older-public composition `f4e264523f` +passes 34 groups with the same 4,531 symbol checks; its native controllers and +asset owner remain separate. Unchanged-mod run `e4a3201258-firered` completes the +real trainer, five view changes and native move-swap/restore/cancel with exit 0 +and idle cleanup. No full action-command, all-edition gameplay, complete visual +or sustained-FPS acceptance follows. QA keeps isolated profiles and at least +20 GiB free; player saves and private runtime files remain untouched. diff --git a/docs/qa/native-foundation/move-rule-metadata.md b/docs/qa/native-foundation/move-rule-metadata.md new file mode 100644 index 000000000..7395a5631 --- /dev/null +++ b/docs/qa/native-foundation/move-rule-metadata.md @@ -0,0 +1,44 @@ +# Native move and critical-rule observations + +Contributions by Roxas2712 — https://github.com/Roxas2712. +See LICENSE-ROXAS2712.md for the contribution terms and specific integration +permission for Cedric / UNDERdecoded. Existing notices and rights remain intact. + +`battle.moves.get` now publishes the actual imported move index as both `id` +and `numId`, with `nativeId` retaining the native registry key. Numeric strings +and the existing `{id=...}` / `{move=...}` forms resolve through BattleIds. +The effect field projects the validated cartridge `gen3Effect` byte, including +zero; `nativeEffect` retains the native dispatch name. Different cartridge effects +can share that name, so an old cache without the original byte is never reverse +guessed. Invalid present bytes are rejected with a diagnostic. Native records and +effect dispatch stay unchanged. + +The query also preserves native flags, percentage secondary chance, the existing +native probability, contact/critical properties and multi-hit metadata. Nested +flag/hit tables are detached, including explicit false values. A native hit +producer is described as present rather than invoked or exposed as another move +execution path. Each query reads the current registry; no new cross-frame move +cache hides live native edits or catalogue replacement. + +`battle.rules.crit.CHANCE` returns a detached zero-indexed denominator table from +the same private constant used by native Damage. Mutating it cannot alter native +critical rolls. This is GBA metadata only: it does not provide an action executor, +input suspension, animation event producer, RNG owner or complete gameplay +profile. Optional action commands still need those native phase contracts before +they can be enabled. + +Current-main integration retains both the Gen2 typed queue admission/completion +and existing GBA switch/capture/streak/voiceover hooks. Qualification checks those +owners together, including GBA effect/Substitute timing and native Gen2 event +checkpoint reconstruction. The older-public composition keeps its existing +critical calculation and RNG origin; the portable fixture checks its own stage +inputs and denominator span without substituting a later native controller. + +Frozen current-main source `90363e8026` passes 61 CPU groups, including 630 new +move/rule checks, 801 typed-queue checks, 27 typed-checkpoint checks, 13,778 GBA +effect checks, 6,338 Substitute presentation checks and 104,512 Substitute fade +checks. Older-public source `f4b45ce538` separately passes 32 CPU groups with the +same 630 move/rule observations. These are metadata and native phase regressions, +not complete action-command, full-game, visual or sustained-FPS acceptance. +QA uses isolated owned profiles and at least 20 GiB free; player saves and private +runtime files remain untouched. diff --git a/docs/qa/native-foundation/particle-pass.md b/docs/qa/native-foundation/particle-pass.md new file mode 100644 index 000000000..60ee15c89 --- /dev/null +++ b/docs/qa/native-foundation/particle-pass.md @@ -0,0 +1,15 @@ +# Native particle passes + +The unchanged battle bridge calls `Anim.drawParticles` in bands around its battler captures. The native binding borrows existing event and copied-background painters; it does not create particles, a script VM, a task scheduler or another frame clock. + +`Gen3Battle.moveOamObjects` factors the native hardware ordering already used by `drawMoveOamObjects`. The default native painter retains its existing singles path and doubles fallback. The explicit source pass can classify all four real battlers into source bands, shares one ordering sample per guarded UI capture, and uses actual native priority, subpriority, live OAM Y and retained tie ranks. First exact key/Y ties retain the existing `global-sprite-slot-history` residual. Unknown direct OAM events use the existing native event-order/front-layer fallback; no priority is invented. This is not complete hardware sprite-slot parity. + +The pass borrows native monbg copies before/within/after OBJ as appropriate, then native Surf/flourish extras with `skipEvents`, so OBJ are not drawn twice by that extra layer. Existing particle decorators retain their native event positions. Each draw is graphics-scoped; replaced admissions/clips, owner teardown and callback failures stop the borrowed pass without advancing or cancelling the native effect. The native OAM tie diagnostic is also classified as presentation in suspend witnesses. + +The new ROM-free fixture compares the actual native singles painter trace against source bands over all five GBA admissions, checks separate captures at the same frame, query-only clocks/RNG/HP/queue, native particle decorators and failed-callback target restoration, changed cohorts, actual generic monbg copies, unknown-key fallback and all four real doubles slots. Current-main frozen fork `83b3a9d0c3` passes all19 groups, including288 particle and758 selection checks plus the current native Gen2 window/queue/scene/live-view/summary and suspend regressions. Source/test hashes match the candidate. The first attempt `7970e2a8aa` correctly selected the native fallback because its synthetic import omitted battler coordinates; that failed fixture is retained and its explicit imported coordinate data is now present. This is a combined-fork CPU qualification; the exact public composition and actual graphics reprobe are still pending. No actual graphics fix, clean map/terrarium frame, physical-window result or FPS acceptance is claimed. + +Earlier CPU probe `95cc3d63ee` reaches a real missing `Anim.drawParticles` call in the unchanged bridge. Actual battle probe `43b5eb8d59-firered` reaches the native command and stops earlier at missing `Ui.targetHidden`; its separately offered selection binding still needs actual graphics retesting. Tests and future graphics runs use isolated QA profiles and never player saves. + +Contributions by Roxas2712. Existing native code, art and applicable third-party rights retain their provenance. See NOTICE.md and LICENSE-ROXAS2712.md for Cedric / UNDERdecoded integration permission and credit requirements. + +Public baseline note: this older public branch does not contain the fork-only SuspendJournal owner. Its one-line OAM diagnostic classification and semantic-witness fixture remain in the fork offer; no private/native Journal body is copied into this public PR. diff --git a/docs/qa/native-foundation/picture-identity.md b/docs/qa/native-foundation/picture-identity.md new file mode 100644 index 000000000..fb36045a7 --- /dev/null +++ b/docs/qa/native-foundation/picture-identity.md @@ -0,0 +1,11 @@ +# Native picture identity + +Actual unchanged-mod run `998ae70f38-firered` passes the previous stage/target/bounce calls, reaches native command selection, then actor capture fails because `Pokemon.picSpecies` is missing. Native source Pokemon queries now expose registered cartridge picture identities, base-picture normalization and Unown personality selection without inventing logical species records or using National Dex numbers as internal slots. + +The native GBA graphics namespace has Unown A at201 and its other27 pictures at413..439, independently of the412-row name table. The fixed supported edition profiles all declare that name-table size. The personality selector reads the low two bits of each byte and normalizes signed uint32 representations. It uses portable integer arithmetic without requiring a bit library during module loading. Reference: [pret/pokefirered species constants](https://github.com/pret/pokefirered/blob/ce6406d8c2eaf46d543c8a2606de0c3cb1951982/include/constants/species.h) and [LoadSpecialPokePic](https://github.com/pret/pokefirered/blob/ce6406d8c2eaf46d543c8a2606de0c3cb1951982/src/decompress.c). + +Explicit registered native content takes precedence over a picture alias; an alias collision is rejected rather than displaying a different logical species. Ordinary picture requests retain one native context lookup and their existing owned-image cache. Current native imports do not expose the27 extra Unown pictures, so those requests return an explicit missing-art result instead of silently displaying A. Other alternate-form pictures, dynamic Spinda artwork and full picture protocol parity remain open; this query does not claim those features. + +Frozen current-main candidate `f7513c67d9` passes syntax and all18 targeted suites:532 picture identity checks,206 existing Pokemon metadata/art-ownership checks, the qualified selection/particle/stage/battle/UI/ball tests and current Gen2 Unown/palette/scene/live-view/summary/window/queue regressions. Source/test hashes match the candidate. A new actual graphics probe is running. No clean map/terrarium frame, physical-window result, full gameplay or60FPS acceptance follows from these CPU tests. QA enforces20GiB free and never uses player saves. + +Contributions by Roxas2712. Existing native code and applicable third-party rights retain their provenance. See NOTICE.md and LICENSE-ROXAS2712.md for Cedric / UNDERdecoded integration permission and credit requirements. diff --git a/docs/qa/native-foundation/playfield-query.md b/docs/qa/native-foundation/playfield-query.md new file mode 100644 index 000000000..05083947d --- /dev/null +++ b/docs/qa/native-foundation/playfield-query.md @@ -0,0 +1,22 @@ +# Native playfield cutout query + +`src.render.Playfield.cutout(width, height)` observes the rectangle reserved by +the existing native `GameViewport` during its current frame. This is the lazy +query required by unchanged battle/menu layout consumers; it does not install +a skin, allocate a target, change a scissor or begin another viewport phase. + +Explicit full-window dimensions return the native rectangle in window space. +Dimensions of the already entered native target return local origin `(0,0)` +and its actual extent. This avoids subtracting the same window offset twice. +Logical dimensions do not change with DPI. Unrelated preview/actor target +sizes, invalid dimensions, a retired frame and the ordinary full-window path +return no reserved cutout, preserving the caller's safe-area/touch fallback. + +Only `cutout` is offered. The independent `push`/`pop`/skin-owner protocol is +not advertised. Native Gen2 rendering still enters and exits its existing +viewport directly. + +The actual unchanged-package native battle probe `cf9cd39d2b-firered` reaches +native entry but fails before scene frames because this module was absent. +Keep that failed receipt; CPU geometry checks alone do not qualify the later +map/terrarium frame, physical-window output, portrait touch play or FPS. diff --git a/docs/qa/native-foundation/presentation-profiles.md b/docs/qa/native-foundation/presentation-profiles.md new file mode 100644 index 000000000..3b629848d --- /dev/null +++ b/docs/qa/native-foundation/presentation-profiles.md @@ -0,0 +1,11 @@ +# Native presentation profile queries + +Rendering adapters select their edition family, proportional font and RS sprite layout through `profile.forSession`. These facts now come from the native version registry and existing font module. Queries do not bootstrap another game or choose another cache, save, importer or rules engine. Other generations and unknown editions are rejected rather than redirected to a GBA fallback. + +Profiles and nested font/UI records are immutable and reused while their version facts remain unchanged. Replacing a native registry record or changing its label/layout invalidates the cached projection. The cache holds edition metadata only, not a game/session/save. This surface deliberately does not advertise a full gameplay, extractor or screen profile. + +Fork source `7372419961` on main `e6020353` passes37 groups, including266 profile checks across all five GBA editions, real native proportional-font measurements, coordinate/battle/Gen2 regressions and both process/mounted-session shutdown contracts. Exact older-public composition `e05a1f91ac` passes24 groups with the same266 checks, retaining its older native controllers. The initial fixture also caught a non-string-ID boolean guard; corrected before qualification. + +The unchanged adapter's CPU audit `14c959a524` now draws both battlers and the captured-actor fallback in all five editions; the previous missing-profile failures are gone. Its full UI reaches a synthetic fixture's absent `gText_BattleMenu` record. This is a fixture limit, not a new actual-game failure or visual acceptance. The remaining colour-effect and complete device/provider interfaces stay separate. Reference mods and player saves remain unchanged; managed QA enforces20GiB free reserve. + +Contributions by Roxas2712. Existing native code and third-party rights retain their provenance. See NOTICE.md and LICENSE-ROXAS2712.md for Cedric / UNDERdecoded integration permission and visible-credit requirements. diff --git a/docs/qa/native-foundation/presentation-providers.md b/docs/qa/native-foundation/presentation-providers.md new file mode 100644 index 000000000..ec1215085 --- /dev/null +++ b/docs/qa/native-foundation/presentation-providers.md @@ -0,0 +1,62 @@ +# Native presentation providers + +Contributions by Roxas2712 — https://github.com/Roxas2712. +See LICENSE-ROXAS2712.md for the contribution terms and specific integration +permission for Cedric / UNDERdecoded. Existing notices and rights remain intact. + +`src.ui.game3.presentation` and the native compositor share one +`NativePresentation` owner. API version 1 supports provider registration, +enablement, drawing/notifications, widgets, deferred layers, window completion +and health observations. Registration records the calling mod owner independently +of its provider ID, so ordinary mod cleanup retires both. Replaced registrations +have distinct disposer identities. + +The native Game frame admits the current source game view. Its data, save, loader, +edition, stack identity and entire state sequence must remain current until each +paint. A retired view also invalidates the frame. The service owns no simulation, +input queue, save format, imported image or extra rendering loop. + +Native modal groups offer `ui_stack` layers without moving world or battle scene +owners into the UI pass. The compositor flushes them on its current window target +before the native battle transition, veil and final output. The existing explicit +compose-hook takeover flushes after that hook returns. A provider's `present` +callback controls its own window layout; fallback uses the native UI surface and +the compositor's fitting policy. It keeps capture/postprocess targets bound. + +Widget and deferred native painters are once-only leases, including their return +tuples. Provider failure or mid-frame disable/removal returns admitted work to the +native fallback. A swallowed native error still fails the frame. Graphics state, +canvas, scissor, leaked pushes and callback ownership are fenced. Finished leases +release their native closure and frame references; nested layers during flush +paint inline. Limits are 64 registered providers and 128 queued layers per frame; +additional layers paint immediately. Session shutdown drops stale callbacks. + +Only `nativeUiStack`, `windowLayers` and the separately qualified `frameWidgets` +are advertised. PC, bag, naming, summary, +battle paper devices and other themed families need their actual native call +sites and context projections before their feature flags can become true. This +foundation deliberately does not enable an experimental theme or claim those +families are complete. Other generations keep their existing compositor. + +Frozen fork `f4f6a295a7` passes 48 CPU groups, including 538 provider checks. +Owned-output run `70560fd6d9` passes 1,682 checks across 240 frames on each of +Metal and OpenGL: five editions, logical widths 240/320, target DPI 1/2, two +window sizes, fallback/error cases and nested painting. Unchanged-mod run +`1eb7007342-firered` completes a real trainer, five view changes and native +move-swap/restore/cancel input with exit 0 and idle cleanup. The older-public +composition `1dbf66c04e` independently passes 31 CPU groups with the same 538 +provider checks. Its separate owned-output run `461c91ab60` also passes 1,682 +checks across 240 frames on each backend. + +Earlier trainer benchmark `58ae54db42-firered`, source `1830bd24b6`, records +240 readback-free intervals per view averaging about 60 FPS in paper, native +and terrarium views. Its driver finished, but the wrapper failed in a later +macOS screen-lock diagnostic timeout. A separate recovery receipt retains +that failure and the source association; it does not relabel the run accepted. +The diagnostic starter now records that optional timeout rather than discarding +native completion metadata. + +These checks establish bounded frame and integration behavior, not complete +family, full-game, all-edition visual, OS-window or sustained performance parity. +QA uses fresh owned profiles, preserves player saves and retains at least 20 GiB +free. diff --git a/docs/qa/native-foundation/rs-menu-cursor.md b/docs/qa/native-foundation/rs-menu-cursor.md new file mode 100644 index 000000000..b116a5441 --- /dev/null +++ b/docs/qa/native-foundation/rs-menu-cursor.md @@ -0,0 +1,29 @@ +# Shared native RS outline cursor + +The public cursor and the fork's native RS battle panel share +`Gen3RsMenuCursor`. It uses the imported common-UI manifest and central native +Assets cache. Detached segment queries preserve the cartridge's cap/overlap +positions; a bounded sixteen-width plan cache avoids constructing strips on +ordinary repeated draws. Edition/dataset replacement, asset invalidation and +owner teardown retire manifest metadata. Borrowed images are never released by +the cursor, and graphics failures restore the caller's state. + +Images retain their logical 8/32 by16 size independently of texel density. +The native controller changes only its cursor binding; no rule, input, queue, +clock, random or save owner is replaced. The older-public composition offers +the shared drawing service and public facade against its own controller set; +it does not overlay the fork's later RS controller body. + +Fork frozen source `16fd33d5e5` passes45 groups, including644 cursor checks and +RS native-panel readiness. The older-public composition `b60bac3ca5` passes28 +groups, including131 portable cursor checks. The fork additionally compares +513 widths against its retained pinned native reference. + +Owned-output GPU runs `acc5ab25cd` (fork) and `bb8bfe6dd8` (public) each pass +1474 checks across256 constructed frames on Metal and on OpenGL: golden cap/ +overlap placement, DPI1/2, texture density1/2/3, fades/grayscale/brightness, +and actual native two-tone font pixels through the indexed colour facade for +all five editions. Synthetic artwork is deliberate: these are drawing-contract +proofs, not cartridge-art, OS-window, full-game or sustained-FPS acceptance. +Managed QA keeps20GiB free and uses separate identities; reference mods and +player saves are untouched. diff --git a/docs/qa/native-foundation/window-cursor.md b/docs/qa/native-foundation/window-cursor.md new file mode 100644 index 000000000..bf73463e4 --- /dev/null +++ b/docs/qa/native-foundation/window-cursor.md @@ -0,0 +1,7 @@ +# Native pixel cursor binding + +Actual unchanged-mod run `78b938448b-firered` stops in the action-menu painter at missing `Window.cursorPx`. The pixel cursor now borrows the current native Theme cursor symbol and the existing imported proportional-font painter. Native colors, optional face selection, proportional advance and graphics-state/error restoration remain owned by those existing helpers. No private glyph atlas or hardcoded replacement cursor is added. + +Frozen source `ee555e80d2` passes syntax and all23 targeted suites (24 groups), including145 glyph/cursor checks,5344 tutorial/UI checks and existing native menu/battle/START, Gen2 window/queue/scene/live-view/summary, checkpoint and suspend regressions. The fixture changes the native symbol between draws and verifies both actual pixel coordinates and restored face/shader/color state. Actual unchanged-mod graphics reprobe is running; clean scene, OS-window and60FPS acceptance remain open. QA uses isolated profiles and20GiB free reserve; player saves remain untouched. + +Contributions by Roxas2712. Existing native code and third-party notices retain their provenance. See NOTICE.md and LICENSE-ROXAS2712.md for Cedric / UNDERdecoded integration permission and credit requirements. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 297e84c74..8c9400a5f 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -10,6 +10,7 @@ -- bide, recharge, confusion, screens, substitute, transform, ...) is -- ported from engine/battle/core.asm; see docs/behavior-porting-notes.md. +local nativeMoveUsed = {} -- identity of the native announcement producer local Assets = require("src.render.Assets") local Catching = require("src.battle.Catching") local Damage = require("src.battle.Damage") @@ -53,6 +54,8 @@ local TEXT_AREA_ROWS = { 112, 128 } local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Native source observations identify this admitted controller directly. +BattleState.isBattleState = true -- pokered prints the battle lines itself (engine/battle/core.asm and the -- move-effect banks), and the importer extracts every one of them, so the @@ -596,10 +599,7 @@ function BattleState:sgbPalettes() return nil end -local Rulesets = { - gen1_faithful = require("src.battle.rulesets.gen1_faithful"), - modern_clean = require("src.battle.rulesets.modern_clean"), -} +local Rulesets = require('src.battle.Rulesets') -- the Poké Ball toss chain (TossBallAnimation) plays even with battle -- animations off: PlayMoveAnimation jumps to it before checking wOptions @@ -936,7 +936,9 @@ local StatBox = {} StatBox.__index = StatBox function StatBox.new(game, mon, onDone) - return setmetatable({ game = game, mon = mon, onDone = onDone }, StatBox) + local state=setmetatable({ game = game, mon = mon, onDone = onDone }, StatBox) + if GameVersion.isGen3()then require('src.compat.gen1.NativeModalObservation').capture(state,game)end + return state end function StatBox:update() @@ -1364,6 +1366,12 @@ function BattleState.giveWildHeldItem(data, mon, rng, compoundEyes) end function BattleState.newWild(game, species, level, opts) + local authored + if opts and opts.wildRecord then + local why;authored,why=require('src.battle.WildRecord').validate(game.data,opts.wildRecord) + assert(authored,why) + assert(authored.species==species and authored.level==level,'encounter identity mismatch') + end local self = newBattle(game) self.kind = "wild" local playerMon = Party.firstHealthy(game.save.party) @@ -1428,6 +1436,7 @@ function BattleState.newWild(game, species, level, opts) if opts and opts.roamerSeed then Pokemon.applySeed(game.data, wild, opts.roamerSeed) end + if authored then require('src.battle.WildRecord').apply(game.data,wild,authored)end self.enemy = makeBattler(game.data, wild, false) require("src.battle.Gen2Wild").initialize(self, opts) if self.roamer then @@ -2318,9 +2327,12 @@ end -- insert message right after the currently-executing queue item (the -- counter is reset by updateQueue before each fn item runs) -function BattleState:sayNext(text) +function BattleState:sayNext(text, provenance) self.nextInsert = (self.nextInsert or 0) + 1 - table.insert(self.queue, self.nextInsert, { text = text }) + local row = { text = text } + local sequence=GameVersion.isGen3()and package.loaded['src.core.game3.battle.anim_seq'] + if sequence and provenance==nativeMoveUsed then sequence._nativeMessage(row)end + table.insert(self.queue, self.nextInsert, row) end -- insert a UI push right after the current queue item (dex page, the @@ -2593,11 +2605,9 @@ function BattleState:beginMsgLine() self.shown[#self.shown + 1] = {} end -function BattleState:updateQueue() - if self.waitingUI then - if self.game.stack:top() ~= self then return true end - self.waitingUI = nil - end +-- Only display controllers live here. Command holds, HP awards, turn rows +-- and completion remain in updateQueue, even while a mod holds presentation. +function BattleState:updateMovePresentation() -- HOENN'S PARTICLES TICK BEFORE THE QUEUE'S OWN HOLD, and the whole -- reason the animations looked broken is that they used to tick after it. -- @@ -2648,11 +2658,16 @@ function BattleState:updateQueue() if not anim.caught then self.gen3Ball = nil end end end +end + +-- Display-only waits and bar completion. Returning idle grants no command; +-- only updateQueue may consume a battle row after this gate. +function BattleState:updateQueuePresentation() + self:updateMovePresentation() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if source and source._holdsNativeQueue(self) then return true end -- a queued hold (faint slide, hit blink) counts down before the next row - if self.waitFrames and self.waitFrames > 0 then - self.waitFrames = self.waitFrames - 1 - return true - end + if self:stepPresentationWait() then return true end -- WaitForSoundToFinish (home/delay.asm:15-20) blocks until the sfx has -- actually stopped sounding, which is how the original gives a sound its -- own clear window instead of letting the next beat play over it @@ -2682,6 +2697,16 @@ function BattleState:updateQueue() if self:stepExpBar() then return true end self.expFilling = nil end + return false +end + +function BattleState:updateQueue() + if self.waitingUI then + if self.game.stack:top() ~= self then return true end + self.waitingUI = nil + end + if self:updateQueuePresentation() then return true end + if GameVersion.isGen3() and require('src.battle.Gen3VoiceoverPresentation').gate(self) then return true end if self.animPlaying then self.animPlayer:update() if self.animPlayer.pollEffects and self.applyAnimEffect then @@ -2720,7 +2745,8 @@ function BattleState:updateQueue() if not item then return false end if item.fn then self.nextInsert = 0 -- sayNext inserts right after this item - item.fn() + local plan=GameVersion.isGen3()and package.loaded['src.battle.Gen3ActionPlan'] + if plan then plan.run(self,item)else item.fn()end self.current = nil return true end @@ -4085,6 +4111,10 @@ end -- simply stayed gone -- for as long as the opponent took to choose. function BattleState:tickFx() self.frame = self.frame + 1 + if GameVersion.isGen3() then + require('src.battle.Gen3SelectionPresentation').update(self) + require('src.battle.Gen3VoiceoverPresentation').update(self) + end self:updateFx() end @@ -4270,17 +4300,147 @@ function BattleState:touchpressed(_, px, py) end return true end +function BattleState:updateBattlerPresentation(dt) + for _, b in activeBattlers(self) do + if b and b.picAnim then b.picAnim:update(dt) end + if b and b.monAnim then b.monAnim:update(dt) end + end +end + +function BattleState:stepPresentationWait() + if self.waitFrames and self.waitFrames > 0 then + self.waitFrames = self.waitFrames - 1 + return true + end + return false +end + +-- Advance an ending/pose hold without executing battle commands or input. +function BattleState:updatePresentation(dt) + self:tickFx() + self:updateBattlerPresentation(dt) + self:updateQueuePresentation() +end + +-- Native input and borrowed source choices share these same selectors. +-- Bodies factored from this file's existing native handlers (Cedric); the +-- public baseline retains its own refusal messages, locks and target rules. +function BattleState:chooseMenu(choice) + -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / + -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the + -- third slot IS the ball, so the item action becomes the throw. + if choice == "item" and self.bugContest then + self:bugContestBall() + return + end + if choice == "fight" and self.ghost then + self:say(Strings("%s is too\nscared to move!", self.player.name)) + self.phase = "messages" + self.afterQueue = "menu" + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() self:endOfTurn() end) + elseif choice == "fight" then + -- After the menu: own trapping/Bide or foe Wrap skips the move + -- list and forces the locked action (core.asm:320-329) + local fightLock = self:fightLockedAction(self.player) + if fightLock then + self:resolveTurn(fightLock) + return + end + local who = self:menuBattler() + if not self:playerHasPP(who) then + -- _NoMovesLeftText, then Struggle engages + self:say(Strings("%s has no\nmoves left!", who.name)) + self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) + return + end + self.phase = "moveSelect" + self.moveIndex = math.min(self.moveIndex, #who.curMoves) + self.moveSwapIndex = nil + elseif choice == "run" then + self:tryRun() + elseif choice == "item" then + self:openItems() + else + self:openParty() + end + return true +end +function BattleState:chooseMove(index) + local chooser=self:menuBattler() + local moves=chooser.curMoves + self.moveIndex=index + local mv = moves[self.moveIndex] + local mvDef = mv and self.data.moves[mv.id] + if chooser.disabledSlot == self.moveIndex then + self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) + self.phase = "messages" + self.afterQueue = "menu" + -- TAUNT and TORMENT refuse the move at SELECTION rather than after the + -- fact, the way DISABLE already does, so the player is never told to + -- pick again for something the game was never going to allow. + elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then + -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block + self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tauntBlocks(chooser, mvDef) then + self:say(Strings("%s can't use\n%s after the\ntaunt!", + chooser.name, mvDef and mvDef.name or mv.id)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tormentBlocks(chooser, mvDef) then + self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", + chooser.name)) + self.phase = "messages" + self.afterQueue = "menu" + elseif mv.pp <= 0 then + self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + self.phase = "messages" + self.afterQueue = "menu" + elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then + -- WHICH OF THE TWO, when the move takes one and there are two. + -- + -- Emerald's selector cycles over the legal slots and names the + -- candidate; nothing else in the menu changes. A single battle + -- never gets here, because needsChoice answers false whenever there + -- is only one thing to aim at -- and on the Game Boy cartridges the + -- moves carry no target byte at all, so it answers false for every + -- move in the game. + self.pendingMove = mv + self.targetChoices = Targeting.choices(self, self:menuBattler()) + self.targetIndex = 1 + self.phase = "targetSelect" + else + self:chooseAction(mv) + end + return true +end +function BattleState:chooseTarget(index) + local list=self.targetChoices or{} + self.targetIndex=index + local mv = self.pendingMove + local pick = list[self.targetIndex] + self.pendingMove, self.targetChoices = nil, nil + self.chosenTargets = self.chosenTargets or {} + local who = self:menuBattler() + if who and who.position then self.chosenTargets[who.position] = pick end + self:chooseAction(mv) + return true +end + function BattleState:update(dt) + local shortcuts=package.loaded['src.mods.FeatureOptions']and require('src.battle.QuickItem') + local quick=shortcuts and shortcuts.request(self) self:tickFx() -- one sample a second into the fight, while it is still on screen self._stadiumReportTicks = (self._stadiumReportTicks or 0) + 1 if self._stadiumReportTicks == 60 then self:reportStadiumBattle("mid") end -- Crystal pic animations run off the wall clock like every other battle -- pic effect; a Gold/Silver battler has no picAnim and this is a no-op. - for _, b in activeBattlers(self) do - if b and b.picAnim then b.picAnim:update(dt) end - if b and b.monAnim then b.monAnim:update(dt) end - end + self:updateBattlerPresentation(dt) local input = self.game.input -- L PUTS THE BOTTOM SCREEN AWAY, AND BRINGS IT BACK. @@ -4455,6 +4615,7 @@ function BattleState:update(dt) end if self.phase == "menu" then + if quick and shortcuts.dispatch(self,quick)then return end -- WHO THIS MENU IS FOR, when the left flank is gone. -- -- `self.player` is the left flank, and in a double battle that flank can @@ -4542,47 +4703,7 @@ function BattleState:update(dt) end end if input:wasPressed("a") then - local choice = self:menuActions()[self.menuIndex] - -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / - -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the - -- third slot IS the ball, so the item action becomes the throw. - if choice == "item" and self.bugContest then - self:bugContestBall() - return - end - if choice == "fight" and self.ghost then - self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" - self.afterQueue = "menu" - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) - self:act(function() self:endOfTurn() end) - elseif choice == "fight" then - -- After the menu: own trapping/Bide or foe Wrap skips the move - -- list and forces the locked action (core.asm:320-329) - local fightLock = self:fightLockedAction(self.player) - if fightLock then - self:resolveTurn(fightLock) - return - end - local who = self:menuBattler() - if not self:playerHasPP(who) then - -- _NoMovesLeftText, then Struggle engages - self:say(Strings("%s has no\nmoves left!", who.name)) - self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) - return - end - self.phase = "moveSelect" - self.moveIndex = math.min(self.moveIndex, #who.curMoves) - self.moveSwapIndex = nil - elseif choice == "run" then - self:tryRun() - elseif choice == "item" then - self:openItems() - else - self:openParty() - end + self:chooseMenu(self:menuActions()[self.menuIndex]) end return end @@ -4616,50 +4737,7 @@ function BattleState:update(dt) self.moveSwapIndex = nil return end - local mv = moves[self.moveIndex] - local mvDef = mv and self.data.moves[mv.id] - if chooser.disabledSlot == self.moveIndex then - self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) - self.phase = "messages" - self.afterQueue = "menu" - -- TAUNT and TORMENT refuse the move at SELECTION rather than after the - -- fact, the way DISABLE already does, so the player is never told to - -- pick again for something the game was never going to allow. - elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then - -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block - self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tauntBlocks(chooser, mvDef) then - self:say(Strings("%s can't use\n%s after the\ntaunt!", - chooser.name, mvDef and mvDef.name or mv.id)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tormentBlocks(chooser, mvDef) then - self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", - chooser.name)) - self.phase = "messages" - self.afterQueue = "menu" - elseif mv.pp <= 0 then - self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" - self.afterQueue = "menu" - elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then - -- WHICH OF THE TWO, when the move takes one and there are two. - -- - -- Emerald's selector cycles over the legal slots and names the - -- candidate; nothing else in the menu changes. A single battle - -- never gets here, because needsChoice answers false whenever there - -- is only one thing to aim at -- and on the Game Boy cartridges the - -- moves carry no target byte at all, so it answers false for every - -- move in the game. - self.pendingMove = mv - self.targetChoices = Targeting.choices(self, self:menuBattler()) - self.targetIndex = 1 - self.phase = "targetSelect" - else - self:chooseAction(mv) - end + self:chooseMove(self.moveIndex) end return end @@ -4680,13 +4758,7 @@ function BattleState:update(dt) self.pendingMove, self.targetChoices = nil, nil self.phase = "moveSelect" elseif input:wasPressed("a") then - local mv = self.pendingMove - local pick = list[self.targetIndex] - self.pendingMove, self.targetChoices = nil, nil - self.chosenTargets = self.chosenTargets or {} - local who = self:menuBattler() - if who and who.position then self.chosenTargets[who.position] = pick end - self:chooseAction(mv) + self:chooseTarget(self.targetIndex) end return end @@ -4973,7 +5045,7 @@ function BattleState:queuePokedudeVoices(indices) pd.voiceDone[index] = true local text = self:pokedudeVoice(index) if type(text) == "string" and text ~= "" then - self:say(text) + require('src.battle.Gen3VoiceoverPresentation').queue(self,text,pd.lesson=='status'and index==2) queued = true end end @@ -5736,8 +5808,12 @@ function BattleState:resolveTurn(playerAction) self.phase = "messages" self.afterQueue = "menu" - for _, entry in ipairs(order) do - self:act(function() + local nativePlan + if GameVersion.isGen3()and package.loaded['src.core.game3.battle']then + nativePlan=require('src.battle.Gen3ActionPlan').begin(self,order) + end + for slot, entry in ipairs(order) do + local nativeAction=function() -- WHO IT HITS IS DECIDED HERE, not when the turn was sorted. -- -- Between the sort and this entry running, the target can faint -- @@ -5760,11 +5836,14 @@ function BattleState:resolveTurn(playerAction) or self:defaultTargetFor(entry[1]) end self:executeAction(entry[1], target, entry[3]) - end) + end + self:act(nativeAction) + if nativePlan then require('src.battle.Gen3ActionPlan').attach(self,nativePlan,self.queue[#self.queue],slot,nativeAction)end end self:act(function() -- the collected choices belong to the turn that just ran, not the next self.pendingActions, self.chosenTargets = nil, nil + if nativePlan then require('src.battle.Gen3ActionPlan').retire(self)end self:endOfTurn() end) end @@ -8151,7 +8230,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima self.moveAnimRow = nil if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then - self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) + self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name), nativeMoveUsed) -- the move's animation plays right after the announcement; the -- damage path attaches the target's hit blink to this row so the -- blink follows the animation (pokered's order). Mimic is the @@ -8163,6 +8242,8 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima attackerPosition = user.position, targetPosition = target and target.position, animContext = animContext } + local sequence=GameVersion.isGen3()and package.loaded['src.core.game3.battle.anim_seq'] + if sequence then sequence._nativeMove(self.moveAnimRow)end table.insert(self.queue, self.nextInsert, self.moveAnimRow) end end @@ -10699,7 +10780,7 @@ function BattleState:finish() local text = index and self:pokedudeVoice(index) if type(text) == "string" and text ~= "" then pd.voiceDone[index] = true - self:say(text) + require('src.battle.Gen3VoiceoverPresentation').queue(self,text,false) self.phase = "messages" self.afterQueue = "finish" return @@ -11433,6 +11514,11 @@ function BattleState:gen3AnimSquash(battler) end function BattleState:drawBattlerPic(battler, x, y, scale) + if GameVersion.isGen3() then + local selection=require('src.battle.Gen3SelectionPresentation') + if selection.hidden(self,battler) then return end + y=y+selection.offset(self,'mon',battler) + end if self:gen3AnimMonBgHidden(battler) then return end local angle, rise = self:gen3AnimRotate(battler) local sqx, sqy = self:gen3AnimSquash(battler) @@ -12855,4 +12941,67 @@ function BattleState:drawClassic() end end +-- Source wrappers observe the real GBA owner. Unrequested modules and other +-- generations keep the direct native path; there is no additional clock. +do + local unpack_=table.unpack or unpack + local function pack(...)return{n=select('#',...),...}end + local update, draw, field, exit, enter = BattleState.update, BattleState.draw, + BattleState.drawBattleField, BattleState.exit, BattleState.enter + local Clock = require('src.battle.PresentationClock') + for _, name in ipairs{'tickFx','updateBattlerPresentation','updateMovePresentation', + 'stepHPDrain','stepExpBar','stepPresentationWait','updateQueuePresentation'} do + local body = BattleState[name] + BattleState[name] = function(self, ...) + return Clock.once(self, name, body, ...) + end + end + BattleState._drawNative = draw + local battlerDraw = BattleState.drawBattlerPic + function BattleState:drawBattlerPic(battler, ...) + local anim = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if anim then return anim._drawBattler(self, battler, battlerDraw, ...) end + return battlerDraw(self, battler, ...) + end + local chooseAction=BattleState.chooseAction + function BattleState:chooseAction(action) + local ui=GameVersion.isGen3() and package.loaded['src.core.game3.battle.ui'] + if ui then return ui._nativeCommand(self,action,chooseAction) end + return chooseAction(self,action) + end + function BattleState:enter() + enter(self) + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeEnter(self) end + end + function BattleState:update(dt) + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + local anim = source and package.loaded['src.core.game3.battle.anim'] + if anim then + return Clock.run(self, dt, function() + local result=pack(source._nativeUpdate(self, dt, update)) + anim._nativeAfterUpdate(self) + return unpack_(result,1,result.n) + end) + end + if source then return source._nativeUpdate(self, dt, update) end + return update(self, dt) + end + function BattleState:draw() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeDraw(self, draw) end + return draw(self) + end + function BattleState:drawBattleField() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeField(self, field) end + return field(self) + end + function BattleState:exit() + local source = package.loaded['src.core.game3.battle'] + if source then source._nativeExit(self) end + return exit(self) + end +end + return BattleState diff --git a/src/battle/CaughtMarker.lua b/src/battle/CaughtMarker.lua new file mode 100644 index 000000000..b76d76305 --- /dev/null +++ b/src/battle/CaughtMarker.lua @@ -0,0 +1,82 @@ +-- Eligibility and strict boolean verdict shared by the native GB/GBA renderers. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local M = {} +function M.caught(game, species) + local dex = game and game.save and game.save.pokedex + return not not (dex and ((dex.owned and dex.owned[species]) or (dex.caught and dex.caught[species]))) +end +function M.visible(battle, battler) + local gen = Version.generation() + battler = battler or battle.enemy + if + gen > 3 + or not battler + or battler.isPlayer + or not battler.mon + or (battle.kind ~= "wild" and battle.kind ~= "safari") + or not M.caught(battle.game, battler.mon.species) + then + return false + end + -- Gold latches the already-caught bit when the encounter starts, then checks + -- the active species again when drawing (e.g. after Transform). + if gen == 2 and not battle.caughtMark then + return false + end + if + gen == 3 + and ( + battle.demo + or battle.dudeDemo + or battle.pokedudeDemo + or battle.firstBattle + or (battle.ghost and not battle.ghostUnveiled) + ) + then + return false + end + local default = gen > 1 + if not Runtime.wantsHook("battle.caught_marker_visible") then + return default + end + return Runtime.call("battle.caught_marker_visible", function() + return default + end, battle) == true +end +-- Legacy standalone compositors can carry a partial battle fixture. +function M.forBattle(battle, battler) + local method = battle.caughtMarkerVisible + if type(method) == "function" then + return method(battle, battler) + end + return M.visible(battle, battler) +end +-- The GBA renderer creates this element for a presented healthbox and retains +-- its verdict until that presentation changes, as in the reference engine. +function M.gbaVisible(battle, battler) + if not battler or battler.shownStatus or (battle.isDouble and battle:isDouble()) then + return false + end + local old = battler._caughtIcon + local species = battler.mon and battler.mon.species + if + old + and old.mon == battler.mon + and old.species == species + and old.name == battler.name + and old.status == battler.shownStatus + then + return old.show + end + local show = M.visible(battle, battler) + battler._caughtIcon = { + mon = battler.mon, + species = species, + name = battler.name, + status = battler.shownStatus, + show = show, + } + return show +end +return M diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 2664e4b17..265bac42b 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -81,6 +81,12 @@ Damage.stageOf = stageOf -- (1/16, 1/8, 1/4, 1/3, 1/2). Speed stops mattering entirely, which is why a -- ruleset that sets critStages cannot just tune the Gen 1 numbers. local CRIT_STAGE_DEN = { [0] = 16, 8, 4, 3, 2 } +-- Detached observations share the calculation's authority. Source queries +-- cannot change native critical rates by mutating a returned table. +function Damage.gen3CritDenominators() + local out={};for stage,den in pairs(CRIT_STAGE_DEN)do out[stage]=den end + return out +end function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit, data) rng = rng or love.math.random diff --git a/src/battle/Experience.lua b/src/battle/Experience.lua index 2c0dc6401..701c78196 100644 --- a/src/battle/Experience.lua +++ b/src/battle/Experience.lua @@ -122,6 +122,15 @@ function Experience.apply(data, mon, defeatedDef, level, isTrainer, end -- Lucky Egg is a per-recipient x1.5 held-item boost in Generation II. gained = HeldItems.modifyExperience(data, mon, gained) + return Experience.add(data, mon, gained) +end + +-- Shared absolute award: scripted rewards use native growth, stats and events. +-- Defeat-only EVs, held boosts and exp.gain remain in apply above. +function Experience.add(data, mon, gained) + assert(type(gained)=='number'and gained==gained and gained>=0 and gained%1==0,'invalid experience award') + local speciesDef=require('src.pokemon.Gen4Forms').definition(data,mon) + local consts=data.constants mon.exp = mon.exp + gained local cap = consts and consts.levelCap or 100 diff --git a/src/battle/Gen3ActionPlan.lua b/src/battle/Gen3ActionPlan.lua new file mode 100644 index 000000000..5527e2e79 --- /dev/null +++ b/src/battle/Gen3ActionPlan.lua @@ -0,0 +1,126 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Observations of the native turn order and its actual queue rows. This +-- ledger never starts, removes, schedules or reorders an action on its own. +local Version=require('src.core.GameVersion') +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local actionKeys={'kind','meta','move','user','target','userId','targetId','order'} +local function actionPairs(_,source) + local out={} + for _,key in ipairs(actionKeys)do out[key]=source[key]end + return out +end +local ABSENT={} +local function loader(game) + local value=game and game.mods + return value==nil and ABSENT or value +end +local function valid(b,p) + if not p or p.retired then return false end + if not b or b._sourceActionPlan~=p or b.result then p.retired=true;return false end + local r=p.refs;local game=r[2] + if r[1]~=b or not game or b.game~=game or b.data~=r[3]or game.data~=r[3] + or game.save~=r[4]or loader(game)~=r[5]or game.stack~=r[6] + or b.queue~=r[7]or Version.get()~=p.edition or not Version.isGen3()then p.retired=true;return false end + for _,state in ipairs(game.stack and game.stack.states or{})do if state==b then return true end end + p.retired=true;return false +end +function M.begin(b,order) + if not Version.isGen3()then return end + M.retire(b) + local game=b.game + local p={order=order,rows={},fns={},completed={},byRow=setmetatable({},{__mode='k'}),views=setmetatable({},{__mode='k'}),edition=Version.get(), + refs=setmetatable({b,game,b.data,game and game.save,loader(game),game and game.stack,b.queue},{__mode='v'})} + b._sourceActionPlan=p + return p +end +function M.attach(b,p,row,index,nativeFn) + if b._sourceActionPlan~=p then return false end + assert(p.order[index],'native action does not belong to its turn') + -- A separate native act wrapper may replace/suppress the queued body. + -- Keep that behavior; do not mislabel another row as this native action. + if type(row)~='table'or row.fn~=nativeFn or p.byRow[row]then M.retire(b);p.retired=true;return false end + p.rows[index]=row;p.fns[index]=nativeFn;p.byRow[row]=index + return true +end +function M.retire(b) + local p=b._sourceActionPlan + if p then p.retired=true end + b._sourceActionPlan=nil +end +function M.run(b,item) + local p=b._sourceActionPlan + if not(valid(b,p)and p.byRow[item])then return item.fn()end + local index=p.byRow[item] + if p.completed[index]or item.fn~=p.fns[index]then M.retire(b);return item.fn()end + -- This is the same native fn invocation. An exception must retire its + -- observation instead of leaving a falsely executing action behind. + p.current=item + local sequence=package.loaded['src.core.game3.battle.anim_seq'] + local ok,why + if sequence then ok,why=pcall(sequence._nativeAction,b,item,item.fn) + else ok,why=pcall(item.fn)end + p.current=nil + if not ok then M.retire(b);error(why,0)end + p.completed[index]=true +end +local function actionValid(b,p,index) + if not p.completed[index]and p.rows[index].fn~=p.fns[index]then M.retire(b);return false end + return true +end +function M.cursor(b) + local p=b and b._sourceActionPlan + if not valid(b,p)then return 0 end + local index=p.byRow[p.current or b.queue[1]] + if not index or p.completed[index]or not actionValid(b,p,index)then return 0 end + return index +end +local function side(b,actor) + if actor==b.player then return 'player'end + if actor==b.enemy then return 'enemy'end +end +local function actionView(b,p,admission,index) + local views=p.views[admission] + if not views then views={};p.views[admission]=views end + local old=views[index];if old then return old end + local refs=setmetatable({b,p,admission},{__mode='v'}) + local retired=false + local view=setmetatable({}, { + __index=function(_,key) + local screen,plan,current=refs[1],refs[2],refs[3] + if retired then return nil end + if not valid(screen,plan)or not current or current.retired or current.screen~=screen + or not current.ids:valid()or not actionValid(screen,plan,index)then retired=true;return nil end + local entry=plan.order[index];local action=entry and entry[3] + if not action then return nil end + if key=='kind'then return not action.special and action.id~=nil and'move'or'meta'end + if key=='meta'then return not not action.special or action.id==nil or not side(screen,entry[1])or not side(screen,entry[2])end + if key=='move'then return not action.special and action.id or nil end + if key=='user'then return side(screen,entry[1])end + if key=='target'then return side(screen,entry[2])end + if key=='userId'then return entry[1]and entry[1].position end + if key=='targetId'then return entry[2]and entry[2].position end + if key=='order'then return index end + end, + __newindex=function()error('native action observation is read-only',2)end, + __metatable='native action observation', + }) + -- Use the shared LuaJIT pairs/next authority. The reader has no closure + -- over this view, its native battle or its source admission. + Iteration.register(view,actionPairs) + views[index]=view + return view +end +function M.snapshot(b,admission) + local p=b and b._sourceActionPlan + if not valid(b,p)or not admission or admission.retired or admission.screen~=b or not admission.ids:valid()then return end + local out={} + for i=1,#p.order do + if p.rows[i]then + if not actionValid(b,p,i)then return nil end + out[i]=actionView(b,p,admission,i) + end + end + return out +end +return M diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index dcaa62f71..8e6b32fd3 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -36,6 +36,7 @@ -- the window edges fall. Those are staging, and they are marked below. local Assets = require("src.render.Assets") +local Selection = require("src.battle.Gen3SelectionPresentation") local Logger = require("src.core.Logger") local Theme = require("src.ui.Theme") local Font = require("src.render.Font") @@ -217,45 +218,15 @@ end local stripCache = {} local stripQuads = {} local bandQuads = setmetatable({}, { __mode = "k" }) -local moveBgIndexShaderCache = nil - -local function moveBgIndexShader() - if moveBgIndexShaderCache ~= nil then return moveBgIndexShaderCache or nil end - if not (love.graphics and love.graphics.newShader) then - moveBgIndexShaderCache = false - return nil - end - local uniforms, choices = {}, {} - for i = 0, 15 do uniforms[#uniforms + 1] = ("extern vec3 c%d;"):format(i) end - for i = 0, 15 do - choices[#choices + 1] = ((i == 0) and "if" or "else if") - .. (" (idx < %.1f) rgb = c%d;"):format(i + 0.5, i) - end - local src = table.concat(uniforms, "\n") .. [[ - vec4 effect(vec4 colour, Image tex, vec2 uv, vec2 screen) { - vec4 px = Texel(tex, uv); - number idx = floor(px.r * 15.0 + 0.5); - vec3 rgb = c0; - ]] .. table.concat(choices, "\n") .. [[ - return vec4(rgb, px.a) * colour; - } - ]] - local ok, shader = pcall(love.graphics.newShader, src) - moveBgIndexShaderCache = ok and shader or false - return ok and shader or nil -end +local moveBgPalette = require('src.render.Gen3IndexedPalette').new() local function drawIndexedMoveBg(g, battle, layer) if not (layer and layer.indexed and layer.indexImage and layer.palette) then return false end local okImage, image = pcall(Assets.image, layer.indexImage) - local shader = moveBgIndexShader() + local shader = moveBgPalette:sendNormalized(layer.palette) if not (okImage and image and shader) then return false end - for i = 0, 15 do - local c = layer.palette[i + 1] or { 0, 0, 0 } - shader:send("c" .. i, { c[1] or 0, c[2] or 0, c[3] or 0 }) - end local oldShader = g.getShader and g.getShader() or nil local sx, sy, sw, sh if g.getScissor then sx, sy, sw, sh = g.getScissor() end @@ -900,7 +871,7 @@ local function levelAt(battle, battler, x, y, text) end end -local function drawStatusPanel(battle, battler, x, y, player) +local function drawStatusPanelBody(battle, battler, x, y, player) local record = hudRecord(battle) local images = record and record.images or {} local panel = hudImage(player and images.player or images.opponent) @@ -1204,6 +1175,16 @@ end -- -- AND THE FOE'S ROW IS A TRAINER'S ONLY. A wild battle has no party to show, -- so BattleIntroDrawPartySummaryScreens raises the player's alone. +-- Expose the same native painter to source healthbox captures. +Gen3Battle.drawStatusPanel=drawStatusPanelBody +local function drawStatusPanel(battle,battler,x,y,player) + local source=package.loaded['src.core.game3.battle.healthbox'] + if source then return source._nativeDraw(battle,battler,x,y,player,function() + return drawStatusPanelBody(battle,battler,x,y,player) + end)end + return drawStatusPanelBody(battle,battler,x,y,player) +end + local function summaryRecord(battle) local record = hudRecord(battle) local summary = record and record.partySummary @@ -1281,49 +1262,62 @@ Gen3Battle.SUMMARY_BALL_FRAMES = 4 -- twenty-four off the RIGHT of it. So on a wider surface the foe's row does -- not move and the player's moves out by the whole of the extra width, which -- is the flip flag this function already reads for the mirroring. -local function drawSummaryRow(summary, side, party, age, extra) +local function drawSummaryRow(summary, side, party, age, extra, presentation) local edge = side.flip and 0 or math.floor(tonumber(extra) or 0) local bar, balls = hudImage(summary.bar), hudImage(summary.balls) - if not (bar and balls) then return end + if not (bar and balls) then return false end local cell = summary.ballSide or 8 local barW = summary.barWidth or bar:getWidth() + local barScale=barW/bar:getWidth() + assert(not summary.barHeight or bar:getHeight()*barScale==summary.barHeight,'invalid native party bar density') -- how far in the row has travelled: 0 is fully off its own edge, 1 home - local slide = Gen3Battle.SUMMARY_SLIDE_FRAMES - local p = 1 - if age and slide > 0 then p = math.max(0, math.min(1, age / slide)) end - -- eased so it settles rather than stopping dead - local eased = 1 - (1 - p) * (1 - p) -- the foe's row lives on the left of the screen and enters from there; the -- player's lives on the right and enters from the right - local dir = side.flip and -1 or 1 - local dx = dir * (1 - eased) * (barW + 16) - love.graphics.setColor(1, 1, 1, 1) + local slide = Gen3Battle.SUMMARY_SLIDE_FRAMES + local dx = Gen3Battle.summaryRowOffset(summary,side,age) + local offsetX,offsetY=0,0 + local alpha=1 + if presentation then + local anchor=side.barX+(side.flip and barW or edge) + offsetX=(presentation.x or anchor)-anchor + offsetY=(presentation.y or side.barY)-side.barY + dx=presentation.ox==nil and dx or presentation.ox + alpha=presentation.alpha or 1 + end + love.graphics.setColor(1, 1, 1, alpha) -- the strip, mirrored for the foe: drawn from its far corner with a -- negative x scale, which is what ST_OAM_HFLIP does to the four pieces if side.flip then - love.graphics.draw(bar, side.barX + barW + dx, side.barY, 0, -1, 1) + love.graphics.draw(bar, side.barX + barW + dx + offsetX, side.barY + offsetY, 0, -barScale, barScale) else - love.graphics.draw(bar, side.barX + edge + dx, side.barY) + if barScale==1 then love.graphics.draw(bar, side.barX + edge + dx + offsetX, side.barY + offsetY) + else love.graphics.draw(bar, side.barX + edge + dx + offsetX, side.barY + offsetY,0,barScale,barScale)end end -- ...and the icons, laid out from the middle of the screen outwards - local slots = Gen3Battle.summarySlots(summary, side, party) + local slots = presentation and presentation.slots or Gen3Battle.summarySlots(summary, side, party) local count = summary.count or 6 local icons = summary.icons or {} local bw, bh = balls:getDimensions() + assert(bh>0 and bw>=bh and bw%bh==0,'invalid native party icon density') + local ballScale=cell/bh for slot = 1, count do local column = icons[slots[slot] or "empty"] -- each icon waits for the bar to land and then for its own turn; with no -- age at all (a caller that does not track one) every icon is already down local due = slide + (slot - 1) * Gen3Battle.SUMMARY_BALL_FRAMES - if column and (age == nil or age >= due) then - local quad = love.graphics.newQuad(column * cell, 0, cell, cell, + local hidden=presentation and presentation.ballHidden + local show=hidden and not hidden[slot]or not hidden and(age==nil or age>=due) + if column and show then + local quad = love.graphics.newQuad(column * bh, 0, bh, bh, bw, bh) - love.graphics.draw(balls, quad, - side.ballX + edge - + (slot - 1) * (summary.step or 10) + dx, - side.ballY) + local ballDx=presentation and presentation.ballOx and(presentation.ballOx[slot]or 0)or dx + local x=side.ballX+edge+(slot-1)*(summary.step or 10)+ballDx+offsetX + local y=side.ballY+offsetY + if ballScale==1 then love.graphics.draw(balls,quad,x,y) + else love.graphics.draw(balls,quad,x,y,0,ballScale,ballScale)end end end + return true end -- Both teams when two trainers walked up; see BattleState:foeSummaryParty. @@ -1336,6 +1330,61 @@ local function foeRowParty(battle) return battle.enemyParty end +function Gen3Battle.partySummary(battle,player) + if not battle.introBalls then return end + local summary=summaryRecord(battle) + local row=summary and summary[player and'player'or'opponent'] + if not row then return end + if not player and(not battle.enemyParty or battle.kind~='trainer'and battle.kind~='link')then return end + return summary,row,player and(battle.playerParty or battle.game.save.party)or foeRowParty(battle) +end +function Gen3Battle.summaryBarWidth(summary) + local bar=hudImage(summary.bar) + return summary.barWidth or bar and bar:getWidth() +end +function Gen3Battle.summaryRowOffset(summary,side,age) + local width=Gen3Battle.summaryBarWidth(summary) + if not width then return nil end + local p=1 + if age and Gen3Battle.SUMMARY_SLIDE_FRAMES>0 then p=math.max(0,math.min(1,age/Gen3Battle.SUMMARY_SLIDE_FRAMES))end + local eased=1-(1-p)*(1-p) + return(side.flip and -1 or 1)*(1-eased)*(width+16) +end +-- Presentation overrides borrow the same row geometry, icon packing and +-- first-paint stamp as native HUDs. No battle/input clock is advanced here. +function Gen3Battle.paintPartySummary(battle,player,presentation) + local summary,row,party=Gen3Battle.partySummary(battle,player) + if not summary then return false end + if presentation then + assert(type(presentation)=='table','invalid party summary presentation') + for _,key in ipairs{'x','y','ox','alpha'}do + local v=presentation[key] + assert(v==nil or type(v)=='number'and v==v and math.abs(v)=0 and presentation.alpha<=1,'invalid party summary alpha') + assert(not presentation.extended,'extended native party summary unavailable') + for _,key in ipairs{'slots','ballHidden','ballOx'}do + assert(presentation[key]==nil or type(presentation[key])=='table','invalid party summary '..key) + end + for i=1,summary.count or 6 do + local key=presentation.slots and presentation.slots[i] + assert(key==nil or type(key)=='string'and(summary.icons or{})[key]~=nil,'invalid party summary icon') + local hidden=presentation.ballHidden and presentation.ballHidden[i] + assert(hidden==nil or type(hidden)=='boolean','invalid party summary hidden flag') + local offset=presentation.ballOx and presentation.ballOx[i] + assert(offset==nil or type(offset)=='number'and offset==offset and math.abs(offset)=2 then key=key..'Doubles' end + local image=hudImage(images[key]or images[player and 'player'or'opponent']) + local h=image and image:getHeight()or 32 + if battle:isDouble()then + local keys={[0]='playerLeft','opponentLeft','playerRight','opponentRight'} + local c=battle.data.constants.gen3BattlerCoords + local pos=c and c.healthbox and c.healthbox[keys[id]] + if not pos then return nil,'native double healthbox placement unavailable'end + return pos.x-32+(player and Gen3Battle.extra(battle)or 0),pos.y-math.floor(h/2),h + end + if id>=2 then return nil,'absent single-battle slot'end + local x,y=healthboxAt(battle,player and 'singlesPlayer'or'singlesOpponent',image) + return x,y,h +end + +-- The same native visibility predicate serves drawing and typed observers. +function Gen3Battle.healthboxVisible(battle,id,slide,showStatus) + if showStatus==nil then showStatus=true end + if not showStatus or slide~=0 then return false end + local double=battle.isDouble and battle:isDouble() + local b=id==0 and battle.player or id==1 and battle.enemy or battle:battlerAt(id) + if not b or id>=2 and not double then return false end + if double then + if not b.mon or b.fainted then return false end + if id%2==0 then return not battle.showPlayerBack end + elseif id==0 then + return not battle.safari and not battle.demo and not battle.showPlayerBack + elseif b.fainted then return false end + return not battle.showEnemyTrainer and not battle.enemySendingOut + and not battle:growInScale(b) and not battle.introBalls +end +function Gen3Battle.drawSafariLabel(label,x,y) + local pen=require('src.render.Gen3TextPen') + local width=pen.drawCompound(label,x,y) + if width~=nil then return width end + Font.draw(label,x,y);return Font.width(label) +end + +function Gen3Battle.drawSafariPanel(battle,x,y) + if not battle.gen3Safari then return false end + if battle.rsBattlePanel then + return require('src.battle.Gen3RsHealthbox').drawSafari(battle,x,y,battle.rsBattlePanel.font) + end + return require('src.battle.Gen3NativeHealthbox').drawSafari(battle,x,y) +end local function drawHUDs(battle, slide) local record = hudRecord(battle) local images = record and record.images or {} local double = battle.isDouble and battle:isDouble() - if not double and battle.enemy and not battle.showEnemyTrainer - and not battle.enemySendingOut and not battle:growInScale(battle.enemy) - and slide == 0 and not battle.introBalls and not battle.enemy.fainted then + if not double and Gen3Battle.healthboxVisible(battle,1,slide,true) then local x, y = healthboxAt(battle, "singlesOpponent", hudImage(images.opponent), 8, 8) - drawStatusPanel(battle, battle.enemy, x, y, false) + drawStatusPanel(battle, battle.enemy, x, y+Selection.offset(battle,'hb',battle.enemy), false) end - if not double and not battle.safari and battle.player and not battle.demo - and not battle.showPlayerBack and slide == 0 then + if not double and Gen3Battle.healthboxVisible(battle,0,slide,true) then local x, y = healthboxAt(battle, "singlesPlayer", hudImage(images.player), 112, 64) - drawStatusPanel(battle, battle.player, x, y, true) + drawStatusPanel(battle, battle.player, x, y+Selection.offset(battle,'hb',battle.player), true) end -- ...AND THE OTHER TWO PANELS. -- @@ -1501,15 +1578,8 @@ local function drawHUDs(battle, slide) -- -- Same conditions, per side, so a double intro reads like two singles -- happening at once, which is what it is. - local enemyReady = not battle.showEnemyTrainer - and not battle.enemySendingOut - and not battle.introBalls - local playerReady = not battle.showPlayerBack for _, row in ipairs(pairs_) do local b = battle:battlerAt(row.pos) - local ready = row.player and playerReady - or (not row.player and enemyReady - and not (b and battle:growInScale(b))) -- ...AND IT STAYS UP WHILE THE BAR EMPTIES. -- -- Reported from play: in a double battle a Pokemon's panel vanishes the @@ -1523,7 +1593,7 @@ local function drawHUDs(battle, slide) -- cry and starts the slide, which runs AFTER the drain, so the bar has -- emptied by the time it turns true. Both layouts now answer the same -- question, and an empty slot is still nil rather than a zeroed one. - if ready and b and b.mon and not b.fainted then + if Gen3Battle.healthboxVisible(battle,row.pos,slide,true) then local panel = hudImage(row.img) local h = 32 if panel and panel.getHeight then @@ -1539,7 +1609,7 @@ local function drawHUDs(battle, slide) -- ride the right edge; opponentLeft and opponentRight are the -- left-hand pair (44 and 32) and stay put. local edge = row.player and Gen3Battle.extra(battle) or 0 - local px, py = pos.x - 32 + edge, pos.y - math.floor(h / 2) + local px, py = pos.x - 32 + edge, pos.y - math.floor(h / 2)+Selection.offset(battle,'hb',b) drawStatusPanel(battle, b, px, py, row.player) if b == aimed and math.floor((battle.frame or 0) / 8) % 2 == 0 then love.graphics.setColor(1, 1, 0.3, 1) @@ -1781,7 +1851,7 @@ end -- Draws the panel and answers whether it did, so a caller can fall back to -- its own drawn box on a cache that has none. -local function drawPanel(battle, which) +local function drawPanelBody(battle, which) local img, record = panelImage(battle, which) if not img then return false end love.graphics.setColor(1, 1, 1, 1) @@ -1792,6 +1862,14 @@ local function drawPanel(battle, which) return true end +-- Source panel wrappers run inside this existing native drawing phase. +Gen3Battle.drawPanel=drawPanelBody +local function drawPanel(battle,which) + local source=package.loaded['src.ui.game3.battle_chrome'] + if source then return source._panel(battle,which,function()return drawPanelBody(battle,which)end)end + return drawPanelBody(battle,which) +end + -- THE TEXT IS PRINTED TWICE. Every string in a Gen 3 battle is a foreground -- pass over a shadow pass one pixel down and right; the shadow is what makes -- white legible on teal, and dropping it is most of why black-on-white was @@ -1807,6 +1885,8 @@ local function paintPair(battle, key) return norm(row.foreground), norm(row.shadow) end +Gen3Battle.textColors=paintPair + -- ...ONCE, THROUGH THE PAGE'S OWN TWO TONES. -- -- Reported from play: "the textbox in battle doesnt seem to be using the @@ -1963,7 +2043,11 @@ local function drawCommandMenu(battle) local prompt = record and record.prompt local chooser = (battle.menuBattler and battle:menuBattler()) or battle.player local who = chooser and chooser.name or "" - if prompt and prompt[1] and prompt[2] then + local quick=package.loaded['src.mods.FeatureOptions']and require('src.battle.QuickItem').label(battle) + if quick then + Font.draw(fitName(quick,88),w.prompt.textX+dx,w.prompt.row1+dy) + Font.draw("L: NEXT R: THROW",w.prompt.textX+dx,w.prompt.row2+dy) + elseif prompt and prompt[1] and prompt[2] then Font.draw(prompt[1], w.prompt.textX + dx, w.prompt.row1 + dy) local second = tostring(prompt[2]):gsub("{MON}", fitName(who, 88)) Font.draw(second, w.prompt.textX + dx, w.prompt.row2 + dy) @@ -2317,12 +2401,12 @@ end -- (20/30 and 40/50). In singles, monbg copies are inserted between the exact -- OBJ priority bands: same-priority OBJ wins over BG, while splitbgprio can -- promote BG1 from priority 2 to priority 1. -function Gen3Battle.drawMoveOamObjects(battle, slide, sx, sy) +function Gen3Battle.moveOamObjects(battle, allowDouble) local anim = battle and battle.gen3AnimPlaying and battle.gen3Anim if not (anim and anim.oamEventDescriptors) then return false, "inactive" end if battle.isDouble then local ok, double = pcall(battle.isDouble, battle) - if ok and double then return false, "double-battler-slot" end + if ok and double and not allowDouble then return false, "double-battler-slot" end end local events, reason = anim:oamEventDescriptors(battle, Gen3Battle) @@ -2349,6 +2433,12 @@ function Gen3Battle.drawMoveOamObjects(battle, slide, sx, sy) or not addBattler(battle.enemy, "enemy", 2) then return false, "unknown-battler-oam" end + if allowDouble and battle.isDouble and battle:isDouble() then + if not addBattler(battle:battlerAt(2), "player", 3) + or not addBattler(battle:battlerAt(3), "enemy", 4) then + return false, "unknown-battler-oam" + end + end for _, item in ipairs(events) do item.seed = 1000 + (item.index or 0) objects[#objects + 1] = item @@ -2386,7 +2476,12 @@ function Gen3Battle.drawMoveOamObjects(battle, slide, sx, sy) if item.event and anim.oamRanks then anim.oamRanks[item.event] = i end end battle._gen3OamObjectRanks = previous + return objects,copies,anim +end +function Gen3Battle.drawMoveOamObjects(battle, slide, sx, sy) + local objects,copies,anim=Gen3Battle.moveOamObjects(battle,false) + if not objects then return false,copies end local function drawObject(item) if item.kind == "battler" then battle:drawPicsLayer(slide, sx, sy, item.side, true) @@ -2905,7 +3000,7 @@ local function ballSheet(battle, key) return (ok and img) or nil end -function Gen3Battle.drawBall(battle) +local function drawNativeBall(battle) local record, anim = Gen3Battle.ballRecord(battle) if not anim then return end local g = love.graphics @@ -3013,6 +3108,18 @@ function Gen3Battle.drawBall(battle) g.setColor(1, 1, 1, 1) end +function Gen3Battle.drawBall(battle) + local source=package.loaded['src.core.game3.battle.ball_open'] + if source then return source._nativeDraw(battle,drawNativeBall) end + return drawNativeBall(battle) +end + +function Gen3Battle.introWindow(battle) + local fi=battle.frlgIntro + if not fi or fi.t>=60 then return end + local half=fi.t<=32 and fi.t or 32+(fi.t-32)*4 + if half<80 then return 80-half,80+half end +end function Gen3Battle.draw(battle) local g = love.graphics battle:drawBattleField() @@ -3109,20 +3216,28 @@ function Gen3Battle.draw(battle) -- across the field, and a screen shake moves the field shaken(function() Gen3Battle.drawBall(battle) end) battle:drawAnimLayer() + local voice=require('src.battle.Gen3VoiceoverPresentation') + local dim=voice.dim(battle) + if dim>0 then + g.setColor(0,0,0,dim);g.rectangle('fill',0,0,Gen3Battle.width(battle),Gen3Battle.HEIGHT) + g.setColor(1,1,1,1) + if voice.lit(battle)and Gen3Battle.healthboxVisible(battle,0,slide)then + local record=hudRecord(battle);local image=hudImage(record and record.images and record.images.player) + local x,y=healthboxAt(battle,'singlesPlayer',image,112,64) + drawStatusPanel(battle,battle.player,x,y+Selection.offset(battle,'hb',battle.player),true) + end + end shaken(function() battle:drawTextArea() end) -- the window opening out of the middle: 1 row a side for 32 frames, then 4 -- (BattleIntroSlide1 cases 2 and 3); nothing shows outside it - local fi = battle.frlgIntro - if fi and fi.t < 60 then - local half = fi.t <= 32 and fi.t or (32 + (fi.t - 32) * 4) - if half < 80 then - local W, H = Gen3Battle.WIDTH, Gen3Battle.HEIGHT - g.setColor(0, 0, 0, 1) - g.rectangle("fill", 0, 0, W, 80 - half) - g.rectangle("fill", 0, 80 + half, W, H - 80 - half) - g.setColor(1, 1, 1, 1) - end + local top,bottom=Gen3Battle.introWindow(battle) + if top then + local W,H=Gen3Battle.WIDTH,Gen3Battle.HEIGHT + g.setColor(0,0,0,1) + g.rectangle('fill',0,0,W,top) + g.rectangle('fill',0,bottom,W,H-bottom) + g.setColor(1,1,1,1) end if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then diff --git a/src/battle/Gen3BattleFrames.lua b/src/battle/Gen3BattleFrames.lua new file mode 100644 index 000000000..263ed8e4c --- /dev/null +++ b/src/battle/Gen3BattleFrames.lua @@ -0,0 +1,51 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Decorations borrow the actual native panel/window/frame owners. +local Native=require('src.battle.Gen3Battle') +local Font=require('src.render.Font') +local Version=require('src.core.GameVersion') +local M={} +local function rs(b) + local v=Version.get();local owner=b.rsBattlePanel + if (v=='ruby'or v=='sapphire')and owner and owner.chrome and owner.images + and (not b.safari or b.gen3Safari)and Native.width(b)==240 then return owner end +end +function M.rects(b,which) + local s,w=Native.strip(b),Native.windows(b) + if which=='action'then + if b.safari then return{{s.tx,s.ty,s.tw,s.th}}end + return{{w.prompt.tx,w.prompt.ty,w.prompt.tw,w.prompt.th},{w.action.tx,w.action.ty,w.action.tw,w.action.th}} + elseif which=='moves'then + local right=20 + if w.moves then right=math.max(w.moves[2].tx+w.moves[2].tw,w.moves[4].tx+w.moves[4].tw)-s.tx end + local detail=s.tx+20 + if w.moves then detail=math.max(w.moves[2].tx+w.moves[2].tw,w.moves[4].tx+w.moves[4].tw)end + local edge=Native.extra(b)/8;detail=detail+edge + return{{s.tx,s.ty,right,s.th},{detail,s.ty,30+edge-detail,s.th}} + end + return{} +end +function M.panel(b,which) + local owner=rs(b) + if owner and owner.images[which]then + love.graphics.setColor(1,1,1,1);love.graphics.draw(owner.images[which],0,112) + return true,false -- Native RS user frames are a separate native layer. + end + local drawn=Native.drawPanel(b,which) + return drawn,drawn==true -- Generic native panels already contain the frame. +end +function M.rsFrames(b,which,owner) + owner=owner or rs(b);if not owner then return false end + owner.chrome.setFrameType(math.max(0,((b.game.save.options or{}).gen3Frame or 1)-1)) + local rectangles=which=='action'and{{18,15,11,4}} + or which=='moves'and{{1,15,20,4},{23,15,6,4}}or{} + for _,r in ipairs(rectangles)do owner.chrome.userFrame(owner.chrome._frameType or 0,r[1],r[2],r[3],r[4])end + return true,#rectangles +end +function M.draw(b,which,included) + if included then return true,0 end + local owner=rs(b);if owner then return M.rsFrames(b,which,owner)end + local rectangles=M.rects(b,which) + for _,r in ipairs(rectangles)do Font.drawBox(r[1],r[2],r[3],r[4])end + return true,#rectangles +end +return M diff --git a/src/battle/Gen3EffectCatalog.lua b/src/battle/Gen3EffectCatalog.lua new file mode 100644 index 000000000..c71049c73 --- /dev/null +++ b/src/battle/Gen3EffectCatalog.lua @@ -0,0 +1,46 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Canonical GBA dispatch identifiers, from the public constant header: +-- https://github.com/pret/pokefirered/blob/ce6406d8c2eaf46d543c8a2606de0c3cb1951982/include/constants/battle_move_effects.h +-- Header SHA-256: fa15e285903818cc3cc7ecb2ca32c11d8317b14d261ca2d1caf44e614b53828a. +-- Names are metadata facts, including unused slots, not effect implementations. +local names=[[ +HIT SLEEP POISON_HIT ABSORB BURN_HIT FREEZE_HIT PARALYZE_HIT EXPLOSION DREAM_EATER MIRROR_MOVE ATTACK_UP +DEFENSE_UP SPEED_UP SPECIAL_ATTACK_UP SPECIAL_DEFENSE_UP ACCURACY_UP EVASION_UP ALWAYS_HIT ATTACK_DOWN +DEFENSE_DOWN SPEED_DOWN SPECIAL_ATTACK_DOWN SPECIAL_DEFENSE_DOWN ACCURACY_DOWN EVASION_DOWN HAZE BIDE +RAMPAGE ROAR MULTI_HIT CONVERSION FLINCH_HIT RESTORE_HP TOXIC PAY_DAY LIGHT_SCREEN TRI_ATTACK REST OHKO +RAZOR_WIND SUPER_FANG DRAGON_RAGE TRAP HIGH_CRITICAL DOUBLE_HIT RECOIL_IF_MISS MIST FOCUS_ENERGY RECOIL +CONFUSE ATTACK_UP_2 DEFENSE_UP_2 SPEED_UP_2 SPECIAL_ATTACK_UP_2 SPECIAL_DEFENSE_UP_2 ACCURACY_UP_2 +EVASION_UP_2 TRANSFORM ATTACK_DOWN_2 DEFENSE_DOWN_2 SPEED_DOWN_2 SPECIAL_ATTACK_DOWN_2 +SPECIAL_DEFENSE_DOWN_2 ACCURACY_DOWN_2 EVASION_DOWN_2 REFLECT POISON PARALYZE ATTACK_DOWN_HIT +DEFENSE_DOWN_HIT SPEED_DOWN_HIT SPECIAL_ATTACK_DOWN_HIT SPECIAL_DEFENSE_DOWN_HIT ACCURACY_DOWN_HIT +EVASION_DOWN_HIT SKY_ATTACK CONFUSE_HIT TWINEEDLE VITAL_THROW SUBSTITUTE RECHARGE RAGE MIMIC METRONOME +LEECH_SEED SPLASH DISABLE LEVEL_DAMAGE PSYWAVE COUNTER ENCORE PAIN_SPLIT SNORE CONVERSION_2 LOCK_ON SKETCH +UNUSED_60 SLEEP_TALK DESTINY_BOND FLAIL SPITE FALSE_SWIPE HEAL_BELL QUICK_ATTACK TRIPLE_KICK THIEF MEAN_LOOK +NIGHTMARE MINIMIZE CURSE UNUSED_6E PROTECT SPIKES FORESIGHT PERISH_SONG SANDSTORM ENDURE ROLLOUT SWAGGER +FURY_CUTTER ATTRACT RETURN PRESENT FRUSTRATION SAFEGUARD THAW_HIT MAGNITUDE BATON_PASS PURSUIT RAPID_SPIN +SONICBOOM UNUSED_83 MORNING_SUN SYNTHESIS MOONLIGHT HIDDEN_POWER RAIN_DANCE SUNNY_DAY DEFENSE_UP_HIT +ATTACK_UP_HIT ALL_STATS_UP_HIT UNUSED_8D BELLY_DRUM PSYCH_UP MIRROR_COAT SKULL_BASH TWISTER EARTHQUAKE +FUTURE_SIGHT GUST FLINCH_MINIMIZE_HIT SOLAR_BEAM THUNDER TELEPORT BEAT_UP SEMI_INVULNERABLE DEFENSE_CURL +SOFTBOILED FAKE_OUT UPROAR STOCKPILE SPIT_UP SWALLOW UNUSED_A3 HAIL TORMENT FLATTER WILL_O_WISP MEMENTO +FACADE FOCUS_PUNCH SMELLINGSALT FOLLOW_ME NATURE_POWER CHARGE TAUNT HELPING_HAND TRICK ROLE_PLAY WISH ASSIST +INGRAIN SUPERPOWER MAGIC_COAT RECYCLE REVENGE BRICK_BREAK YAWN KNOCK_OFF ENDEAVOR ERUPTION SKILL_SWAP +IMPRISON REFRESH GRUDGE SNATCH LOW_KICK SECRET_POWER DOUBLE_EDGE TEETER_DANCE BLAZE_KICK MUD_SPORT +POISON_FANG WEATHER_BALL OVERHEAT TICKLE COSMIC_POWER SKY_UPPERCUT BULK_UP POISON_TAIL WATER_SPORT CALM_MIND +DRAGON_DANCE CAMOUFLAGE +]] +local byName,byId,count={},{},0 +for name in names:gmatch('%S+')do + assert(not byName[name],'duplicate native move-effect symbol') + byName[name],byId[count]=count,name;count=count+1 +end +assert(count==214,'incomplete native move-effect symbols') +local C={} +function C.id(name)if type(name)=='string'then return byName[name]end end +function C.name(id) + if type(id)=='number'and id%1==0 and id>=0 and id0 or false +end +return M diff --git a/src/battle/MenuAuxiliary.lua b/src/battle/MenuAuxiliary.lua new file mode 100644 index 000000000..2364a1c7d --- /dev/null +++ b/src/battle/MenuAuxiliary.lua @@ -0,0 +1,163 @@ +-- Public START action at an idle player command menu, before native navigation. +local Runtime = require("src.mods.Runtime") +local M = {} +local BUSY = { + "current", + "nextInsert", + "pendingHit", + "waitingUI", + "waitingSound", + "draining", + "animPlaying", + "growIn", + "shrinkOut", + "ghostReveal", + "mimicCtx", + "result", + "gen3BallPlaying", + "gen4TouchPress", +} +local COUNTERS = { "waitFrames", "hitSfxWait", "introSlide" } +local VARIANTS = { + "safari", + "ghost", + "scopeReveal", + "demo", + "pokedudeDemo", + "noCatch", + "palace", + "arena", + "ally", + "factory", + "pyramid", + "pike", + "battleTower", + "trainerTower", + "secretBase", + "eReader", +} +local function nonempty(value) + return type(value) == "table" and next(value) ~= nil +end +local function defaultAction() + return false +end +function M.ready(battle) + local game = battle and battle.game + if + not game + or not game.save + or not game.data + or battle.phase ~= "menu" + or (battle.kind ~= "wild" and battle.kind ~= "trainer") + then + return false + end + local stack = game.stack + if not stack or not stack.top or stack:top() ~= battle then + return false + end + if + nonempty(battle.queue) + or nonempty(battle.pendingActions) + or nonempty(battle.chosenTargets) + or (battle.afterQueue and battle.afterQueue ~= "menu") + then + return false + end + for _, field in ipairs(VARIANTS) do + if battle[field] then + return false + end + end + for _, field in ipairs(BUSY) do + if battle[field] then + return false + end + end + for _, field in ipairs(COUNTERS) do + local value = battle[field] + if value and value ~= 0 then + return false + end + end + if battle.showEnemyTrainer or battle.showPlayerBack or battle.enemySendingOut then + return false + end + local actor = battle.choosingBattler and battle:choosingBattler() or battle.player + if + not actor + or actor.ally + or not actor.mon + or (actor.mon.hp or 0) <= 0 + or (battle.menuLockedAction and battle:menuLockedAction(actor)) + or actor.bwSkyDropped + then + return false + end + local count = battle.isDouble and battle:isDouble() and 4 or 2 + local enemyStanding = false + for position = 0, count - 1 do + local battler = battle.battlerAt and battle:battlerAt(position) + or (position == 0 and battle.player or position == 1 and battle.enemy) + if battler and battler.mon then + local mon = battler.mon + if + (mon.hp or 0) <= 0 + or battler.fainted + or battler.faintQueued + or battler.draining + or battler.drainFloor ~= nil + or battler.drainHold ~= nil + or battler.shownHP ~= mon.hp + or battler.shownStatus ~= mon.status + then + return false + end + if position % 2 == 1 then + enemyStanding = true + end + elseif count == 2 then + return false + end + end + return enemyStanding +end +function M.request(battle) + if not Runtime.wantsHook("battle.menu_auxiliary") then + return + end + local game = battle.game + if not game or not game.input or not game.input:wasPressed("start") or not M.ready(battle) then + return + end + return { + game = game, + save = game.save, + data = game.data, + stack = game.stack, + session = game.sessionStartedAt, + kind = battle.kind, + } +end +local function current(battle, token) + local game = token.game + return battle.game == game + and game.save == token.save + and game.data == token.data + and game.stack == token.stack + and game.sessionStartedAt == token.session + and battle.kind == token.kind + and M.ready(battle) +end +function M.dispatch(battle, token) + -- Retire input captured for an owner/session that changed during native FX. + if not current(battle, token) then + return true + end + local result = Runtime.call("battle.menu_auxiliary", defaultAction, token.game, { kind = token.kind }) + -- A pushed overlay or changed session must never receive the same input as + -- the retired battle menu, even if the wrapper returned false or raised. + return result == true or not current(battle, token) +end +return M diff --git a/src/battle/PresentationClock.lua b/src/battle/PresentationClock.lua new file mode 100644 index 000000000..b27b6655f --- /dev/null +++ b/src/battle/PresentationClock.lua @@ -0,0 +1,37 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Presentation holds borrow a native update; they never drain its command +-- queue. Results (including failures and nils) are shared within that update. +local M={} +local phases=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +function M.active(screen)return phases[screen]~=nil end +function M.delta(screen)return phases[screen]and phases[screen].delta end +function M.called(screen,key) + local phase=phases[screen];return phase~=nil and phase.steps[key]~=nil +end +function M.once(screen,key,body,...) + local phase=phases[screen] + if not phase then return body(screen,...)end + local entry=phase.steps[key] + if not entry then + entry={};phase.steps[key]=entry + entry.result=pack(pcall(body,screen,...)) + end + local r=entry.result + if not r then return nil,'presentation step is already executing'end + if not r[1]then error(r[2],0)end + return unpack(r,2,r.n) +end +function M.run(screen,dt,body,...) + if phases[screen]then return body(...)end + local phase={steps={},delta=dt};phases[screen]=phase + local result=pack(pcall(body,...));phases[screen]=nil + -- A wrapper cannot swallow a failed display step and retry that step. + for _,entry in pairs(phase.steps)do + if entry.result and not entry.result[1]then error(entry.result[2],0)end + end + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +return M diff --git a/src/battle/QuickItem.lua b/src/battle/QuickItem.lua new file mode 100644 index 000000000..3ef2907de --- /dev/null +++ b/src/battle/QuickItem.lua @@ -0,0 +1,52 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional ball shortcuts enter the ordinary native bag/catch transaction. +local Ready=require('src.battle.MenuAuxiliary') +local Effects=require('src.inventory.ItemEffects') +local Q={} +local function available(battle) + local features=package.loaded['src.mods.FeatureOptions'] + return features and features.enabled('quickBall')and require('src.core.GameVersion').isGen3() + and battle.kind=='wild'and not battle:isDouble()and Ready.ready(battle) +end +local function balls(battle) + local out={} + for key,n in pairs(battle.game.save.inventory or{})do + local def=battle.data.items[key] + if n>0 and Effects.isBall(key,def)then out[#out+1]=key end + end + table.sort(out,function(a,b) + local x,y=battle.data.items[a],battle.data.items[b] + local i,j=x and x.index or 65535,y and y.index or 65535 + if i~=j then return i=0 and n<=max and n%1==0 end +function M.validate(data,record) + if type(record)~='table'then return nil,'encounter record required'end + for key in pairs(record)do if not fields[key]then return nil,'unsupported encounter field: '..tostring(key)end end + if not(data.pokemon and data.pokemon[record.species])then return nil,'unknown native species'end + if not(uint(record.level,100)and record.level>=1)then return nil,'invalid encounter level'end + local out={species=record.species,level=record.level} + if record.personality~=nil then + if not uint(record.personality,4294967295)then return nil,'invalid encounter personality'end + out.personality=record.personality + end + if record.ivs~=nil then + if type(record.ivs)~='table'then return nil,'invalid encounter IVs'end + out.ivs={} + for _,key in ipairs(stats)do + if not uint(record.ivs[key],31)then return nil,'invalid encounter IV '..key end + out.ivs[key]=record.ivs[key] + end + for key in pairs(record.ivs)do if out.ivs[key]==nil then return nil,'unknown encounter IV '..tostring(key)end end + end + if record.item~=nil then + if record.item~=false and not(data.items and data.items[record.item])then return nil,'unknown encounter item'end + out.item=record.item + end + if record.hp~=nil then + if not(uint(record.hp,65535)and record.hp>0)then return nil,'invalid encounter HP'end + out.hp=record.hp + end + if record.status~=nil then + if not uint(record.status,255)or record.status>7 and not({[8]=true,[16]=true,[32]=true,[64]=true,[128]=true})[record.status]then + return nil,'invalid encounter status' + end + out.status=record.status + end + if record.moves~=nil then + if type(record.moves)~='table'or#record.moves<1 or#record.moves>4 then return nil,'invalid encounter moves'end + out.moves={} + for i,row in ipairs(record.moves)do + local id=type(row)=='table'and row.id or row + local def=data.moves and data.moves[id] + if not def then return nil,'unknown encounter move'end + local pp=type(row)=='table'and row.pp or def.pp + if not(uint(pp,255)and pp<=(def.pp or 0))then return nil,'invalid encounter PP'end + out.moves[i]={id=id,pp=pp} + end + for i in pairs(record.moves)do + if not uint(i,4)or i<1 or i>#out.moves then return nil,'invalid encounter move slot'end + end + end + if record.roamer~=nil and type(record.roamer)~='boolean'then return nil,'invalid roaming encounter'end + out.roamer=record.roamer + return out +end +function M.apply(data,mon,record) + if record.personality~=nil or record.ivs then + require('src.pokemon.Pokemon').applySeed(data,mon,{personality=record.personality,ivs=record.ivs}) + end + if record.item~=nil then mon.item=record.item or nil end + if record.moves then mon.moves=record.moves end + if record.hp then mon.hp=math.min(record.hp,mon.stats.hp)end + if record.status~=nil then + local bits=record.status + mon.status=bits>0 and(bits<=7 and'SLP'or({[8]='PSN',[16]='BRN',[32]='FRZ',[64]='PAR',[128]='PSN'})[bits])or nil + mon.sleepTurns=bits>0 and bits<=7 and bits or nil;mon.toxic=bits==128 or nil + end +end +return M diff --git a/src/compat/game3/BattleCommandPhase.lua b/src/compat/game3/BattleCommandPhase.lua new file mode 100644 index 000000000..a330bb041 --- /dev/null +++ b/src/compat/game3/BattleCommandPhase.lua @@ -0,0 +1,104 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- A borrowed native move-selection boundary, not a command queue. Only the +-- existing controller commits the returned choice and advances battle rules. +local M={} +local pending +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function integer(v,lo,hi)return type(v)=='number'and v==v and v%1==0 and v>=lo and v<=hi end +function M.take() + local p=pending + if not p or p.consumed or p.committing then return nil end + p.consumed=true + return p.command +end +function M.dispatch(screen,action,body,source) + if pending then + if pending.screen==screen and pending.committing then + local values=pack(body(screen,action));pending.result=values;return unpack(values,1,values.n) + end + return false,'native command phase is already pending' + end + local Battle=require('src.core.game3.battle') + local game=screen.game + if Battle._screen()~=screen or not game or game.stack:top()~=screen then + return false,'native battle does not own input' + end + if screen.phase~='moveSelect'and screen.phase~='targetSelect' + or screen.kind=='link'or screen.spectating or screen.demo or screen.pokedudeDemo + or screen.palace or screen.arena then return body(screen,action)end + local actor=screen:menuBattler();local slot + for i,m in ipairs(actor and actor.curMoves or{})do if m==action then slot=i;break end end + -- Forced actions, switches and other sequencers keep their existing owner. + if not slot then return body(screen,action)end + local ids=require('src.compat.gen1.BattleIds').new(screen.data) + local move=ids:read('moves',action.id) + if not move then return body(screen,action)end + local pos=screen:choosingSlotNow() + local target=screen.chosenTargets and screen.chosenTargets[pos] + local targetId=target and target.position + local command={kind='move',user='player',battler=pos,slot=slot,move=move,target=targetId} + local admission=Battle.getState() + local p={screen=screen,command=command};pending=p + local phase,turn,save,data,stack,mon,moves=screen.phase,screen.turnCount,game.save,game.data,game.stack,actor.mon,actor.curMoves + local result=pack(pcall(function() + local nextCommand=source() + if Battle._screen()~=screen or Battle.getState()~=admission or game.stack~=stack or stack:top()~=screen + or game.save~=save or game.data~=data or screen.data~=data or not ids.valid() + or screen.phase~=phase or screen.turnCount~=turn or screen:menuBattler()~=actor + or actor.mon~=mon or actor.curMoves~=moves or moves[slot]~=action + or screen:choosingSlotNow()~=pos or screen.result or screen.dead or screen.waitingUI + or actor.fainted or not actor.mon or(actor.mon.hp or 0)<=0 + or(screen.chosenTargets and screen.chosenTargets[pos])~=target then + return false,'native command context changed' + end + if nextCommand==nil then + if screen.chosenTargets then screen.chosenTargets[pos]=nil end + screen.phase='moveSelect' + return false,'native move selection cancelled' + end + assert(type(nextCommand)=='table'and getmetatable(nextCommand)==nil,'command must be a plain move record') + local fields={kind=true,user=true,battler=true,slot=true,move=true,target=true} + for key in pairs(nextCommand)do assert(fields[key],'unsupported native move command field')end + assert(nextCommand.kind=='move'and nextCommand.user=='player'and nextCommand.battler==pos,'native move command changed its acting battler') + local selected=nextCommand.slot + assert(integer(selected,1,#moves)and selected<=4,'invalid native move command slot') + local nextMove=moves[selected] + assert(nextCommand.move==ids:read('moves',nextMove.id),'native command move does not match its slot') + local chosen=nextCommand.target;local nativeTarget + if chosen~=nil then + assert(integer(chosen,0,3),'invalid native move target') + for _,candidate in ipairs(require('src.battle.Targeting').choices(screen,actor))do + if candidate.position==chosen then nativeTarget=candidate;break end + end + assert(nativeTarget,'native move target is not selectable') + end + if not Battle._flushNative(screen)then return false,'native command admission retired'end + p.committing=true + local selection=package.loaded['src.battle.Gen3MoveSelection'] + if selection and selection.manualReason then + local reason=selection.manualReason(screen,actor,nextMove,selected) + if selected==slot and chosen==targetId and not reason then return body(screen,action)end + end + -- Modified choices go back through the native refusal/target selector. + if screen.chosenTargets then screen.chosenTargets[pos]=nil end + screen.phase='moveSelect' + local a,b=screen:chooseMove(selected) + if screen.phase=='targetSelect'and nativeTarget then + for i,candidate in ipairs(screen.targetChoices or{})do + if candidate==nativeTarget then + a,b=screen:chooseTarget(i) + if p.result then return unpack(p.result,1,p.result.n)end + return a,b + end + end + return false,'native move target changed' + end + if p.result then return unpack(p.result,1,p.result.n)end + return a,b + end)) + pending=nil + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +return M diff --git a/src/compat/game3/Follower.lua b/src/compat/game3/Follower.lua new file mode 100644 index 000000000..8628ac6f6 --- /dev/null +++ b/src/compat/game3/Follower.lua @@ -0,0 +1,50 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Native=require('src.world.OptionalFollower') +local F={setShouldSpawn=Native.setShouldSpawn} +function F.current(world) + local game=package.loaded['src.core.Game'] + world=world or game and game.overworld + local npc=world and Native.current(world)or nil + if npc and require('src.core.GameVersion').isGen3()then return require('src.core.game3.objects').find(npc.def.index)end + return npc +end +function F.actor() + local npc=F.current();if not(npc and npc.visible~=false and not npc.hidden and npc.sprite)then return nil end + return{kind='follower',i=npc.localId,x=npc.px,y=npc.py,sortY=npc.py,elevation=npc.currentElevation, + facing=npc.facing,walkPhase=npc.moving and 1 or 0,renderer=npc.sprite} +end +function F.reset() + local game,world=require('src.compat.gen1.NativeSession').resolve() + if not game then return false end + return Native.reset(world) +end +function F.at(_,x,y) + local npc=F.current();if npc and not npc.moving and npc.cellX==x and npc.cellY==y then return npc end +end +function F.update()return require('src.compat.gen1.ScriptBoundary').invoke('follower.update')end +local nativeUpdate=F.update +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +function F._dispatch(game,world,body) + local Call=require('src.compat.gen1.SourceCall') + local function advance() + local result=pack(body()) + -- A wrapped source follower starts as an unpainted logical actor. Native + -- consumers retain their normal artwork; merely reading the facade cannot + -- hide it. Existing owned renderers also keep their installing owner. + local who=F.update~=nativeUpdate and Call.owner(F,'update') + local actor=who and Native.current(world) + if actor then + local _,assigned=require('src.compat.gen1.SourceActors').renderer(actor) + if not assigned then + local view=F.current(world) + if view then require('src.core.Game3')._withCallbackOwner(who,function()view.sprite=nil end)end + end + end + return unpack(result,1,result.n) + end + return require('src.compat.gen1.ScriptBoundary').dispatch('follower.update',advance,function(...) + return Call.call(F,'update',...) + end,require('src.core.Game3').forNative(game)) +end +return F diff --git a/src/compat/gen1/BattleIds.lua b/src/compat/gen1/BattleIds.lua new file mode 100644 index 000000000..cbc8b08c7 --- /dev/null +++ b/src/compat/gen1/BattleIds.lua @@ -0,0 +1,56 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Cartridge indices belong to this imported dataset, including sparse species +-- slots. Portable names and National Dex numbers are not substitutes for them. +local M={} +local schema={pokemon={'speciesOrder','pokemon'},moves={'moveOrder','moves'},items={'itemOrder','items'}, + abilities={'abilityOrder','abilities',1},types={'typeOrder','types',1},trainers={'trainerOrder','trainers'}} +function M.new(data) + local indexes={} + local constants=data.constants + local catalogues={} + for kind,spec in pairs(schema)do + catalogues[kind]={order=constants and constants[spec[1]],rows=data[spec[2]]or constants and constants[spec[2]]} + end + local function valid() + if data.constants~=constants then return false end + for kind,c in pairs(catalogues)do + local spec=schema[kind] + if (constants and constants[spec[1]])~=c.order or(data[spec[2]]or constants and constants[spec[2]])~=c.rows then return false end + end + return true + end + local function index(kind) + if indexes[kind]then return indexes[kind]end + local spec=assert(schema[kind],'unknown battle identifier kind') + local constants=data.constants or{};local rows=data[spec[2]]or constants[spec[2]]or{} + local order=constants[spec[1]]or{};local forward,reverse={},{} + local function add(number,key) + if type(number)~='number'or number<0 or number%1~=0 then return end + if forward[number]~=nil and forward[number]~=key then forward[number]=false else forward[number]=key end + if reverse[key]~=nil and reverse[key]~=number then reverse[key]=false else reverse[key]=number end + end + for number,key in pairs(order)do if type(number)=='number'and rows[key]~=nil then add(number-(spec[3]or 0),key)end end + for key,row in pairs(rows)do + if type(row)=='table'and type(row.index)=='number'and order[row.index+(spec[3]or 0)]==nil then add(row.index,key)end + end + local result={forward=forward,reverse=reverse,rows=rows};indexes[kind]=result;return result + end + return { + valid=valid, + read=function(_,kind,key) + if not valid()then return nil end + if key==nil or key==false then return(kind=='items'or kind=='abilities')and 0 or nil end + local i=index(kind);local n=i.reverse[key] + if n~=nil then return n or nil end + if type(key)=='number'and i.rows[key]then return key end + end, + native=function(_,kind,value) + if not valid()then return nil end + if value==nil or value==0 and(kind=='items'or kind=='abilities')then return nil end + local i=index(kind) + if type(value)=='number'then return i.forward[value]or nil end + if i.rows[value]then return value end + end, + } +end +return M diff --git a/src/compat/gen1/BattleRequests.lua b/src/compat/gen1/BattleRequests.lua new file mode 100644 index 000000000..a1c234e83 --- /dev/null +++ b/src/compat/gen1/BattleRequests.lua @@ -0,0 +1,123 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Queued native encounters reuse the host's admission, trainer flags, +-- afterBattle cleanup and callback cancellation. Unsupported source-owned +-- party simulations are rejected before constructing or rolling a Pokemon. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Version=require('src.core.GameVersion') +local R={} +local fields={wild=true,foe=true,trainerId=true,onDone=true} +function R.start(opts) + if type(opts)~='table'then return false,'invalid battle options'end + for key in pairs(opts)do if not fields[key]then return false,'native battle option unavailable: '..tostring(key)end end + if opts.onDone~=nil and type(opts.onDone)~='function'then return false,'invalid battle continuation'end + local game,why=Native.resolve();if not game then return false,why end + local Host=require('src.mods.Runtime');local facade=require('src.core.Game3') + local owner=Host.currentMod or facade._callbackOwner() + local loader=game.mods;local mod=loader and loader.mods and loader.mods[owner] + if not(mod and loader.hooks and type(loader.hooks.wrap)=='function')then return false,'battle request has no live mod owner'end + local save,data,edition=game.save,game.data,Version.get() + local function licensed() + return game.mods==loader and game.save==save and game.data==data and Version.get()==edition + and loader.mods[owner]==mod and mod.enabled~=false and not mod.failed + end + local function current()return Native.game()end + local function world(g)return{overworld=function()return Native.world(g)end}end + local available,admission=pcall(require,'src.mods.Api3WildBattle') + if not available then return false,'native encounter admission provider unavailable: '..tostring(admission)end + local start=admission.new + if opts.wild==true then + local foe=opts.foe + if type(foe)~='table'then return false,'wild battle requires a species and level'end + for key in pairs(foe)do if key~='species'and key~='level'then return false,'authored wild Pokemon field unavailable: '..tostring(key)end end + local species=Ids.new(data):native('pokemon',foe.species) + if not species then return false,'unknown cartridge species'end + if type(foe.level)~='number'or foe.level%1~=0 or foe.level<1 or foe.level>100 then return false,'invalid wild level'end + -- Resolve has already validated the cartridge key; the native plan avoids + -- interpreting it again as a portable species name. + local plan={resolve=function()return species end,construct=function(g,key) + return require('src.battle.BattleState').newWild(g,key,foe.level) + end} + return start(loader,owner,current,world,licensed,require,nil,plan)(species,foe.level,opts.onDone) + end + if opts.foe~=nil then return false,'trainer requests require the imported trainer id'end + if opts.trainerId==nil then return false,'native trainer id required'end + return start(loader,owner,current,world,licensed,require,nil, + require('src.mods.ScriptBattles').nativeTrainer)(opts.trainerId,1,opts.onDone) +end +-- Full source encounter records use the same guarded admission as portable +-- battles; numeric source IDs are converted before the native plan is built. +function R.startWild(value,encounter,opts) + opts=opts or{} + if type(opts)~='table'or type(encounter)~='table'then return nil,'invalid wild encounter request'end + for key in pairs(opts)do if key~='done'then return nil,'native wild option unavailable: '..tostring(key)end end + if opts.done~=nil and type(opts.done)~='function'then return nil,'invalid battle continuation'end + local game,why=Native.resolve(value) + if not game or game~=Native.game()then return nil,why or'foreign native game'end + local host=require('src.mods.Runtime');local facade=require('src.core.Game3') + local owner=host.currentMod or facade._callbackOwner() + local loader=game.mods;local mod=loader and loader.mods and loader.mods[owner] + if not(mod and loader.hooks and loader.hooks.wrap)then return nil,'battle request has no live mod owner'end + local data,save,edition=game.data,game.save,Version.get();local ids=Ids.new(data) + local fields={species=true,level=true,personality=true,ivs=true,item=true,hp=true,status=true,moves=true,pp=true,roamer=true} + local raw={} + for key,v in pairs(encounter)do if not fields[key]then return nil,'unsupported encounter field: '..tostring(key)end;raw[key]=v end + raw.species=ids:native('pokemon',encounter.species) + if encounter.item~=nil then + raw.item=encounter.item==0 and false or ids:native('items',encounter.item) + if raw.item==nil then return nil,'unknown cartridge held item'end + end + if encounter.ivs~=nil then + if type(encounter.ivs)~='table'then return nil,'invalid encounter IVs'end + raw.ivs={} + local names={hp='hp',atk='attack',def='defense',spe='speed',spa='spatk',spd='spdef', + attack='attack',defense='defense',speed='speed',spatk='spatk',spdef='spdef'} + for key,v in pairs(encounter.ivs)do + local target=names[key];if not target or raw.ivs[target]~=nil then return nil,'invalid encounter IV name'end + raw.ivs[target]=v + end + end + raw.pp=nil + if encounter.moves~=nil then + if type(encounter.moves)~='table'then return nil,'invalid encounter moves'end + raw.moves={} + for i,row in ipairs(encounter.moves)do + local id=type(row)=='table'and(row.id or row.move or row.moveId)or row + local native=ids:native('moves',id) + if not native then return nil,'unknown cartridge move'end + local pp=type(row)=='table'and row.pp or type(encounter.pp)=='table'and encounter.pp[i] + raw.moves[i]={id=native,pp=pp or data.moves[native].pp} + end + for i in pairs(encounter.moves)do + if type(i)~='number'or i%1~=0 or i<1 or i>#raw.moves then return nil,'invalid encounter move slot'end + end + end + local record;record,why=require('src.battle.WildRecord').validate(data,raw) + if not record then return nil,why end + local roamer + if record.roamer then + roamer=require('src.world.Gen3Roamers').active(save) + if not roamer or roamer.species~=record.species or roamer.level~=record.level + or record.personality~=nil and roamer.personality~=record.personality then return nil,'roaming individual changed'end + -- A source snapshot cannot overwrite the native roaming individual. + record.personality=roamer.personality + record.ivs=nil + record.hp=nil;record.status=nil + + end + local function licensed() + return game.mods==loader and game.data==data and game.save==save and Version.get()==edition + and loader.mods[owner]==mod and mod.enabled~=false and not mod.failed and ids:valid() + end + local plan={resolve=function()return record.species end,construct=function(g,key) + local options={wildRecord=record} + if roamer then options.roamer='gen3';options.roamerSeed=roamer;options.roamerHP=roamer.hp;options.roamerStatus=roamer.status end + local battle=require('src.battle.BattleState').newWild(g,key,record.level,options) + if save.safari then battle:makeSafari(save.safari)end + return battle + end} + return require('src.mods.Api3WildBattle').new(loader,owner,function()return Native.game()end, + function(g)return{overworld=function()return Native.world(g)end}end,licensed,require,nil,plan)(record.species,record.level,opts.done) +end + +return R diff --git a/src/compat/gen1/BattleRuntime.lua b/src/compat/gen1/BattleRuntime.lua new file mode 100644 index 000000000..4ef2fc296 --- /dev/null +++ b/src/compat/gen1/BattleRuntime.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Observe real native stack admissions. Completed source views keep no save, +-- controller, imported dataset or borrowed GPU resource alive. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Views=require('src.compat.gen1.BattleView') +local Ids=require('src.compat.gen1.BattleIds') +local R={};R.__index=R +local function owner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner() +end +local function cohort(c) + return c.game.data==c.data and c.game.save==c.save and c.game.mods==c.loader + and c.game.stack==c.stack and Version.get()==c.edition and c.ids:valid() +end +local function member(c) + if c.retired or not cohort(c)then return false end + for _,state in ipairs(c.stack.states or{})do if state==c.screen then return true end end + return false +end +function R.new()return setmetatable({records=setmetatable({},{__mode='kv'})},R)end +function R:retire(c,completed) + if not c or c.retired then return end + c.views:retire();c.retired=true + self.records[c.screen]=nil + if self.current==c then self.current=nil end + if completed then + self.last=c.views.view + self.lastSession=setmetatable({c.game,c.data,c.save,c.stack,c.loader},{__mode='v'}) + self.lastEdition=c.edition + end + c.game,c.screen,c.data,c.save,c.stack,c.loader,c.ids=nil,nil,nil,nil,nil,nil,nil +end +function R:bind(screen) + local game=screen and Native.game(screen.game) + if not(game and game==Native.game()and screen.isBattleState and screen.data==game.data and game.stack and game.save)then return end + local c=self.records[screen] + if c and member(c)then local who=owner();if who then c.users[who]=true end;return c end + if c then self:retire(c,false)end + c={screen=screen,game=game,data=game.data,save=game.save,stack=game.stack,loader=game.mods,edition=Version.get(),users={},ids=Ids.new(game.data)} + if not member(c)then return end + if self.current and self.current~=c then self:retire(self.current,false)end + c.views=Views.new(game,screen,function()return member(c)end,c.ids) + local who=owner();if who then c.users[who]=true end + self.records[screen]=c;self.current=c;self.last=nil + return c +end +function R:active(value) + local game=Native.game(value) + if value~=nil and game~=Native.game()then return nil end + local last=self.lastSession + if last and(not game or last[1]~=game or last[2]~=game.data or last[3]~=game.save + or last[4]~=game.stack or last[5]~=game.mods or self.lastEdition~=Version.get())then + self.last,self.lastSession,self.lastEdition=nil,nil,nil + end + if self.current and(not member(self.current)or self.current.game~=game)then self:retire(self.current,false)end + if not game then return end + for i=#(game.stack and game.stack.states or{}),1,-1 do + local state=game.stack.states[i] + if state.isBattleState and state.game==game then return self:bind(state)end + end +end +function R:exit(screen) + local c=self.records[screen] + if c then self:retire(c,cohort(c))end +end +function R:release() + if self.current then self:retire(self.current,false)end + self.last,self.lastSession,self.lastEdition=nil,nil,nil +end +function R:poll()self:active()end +function R:releaseOwner(who) + if self.current and self.current.users[who]then self:retire(self.current,false)end +end +R.member=member +return R diff --git a/src/compat/gen1/BattleView.lua b/src/compat/gen1/BattleView.lua new file mode 100644 index 000000000..3921f615b --- /dev/null +++ b/src/compat/gen1/BattleView.lua @@ -0,0 +1,234 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Live, typed source observations over one admitted native battle. Views own +-- their arrays; they never lend the native controller/save under source names. +local Ids=require('src.compat.gen1.BattleIds') +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local residents=setmetatable({},{__mode="k"}) +function M.nativeMon(view) + local d=residents[view];if not d then return nil,false end + local e=d.environment + if not e.context or not e.valid()then return nil,true end + e:flush();return d.entry.raw,true,e.context.game +end +local statNames={attack='attack',defense='defense',speed='speed',spAtk='spatk',spDef='spdef',hp='hp'} +local statusBits={PSN=8,BRN=16,FRZ=32,PAR=64,TOX=128} +local function status(mon) + if mon.status=='SLP'then return math.max(1,math.min(7,tonumber(mon.sleepTurns)or 1))end + if mon.status=='PSN'and mon.toxic then return 128 end + return statusBits[mon.status]or 0 +end +local function integer(v,lo,hi)return type(v)=='number'and v==v and v%1==0 and v>=lo and v<=hi end +local monKeys={'species','nickname','level','hp','maxHp','attack','defense','speed','spAtk','spDef', + 'moves','pp','maxPp','item','status','ability','personality','friendship','isEgg','otId','otSecretId'} +local battlerKeys={'id','side','flank','mon','species','type1','type2','ability','item','status','fainted','stages','partyIndex'} +local stateKeys={'player','enemy','battlers','party','playerParty','enemyParty','turn','wild','double','over','result', + 'weather','weatherTurns','session','playerIndex','enemyIndex','trainerClass','trainerName','trainerPicId', + 'link','safari','tutorial','oldManTutorial','pokedude','firstBattle','ghostBattle','kinds','rng'} +local reserved={} +for _,key in ipairs(stateKeys)do reserved[key]=true end +function M.new(game,screen,valid,identifiers) + local e={context={game=game,screen=screen,data=game.data},entries=setmetatable({},{__mode='k'}), + caches={},arrays={},valid=valid,retired=false} + local ids=identifiers or Ids.new(game.data) + local function at(pos) + local s=e.context and e.context.screen;if not s then return end + if pos==0 then return s.player elseif pos==1 then return s.enemy end + return s.battlerAt and s:battlerAt(pos) + end + local read + local function collection(name,values,convert) + local a=e.arrays[name] + if not a then a={view={}};e.arrays[name]=a end + local out=a.view;local old=#out + for i,value in ipairs(values or{})do out[i]=convert and convert(value,i)or value end + for i=#(values or{})+1,old do out[i]=nil end + return out + end + local function proxy(kind,raw) + if not raw then return nil end + local cache=e.caches[kind];if not cache then cache=setmetatable({},{__mode='kv'});e.caches[kind]=cache end + local existing=cache[raw];if existing then return existing end + local d={kind=kind,raw=raw};local view={};cache[raw]=view;e.entries[view]=d + if kind=="mon"then residents[view]={entry=d,environment=e}end + setmetatable(view,{__index=function(_,key) + if d.snapshot then return d.snapshot[key]end + if not e.context or not e.valid()then return nil end + return read(d,key) + end,__newindex=function(t,key,value) + assert(e.context and e.valid(),'native battle view retired') + if kind=='mon'and(key=='hp'or key=='nickname'or key=='item'or key=='status')then + local mon=d.raw + if key=='hp'then assert(integer(value,0,mon.stats.hp),'invalid native HP');mon.hp=value + elseif key=='nickname'then assert(type(value)=='string'and#value<=32,'invalid native nickname');mon.nickname=value + elseif key=='item'then + local item=ids:native('items',value);assert(value==0 or item,'unknown native held item');mon.item=item + else + assert(integer(value,0,255),'invalid native status') + local inverse={[8]='PSN',[16]='BRN',[32]='FRZ',[64]='PAR',[128]='PSN'} + assert(value==0 or value<=7 or inverse[value],'unsupported combined native status') + mon.status=value>0 and(value<=7 and'SLP'or inverse[value])or nil + mon.sleepTurns=value>0 and value<=7 and value or nil;mon.toxic=value==128 or nil + end + elseif kind=='state'and not reserved[key]then + -- Presentation extensions belong to this view; they cannot replace the + -- controller's command queue, party aliases or completion result. + rawset(t,key,value) + else error('native battle '..kind..' '..tostring(key)..' is not directly writable',2)end + end}) + -- LuaJIT does not consult __pairs. Keep registry values weak so its + -- weak key cannot retain itself through this environment's public view. + local observation=setmetatable({environment=e,entry=d},{__mode='v'}) + Iteration.register(view,function(spec,source) + local environment,entry=spec.environment,spec.entry + assert(environment and entry,'native battle view retired') + local out={};for key,value in Iteration.rawPairs(source)do out[key]=value end + if entry.snapshot then for key,value in pairs(entry.snapshot)do out[key]=value end;return out end + assert(environment.context and environment.valid(),'native battle view retired') + local keys=entry.kind=='state'and stateKeys or entry.kind=='battler'and battlerKeys or entry.kind=='mon'and monKeys + or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'} + for _,key in ipairs(keys)do out[key]=environment.read(entry,key)end + return out + end,observation) + return view + end + local function partyIndex(party,mon)for i,value in ipairs(party or{})do if value==mon then return i end end end + local function party()local c=e.context;return c.screen.playerParty or c.game.save.party or{}end + local function moveArrays(mon) + local d=e.moveArrays and e.moveArrays[mon] + if not d then + e.moveArrays=e.moveArrays or{};d={moves={},pp={},maxPp={},baseline={}};e.moveArrays[mon]=d + end + -- PP edits use an explicit native transaction before the next native body. + if not d.pending then + for _,key in ipairs{'moves','pp','maxPp'}do for i in pairs(d[key])do d[key][i]=nil end end + d.baseline={};d.ids={};d.maximum={} + for i,mv in ipairs(mon.moves or{})do + d.moves[i]=ids:read('moves',mv.id);d.pp[i]=mv.pp;d.baseline[i]=mv.pp + local def=(e.context.data.moves or{})[mv.id] + d.maxPp[i]=def and def.pp and def.pp+math.floor(def.pp/5)*(mv.ppUps or 0)or nil + d.ids[i]=d.moves[i];d.maximum[i]=d.maxPp[i] + end + d.pending=true + end + return d + end + read=function(d,key) + local raw=d.raw;local c=e.context + if d.kind=='mon'then + if key=='species'then return ids:read('pokemon',raw.species)end + if key=='item'then return ids:read('items',raw.item)end + if key=='ability'then + local b;for pos=0,3 do local actual=at(pos);if actual and actual.mon==raw then b=actual;break end end + return ids:read('abilities',b and b.ability or raw.ability) + end + if key=='hp'then return raw.hp end + if key=='maxHp'then return raw.stats and raw.stats.hp end + if statNames[key]then return raw.stats and raw.stats[statNames[key]]end + if key=='status'then return status(raw)end + if key=='moves'or key=='pp'or key=='maxPp'then return moveArrays(raw)[key]end + if key=='otId'then return raw.otId or raw.otID end + if key=='otSecretId'then return raw.otSecretId or raw.secretId end + if key=='friendship'then return raw.friendship or raw.happiness end + if key=='nickname'or key=='level'or key=='personality'or key=='isEgg'then return raw[key]end + elseif d.kind=='battler'then + local pos=raw.position + if pos==nil then for i=0,3 do if at(i)==raw then pos=i;break end end end + if key=='id'then return pos end + if key=='side'then return pos and(pos%2==0 and'player'or'enemy')end + if key=='flank'then return pos and(pos<2 and'left'or'right')end + if key=='mon'then return proxy('mon',raw.mon)end + if key=='species'then return ids:read('pokemon',raw.species or raw.mon.species)end + if key=='ability'then return ids:read('abilities',raw.ability)end + if key=='item'then return ids:read('items',raw.mon.item)end + if key=='status'then return status(raw.mon)end + if key=='fainted'then return raw.fainted==true or raw.mon.hp<=0 end + if key=='partyIndex'then return partyIndex(pos and pos%2==0 and party()or c.screen.enemyParty,raw.mon)or 1 end + if key=='stages'then return proxy('stages',raw.stages or{})end + if key=='type1'or key=='type2'then return ids:read('types',(raw.curTypes or{})[key=='type1'and 1 or 2])end + elseif d.kind=='stages'then return raw[statNames[key]or key] + elseif d.kind=='state'then + if key=='player'or key=='enemy'then return proxy('battler',raw[key])end + if key=='battlers'then + e.battlers=e.battlers or{};for pos=0,3 do e.battlers[pos]=proxy('battler',at(pos))end;return e.battlers + end + if key=='party'or key=='playerParty'then return collection('party',party(),function(mon)return proxy('mon',mon)end)end + if key=='enemyParty'then + e.wildParty=e.wildParty or{};e.wildParty[1]=raw.enemy and raw.enemy.mon + return collection('enemyParty',raw.enemyParty or e.wildParty,function(mon)return proxy('mon',mon)end) + end + if key=='turn'then return raw.turnCount or 0 end + if key=='wild'then return raw.kind=='wild'end + if key=='double'then return raw.isDouble and raw:isDouble()or raw.double==true end + if key=='over'then return raw.result~=nil or raw.dead==true end + if key=='result'then return raw.result end + if key=='weather'then return raw.field and raw.field.weather end + if key=='weatherTurns'then return raw.field and raw.field.weatherTurns end + if key=='playerIndex'then return partyIndex(party(),raw.player and raw.player.mon)end + if key=='enemyIndex'then return raw.enemyIndex or(raw.enemy and 1)end + if key=='trainerClass'then return raw.trainer and raw.trainer.class or raw.trainerClass end + if key=='trainerName'then return raw.trainerName or raw.trainer and raw.trainer.name end + if key=='trainerPicId'then return raw.trainer and(raw.trainer.picIndex or raw.trainer.index)end + if key=='session'then return require('src.core.Game3').forNative(c.game).session end + if key=='link'then return raw.kind=='link'end + if key=='safari'then return raw.safari~=nil and raw.safari~=false end + if key=='ghostBattle'then return raw.ghost==true end + if key=='tutorial'or key=='oldManTutorial'then return raw.demo==true or raw.dudeDemo==true end + if key=='pokedude'then return raw.pokedudeDemo~=nil and raw.pokedudeDemo~=false end + if key=='firstBattle'then return raw.firstBattle==true end + if key=='kinds'then + e.kinds=e.kinds or{};e.kinds.tutorial=raw.pokedudeDemo and'pokedude'or(raw.demo and'oldMan'or nil);return e.kinds + end + if key=='rng'then return e.rng end + end + end + e.read=read + e.view=proxy('state',screen) + e.rng=function(...)assert(e.context and e.valid(),'native battle RNG retired');return e.context.screen.rng(...)end + function e:flush() + if not self.context or not self.valid()then return false end + local writes={} + for mon,d in pairs(self.moveArrays or{})do + for i,value in pairs(d.moves)do assert(value==d.ids[i],'source move replacement requires native move learning')end + for i,value in pairs(d.ids)do assert(d.moves[i]==value,'source move replacement requires native move learning')end + for i,value in pairs(d.maxPp)do assert(value==d.maximum[i],'source maximum PP is read-only')end + for i,value in pairs(d.maximum)do assert(d.maxPp[i]==value,'source maximum PP is read-only')end + for i in pairs(d.baseline)do assert(d.pp[i]~=nil,'source PP slot cannot be removed')end + for i,value in pairs(d.pp)do + assert(d.baseline[i]~=nil,'unknown source PP slot') + if value~=d.baseline[i]then + local mv=mon.moves and mon.moves[i];local maximum=d.maxPp[i] + assert(mv and mv.pp==d.baseline[i],'native move PP changed during source edit') + assert(integer(value,0,maximum or 99),'invalid source move PP') + writes[#writes+1]={move=mv,value=value} + end + end + end + for _,write in ipairs(writes)do write.move.pp=write.value end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + return true + end + function e:retire() + if self.retired then return end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + -- Populate only declared observations. No native queue, functions, save or + -- data table is copied into the completed record or retained by its views. + local seen={} + repeat + local pending=false + for view,d in pairs(self.entries)do if not seen[view]then + pending=true;seen[view]=true;local snapshot={} + for _,key in ipairs(d.kind=='state'and stateKeys or d.kind=='battler'and battlerKeys or d.kind=='mon'and monKeys or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'})do + if key~='session'and key~='rng'then snapshot[key]=read(d,key)end + end + d.snapshot=snapshot + end end + if not pending then break end + until false + for _,d in pairs(self.entries)do d.raw=nil end + self.context,self.moveArrays,self.wildParty,self.valid=nil,nil,nil,function()return false end + self.retired=true;ids=nil + end + return e +end +return M 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/CoroutineScopes.lua b/src/compat/gen1/CoroutineScopes.lua new file mode 100644 index 000000000..d4c337d55 --- /dev/null +++ b/src/compat/gen1/CoroutineScopes.lua @@ -0,0 +1,96 @@ +-- Dynamic native scopes may be borrowed by an actively resumed child. A +-- suspended child cannot lend its scope to an unrelated native operation. +local M={} +local MAIN={} +-- Retirement crosses phase/attribution registries. A source coroutine that +-- outlives a borrowed native phase cannot acquire another caller's authority. +local revoked=setmetatable({},{__mode='k'}) +local function thread() + local co,main=coroutine.running() + return main and MAIN or co or MAIN +end +function M.retired()return revoked[thread()]==true end +local function visible(frame,co) + if frame.closed then return false end + local creator=frame.creator + return creator==co or creator==MAIN or coroutine.status(creator)=='normal' +end +local function previous(frame,key) + repeat frame=frame and frame[key] until not frame or not frame.closed + return frame +end +local function within(frame,ancestor) + while frame do + if frame==ancestor then return true end + frame=frame.parent + end + return false +end +local function selectFrame(head,key,co) + local frame=head + -- A resumed coroutine's own phase wins over an unrelated caller's newer + -- phase. Creation order alone does not describe coroutine ancestry. + while frame do + if not frame.closed and frame.creator==co then return frame end + frame=frame[key] + end + local selected + frame=head + while frame do + if visible(frame,co)then + if not selected or within(frame,selected)then selected=frame + elseif not within(selected,frame)then + -- Lua exposes running/suspended status, not a resumer ancestry API. + -- Independent native roots cannot safely lend a phase to this child. + return nil,'ambiguous native coroutine scope' + end + end + frame=frame[key] + end + return selected +end +function M.new() + -- Weak heads do not keep an abandoned suspended coroutine (and the native + -- body on its stack) alive. Live dispatch calls retain their own frame. + local names=setmetatable({},{__mode='v'}) + local latest=setmetatable({},{__mode='v'}) + local locals=setmetatable({},{__mode='kv'}) + local stack={} + function stack:find(name) + local co=thread() + if revoked[co] or locals[co]and locals[co].closed then return nil,'native coroutine scope retired'end + return selectFrame(names[name],'previousName',co) + end + function stack:push(name,value,parent) + local co=thread() + assert(not revoked[co]and not(locals[co]and locals[co].closed),'native coroutine scope retired') + parent=parent or selectFrame(latest[1],'previousAll',co) + assert(not parent or not parent.closed,'native coroutine scope retired') + local frame={name=name,value=value,creator=co,parent=parent, + previousName=names[name],previousAll=latest[1],previousLocal=locals[co]} + if parent then + parent.children=parent.children or setmetatable({},{__mode='k'}) + parent.children[frame]=true + end + names[name],latest[1],locals[co]=frame,frame,frame + return frame + end + local function retire(frame,forced) + if frame.closed then return end + frame.closed=true + if forced and frame.creator~=MAIN and frame.creator~=thread()then revoked[frame.creator]=true end + -- A parent returning retires nested dispatches that yielded in a child. + -- Their later finally blocks cannot restore an expired native phase. + if frame.children then for child in pairs(frame.children)do retire(child,true)end end + if frame.parent then frame.parent.children[frame]=nil end + if names[frame.name]==frame then names[frame.name]=previous(frame,'previousName')end + if latest[1]==frame then latest[1]=previous(frame,'previousAll')end + frame.value,frame.body,frame.result=nil,nil,nil + end + function stack:close(frame) + retire(frame,false) + if locals[frame.creator]==frame then locals[frame.creator]=frame.previousLocal end + end + return stack +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/FieldEffectCache.lua b/src/compat/gen1/FieldEffectCache.lua new file mode 100644 index 000000000..4c7817682 --- /dev/null +++ b/src/compat/gen1/FieldEffectCache.lua @@ -0,0 +1,67 @@ +-- Explicit cache input only. No cartridge import or player filesystem I/O. +local M={} +local function integer(n,lo,hi)return type(n)=='number'and n==n and n%1==0 and n>=lo and n<=hi end +local function copy(v,state,depth) + local kind=type(v) + if kind=='number'then assert(v==v and math.abs(v)1048576 then return nil,'field manifest size limit'end + if bytes:byte(1)==27 then return nil,'field manifest must be text'end + local chunk,why + if setfenv then chunk,why=loadstring(bytes,'@field-effect-manifest');if chunk then setfenv(chunk,{})end + else chunk,why=load(bytes,'@field-effect-manifest','t',{})end + if not chunk then return nil,why end + if jit and jit.off then jit.off(chunk,true)end + local hook,mask,count=debug.gethook() + debug.sethook(function()error('field manifest instruction limit')end,'',200000) + local ok,result=pcall(chunk);debug.sethook(hook,mask,count) + if not ok or type(result)~='table'then return nil,'invalid field manifest'end + local safe,value=pcall(copy,result,{nodes=0,seen={}},0) + if not safe then return nil,tostring(value)end + return value +end +local function relative(path) + if type(path)~='string'or#path==0 or#path>256 or path:sub(1,1)=='/'or path:find('[\\:%z]')then return false end + for part in path:gmatch('[^/]+')do if part=='..'or part=='.'then return false end end + return not path:find('//',1,true) +end +function M.manifest(cache,root,family) + local bytes=cache:read(root..'/objects.lua') + if bytes==nil then return nil end + local manifest,why=decode(bytes);if not manifest then return nil,why end + if manifest.family and manifest.family~=family then return nil,'field manifest belongs to another cartridge family'end + local names={} + for _,key in ipairs({'objects','extras'})do + local list=manifest[key] + if list~=nil and type(list)~='table'then return nil,'invalid field object list'end + for _,row in pairs(list or{})do + if type(row)~='table'or type(row.name)~='string'or not row.name:match('^[%w_%-]+$')or#row.name>80 + or not integer(row.fw,8,128)or not integer(row.fh,8,128)or not integer(row.frames,1,256) + or row.fw*row.fh*row.frames>1048576 or not relative(row.rgba)then + return nil,'invalid field object geometry or path' + end + if names[row.name]then return nil,'ambiguous field object name'end + names[row.name]=row + end + end + manifest._byName=names;return manifest +end +function M.rgba(cache,root,row) + local bytes=cache:read(root..'/'..row.rgba) + if type(bytes)~='string'or#bytes~=row.fw*row.fh*row.frames*4 then return nil,'invalid field RGBA payload'end + return bytes +end +M.copy=function(value)return copy(value,{nodes=0,seen={}},0)end +return M diff --git a/src/compat/gen1/FieldEffectRuntime.lua b/src/compat/gen1/FieldEffectRuntime.lua new file mode 100644 index 000000000..c06e2cfe4 --- /dev/null +++ b/src/compat/gen1/FieldEffectRuntime.lua @@ -0,0 +1,147 @@ +-- Owned field-effect artwork over the native dataset or an explicit cache. +-- This service has no actor, animation, task, RNG or gameplay clock. +local Session=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Catalog=require('src.render.Gen3FieldEffectCatalog') +local Cache=require('src.compat.gen1.FieldEffectCache') +local Assets=require('src.render.Assets') +local R={};R.__index=R +local ENGINE={} +local function owner() + local runtime=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return runtime and runtime.currentMod or facade and facade._callbackOwner and facade._callbackOwner()or ENGINE +end +local function release(value)if value and value.release then pcall(value.release,value)end end +local function root() + local extract=package.loaded['src.import.gba.extract_island1'] + return ((extract and extract.CACHE_ROOT)or'data/generated/gba')..'/field_effects' +end +local function signature(r)return table.concat({r.path or r.rgba,r.fw,r.fh,r.frames},'\0')end +function R.new() + return setmetatable({loaded={},owned={},providers={},bytes=0,maxBytes=64*1024*1024},R) +end +function R:drop(sheet) + local held=self.owned[sheet];if not held then return end + self.owned[sheet]=nil;self.bytes=self.bytes-held.bytes + -- Public sheet tables are mutable. Retire only allocations recorded at + -- construction, never an image/quad a mod later assigned to that table. + for _,part in ipairs(held.parts)do release(part)end + sheet.image,sheet.imageData,sheet.quads,sheet.quadsFront=nil,nil,{},{};sheet._retired=true +end +function R:invalidate() + for sheet in pairs(self.owned)do self:drop(sheet)end + self.loaded={};self.context=nil +end +function R:release()self:invalidate();self.providers={}end +function R:current() + local game,why=Session.game();local edition=Version.get() + if not(game and type(game.data)=='table'and type(game.data.constants)=='table')then + self:release();return nil,why or'no native field-effect dataset' + end + local provider + for i=#self.providers,1,-1 do + local p=self.providers[i] + if p.game==game and p.data==game.data and p.save==game.save and p.mods==game.mods and p.edition==edition then + provider=provider or p + else table.remove(self.providers,i)end + end + local c=self.context;local prefix=root() + if c and(c.game~=game or c.data~=game.data or c.constants~=game.data.constants or c.sprites~=game.data.sprites + or c.save~=game.save or c.mods~=game.mods or c.edition~=edition or c.provider~=provider + or c.root~=prefix or c.assets~=Assets.loader)then self:invalidate();c=nil end + if not c then + c={game=game,data=game.data,constants=game.data.constants,sprites=game.data.sprites, + save=game.save,mods=game.mods,edition=edition,provider=provider,root=prefix,assets=Assets.loader} + self.context=c + end + return c +end +function R:install(cache) + if cache~=nil and(type(cache)~='table'or type(cache.read)~='function')then return nil,'invalid field cache provider'end + local c,why=self:current();if not c then return nil,why end + local who=owner() + for i=#self.providers,1,-1 do if self.providers[i].owner==who then table.remove(self.providers,i)end end + if cache then self.providers[#self.providers+1]={owner=who,cache=cache,game=c.game,data=c.data,save=c.save,mods=c.mods,edition=c.edition}end + self:invalidate();return self:manifest() +end +function R:catalog() + local c,why=self:current();if not c then return nil,why end + if c.provider and not c.providerRead then + c.providerRead=true + local ok,manifest,err=pcall(Cache.manifest,c.provider.cache,c.root,Version.isFRLG(c.edition)and'frlg'or'rse') + if not ok then c.manifestError=tostring(manifest)else c.manifest,c.manifestError=manifest,err end + if self:current()~=c then return nil,'field cache cohort retired during manifest read'end + end + if c.manifestError then return nil,c.manifestError end + return c +end +function R:manifest() + local c,why=self:catalog();if not c then return nil,why end + if c.manifest then return Cache.copy(c.manifest)end + local manifest={family=Version.isFRLG(c.edition)and'frlg'or'rse',objects={},extras={},_byName={}} + for name,row in pairs(Catalog.records(c.data))do + local item={name=name,fw=row.fw,fh=row.fh,frames=row.frames,nativePath=row.path} + manifest.objects[#manifest.objects+1]=item;manifest._byName[name]=item + end + table.sort(manifest.objects,function(a,b)return a.name80 or not name:match('^[%w_%-]+$')then return nil,'invalid field sheet name'end + local c,why=self:catalog();if not c then return nil,why end + if c.manifest then + local row=c.manifest._byName[name] + if not row then return nil,'field sheet absent from installed manifest'end + return row,c,true + end + local row=Catalog.records(c.data)[name] + if not row then return nil,'native field sheet unavailable'end + return row,c,false +end +function R:get(name) + local row,c,explicit=self:record(name);if not row then return nil,c end + local sig=signature(row) + if not explicit then sig=sig..'\0'..Assets.resolve(row.path)end + local cached=self.loaded[name];local held=cached and self.owned[cached] + if held and held.signature==sig then held.owners[owner()]=true;return cached end + if cached then self:drop(cached);self.loaded[name]=nil end + if not(love and love.image and love.graphics)then return nil,'field sheet graphics unavailable'end + local size=row.fw*row.fh*row.frames*8 + if self.bytes+size>self.maxBytes then return nil,'owned field sheet budget exhausted'end + local parts={};local function keep(x)parts[#parts+1]=x;return x end + local ok,sheet=pcall(function() + local data + if explicit then + local bytes,why=Cache.rgba(c.provider.cache,c.root,row);assert(bytes,why) + data=keep(love.image.newImageData(row.fw,row.fh*row.frames,'rgba8',bytes)) + else data=keep(love.image.newImageData(Assets.resolve(row.path)))end + assert(data:getWidth()==row.fw and data:getHeight()==row.fh*row.frames,'field sheet image dimensions differ') + local limits=love.graphics.getSystemLimits and love.graphics.getSystemLimits() + assert(not limits or row.fh*row.frames<=limits.texturesize,'field sheet exceeds GPU limit') + local image=keep(love.graphics.newImage(data));image:setFilter('nearest','nearest') + local made={image=image,imageData=data,fw=row.fw,fh=row.fh,frames=row.frames,quads={},quadsFront={}} + for i=0,row.frames-1 do + made.quads[i]=keep(love.graphics.newQuad(0,i*row.fh,row.fw,row.fh,row.fw,row.fh*row.frames)) + made.quadsFront[i]=keep(love.graphics.newQuad(0,i*row.fh+row.fh-8,row.fw,8,row.fw,row.fh*row.frames)) + end + assert(self:current()==c,'field sheet cohort retired during preparation') + local latest,context,provider=self:record(name) + assert(context==c and provider==explicit and signature(latest)==signature(row) + and(explicit or sig==signature(latest)..'\0'..Assets.resolve(latest.path)),'field sheet record changed during preparation') + assert(self.bytes+size<=self.maxBytes,'owned field sheet budget exhausted during preparation') + return made + end) + if not ok then for _,part in ipairs(parts)do release(part)end;return nil,tostring(sheet)end + self.owned[sheet]={signature=sig,bytes=size,parts=parts,owners={[owner()]=true}};self.bytes=self.bytes+size + self.loaded[name]=sheet;return sheet +end +function R:releaseOwner(who) + for i=#self.providers,1,-1 do if self.providers[i].owner==who then table.remove(self.providers,i)end end + for name,sheet in pairs(self.loaded)do + local held=self.owned[sheet] + if held then held.owners[who]=nil;if next(held.owners)==nil then self:drop(sheet);self.loaded[name]=nil end end + end + self:current() +end +function R:poll()self:current()end +return R diff --git a/src/compat/gen1/FieldMoveRuntime.lua b/src/compat/gen1/FieldMoveRuntime.lua new file mode 100644 index 000000000..cf4cc368d --- /dev/null +++ b/src/compat/gen1/FieldMoveRuntime.lua @@ -0,0 +1,137 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Bind compatible field-move operations to the actual native stack state. +local Session=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local R={};R.__index=R +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function owner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner and facade._callbackOwner() +end +local function current(c) + return not c.retired and c.game.data==c.data and c.game.save==c.save and c.game.mods==c.mods and Version.get()==c.edition +end +function R.new()return setmetatable({records=setmetatable({},{__mode='kv'}),callbacks=setmetatable({},{__mode='k'})},R)end +function R:bind(state) + local c=self.records[state];if c then return current(c)and state:valid(false)and c or nil end + local game=Session.game(state.game);if not(game and state:valid(false))then return nil end + c={state=state,game=game,data=game.data,save=game.save,mods=game.mods,edition=Version.get(),owner=owner(),sourceStarted=state.sourceOwned==true} + c.nativeCallback=state.onDone + local callback=c.nativeCallback + local admission=setmetatable({state},{__mode='v'}) + c.continue=function() + if c.detachedCallback then return c.detachedCallback()end + if c.cancelled then return false,'native field move owner retired'end + if c.error then error(c.error,0)end + if c.result then return unpack(c.result,1,c.result.n)end + if c.running then return false,'native field move continuation is already executing'end + local live=admission[1] + if not(c.exited and c.completed and live and live.callbackReady)then return false,'native field move has not completed'end + c.running=true;local fn=callback;callback=nil;c.nativeCallback=nil;admission[1]=nil + local result=pack(pcall(function()if fn then return fn()end end));fn=nil;c.running=false + if not result[1]then c.error=result[2];error(result[2],0)end + c.result={n=result.n-1};for i=2,result.n do c.result[i-1]=result[i]end + return unpack(c.result,1,c.result.n) + end + c.forgetCallback=function()callback=nil;c.nativeCallback=nil;admission[1]=nil end + state.onDone=c.continue;c.installed=c.continue;self.records[state]=c;self.callbacks[c]=true + return c +end +function R:current() + local game=Session.game();if not game then return nil end + local class=package.loaded['src.world.Gen3FieldMove'] + if not class then return nil end + for i=#(game.stack and game.stack.states or{}),1,-1 do + local state=game.stack.states[i] + if getmetatable(state)==class and not state.done then return self:bind(state)end + end +end +function R:exit(state) + local c=self.records[state];if not c or c.exited then return end + c.exited=true;c.completed=state.completed==true and current(c) + if not c.completed then c.forgetCallback()end + self.records[state]=nil;c.retired=true;c.state,c.game,c.data,c.save,c.mods=nil,nil,nil,nil,nil +end +function R:detach(c) + local state=c.state + if state and state.onDone==c.installed then state.onDone=c.nativeCallback + elseif state then c.detachedCallback=c.nativeCallback end + c.forgetCallback();c.retired=true + if state then self.records[state]=nil end + c.state,c.game,c.data,c.save,c.mods=nil,nil,nil,nil,nil +end +local function cancelCallback(c) + c.cancelled=true;c.result=nil;c.error=nil;c.forgetCallback() +end +function R:release() + local selected={};for c in pairs(self.callbacks)do selected[#selected+1]=c end + for _,c in ipairs(selected)do + if c.sourceStarted then + if c.state then c.state:retire()end;cancelCallback(c) + elseif c.state then self:detach(c)end + end + self.records=setmetatable({},{__mode='kv'}) +end +function R:cancelOwner(who) + local selected={} + for c in pairs(self.callbacks)do if c.owner==who then selected[#selected+1]=c end end + for _,c in ipairs(selected)do + if c.sourceStarted then + if c.state then c.state:retire()end;cancelCallback(c) + elseif c.state then self:detach(c)end + end + return #selected>0 +end +function R:mon(game,value) + local mon=type(value)=='table'and value or{species=value};local out={} + for k,v in pairs(mon)do out[k]=v end + if type(out.species)=='number'and not(game.data.pokemon and game.data.pokemon[out.species])then out.species=(game.data.constants.speciesOrder or{})[out.species]or out.species end + if not(game.data.pokemon and game.data.pokemon[out.species])then return nil,'native species artwork unavailable'end + if mon.isShiny~=nil then out.shiny=mon.isShiny==true end + return out +end +function R:start(mon,opts,callback) + local game,world=Session.resolve() + if not(game and world and Session.acceptsInput(game,world,world.player))then return false,'no available native field-move owner'end + if self:current()then return false,'native field move is busy'end + local converted,why=self:mon(game,mon);if not converted then return false,why end + opts=opts or{};if type(opts)~='table'or(callback~=nil and type(callback)~='function')then return false,'invalid field move options'end + return require('src.world.Gen3FieldMove').show(game,converted,callback, + {presentation='cartridge',pose=opts.pose==true,noDuck=opts.noDuck==true,sourceStarted=true,sourceOwner=owner()}) +end +function R:configure(c,mon,opts,callback) + if not(current(c)and c.state:valid(true))then return false,'native field move admission retired'end + if c.configured then return true end + opts=opts or{};assert(type(opts)=='table','invalid field move options') + local converted,why=self:mon(c.game,mon);if not converted then return false,why end + local ok,err=c.state:configure(converted,opts,callback);if not ok then return false,err end + c.installed=callback + c.owner=c.owner or owner();c.configured=true + return true +end +function R:phase(c,name,body,fn,...) + if not c or not current(c)or not c.state:valid(name=='step')then return false,'native field move admission retired'end + local result=pack(Boundary.dispatch('field_move.'..name,body,fn,...)) + if c.error then error(c.error,0)end + return unpack(result,1,result.n) +end +function R:reset() + local c=self:current();if not c then return false end + local who=owner();if c.owner and who and c.owner~=who then return false,'native field move belongs to another mod'end + return c.state:complete(false) +end +function R:snapshot() + local c=self:current();local fx=c and c.state.effect + if not fx then return nil end + local out={} + for k,v in pairs(fx)do if type(v)~='table'and type(v)~='function'then out[k]=v end end + if fx.sprite then out.sprite={};for k,v in pairs(fx.sprite)do out.sprite[k]=v end end + local order=c.data.constants.speciesOrder or{} + for id,species in pairs(order)do if species==fx.species then out.species=id;break end end + out.shiny=require('src.pokemon.Pokemon').isShiny(c.state.mon) + out.picSpecies=out.species + return out +end +return R 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/NativeModalObservation.lua b/src/compat/gen1/NativeModalObservation.lua new file mode 100644 index 000000000..65f9c6e95 --- /dev/null +++ b/src/compat/gen1/NativeModalObservation.lua @@ -0,0 +1,35 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Constructor-bound native modal observations. Weak owner slots avoid a +-- weak-key/value/stack cycle keeping a retired world alive under LuaJIT. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local M={} +local records=setmetatable({},{__mode='k'}) +function M.capture(state,game) + if Version.isGen3()and Native.game()==game and game.stack then + records[state]={edition=Version.get(),hasMods=game.mods~=nil, + refs=setmetatable({game,game.data,game.save,game.stack,game.mods},{__mode='v'})} + end + return state +end +function M.find(classes) + local game=Native.game();if not(game and game.stack)then return end + for i=#game.stack.states,1,-1 do + local state=game.stack.states[i];local class=getmetatable(state) + local matching=false + for _,name in ipairs(classes)do + local nested=type(name)=='table' + local module=package.loaded[nested and name[1]or name] + local candidate=module + if nested then candidate=module and module[name[2]]end + if candidate~=nil and class==candidate then matching=true;break end + end + if matching then + local row=records[state];local r=row and row.refs + if r and state.game==game and row.edition==Version.get()and r[1]==game + and r[2]~=nil and r[2]==game.data and r[3]~=nil and r[3]==game.save + and r[4]==game.stack and r[5]==game.mods and row.hasMods==(game.mods~=nil)then return state end + end + end +end +return M diff --git a/src/compat/gen1/NativeRegionMap.lua b/src/compat/gen1/NativeRegionMap.lua new file mode 100644 index 000000000..13c291520 --- /dev/null +++ b/src/compat/gen1/NativeRegionMap.lua @@ -0,0 +1,121 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Observations of the admitted native picker, never a second map controller. +local Native=require('src.compat.gen1.NativeSession') +local Modal=require('src.compat.gen1.NativeModalObservation') +local Version=require('src.core.GameVersion') +local Targets=require('src.world.Gen3FlyTargets') +local M={} +local current +local function screen() + local game=Native.resolve();if not(game and game.stack and type(game.stack.states)=='table')then return end + local s=Modal.find({'src.ui.Gen3RegionMap'}) + return s and not s.closed and s or nil +end +local function valid(c) + local r=c and not c.retired and c.refs + if not(r and r[1]and r[2]and r[6])then return false end + local game,s=r[1],r[2];local G=package.loaded['src.core.Game3'] + return G and G.native(r[6])==game and Native.game()==game and Version.get()==c.edition + and game.data==r[3]and game.save==r[4]and game.mods==r[5]and screen()==s + and s.rsNative==r[7]and(not r[7]or s.rsNative.state==r[8]) +end +local function release() + if current then current.retired=true;current.refs=nil;current.view=nil;current.users={}end + current=nil +end +local function ready(c) + if not valid(c)then return false end + local game,s=c.refs[1],c.refs[2];local stack=game.stack + local top=stack.active and stack:active()or stack.states[#stack.states] + if top~=s or s.area then return false end + if c.refs[7]then + local raw=c.refs[8];return not c.refs[7].closed and raw.state==12 and raw.inputFn~='move' + end + return not s:zooming() +end +local function section(c) + if not valid(c)then return end + if c.refs[7]then return c.refs[8].mapSecId end + local place=c.refs[2]:current();return place and place.section +end +local function destination(c,sec,pos) + if not valid(c)then return end + local game,s=c.refs[1],c.refs[2] + if not s.fly or sec~=section(c)then return end + if c.refs[7]then + local rs=c.refs[7];local raw=c.refs[8] + if pos~=nil and pos~=raw.posWithinMapSec then return end + local kind=rs.native.mapSecType(raw,sec) + if kind~=rs.native.TYPE.CITY_CANFLY and kind~=rs.native.TYPE.BATTLE_FRONTIER then return end + local dest=rs:destination(sec,raw.posWithinMapSec) + return dest and Targets.detached(game,dest.map,dest) + end + if s.frlg and s.page~=(s.playerPage or'kanto')then return end + return Targets.destination(game,sec,s.targets) +end +function M.active(family) + local s=screen();local game=Native.game() + local frlg=Version.isFRLG() + if not(s and game)then release();return end + if family and(family=='frlg')~=frlg then return end + if not valid(current)then + release();local G=require('src.core.Game3');local view=G.forNative(game) + if not view then return end + local rs=s.rsNative + local c={edition=Version.get(),users={},refs=setmetatable({game,s,game.data,game.save,game.mods,view,rs,rs and rs.state},{__mode='v'})} + c.view=setmetatable({},{__index=function(_,key) + if key=='open'then return valid(c)end + if not valid(c)then return end + local state=c.refs[2];local raw=c.refs[8] + if key=='mode'then return state.fly and'fly'or state.area and'area'or'wall'end + if key=='mapSecId'then return section(c)end + if raw and(key=='state'or key=='inputFn'or key=='posWithinMapSec'or key=='chose')then return raw[key]end + -- The generic picker has no fade task: 12 denotes its real input-ready + -- phase in this observation protocol, rather than an invented timer. + if key=='state'then return ready(c)and 12 or 0 end + if key=='inputFn'then return ready(c)and'full'or nil end + if key=='selectedRegion'then return state.page end + if key=='playersRegion'then return state.playerPage or(state.frlg and'kanto'or nil)end + if key=='cursor'then return{x=state.cx,y=state.cy,moveCounter=raw and raw.moveCounter or 0}end + end,__newindex=function()error('native region map observations are read-only',2)end}) + current=c + end + local host=package.loaded['src.mods.Runtime'];local G=package.loaded['src.core.Game3'] + local who=host and host.currentMod or G and G._callbackOwner() + if who then current.users[who]=true end + return current.view +end +local function admitted(view) + return current and valid(current)and(view==nil or view==current.view)and current or nil +end +function M.ready()local c=admitted();return c and ready(c)or false end +function M.section()local c=admitted();return c and section(c)end +function M.destination(sec,pos) + local c=admitted();if c then return destination(c,sec,pos)end +end +function M.kind(view,sec) + local c=admitted(view);if not c or sec~=section(c)then return 0 end + if c.refs[7]then return c.refs[7].native.mapSecType(c.refs[8],sec)end + -- This older native controller only classifies selectable fly targets. + -- Do not guess route/city/landmark identities for other atlas sections. + return destination(c,sec)and 2 or 0 +end +function M.manifest() + local game=Native.resolve() + assert(game and not Version.isFRLG(),'native Hoenn atlas requires an admitted field') + local s=screen() + if s and s.rsNative then return require('src.mods.Merge').deepCopy(s.rsNative.kit:manifest())end + local spec=game.data.constants and game.data.constants.gen3RsMap + if spec then + local ok,kit=pcall(require,'src.ui.Gen3RsMapKit') + assert(ok and kit.available(game),'native RS atlas owner unavailable') + return require('src.mods.Merge').deepCopy(kit.new(game):manifest()) + end + return require('src.ui.Gen3RegionMap').manifest(game) +end +M._service={release=release,poll=function()if current and not valid(current)then release()end end, + releaseOwner=function(_,who) + if current and current.users[who]then current.users[who]=nil;if not next(current.users)then release()end end + end} +require('src.core.game3.asset_stream').register(M._service) +return M 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/compat/gen1/ObjectRuntime.lua b/src/compat/gen1/ObjectRuntime.lua new file mode 100644 index 000000000..b2734daaf --- /dev/null +++ b/src/compat/gen1/ObjectRuntime.lua @@ -0,0 +1,208 @@ +-- Owned, live observations of the native object pool. No second actor loop. +local Session = require('src.compat.gen1.NativeSession') +local Identity = require('src.compat.gen1.MapIdentity') +local Version = require('src.core.GameVersion') +local R = {}; R.__index = R +local directions = {up=true,down=true,left=true,right=true} +local function finite(v) return type(v)=='number' and v==v and math.abs(v)=lo and v<=hi end +local function copy(v,seen) + if type(v)~='table' then + if type(v)=='string' or type(v)=='number' or type(v)=='boolean' then return v end + return nil + end + seen=seen or {}; if seen[v] then return seen[v] end + local out={}; seen[v]=out + for k,value in pairs(v) do if type(k)=='string' or type(k)=='number' then out[k]=copy(value,seen) end end + return out +end +local fields={cellX='cellX',cellY='cellY',px='px',py='py',targetX='targetX',targetY='targetY', + moving='moving',progress='progress',facing='facing',stepFlip='stepFlip',animClock='animClock', + invisible='invisible',hidden='hidden',passable='passable',runtime='runtime', + customFrame='fixedFrame',raiseX='shiftPx',homeX='roamOriginX',homeY='roamOriginY'} +local boolWrite={hidden='hidden',invisible='invisible',passable='passable'} +function R.new() return setmetatable({views=setmetatable({},{__mode='kv'}),records=setmetatable({},{__mode='kv'})},R) end +function R:release() + if self.context then self.context.retired=true end + self.context=nil +end +function R:current(game) + local g,w,p=Session.resolve(game) + local c=self.context + if not g then self:release(); return nil,w end + if c and (c.retired or c.game~=g or c.world~=w or c.player~=p or c.map~=w.map or c.def~=w.map.def or c.data~=g.data + or c.maps~=g.data.maps or c.save~=g.save or c.loader~=g.mods or c.edition~=Version.get() + or c.binding:legacyId(w.map.id)~=c.id) then + self:release(); c=nil + end + if not c then + c={game=g,world=w,player=p,map=w.map,def=w.map.def,data=g.data,maps=g.data.maps, + save=g.save,loader=g.mods,edition=Version.get()} + c.binding=Identity.bind(c.maps,c.edition) + local why; c.id,why=c.binding:legacyId(w.map.id) + if not c.id then return nil,why end + self.context=c + end + return c +end +local function live(c) + if c.retired then return false end + local g,w,p=Session.resolve(c.game) + if g~=c.game or w~=c.world or p~=c.player or w.map~=c.map or w.map.def~=c.def + or g.data~=c.data or g.data.maps~=c.maps or g.save~=c.save or g.mods~=c.loader + or Version.get()~=c.edition or c.binding:legacyId(c.map.id)~=c.id then c.retired=true; return false end + return true +end +local function member(c,e,mapId) + if not live(c) then return false end + if mapId==c.map.id then + for _,a in ipairs(c.world.npcs or c.world.entities or {}) do if a==e then return true end end + else + for _,nb in ipairs(c.world.neighbors or {}) do + if nb.map and nb.map.id==mapId and c.maps[mapId]==nb.map.def then + for _,a in ipairs(nb.peers or {}) do if a==e then return true end end + end + end + for _,ghost in ipairs(c.world.ghosts or {}) do + local map=ghost.nb and ghost.nb.map + if map and map.id==mapId and c.maps[mapId]==map.def and ghost.npc==e then return true end + end + end + return false +end +function R:view(c,e,mapId) + local class=package.loaded['src.world.NPC'] + if type(class)~='table' or getmetatable(e)~=class or type(e.def)~='table' + or not integer(e.def.index,1,65535) then return nil,'unsupported native object identity' end + local id,why=c.binding:legacyId(mapId); if not id then return nil,why end + local record=self.views[e] + if record and record.context==c and record.mapId==mapId and not record.retired then return record.view end + record={context=c,actor=e,mapId=mapId,extras={},definition=copy(e.def)} + record.definition.localId=e.def.index + local function valid() + if record.retired then return false end + if not member(c,e,mapId) or c.binding:legacyId(mapId)~=id then record.retired=true; return false end + return true + end + local function read(_,key) + if not valid() then return nil end + if key=='passable' then return e.passable==true or e.def.passable==true end + if key=='runtime' then return e.def.runtime==true end + if fields[key] then return e[fields[key]] end + if key=='def' then return record.definition end + if key=='localId' or key=='originLocalId' then return e.def.index end + if key=='id' then return e.id end + if key=='originMapId' then return id end + if key=='originMapGroup' then return c.maps[mapId].group end + if key=='originMapNum' then return c.maps[mapId].number end + if key=='graphicsId' then + -- The extractor preserves the cartridge row on the actual sprite. + -- Variable sprites resolve to a different sheet without changing def. + local sprite=e.sprite and e.sprite.def + local key=type(sprite)=='table' and sprite.id + return type(key)=='string' and tonumber(key:match('^SPRITE_G3_(%d+)$')) or e.def.graphicsId + end + if key=='visible' then return not(e.hidden or e.visible==false or e.berryVisualEmpty) end + if key=='currentElevation' or key=='elevation' then return e.elevation or e.gen3Elevation or e.def.elevation or 0 end + if key=='stepFrames' then return e.scriptStepLen or e.stepFrames or 16 end + if key=='raiseY' then + local observed={}; for k,v in pairs(e) do observed[k]=v end + setmetatable(observed,class) + local _,_,py=class.pose(observed) + return e.py-py + end + if key=='movementType' then return e.gen3MovementType or e.def.movementType end + if key=='frozen' then return e.frozen==true or e.gen3ScriptFrozen==true end + if key=='scriptBusy' then + for _,mv in ipairs(c.world.scriptMoves or {}) do if mv.entity==e then return true end end + return false + end + return record.extras[key] + end + local function write(_,key,value) + assert(valid(),'native object view retired') + if key=='sprite'then + assert(value==nil or type(value)=='table'and type(value.draw)=='function','invalid object renderer') + local host=require('src.mods.Runtime');local facade=require('src.core.Game3') + local who=host.currentMod or facade._callbackOwner();assert(who,'object renderer requires a mod owner') + record.extras.sprite=value;record.presentationSet=true;record.presentationOwner=who + require('src.compat.gen1.SourceActors').bind(e,record) + elseif boolWrite[key] then + assert(type(value)=='boolean','invalid object boolean'); e[boolWrite[key]]=value + elseif key=='visible' then + assert(type(value)=='boolean','invalid object visibility'); e.hidden=not value; e.visible=value + elseif key=='facing' then + assert(directions[value],'invalid object facing'); e.facing=value + elseif key=='raiseX' then + assert(finite(value) and math.abs(value)<=16777216,'invalid object offset'); e.shiftPx=value + elseif key=='customFrame' then + local frames=e.sprite and e.sprite.def and math.max(e.sprite.def.frames or 1,e.sprite.def.poseFrames or 1) or 1 + assert(value==nil or integer(value,0,frames-1),'invalid object frame'); e.fixedFrame=value + elseif key=='frozen' then + assert(type(value)=='boolean','invalid object freeze'); e.gen3ScriptFrozen=value or nil + elseif fields[key] or key=='def' or key=='localId' or key=='graphicsId' or key=='id' + or key=='currentElevation' or key=='elevation' or key=='stepFrames' or key=='movementType' + or key=='scriptBusy' or key=='raiseY' or type(key)=='string' and key:match('^origin') then + error('object '..key..' requires a native command owner',2) + else record.extras[key]=value end + end + record.view=setmetatable({},{__index=read,__newindex=write}) + self.views[e]=record + self.records[record.view]=record + return record.view +end +function R:actor(view) + local r=view and self.records[view] + if r then + if not r.retired and r.view.localId~=nil then return r.actor,r.context,r.mapId end + r.retired=true; return nil,'native object view retired' + end + return nil,'foreign object view' +end +function R:pool(mapId,game) + local c,why=self:current(game); if not c then return nil,why end + local nativeId=c.map.id + if mapId then + nativeId=c.binding:nativeId(mapId) + if not nativeId then + -- Registries can acquire authored maps without replacing data.maps. + -- Re-index on a miss; never fall back to the current map's objects. + c.binding=Identity.bind(c.maps,c.edition) + nativeId=c.binding:nativeId(mapId) + end + if not nativeId then return nil,'unknown object map' end + end + local list,seen,ids={},{},{} + local function add(e) + if seen[e] then return true end + local view,err=self:view(c,e,nativeId); if not view then return nil,err end + if ids[view.localId] then return nil,'ambiguous native object local ID' end + seen[e]=true; ids[view.localId]=true; list[#list+1]=view + return true + end + if nativeId==c.map.id then + for _,e in ipairs(c.world.npcs or c.world.entities or {}) do + if e~=c.player then local ok,err=add(e); if not ok then return nil,err end end + end + else + for _,nb in ipairs(c.world.neighbors or {}) do + if nb.map and nb.map.id==nativeId then + for _,e in ipairs(nb.peers or {}) do local ok,err=add(e); if not ok then return nil,err end end + end + end + for _,ghost in ipairs(c.world.ghosts or {}) do + if ghost.nb and ghost.nb.map and ghost.nb.map.id==nativeId then + local ok,err=add(ghost.npc); if not ok then return nil,err end + end + end + end + return list,c +end +function R:walkPhase(view) + local e=self:actor(view) + return e and not e.sliding and e:walkPhase() or 0 +end +R.integer=integer +R.copy=copy +R.directions=directions +return R diff --git a/src/compat/gen1/PlayerPose.lua b/src/compat/gen1/PlayerPose.lua new file mode 100644 index 000000000..61d258ed1 --- /dev/null +++ b/src/compat/gen1/PlayerPose.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Short field gestures use the native player's fixed step and pose owner. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local M={} +local active +local function release() + local a=active;active=nil + if a and a.player._gen3FieldMovePoseOwner==a then + a.player.fieldMove,a.player.fieldMovePose,a.player.fieldMoveFrames=a.previous,a.pose,a.frames + a.player._gen3FieldMovePoseOwner=a.previousOwner + end +end +local function live(a) + local g,w,p=Native.resolve() + return a and g==a.game and w==a.world and p==a.player and w.map==a.map and g.data==a.data + and g.save==a.save and g.mods==a.loader and Version.get()==a.edition +end +function M.start(duration,kind) + if active and not live(active)then release()end + local g,w,p=Native.resolve();if not g or p.moving or p._gen3FieldMovePoseOwner then return false end + duration=duration or 28 + assert(type(duration)=='number'and duration%1==0 and duration>=1 and duration<=3600,'invalid field gesture duration') + assert(kind==nil or type(kind)=='string','invalid field gesture kind') + local host=require('src.mods.Runtime');local facade=require('src.core.Game3');local who=host.currentMod or facade._callbackOwner() + if not who then return false end + active={game=g,world=w,player=p,map=w.map,data=g.data,save=g.save,loader=g.mods,edition=Version.get(),who=who, + remaining=duration,previous=p.fieldMove,pose=p.fieldMovePose,frames=p.fieldMoveFrames,previousOwner=p._gen3FieldMovePoseOwner} + p._gen3FieldMovePoseOwner=active;p.fieldMove=true;p.fieldMovePose=kind or'field_move';p.fieldMoveFrames=duration + return true +end +function M.tick(player) + local a=active;if not a then return end + if not live(a)or a.player._gen3FieldMovePoseOwner~=a then release();return end + if player~=a.player then return end + a.remaining=a.remaining-1;a.player.fieldMoveFrames=a.remaining + if a.remaining<=0 then release()end +end +M.release=release +M.releaseOwner=function(_,who)if active and active.who==who then release()end end +M.poll=function()if active and not live(active)then release()end end +require('src.core.game3.asset_stream').register(M) +return M diff --git a/src/compat/gen1/PokemonView.lua b/src/compat/gen1/PokemonView.lua new file mode 100644 index 000000000..fc53d6e0e --- /dev/null +++ b/src/compat/gen1/PokemonView.lua @@ -0,0 +1,127 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Typed party observations share native residents, never another party/save. +local Ids=require('src.compat.gen1.BattleIds') +local M={} +local records=setmetatable({},{__mode='k'}) +local environments=setmetatable({},{__mode='k'}) +local Iteration=require('src.compat.gen1.TableIteration') +local stats={maxHp='hp',attack='attack',defense='defense',speed='speed',spAtk='spatk',spDef='spdef'} +local bits={PSN=8,BRN=16,FRZ=32,PAR=64} +local function uint(n,hi)return type(n)=='number'and n==n and n>=0 and n<=hi and n%1==0 end +function M.status(mon) + if mon.status=='SLP'then return math.max(1,math.min(7,mon.sleepTurns or 1))end + return mon.status=='PSN'and mon.toxic and 128 or bits[mon.status]or 0 +end +function M.setStatus(mon,value) + if value==nil then value=0 end + assert(uint(value,255)and(value<=7 or({[8]=true,[16]=true,[32]=true,[64]=true,[128]=true})[value]),'invalid native status') + mon.status=value>0 and(value<=7 and'SLP'or({[8]='PSN',[16]='BRN',[32]='FRZ',[64]='PAR',[128]='PSN'})[value])or nil + mon.sleepTurns=value>0 and value<=7 and value or nil;mon.toxic=value==128 or nil +end +function M.new(game,valid) + local e={game=game,data=game.data,ids=Ids.new(game.data),entries=setmetatable({},{__mode='k'}),cache=setmetatable({},{__mode='kv'}),valid=valid} + environments[e]=true + function e:live() + if not self.game then return false end + if not self.valid()or not self.ids:valid()then self:release();return false end + return true + end + function e:release() + for _,entry in pairs(self.entries)do entry.raw=nil;entry.arrays=nil end + self.game,self.data,self.ids=nil,nil,nil;self.cache={};self.valid=function()return false end + end + local function vectors(d) + if d.arrays then return d.arrays end + local a={moves={},pp={},maxPp={},baseline={},native={}} + for i,row in ipairs(d.raw.moves or{})do + local def=e.data.moves[row.id] + a.moves[i]=e.ids:read('moves',row.id);a.pp[i]=row.pp;a.baseline[i]=row.pp + a.maxPp[i]=(def and def.pp or 0)+math.floor((def and def.pp or 0)/5)*(row.ppUps or 0) + a.native[i]=row + end + d.arrays=a;return a + end + function e:flush() + if not self:live()then return end + local writes,clear={},{} + for _,d in pairs(self.entries)do + local a=d.arrays + if a and d.raw then + for i,row in ipairs(a.native)do + assert(d.raw.moves[i]==row and e.ids:read('moves',row.id)==a.moves[i],'native moves changed during source edit') + local def=e.data.moves[row.id];local maximum=def.pp+math.floor(def.pp/5)*(row.ppUps or 0) + assert(a.maxPp[i]==maximum,'native maximum PP is read-only') + assert(uint(a.pp[i],maximum),'invalid native PP') + assert(a.pp[i]==a.baseline[i]or row.pp==a.baseline[i],'native PP changed during source edit') + if a.pp[i]~=a.baseline[i]then writes[#writes+1]={row,a.pp[i]}end + end + for i in pairs(a.pp)do assert(uint(i,#a.native)and i>0,'invalid native PP slot')end + for i,v in pairs(a.moves)do assert(a.native[i]and e.ids:read('moves',a.native[i].id)==v,'native move list is read-only')end + for i,v in pairs(a.maxPp)do + local row=a.native[i];local def=row and e.data.moves[row.id] + assert(def and v==def.pp+math.floor(def.pp/5)*(row.ppUps or 0),'native maximum PP is read-only') + end + clear[#clear+1]=d + end + end + for _,write in ipairs(writes)do write[1].pp=write[2]end + for _,d in ipairs(clear)do d.arrays=nil end + end + function e:view(mon) + if not self:live()or type(mon)~='table'then return nil end + local old=self.cache[mon];if old then return old end + local view={};local d={raw=mon,env=self};self.cache[mon]=view;self.entries[view]=d;records[view]=d + setmetatable(view,{__index=function(_,key) + if not e:live()or not d.raw then return nil end + local m=d.raw + if key=='species'then return e.ids:read('pokemon',m.species)end + if key=='item'then return e.ids:read('items',m.item)end + if key=='status'then return M.status(m)end + if key=='friendship'or key=='happiness'then return m.happiness end + if key=='isEgg'or key=='egg'then return require('src.pokemon.Party').isEgg(m)end + if key=='moves'or key=='pp'or key=='maxPp'then return vectors(d)[key]end + if stats[key]then return m.stats and m.stats[stats[key]]end + if key=='otId'then return m.otId or m.otID end + if key=='otSecretId'then return m.otSecretId or m.secretId end + if key=='ivs'then + local out={};for a,b in pairs{hp='hp',atk='attack',def='defense',spe='speed',spa='spatk',spd='spdef'}do out[a]=m.ivs and m.ivs[b]end;return out + end + if key=='hp'or key=='level'or key=='exp'or key=='nickname'or key=='personality'or key=='sleep'then + return m[key=='sleep'and'sleepTurns'or key] + end + end,__newindex=function(_,key,value) + assert(e:live()and d.raw,'native party view retired') + local m=d.raw + if key=='hp'then assert(uint(value,m.stats.hp),'invalid native HP');m.hp=value + elseif key=='friendship'or key=='happiness'then assert(uint(value,255),'invalid native friendship');m.happiness=value + elseif key=='nickname'then assert(type(value)=='string'and#value<=32,'invalid native nickname');m.nickname=value + elseif key=='status'then M.setStatus(m,value) + elseif key=='sleep'then assert(uint(value or 0,7),'invalid native sleep');m.sleepTurns=value + elseif key=='item'then + local item=e.ids:native('items',value);assert(value==0 or item,'unknown native item');m.item=item + else error('native party '..tostring(key)..' belongs to its command owner',2)end + end}) + Iteration.register(view,function(spec,source) + assert(spec.environment and spec.environment:live(),'native party view retired') + local out={} + for _,key in ipairs{'species','item','hp','level','exp','nickname','personality','friendship','status','ivs','otId','otSecretId','isEgg','maxHp','attack','defense','speed','spAtk','spDef','moves','pp','maxPp'}do + local value=source[key] + if type(value)=='table'then local a={};for k,v in pairs(value)do a[k]=v end;value=a end + out[key]=value + end + return out + end,setmetatable({environment=e},{__mode='v'})) + return view + end + return e +end +function M.resolve(view) + local d=records[view] + if not d then return nil,false end + if not d.env:live()then return nil,true end + d.env:flush();return d.raw,true,d.env.game +end +function M.flush() + for e in pairs(environments)do e:flush()end +end +return M diff --git a/src/compat/gen1/ScriptBoundary.lua b/src/compat/gen1/ScriptBoundary.lua new file mode 100644 index 000000000..66093de5f --- /dev/null +++ b/src/compat/gen1/ScriptBoundary.lua @@ -0,0 +1,38 @@ +-- Native owners open a scope; source base calls can consume its body once. +-- Queries made between native phases cannot advance another script clock. +local M={} +local scopes=require('src.compat.gen1.CoroutineScopes').new() +local BODY={} +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +function M.invoke(name) + local s=scopes:find(name) + if not s then return nil,'script operation belongs to its native phase'end + if s.failed then error(s.failure,0)end + if not s.called then + s.called=true + local claim=scopes:push(BODY,nil,s) + local r=pack(pcall(s.body)) + local retired=s.closed or claim.closed + scopes:close(claim) + s.body=nil + if not r[1]then s.failed=true;s.failure=r[2];error(r[2],0)end + if retired then error('native script phase retired while suspended',0)end + s.result={n=r.n-1};for i=2,r.n do s.result[i-1]=r[i]end + end + if not s.result then return nil,'native script phase is already executing' end + return unpack(s.result,1,s.result.n) +end +function M.dispatch(name,body,source,...) + local s=scopes:push(name);s.body=body + local result=pack(pcall(source,...)) + local retired,failed,failure=s.closed,s.failed,s.failure + scopes:close(s) + -- A wrapper cannot swallow a failing native command and retry it. + if failed then error(failure,0)end + if retired then error('native script phase retired while suspended',0)end + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function M.active(name)return scopes:find(name)~=nil end +return M diff --git a/src/compat/gen1/SessionView.lua b/src/compat/gen1/SessionView.lua new file mode 100644 index 000000000..89ebc6b3e --- /dev/null +++ b/src/compat/gen1/SessionView.lua @@ -0,0 +1,163 @@ +-- Typed observations of one native GBA save, never another save/simulation. +-- Collection schemas (party, bag, script stores) have separate adapters; do +-- not lend differently shaped native tables under those source names. +local Native=require('src.compat.gen1.NativeSession') +local Identity=require('src.compat.gen1.MapIdentity') +local Version=require('src.core.GameVersion') +local ScriptStore=require('src.compat.gen1.ScriptStore') +local ScriptMap=require('src.compat.gen1.ScriptMap') +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local records=setmetatable({},{__mode='k'}) +local stores=setmetatable({},{__mode='k'}) +local function finite(n)return type(n)=='number'and n==n and math.abs(n)=0 and n or 0 end + return number(value.hours)*3600+number(value.minutes,value.mins)*60 + +number(value.seconds,value.secs)+number(value.frames,value.vblanks)/60 + end + local n=tonumber(value);return finite(n)and n>=0 and n or 0 +end +function M.new(game) + local owner,why=Native.game(game);if not owner then return nil,why end + local data,save,edition=owner.data,owner.save,Version.get() + if type(data)~='table'or type(data.maps)~='table'or type(save)~='table'then return nil,'native save data unavailable'end + if not Identity.lookup(edition,0,0)then return nil,'native source map catalogue unavailable'end + local binding=Identity.bind(data.maps,edition) + local record={owner=owner,data=data,save=save,edition=edition,maps=data.maps,clock={}} + local scripts=assert(ScriptStore.new(owner)) + local flags,vars=ScriptMap.new(scripts,'flags'),ScriptMap.new(scripts,'vars') + local view={} + local bag,dex,party={},{},{} + stores[bag]={record=record,kind='bag'};stores[dex]={record=record,kind='dex'} + function record:valid() + if self.retired then return false end + if not scripts:valid()or Version.get()~=edition or owner.data~=data or owner.save~=save or data.maps~=self.maps then + self.retired=true;scripts:release();if self.mons then self.mons:release()end;return false + end + return true + end + record.mons=require('src.compat.gen1.PokemonView').new(owner,function()return record:valid()end) + function record:seconds()return M.seconds(save)end + function record:world() + if not self:valid()then return end + local _,world,player=Native.resolve(owner);return world,player + end + function record:map() + local world=self:world();local id=world and world.map.id or(save.player and save.player.map) + return id and binding:legacyId(id) + end + local function current() + if not record:valid()then return nil end + return save.player or{},record:world() + end + setmetatable(view,{ + __index=function(_,key) + local player,world,actor=current();if not player then return nil end + if key=='version'then return edition end + if key=='generation'then return 3 end + if key=='flags'then return flags end + if key=='vars'then return vars end + if key=='engine'then return 'game3'end + if key=='bag'then return bag end + if key=='dex'then return dex end + if key=='party'then + local count=#(save.party or{}) + for i,m in ipairs(save.party or{})do party[i]=record.mons:view(m)end + for i=#party,count+1,-1 do party[i]=nil end + return party + end + if key=='secretId'then return player.secretId or player.sid or 0 end + if key=='safari'then + local out;if save.safari then out={};for k,v in pairs(save.safari)do if type(v)~='table'then out[k]=v end end end + return out + end + if key=='map'then return record:map()end + if key=='x'or key=='y'then return actor and actor[key=='x'and'cellX'or'cellY']or player[key]end + if key=='facing'then return actor and actor.facing or player.facing end + if key=='name'then return player.name end + if key=='rivalName'then return player.rival end + if key=='trainerId'then return player.id end + if key=='gender'then return player.gender end + if key=='money'or key=='coins'or key=='modData'or key=='meta'or key=='options'then return save[key]end + if key=='biking'then return save.onBike==true end + if key=='bikeType'then return actor and actor.acroBike and'acro'or save.bikeType end + if key=='surfing'then if actor then return actor.surfing==true end;return player.surfing==true end + if key=='underwater'then return world and world.map.def.mapType=='UNDERWATER'or false end + if key=='elevation'then return actor and actor.elevation or player.elevation end + local seconds=record:seconds();local whole=math.floor(seconds) + if key=='playTime'or key=='playtime'then + local clock=record.clock + clock.hours,clock.minutes,clock.seconds=math.floor(whole/3600),math.floor(whole/60)%60,whole%60 + clock.vblanks=math.floor((seconds-whole)*60+.000001) + return clock + end + if key=='hours'or key=='playTimeHours'then return math.floor(whole/3600)end + if key=='minutes'or key=='playTimeMinutes'then return math.floor(whole/60)%60 end + if key=='seconds'or key=='playTimeSeconds'then return whole%60 end + end, + __newindex=function(t,key,value) + assert(record:valid(),'native session view retired') + local player=save.player + if key=='money'or key=='coins'then + assert(finite(value)and value>=0 and value%1==0 and value<4294967296,'invalid session '..key) + save[key]=value + elseif key=='flags'or key=='vars'then + ScriptMap.replace(scripts,key,value) + elseif key=='name'or key=='rivalName'then + assert(player and type(value)=='string'and#value<=10 and not value:find('%c'),'invalid player name') + player[key=='name'and'name'or'rival']=value + elseif key=='gender'then + assert(player and(value==0 or value==1),'invalid player gender');player.gender=value + elseif key=='trainerId'then + assert(player and finite(value)and value>=0 and value<=65535 and value%1==0,'invalid native trainer id');player.id=value + elseif key=='biking'then + assert(type(value)=='boolean','invalid bike state');save.onBike=value + elseif key=='party'or key=='bag'or key=='dex'or key=='secretId'or key=='safari'or key=='map'or key=='x'or key=='y'or key=='facing'or key=='elevation'or key=='surfing' + or key=='version'or key=='generation'or key=='engine'or key=='options'or key=='modData'or key=='meta' + or key=='playTime'or key=='playtime'or key=='hours'or key=='minutes'or key=='seconds' + or key=='playTimeHours'or key=='playTimeMinutes'or key=='playTimeSeconds'then + error('session '..key..' belongs to its native command owner',2) + else + -- Temporary presentation/preview fields stay detached. Persistence + -- belongs to mod.save; this never adds fields to a player's save. + rawset(t,key,value) + end + end, + }) + record.scripts=scripts + local fields={'version','generation','engine','map','x','y','facing','name','rivalName','trainerId','gender', + 'party','bag','dex','secretId','safari','money','coins','modData','meta','options','biking','bikeType','surfing','underwater','elevation', + 'playTime','flags','vars'} + Iteration.register(view,function(state,source) + assert(state:valid(),'native session view retired') + local out={} + -- Include detached presentation fields without recursing through the + -- registered iterator. No registry callback captures its own weak key. + for k,v in Iteration.rawPairs(source)do out[k]=v end + for _,key in ipairs(fields)do + local v=source[key] + if key=='flags'or key=='vars'then v=Iteration.snapshot(v) + elseif key=='playTime'and v then + v={hours=v.hours,minutes=v.minutes,seconds=v.seconds,vblanks=v.vblanks} + end + out[key]=v + end + return out + end,record) + records[view]=record + return view,record +end +function M.record(view)return records[view]end +function M.release(view) + local record=records[view] + if record then record.retired=true;record.scripts:release();if record.mons then record.mons:release()end end +end +function M.store(value,kind) + local entry=stores[value] + if entry and entry.kind==kind and entry.record:valid()then return entry.record.owner,entry.record.save end +end + +return M diff --git a/src/compat/gen1/SourceActors.lua b/src/compat/gen1/SourceActors.lua new file mode 100644 index 000000000..af8f3e711 --- /dev/null +++ b/src/compat/gen1/SourceActors.lua @@ -0,0 +1,92 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Draw-only actors join native sorting, never NPC updates or collision pools. +local Native=require('src.compat.gen1.NativeSession') +local Scope=require('src.render.GraphicsScope') +local M={} +-- An installed presentation is owned even when the mod drops its view. +-- Retire it explicitly with the native actor/cohort or its installing owner. +local bindings=setmetatable({},{__mode='k'}) +function M.bind(actor,record)bindings[actor]=record end +function M.poll() + for actor,r in pairs(bindings)do + if r.retired or not r.presentationSet or r.view.localId==nil then bindings[actor]=nil end + end +end +function M.renderer(actor) + local r=bindings[actor] + if not r then return nil,false end + if r.retired or not r.presentationSet or r.view.localId==nil then bindings[actor]=nil;return nil,false end + return r.extras.sprite,true +end +function M.releaseOwner(_,who) + for actor,r in pairs(bindings)do if r.presentationOwner==who then + r.presentationSet=nil;r.extras.sprite=nil;bindings[actor]=nil + end end +end +function M.release() + for actor,r in pairs(bindings)do r.presentationSet=nil;r.extras.sprite=nil;bindings[actor]=nil end +end +require('src.core.game3.asset_stream').register(M) +local function finite(n)return type(n)=='number'and n==n and math.abs(n)=1 and level<=100,'invalid Pokemon level') + mon={species=key,level=level,personality=value.personality or 0,ivs={},evs={},moves={},hp=value.hp,exp=value.exp, + happiness=value.friendship or value.happiness or def.friendship or 70,nickname=value.nickname,isEgg=value.isEgg, + item=ids:native('items',value.item),otId=value.otId,otSecretId=value.otSecretId} + assert(type(mon.personality)=='number'and mon.personality%1==0 and mon.personality>=0 and mon.personality<4294967296,'invalid personality') + mon.nature=(data.constants.natureOrder or{})[mon.personality%25+1] + mon.abilitySlot=mon.personality%2+1 + for a,b in pairs(names)do + local iv=(value.ivs or{})[a]or(value.ivs or{})[b]or 0 + local ev=(value.evs or{})[a]or(value.evs or{})[b]or 0 + assert(type(iv)=='number'and iv%1==0 and iv>=0 and iv<=31,'invalid IV') + assert(type(ev)=='number'and ev%1==0 and ev>=0 and ev<=255,'invalid EV') + mon.ivs[b],mon.evs[b]=iv,ev + end + mon.stats=Stats.calcGen3(def,level,mon.ivs,mon.evs,mon.nature) + -- Explicit combat stats may include temporary overworld modifiers. + for a,b in pairs(fields)do + local n=value[a] + if type(n)=='number'and n>0 then assert(n==n and n16 then return end + local label=kind.label or 'File' + if type(label)~='string'or #label>128 or label:find('[%z\r\n|]')then return end + local exts,seen={},{} + for _,ext in ipairs(kind.exts)do + if type(ext)~='string'or #ext>24 or not ext:match('^[%w][%w_.-]*$')then return end + ext=ext:lower();if not seen[ext]then seen[ext]=true;exts[#exts+1]=ext end + end + return{label=label,exts=exts} +end +local function backend() + if not Host.canSpawnProcess()then return end + local os=love.system.getOS() + if os=='OS X'or os=='Windows'then return os end + if os=='Linux'then + if Host.hasCommand('zenity')then return 'zenity'end + if Host.hasCommand('kdialog')then return 'kdialog'end + end +end +function M.available()return backend()~=nil end +function M.matches(name,kind) + local k=kindOf(kind);if not k or type(name)~='string'then return false end + name=name:lower() + for _,ext in ipairs(k.exts)do if name:sub(-#ext-1)=='.'..ext then return true end end + return false +end +local function shell(value)return "'"..value:gsub("'","'\\''").."'"end +local function apple(value)return '"'..value:gsub('\\','\\\\'):gsub('"','\\"'):gsub('\n','\\n'):gsub('\r','\\r')..'"'end +-- Untrusted UTF-8 travels as decimal bytes, never as PowerShell/cmd syntax. +local function windows(value) + local bytes={};for i=1,#value do bytes[i]=tostring(value:byte(i))end + return '([Text.Encoding]::UTF8.GetString([byte[]]@('..table.concat(bytes,',')..')))' +end +function M.commands(prompt,kind) + local k=kindOf(kind) + if not k or not promptOk(prompt)then return end + local os=backend();if not os then return end + local globs,types={},{} + for i,ext in ipairs(k.exts)do globs[i]='*.'..ext;types[i]=apple(ext)end + if os=='OS X'then + local script='POSIX path of (choose file with prompt '..apple(prompt)..' of type {'..table.concat(types,', ')..'})' + return{'osascript -e '..shell(script)..' 2>/dev/null'} + elseif os=='Windows'then + local patterns=table.concat(globs,';') + local filter=k.label..' ('..patterns..')|'..patterns..'|All files (*.*)|*.*' + local script=table.concat({'Add-Type -AssemblyName System.Windows.Forms;', + '$d=New-Object System.Windows.Forms.OpenFileDialog;','$d.Title='..windows(prompt)..';', + '$d.Filter='..windows(filter)..';',"if($d.ShowDialog() -eq 'OK'){$p=$d.FileName;$ascii=$true;", + 'foreach($c in $p.ToCharArray()){if([int]$c -lt 32 -or [int]$c -gt 126){$ascii=$false}};', + '[Console]::OutputEncoding=[Text.Encoding]::UTF8;if($ascii){[Console]::Write($p)}else{', + "$t=Join-Path $env:TEMP ('pokeport_base_'+[Guid]::NewGuid().ToString('N')+'.bin');", + 'try{Copy-Item -LiteralPath $p -Destination $t -ErrorAction Stop;[Console]::Write($t)}catch{}}}'}) + local command='powershell -NoProfile -STA -Command "'..script..'"' + if #command>7000 then return end + return{command} + elseif os=='zenity'then + return{'zenity --file-selection --title='..shell(prompt)..' --file-filter='..shell(k.label..' | '..table.concat(globs,' '))..' 2>/dev/null'} + else + return{'kdialog --getopenfilename "$HOME" '..shell(table.concat(globs,' ')..'|'..k.label)..' --title '..shell(prompt)..' 2>/dev/null'} + end +end +function M.open(prompt,kind) + local k=kindOf(kind);if not k or not promptOk(prompt)then return end + return require('src.import.RomImporter').chooseFileByExt(k.exts,nil,{title=prompt,label=k.label}) +end +function M.basename(path)return tostring(path or''):match('([^/\\]+)$')or tostring(path or'')end +function M.read(path) + if type(path)~='string'or path:find('%z')then return nil,'invalid native file path'end + local opened,file,why=pcall(io.open,path,'rb') + if not opened or not file then return nil,opened and why or file end + local ok,bytes,err=pcall(file.read,file,'*a');pcall(file.close,file) + if not ok then return nil,bytes end + if not bytes or bytes==''then return nil,err or 'empty file'end + return bytes +end +return M diff --git a/src/core/Game.lua b/src/core/Game.lua index 0ebe75e81..bcf1450d2 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1004,78 +1004,17 @@ function Game:toggleFrameProfile() return FrameProfile.toggle() end -function Game:_draw() - -- the UI canvas clears transparent when the overworld's world pass - -- shows through beneath it; opaque full-screen states get the classic - -- white clear - local base = self.stack:visibleBase() - local worldBelow = self.stack.states[base] == self.overworld - -- 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 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 - -- in that surface below, so their classic coordinates and hit testing stay - -- unchanged. Outside a battle, including the title screen, the option is - -- intentionally inactive because it is a battle-layout setting. - local wideBattle = Game.wideBattleInStack(self.stack) - -- ...and a Gen 3 battle that widened its own screen holds it the same way, - -- ahead of the topmost-uiSize pick (see Game.heldSurfaceInStack). - local held = not wideBattle and Game.heldSurfaceInStack(self.stack) or nil - local native = not wideBattle - and (held or Game.nativeSurfaceInStack(self.stack)) or nil - local classicOffset, classicOffsetY = 0, 0 - if wideBattle and wideBattle.uiSize then - Renderer:setUISize(wideBattle:uiSize()) - classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2) - elseif native then - Renderer:setUISize(native:uiSize()) - -- ...and a state that did NOT ask for the bigger surface is centred in - -- it, the way the wide battle centres its menus: its layout is in Game - -- Boy coordinates and must land in the middle rather than in a corner. - local uw, uh = Renderer:uiSize() - classicOffset = math.floor((uw - Renderer.WIDTH) / 2) - classicOffsetY = math.floor((uh - Renderer.HEIGHT) / 2) - else - Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT) - end - -- the surface every state below is either drawing in or being centred - -- inside, resolved before any of them draws - local surfW, surfH = Renderer:uiSize() - -- BATTLE SIZE: scale the battle surface to the window instead of the - -- classic integer letterbox. Read from the whole stack, not just the top, - -- so a party menu or text box opened mid-battle keeps the same surface. - Renderer.uiFill = Game.fillScaleInStack(self.stack) - -- BATTLE BG "world": dim the overworld the battle is drawn over. Read off - -- the stack for the same reason as uiFill above -- a prompt opened during - -- the battle must not drop the dim for a frame. - Renderer.battleDim = Game.worldBgBattleDim(self.stack) - -- ...and for the same reason the UI's own scale has to know the world is - -- still the backdrop while an opaque menu covers it. Renderer:uiScale - -- steps the UI down with the survey zoom only while a world is behind it, - -- gated on this frame's world pass -- which the party menu and the bag end - -- by being opaque. Without this hold they lose the step-down and blit at - -- full fit scale over a battle drawn at the zoomed-out one. - Renderer.uiWorldHold = Renderer.battleDim ~= nil - -- ...and a battle keeps its dialogue box and YES/NO inside its own screen - -- instead of letting them dock to the window edge. - -- UI LAYOUT: CENTERED (the default) is a fixed letterbox -- every element - -- stays inside the 160x144 canvas and the UI does not follow the survey - -- zoom, so the screen furniture never moves or resizes under the player. - -- That is the composition the port shipped with. DYNAMIC opts into both - -- halves: the dialogue box docks to the window's bottom edge, the START - -- menu to its top right, and the whole UI steps down with the zoom. - Renderer.uiCentered = not Game.dynamicUI(self.save) - Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack) +local function paintResolvedFrame(self, drawFrom, worldBelow, worldDrawn, native, classicOffset, classicOffsetY, surfW, surfH) Renderer:beginFrame(worldBelow) + local presentation=package.loaded['src.render.NativePresentation'] + if presentation then presentation._nativeBegin(self,Renderer:uiSize())end -- SINGLE-SCREEN DS PLAY: an opaque DS screen at the bottom of what is -- drawn (a menu, the bag, the box -- no world pass under it) is drawn with -- everything over it at the window's scale, letters smoothed, rather than -- blown up afterwards (src/render/SuperSample.lua). The battle does the -- same for itself when it is drawn over the world. - local function drawStates() - for i = drawFrom, #self.stack.states do + local function drawStates(first, last) + for i = first or drawFrom, last or #self.stack.states do local state = self.stack.states[i] local wideState = state and state.isWideBattleLayout and state:isWideBattleLayout() @@ -1115,13 +1054,19 @@ function Game:_draw() end end end + local paintStates=drawStates + local uiPass=require('src.core.GameVersion').isGen3() + and package.loaded['src.ui.game3.ui_pass'] + if uiPass and (uiPass._wrapped()or presentation and presentation._active())then + paintStates=function()return uiPass._nativeStates(self,self.stack,drawFrom,drawStates)end + end local baseState = self.stack.states[drawFrom] local SuperSample = require("src.render.SuperSample") if 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) + SuperSample.draw(self, self.stack:top(), paintStates) else - drawStates() + paintStates() end -- SGB colorization: the topmost state that knows its palette owns the -- screen (overlays like text boxes inherit from what's beneath them); @@ -1194,6 +1139,78 @@ function Game:_draw() if not Renderer.splitViewport then TouchControls:draw() end end +function Game:_draw() + -- the UI canvas clears transparent when the overworld's world pass + -- shows through beneath it; opaque full-screen states get the classic + -- white clear + local base = self.stack:visibleBase() + local worldBelow = self.stack.states[base] == self.overworld + -- 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 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 + -- in that surface below, so their classic coordinates and hit testing stay + -- unchanged. Outside a battle, including the title screen, the option is + -- intentionally inactive because it is a battle-layout setting. + local wideBattle = Game.wideBattleInStack(self.stack) + -- ...and a Gen 3 battle that widened its own screen holds it the same way, + -- ahead of the topmost-uiSize pick (see Game.heldSurfaceInStack). + local held = not wideBattle and Game.heldSurfaceInStack(self.stack) or nil + local native = not wideBattle + and (held or Game.nativeSurfaceInStack(self.stack)) or nil + local classicOffset, classicOffsetY = 0, 0 + if wideBattle and wideBattle.uiSize then + Renderer:setUISize(wideBattle:uiSize()) + classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2) + elseif native then + Renderer:setUISize(native:uiSize()) + -- ...and a state that did NOT ask for the bigger surface is centred in + -- it, the way the wide battle centres its menus: its layout is in Game + -- Boy coordinates and must land in the middle rather than in a corner. + local uw, uh = Renderer:uiSize() + classicOffset = math.floor((uw - Renderer.WIDTH) / 2) + classicOffsetY = math.floor((uh - Renderer.HEIGHT) / 2) + else + Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT) + end + -- the surface every state below is either drawing in or being centred + -- inside, resolved before any of them draws + local surfW, surfH = Renderer:uiSize() + -- BATTLE SIZE: scale the battle surface to the window instead of the + -- classic integer letterbox. Read from the whole stack, not just the top, + -- so a party menu or text box opened mid-battle keeps the same surface. + Renderer.uiFill = Game.fillScaleInStack(self.stack) + -- BATTLE BG "world": dim the overworld the battle is drawn over. Read off + -- the stack for the same reason as uiFill above -- a prompt opened during + -- the battle must not drop the dim for a frame. + Renderer.battleDim = Game.worldBgBattleDim(self.stack) + -- ...and for the same reason the UI's own scale has to know the world is + -- still the backdrop while an opaque menu covers it. Renderer:uiScale + -- steps the UI down with the survey zoom only while a world is behind it, + -- gated on this frame's world pass -- which the party menu and the bag end + -- by being opaque. Without this hold they lose the step-down and blit at + -- full fit scale over a battle drawn at the zoomed-out one. + Renderer.uiWorldHold = Renderer.battleDim ~= nil + -- ...and a battle keeps its dialogue box and YES/NO inside its own screen + -- instead of letting them dock to the window edge. + -- UI LAYOUT: CENTERED (the default) is a fixed letterbox -- every element + -- stays inside the 160x144 canvas and the UI does not follow the survey + -- zoom, so the screen furniture never moves or resizes under the player. + -- That is the composition the port shipped with. DYNAMIC opts into both + -- halves: the dialogue box docks to the window's bottom edge, the START + -- menu to its top right, and the whole UI steps down with the zoom. + Renderer.uiCentered = not Game.dynamicUI(self.save) + Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack) + local display = require('src.core.GameVersion').generation() == 3 + and package.loaded['src.core.game3.display'] + if display then + return display._nativePresent(self, paintResolvedFrame, drawFrom, worldBelow, worldDrawn, native, classicOffset, classicOffsetY, surfW, surfH) + end + return paintResolvedFrame(self, drawFrom, worldBelow, worldDrawn, native, classicOffset, classicOffsetY, surfW, surfH) +end + -- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out function Game:zoomStep(delta) local view=self:zoomView() diff --git a/src/core/Game3.lua b/src/core/Game3.lua new file mode 100644 index 000000000..92c3ae9e5 --- /dev/null +++ b/src/core/Game3.lua @@ -0,0 +1,300 @@ +-- A distinct facade for source wrappers. The existing Game still owns every +-- simulation, frame and IO operation; requiring this cannot bootstrap it. +local Native=require('src.compat.gen1.NativeSession') +local Views=require('src.compat.gen1.SessionView') +local Version=require('src.core.GameVersion') +local CoroutineScopes=require('src.compat.gen1.CoroutineScopes') +local G={} +local entries=setmetatable({},{__mode='k'}) +local owners=setmetatable({},{__mode='k'}) +local scopes=CoroutineScopes.new() +local BODY={} +local bases={} +local bootLeases=setmetatable({},{__mode='k'}) +local mouseBodies=setmetatable({},{__mode='k'}) +local callbackOwners={} +local actorCallbackOwners={} +local callbackScopes=CoroutineScopes.new() +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +function G._callbackOwner() + local frame=callbackScopes:find('owner') + return frame and frame.value +end +function G._withCallbackOwner(owner,fn,...) + local frame=callbackScopes:push('owner',owner) + local result=pack(pcall(fn,...)) + local views=package.loaded['src.compat.gen1.PokemonView'] + if result[1]and not frame.closed and not CoroutineScopes.retired()and views then + local ok,why=pcall(views.flush) + if not ok then result={n=2,false,why}end + end + local retired=frame.closed or CoroutineScopes.retired() + callbackScopes:close(frame) + if not result[1]then error(result[2],0)end + if retired then error('native callback owner retired while suspended',0)end + return unpack(result,2,result.n) +end +local function methodOwner(name) + local seams=package.loaded['src.mods.ModSeams'];local journal=seams and seams._journal or{} + for i=#journal,1,-1 do + local change=journal[i] + if change.tbl==G and change.key==name and change.after==G[name]then return change.owner end + end +end +local function entry(view) + local e=entries[view] + assert(e and not e.retired,'native Game3 facade retired or unbound') + return e +end +function G.native(value) + if value==nil then return package.loaded['src.core.Game']end + local e=entries[value] + if e then return not e.retired and e.owner or nil end + if type(value)=='table'and type(rawget(value,'game'))=='table'then return G.native(rawget(value,'game'))end + return value +end +local function session(e) + if e.session then + local record=Views.record(e.session) + if record and record:valid()then return e.session end + Views.release(e.session);e.session=nil + end + e.session=Views.new(e.owner) + return e.session +end +local function forward(view,name,...) + assert(not CoroutineScopes.retired(),'native Game3 coroutine retired') + local e=entry(view);local c=scopes:find(name) + if c and c.view==view then + if c.called then + if c.failed then error(c.failure,0)end + if c.result then return unpack(c.result,1,c.result.n)end + return + end + c.called=true + local claim=scopes:push(BODY,nil,c) + local result=pack(pcall(c.native,e.owner,...)) + local retired=c.closed or claim.closed + scopes:close(claim) + if not result[1]then c.failed=true;c.failure=result[2];error(result[2],0)end + if retired then error('native Game3 phase retired while suspended',0)end + c.result={n=result.n-1};for i=2,result.n do c.result[i-1]=result[i]end + return unpack(c.result,1,c.result.n) + end + local method=e.owner[name] + if type(method)~='function'then return nil,'native method unavailable: '..name end + return method(e.owner,...) +end +for _,name in ipairs({'update','draw','keypressed','keyreleased','textinput','wheelmoved', + 'touchpressed','touchmoved','touchreleased','gamepadpressed','gamepadreleased','gamepadaxis', + 'joystickpressed','joystickreleased','joystickaxis','joystickhat','joystickremoved','focus','visible', + 'mousepressed','mousemoved','mousereleased','writeOptions','applyOptions','writeSave','returnToTitle', + 'logicSpeed','speedLocked','_resolveLogicSpeed','zoomStep', + 'returnToLauncher','restartWithMods'})do + local key=name;G[key]=function(view,...)return forward(view,key,...)end;bases[key]=G[key] +end +function G.fixedUpdate(view,...)return forward(view,'_step',...)end +bases.fixedUpdate=G.fixedUpdate +function G.reset(view) + local e=entry(view) + local runtime=package.loaded['src.core.game3.runtime'];if runtime and runtime.release then runtime.release()end + if e.session then Views.release(e.session);e.session=nil end + e.overrides={};e.overridden={};e.bootEpoch={} +end +bases.reset=G.reset +function G._handleBootAction(view,action) + local e=entry(view);local lease=type(action)=='table'and bootLeases[action] + if not lease or lease.view~=view or lease.epoch~=e.bootEpoch or action.action~=lease.kind then + return nil,'boot action belongs to a retired or absent native title callback' + end + if lease.failed then error(lease.failed,0)end + if lease.done then return unpack(lease.result,1,lease.result.n)end + if lease.owner.data~=lease.data or lease.owner.save~=lease.save or lease.owner.mods~=lease.loader then + return nil,'native title callback retired' + end + if not lease.done then + lease.done=true + local ok,why=xpcall(function()lease.result=pack(lease.callback())end,debug.traceback) + if not ok then lease.failed=why;error(why,0)end + end + return unpack(lease.result,1,lease.result.n) +end +bases._handleBootAction=G._handleBootAction +function G._bootAction(owner,action,callback) + if Version.generation()~=3 or G._handleBootAction==bases._handleBootAction then return callback()end + local view=G.forNative(owner);if not view then return callback()end + local e=entries[view] + bootLeases[action]={view=view,owner=owner,data=owner.data,save=owner.save,loader=owner.mods, + epoch=e.bootEpoch,kind=action.action,callback=callback} + return G._dispatch('_handleBootAction',owner,function()return bases._handleBootAction(view,action)end,action) +end +function G.stepGC()if collectgarbage then return collectgarbage('step',1)end end +function G._stepGC() + if Version.generation()~=3 then return false end + G.stepGC();return true +end +function G.forNative(value) + local native=G.native(value) + if value~=nil and native==nil then return nil,'native Game3 facade retired'end + local owner,why=Native.game(native);if not owner then return nil,why end + local existing=owners[owner] + if existing and not entries[existing].retired then + local e=entries[existing] + if e.loader==owner.mods or e.resetting then return existing end + G._notifyReset(owner);e.retired=true;if e.session then Views.release(e.session)end + end + local view={};local e={owner=owner,overrides={},overridden={},loader=owner.mods,bootEpoch={}};entries[view]=e;owners[owner]=view + setmetatable(view,{ + __index=function(_,key) + if G[key]~=nil then return G[key]end + if e.retired then return nil end + if e.overridden[key]then return e.overrides[key]end + if key=='mods'then return e.loader end + if key=='generation'then return 3 end + if key=='session'or key=='save'then return session(e)end + if key=='phase'then return Native.resolve(owner)and'field'or'boot'end + if key=='options'then return owner.save and owner.save.options end + if key=='data'then + if e.dataset~=owner.data then + e.dataset=owner.data + local dataset=e.dataset + e.dataView=setmetatable({}, {__index=function(_,field) + if owner.data~=dataset then return nil end + if field=='maps'then return require('src.core.game3.map').definitions(owner)end + return dataset and dataset[field] + end}) + end + return e.dataView + end + local value=owner[key] + if type(value)~='function'then return value end + end, + __newindex=function(t,key,value) + assert(not e.retired,'native Game3 facade retired') + if key=='data'or key=='generation'or key=='phase'then error('Game3 '..key..' belongs to its native owner',2)end + if key=='speedOverride'then + assert(value==nil or value==false or(type(value)=='number'and value>0 and value80 + or not name:match('^[%w][%w_.+-]*$') then return false end + local windows=love.system.getOS()=='Windows' + local pipe=HostShell.popen(windows and('where '..name..' 2>NUL')or("command -v '"..name.."' 2>/dev/null")) + if not pipe then return false end + local ok,value=pcall(pipe.read,pipe,'*a') + pcall(pipe.close,pipe) + return ok and type(value)=='string'and value:find('%S')~=nil +end + -- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's -- boot in-process, which calls love.filesystem.init a second time -- and inside -- an AppImage physfs is already initialized, so that second init throws diff --git a/src/core/Music.lua b/src/core/Music.lua index e5b95baf6..fca19f123 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -136,7 +136,14 @@ end -- the jingle ends (see update()). -- Called by Sound.playCry: unlike a fanfare, the song keeps playing and is -- merely attenuated for as long as the cry sounds. -function Music.duckForCry(data, src) +function Music.duckForCry(data, src, noDuck) + if noDuck then + if cryDuck and cryDuck.src==src then + cryDuck=nil + if not state.fade then applyVolume(state.source);applyVolume(state.loopSource)end + end + return + end local c = data and data.constants and data.constants.gen3Cry local duck = c and tonumber(c.duckBgm) local scale = c and tonumber(c.scale) diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 7093a32f5..0b3a54297 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -12,6 +12,8 @@ local Runtime = require("src.mods.Runtime") local Sound = {} local cache = {} +local effectUses = setmetatable({}, { __mode = "k" }) +local effectCapture -- port addition: 0-7 SFX volume from save.options.sfxVol (OptionsMenu), -- scaling the 0.8 base every source gets local BASE_VOLUME = 0.8 @@ -227,6 +229,7 @@ end local function playPath(data, key, def, pitch, tempo) key = tostring(key) + if effectCapture and not effectCapture.source then key = key .. "@borrow:" .. effectCapture.owner end if not love.audio or not def then return nil end -- INTERRUPTION: building a Source now would fail and cache `false` below, -- permanently disabling this effect (see sessionSuspended, case 2) @@ -245,6 +248,8 @@ local function playPath(data, key, def, pitch, tempo) src = s end if not restart(src) then return nil end + local use = { key = key }; effectUses[src] = use + if effectCapture and not effectCapture.source then effectCapture.source, effectCapture.use = src, use end return src end @@ -264,14 +269,76 @@ end -- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it -- Resolve a cartridge effect by its extracted native name, including effects -- that have no shared engine role in audio.sfx. -function Sound.playNamedEffect(data,name) - for id,song in pairs(data and data.audio and data.audio.songs or {}) do - if type(song)=="table" and song.sfxName==name and tonumber(song.index) then - local src=playPath(data,id,{song=id,m4a=tonumber(song.index)}) - if src then played("sfx",name) end - return src +-- Native numeric/symbolic sequence aliases must resolve unambiguously. +function Sound.sequenceKey(data, name) + if type(name) ~= "string" and type(name) ~= "number" then return nil end + local songs = data and data.audio and data.audio.songs or {} + if (type(name) == "string" or type(name) == "number") and (type(songs[name]) == "string" or type(songs[name]) == "table") then + return name, songs[name] + end + local key, found + for id, song in pairs(songs) do + if type(song) == "table" and (song.sfxName == name or song.musName == name + or type(name) == "number" and song.index == name) then + if found and found ~= song then return nil, "ambiguous native audio id" end + key, found = id, song end end + return key, found +end + +-- Borrow the first playback atomically. Events may start another effect +-- before playSe returns; a replaced playback cannot transfer its new owner +-- to the earlier caller. Borrowed cache variants preserve native voice gain. +function Sound.borrowEffect(owner, play, ...) + local previous = effectCapture + local capture = { owner = tostring(owner) } + effectCapture = capture + local ok, src = pcall(play, ...) + effectCapture = previous + if not ok then error(src, 0) end + local use = capture.use + if not use or capture.source ~= src then return src, nil end + local function valid() + return not sessionSuspended and effectUses[src] == use and cache[use.key] == src + end + return src, { valid = valid, level = function()return volumeFor(use.key)end, stop = function() + if not valid() then return false end + effectUses[src] = nil + safeAudio("Source:stop", src.stop, src) + return true + end } +end + +function Sound.playNamedEffect(data, name) + local id, song = Sound.sequenceKey(data, name) + if not id then return nil end + local def = song + if type(song) == "table" and tonumber(song.index) and not song.file then + def = { song = id, m4a = tonumber(song.index) } + end + local src = playPath(data, id, def) + if src then played("sfx", name) end + return src +end + +-- Weather owns a separate native voice: an ambient stop must not cut off a +-- mod or scripted one-shot of the same cartridge sequence. +function Sound.playWeatherEffect(data, name, looping) + return Runtime.call("field.weather.sound", function(value) + local id, song = Sound.sequenceKey(data, value) + if not id then return nil end + local def = song + if type(song) == "table" and tonumber(song.index) and not song.file then + def = { song = id, m4a = tonumber(song.index) } + end + local src = playPath(data, "weather:" .. tostring(id), def) + if src then + safeAudio("Source:setLooping", src.setLooping, src, looping == true) + played("sfx", value) + end + return src + end, name) end function Sound.play(data, name) @@ -630,7 +697,12 @@ function Sound.playCry(data, species, opts) end -- set every time rather than at build: the cached source outlives a change -- of game, and the scale is the loaded cartridge's - pcall(src.setVolume, src, volumeFor(key) * cryScale(data)) + local scale=cryScale(data) + if opts and type(opts.volume)=='number'and opts.volume==opts.volume and opts.volume>=0 and opts.volume<=256 then + local unit=data.constants and data.constants.gen3Cry and tonumber(data.constants.gen3Cry.scale)or 256 + if unit and unit>0 then scale=opts.volume/unit end + end + pcall(src.setVolume, src, volumeFor(key) * scale) -- Interactive Dex playback owns its source; loop/effect settings must never -- leak onto the cached cry used by battles, NPC scripts or other menus. if opts and opts.isolated and src.clone then @@ -638,7 +710,7 @@ function Sound.playCry(data, species, opts) if ok and copy then src=copy end end if not restart(src) then return nil end - require("src.core.Music").duckForCry(data, src) + require("src.core.Music").duckForCry(data, src,opts and opts.noDuck==true) played("cry", species, species) return src end diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index baf622c4c..16715e27d 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -87,6 +87,7 @@ end -- settled state; the wants guard keeps the no-listener path allocation-free function StateStack:push(state, ...) + local flows=package.loaded["src.compat.gen1.UiFlow"];if flows then flows.inherit(self,state)end table.insert(self.states, state) if state.enter then state:enter(...) end local depth = #self.states @@ -105,15 +106,14 @@ function StateStack:push(state, ...) end end -function StateStack:pop() - local state = table.remove(self.states) +local function finishRemoval(self, state) if state and state.exit then state:exit() end -- A POP THAT EMPTIES THE STACK is not debug-level news. The engine does it -- on purpose in exactly two places and pushes the replacement in the same -- breath; everywhere else it is one pop too many, and the thing it leaves -- behind -- a screen that never changes, with no error -- gives the reader -- nothing to search for. So it is said in words, once per culprit. - if state ~= nil and #self.states == 0 then + if state ~= nil and #self.states == 0 and not self.worldState then Logger.warn("state stack: popping %s emptied the stack -- nothing will " .. "update or draw until something is pushed back", StateStack.describe(state)) @@ -127,6 +127,35 @@ function StateStack:pop() return state end +function StateStack:pop() + local state = table.remove(self.states) + if state and self._modalTracker then self._modalTracker:removed(state) end + return finishRemoval(self, state) +end + +-- Remove actual native states, including a named modal beneath another one. +-- Detach the whole selection before callbacks: a new screen opened by exit +-- must survive and must never receive another removed screen's exit event. +function StateStack:removeAll(selected) + local wanted, removed = {}, {} + for _, state in ipairs(selected) do wanted[state] = true end + for i = #self.states, 1, -1 do + local state = self.states[i] + if wanted[state] then + table.remove(self.states, i) + if self._modalTracker then self._modalTracker:removed(state) end + removed[#removed + 1] = state + end + end + local firstError + for _, state in ipairs(removed) do + local ok, err = pcall(finishRemoval, self, state) + if not ok and not firstError then firstError = err end + end + if firstError then error(firstError, 0) end + return #removed +end + function StateStack:top() return self.states[#self.states] end @@ -174,7 +203,12 @@ end -- that wants both puts the motion in animate and calls it from update. function StateStack:update(dt) local top = self:top() - if top and top.update then top:update(dt) end + if top and top.update then + local flow=top.nativeUiFlow + if flow then + if flow:valid()then flow:admit(top.update,top,dt)else flow:retire()end + else top:update(dt)end + end for i = self:visibleBase(), #self.states - 1 do local state = self.states[i] if state.animate then state:animate(dt) end diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua new file mode 100644 index 000000000..466e32484 --- /dev/null +++ b/src/core/game3/audio.lua @@ -0,0 +1,103 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Sound=require('src.core.Sound') +local A={} +local Version=require('src.core.GameVersion') +local records,sources,metadata={},{},{} +local ENGINE={} +local function who() + local host=package.loaded['src.mods.Runtime'] + return host and host.currentMod or require('src.core.Game3')._callbackOwner()or ENGINE +end +local function finite(n)return type(n)=='number'and n==n and math.abs(n)(key=='volume'and 16 or 1)then + return false,'unsupported native sound option: '..tostring(key) + end + end + local value=require('src.core.game3.se_ids').resolve(id) + if not finite(value)or value<0 or value%1~=0 then return false end + local owner=who() + local source,lease=Sound.borrowEffect(owner,function() + if Sound.waitSe then return Sound.playSe(g.data,value)end + return Sound.playNamedEffect(g.data,id)or Sound.playNamedEffect(g.data,value)or Sound.playId(g.data,value) + end) + if not source then return false end + if not lease or not lease.valid()then return false,'native playback was superseded'end + local volume=opts.sourceVolume + if volume==nil then + volume=lease.level()*(opts.volume or 1) + end + pcall(source.setVolume,source,math.max(0,math.min(1,volume))) + records[source]={game=g,data=g.data,save=g.save,mods=g.mods,edition=Version.get(),owner=owner,id=value,lease=lease} + metadata[source]={id=value} + poll();return true +end +function A.stopSe(id) + poll();local value=id~=nil and require('src.core.game3.se_ids').resolve(id)or nil + if id~=nil and value==nil then return 0 end + local owner=who();local count=0 + for src,r in pairs(records)do + if r.owner==owner and(id==nil or r.id==value)then remove(src,r);count=count+1 end + end + poll();return count +end +function A.isSePlaying(id) + poll();local value=id~=nil and require('src.core.game3.se_ids').resolve(id)or nil + if id~=nil and value==nil then return false end + for src,r in pairs(records)do + if(id==nil or r.id==value)then local ok,playing=pcall(src.isPlaying,src);if ok and playing then return true end end + end + return false +end +-- Observe and retire native voices. The engine's audio clock stays with the +-- native music/sound services, including frames a renderer holds for loading. +function A.update()return poll()end +function A.playCry(species,mode,pan) + local g=Native.game();if not g then return false end + local key=Ids.new(g.data):native('pokemon',species);if not key then return false end + return Sound.playCry(g.data,key,{noDuck=type(mode)=='table'and mode.noDuck or mode==1, + volume=type(mode)=='table'and mode.volume or nil})~=nil +end +function A.isCryFinished()if Sound.isCryFinished then return Sound.isCryFinished()end;return not Sound.anyPlaying('cry')end +A._seSources,A._seMeta=sources,metadata +A._service={release=function() + for src,r in pairs(records)do remove(src,r)end;poll() +end,releaseOwner=function(_,owner) + for src,r in pairs(records)do if r.owner==owner then remove(src,r)end end;poll() +end,poll=poll} +require('src.core.game3.asset_stream').register(A._service) +return setmetatable(A,{__index=function(_,key) + local g=Native.game() + if key=='_sfxVolume'then return gain(g)end + if key=='_suspended'then return not g or g.audioSuspended==true end +end}) diff --git a/src/core/game3/bag.lua b/src/core/game3/bag.lua new file mode 100644 index 000000000..233e3479d --- /dev/null +++ b/src/core/game3/bag.lua @@ -0,0 +1,31 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Views=require('src.compat.gen1.SessionView') +local Ids=require('src.compat.gen1.BattleIds') +local Native=require('src.inventory.Bag') +local B={} +local function context(bag,id) + local game,save=Views.store(bag,'bag');if not game then return end + local key=Ids.new(game.data):native('items',id) + if key then return game,save,key end +end +function B.canAdd(bag,id,qty) + local g,s,key=context(bag,id) + return g~=nil and Native.canAdd(s,key,qty,g.data)or false +end +function B.add(bag,id,qty) + local g,s,key=context(bag,id);qty=qty or 1 + if not(g and Native.canAdd(s,key,qty,g.data))then return false,0 end + local ok=Native.add(s,key,qty,g.data);return ok,ok and qty or 0 +end +function B.has(bag,id,qty) + local g,s,key=context(bag,id) + return g~=nil and(s.inventory[key]or 0)>=(qty or 1)or false +end +function B.count(bag,id)local g,s,key=context(bag,id);return g and(s.inventory[key]or 0)or 0 end +function B.remove(bag,id,qty) + local g,s,key=context(bag,id) + qty=qty or 1 + if type(qty)~='number'or qty~=qty or qty%1~=0 or qty<1 or qty>999 or not g or not B.has(bag,id,qty)then return false end + Native.remove(s,key,qty or 1);return true +end +return B diff --git a/src/core/game3/battle.lua b/src/core/game3/battle.lua new file mode 100644 index 000000000..e764d357e --- /dev/null +++ b/src/core/game3/battle.lua @@ -0,0 +1,207 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source battle presentation and observations, driven by the native owner. +local Runtime=require('src.compat.gen1.BattleRuntime') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Scope=require('src.render.GraphicsScope') +local M={} +local runtime=Runtime.new() +local function admitted(c) + if Runtime.member(c)then return true end + runtime:retire(c,false);return false +end +local captures=setmetatable({},{__mode='k'}) +local function serial()local renderer=package.loaded['src.render.Renderer'];return renderer and renderer.frameSerial end +local function sameTarget(a,b,depth) + if a==b then return true end + if type(a)~='table'or type(b)~='table'or(depth or 0)>=4 then return false end + -- LOVE canvases are userdata; preserve identity for table-based test or + -- external wrappers too. Only the MRT binding description is structural. + if a.typeOf or b.typeOf or a.getDimensions or b.getDimensions or a.getWidth or b.getWidth then return false end + for k,v in pairs(a)do if not sameTarget(v,b[k],(depth or 0)+1)then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function target(value,depth) + if depth==nil then value=love.graphics.getCanvas();depth=0 end + if type(value)~='table'or value.typeOf or value.getDimensions or value.getWidth or depth>=4 then return value end + local out={};for k,v in pairs(value)do out[k]=target(v,depth+1)end;return out +end +M._renderTarget=target +M._sameRenderTarget=sameTarget +local function call(name,...) + local facade=require('src.core.Game3');local owner=facade._callbackOwner() + local seams=package.loaded['src.mods.ModSeams'] + for i=#(seams and seams._journal or{}),1,-1 do + local change=seams._journal[i] + if change.tbl==M and change.key==name and change.after==M[name]then owner=change.owner;break end + end + return facade._withCallbackOwner(owner,M[name],...) +end +function M.isActive()return runtime:active()~=nil end +function M.getState()local c=runtime:active();return c and c.views.view or runtime.last end +function M.getResult()local view=M.getState();return view and view.result end +function M.start(opts) + if Boundary.active('battle.start')then return Boundary.invoke('battle.start')end + return require('src.compat.gen1.BattleRequests').start(opts) +end +function M.songInfo(st) + st=st or M.getState();local kinds=st and st.kinds or{} + return{wild=st and st.wild or false,link=st and st.link or false, + trainerClass=st and st.trainerClass,trainerName=st and st.trainerName,frontier=kinds.frontier, + kind=kinds.kyogreGroudon and'kyogreGroudon'or kinds.regi and'regi'or nil} +end +function M.statWindowPhase() + local c=runtime:active();if not c then return false end + local class=c.screen.StatBox;if not class then return false end + for _,state in ipairs(c.stack.states)do + if getmetatable(state)==class then return true end + end + return false +end +function M.update()return Boundary.invoke('battle.update')end +local function draw(value,w,h) + local c=runtime:active(value);if not c then return false,'no admitted native battle'end + local bw,bh=c.screen:uiSize();w,h=w or bw,h or bh + if w~=bw or h~=bh then return false,'battle capture must use the native surface size'end + if Boundary.active('battle.draw')then return Boundary.invoke('battle.draw')end + -- A capture can draw the native screen but cannot re-enter source wrappers. + return M._captureNative(c.screen) +end +M.draw=draw +function M._captureNative(screen) + local c=runtime:active();if not c or c.screen~=screen then return false,'native battle admission retired'end + local ok,result=Scope.call(function() + return require('src.core.Game3')._withCallbackOwner(nil,screen._drawNative,screen) + end) + if not ok then error(result,0)end + if admitted(c)then captures[screen]={serial=serial(),target=target()}end + return true +end +function M.abort(result) + result=result or'run' + local c=runtime:active();if not c then return false,'no admitted native battle'end + if c.stack:top()~=c.screen then return false,'native battle is covered by another state'end + if c.aborting then return false,'native battle completion already requested'end + if result~='run'and result~='lose'and result~='win'and result~='caught'then + return false,'a native battle outcome is required' + end + -- Aborting cannot award a victory or catch that the native rules never made. + if result~='run'and c.screen.result~=result then return false,'outcome has not been decided by the native battle'end + if c.screen.kind=='link'then return false,'link termination belongs to the native peer protocol'end + c.views:flush();c.aborting=true;c.screen.result=result;c.screen:finish();return true +end +function M._nativeEnter(screen) + local c=runtime:bind(screen);if not c then return end + if c.notified then return end;c.notified=true + local st=c.views.view + return Boundary.dispatch('battle.start',function()return true end,function() + return call('start',{wild=st.wild,playerParty=st.playerParty,foe=st.enemy and st.enemy.mon, + trainerId=screen.trainer and screen.trainer.id}) + end) +end +function M.reset() + if runtime:active()then return false,'cannot reset an admitted native battle'end + runtime:release();return true +end +function M._nativeUpdate(screen,dt,body) + captures[screen]=nil + local c=runtime:bind(screen);if not c then return body(screen,dt)end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + if Boundary.active('battle.update')then return Boundary.invoke('battle.update')end + return Boundary.dispatch('battle.update',function() + if not admitted(c)then return false,'native battle admission retired'end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + local function native(owner,step) + if not admitted(c)then return false,'native battle admission retired'end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + c.views:flush() + local intro=package.loaded['src.core.game3.battle.intro_seq'] + if intro then + return require('src.core.Game3')._withCallbackOwner(nil,intro._nativeUpdate,owner,step,body) + end + return require('src.core.Game3')._withCallbackOwner(nil,body,owner,step) + end + local ui=package.loaded['src.core.game3.battle.ui'] + if ui then return ui._nativeUpdate(screen,dt,native)end + return native(screen,dt) + end,function() + local anim=package.loaded['src.core.game3.battle.anim'] + if anim then anim._nativeStep(screen,dt)end + if not admitted(c)or c.stack:top()~=screen then return false,'native battle admission retired'end + local result=call('update',dt,require('src.core.Game3').forNative(c.game)) + if admitted(c)and c.stack:top()==screen then c.views:flush()end + return result + end) +end +function M._nativeDraw(screen,body) + local c=runtime:bind(screen);if not c then return body(screen)end + if screen.introBalls and screen.introBallsFrom==nil then screen.introBallsFrom=screen.frame or 0 end + if Boundary.active('battle.draw')then return Boundary.invoke('battle.draw')end + local capture=captures[screen];local frame=serial() + if capture and sameTarget(capture.target,target())and(capture.serial~=nil and capture.serial==frame or capture.serial==nil)then + if capture.serial==nil then captures[screen]=nil end;return true + end + local ui=package.loaded['src.core.game3.battle.ui'] + local function guarded(owner) + if not admitted(c)then return false,'native battle admission retired'end + return require('src.core.Game3')._withCallbackOwner(nil,body,owner) + end + local function native() + if not admitted(c)then return false,'native battle admission retired'end + if ui then return ui._nativeDraw(screen,guarded)end + return guarded(screen) + end + if M.draw==draw then return native()end + local w,h=screen:uiSize() + local ok,result=Scope.call(function() + return Boundary.dispatch('battle.draw',native,function() + return call('draw',require('src.core.Game3').forNative(c.game),w,h) + end) + end) + if not ok then error(result,0)end + return result +end +function M._nativeField(screen,body) + local bg=package.loaded['src.core.game3.battle.bg'] + if not bg then return body(screen)end + local c=runtime:bind(screen);if not c then return body(screen)end + return bg._nativeDraw(screen,function(owner) + if not admitted(c)then return false,'native battle admission retired'end + return require('src.core.Game3')._withCallbackOwner(nil,body,owner) + end) +end +function M._nativeExit(screen) + captures[screen]=nil + local anim=package.loaded['src.core.game3.battle.anim'] + if anim then anim._nativeExit(screen)end + runtime:exit(screen) +end +-- Private typed consumers can resolve a source observation to the same native +-- admission; callers cannot submit another state or a stale completed view. +function M._flushNative(screen) + local c=runtime:active() + if not c or c.screen~=screen or c.stack:top()~=screen or not admitted(c)then return false end + c.views:flush();return true +end +function M._screen(view) + local c=runtime:active() + if c and(view==nil or view==c.views.view)then return c.screen end +end +local service={poll=function()runtime:poll()end,release=function()captures=setmetatable({},{__mode='k'});runtime:release()end, + releaseOwner=function(_,who)runtime:releaseOwner(who)end} +M._service=service +require('src.core.game3.asset_stream').register(service) +setmetatable(M,{__index=function(_,key) + local c=runtime:active() + if key=='_active'then return c~=nil end + if key=='_st'then return c and c.views.view or runtime.last end + if not c then return nil end + if key=='_actions'or key=='_actionI'then + local plan=package.loaded['src.battle.Gen3ActionPlan'] + if key=='_actionI'then return plan and plan.cursor(c.screen)or 0 end + return plan and plan.snapshot(c.screen,c) + end + if key=='_pendingEnd'then return c.screen.result end + if key=='_phase'then return c.screen.result and'ending'or c.screen.phase end +end}) +return M diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua new file mode 100644 index 000000000..0276161bc --- /dev/null +++ b/src/core/game3/battle/anim.lua @@ -0,0 +1,301 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- One admitted native presentation owner. This is not a second battle VM; +-- authored command/particle APIs are advertised only as they are integrated. +local Battle=require('src.core.game3.battle') +local Coords=require('src.core.game3.battle.anim_coords') +local Clock=require('src.battle.PresentationClock') +local Scope=require('src.render.GraphicsScope') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local Ids=require('src.compat.gen1.BattleIds') +local A={Coords=Coords,ENEMY_MON={x=176,y=40},PLAYER_MON={x=72,y=80}} +function A.stage() + return require('src.core.game3.battle.stage').current(require('src.compat.gen1.SourceCall').owner(A,'stage')) +end +function A.drawParticles(lo,hi) + return require('src.core.game3.battle.particle_pass').draw(lo,hi,require('src.compat.gen1.SourceCall').owner(A,'drawParticles')) +end +local current +local numeric={ox=true,oy=true,alpha=true,scale=true,sx=true,sy=true,rotation=true, + darken=true,flash=true,displayHp=true,displayMaxHp=true,displayLevel=true,displayExp=true} +local function retire() + if current then + local screen=current.screen[1] + if screen and current.clip and screen.gen3Anim==current.clip.player + and current.clip.serial==screen.gen3Anim.presentationSerial then + screen.gen3Anim:release();screen.gen3AnimPlaying=false + end + current.clip=nil + if current.effect then current.effect:release();current.effect=nil end + current.tasks={};current.onEnd=nil;current.users={};current.overrides={} + current.view=nil;current.screen[1]=nil + end + current=nil +end +local function context() + local screen=Battle._screen();local view=Battle.getState() + if not screen then retire();return end + if not current or current.screen[1]~=screen or current.view~=view then + retire() + current={screen=setmetatable({screen},{__mode='v'}),view=view,present={},overrides={}, + tasks={},nextTask=0,users={},ids=Ids.new(screen.data)} + end + local facade=require('src.core.Game3');local owner=facade._callbackOwner() + local host=package.loaded['src.mods.Runtime'];owner=host and host.currentMod or owner + if owner then current.users[owner]=true end + return current,screen +end +local function battler(screen,id) + if id==0 then return screen.player elseif id==1 then return screen.enemy end + return screen:battlerAt(id) +end +local function number(v)return type(v)=='number'and v==v and v~=math.huge and v~=-math.huge end +local function callback(fn,...) + if fn then return require('src.core.Game3')._withCallbackOwner(fn.owner,fn.call,...)end +end +local function owned(fn) + if fn==nil then return end + assert(type(fn)=='function','presentation callback must be a function') + local host=package.loaded['src.mods.Runtime'] + return{call=fn,owner=host and host.currentMod or require('src.core.Game3')._callbackOwner()} +end +local function read(d,id,key) + local active,screen=context();if active~=d then return nil end + local b=battler(screen,id);if not b then return nil end + local p=d.overrides[id] + if p[key]~=nil then return p[key]end + if key=='id'then return id end + if key=='side'then return Coords.sideOf(id)end + if key=='visible'then return not b.fainted and not screen:fxHidden(b)and not screen:gen3AnimMonBgHidden(b)end + if key=='invisible'or key=='battlerInvisible'then return screen:gen3AnimMonBgHidden(b)end + if key=='ox'or key=='oy'then + local x,y=screen:gen3AnimShake(b);local fx=screen.picFx and screen.picFx[b] + return(key=='ox'and x+(fx and fx.ox or 0)or y+(fx and fx.oy or 0)) + end + if key=='scale'then return screen:gen3AnimScale(b)or 1 end + if key=='sx'or key=='sy'then + local x,y=screen:gen3AnimSquash(b);return(key=='sx'and x or y)or 1 + end + if key=='rotation'then return screen:gen3AnimRotate(b)or 0 end + if key=='alpha'then local fx=screen:gen3AnimSpecialMonState(b);return fx and fx.alpha or 1 end + if key=='hFlip'then return false end + if key=='darken'or key=='flash'then return 0 end + if key=='displayHp'then return b.shownHP or b.mon.hp end + if key=='displayMaxHp'then return b.mon.stats.hp end + if key=='displayLevel'then return b.mon.level end + if key=='displayStatus'then + -- Logical status may already describe a queued future event. The native + -- HUD owns the shown ailment; false is its explicit healthy sentinel. + local status=b.shownStatus + if not status then return false end + if status=='SLP'then return 1 end + return({PSN=8,BRN=16,FRZ=32,PAR=64,TOX=128})[status]or false + end + if key=='displayExp'then return screen:expFraction(b.mon)end +end +function A.idOf(key) + if key=='attacker_side'then + local _,screen=context();local anim=screen and screen.gen3Anim + return anim and anim.attackerPosition or 0 + end + return Coords.idOf(key) +end +A.sideOf,A.isDouble,A.setDouble=Coords.sideOf,Coords.isDouble,Coords.setDouble +A.coords,A.subpriority,A.bgPriorityRank=Coords.coords,Coords.subpriority,Coords.bgPriorityRank +A.monDrawOrder,A.particleBand,A.battlerIds=Coords.monDrawOrder,Coords.particleBand,Coords.ids +function A.x(value) + local _,screen=context();local anim=screen and screen.gen3Anim + value=tonumber(value)or 0 + return anim and anim.attackerIsPlayer==false and -value or value +end +function A.present(key) + local d,screen=context();local id=A.idOf(key) + if not d or id==nil or id>=2 and not Coords.isDouble(d.view)or not battler(screen,id)then return nil end + if not d.present[id]then + d.overrides[id]={} + d.present[id]=setmetatable({},{__index=function(_,k)return read(d,id,k)end, + __newindex=function(_,k,v) + assert(context()==d,'native battle presentation retired') + assert(k~='id'and k~='side','native battler identity is read-only') + if v~=nil and(numeric[k]or type(v)=='number')then assert(number(v),'invalid presentation number')end + d.overrides[id][k]=v + end}) + end + return d.present[id] +end +function A.battlerCenter(key) + local p=A.present(key);local base=Coords.coords(nil,A.idOf(key)) + if not p or not base then return nil end + return base.x+(p.ox or 0),base.y+(p.oy or 0) +end +function A.shownBattler(key,fallback)local p=A.present(key);return p and p.shown or fallback end +function A.setShown(key,value)local p=A.present(key);if p then p.shown=value;return true end;return false end +function A.ballIdOf(key) + local d,screen=context();local id=A.idOf(key);local b=d and id and battler(screen,id) + if not b then return nil end + return require('src.battle.Gen3BallAnim').ballIndex(screen.data,b.mon.ball or'POKE_BALL',screen.data.items) +end +function A.displayHpRatio(key,fallback) + local p=A.present(key);local mon=fallback and fallback.mon + local hp=p and p.displayHp or mon and mon.hp or 0 + local maximum=math.max(1,p and p.displayMaxHp or mon and mon.maxHp or 1) + return math.max(0,math.min(1,hp/maximum)),hp,maximum +end +function A.displayExpRatio(key,fallback) + local p=A.present(key or'player') + return p and p.displayExp or 0,p and p.displayLevel or fallback and fallback.mon and fallback.mon.level or 1 +end +function A.hpTweening() + local _,screen=context();if not screen then return false end + for _,id in ipairs(Coords.ids())do + local b=battler(screen,id) + if b and b.shownHP and(b.shownHP~=b.mon.hp or b.draining or(b.drainHold or 0)>0)then return true end + end + return false +end +local function effect() + local d=context();if not d then return end + if not d.effect then d.effect=require('src.render.BattleScreenEffect').new()end + return d.effect +end +function A.setScreenEffect(opts)local e=effect();return e and e:set(opts)or false end +function A.screenEffect()local e=effect();return e and e.value end +function A.beginScreenEffect()local e=effect();return e and e:begin()or false end +function A.endScreenEffect()local e=effect();return e and e:finish()or false end +function A.busy() + local d,screen=context() + return d~=nil and(screen.gen3AnimPlaying==true or screen.gen3BallPlaying==true + or A.hpTweening()or screen.expFilling==true or next(d.tasks)~=nil)or false +end +function A.setSeqBusy(value)local d=context();if d then d.seqBusy=value==true end end +function A.seqBusy()local d=context();return d and d.seqBusy==true or false end +function A.tweenStage(frames,onStep,onComplete) + local d=context();if not d then return nil,'no admitted native battle'end + assert(number(frames)and frames>=0,'invalid presentation duration') + d.nextTask=d.nextTask+1 + local task={id=d.nextTask,frames=0,total=math.max(1,math.ceil(frames)),step=owned(onStep),done=owned(onComplete)} + d.tasks[task.id]=task + return task +end +function A._nativeStep(screen,dt) + if not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + local d,actual=context();if actual~=screen or screen.game.stack:top()~=screen then return false end + return Clock.once(screen,'source.presentation',function() + local clip=d.clip + if clip then + if clip.player~=screen.gen3Anim or clip.serial~=screen.gen3Anim.presentationSerial then + d.clip,d.onEnd=nil,nil + elseif screen.gen3AnimPlaying then + require('src.core.Game3')._withCallbackOwner(nil,screen.updateMovePresentation,screen) + end + end + -- Snapshot the IDs: a callback may schedule another tween, which starts + -- next frame, or retire the admission, which cancels the remaining work. + local ids={};for id in pairs(d.tasks)do ids[#ids+1]=id end;table.sort(ids) + for _,id in ipairs(ids)do + if context()~=d or screen.game.stack:top()~=screen then return false end + local task=d.tasks[id] + if task then + task.frames=task.frames+1 + callback(task.step,math.min(1,task.frames/task.total),task) + if context()~=d then return false end + if task.frames>=task.total then d.tasks[id]=nil;callback(task.done)end + end + end + if context()==d and screen.game.stack:top()==screen and d.onEnd and not screen.gen3AnimPlaying then + local done=d.onEnd;d.onEnd,d.clip=nil,nil;callback(done) + elseif context()==d and not screen.gen3AnimPlaying then d.clip=nil + end + return true + end) +end +function A._holdsNativeQueue(screen) + local d,actual=context();local clip=d and d.clip + return actual==screen and clip~=nil and clip.player==screen.gen3Anim + and clip.serial==screen.gen3Anim.presentationSerial and screen.gen3AnimPlaying==true +end +local publicUpdateStep='source.animation.public' +local function update(dt) + if Boundary.active('battle.animation.update')then return Boundary.invoke('battle.animation.update')end + local _,screen=context() + if not screen or not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + return Clock.once(screen,publicUpdateStep,function() + A._nativeStep(screen,Clock.delta(screen)) + if Battle._screen()~=screen or screen.game.stack:top()~=screen then return false,'native battle admission retired'end + -- Explicit source holds and the native body share the same display steps. + require('src.core.Game3')._withCallbackOwner(nil,screen.updatePresentation,screen,Clock.delta(screen)) + return true + end) +end +A.update=update +-- Native frames also notify decoration-only wrappers. If a source update +-- already called its base, do not invoke the public wrapper a second time. +-- A wrapper's base here borrows completed presentation work, never another +-- native frame or command queue. +function A._nativeAfterUpdate(screen) + if A.update==update or not Clock.active(screen)or Clock.called(screen,publicUpdateStep)then return end + local _,actual=context() + if actual~=screen or screen.game.stack:top()~=screen then return end + return Clock.once(screen,publicUpdateStep,function() + return Boundary.dispatch('battle.animation.update',function() + if Battle._screen()~=screen or screen.game.stack:top()~=screen then return false,'native battle admission retired'end + A._nativeStep(screen,Clock.delta(screen));return true + end,function()return Call.call(A,'update',Clock.delta(screen))end) + end) +end +function A.launchMove(moveId,opts) + opts=opts or{};local d,screen=context() + if not d then return false,'no admitted native battle'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + if screen.gen3AnimPlaying then return false,'native animation is already active'end + local native=d.ids:native('moves',moveId);local anim=screen.gen3Anim + if not native or not anim or not anim:has(native,opts)then return false,'no native animation record'end + if opts.miss then return false,'native miss presentation requires its authored command'end + local attacker=A.idOf(opts.attackerId or opts.attackerSide or'player') + local target=A.idOf(opts.targetId or opts.targetSide or'enemy') + if attacker==nil or target==nil or not battler(screen,attacker)or not battler(screen,target)then + return false,'animation requires admitted native battlers' + end + local done=owned(opts.onEnd) + if not require('src.core.Game3')._withCallbackOwner(nil,anim.start,anim,native,attacker%2==0,attacker,target,opts,screen)then return false end + screen.gen3AnimPlaying=true;d.onEnd=done;d.clip={player=anim,serial=anim.presentationSerial} + return true +end +function A.reset() + retire();Coords._service.release();return true +end +function A._nativeExit(screen) + if current and current.screen[1]==screen then A.reset()end +end +-- Applied around the existing native sprite draw, preserving its scanlines, +-- tints, substitutes and affine effects. Only source overrides compose here; +-- querying present() never feeds native effects back into themselves. +function A._drawBattler(screen,b,body,...) + local d,actual=context();local id=b.position or(b.isPlayer and 0 or 1) + local p=d and actual==screen and d.overrides[id] + if not p or next(p)==nil then return body(screen,b,...)end + if p.visible==false or p.invisible or p.battlerInvisible or p.blinkHidden then return end + if not(p.hFlip or(p.ox or 0)~=0 or(p.oy or 0)~=0 or(p.rotation or 0)~=0 + or(p.scale or 1)~=1 or(p.sx or 1)~=1 or(p.sy or 1)~=1 or(p.alpha or 1)~=1)then + return body(screen,b,...) + end + local args={...};local x,y,scale=args[1],args[2],args[3]or 1 + local img=screen:battlerPic(b);local w,h=img:getDimensions() + local cx,cy=x+w*scale/2,y+h*scale/2 + local ok,result=Scope.call(function() + local g=love.graphics + g.translate(cx+(p.ox or 0),cy+(p.oy or 0));g.rotate(p.rotation or 0) + g.scale((p.scale or 1)*(p.sx or 1)*(p.hFlip and -1 or 1),(p.scale or 1)*(p.sy or 1)) + g.translate(-cx,-cy) + local r,green,blue,a=g.getColor();g.setColor(r,green,blue,a*(p.alpha or 1)) + return body(screen,b,x,y,scale) + end) + if not ok then error(result,0)end + return result +end +A._service={release=A.reset,poll=function()context()end,releaseOwner=function(_,owner) + if current and current.users[owner]then A.reset()end +end} +require('src.core.game3.asset_stream').register(A._service) +return A diff --git a/src/core/game3/battle/anim_coords.lua b/src/core/game3/battle/anim_coords.lua new file mode 100644 index 000000000..6f8611cf7 --- /dev/null +++ b/src/core/game3/battle/anim_coords.lua @@ -0,0 +1,131 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Authored 240x160 sprite anchors, separate from opaque image bounds. Imported +-- cartridge coordinates override the standard anchors on the native owner. +local Battle=require('src.core.game3.battle') +local C={SINGLES={},DOUBLES={}} +C.SUBPRIORITY={[0]=30,40,20,50} +C.BG_PRIORITY_RANK={[0]=2,1,1,2} +C.DRAW_ORDER_SINGLES={1,0} +C.DRAW_ORDER_DOUBLES={3,1,0,2} +C.battleState=Battle.getState +local function point(x,y)return{x=x,y=y,[1]=x,[2]=y}end +for id,p in pairs{[0]={72,80},{176,40},{48,40},{112,80}}do C.SINGLES[id]=point(p[1],p[2])end +for id,p in pairs{[0]={32,80},{200,40},{90,88},{152,32}}do C.DOUBLES[id]=point(p[1],p[2])end +local double,overrides,binding +local users={} +local function recordOwner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local owner=host and host.currentMod or facade and facade._callbackOwner() + if owner then users[owner]=true end +end +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +function C.sideOf(id)return type(id)=='number'and(id%2==0 and'player'or'enemy')or nil end +function C.partner(id)return type(id)=='number'and(id+2)%4 or nil end +function C.fixedId(key) + if type(key)=='number'and key==key and key>=0 and key<=3 then return math.floor(key)end + return({player=0,enemy=1})[key] +end +function C.idOf(key) + if type(key)=='table'then key=key.id or key.side end + if binding and binding[key]~=nil then return binding[key]end + return C.fixedId(key) +end +function C.bind(attacker,target) + recordOwner() + if attacker==nil then binding=nil;return end + attacker=C.fixedId(attacker);target=C.fixedId(target) + binding={} + if target then binding[C.sideOf(target)]=target end + if attacker then binding[C.sideOf(attacker)]=attacker end +end +function C.withBattler(key,fn,...) + local previous=binding;local id=C.idOf(key) + binding={player=previous and previous.player or 0,enemy=previous and previous.enemy or 1} + if id then binding[C.sideOf(id)]=id end + local result=pack(pcall(fn,C.sideOf(id),...));binding=previous + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function C.sideArg(fn,index) + index=index or 1 + return function(...) + local args=pack(...);local id=C.idOf(args[index]) + if type(args[index])~='number'or not id then return fn(...)end + return C.withBattler(id,function(side) + args[index]=side;return fn(unpack(args,1,args.n)) + end) + end +end +function C.setDouble(value)recordOwner();if value==nil then double=nil else double=not not value end end +function C.isDouble(st) + if double~=nil then return double end + st=st or Battle.getState();return st and st.double==true or false +end +function C.setCoordinateOverrides(value)recordOwner();local previous=overrides;overrides=value;return previous end +function C.coords(st,key) + local id=C.idOf(key);if id==nil then return nil end + local p=overrides and overrides[id] + if p then return p end + local screen=Battle._screen(st) + local imported=screen and screen.data.constants.gen3BattlerCoords + local row=imported and(C.isDouble(st)and imported.doubles or imported.singles) + p=row and row[id+1]or(C.isDouble(st)and C.DOUBLES or C.SINGLES)[id] + return point(p.x or p[1],p.y or p[2]) +end +function C.ids(st)return C.isDouble(st)and{0,1,2,3}or{0,1}end +function C.battler(st,key) + st=st or Battle.getState();local id=C.idOf(key) + if not st or id==nil then return nil end + return st.battlers and st.battlers[id]or(id==0 and st.player or id==1 and st.enemy or nil) +end +function C.spritePresent(st,key) + local id=C.idOf(key);if id==nil or id>=2 and not C.isDouble(st)then return false end + local b=C.battler(st,id) + if not st then st=Battle.getState()end + if st and st.absent and st.absent[id]then return false end + return b==nil and id<2 or b~=nil and not b.absent and not b.fainted and(b.mon==nil or(b.mon.hp or 0)>0) +end +function C.subpriority(key)return C.SUBPRIORITY[C.idOf(key)]or 40 end +function C.bgPriorityRank(key)return C.BG_PRIORITY_RANK[C.idOf(key)]or 1 end +function C.monDrawOrder(st)return C.isDouble(st)and{3,1,0,2}or{1,0}end +function C.monBehindZ(key,st) + local id=C.idOf(key) + for i,slot in ipairs(C.monDrawOrder(st))do if slot==id then return(i-1)*100+95 end end + return 95 +end +function C.particleBand(k,st) + k=tonumber(k)or 0 + if k<=0 then return 0,99 end + return k*100+1,k>=#C.monDrawOrder(st)and 999 or k*100+99 +end +function C.layerZ(sub,monbg,bgPriority,st) + sub=tonumber(sub)or 0 + local order=C.monDrawOrder(st);local band=0 + for i=#order,1,-1 do + local id=order[i] + local front + if monbg and rawget(monbg,id)then front=((bgPriority and bgPriority[C.bgPriorityRank(id)])or 2)>=2 + else front=sub=3 then return math.max(1,math.min(98,98-sub))end + return C.layerZ(sub,vm and vm._monbg,vm and vm._bgPrio,st) +end +function C.idTable(values) + local result=setmetatable({},{__index=function(t,k)local id=C.idOf(k);if id~=nil then return rawget(t,id)end end, + __newindex=function(t,k,v)local id=C.idOf(k);rawset(t,id~=nil and id or k,v)end}) + for k,v in pairs(values or{})do result[k]=v end + return result +end +C._service={release=function()double,overrides,binding=nil,nil,nil;users={}end} +C._service.poll=function()if not Battle.isActive()then C._service.release()end end +C._service.releaseOwner=function(_,owner)if users[owner]then C._service.release()end end +require('src.core.game3.asset_stream').register(C._service) +return C diff --git a/src/core/game3/battle/anim_pal.lua b/src/core/game3/battle/anim_pal.lua new file mode 100644 index 000000000..4e34adece --- /dev/null +++ b/src/core/game3/battle/anim_pal.lua @@ -0,0 +1,48 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Packed palette arithmetic and a real native indexed shader. Animation +-- buffers/commands remain native contracts; no substitute VM or clock here. +local Core=require('src.render.Gen3IndexedPalette') +local bank=Core.new({cutout=true,paletteAlpha=true}) +local M={rgb=Core.rgb,pack=Core.pack,blend5=Core.blend5,blendColor=Core.blendColor,gray5=Core.gray5} +local owners,rgba,resolved={},{},{} +for i=1,16 do rgba[i]={0,0,0,0}end +local function own() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local who=type(host)=='table'and host.currentMod + or type(facade)=='table'and type(facade._callbackOwner)=='function'and facade._callbackOwner()or'engine' + owners[who]=true +end +local function channel(n)return math.max(0,math.min(31,math.floor(n+.5)))end +function M.shader()own();return bank:shader()end +function M.resolve(colours,opts,out) + opts=opts or{};out=out or{} + local tr,tg,tb=Core.rgb(opts.color) + for i=0,15 do + local r,g,b=Core.rgb(colours and colours[i]) + if (tonumber(opts.coeff)or 0)>0 then r,g,b=Core.blend5(r,g,b,opts.coeff,tr,tg,tb)end + local a=opts.affine + if a then + if a.m>=0 and 1-a.m>.0001 then + local k=1-a.m + r,g,b=Core.blend5(r,g,b,math.floor(k*16+.5),channel(a.r/k*31),channel(a.g/k*31),channel(a.b/k*31)) + elseif a.m<0 then + r,g,b=channel(r*a.m+a.r*31),channel(g*a.m+a.g*31),channel(b*a.m+a.b*31) + end + end + if opts.gray then r,g,b=Core.gray5(r,g,b)end + out[i]=Core.pack(r,g,b) + end + return out +end +function M.send(colours,opts) + own();opts=opts or{};M.resolve(colours,opts,resolved) + for i=0,15 do + local r,g,b=Core.rgb(M.blackPass and 0 or resolved[i]);local c=rgba[i+1] + c[1],c[2],c[3],c[4]=r/31,g/31,b/31,(i==0 and not opts.opaque0)and 0 or 1 + end + return bank:sendNormalized(rgba) +end +local service={release=function()owners={};bank:release()end, + releaseOwner=function(_,who)owners[who]=nil;if not next(owners)then bank:release()end end} +M._service=service;require('src.core.game3.asset_stream').register(service) +return M diff --git a/src/core/game3/battle/anim_seq.lua b/src/core/game3/battle/anim_seq.lua new file mode 100644 index 000000000..fea506100 --- /dev/null +++ b/src/core/game3/battle/anim_seq.lua @@ -0,0 +1,144 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Presentation filtering over the rows actually produced by one native action. +-- Gameplay rows, clocks and executable callbacks remain in the native queue. +local Battle=require('src.core.game3.battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local CoroutineScopes=require('src.compat.gen1.CoroutineScopes') +local Scopes=CoroutineScopes.new() +local Call=require('src.compat.gen1.SourceCall') +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local tags=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +local function shallow(t)local out={};for k,v in pairs(t)do out[k]=v end;return out end +local function same(a,b) + for k,v in pairs(a)do if b[k]~=v then return false end end + for k,v in pairs(b)do if a[k]~=v then return false end end + return true +end +local function valid(token) + if token.retired then return false end + if token.phase and token.phase.closed then token.retired=true;return false end + if Battle._screen(token.view)~=token.screen or token.screen.queue~=token.queue + or token.screen.game.stack:top()~=token.screen then token.retired=true;return false end + local positions={} + for index,row in ipairs(token.queue)do + if positions[row]then token.retired=true;return false end + positions[row]=index + end + local previous=0 + for i,row in ipairs(token.rows)do + local index=positions[row] + if not index or index<=previous or not same(row,token.signatures[i])then token.retired=true;return false end + previous=index + end + return true +end +local function facts(_,view) + local out={};for _,key in ipairs{'kind','id','move','attackerId','targetId','text','type'}do out[key]=view[key]end + return out +end +local function event(token,row,tag) + local spec={kind='native'} + for _,kind in ipairs{'fn','ui','drain','anim','text','wait','sound','exp'}do + if row[kind]~=nil then spec.type=kind;break end + end + if tag=='used'then spec.kind,spec.id,spec.text='msg','_ItemUseText001',row.text + elseif tag=='move'then + spec.kind,spec.move,spec.attackerId,spec.targetId='move',row.anim,row.attackerPosition,row.targetPosition + end + local refs=setmetatable({token},{__mode='v'}) + local view=setmetatable({},{__index=function(_,key) + local current=refs[1];if current and valid(current)then return spec[key]end + end,__newindex=function()error('native animation event is read-only',2)end, + __metatable='native animation event'}) + Iteration.register(view,facts) + return view +end +-- Called only at the actual native row constructors. No text matching. +function M._nativeMessage(row)tags[row]='used'end +function M._nativeMove(row)tags[row]='move'end +function M.isMoveUsedId(id)return id=='_ItemUseText001'end +local function request(token,meta) + assert(valid(token),'native animation admission retired') + assert(type(meta)=='table'and type(meta.events)=='table','native animation events required') + local keep,previous,count={},0,0 + for key,value in pairs(meta.events)do + assert(type(key)=='number'and key%1==0 and key>=1 and key<=#meta.events,'native animation events must be dense') + count=count+1 + end + assert(count==#meta.events,'native animation events must be dense') + for _,value in ipairs(meta.events)do + local index=token.byEvent[value] + assert(index and index>previous,'native animation event identity/order changed') + previous=index;keep[index]=true + end + local drop={} + for index,row in ipairs(token.rows)do + if not keep[index]then + assert(token.removable[index],'native gameplay/presentation row cannot be removed') + drop[row]=true + end + end + return drop +end +function M.begin(meta) + local phase=Scopes:find('sequence') + if not phase then return false,'animation begin belongs to its native action'end + if not phase.called then phase.drop=request(phase.value,meta);phase.called=true end + return Boundary.invoke('battle.anim.begin') +end +function M._nativeAction(screen,row,body) + -- This boundary observes the same native callback once. Outside a live + -- source admission there is no presentation permission to lend. + local view=Battle.getState() + if Battle._screen(view)~=screen then return body()end + local queue=screen.queue;local before={} + for _,entry in ipairs(queue)do before[entry]=true end + local result=pack(body()) + if Battle._screen(view)~=screen or screen.queue~=queue then return unpack(result,1,result.n)end + local token={screen=screen,view=view,queue=queue,rows={},events={},signatures={},removable={},byEvent={}} + for _,entry in ipairs(queue)do if not before[entry]then + local index=#token.rows+1;token.rows[index]=entry;token.signatures[index]=shallow(entry) + local tag=tags[entry] + -- A tagged row acquiring any executable/gameplay payload loses its + -- removal permission, even if another mod retains its original text. + local removable=false + if tag=='used'then + removable=type(entry.text)=='string';for key in pairs(entry)do if key~='text'then removable=false end end + elseif tag=='move'then + removable=type(entry.anim)=='string'or type(entry.anim)=='number' + local allowed={anim=true,attackerIsPlayer=true,attackerPosition=true,targetPosition=true,animContext=true,hit=true} + for key in pairs(entry)do if not allowed[key]then removable=false end end + if entry.hit then + if type(entry.hit)~='table'then removable=false + else for key in pairs(entry.hit)do if key~='sfx'and key~='animType'and key~='blink'then removable=false end end end + end + end + token.removable[index]=removable==true + local value=event(token,entry,removable and tag or nil);token.events[index]=value;token.byEvent[value]=index + end end + if #token.rows==0 then token.retired=true;return unpack(result,1,result.n)end + local phase=Scopes:push('sequence',token);token.phase=phase + local ok,why=pcall(function() + return Boundary.dispatch('battle.anim.begin',function() + assert(valid(token),'native animation admission retired');return true + end,function()return Call.call(M,'begin',{events=shallow(token.events)})end) + end) + -- Commit only after every decorator returns successfully. A throwing + -- wrapper cannot partly filter the native presentation or replay combat. + local expired=phase.closed or CoroutineScopes.retired() + if ok and not expired and phase.called and valid(token)then + for i=#queue,1,-1 do if phase.drop[queue[i]]then + if screen.moveAnimRow==queue[i]then screen.moveAnimRow=nil end + table.remove(queue,i) + if screen.nextInsert and i<=screen.nextInsert then screen.nextInsert=screen.nextInsert-1 end + end end + end + token.retired=true;Scopes:close(phase) + if not ok then error(why,0)end + if expired then error('native animation phase retired while suspended',0)end + return unpack(result,1,result.n) +end +return M diff --git a/src/core/game3/battle/anim_sprites.lua b/src/core/game3/battle/anim_sprites.lua new file mode 100644 index 000000000..93a679341 --- /dev/null +++ b/src/core/game3/battle/anim_sprites.lua @@ -0,0 +1,86 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native event observations and reversible drawing transforms. There is no +-- sprite scheduler here: event life, OAM order and animation clocks stay native. +local Battle=require('src.core.game3.battle') +local Scope=require('src.render.GraphicsScope') +local Native=require('src.battle.Gen3Battle') +local S={} +local current +local interested=false +local function retire() + if current then for _,row in ipairs(current.pool)do row.active=false;row.customDraw=nil end end + current=nil +end +local function context() + local screen=Battle._screen();local anim=screen and screen.gen3AnimPlaying and screen.gen3Anim + if not(anim and anim.events)then retire();return end + if not current or current.screen~=screen or current.anim~=anim or current.serial~=anim.presentationSerial then + retire();current={screen=screen,anim=anim,serial=anim.presentationSerial,pool={},entries={},users={}} + end + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local who=host and host.currentMod or facade and facade._callbackOwner();if who then current.users[who]=true end + return current +end +local function sample(c) + if c.sampled and c.frame==c.anim.frame then return c.pool end + c.frame=c.anim.frame;c.sampled=true + local seen,out={},{} + for _,event in ipairs(c.anim.events)do + local pose=c.anim:eventPose(event,c.screen,Native) + if pose then + local row=c.entries[event]or{};c.entries[event]=row;seen[event]=true + local key=c.anim:eventPriorityKey(event,c.screen) + local x,y=pose.centreX+(pose.dx or 0),pose.centreY+(pose.dy or 0) + row.active,row.visible,row.invisible=true,true,pose.callbackState and pose.callbackState.invisible or false + row.x,row.y,row.ox,row.oy=x,y,0,0;row.scaleX,row.scaleY,row.rotation=1,1,0 + row.alpha,row.age,row.tag,row.template=1,pose.age,event.sheet,event.template + row.subpriority=key and key%256;row.priority=key and math.floor(key/256) + row.hostId=event.target and c.anim.targetPosition or c.anim.attackerPosition + row.image=pose.sheet.image;row.customDraw=nil + row._nativeX,row._nativeY=x,y + out[#out+1]=row + end + end + for event,row in pairs(c.entries)do if not seen[event]then row.active=false;row.customDraw=nil;c.entries[event]=nil end end + -- Keep the pool identity stable for the native clip; removed rows retire. + for i,row in ipairs(out)do c.pool[i]=row end + for i=#out+1,#c.pool do c.pool[i]=nil end + return c.pool +end +function S.pool() + interested=true + local c=context();if not c then return{}end + return sample(c) +end +function S.activeCount()return#S.pool()end +function S.forEachActive(fn) + assert(type(fn)=='function','particle observer required') + for _,row in ipairs(S.pool())do fn(row)end +end +local function finite(n)return type(n)=='number'and n==n and math.abs(n)=0,'invalid move power');move.power=opts.power end + if opts.moveType~=nil then move.type=assert(ids:native('types',opts.moveType),'unknown cartridge move type')end + local Defaults=require('src.battle.RulesetDefaults');local rulesets=g.data.rulesets or require('src.battle.Rulesets') + local selected=g.save.options and g.save.options.ruleset + local rules=rulesets[selected]or rulesets[Defaults.defaultFor(g)] + assert(rules,'native ruleset unavailable') + local critical=opts.forceCrit;if opts.noCrit then critical=false end + local damage,info=Native.compute(rules,battler(attacker,g,ids),battler(defender,g,ids),move, + {rng=opts.rng,forceCrit=critical,weather=opts.weather,data=g.data}) + if opts.multiplier then assert(type(opts.multiplier)=='number'and opts.multiplier>=0,'invalid damage multiplier');damage=math.floor(damage*opts.multiplier)end + return damage,{move=require('src.core.game3.battle.moves').get(moveId),effectiveness=info.typeMult/10, + critical=info.crit,status=move.power==0 or move.category=='status'or move.class=='status', + physical=(move.category or move.class or require('src.battle.TypeChart').category(move.type))=='physical', + ability=info.ability,missed=info.missed} +end +return D diff --git a/src/core/game3/battle/effect_ids.lua b/src/core/game3/battle/effect_ids.lua new file mode 100644 index 000000000..0218a8444 --- /dev/null +++ b/src/core/game3/battle/effect_ids.lua @@ -0,0 +1,3 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source consumers may decorate their snapshot without changing native IDs. +return require('src.battle.Gen3EffectCatalog').snapshot() diff --git a/src/core/game3/battle/experience.lua b/src/core/game3/battle/experience.lua new file mode 100644 index 000000000..3f1ac1911 --- /dev/null +++ b/src/core/game3/battle/experience.lua @@ -0,0 +1,57 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Source=require('src.compat.gen1.SourcePokemon') +local Growth=require('src.pokemon.Growth') +local Awards=require('src.battle.Experience') +local E={MAX_LEVEL=100} +local function definition(value) + local g=Native.game();if not g then return end + local id=type(value)=='table'and value.species or value + local key=Ids.new(g.data):native('pokemon',id) + return key and g.data.pokemon[key],g +end +function E.expYield(value)local d=definition(value);return d and d.baseExp or 0 end +function E.growthRate(value)local d=definition(value);return d and d.growthRate end +function E.expForLevel(value,level) + local d,g=definition(value);local rate=d and d.growthRate or value + return Growth.expForLevel(rate,level,g and g.data.growth_rates) +end +function E.levelForExp(value,exp) + local d,g=definition(value);return Growth.levelForExp(d and d.growthRate or value,exp,100,g and g.data.growth_rates) +end +function E.gainFor(species,level,opts) + opts=opts or{};local d,g=definition(species);if not d then return 0 end + local n=Awards.gainFor(d,level,false,opts.participants,false,g.data.constants) + local tune=g.data.constants.exp or{} + for _,mult in ipairs{opts.luckyEgg and 1.5 or 1,opts.trainer and(tune.trainerMult or 1.5)or 1,opts.traded and(tune.tradedMult or 1.5)or 1}do n=math.floor(n*mult)end + return math.max(1,n) +end +function E.apply(value,amount) + local mon,g,commit=Source.resolve(value) + assert(type(amount)=='number'and amount==amount and math.abs(amount)=nextLevel then mon.exp=start end + local result={gained=0,rawGained=amount,fromLevel=mon.level,toLevel=mon.level,fromExp=mon.exp,toExp=mon.exp,levels={},steps={}} + local target=mon.level>=cap and mon.exp or math.min(threshold(cap),mon.exp+amount) + while mon.exp0 then + step.grewTo=mon.level;step.hp=mon.hp;step.maxHp=mon.stats.hp + step.oldStats={maxHp=old.hp,atk=old.attack,def=old.defense,spa=old.spatk,spd=old.spdef,spe=old.speed} + local s=mon.stats;step.newStats={maxHp=s.hp,atk=s.attack,def=s.defense,spa=s.spatk,spd=s.spdef,spe=s.speed} + for _,n in ipairs(levels)do result.levels[#result.levels+1]=n end + end + result.steps[#result.steps+1]=step + end + result.toExp,result.toLevel=mon.exp,mon.level;result.gained=mon.exp-result.fromExp + commit();return result +end +return E diff --git a/src/core/game3/battle/healthbox.lua b/src/core/game3/battle/healthbox.lua new file mode 100644 index 000000000..0a3e38775 --- /dev/null +++ b/src/core/game3/battle/healthbox.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source healthboxes use native shown state and the existing imported painter. +local Battle=require('src.core.game3.battle') +local Coords=require('src.core.game3.battle.anim_coords') +local Native=require('src.battle.Gen3Battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Scope=require('src.render.GraphicsScope') +local H={} +local scopes={} +local function resident(st,key,view) + local screen=Battle._screen(st);if not screen then return end + local id=Coords.idOf(key);if id==nil then return end + local observed=Coords.battler(Battle.getState(),id) + if view~=nil and view~=observed then return end + local native;if id==0 then native=screen.player elseif id==1 then native=screen.enemy else native=screen:battlerAt(id)end + return screen,native,id,observed +end +function H.center(st,id) + local screen,b=resident(st,id);if not b then return nil end + local x,y,h=Native.healthboxGeometry(screen,Coords.idOf(id));if not x then return nil end + return{x=x+32,y=y+math.floor(h/2),x+32,y+math.floor(h/2)} +end +local function paint(screen,b,id,opts) + local x,y=Native.healthboxGeometry(screen,id);if not x then return false end + opts=opts or{} + for _,key in ipairs{'ox','oy'}do + local n=opts[key];assert(n==nil or type(n)=='number'and n==n and math.abs(n)255)then + return nil,'invalid imported native move-effect byte' + end + local id=ids:read('moves',key) + local out={id=id,numId=id,nativeId=key,name=row.name,type=ids:read('types',row.type), + power=row.power,accuracy=row.accuracy,pp=row.pp,priority=row.priority,target=row.target, + category=row.category or row.class,effect=effect~=nil and effect or row.effect, + gen3Effect=effect,effectSymbol=Effects.name(effect),nativeEffect=row.effect,effectChance=row.effectChance, + secondaryChance=row.secondaryChance,flags=detached(row.flags),hits=type(row.hits)=='function'and true or detached(row.hits), + highCrit=row.highCrit,makesContact=row.makesContact,protectAffected=row.protectAffected, + magicCoatAffected=row.magicCoatAffected,snatchAffected=row.snatchAffected, + mirrorMoveAffected=row.mirrorMoveAffected,kingsRockAffected=row.kingsRockAffected} + return out +end +return M diff --git a/src/core/game3/battle/particle_pass.lua b/src/core/game3/battle/particle_pass.lua new file mode 100644 index 000000000..ba0a779e8 --- /dev/null +++ b/src/core/game3/battle/particle_pass.lua @@ -0,0 +1,96 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Borrow actual native OBJ/copy painters in the caller's battler bands. +local Battle=require('src.core.game3.battle') +local Native=require('src.battle.Gen3Battle') +local Coords=require('src.core.game3.battle.anim_coords') +local Scope=require('src.render.GraphicsScope') +local Assets=require('src.render.Assets') +local M={} +local current +local function retire() + if current then current.refs[1]=nil;current.view=nil;current.rows=nil;current.users={} end + current=nil +end +local function live(c) + local screen=c.refs[1] + return screen and Battle._screen()==screen and Battle.getState()==c.view + and screen.gen3Anim==c.refs[2] and screen.gen3AnimPlaying +end +local function context(owner) + local screen=Battle._screen();local anim=screen and screen.gen3AnimPlaying and screen.gen3Anim + if not anim then retire();return end + if current and not live(current) then retire() end + if not current then current={refs=setmetatable({screen,anim},{__mode='v'}),view=Battle.getState(),users={}} end + if owner then current.users[owner]=true end + return current,screen,anim +end +local function sample(c,screen,anim) + local token=Scope.token() + if token and c.rows and c.token==token and c.frame==anim.frame and c.serial==anim.presentationSerial then return c.rows end + local rows={};local order=Coords.monDrawOrder(c.view);local slot={} + for i,id in ipairs(order)do slot[id]=i end + local function add(kind,value,z)rows[#rows+1]={kind=kind,value=value,z=z}end + local function copies(phase,z) + for _,copy in ipairs(anim.monBgCopies and anim:monBgCopies(screen,phase)or{})do add('copy',copy,z)end + end + copies('pre',50) + c.residual=nil + local objects,bgs=Native.moveOamObjects(screen,true) + if objects then + local band=0 + for priority=3,0,-1 do + for _,copy in ipairs(bgs)do if (tonumber(copy.priority)or 2)==priority then add('copy',copy,band*100+50)end end + for i=#objects,1,-1 do + local row=objects[i] + if math.floor(row.key/256)==priority then + if row.kind=='battler' then + local b=row.battler;local id=b.position or(b==screen.player and 0 or 1) + band=slot[id]or band + else add('event',row,band*100+50)end + end + end + end + else + -- Native task/helper barriers without a complete OAM key retain the native + -- fallback: actual event order above the battlers, not invented priorities. + c.residual=bgs + for _,copy in ipairs(anim.monBgCopies and anim:monBgCopies(screen,'oam')or{})do add('copy',copy,50)end + for _,row in ipairs(anim:eventsForDraw(screen,Native))do add('event',row,999)end + end + copies('post',999) + -- Surf and flourish keep their own native task/graphics implementations. + -- skipEvents prevents this borrowed extra layer from redrawing OBJ events. + add('extras',true,999) + c.token,c.frame,c.serial,c.rows=token,anim.frame,anim.presentationSerial,rows + return rows +end +local function bound(n,default) + if n==nil then return default end + assert(type(n)=='number' and n==n and math.abs(n)hi then return false,'empty particle band' end + local c,screen,anim=context(owner);if not c then return false,'inactive native animation' end + local rows=sample(c,screen,anim);local count=0 + local ok,result,reason=Scope.call(function() + for _,row in ipairs(rows)do + if row.z>=lo and row.z<=hi then + if not live(c) or screen.gen3Anim~=anim or anim.frame~=c.frame then retire();return false,'native particle admission changed' end + if row.kind=='event' then + local entry=row.value;anim:drawEvent(entry.event,screen,Native,Assets,entry.pose,true) + elseif row.kind=='copy' then screen:drawGen3MonBgState(row.value) + else anim:draw(screen,true) end + if not live(c) or screen.gen3Anim~=anim or anim.frame~=c.frame then retire();return false,'native particle admission changed' end + count=count+1 + end + end + return true,count + end) + if not ok then error(result,0)end + return result,reason +end +M._service={release=retire,poll=function()if current and not live(current)then retire()end end, + releaseOwner=function(_,owner)if current and current.users[owner]then retire()end end} +require('src.core.game3.asset_stream').register(M._service) +return M diff --git a/src/core/game3/battle/pic_coords.lua b/src/core/game3/battle/pic_coords.lua new file mode 100644 index 000000000..df0b9fbe3 --- /dev/null +++ b/src/core/game3/battle/pic_coords.lua @@ -0,0 +1,41 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Live cartridge-numbered offsets; changing datasets never keeps an old row. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local P={} +local function finite(n)return type(n)=='number'and n==n and n~=math.huge and n~=-math.huge end +-- Base sprite anchors are independent of the current render transform and +-- animation coordinate overrides. Read the live cartridge record each time; +-- the returned point is detached so callers cannot edit native layout data. +function P.battlerCoords(double,id) + if double~=nil and type(double)~='boolean'then return nil,'double must be a boolean'end + id=id==nil and 0 or tonumber(id) + if not id or id~=id or id%1~=0 or id<0 or id>3 then return nil,'invalid battler slot'end + local game,why=Native.game();if not game then return nil,why end + local constants=game.data and game.data.constants + local imported=constants and constants.gen3BattlerCoords + if imported~=nil and type(imported)~='table'then return nil,'invalid native battler coordinates'end + local layout + if imported then if double then layout=imported.doubles else layout=imported.singles end end + if layout~=nil and type(layout)~='table'then return nil,'invalid native battler coordinates'end + local point=layout and layout[id+1] + if point==nil then + local coords=require('src.core.game3.battle.anim_coords') + point=(double and coords.DOUBLES or coords.SINGLES)[id] + end + if type(point)~='table'then return nil,'invalid native battler coordinates'end + local x,y=point.x or point[1],point.y or point[2] + if not finite(x)or not finite(y)then return nil,'invalid native battler coordinates'end + return{x=x,y=y,[1]=x,[2]=y} +end +for _,kind in ipairs{'front','back','elev'}do + P[kind]=setmetatable({},{__index=function(_,id) + local g=Native.game();if not g then return nil end + local key=Ids.new(g.data):native('pokemon',id);local row=key and g.data.pokemon[key] + if not row then return nil end + if kind=='elev'then return g.data.constants.gen3Elevation and g.data.constants.gen3Elevation[key]or 0 end + local coords=kind=='front'and row.frontPicCoords or row.backPicCoords + return coords and coords.yOffset + end,__newindex=function()error('native picture coordinates are read-only',2)end}) +end +return P diff --git a/src/core/game3/battle/profile.lua b/src/core/game3/battle/profile.lua new file mode 100644 index 000000000..923cbfa44 --- /dev/null +++ b/src/core/game3/battle/profile.lua @@ -0,0 +1,33 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Bounded native presentation facts. Rules/action/theme capabilities are not +-- inferred from this view; the admitted native battle remains authoritative. +local Battle=require('src.core.game3.battle') +local Native=require('src.compat.gen1.NativeSession') +local Text=require('src.battle.Gen3SafariText') +local Version=require('src.core.GameVersion') +local M={} +function M.of(state) + local b=state~=nil and Battle._screen(state)or nil + local game=Native.game();local edition=Version.get() + if not(b and game and b.game==game and b.gen3Safari and Text.spec(game.data,edition))then + return nil,'no admitted native Safari presentation profile' + end + local refs=setmetatable({state,b,game.data,b.gen3Safari},{__mode='v'}) + local function valid() + local view,screen=refs[1],refs[2];local g=Native.game() + return view~=nil and screen~=nil and Battle._screen(view)==screen + and g~=nil and screen.game==g and g.data==refs[3]and screen.gen3Safari==refs[4] + and Version.get()==edition and Text.spec(g.data,edition)~=nil + end + local strings=setmetatable({},{__index=function(_,key) + if valid()and key=='safariPrompt'then return Text.PREFIX..'prompt'end + end,__newindex=function()error('native battle profile is read-only',2)end}) + return setmetatable({},{__index=function(_,key) + if key=='open'then return valid()end + if not valid()then return end + if key=='strings'then return strings end + if key=='id'then return edition end + if key=='family'then return Version.layout(edition)end + end,__newindex=function()error('native battle profile is read-only',2)end}) +end +return M diff --git a/src/core/game3/battle/rules.lua b/src/core/game3/battle/rules.lua new file mode 100644 index 000000000..a283d4bed --- /dev/null +++ b/src/core/game3/battle/rules.lua @@ -0,0 +1,7 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Observation only; native Damage and the active battle still resolve hits. +local Native=require('src.compat.gen1.NativeSession') +local crit=setmetatable({},{__index=function(_,key) + if key=='CHANCE'and Native.game()then return require('src.battle.Damage').gen3CritDenominators()end +end,__newindex=function()error('native critical rule observations are read-only',2)end}) +return{crit=crit} diff --git a/src/core/game3/battle/stage.lua b/src/core/game3/battle/stage.lua new file mode 100644 index 000000000..54bf0a498 --- /dev/null +++ b/src/core/game3/battle/stage.lua @@ -0,0 +1,142 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Live presentation observations over one actual native battle admission. +local Battle=require('src.core.game3.battle') +local Native=require('src.battle.Gen3Battle') +local Coords=require('src.core.game3.battle.anim_coords') +local Iteration=require('src.compat.gen1.TableIteration') +local Ball=require('src.compat.gen1.NativeBallObservation') +local Timing=require('src.core.Timing') +local M={} +local current +local function retire(c) + if not c then return end + c.retired=true;c.refs[1]=nil;c.view=nil;c.nodes={};c.users={};c.ballRef=nil;c.ballView=nil + if current==c then current=nil end +end +local function live(c) + local s=not c.retired and c.refs[1] + if s and Battle._screen()==s and Battle.getState()==c.view then return s end + retire(c) +end +local read +local keys={stage={'slide','slideDone','trainer','ball','balls','healthbox','partyBar','bgSlide','win0'}, + trainer={'player','enemy'},healthbox={'player','enemy',0,1,2,3},balls={0,1,2,3}, + portrait={'visible','x','y','ox','oy','frame','gender','picId','pic2','x2'}, + box={'visible','ox'},bg={'enemyOx','playerOx'},mask={1,2}, + party={'player','enemy'},bar={'visible','x','y','ox','balls','ballHidden'}} +local function node(c,kind,id,anim) + local name=kind..':'..tostring(id or'') + if not anim and c.nodes[name]then return c.nodes[name]end + local refs=anim and setmetatable({anim},{__mode='v'}) + local fieldKeys=anim and Ball.keys or keys[kind] + local function get(key) + local s=live(c);if not s then if key=='visible'then return false end;return nil end + if refs then + local a=refs[1];if not a or s.gen3Ball~=a then if key=='visible'then return false end;return nil end + return Ball.read(s,a,key) + end + return read(c,s,kind,id,key) + end + local value=setmetatable({},{__index=function(_,key)return get(key)end, + __newindex=function()error('native battle stage observation is read-only',2)end}) + local weak=setmetatable({value=value},{__mode='v'}) + -- The registry must not retain its own weak key through this reader's state. + Iteration.register(value,function(spec) + local v=spec.value;local out={} + if v then for _,key in ipairs(fieldKeys)do out[key]=v[key]end end + return out + end,weak) + if not anim then c.nodes[name]=value end + return value +end +local function battler(s,id) + return id==0 and s.player or id==1 and s.enemy or s:battlerAt(id) +end +local function ball(c,s) + local a=s.gen3Ball;if not a then return nil end + local catch=package.loaded['src.core.game3.battle.catch_seq'] + local caught=catch and catch._ball + if caught and caught.stage then return caught.stage end + if c.ballRef and c.ballRef[1]==a then return c.ballView end + c.ballRef=setmetatable({a},{__mode='v'});c.ballView=node(c,'ball',nil,a) + return c.ballView +end +local function slide(s)return(s.introSlide or 0)*Timing.BATTLE_SLIDE_PX_PER_FRAME end +local names={trainer='trainer',healthbox='healthbox',partyBar='party',bgSlide='bg',balls='balls'} +read=function(c,s,kind,id,key) + if kind=='stage'then + if key=='slideDone'then return(s.introSlide or 0)<=0 and(s.frlgIntro and s.frlgIntro.hold or 0)<=0 end + if key=='slide'then + local intro=s._sourceIntroSequence + local total=intro and intro.steps[1].data.slideFrames or Timing.BATTLE_SLIDE_IN_FRAMES + return total>0 and math.max(0,math.min(1,1-(s.introSlide or 0)/total))or 1 + end + if key=='ball'then return ball(c,s)end + if key=='win0'then local top=Native.introWindow(s);return top and node(c,'mask')or nil end + if names[key]then return node(c,names[key])end + elseif kind=='trainer'or kind=='party'then + local pos=key=='player'and 0 or key=='enemy'and 1 + if pos then return node(c,kind=='trainer'and'portrait'or'bar',pos)end + elseif kind=='healthbox'then + local pos=Coords.fixedId(key);if pos then return node(c,'box',pos)end + elseif kind=='balls'then + if type(key)=='number'and key%1==0 and key>=0 and key<=3 then + local b=battler(s,key);local a=b and s:gen3BallFor(b) + if a and(a.battler==b or key==1 and not a.battler)then return ball(c,s)end + end + elseif kind=='mask'then + local top,bottom=Native.introWindow(s);return key==1 and top or key==2 and bottom or nil + elseif kind=='bg'then + if key=='enemyOx'then return -slide(s)end + if key=='playerOx'then return slide(s)end + elseif kind=='box'then + if key=='visible'then return Native.healthboxVisible(s,id,slide(s))end + if key=='ox'then return 0 end -- Visible native panels have no slide offset. + elseif kind=='portrait'then + local player=id==0 + local constants=s.data.constants.gen3BattlerCoords + local positions=constants and(s:isDouble()and constants.doubles or constants.singles) + local p=positions and positions[id+1]or(s:isDouble()and Coords.DOUBLES or Coords.SINGLES)[id] + if key=='visible'then return player and s.showPlayerBack==true or not player and s.showEnemyTrainer==true end + if key=='x'then return p.x or p[1]end + if key=='y'then return p.y or p[2]end + if key=='ox'then return(player and slide(s)or -slide(s))+s:picOffset(player and'back'or'foe')end + if key=='oy'then return 0 end + if key=='frame'then return player and(s:backThrowFrame()or 0)or 0 end + if key=='picId'and not player then return c.view.trainerPicId end + if key=='pic2'and not player then return s.trainerB and s.trainerB.picIndex end + if key=='x2'and not player and s.trainerB then + local q=positions and positions[4]or Coords.DOUBLES[3];return q.x or q[1] + end + if key=='gender'and player then + local forms=s.data.field and s.data.field.playerForms + local form=require('src.pokemon.Sprites').playerForm(s.data,s.game.save) + return forms and form and(forms.girl==form and 1 or 0)or nil + end + elseif kind=='bar'then + local summary,row,party=Native.partySummary(s,id==0) + if key=='visible'then return summary~=nil end + if not summary then return nil end + if key=='x'then return row.barX+(row.flip and(Native.summaryBarWidth(summary)or 0)or 0)end + if key=='y'then return row.barY end + if key=='ox'then return Native.summaryRowOffset(summary,row,(s.frame or 0)-(s.introBallsFrom or s.frame or 0))end + if key=='balls'then return Native.summarySlots(summary,row,party)end + if key=='ballHidden'then + local age=(s.frame or 0)-(s.introBallsFrom or s.frame or 0);local hidden={} + for i=1,summary.count or 6 do hidden[i]=age