diff --git a/.github/workflows/gen2-queue-screen-contract.yml b/.github/workflows/gen2-queue-screen-contract.yml new file mode 100644 index 000000000..631e86847 --- /dev/null +++ b/.github/workflows/gen2-queue-screen-contract.yml @@ -0,0 +1,47 @@ +name: native Gen2 queue screen contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gen2-queue-screen-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Install ROM-free runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Previous native source exposes the item-result regressions + shell: bash + run: | + git -c gc.auto=0 fetch --no-tags --depth=1 origin 2727365b0c38495717c688708d521cf8150cfec4 + baseline="$RUNNER_TEMP/gen2-party-result-baseline" + mkdir -p "$baseline" + git archive 2727365b0c38495717c688708d521cf8150cfec4 src tests data tools | tar -x -C "$baseline" + cp src/render/NativeScreenOverlay.lua "$baseline/src/render/NativeScreenOverlay.lua" + cp src/ui/Gen2PartyItemResult.lua "$baseline/src/ui/Gen2PartyItemResult.lua" + cp src/ui/Gen2MoveDeleter.lua src/ui/Gen2ForgetMoveList.lua "$baseline/src/ui/" + cp src/pokemon/Gen2PP.lua "$baseline/src/pokemon/Gen2PP.lua" + cp src/battle/rulesets/gen2_finite_pp.lua "$baseline/src/battle/rulesets/gen2_finite_pp.lua" + cp tests/engine/gen2_pp_selection.lua tests/engine/gen2_pp_defaults.lua "$baseline/tests/engine/" + cp tests/engine/gen2_battle_pack.lua tests/engine/gen2_party_item_result.lua "$baseline/tests/engine/" + for suite in gen2_party_item_result gen2_battle_pack gen2_pp_selection; do + if (cd "$baseline" && luajit "tests/engine/$suite.lua") > "$RUNNER_TEMP/$suite-baseline.log" 2>&1; then + cat "$RUNNER_TEMP/$suite-baseline.log" + echo "Previous source unexpectedly passed $suite" + exit 1 + fi + cat "$RUNNER_TEMP/$suite-baseline.log" + grep -q '^FAIL' "$RUNNER_TEMP/$suite-baseline.log" + done + - name: Native queue, screen and available menu owners + run: | + failed=0 + for suite in gen2_battle_queue_view gen2_battle_window gen2_battle_scene wide_battle_layout wide_battle_shake_bug562 gen2_battle_pack gen2_party_item_result gen2_pp_selection gen2_pp_defaults screen_space_ui party_live_data gen2_battle_live_view gen2_battle_submenu gen2_summary_contract frlg_battle_gender; do + if ! luajit "tests/engine/$suite.lua"; then failed=1; fi + done + exit "$failed" diff --git a/docs/NATIVE-GEN2-DISPLAY.md b/docs/NATIVE-GEN2-DISPLAY.md new file mode 100644 index 000000000..05e36f705 --- /dev/null +++ b/docs/NATIVE-GEN2-DISPLAY.md @@ -0,0 +1,64 @@ +# Native Gen2 display selection + +Gen2 display selection belongs to `PaletteFX`, the same owner used by the +native tile, sprite and battle renderers. `GbcPalette` exposes the Gen1Recomp +public vocabulary over that state: `gbc`, `dmg`, `classic`, and `custom`. +The generic native ID for DMG remains `og`, preserving existing option files. + +GBC uses the cartridge's indexed palettes. DMG retains the four original +indexed shades as grayscale. Classic uses that same grayscale intermediate +and maps the completed frame through the green ramp once. A custom ramp +replaces indexed palette rows; already-coloured art keeps its existing +true-colour contract. The unrelated Gen1 colourization packs and Gen3 +display behaviour retain their existing paths. + +`setMode`, `cycle(delta)`, `setCustomRamp`, `applyOptions`, `modeLabel`, and +`presentColors` are available through `GbcPalette`. The mode, custom ramp and +byte BGP all share native state. Raw indexed drawing bypasses display/BGP +selection. A custom ramp takes priority over the selected indexed mode, as +in the reference protocol; Classic's final presentation remains a separate +operation. Missing custom ramps fall back to grayscale. + +`PaletteFX.setDisplay(mode, ramp)` applies a selection atomically. It invalidates +tile and animation textures, OBJ images, battle images and cached maps, then +reloads the entered map. The shared `Assets.register` invalidation bus also +notifies external texture producers, including repeated ramps with the same mode +ID. Retained battle image references resolve through their +original palette and crop on the next draw. Reapplying an edited ramp table +also updates its key and invalidates dependent caches. Transient BGP writes do +not enter static atlas cache keys. Mono/custom world bakes retain cave darkness; +battle palette permutations and blackout apply after display selection. + +Native Gen2 options and hotkey 2 use the GBC → DMG → Classic ladder, with a +backward step supported. Cycling clears a selected custom ramp and saved +palette ID. Native `options.colors` and legacy `options.color` record the same +selection. Applying legacy mode plus `options.palette` performs one rebuild. + +`src.render.Palette` supplies the public palette ID/library operations. Mono IDs +are `m:rrggbb`; named IDs are `p:group/name`. An optional authored +`data.gb_palettes` module supplies named entries; no palette catalogue is +invented or extracted by this library. Categories, labels, lookup, swatches, +ramp caching and invalidation use the reference vocabulary. + +## Completed-frame zones and composition + +For native Gen2 gameplay, `render.zones` receives the final display zones once, +after native drawing has settled its palettes. Classic starts with one green +whole-frame zone; other modes start with nil. Consumers can replace, remove or +extend those zones. Each dispatch gets its own list. This final pass uses raw +palette destinations so Classic cannot resolve its own green ramp back to gray. + +The native Gen2 compositor first combines world and UI into one scene. Its +window-menu callback includes a window-resolution drawer when one is supplied; +this offer does not add the separate native window-menu classes or a palette-picker UI. `render.compose` then receives that completed texture in +`sceneCanvas`, `worldCanvas` and `uiCanvas`, with `generation = 2`, the native +renderer and an explicit `game` owner. The render target is different from the +source texture. A consumer taking composition ownership also owns presentation +of the supplied final zones; `Renderer:blitZones` provides the raw zone blit. +Otherwise the engine applies final zones before present/output effects. HUD and +touch controls retain their screen-space placement outside this game scene. + +This is display work toward Gen1 parity. It does not establish complete UI +raster parity, a full unchanged-mod playthrough, save/link acceptance, hardware +performance or full engine parity. Existing upstream/Gen1 licences and the +[Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md) remain applicable. diff --git a/docs/NATIVE-GEN2-HP.md b/docs/NATIVE-GEN2-HP.md new file mode 100644 index 000000000..86a36cd14 --- /dev/null +++ b/docs/NATIVE-GEN2-HP.md @@ -0,0 +1,45 @@ +# Native Gen2 HP presentation + +The shared battle engine resolves damage and queues the presentation of each +damage or healing beat. Gen2 now dispatches that actual queue row through +`BattleState:stepHpAnim()`, with `hpAnim.side` and `hpAnim.to` describing the +active beat. `shownHp.player` and `shownHp.enemy` read the real battlers' +displayed HP. Writes are admitted only inside that callback, for its actual +side and Pokemon, with finite values within the Pokemon's maximum HP. + +The default callback retains the Gen1Recomp Gen2 implementation's 48-pixel +short/long loops, two-frame pixel cadence and high-HP integer division. A +decorator may choose different display increments for a wider HUD. The native +queue still waits for the actual target and retirement of `hpAnim`; returning +early cannot grant the next battle command. Display writes never mutate the +Pokemon's resolved HP. Queued multi-hit stops and battler switches preserve +their native owners. Gen1 and Gen3 retain their existing drain path. + +`tests/engine/gen2_hp_presentation.lua` exercises the real shared battle class, +queue dispatch, multi-hit floors, healing, fainting, fractional display steps, +invalid writes, callback failure cleanup, battler replacement and the other +generations' unchanged path. These tests do not prove full mod playability, +move animation or capture rendering; those require separate implementation +and gameplay acceptance. This upstream contribution binds the HP view directly +in the native constructor; the fork's additional live battle-model and sprite +capture view is a separate integration. The HP regression stands on its own. + +`hudHp` and `hudHpPixels` expose the displayed number and the independent +animated pixel cursor. Both native HUD tile output and its color-zone selection +read that cursor, including short-loop frames whose HP number repeats. Party +and summary bars use GSC's high-HP byte division and half-HP green boundary; +explicit imported hack geometry and other generations retain their defaults. +Changing between a wider decorated HUD and the native step discards a stale +pixel cursor, so returning to the native HUD cannot reverse displayed damage. + +`gen2_hp_pixels.lua` checks actual native HUD tile output, palette boundaries +and display-owner handoff. `tests/gpu/gen2_hp_pixels.lua` reads actual GPU pixels +from the native queue and tile renderer using a generated test atlas in Gold, +Silver and Crystal, with color and grayscale output. It does not establish +whole-mod or final OS-window acceptance. + +Pixel stepping adapts Gen1Recomp's `src/battle/gen2/HpBar.lua` and +`src/ui/gen2/BattleState.lua`. Existing upstream and Gen1Recomp rights and +credits remain applicable; see `NOTICE.md`. Native-owner integration and +regressions are contributions by Roxas2712, under `LICENSE-ROXAS2712.md` to the +extent of rights held. diff --git a/docs/NATIVE-GEN2-PALETTE-REGISTER.md b/docs/NATIVE-GEN2-PALETTE-REGISTER.md new file mode 100644 index 000000000..9f5188eed --- /dev/null +++ b/docs/NATIVE-GEN2-PALETTE-REGISTER.md @@ -0,0 +1,66 @@ +# Native palette register and frame remapping + +The public `src.render.GbcPalette` module resolves to the engine's +`Gen2PaletteDraw`. Its byte-register API shares `PaletteFX`'s native shade map; +there is no second register owned by a compatibility facade. + +## Forward drawing + +`BGP_IDENTITY` is `0xe4`. `bgpShades(byte)` returns the four two-bit fields, +with colour zero first, in a one-based array. `remap(colors, byte)` applies +those indices to a four-row RGB palette. Nil and identity preserve the original +palette table. + +`setBgp(byte)` returns the previous byte. Identity is represented by nil. +Reading or assigning `GbcPalette.bgp` accesses this same native state; native +`PaletteFX.setShadeMap` writes are immediately visible through it. The existing +native frame reset clears it as well. + +`resolve(colors)` selects the display palette without applying the register. +`color`, `use`, `with` and keyed draws apply the current register once. +`useRaw` and `withRaw` bypass display selection and the register. Native +`PaletteFX.sendColors` consumes the same state. The separate palette-upload +optimization uses this owner when present. + +## Already-coloured frames + +`remapTable(bgPalettes, byte, objPalettes, ramp, objRamped)` returns source +colours, destination colours, a live count and an ambiguity count. Tables hold +0–255 RGB triples, padded to `REMAP_MAX` (64) with detached rows. BG palettes +take the explicit byte permutation. OBJ palettes normally retain their colours. +An optional forced ramp supplies BG destinations; selected `objRamped` entries +take its unpermuted colours. + +Shared RGB values can no longer identify the original palette after composition. +The first BG interpretation wins; conflicting destinations increase the ambiguity +count. This is a limitation of remapping RGB frames, not proof of indexed +per-pixel equivalence. Extra entries beyond 64 are omitted with BG slots first. + +`remapUniforms(...)` builds normalized shader inputs, or returns nil for an empty +table. `useRemapUniforms(inputs)` and `useRemap(...)` bind the actual remap shader +and return `true, ambiguityCount` on success. Matching uses squared RGB distance +below `(3/255)^2`; unmatched pixels retain their RGB. Alpha and draw tint are +preserved. Compilation/upload failure returns false, retaining the previous +shader. A failed remap shader can recover when the graphics compiler changes. + +Public uniform uploads are unconditional. Nested `with`, `withRaw` and +`keyedWith` restore both shader identity and prior palette/remap uniforms, +including after a callback or partial upload fails. + +## Verification and remaining display work + +`tests/engine/gen2_palette_register.lua` checks all 256 bytes against independent +field decoding in Red, Gold, Silver and Crystal, native/public register ownership, +BG/OBJ remapping, collisions, forced ramps, bounds, uniform ownership and failures. +`tests/gpu/gen2_palette_register.lua` verifies actual pixels for all 256 bytes +through public and native shaders, including the optimized native upload path, +OBJ preservation, unrelated RGB, alpha and tint. Run `run()` from an isolated +LÖVE host separately on each backend. It requires no cartridge or private mod. + +This register change alone does not establish full Gen1 display parity. See +`NATIVE-GEN2-DISPLAY.md` for the native display-selection and cache/presentation +owners. Full unchanged-mod story/save/link acceptance and wider engine parity +remain separate requirements. + +The public method protocol follows the Gen1Recomp reference interface. Existing +Gen1Recomp and upstream credits and licences remain applicable; see `NOTICE.md`. diff --git a/docs/mod-naming-compat.md b/docs/mod-naming-compat.md new file mode 100644 index 000000000..27ec99978 --- /dev/null +++ b/docs/mod-naming-compat.md @@ -0,0 +1,23 @@ +# Gen1Recomp Gen-2 naming screen compatibility + +API-2 mods may open `mod.ui.push(game, "Gen2NamingScreen", opts)`, the public +screen ID used by Anytime Rename 1.2.1. The adapter uses Gen2Recomped's native +keyboard and supports `type` (player, rival, mom, box, nickname), `prompt`, +`maxLength`, `initial`, `mon`, `monName` and `onDone(name)`. + +The completion callback owns closing this screen. It runs while the naming +state is still on the stack; a caller may pop it and return to its existing +parent menu. The ordinary `NamingScreen` API remains different: it pops itself +before calling `onDone`. Mapping the ID directly to that class would pop two +states when an existing Gen1Recomp mod closes its own screen. + +`text` reflects the editable name and may also be assigned. Initial values and +assignments are limited by glyph count, preserving UTF-8 and charmap tokens. +The adapter keeps the native keyboard's input, drawing and `ui.naming.grid` +hook; it does not reproduce Gen1Recomp's separate Gen-2 keyboard layout or +change native game save fields. Mods should use the native naming screen for +their target host when they do not need this compatibility contract. + +Regression coverage: `tests/engine/gen2_mod_naming.lua`. External verification +uses the unchanged Anytime Rename 1.2.1 entry point and its 55-check suite; +its native-menu flow suite additionally runs six games in two languages. diff --git a/docs/native-gen2-battle-item-presentation.md b/docs/native-gen2-battle-item-presentation.md new file mode 100644 index 000000000..18dcd5c56 --- /dev/null +++ b/docs/native-gen2-battle-item-presentation.md @@ -0,0 +1,23 @@ +# Native Gen2 battle item presentation ownership + +BattlePack must close before constructing a party-item target. Leaving it +underneath the picker makes a resolved presentation see the PACK's boolean +battle flag where the native battle model should be. The Gen2 controller now +closes the actual PACK first and lends its real battle context to the picker. +Cancellation queues a new native PACK, restoring its session pocket/cursor +without consuming inventory, PP or a turn. + +Successful medicine and no-effect refusals now use the existing native battle +text queue. A standalone field TextBox has no battlefield composition owner +and produced a white backing over the decorated battle. Effects still run +through BagMenu's existing item-use dispatch; success delegates to itemUsed +once, while refusal queues text without a native enemy action. Other +cartridges keep their existing field-text path. + +The updated native PACK regression fails 35 assertions on the preceding +implementation and passes 191 after the change. Live native model, submenu, summary and FireRed +regressions also pass on this independently adapted upstream-PR composition +(976 checks together). These ROM-free source/input +checks do not establish unchanged-client GPU, full Gen1 visual/story parity +or final package acceptance. Native party-result animation/timing and broader +item-family presentation remain subject to reference acceptance. diff --git a/docs/native-gen2-battle-model.md b/docs/native-gen2-battle-model.md new file mode 100644 index 000000000..52ebe3856 --- /dev/null +++ b/docs/native-gen2-battle-model.md @@ -0,0 +1,16 @@ +# Live native Gen2 battle model + +A Gen2 screen exposes actual current Pokemon, parties, save, catalog and outcome +through a live model view rather than treating the shared battler-wrapper screen +as that model. HP presentation remains on the existing native drain owner. +Pokemon picture captures use their real native slots, source image, crop, status +and effects without replacing a live rear sprite with its isolated front view. + +Native model reader keys remain protected. Presentation consumers may attach and +release transient metadata on the view, without writing to commands or saves. + +Independent checks on this PR composition pass: 366 live-model/HP/sprite-capture, +84 submenu-owner, 111 PACK and 9 FireRed checks (570 total). The new model test +fails before integration. These are ROM-free tests; no unchanged-consumer GPU, +release, full-PR or complete Gen1 parity acceptance is claimed. Existing engine +and Gen1 rights and attribution are retained. diff --git a/docs/native-gen2-battle-window.md b/docs/native-gen2-battle-window.md new file mode 100644 index 000000000..305d82b4c --- /dev/null +++ b/docs/native-gen2-battle-window.md @@ -0,0 +1,24 @@ +# Native Gen2 scene/window owner + +Expose `drawWidescreen` and explicit Gen2-only compositor admission on the actual +shared battle class. This upstream adaptation also splits the existing Gen2 +classic/wide BG, objects and final-overlay composition into `drawPanel`, +`drawSceneBody` and `drawScene`; the older branch did not provide them. The +same native scene, chrome, layout and fixed/fill setting remain authoritative. +The graphics guard restores caller canvas/state even after leaked pushes. +Other editions retain the original classic/wide route. No mod or game data +is changed or included. + +The public contract was checked against Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/ui/gen2/BattleState.lua`. The extracted native composition retains the +existing BattleState/WideBattle provenance and applicable project rights. +The additional graphics guard is an independently owned Roxas2712 contribution +offered under the permission described in this PR. + +Source tests cover native window admission, multiple viewport/DPI sizes, +classic/wide scene ownership, fixed/fill options, caller-state restoration +and native menu/model/summary regressions. The separate fork's earlier +54 functional Metal checks and12 inspected captures diagnose a missing native +drawer and a white fallback on an older source; they do not qualify this +upstream composition, current visual parity, packages or a release. diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md new file mode 100644 index 000000000..8efd85550 --- /dev/null +++ b/docs/native-gen2-clock.md @@ -0,0 +1,74 @@ +# Shared native Gen2 RTC + +The Pokégear previously displayed elapsed play time while world palettes, +object time masks and script variables used the host clock. The native +`Gen2Clock` now provides the same saved RTC-base reads to these consumers, +and the native happiness evolution gate. +The existing `src.core.gen2.Clock` name resolves to this native module. +`world.hour()`, `world.minute()` and `world.weekday()` read that same save. + +`setTime(save, hour, minute)` anchors the chosen minute, current game +weekday and calendar date to elapsed RTC seconds. Hours, weekdays and daily +calendar identity cross the same in-game midnight, including after a saved +session has been closed for days. Automatic host DST and time-zone changes do +not add another hour to a clock the player already set. Explicit system clock +adjustments still affect the host RTC; this is not a monotonic stopwatch. + +The legacy `rtc.startMinute`, `rtc.startDay` and `g2DayOffset` projections are +retained for older readers. New writes also store `anchorTime`, `anchorMinute`, +`anchorDay` and `anchorDate` in the same `save.rtc` record. Consumers should use +the native setters/readers instead of mutating these projections directly. +`setWeekday` edits the current game weekday without changing elapsed calendar +age. Editing time reanchors the clock without resetting its current date. + +Old saves without the elapsed anchor retain their original host-hour and +weekday-offset semantics, including their old midnight relationship, until +the clock is explicitly set. Read calls never create or migrate clock data. +Invalid/non-finite writes are rejected. The normal native save codec retains +both formats; elapsed play time is separate. + +`calendarDate(save, now)` and `dateKey(save, now)` give native daily and weekly +services the calendar belonging to that RTC. Daily flags/fruit use and the +lottery now expire on game midnight and the selected game Friday instead of +an unrelated host-calendar boundary. The optional timestamp on clock reads +uses the same snapshot for minute, weekday and calendar queries. + +World palettes, object-load period bits and `VAR_HOUR` share the saved hour; +the normal object-load freeze still applies. An explicit palette-query hour +retains precedence. The existing `world.tod` hook remains a presentation +override and does not rewrite the saved RTC. A raw scene-clock query uses +the current native save, independently of a room's forced palette. +`PokegearMenu:clockParts()` exposes hour/minute/one-based UI weekday from +the live native world or shared saved clock. The built-in card and external +presenters use this same getter, retaining authored card-clock overrides. +This prevents an external presenter from showing its fallback zero clock +while the internal RTC and built-in text are already correct. +AUTO neighbours share the effective clock presentation while the entered +map also follows AUTO. From a pinned room/cave, an AUTO neighbour instead +uses the saved RTC; that room's unused world-TOD hook is not a neighbour +palette override. This keeps explicit native/external hour queries aligned. + +`clock.day_changed` is produced by actual weekday reads, including the live +world clock poll. It reports `day`, `previous` and `reason` (`rollover` or +`set`). A save's first observation is silent. Observations are per save, +reentrant-safe, subscription-aware and retired on mod/edition reload. An +observed offline whole week still produces one change, even when the weekday +number is the same; its calendar date is different. +Native API 1/2 use the existing event dispatch boundary. The native GSC +producer remains generation-scoped. + +The clock card, native script readers, evolution time gate and world methods +are exercised directly in synthetic engine tests, including minute wrap, +weekly rollover, save roundtrip, RTC-versus-play-time display, the native callback +path and actual mod loading. Graphics IO alone is replaced for the clock +card. These checks do not establish a complete original-story clock-setting +sequence, manual DST-setting/check dialogs, battery-backed SRAM RTC +conversion, all daily services or fresh GPU acceptance for this change. +Automatic host DST isolation is covered; the original story clock-setting +screen now has a native implementation described in +[native-gen2-init-clock.md](native-gen2-init-clock.md); fresh original-story/GPU +acceptance and the remaining manual DST lifecycle are separately gated. + +The public contract is compared with the read-only Gen1Recomp reference; +the implementation is native fork code. No VASC implementation, private +assets, game text or ROM-derived clock data is included. diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md new file mode 100644 index 000000000..702920fc3 --- /dev/null +++ b/docs/native-gen2-decorations.md @@ -0,0 +1,127 @@ +# Native GSC bedroom decorations + +The engine imports decoration attributes, display names, default placed bytes, +gift ordinals and room-description entry points from the user's current +Gold/Silver/Crystal cartridge. No retail attribute catalogue, dialogue or room +art is bundled. The virtual `decorations` registry writes the same DECO-byte +records that native room callbacks and the player's bedroom PC consume. + +```lua +local decorations = mod.content.decorations +local available, reason = decorations:supports() +if available then + decorations:patch("deco:2", { name = "CUSTOM" }) +end +``` + +Native API 1/2 use `deco:0` through `deco:255`. The number is +the cartridge's DECO byte, not a sorted list index. Records contain `type` +(1..6), `name`, optional `action`, `flag` (native event word) and `sprite` +(native object-sprite or map-block byte). Only the fourteen known +`SET_UP_`/`PUT_AWAY_` actions are accepted. Cancel and put-away rows do not grant +ownership. API views are detached; invalid/failed mod entries roll back and +reload retires owned writes individually while retaining external edits. +Removing a record removes both numeric and string aliases of its byte. + +The importer derives the attribute count from the adjacent native names-table +boundary and reads six bytes per row. Invalid/truncated attributes or names +publish no partial table. Species-backed names keep their native species ID, +so later species renames remain visible unless the decoration itself is +renamed. Native initialization is recognized only when the actual instruction +operands store valid bed/poster IDs to the corresponding WRAM symbols. Missing +initialization does not invent retail defaults. Existing placed state, +including an explicitly empty table, is retained. + +The house-PC option appears only with an imported native table. Owned records +form the native categories; their put-away headers and cancel choice follow +the records, with eight visible rows and the native right/left/cancel order +for ornaments. Feedback and side prompts enter the normal ROM text importer +and `TextBox` path. A replacement uses buffers three/four for old/new names. +State changes commit after feedback closes. Moving an ornament clears an +opposite duplicate; selecting the same ornament does not mutate the opposite +side. Ordinary item-storage PCs do not gain this option. + +`PlayersHousePC` waits until LOG OFF/B and returns the accumulated change bit. +The exact GSC `warp NONE` pair reloads the current map after the script ends, +without relocating the player to cell zero. Unknown map pairs are not reloads. +The existing native callback queue then applies placed object visibility, +variable sprites and room blocks. Description bytecode branches into the +actual imported poster/ornament/big-doll scripts rather than being skipped. +Block zero is valid for a nonzero DECO byte; carpet offsets wrap as native +bytes. An empty slot writes no block, leaving the newly loaded bare room. + +Raw SRAM conversion reads/writes the eight placed bytes using the selected +edition's WRAM-to-SRAM layout. The physical order ends with the big doll and +is distinct from the public slot list. Absent legacy decoration state leaves +the imported template intact; invalid placed bytes fail instead of wrapping. +Unreviewed hack layouts receive neither guessed retail tables nor guessed +save offsets. + +## Legacy module contract + +`require("src.core.gen2.Decorations")` resolves to a signature adapter, not +directly to `src.world.Gen2Decorations`. It exposes `state(save)`, +`attributes(id)`, `name(id, monName)`, `idFor(id)`, `idForFlag(ordinal)`, +`registerInto(registry, data, owner)`, ownership/gift methods, +`ownedCategories(events)`, `rows(events, category)`, `apply(state, id, side, +monName)`, `clearOtherSide`, `visibility`, `tiles` and `posterVisible`. +The action/slot/description constants retain the legacy shapes. `ATTRIBUTES`, +`CATEGORIES` and `NOTHING_TO_CHOOSE` are read projections of the current +native dataset. Attribute rows remain the actual native records; registering +them into another registry passes copies. + +Data initialization and Loader entry bind the current native dataset. Reads +follow subsequent registry folding and rollback. Teardown or an edition +change retires the binding. Before a complete import, the facade returns no +catalogue/defaults instead of fabricating retail content. Removed records do +not fall back to a second catalogue. Both numeric bytes and numeric-string +bytes resolve and placements store numbers. + +Ownership accepts the legacy numeric `events:get/set` protocol or a native +save directly. `events(save)` supplies a numeric view backed by the same +canonical flags used by scripts and SRAM, with no parallel bitfield. That +view expires on rebinding/unload. Cancel/header rows cannot grant event zero. +Categories are rebuilt from native action identities; added rows participate +and removed headers are not invented. Existing empty placement is preserved. + +`apply` follows the legacy two-result contract `(changed, pages)` and does +not check ownership; the menu owns that check. Ornament side cancellation +does nothing, and `clearOtherSide` remains a separate operation for these +callers. The pages use the actual imported feedback text and buffers 3/4, +including authored replacements and explicit script-text removal. The native +PC and facade share placement planning and tile/object layout routines; the +PC still commits after feedback closes. A valid block zero and wrapped +carpet offsets retain native behavior rather than the old facade's filter. +This adapter does not turn a detached external event bitfield into the live +world, nor does wrapping a facade method patch every native module method. + +## Evidence and remaining scope + +ROM-free tests exercise actual importers, Loader, native script compiler and +runner, menus, TextBox, StateStack, ownership flags, callback commands and save +codec. The eight save offsets are independently checked against each shipped +GSC symbol manifest. Test cartridges and dialogue are authored synthetic data. +The callback/warp tests use controlled overworld presentation/queue boundaries; +they are not GPU-render or complete-game acceptance tests. + +A separate private read-only Crystal probe independently compares all 53 +attribute rows' numeric bytes, imports 45 gift ordinals and the native defaults, +executes the original house-PC/description scripts and room callbacks, and +round-trips all eight placed bytes. Original ROM hashes are checked unchanged. +The local font/audio/render harness is used; no visual acceptance is inferred. + +The public-module tests load a synthetic mod through the actual Loader, +change and remove records, grant ownership, place decorations and execute +the native room callback VM. They verify feedback pages, byte identity, +retirement on reload/edition changes, and rollback without a second catalogue. + +Mom’s native shopping caller is covered separately in [native-gen2-mom-shopping.md](native-gen2-mom-shopping.md). +The remaining gift lifecycle, legacy MomShopping/phone contracts, +new graphical acceptance and a full unchanged VASC playthrough remain separate +open work. Importing the gift-ordinal table alone does not implement those +callers. The existing preview is not refreshed by this source change. + +Contract references: [Crystal decoration routines](https://github.com/pret/pokecrystal/blob/master/engine/overworld/decorations.asm), +[Gold bedroom script](https://github.com/pret/pokegold/blob/master/maps/PlayersHouse2F.asm). +Original fork additions: [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/docs/native-gen2-init-clock.md b/docs/native-gen2-init-clock.md new file mode 100644 index 000000000..058717d8a --- /dev/null +++ b/docs/native-gen2-init-clock.md @@ -0,0 +1,37 @@ +# Native Gen2 initial clock and weekday selection + +Gold, Silver and Crystal now open their native Oak speech with an +`init_clock` step. This opens `Gen2InitClock` before the professor's welcome +and music. The required hour and minute pickers wrap in both directions, +retain their selection after NO, read the complete time back, and commit to +the shared saved RTC only after the final acknowledgement. Completion resumes +the original Oak parent once. Other Gen2 introductions keep their existing +step list. + +The native `SetDayOfWeek` special uses the same screen in day mode. It opens +on Sunday and remains suspended through selection and confirmation. NO +returns to the chosen value without writing the save or resuming the script; +YES writes through `Gen2Clock.setWeekday` and resumes the original runner once. + +`src.ui.gen2.InitClock` resolves to this actual native screen. Its constructor +accepts mode, save, hour/minute/day, onDone and autoConfirm options. Common +hour/time/response formatting helpers use the native clock labels. Native +TextBox and ChoiceBox own text paging, typing and YES/NO input; the clock +screen owns picker state, transitions, save commitment and optional fades. + +Dialogue is read from the active imported native text bank. Native wrapper +and underscore text spellings are supported; short engine prompts are used +only if an older cache lacks a line. No ROM dialogue, artwork, palette or +coordinate table is embedded in the implementation. The current panel layout +is engine-owned. This is not a claim of exact reference raster/layout helper +parity; the remaining reference drawing/metadata helpers and original clock +art require separate work. + +ROM-free coverage exercises the actual native widgets and stack, wrapped +selection and NO retries, final acknowledgement, save-codec retention, the +real special compiler/ScriptRunner yield/resume, the real Oak step and loaded +native API1/2 clients. These tests do not establish fresh +GPU or complete natural new-game/story acceptance. Manual DST dialogs/checks, +SRAM RTC conversion, broader story/hardware acceptance and full VASC/Gen1 +parity remain open. Private VASC code and protected Gen1/player instances are +unchanged. diff --git a/docs/native-gen2-mom-shopping.md b/docs/native-gen2-mom-shopping.md new file mode 100644 index 000000000..9a3277427 --- /dev/null +++ b/docs/native-gen2-mom-shopping.md @@ -0,0 +1,87 @@ +# Native GSC Mom accounts and purchases + +The existing native banking screen changed a separate saving boolean, while +trainer rewards went entirely to the wallet. The cartridge's shopping tables +and notification scripts were not imported. This connects the banking choice, +native flags, trainer reward accounts, ROM purchase rows, original phone +programs, decoration ownership and raw-save state. + +## Cartridge data and account state + +`map_scripts.momShopping` contains the user's cartridge's random and ladder +lists. Each row is eight bytes: big-endian three-byte trigger and price, then +kind and item/DECO bytes. End symbols determine the count. The random threshold +increment is read from the native instruction operands that populate +`hMoneyTemp`; no retail catalogue, purchase dialogue or guessed hack layout is +bundled. Both original item/doll phone programs enter the shared script/text +importer. Invalid or incomplete bindings publish no partial lists and do not +partially enqueue programs. The new GSC cache marker requires reimport. + +The native account fields are `g2MomMoney`, `g2MomSavingMode` and +`g2MomBankOpen`, alongside the existing boolean `g2MomSaving`. Saving bit zero +and the active bit agree with the same engine flags original scripts read. +The bank UI uses those shared fields; an imported active account is recognized +instead of silently opening a new account. Explicit start/stop choices select +SOME/none. Imported HALF/ALL settings retain their native low-bit meanings. + +Trainer rewards deal four quarters between the accounts. SOME sends one, +HALF two and ALL four to Mom; an already maxed Mom account sends everything to +the wallet. Each account saturates independently, so near-cap overflow is not +redirected. Held-item reward modification happens before this split. Original +reward-text entry points select the current mode and receive the real player +name and full prize total. Other generations and unreviewed hacks retain their +existing reward paths, including Prism's separate bank. + +## Purchase and phone lifecycle + +`g2MomShopping` retains the native ladder index, selected random-row byte and +three-byte trigger balance. The current ladder row has priority whenever its +trigger is met. Otherwise the threshold advances to/past the balance and only +an exact landing selects a random row; that threshold is consumed even when a +full PC refuses delivery. A random purchase does not advance the ladder. + +Items resolve by the current dataset's native index and enter the existing PC +store with the native stack/capacity rule. Dolls set the actual imported DECO +ownership event; they do not place themselves in the room. Successful delivery +precedes deduction and ladder advancement. No reception or an occupied native +reentry queue prevents a purchase before selection state changes. Missing +original phone programs do not grant an unannounced purchase. + +The common native trainer-return path attempts the purchase once. Its original +phone program waits behind the trainer's queued post-battle script and UI +overlays, then enters the existing native FIFO with Mom's current contact ID. +Caller state, ring/hang-up audio, and receive-delay reset use the existing native call path. +Where the host exposes an incoming-call notification seam, it is also used. Repeated battle +callbacks cannot duplicate the purchase, and a pending call from another +save/dataset cannot execute in the current session. Link and Battle Tower +returns do not trigger shopping. + +Raw SRAM conversion uses each GSC edition's actual WRAM-derived account, +mode, ladder, row-selection and threshold offsets. The account/threshold use +big-endian triples; surrounding mode bits and absent legacy template values +are retained. Unsupported layouts receive no guessed new offsets. + +## Verification and limits + +ROM-free tests exercise actual table/instruction readers, banking VM/UI, +trainer battle win, native world return, script FIFO, flags, capacity refusal, +threshold selection and SRAM conversion. Save addresses are independently +compared with the shipped GSC manifests. Synthetic cartridges, texts and +records are authored fixtures rather than retail data. + +A private read-only Crystal probe independently compares every numeric byte +of both shopping lists, executes all ten original ladder phone notifications, +checks actual item and DECO ownership delivery, resolves native reward slots +and round-trips the account/progress fields. It uses controlled dataset and +font/render/audio IO, not a GPU acceptance or full natural-story playthrough. +Original ROM hashes are checked unchanged. No ROM/private mod/save data is +included in the contribution. + +The legacy `src.core.gen2.MomShopping` facade, full legacy phone parity, +new GPU acceptance and a complete unchanged VASC playthrough remain open. +This source change does not refresh public preview assets. + +Contract references: [native purchase routine](https://github.com/pret/pokecrystal/blob/master/engine/events/mom_phone.asm), +[native trainer reward split](https://github.com/pret/pokecrystal/blob/master/engine/battle/core.asm). +Original additions use the [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/docs/native-gen2-namepick.md b/docs/native-gen2-namepick.md new file mode 100644 index 000000000..2c56bdebb --- /dev/null +++ b/docs/native-gen2-namepick.md @@ -0,0 +1,28 @@ +# Native Gen2 player-name choice + +The player-name step in Gen2 Oak speech now opens `Gen2NamePick`, rather than +using the generic preset menu attached to the naming keyboard. Its original +player portrait moves from tile column 6 to 13, one tile per update, before +input is admitted. A preset moves the portrait back before the original Oak +step resumes. B cannot dismiss this required choice. The class and tile layout +follow Gen1Recomp's separate Gen2 NamePick owner; original source provenance is +retained in the implementation. + +NEW NAME opens the existing native Gen2 keyboard through `Screens.push`, so +screen registry overrides still apply. It retains the actual gender and field +length without turning an eight-slot player field into box entry. START selects +END and A confirms; empty input keeps the actual supplied preset. Completion is +once-only, and a keyboard whose original caller was removed cannot commit to it. +The public picker constructor leaves dismissal to its caller. Native Oak owns +its picker dismissal, player-name write and original continuation. + +Portraits and form-specific names use the engine's actual imported player data. +The class spelling `src.ui.gen2.NamePick` addresses this same native owner. +Ordinary non-Gen2 and rival naming retain their existing route. Screens with no +presets can still open the keyboard directly. No mod-specific name flow is added. + +ROM-free tests cover input blocking during both directions of movement, input +wrap, B rejection, native keyboard/empty-name ownership and actual Oak dispatch +for Gold, Silver and Crystal. Exact-source GPU acceptance of the new picker, +ROM naming-program/menu-art import, remaining raster/header details and a full +unmodified-mod story/save/link playthrough are separate unfinished work. diff --git a/docs/native-gen2-naming.md b/docs/native-gen2-naming.md new file mode 100644 index 000000000..fe8ca6192 --- /dev/null +++ b/docs/native-gen2-naming.md @@ -0,0 +1,48 @@ +# Native Gen2 naming owner + +`Gen2NamingScreen` now owns Gen2 entry state, input and presentation rather +than adapting the Gen1 keyboard. The zero-based cursor addresses four letter +rows (five for a box) and a bottom row of CASE, DEL and END targets. SELECT +changes case, B deletes a complete font glyph, START selects END, and A on END +accepts. Filling the field also selects END and waits for confirmation. Spaces +are actual cells. Unicode and charmap ligatures occupy complete name slots. + +The public constructor preserves caller-owned dismissal. The ordinary +`NamingScreen` constructor delegates to this same native owner on Gen2 while +preserving its existing pop-before-callback convention. Native scripts, +catches and Oak therefore use the real Gen2 model. Other generations retain +their existing constructors. Screen registry overrides remain authoritative. +The `src.ui.gen2.NamingScreen` class spelling resolves to this owner, so external +presenters can observe and draw its actual rows, cursor, text and field size. + +Name presets temporarily make the naming screen transparent and suppress its +keyboard until NEW NAME is chosen; the original parent remains underneath. +Suppression belongs to the active instance, so class-level external presenters +cannot draw the inactive keyboard through their wrappers. NEW NAME restores +the original instance/class presentation methods. +Native imported sprite and optional menu-art records can supply the header, +backdrop and retained cursor quads. Mail entry keeps both sixteen-glyph rows +above its keyboard. Native Oak and rival/name-rater calls carry their actual +player/rival/Pokemon role instead of inferring it from a field length. Default board data is an engine fallback; a `data.gen2Naming` record +can supply normal/box upper/lower rows. `ui.naming.grid` retains its public +context and can replace those rows. + +Font aliases for ``, `` and `` resolve only to sequences actually +present in the active font. They neither assume retail glyph ordinals nor +replace explicitly authored aliases. Measurements, editing and drawing use +that same charmap. + +ROM-free tests cover actual stack/menu input, name types and glyph limits, +caller/native completion ownership, preset underlay suppression, Gen1 +retention and actual Loader clients through native API1/2. +GPU acceptance and full reference raster/helper parity remain separate: +ROM naming-program/menu-art extraction and exact cursor/header rendering are +still open. The separate native portrait selector is described in +`native-gen2-namepick.md`; its exact-source GPU acceptance remains open. This change does not +establish complete VASC, naming-story, save/link or full Gen1 parity. + +This offer includes the shared Gen2 menu chrome already offered in PR #224 +and indexed palette drawer from PR #202 so its native screen can run without +either branch having merged. Existing Gen1 provenance and license notices are +preserved. Original contributions are offered under the specific permission at +https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. diff --git a/docs/native-gen2-palette-context.md b/docs/native-gen2-palette-context.md new file mode 100644 index 000000000..bda03ba07 --- /dev/null +++ b/docs/native-gen2-palette-context.md @@ -0,0 +1,54 @@ +# Native Gen2 map palette context + +Native GSC maps store the header's palette nibble in `mapPalette`. External +renderers also read the symbolic `def.palette`, `world.daytime` and +`world.flashUsed` contracts. These read views now expose native facts without +seeding another clock, Flash bit or palette catalogue. + +Map palette names are computed from the current byte. Explicit authored +values and existing record metatables are preserved. Unused native header +slots 5..7 follow AUTO; invalid/missing bytes do not invent a named palette. +The public world's effective daytime reads the actual palette bake state, +including darkness; Flash reads the actual `save.flashLit` field written by +the native script command. Explicit authored Lua fields retain precedence. +This does not implement the separate saved RTC-base protocol or every legacy +world mutation method. + +`PaletteFX.daytimeFor(def, hour, flashUsed)` resolves a known map from its own +header. A neighbouring room stays DAY while the current cave is dark, and an +unentered DARK cave stays dark until Flash is supplied. Metadata-free old +queries retain their prior live-state fallback. The native renderer uses +`gen2RowFor` to distinguish the entered map's actual presentation from a +neighbour's own header and the active clock/Flash presentation state. Native +map entry and Flash update that state; atlas cache keys include the selected +row. Animated overdraw receives the same selected palette as the static bake. +An omitted hour shares the entered world's `world.tod` selection when +that scene follows AUTO. From a pinned room/cave, AUTO neighbours instead +read the saved RTC. Outside an entered context the query reads that RTC +without inheriting a map presentation override. An explicit hour remains authoritative. Mod load/unload and edition +changes retire the entered context, so returning to the launcher cannot +revive a previous world's clock selection. + +`PaletteFX.bgSet` accepts the full native dataset or its published +`gen2Palettes` view. The latter carries a private association with its owner, +so imported special-map rules and roof pairs remain available. A superseded +view cannot combine old tileset rows with new metadata. Colourless caches and +DMG mode still decline colouring. + +Special-map rule ordering and roof selection are shared with `TileRenderer`. +The first matching special rule owns the decision even if its set is missing; +darkness skips specials. Roof replacement copies only the row list and two +middle roof colours, retaining endpoints and the source tileset. Live metadata +edits are observed through the normal native invalidation/rebuild lifecycle. +No ROM colours, dialogue, source art or private mod implementation is bundled. + +Synthetic tests exercise the actual Loader, public module calls, native Flash +writer and `TileRenderer` CPU atlas bake. Owned in-memory pixel/image adapters +replace graphics IO, and baked RGB samples are compared with the public query +for AUTO, forced rows, dark caves, special maps and roofs. These are not GPU +or complete unchanged-VASC gameplay acceptance tests. General legacy +`map.palette` row-hook semantics and saved RTC integration remain separate +parity work; the existing SGB palette-name hook is preserved. + +Original native selection routines retain Cedric's rights; additional +integration is covered by [the Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-party-item-results.md b/docs/native-gen2-party-item-results.md new file mode 100644 index 000000000..5ce4ba63d --- /dev/null +++ b/docs/native-gen2-party-item-results.md @@ -0,0 +1,36 @@ +# Native Gen2 party item results + +Gen2 party-target items keep the actual PartyMenu above the battle through +HP fill and result confirmation. The effect and inventory mutation still run +once in ItemEffects. Only after native A/B acknowledgment does the same battle +controller queue its normal enemy action and end-of-turn effects. + +The native result surface follows Gen1Recomp 535a5ec0 PartyMenu/HpBar: +`showItemResult`, `itemResultClimbing`, `shownHpFor`, and `updateItemResult`. +HP display walks one point per tick below 48 maximum HP, otherwise +`ceil(maxHp / 48)`, then holds confirmation for 50 ticks. Native row rendering +reads that display value without changing the real party mon. Status and PP +results use the same owner with no HP animation. Longer shared-engine text +continues on another native two-row page instead of silently dropping its tail. +The item effect already plays the imported heal cue; result presentation does +not replay it. + +The shared `heal` hold refers to the same result object, so existing party +navigation cannot advance a second input/animation clock. Once the party result +finishes, the active battler's displayed HP catches up before battle drawing +resumes. The normal low-health alarm can then reevaluate the actual HP. + +A controller-owned post-draw pass preserves the result text when a consumer +replaces the party view. It runs after that view, before later modal children, +in classic, expanded native and window-resolution rendering. The graphics +boundary restores caller state even if a replaceable view leaks a push or +changes canvas. A consumer drawing HP directly from `mon.hp` still displays +that real final value; the engine does not rewrite gameplay HP to animate an +independently authored view. Such a view can use `shownHpFor` for native timing. + +Targeted contracts cover actual PACK/target/move-selection input, Potion, +status cure, revive and PP restoration; native timing, cancellation, no-effect +refusal, one item and one enemy action; render ordering and error restoration. +Local source-frozen execution and unchanged-client GPU qualification must be +recorded separately. This document does not claim release or full parity +acceptance from the presence of these tests. diff --git a/docs/native-gen2-pc-mail-ui.md b/docs/native-gen2-pc-mail-ui.md new file mode 100644 index 000000000..ac918f1c0 --- /dev/null +++ b/docs/native-gen2-pc-mail-ui.md @@ -0,0 +1,33 @@ +# Native GSC PC and mail menus + +This is the menu layer for the native PC/mail core offered in PR #264. +It also uses the caller-owned native Gen2 keyboard adapter offered in +PR #135. Those prerequisite changes are included in this branch for an +executable comparison; the new menu commit is separate from them. + +Native bag/party actions open the 32-character, two-line composition +keyboard. Cancelling consumes no stationery; acceptance commits the real +inventory exchange and party-slot letter together. Existing held mail +opens READ/TAKE choices; message destruction requires a default-No choice. +The player PC opens the real mailbox, which attaches the selected letter +through the native party picker and keeps dense row identities after removal. + +Bill's PC uses native GSC deposit/withdraw/release guards and exposes its +actual save for existing Gen2 host adapters. Legacy callers owning dismissal +receive their close callback while the PC is still on the stack; native +callers retain pop-before-callback behavior. Alternate-save menus do not +write the live profile. Summary/dex entry IDs route to existing native screens. +Party reorders move slot-indexed letters alongside their carriers. Native +quarantine UI reports retained/recovered letters instead of hiding repairs. + +Focused comparison: 208 core/codec checks, 69 bytecode/runner/SDK checks, +61 naming-adapter checks and 50 menu/inventory/codec/SDK checks pass. The new +menu suite drives actual production keyboard/menu input, failed/stale/cancelled +transactions, mailbox compaction, player-PC admission, caller-owned PC closing, +alternate saves and missing-carrier recovery. Synthetic fixtures only. + +The fork separately has source-bound unchanged-Johto/Crystal GPU evidence. +That evidence does not qualify this different upstream composition. Full +stationery art, SRAM mail conversion, every legacy PC mode, complete VASC +compatibility and real hardware/story acceptance are not claimed here. +No private mod code/assets, cartridge bytes, generated art or saves are included. diff --git a/docs/native-gen2-pc-mail.md b/docs/native-gen2-pc-mail.md new file mode 100644 index 000000000..e47827cda --- /dev/null +++ b/docs/native-gen2-pc-mail.md @@ -0,0 +1,27 @@ +# Native GSC PC/mail core + +The legacy Gen2 Mail module is absent and the Boxes alias has an incompatible +count contract. Native gift/delivery scripts are stubs; Gold/Silver mail +operands consume three bytes instead of two. + +Add native slot-indexed party letters, ten-entry mailbox operations and a +14x20 PC facade over the existing save tables. Mail follows team removal +and reordering APIs, held letters block deposit/ordinary item replacement, +and last-healthy restrictions use the actual native HP/stats. Save +validation retains detached letters from quarantined carriers. + +Raw ROM mail is decoded separately from dialogue and queued scripts. The +actual ScriptRunner gives letters and yields to the real party menu for all +five delivery outcomes. mail.read emits once per opening; mail.written +identifies compose/script. Item recognition uses imported semantic keys. +The primary pointer contracts are in pret/pokegold and pret/pokecrystal, +engine/overworld/scripting.asm (Script_givepokemail/Script_checkpokemail). + +Validation: 208 PC/mail/codec checks and 69 binary ROM/actual runner/public +SDK checks pass. The current upstream baseline also has existing suite +failures; comparative engine/SDK results are recorded in the PR. + +This standalone core contribution does not import the fork's separate UI, +capability facade, qualified runtime or packaged acceptance. Composition, +mailbox screens, SRAM conversion, stationery artwork and complete parity +are not claimed here. No private mod, ROM or generated artwork is included. diff --git a/docs/native-gen2-pokemon-palettes.md b/docs/native-gen2-pokemon-palettes.md new file mode 100644 index 000000000..d6537cc18 --- /dev/null +++ b/docs/native-gen2-pokemon-palettes.md @@ -0,0 +1,50 @@ +# Native Gen2 Pokemon palette drawing + +`src.world.gen2.Palettes.monColors(data, species, shiny)` reads the existing +native Pokemon/palette records. It accepts a full native dataset, its current +published `gen2Palettes` view, or reference palette-only normal/shiny pairs. +Native species keys resolve to the same native records. Existing dataset +aliases are honoured; this patch does not install another species catalogue. Four detached 0–255 RGB triples are returned; missing or +invalid colours return `nil`. Retired dataset views cannot revive old data. +The built-in `PaletteFX.monPal` uses the same native selection without copying +its source palette. Transformed Pokemon retain their existing precedence. + +`src.render.GbcPalette` now resolves to `src.render.Gen2PaletteDraw`, rather +than the unrelated LCD post-process `src.render.GBCFX`. Its indexed-image +shader recovers the four source grayscale shades and substitutes the supplied +palette while preserving texture alpha and drawing tint. `use`, `with`, +`useKeyed`, `keyedWith`, `useRaw`, `withRaw`, `shader`, `keyedShader`, +`available`, `resolve`, `color` and `clear` are provided. Keyed drawing makes +source shade zero transparent. Normal drawing resolves native `PaletteFX` +display/register state; raw drawing bypasses that resolution. +The public `mode` read follows that native resolution, so external evolution +presenters can preserve already coloured art instead of passing it through a +grayscale-index shader. It does not create another display option. + +Scoped drawing restores both the previous shader object and managed palette +uniforms, including nested calls, callback failures and partial upload errors. +Public calls upload all four rows because callers can modify exposed shader +objects directly. No private renderer or ROM-derived colour catalogue is +embedded in the implementation. + +## Validation and remaining work + +ROM-free suites cover public Loader consumers, Gold/Silver/Crystal species +queries, native source identity, shiny/normal/transformed precedence, authored +changes, detached results, retired views, invalid values and graphics failure +paths. The GPU probe `tests/gpu/gen2_palette_draw.lua` compares actual canvas +pixels for every shade, alpha, tint, keying, native register remap, nested +success/error and foreign shader restoration on each tested backend. + +Real-ROM validation must additionally compare the native palette producer and +public queries with an independent decoder, using actual imported Pokemon +metadata. Those private inputs and outputs are excluded from the repository. +A successful pixel probe is not proof of every unchanged mod presentation or +full gameplay acceptance. + +The complete reference `GbcPalette` API is not yet implemented: its separate +mode/custom-ramp controls and backwards remapping of already coloured frames +still need integration with native display state. The LCD effects API remains +separate. Full colour art, UI layout, original story admission, broader hardware +and complete Gen1/VASC parity remain independent acceptance work. No VASC code +or assets are changed by this feature. diff --git a/docs/native-gen2-pp-selection.md b/docs/native-gen2-pp-selection.md new file mode 100644 index 000000000..d990c71de --- /dev/null +++ b/docs/native-gen2-pp-selection.md @@ -0,0 +1,40 @@ +# Native Gen2 PP selection and maximum + +The Gen2 party-target ETHER, MAX ETHER, MYSTERYBERRY and PP UP route uses the native `Gen2MoveDeleter` screen from +Gen1Recomp 535a5ec0. It retains the actual party mon and moves. The ordinary +PP cursor stops at both ends; the native forget layout wraps. Only the +caller's normal choose/cancel continuation controls stack lifetime. The +move-list window preserves the party underneath, and B drops only that list. +Field PP selection also keeps its party parent until its existing result +flow closes it. No consumer files need changing. + +`Gen2PP` owns Gen2's `ComputeMaxPP` arithmetic. Each PP Up adds +`min(floor(basePP / 5), 7)`, so a move with 40 base PP and three PP Ups has +61 maximum PP. Legacy slots with a valid stored `maxPp` infer their existing +bonus without a mutation during reads. PP Up refuses Sketch and a fourth +bonus. Shared maximum readers accept an explicit destination generation for +cross-game reconstruction, while other generations retain their existing +maximum rule. + +Item restoration, PP Up, party healing, daycare retrieval, classic/wide battle +labels, summary, held-item restoration and link reconstruction use the common +maximum. Fork-only public records and Bank modules are not present in this +upstream branch; this adaptation does not invent or claim those interfaces. + +Fresh automatic settings select the finite-enemy-PP Gen2 compatibility rule. +An explicit saved ruleset remains effective, including an intentional Gen1 +unlimited-PP choice. The options row can return to AUTO without reserving a +string id from mods. Only the enemy PP policy differs from the previous Gen2 +host rule fields; complete Gen2 battle-rule parity remains unproven. + +The native move-list and forget-list layouts preserve reference provenance. +Sound uses the existing imported cue and actual Sound.play service; the +reference's unavailable Sound.resolve is not introduced. + +Contracts exercise the real screen registry, native input, bounds and forget +wrapping, cartridge drawing coordinates, callback/cue lifetime and immutable +party reads; actual item effects, native maxima/legacy counters, healing, +link clamping. The battle PACK suite additionally +requires its real ETHER route to open this controller. Qualification results, +including baseline failures and unchanged-client GPU acceptance, must be +recorded separately; the presence of contracts does not prove full parity. diff --git a/docs/native-gen2-queue-screen.md b/docs/native-gen2-queue-screen.md new file mode 100644 index 000000000..f5497bffe --- /dev/null +++ b/docs/native-gen2-queue-screen.md @@ -0,0 +1,19 @@ +# Native Gen2 resolution/message screen contract + +Gen2 queue writers publish `resolving`, retaining `messages` for other editions. +The native queue accepts earlier Gen2 records and publishes its actual current +text through `message` and its manual acknowledgment hold through `messageTimer`. +The existing typer, prompt delays, choice callback and queue remain authoritative. +Decision boundaries clear the text; classic/wide composition and link completion +recognize the same queue predicate. No mod or game data is included. + +The screen contract was checked against Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/ui/gen2/BattleState.lua`. Existing project rights and notices remain applicable. + +`tests/engine/gen2_battle_queue_view.lua` exercises actual native refusal, +manual text advancement, legacy queued children and delayed YES/NO ownership +across five Gen2 editions, with Red/FireRed/Emerald regressions. This independent +upstream adaptation uses only its available native modules. It does not imply +a new client/GPU check, whole-PR acceptance or complete event/page/animation API +parity. The separately observed white view still needs causal GPU diagnosis. diff --git a/docs/native-gen2-save-menu.md b/docs/native-gen2-save-menu.md new file mode 100644 index 000000000..a55e8614a --- /dev/null +++ b/docs/native-gen2-save-menu.md @@ -0,0 +1,42 @@ +# Native Gen2 START and save dialogue + +Gen2 START has its own native `Gen2StartMenu` class. Its public +`src.ui.gen2.StartMenu` spelling identifies the actual field-menu owner. +It uses the cartridge row IDs, scrolling list, descriptions and quit prompts, +retains the same START instance below native submenus, and remembers the +cursor in the save. Gen1 keeps its existing generic menu path. The START +controller and reusable typewriter are adapted from the read-only reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`; existing rights remain applicable. + +START opens `Gen2SaveMenu`, an actual state over the native text +printer and save pipeline. The public `src.ui.gen2.SaveMenu` spelling names +this same owner. Screen registry overrides retain authority over the native +`Gen2SaveMenu` entry. + +An existing active save slot requires a second overwrite answer. B/NO cannot +write. The timed saving message uses MID text cadence without held-button +acceleration; the write follows sixteen post-text logic frames. The saved +message follows another thirty-two frames and retains a thirty-frame tail, +waiting for its returned save sound with a bounded host failure escape. +Completion and writing are each guarded against duplicate calls. + +The default operation calls `Game:writeSave`, retaining save-write vetoes, +live overworld capture, metadata capture, mod notifications and the +existing safe serializer. Explicit alternate snapshots use `SaveData.save`; +an injected writer receives the selected snapshot. Failed or throwing writes +cannot produce a successful completion message. Slot detection uses the +same active-slot lookup as native saving; an indeterminate lookup asks for +overwrite confirmation. The panel counts native badge and owned-Dex records. + +ROM-free integration tests exercise Gold/Silver/Crystal through actual stack, +text printer, START input, screen resolution and the native Game writer, +including refusal, overwrite, fixed timing, duplicate suppression, writer +failure and a real save-hook veto. Gen1 retains its existing dialogue path. +This upstream branch has ROM-free coverage only. Real-cartridge save/reload, +unchanged-mod menu presentation, exact reference raster/typewriter helper +coverage and full story/save/link parity remain unverified. Supplying native +START does not assert that other PC/menu owner contracts are complete. +This state does not change save-format or link-protocol numbers. + +Original contributions by Roxas2712; existing upstream and third-party rights +and credits remain applicable. See NOTICE.md and LICENSE-ROXAS2712.md. diff --git a/docs/native-gen2-summary-contract.md b/docs/native-gen2-summary-contract.md new file mode 100644 index 000000000..af2b95b5e --- /dev/null +++ b/docs/native-gen2-summary-contract.md @@ -0,0 +1,37 @@ +# Native Gen2 summary and move-screen ownership + +The Gen2 summary alias now accepts native `party`/`index` options as well as +an isolated `mon` and the existing positional shared constructor. It resolves +the actual Pokemon and native catalogs instead of treating an options table +as a Pokemon. Party navigation refreshes pictures and native identity in +place, preserving installed draw methods, surface ownership and callbacks. + +Native status pages retain directional wrapping; A closes the final blue +page. SELECT on the green page opens the real move manager. Moving a slot +reorders the existing move record, preserving PP and PP Ups; cancellation, +egg skipping and close continuations retain their native owners. Species, +move, held-item, type, OT and growth queries read the actual engine data. +The move-detail frame uses native border glyphs rather than requiring Unicode +box characters in the imported text font. No consumer or mod patch is used. + +The ROM-free `tests/engine/gen2_summary_contract.lua` regression fails on the +preceding implementation and passes 326 checks across five Gen2 editions, +with shared Red/FireRed/Emerald navigation regressions. Together with the +existing live-model, party and FireRed suites, 807 checks pass on this +independently adapted upstream-PR composition. These are source/input +checks with synthetic fixtures, not full-client, Windows or release acceptance. + +A separate fork-only unchanged-client Metal smoke reached the Team and status/move pages, +then exposed an unavailable reference-engine sound resolver during the first +actual move swap. That candidate is rejected. This fork's importer already +installs the native sound label, so the adapted swap now uses this branch’s existing Sound.play +path directly. The regression includes a present imported cue even with no +audio device and fails on the preceding candidate. Visible swap/close/Potion +acceptance for the correction is still pending; the earlier visible pages do +not qualify that complete workflow or the new package. + +Layout/input provenance: Gen1Recomp `535a5ec0`, +`src/ui/gen2/SummaryMenu.lua`. Existing author rights and notices remain +applicable; integration additions by Roxas2712 follow the contribution terms +published in +. diff --git a/src/battle/BattleQueuePhase.lua b/src/battle/BattleQueuePhase.lua new file mode 100644 index 000000000..e9925582c --- /dev/null +++ b/src/battle/BattleQueuePhase.lua @@ -0,0 +1,15 @@ +-- One native queue, with the public Gen2 resolution phase. Earlier shared +-- checkpoints retain their stored spelling until the next native update. +local Version = require("src.core.GameVersion") +local M = {} + +function M.name() + return Version.isGen2() and "resolving" or "messages" +end + +function M.active(screen) + return screen.phase == "messages" + or (screen.phase == "resolving" and Version.isGen2()) +end + +return M diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..a8449a055 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 BattleQueuePhase = require("src.battle.BattleQueuePhase") local Assets = require("src.render.Assets") local Catching = require("src.battle.Catching") local Damage = require("src.battle.Damage") @@ -37,6 +38,7 @@ local Weather = require("src.battle.Weather") local WideBattle = require("src.battle.WideBattle") local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") +local Gen2BattleView = require("src.battle.Gen2BattleView") local GameVersion = require("src.core.GameVersion") -- FORWARD-DECLARED, because they are defined next to the other doubles @@ -598,6 +600,9 @@ end local Rulesets = { gen1_faithful = require("src.battle.rulesets.gen1_faithful"), + gen2_finite_pp = require("src.battle.rulesets.gen2_finite_pp"), + gen3_emerald = require("src.battle.rulesets.gen3_emerald"), + gen4_platinum = require("src.battle.rulesets.gen4_platinum"), modern_clean = require("src.battle.rulesets.modern_clean"), } @@ -639,7 +644,16 @@ local imageMeta = setmetatable({}, WEAK_KEYS) local function getImage(path, pal, trueColor, crop) if not path then return nil end if trueColor then pal = nil end + local sourcePal, displayKey = pal + if GameVersion.isGen2() and not trueColor and pal then + local display = require("src.render.PaletteFX") + displayKey = display.displayKey() + if not pal.displayResolved then + pal = {name=pal.name, colors=display.displayColors(pal.colors), displayResolved=true} + end + end local key = pal and (path .. "#" .. pal.name) or path + if displayKey then key = key .. "#display:" .. displayKey end if crop then key = key .. "@" .. crop.index end if not imageCache[key] then local img, pad, padL = nil, 0, 0 @@ -693,7 +707,7 @@ local function getImage(path, pal, trueColor, crop) -- crop rides along so fadeImage / grayImage / blackImage re-bake the -- SAME cell of the strip, not the whole strip imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil, - crop = crop } + crop = crop, sourcePal = sourcePal, displayKey = displayKey } end return imageCache[key] end @@ -820,7 +834,7 @@ local function fadeImage(img, bgp) local name = (meta.pal and meta.pal.name or "GB") .. "&" .. bgp[0] .. bgp[1] .. bgp[2] .. bgp[3] return getImage(meta.path, - { name = name, colors = PaletteFX.permute(base, bgp) }, + { name = name, colors = PaletteFX.permute(base, bgp), displayResolved=GameVersion.isGen2() }, nil, meta.crop) end @@ -848,8 +862,12 @@ local function blackImage(data, img) local pack = PaletteFX.pack(data) local colors = pack and pack.palettes and pack.palettes.BLACK if not colors then return img end + if GameVersion.isGen2() then + local ramp = PaletteFX.displayColors(colors) + colors = {ramp[4],ramp[4],ramp[4],ramp[4]} + end local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK" - return getImage(meta.path, { name = name, colors = colors }, nil, meta.crop) + return getImage(meta.path, { name = name, colors = colors, displayResolved=GameVersion.isGen2() }, nil, meta.crop) or img end @@ -864,6 +882,15 @@ end -- the image a battler pic actually draws with this frame function BattleState:picImage(img) local PaletteFX = require("src.render.PaletteFX") + if GameVersion.isGen2() then + local meta = imageMeta[img] + -- Battlers retain image references across a live option change. Dropping + -- imageCache alone does not replace those pictures or animation cells. + if meta and not meta.trueColor and meta.sourcePal + and meta.displayKey ~= PaletteFX.displayKey() then + img = getImage(meta.path, meta.sourcePal, nil, meta.crop) or img + end + end -- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that -- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone -- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the @@ -878,7 +905,7 @@ function BattleState:picImage(img) -- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync. local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" - if self.grayPics or mono then return grayImage(img) end + if self.grayPics or (mono and not GameVersion.isGen2()) then return grayImage(img) end -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go -- dark with the HP bars while the blackout text is up (#292). The intro -- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the @@ -1169,7 +1196,7 @@ local function newBattle(game) -- that explicit so anything holding "a thing with a .battle" (the party menu -- during a switch, mods walking the state stack) can treat the battle screen -- and the party menu uniformly instead of special-casing this one screen. - self.battle = self + if GameVersion.isGen2() then Gen2BattleView.bind(self) else self.battle = self end -- BLACK AND WHITE: their move effects named for this engine, their species' -- abilities and their ruleset, before the ruleset below is chosen -- (src/battle/Gen5MoveEffects.lua; sets self.bw on a BW cache only) @@ -1181,7 +1208,7 @@ local function newBattle(game) -- records on a mod-free boot); an unknown save value falls back to the -- default with a notice instead of silently switching behavior local rulesets = game.data.rulesets or Rulesets - local selected = game.save.options and game.save.options.ruleset + local selected = require("src.battle.RulesetDefaults").selection(game) -- THE FALLBACK IS THE CARTRIDGE'S, not Kanto's. It used to read -- `constants.defaultRuleset or "gen1_faithful"`, and nothing has ever -- written that constant -- so Hoenn fought under Generation One's rules on @@ -1324,6 +1351,7 @@ local function newBattle(game) end end self.queue = {} + if GameVersion.isGen2() then self.messageTimer = 0 end self.phase = "intro" self.menuIndex = 1 self.moveIndex = 1 @@ -2447,6 +2475,8 @@ end -- -- Returns true while animating. function BattleState:stepHPDrain() + -- GSC has its own short/long pixel loop and public display callback. + if GameVersion.isGen2() then return require('src.battle.Gen2HpPresentation').drain(self) end local busy = false for _, b in activeBattlers(self) do if b and b.shownHP then @@ -2488,6 +2518,18 @@ function BattleState:stepHPDrain() return busy end +function BattleState:stepHpAnim() + return require('src.battle.Gen2HpPresentation').step(self) +end + +function BattleState:hudHp(mon, side) + return require('src.battle.Gen2HpPresentation').shown(self, mon, side) +end + +function BattleState:hudHpPixels(mon, side) + return require('src.battle.Gen2HpPresentation').fill(self, mon, side) +end + -- AnimateExpBar (engine/battle/experience.asm) creeps the bar to its new -- length a pixel at a time with a tick of sound behind it, instead of -- redrawing it at the new length; the bar drawing itself off the live exp is @@ -2550,6 +2592,11 @@ local function shownHP(b) return math.floor(shown) end +-- Release native text at the decision owned by this queue. +function BattleState:clearQueueMessage() + if GameVersion.isGen2() then self.message, self.messageTimer = nil, 0 end +end + -- Parse a battle message into its rendered lines. The extractor marks -- \n = next line and \v = CONT (home/text.asm ContText: draw the blinking -- ▼, WaitForTextScrollButtonPress, then ScrollTextUpOneLine); the boosted / @@ -2561,6 +2608,10 @@ end -- instead of drawing it off-screen at y=144 (#216). function BattleState:startMessage(item) self.current = item + if GameVersion.isGen2() then + -- The existing native typer/prompt owns this hold; no second clock. + self.message, self.messageTimer = item.text or "", 48 + end self.lines = {} self.total = 0 local text = item.text or "" @@ -2604,11 +2655,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 +-- One display-only step of the existing native queue hold. It cannot consume +-- commands; updateQueue remains the sole dispatcher after this gate. +function BattleState:updateQueuePresentation() -- 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. -- @@ -2685,6 +2734,7 @@ function BattleState:updateQueue() if self.draining then if self:stepHPDrain() then return true end self.draining = nil + if GameVersion.isGen2() then require('src.battle.Gen2HpPresentation').retire(self) end if self.player then self.player.drainFloor = nil end if self.enemy then self.enemy.drainFloor = nil end end @@ -2693,6 +2743,25 @@ function BattleState:updateQueue() if self:stepExpBar() then return true end self.expFilling = nil end + return false +end + +function BattleState:updateQueue() + -- Resume the earlier shared queue spelling at its actual native owner. + if GameVersion.isGen2() and self.phase == "messages" then + self.phase = BattleQueuePhase.name() + if self.current then + self.message, self.messageTimer = self.current.text or "", 48 + end + end + if self.waitingUI then + if self.game.stack:top() ~= self then return true end + self.waitingUI = nil + if GameVersion.isGen2() and self.phase == "submenu" then + self.phase = BattleQueuePhase.name() + end + end + if self:updateQueuePresentation() then return true end if self.animPlaying then self.animPlayer:update() if self.animPlayer.pollEffects and self.applyAnimEffect then @@ -2737,11 +2806,14 @@ function BattleState:updateQueue() end if item.ui then self.waitingUI = true + -- Publish native interactive context before constructing the child. + if GameVersion.isGen2() then self.phase = "submenu" end self.game.stack:push(item.ui()) return true end if item.drain then self.draining = true + if GameVersion.isGen2() then self._gen2HpDrainBattler=item.battler end if item.battler then item.battler.drainFloor = item.stopAt end return true end @@ -3022,6 +3094,7 @@ function BattleState:updateQueue() self.game.stack:push(ChoiceBox.new(self.game, function(yes) local fn = item.choice battle.current = nil + if GameVersion.isGen2() then battle.messageTimer = 0 end fn(yes) end)) return true @@ -3047,6 +3120,7 @@ function BattleState:updateQueue() elseif input:wasPressed("a") or input:wasPressed("b") then self.msgPrompt = nil self.current = nil + if GameVersion.isGen2() then self.messageTimer = 0 end end end end @@ -3869,7 +3943,7 @@ function BattleState:enter() end) self:markParticipant() end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:syncSides() -- THE FIRST SEND-OUT COUNTS TOO. ABILITYEFFECT_ON_SWITCHIN runs the @@ -4360,7 +4434,7 @@ function BattleState:update(dt) end end - if self.phase == "messages" then + if BattleQueuePhase.active(self) or (GameVersion.isGen2() and self.phase == "submenu") then -- Nothing queued starts until the silhouettes have finished sliding in: -- SlidePlayerAndEnemySilhouettesOnScreen ends with `jpfar -- PrintBeginningBattleText` (engine/battle/core.asm:100), so the enemy @@ -4373,6 +4447,7 @@ function BattleState:update(dt) if not self:updateQueue() then if self.afterQueue == "menu" then self.phase = "menu" + self:clearQueueMessage() elseif self.afterQueue == "finish" then self:finish() end @@ -4448,7 +4523,7 @@ function BattleState:update(dt) if self.phase == "menu" and self.safari then if self.safari.balls <= 0 then self:say(Strings("PA: You're out of\nSAFARI BALLs!\nGame over!")) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.result = "run" self.afterQueue = "finish" return @@ -4569,7 +4644,7 @@ function BattleState:update(dt) end if choice == "fight" and self.ghost then self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) @@ -4637,7 +4712,7 @@ function BattleState:update(dt) 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.phase = BattleQueuePhase.name() 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 @@ -4645,26 +4720,26 @@ function BattleState:update(dt) 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.phase = BattleQueuePhase.name() 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.phase = BattleQueuePhase.name() 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.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif self.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef) then -- PLATINUM / HGSS: Gravity and Heal Block (BattleSystem_CanUseMove) self:say(require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef)) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif mv.pp <= 0 then self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then -- WHICH OF THE TWO, when the move takes one and there are two. @@ -4732,7 +4807,7 @@ function BattleState:update(dt) local pick = moves[self.mimicIndex] local ctx = self.mimicCtx self.mimicMoves, self.mimicCtx = nil, nil - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.nextInsert = 0 -- the copy's anim + text go to the queue head self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) end @@ -4873,7 +4948,7 @@ function BattleState:gen3TutorialBag() return true end if require("src.core.GameVersion").isGen4() then - self.phase, self.afterQueue = "messages", "menu" + self.phase, self.afterQueue = BattleQueuePhase.name(), "menu" self:ui(function() local bag = require("src.ui.Gen4BagMenu").new(self.game, { pocket = "POKé BALLS" }) bag.rows = { { id = 4, label = "POKé BALL", qty = 20 } } @@ -4895,7 +4970,7 @@ function BattleState:gen3TutorialBag() return false end local game = self.game - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:ui(function() local bag @@ -4922,7 +4997,7 @@ function BattleState:openOldManBag() if self:gen3TutorialBag() then return end local ListMenu = require("src.ui.ListMenu") local game = self.game - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:ui(function() local list @@ -4956,7 +5031,7 @@ end -- because DisplayBattleMenu swapped wPlayerName (core.asm:2024-2037); -- no ball is consumed (.done returns early, :576-578). function BattleState:oldManThrow() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "finish" self.result = "run" -- nothing is kept; wBattleResult only ends the demo local DsDemo = require("src.battle.CatchTutorialDemo") @@ -5002,7 +5077,7 @@ function BattleState:queuePokedudeVoices(indices) end if queued then pd.timer = 0 - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" end return queued @@ -5190,7 +5265,7 @@ function BattleState:updatePokedudeMenu() -- control to the player with the temporary party still installed. self.result = self.result or "run" self.afterQueue = "finish" - self.phase = "messages" + self.phase = BattleQueuePhase.name() return end @@ -5365,7 +5440,7 @@ function BattleState:pokedudeUseAntidote(step) local pd = self.pokedudeDemo if not pd then return end pd.enemyMove = step.enemyMove - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:say(Strings("POKé DUDE used\nANTIDOTE!")) local result, messages = require("src.inventory.ItemEffects") @@ -5782,7 +5857,7 @@ function BattleState:resolveTurn(playerAction) if entry[1] then self.turnActions[entry[1]] = entry[3] end end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- PLATINUM / HGSS: a TRICK ROOM used this turn resorts everyone still @@ -5859,11 +5934,11 @@ function BattleState:resolveSwitch(newMon) if self.player and self.player.ingrained and self.player.mon.hp > 0 then self:say(Strings("%s anchored\nitself with its\nroots!", displayName(self.player))) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" return end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- BLACK AND WHITE: the foe chooses first, and a Pursuit catches the -- Pokemon leaving (src/battle/Gen5MoveRules.lua) @@ -7316,7 +7391,7 @@ end function BattleState:lowHealthAlarmActive() local p = self.player if not p or self.safari or self.demo or self.result - or self.lowHealthAlarmDisabled then return false end + or self.lowHealthAlarmDisabled or self.partyItemHealing then return false end if self.showPlayerBack or (self.introSlide or 0) > 0 then return false end if p.fainted then return false end -- A siren that is ALREADY sounding follows the drawn bar, not the @@ -9201,20 +9276,8 @@ function BattleState:enemyMonFainted() end) return end - -- ComputeTrainerReward (0E:58D4) sets wBattleReward = the class's base - -- reward byte (TrainerClassAttributes+2) times wCurPartyLevel, the level - -- of the LAST mon the party reader loaded. That is only a QUARTER of the - -- payout though: WinTrainerBattle (0F:4F46) then runs `ld c, 4` and adds - -- wBattleReward once per iteration -- b of the four quarters through - -- .AddMoneyToMom, the remaining c through .AddMoneyToWallet -- so the - -- trainer is worth base * level * 4 in total. b comes from - -- wMomSavingMoney & 7 (0 = you keep it all, 3 = she banks all four), and - -- is forced to 0 when Mom's account is already maxed. Nothing here - -- models Mom's savings account, which is the `keep it all` branch, so all - -- four quarters land in the wallet -- Falkner pays 25 * 9 * 4 = 900 and a - -- Sprout Tower sage 8 * 3 * 4 = 96, instead of the 225 and 24 a single - -- quarter gives. Gen1's TrainerBattleVictory has no such split, so the - -- multiplier stays on the Gen2 side of the fence. + -- Native GSC rewards are four quarters. Their destination is selected + -- by Mom's saving bits; Gen2MomShopping applies the per-account cap. -- THE LEVEL THE PRIZE IS COUNTED FROM. -- -- Gen 1 and Gen 2 count from the mon that was out when the battle ended. @@ -9262,7 +9325,9 @@ function BattleState:enemyMonFainted() prize = P.prize(self.data, self.trainer, opts) if self.trainerB then prize = prize + P.prize(self.data, self.trainerB, opts) end end - self.game.save.money = self.game.save.money + prize + local momAward=require("src.world.Gen2MomShopping").reward(self.game.save,prize) + if not momAward then self.game.save.money=self.game.save.money+prize end + self.g2MomAward=momAward -- Prism's Spurge Bank ATM (event/bank.asm) offers DIRECT DEPOSIT: with it -- enabled, a quarter of what you win in battle is routed to the account -- instead of the wallet. The switch lives in the ATM special @@ -9323,9 +9388,17 @@ function BattleState:enemyMonFainted() -- Platinum's font, so a Sinnoh win read "got 円 480". This default is -- only reached when the dataset has no `_MoneyForWinningText` of its own, -- which is every Gen 3 and Gen 4 cartridge. - self:sayNext(self:romText("_MoneyForWinningText", - "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", - self.game.save.player.name, prize)) + local momPool=require("src.world.Gen2MomShopping").pool(self.data) + local moneyText=momAward and momPool and momPool.rewardTexts and momPool.rewardTexts[momAward.mode] + if moneyText and (self.data.text and self.data.text[moneyText])then + local text=self.data.text[moneyText] + text=text:gsub("{PLAYER}",function()return self.game.save.player.name end):gsub("{NUM}",tostring(prize)) + self:sayNext(text) + else + self:sayNext(self:romText("_MoneyForWinningText", + "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", + self.game.save.player.name, prize)) + end end self.result = "win" self.afterQueue = "finish" @@ -9523,7 +9596,7 @@ end -- out with no free enemy move. function BattleState:openReplacementMenu() local game = self.game - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" local function onSwitch(mon) if Party.isEgg(mon) then @@ -9592,7 +9665,7 @@ end -- engine/items/item_effects.asm. function BattleState:safariAction(choice) if self.gen4Safari then return self:gen4SafariAction(choice) end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" local st = self.safari local playerName = self.game.save.player.name @@ -9662,7 +9735,7 @@ function BattleState:gen4SafariAction(choice) end local playerName = self.game.save.player.name local foe = self.enemy.name - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" if choice == "run" then @@ -9864,7 +9937,7 @@ end -- Gen 1 escape formula (engine/battle/core.asm TryRunningFromBattle) function BattleState:tryRun() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- A wild battle the script says cannot be escaped. There is exactly one in -- Emerald -- the first -- and the roll is skipped rather than rigged, so a @@ -9943,7 +10016,7 @@ function BattleState:tryRun() end function BattleState:openItems() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" -- BATTLE_TYPE_FRONTIER (all but the Pyramid): "Items can't be used now." if self.noItems then @@ -9951,6 +10024,24 @@ function BattleState:openItems() return end self:ui(function() + if GameVersion.isGen2() then + -- Gen2's native PACK owns pockets, battle eligibility and cursor state. + -- It must not route through a generic list skin or a live field world. + local pack + pack = self:buildScreen("Gen2PackMenu", { + battle = true, + world = {}, + onChoose = function(id) + -- Close BattlePack before target construction: the actual caller is + -- the battle model, not the PACK's boolean eligibility flag. + pack:close() + require("src.ui.BagMenu").useItem(self.game, self, id, pack, { + onCancel = function() self:openItems() end, + }) + end, + }) + return pack + end return self:buildScreen(Gen4Battle.bagScreenId(self) or "BagMenu", { battle = self }) end) end @@ -9962,6 +10053,27 @@ function BattleState:gen4ItemTargetScreen() return Gen4Battle.partyScreenId(self) end +-- Gen2's party item screen owns HealHP_SFX_GFX and its confirmation. +-- These methods use the existing battlers and turn queue; the effect and +-- inventory mutation have already happened exactly once in ItemEffects. +function BattleState:beginPartyItemHeal() + self.partyItemHealing = true + self.lowHealthAlarmOn = false + require("src.core.Sound").stopLoop("Low_Health_Alarm") +end + +function BattleState:finishPartyItemTurn(itemId, mon) + if mon and self.player and mon == self.player.mon then + self.player.shownHP = mon.hp + self.player.drainFloor = nil + end + self.partyItemHealing = nil + self:itemUsed({}) + -- This producer commits the real item turn. Resume that queue explicitly + -- after the party owner closes, as the native Gen2 controller does. + self.phase = BattleQueuePhase.name() +end + -- called by BagMenu after an item is used in battle (consumes the turn) function BattleState:itemUsed(messages) -- bag cures clear mon.status before the message UI; refresh the HUD @@ -10581,12 +10693,12 @@ function BattleState:bugContestBall() -- kept throwing past twenty, so refuse here as well -- one guard for the -- last ball of a battle, one for the run. if BugContest.ballsLeft(save) <= 0 then - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:say(Strings("You have no\nPARK BALLs left!")) return end - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" BugContest.useBall(save) self:say(self:romText("_ItemUseText001", "%s used\n%s!", @@ -10673,7 +10785,7 @@ function BattleState:storeContestMon() end function BattleState:openParty() - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "menu" self:ui(function() return self:buildScreen(Gen4Battle.partyScreenId(self) or "PartyMenu", { @@ -10808,7 +10920,7 @@ function BattleState:finish() if type(text) == "string" and text ~= "" then pd.voiceDone[index] = true self:say(text) - self.phase = "messages" + self.phase = BattleQueuePhase.name() self.afterQueue = "finish" return end @@ -10824,7 +10936,7 @@ function BattleState:finish() self.game.save.player.name, self.payDay)) self.payDay = nil self.afterQueue = "finish" - self.phase = "messages" + self.phase = BattleQueuePhase.name() return end -- Invariant: a battle can never hand the overworld a party with nothing @@ -10889,6 +11001,16 @@ local HudTiles = require("src.render.HudTiles") local hudTile = HudTiles.tile local drawHPBar = HudTiles.drawHPBar +local function nativeHpPixels(screen, b, side) + if GameVersion.isGen2() then + -- The public GSC animation owns a 48-pixel bar. Respect a cartridge's + -- wider HUD geometry when writing its actual tile row. + local pixels = screen:hudHpPixels(b.mon, side) + return pixels > 0 and math.max(1, math.floor(pixels + * HudTiles.geometry().hpBarTiles / 6)) or 0 + end +end + -- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile. -- Counted in glyphs, not bytes: a nickname carrying "é" or "♂" is one -- charmap sequence per glyph, and byte length would push it a tile left. @@ -11894,7 +12016,8 @@ function BattleState:sgbBattlePals() local function bar(b) if not b then return pals.GREENBAR end local hp = b.shownHP or b.mon.hp - return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR + return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, + nativeHpPixels(self, b, b.isPlayer and 'player' or 'enemy'))] or pals.GREENBAR end local function mon(b, placeholder) if placeholder or not b then return pals.MEWMON or pals.GREENBAR end @@ -12526,7 +12649,7 @@ function BattleState:drawHUDs(slide) hudTile(0x73, 8, 16) drawHPBar(barData, 2, 2, { hp = shownHP(self.enemy), stats = self.enemy.mon.stats }, - nil, grayFill) + nil, grayFill, nil, nativeHpPixels(self, self.enemy, 'enemy')) hudTile(0x74, 8, 24) for i = 2, 9 do hudTile(0x76, i * 8, 24) end hudTile(0x78, 80, 24) @@ -12607,7 +12730,7 @@ function BattleState:drawHUDs(slide) end drawHPBar(barData, 10, 9, { hp = shownHP(self.player), stats = self.player.mon.stats }, - 1, grayFill) -- wHPBarType 1: the $6D cap + 1, grayFill, nil, nativeHpPixels(self, self.player, 'player')) -- wHPBarType 1: the $6D cap Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80) -- The block's right edge follows the HP bar: two label tiles from column -- 10, then the segments, then the cap -- column 18 with Crystal's @@ -12675,7 +12798,7 @@ end function BattleState:drawTextAreaInner() Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) - if self.phase == "messages" and (self.current or self.animPlaying) then + if BattleQueuePhase.active(self) and (self.current or self.animPlaying) then -- during the move animation self.current is nil but shown still holds -- the "used X!" lines; keep drawing them like pokered, whose move -- animations only touch sprites and never the textbox tilemap (#296) @@ -12802,7 +12925,7 @@ function BattleState:drawTextAreaInner() -- the type record's display name (a mod type shows its name, and -- PSYCHIC_TYPE prints PSYCHIC like the original) Font.draw(def.type and TypeChart.displayName(def.type) or "", 16, 80) - local maxPP = def.pp + (sel.ppUps or 0) * math.floor(def.pp / 5) + local maxPP = require("src.pokemon.Pokemon").maxPP(def, sel) Font.draw(("%2d/%2d"):format(sel.pp, maxPP), 40, 88) end end @@ -12850,6 +12973,7 @@ function BattleState:drawBattleField() end function BattleState:drawClassic() + if GameVersion.isGen2() then return self:drawScene() end -- AskName: ClearSprites + wild ClearScreenArea -- white field under the -- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top. if self.blankForAskName then @@ -12963,4 +13087,13 @@ function BattleState:drawClassic() end end +require('src.battle.Gen2BattleScene').install(BattleState) +require('src.battle.Gen2BattleWindow').install(BattleState) +Gen2BattleView.install(BattleState, { + load = getImage, + palette = monPalette, + path = imagePathOf, + trueColor = function(image) local meta = imageMeta[image]; return meta and meta.trueColor or false end, +}) + return BattleState diff --git a/src/battle/Gen2BattleScene.lua b/src/battle/Gen2BattleScene.lua new file mode 100644 index 000000000..9f93267c5 --- /dev/null +++ b/src/battle/Gen2BattleScene.lua @@ -0,0 +1,134 @@ +-- Native classic composition retained from src/battle/BattleState.lua. +-- The Gen2 scene-owner split reuses its real panels, FX and OAM; it does not +-- own battle decisions or animation time. Existing source provenance remains. +local Timing=require('src.core.Timing') +local Runtime=require('src.mods.Runtime') +local M={} +function M.install(Screen) + function Screen:drawPanel() + -- AskName: ClearSprites + wild ClearScreenArea -- white field under the + -- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top. + if self.blankForAskName then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + return + end + local fx = self.fx + -- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake); + -- the animations-off fallback keeps the old +-2 alternation + local sx = (fx and fx.shakeX) or 0 + local sy = (fx and fx.shakeY) or 0 + if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then + sx = self.frame % 4 < 2 and 2 or -2 + end + -- intro slide-in offset: 2 px per frame, so 144 px over 72 frames + local slide = (self.introSlide or 0) * Timing.BATTLE_SLIDE_PX_PER_FRAME + + if self:colorMode() then + -- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the + -- BGP fade -> mon pics -> OAM anim sprites (never BGP-faded) + local g = love.graphics + local wavy = fx and fx.wavy + -- push("all")/pop restores the bound canvas, a mod's depth set included + g.push("all") + g.setCanvas(self.bgCanvas) + -- The white paper the whole battle is drawn on -- unless something has + -- put a WORLD where the paper goes, in which case painting it is what + -- hides the world. Clearing to transparent instead leaves only what is + -- actually drawn (HUDs, boxes, pics) and lets the 3D frame underneath + -- show between them. The zone shader passes alpha through unchanged + -- (PaletteFX.shader: `vec4(mapped, p.a)`), so transparency survives the + -- colour pass rather than being remapped to a palette shade. + if self:worldBackdrop() then + g.clear(0, 0, 0, 0) + else + g.setColor(1, 1, 1, 1) + g.rectangle("fill", 0, 0, 160, 144) + end + g.setColor(1, 1, 1, 1) + -- the party ball rows are OBJ sprites on hardware (see drawBallRow): + -- collect them here and draw them over the finished zone pass instead + local balls = self:gen2BallColors() and {} or nil + self.deferBalls = balls + self:drawHUDs(slide) + self.deferBalls = nil + self:drawTextArea() + if wavy then + -- the mon pics are BG tiles on the GB, so SE_WAVY_SCREEN bends + -- them too: bake them into the canvas as DMG grays and let the + -- zone pass color them by region (exactly what the SGB did) + self.grayPics = true + g.setScissor(0, 0, 160, 96) -- BG pics live above the text box + self:drawPicsLayer(slide, 0, 0) + g.setScissor() + self.grayPics = nil + end + g.pop() + self:drawZonePass(self:applyWavy(self.bgCanvas), sx, sy) + if balls and balls[1] then + -- they ride the window shake with the HUD chrome they sit in + local shifted = sx ~= 0 or sy ~= 0 + if shifted then g.push() g.translate(sx, sy) end + g.setColor(1, 1, 1, 1) + for _, row in ipairs(balls) do + self:drawBallRow(row[1], row[2], row[3], row[4]) + end + if shifted then g.pop() end + end + if not wavy then + -- the pics are BG tiles in rows 0-11 on the GB: they can never + -- cover the text box, whatever the SE offsets do (a vertical + -- window shake moves the box down with everything else) + g.setScissor(0, 0, 160, 96 + math.max(0, sy)) + self:drawPicsLayer(slide, sx, sy) + g.setScissor() + end + else + -- flat fallback (headless / no shader support): pre-colorized pics + -- on white, no palette fades -- and the same exception as above, so the + -- two paths agree about when there is paper to paint + love.graphics.setColor(1, 1, 1, 1) + if not self:worldBackdrop() then + love.graphics.rectangle("fill", 0, 0, 160, 144) + end + local shaking = sx ~= 0 or sy ~= 0 + if shaking then + love.graphics.push() + love.graphics.translate(sx, sy) + end + self:drawPicsLayer(slide, 0, 0) + self:drawHUDs(slide) + self:drawTextArea() + if shaking then + love.graphics.pop() + end + end + end + + -- panelFn replaces the BG panel; native wide layout also supplies its + -- translated OAM pass and surface dimensions through the same real site. + function Screen:drawSceneBody(panelFn,objectsFn,width,height) + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + if panelFn then panelFn()else self:drawPanel()end + if self.blankForAskName then return end + if objectsFn then objectsFn()else self:drawAnimLayer(self:colorMode())end + local fx=self.fx + if fx and fx.flash and fx.flash>0 and self.frame%4<2 then + g.setShader();g.setColor(1,1,1,.85) + g.rectangle('fill',0,0,width or 160,height or 144) + end + end) + g.pop() + if not ok then error(why,0)end + end + function Screen:drawScene(bodyFn) + if bodyFn then bodyFn()else self:drawSceneBody()end + love.graphics.setColor(1,1,1,1) + if Runtime.wantsHook('battle.overlay')then + Runtime.call('battle.overlay',function()end,self) + end + end +end +return M diff --git a/src/battle/Gen2BattleView.lua b/src/battle/Gen2BattleView.lua new file mode 100644 index 000000000..e8d893120 --- /dev/null +++ b/src/battle/Gen2BattleView.lua @@ -0,0 +1,186 @@ +-- Gen2's public screen/model boundary over the shared native battle engine. +-- The screen owns execution, wrappers and presentation clocks; readers of +-- screen.battle get the actual party Pokemon, including after a queued switch. +local View = {} +local Hp = require('src.battle.Gen2HpPresentation') + +local function partyOf(screen) + return screen.playerParty or screen.game.save.party or {} +end + +local function battler(screen, side) + if side == "player" or side == "enemy" then return screen[side] end +end + +local function indexOf(party, mon) + for index, candidate in ipairs(party or {}) do + if candidate == mon then return index end + end +end + +function View.bind(screen) + -- Bind once; callers read the current native catalog without copying a + -- move, caching a definition, or switching the controller's moveset. + local function moveDef(_, move) + if type(move) == "string" then return screen.data.moves[move] end + if move == nil then return nil end + return screen:moveDef(move) + end + local readers = { + moveDef = function() return moveDef end, + player = function() return screen.player and screen.player.mon end, + enemy = function() return screen.enemy and screen.enemy.mon end, + party = function() return partyOf(screen) end, + enemyParty = function() return screen.enemyParty end, + save = function() return screen.game.save end, + data = function() return screen.data end, + wild = function() return screen.kind == "wild" end, + playerIndex = function() return indexOf(partyOf(screen), screen.player and screen.player.mon) end, + enemyIndex = function() return indexOf(screen.enemyParty, screen.enemy and screen.enemy.mon) end, + over = function() return screen.result ~= nil or screen.dead == true end, + outcome = function() return screen.result end, + battleType = function() return screen.battleType end, + trainer = function() return screen.trainer end, + tutorial = function() return screen.demo or screen.dudeDemo or screen.pokedudeDemo end, + contest = function() return screen.contest end, + safari = function() return screen.safari and true or false end, + link = function() return screen.kind == "link" end, + } + screen.battle = setmetatable({}, { + __index = function(_, key) local read = readers[key]; if read then return read() end end, + __newindex = function(model, key, value) + if readers[key] then error("native Gen2 battle view is read-only", 2) end + -- Native combat fields stay owned by the engine. Presentation consumers + -- may attach and release their own transient owners, as on the native + -- Gen2 model; these extensions never write to a battler or save. + rawset(model, key, value) + end, + }) + Hp.bind(screen) +end + +function View.activeMon(screen, side) + local b = battler(screen, side) + return b and b.mon +end + +local function owner(screen, mon) + for _, side in ipairs({ "player", "enemy" }) do + local b = battler(screen, side) + if b and b.mon == mon then return b, side end + end +end + +-- These are drawing operations, not another battle controller. In particular +-- front-art captures of the player keep the REAL battler as the key for blink, +-- substitute, faint and grow-in effects, and never replace its native rear. +function View.install(Screen, images) + Screen.activeMon = View.activeMon + + -- Older/restored native Pokemon need not carry a stamped gender field. + -- Read the existing identity authority rather than changing the live mon + -- merely to paint a HUD. It also owns explicit overrides and PID editions. + function Screen:genderSymbol(mon) + local gender = require("src.pokemon.Pokemon").genderOf(self.data, mon) + if gender == "male" then return "\226\153\130" end + if gender == "female" then return "\226\153\128" end + end + + function Screen:pic(mon, back) + if not mon then return nil end + local b = owner(self, mon) + if b and b.isPlayer == back then + return self:picImage(b.sprite), images.trueColor(b.sprite), images.path(b.sprite) + end + local species = b and b.species or mon.species + if b and species ~= mon.species then + local image = self:speciesSprite(species, back) + return self:picImage(image), images.trueColor(image), images.path(image) + end + local path, trueColor = require("src.pokemon.Sprites").path(self.data, species, + back and "back" or "front", { mon = mon, kind = "battle" }) + local image = images.load(path, images.palette(self.data, species, + require("src.pokemon.Pokemon").isShiny(mon)), trueColor) + return self:picImage(image), trueColor, path + end + + function Screen:picScale(path, mon, back) + return Screen.resolveBattleScale(self.data, back and "back" or "front", + path, mon and mon.species) + end + + function Screen:frontAnimFrame(mon) + local b = owner(self, mon) + -- The shared engine owns the entrance runner. Reading a capture does not + -- advance its clock, restart it, or animate a player's unrelated rear. + if not (b and not b.isPlayer and b.picAnim and b.picAnim:frame() > 0) then return nil end + local image = self:battlerPic(b) + if not image then return nil end + return image, nil, image:getWidth() + end + + function Screen:animPicState(side) + local b = battler(self, side) + if not b then return nil end + local pf = self.picFx and self.picFx[b] + return { + hidden = (side == "enemy" and (self.enemyHidden or self.enemySendingOut)) + or (side == "player" and self.sendingOut) or self:fxHidden(b) + or (b.fainted and not self:fxFaintActive(b)) or (pf and pf.hidden) or false, + pic = b.substituteHP and "substitute" or nil, + slide = pf and pf.ox or 0, + } + end + + function Screen:drawPic(mon, back) + local b, side = owner(self, mon) + if not b then return end + local state = self:animPicState(side) + if state and state.hidden then return end + if (back and self.slidingBackpic) or (self.picHidden and self.picHidden[side]) then return end + local trainer = back and self.showPlayerBack and self.playerBackPic + or (not back and self.showEnemyTrainer and self.trainerPic) + local image, _, path = self:pic(mon, back) + if trainer then image, path = self:picImage(trainer), images.path(trainer) end + if not image then return end + local frame = not trainer and not back and self:frontAnimFrame(mon) + if frame then image = frame end + local scale = self:picScale(path, not trainer and mon or nil, back) + local grow = not trainer and self:growInScale(b) + if grow then scale = scale * grow end + if scale <= 0 then return end + local w, h = image:getDimensions() + local x = (back and 16 or 96) + math.max(0, math.floor(((back and 48 or 56) - w) / 2)) + local y = (back and 96 or 56) - h + x, y = x + math.floor(w * (1 - scale) / 2), y + math.floor(h * (1 - scale)) + if trainer then love.graphics.draw(image, x, y, 0, scale, scale); return end + local screen = self + local capture = setmetatable({ + -- This isolated window owns the clip below. The native effect drawer's + -- rear-side window would erase a front capture of the SAME player. + wideRegion = true, + battlerPic = function() return image end, + -- Sprite extraction must stay a sprite even with native Stadium enabled. + drawStadiumBattlerPic = function() return false end, + drawSubstituteDoll = function(_, actual) + local g = love.graphics + local dx = (back and 32 or 112) - (actual.isPlayer and 32 or 112) + local dy = (back and 72 or 32) - (actual.isPlayer and 72 or 32) + g.push(); g.translate(dx, dy) + local ok, err = pcall(screen.drawSubstituteDoll, screen, actual) + g.pop(); if not ok then error(err, 0) end + end, + }, { __index = self }) + local g = love.graphics + g.push("all") + if g.intersectScissor then + g.intersectScissor(back and 16 or 96, back and 48 or 0, + back and 48 or 56, back and 48 or 56) + end + local ok, err = pcall(capture.drawBattlerPic, capture, b, x, y, scale) + g.pop() + if not ok then error(err, 0) end + end +end + +return View diff --git a/src/battle/Gen2BattleWindow.lua b/src/battle/Gen2BattleWindow.lua new file mode 100644 index 000000000..f70a5a2a0 --- /dev/null +++ b/src/battle/Gen2BattleWindow.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native Gen2 window drawer over the existing scene/queue. Contract checked +-- against Gen1Recomp 535a5ec0 src/ui/gen2/BattleState.lua; existing project +-- rights and notices remain applicable. No separate battle renderer/controller. +local Version = require('src.core.GameVersion') +local Chrome = require('src.ui.Gen2Chrome') +local Scope = require('src.render.GraphicsScope') +local M = {} + +function M.install(Screen) + function Screen:drawsWidescreen() return Version.isGen2() end + + function Screen:panelSize() return self:uiSize() end + + function Screen:battlePanelScale(width, height) + local pw, ph = self:panelSize() + if self:wantsFillScale() then + local _, _, w, h = Chrome.playfieldRect(width, height) + return math.min(w / pw, h / ph) + end + return Chrome.fitScaleFor(width, height, pw / 8, ph / 8) + end + + function Screen:drawWidescreen(width, height) + local ok, why = Scope.call(function() + local G = love.graphics + G.origin() + local scale = self:battlePanelScale(width, height) + if not self:wideLayout() then + return Chrome.withPanel(width, height, 1, 1, 1, + function() self:drawScene() end, scale) + end + local pw, ph = self:panelSize() + Chrome.letterbox(width, height, 1, 1, 1) + local x, y = Chrome.fitOriginFor(width, height, scale, pw / 8, ph / 8) + G.translate(x, y); G.scale(scale, scale) + Chrome.clipTo(0, 0, pw, ph) + return require('src.battle.WideBattle').draw(self) + end) + if not ok then error(why, 0) end + end +end +return M diff --git a/src/battle/Gen2HpPresentation.lua b/src/battle/Gen2HpPresentation.lua new file mode 100644 index 000000000..a5cefa595 --- /dev/null +++ b/src/battle/Gen2HpPresentation.lua @@ -0,0 +1,128 @@ +-- Native GSC HP presentation over the real shared-engine battlers. +-- Pixel stepping adapts Gen1Recomp src/battle/gen2/HpBar.lua and +-- src/ui/gen2/BattleState.lua; retain their provenance under NOTICE.md. +-- Native-owner integration: Roxas2712 (LICENSE-ROXAS2712.md). +local Hp = {} +local pending = setmetatable({}, {__mode='k'}) +local admissions = setmetatable({}, {__mode='k'}) +local function battler(screen, side) + if side=='player' or side=='enemy' then return screen[side] end +end +local function finite(value) + return type(value)=='number' and value==value and math.abs(value)=256 then product=math.floor(product/4);divisor=math.max(1,math.floor(divisor/4))end + return math.min(48,math.max(1,math.floor(product/divisor))) +end +function Hp.shown(screen, mon, side) + local shown=screen.shownHp and screen.shownHp[side] + if shown~=nil then return shown end + return mon and mon.hp or 0 +end +function Hp.fill(screen, mon, side) + local anim=screen.hpAnim + if anim and anim.side==side and anim.bar then return anim.bar.px end + local maximum=mon and (mon.maxHp or mon.stats and mon.stats.hp) or 0 + return Hp.pixels(Hp.shown(screen,mon,side),maximum) +end +local function newBar(from,to,maximum) + -- A wide presentation may have left a fractional value when its owner + -- changed. The cartridge loop advances integers and must still terminate. + from=from>to and math.ceil(from) or math.floor(from) + return {hp=from,to=to,maximum=maximum,px=Hp.pixels(from,maximum), + toPx=Hp.pixels(to,maximum),dir=to=48,low=math.min(from,to),high=math.max(from,to)} +end +local function stepBar(bar) + if bar.long then + while bar.hp~=bar.to do + bar.hp=bar.hp+bar.dir + local px=Hp.pixels(bar.hp,bar.maximum) + if px~=bar.px then bar.px=px;return false end + end + return true + end + if bar.px==bar.toPx then return true end + bar.px=bar.px+bar.dir + local hp + if bar.px>=48 then hp=bar.maximum + elseif bar.px<=0 then hp=0 + else hp=math.floor(bar.maximum*bar.px/48)+1;hp=math.max(bar.low,math.min(bar.high,hp))end + bar.hp=hp + return false +end +function Hp.bind(screen) + screen.shownHp=setmetatable({}, { + __index=function(_,side)local b=battler(screen,side);return b and b.shownHP end, + __newindex=function(_,side,value) + local active=admissions[screen];local b=battler(screen,side) + assert(active and active.side==side and b==active.battler and b.mon==active.mon, + 'native Gen2 shown HP is owned by stepHPDrain/stepHpAnim') + local maximum=math.max(1,b.mon.stats.hp) + assert(finite(value) and value>=0 and value<=maximum,'invalid native Gen2 displayed HP') + b.shownHP=value + end, + }) +end +-- This is the actual native display step, called through the public screen +-- method while its queue owns the HP row. It never changes battle damage. +function Hp.step(screen) + local anim=screen.hpAnim;local active=admissions[screen] + if not anim then return false end + assert(active and active.anim==anim,'native Gen2 HP step has no admitted row') + if not anim.bar then + anim.bar=newBar(screen.shownHp[anim.side],anim.to,active.mon.stats.hp) + end + if (anim.hold or 0)>0 then anim.hold=anim.hold-1;return true end + if stepBar(anim.bar)then + screen.shownHp[anim.side]=anim.to;screen.hpAnim=nil;return false + end + screen.shownHp[anim.side]=anim.bar.hp;anim.hold=1;return true +end +local function goalOf(b) + local goal=b.mon.hp + if b.drainFloor and b.drainFloor>goal and b.shownHP>=b.drainFloor then goal=b.drainFloor end + return goal +end +function Hp.drain(screen) + local selected=screen._gen2HpDrainBattler + local side + for _,candidate in ipairs({'player','enemy'})do + local b=battler(screen,candidate) + if b and b.shownHP~=nil and (not selected or b==selected) + and (b.shownHP~=goalOf(b) or pending[screen] and pending[screen].battler==b)then + side=candidate;break + end + end + if not side then screen.hpAnim=nil;pending[screen]=nil;return false end + local b=battler(screen,side);local goal=goalOf(b);local active=pending[screen] + if not active or active.battler~=b or active.mon~=b.mon or active.goal~=goal + or screen.hpAnim~=active.anim then + local anim={side=side,to=goal} + active={side=side,battler=b,mon=b.mon,goal=goal,anim=anim} + pending[screen]=active;screen.hpAnim=anim + end + admissions[screen]=active + local ok,why=pcall(screen.stepHpAnim,screen) + admissions[screen]=nil + if not ok then error(why,0)end + local anim=screen.hpAnim + if anim==active.anim and anim.bar and anim.bar.hp~=b.shownHP then + -- A wider HUD decorated this beat after (or instead of) native stepping. + -- Its display number is authoritative for presentation. Discard the old + -- pixel cursor so returning to the cartridge HUD cannot jump backward. + anim.bar,anim.hold=nil,nil + end + -- A decorator cannot release a queued damage beat before its display has + -- reached that beat's authoritative stop. No second timer or damage loop. + if b.shownHP==goal and screen.hpAnim==nil then pending[screen]=nil;return false end + return true +end +function Hp.retire(screen) + pending[screen],admissions[screen]=nil,nil + screen.hpAnim=nil;screen._gen2HpDrainBattler=nil +end +return Hp diff --git a/src/battle/HeldItems.lua b/src/battle/HeldItems.lua index 195f4ad0b..9b5a559b1 100644 --- a/src/battle/HeldItems.lua +++ b/src/battle/HeldItems.lua @@ -387,7 +387,7 @@ local function restorePP(data, holder) local def = data.moves and data.moves[move.id] local base = def and tonumber(def.pp) local maxPP = base - if base then maxPP = base + (tonumber(move.ppUps) or 0) * math.floor(base / 5) end + if base then maxPP = require("src.pokemon.Pokemon").maxPP(def,move,data.constants and data.constants.gen) end local amount = norm(move.id) == "SKETCH" and 1 or 5 move.pp = maxPP and math.min(maxPP, amount) or amount if holder.curMoves and holder.curMoves ~= mon.moves and holder.curMoves[i] then diff --git a/src/battle/RulesetDefaults.lua b/src/battle/RulesetDefaults.lua index 1850ce880..c68fc7343 100644 --- a/src/battle/RulesetDefaults.lua +++ b/src/battle/RulesetDefaults.lua @@ -51,19 +51,25 @@ local RulesetDefaults = {} -- is not. Everything else matched. A player on the old default was therefore -- playing something very close to right, and wrong in every double battle. -- --- GEN 2 IS STILL ON GEN 1'S, and stays there until somebody does to Johto --- what this change did to Sinnoh. There is no Gen 2 ruleset in this repo, and --- handing Johto one written for another region would trade a known wrong --- answer for an unknown one. +-- Gen2's compatibility default tracks enemy PP, matching the native Gold +-- battle loop. All other previous host rule fields remain unchanged; this +-- is not a claim of complete Gen2 battle-rule parity. RulesetDefaults.DEFAULT_BY_GENERATION = { [1] = "gen1_faithful", - [2] = "gen1_faithful", + [2] = "gen2_finite_pp", [3] = "gen3_emerald", [4] = "gen4_platinum", } RulesetDefaults.FALLBACK = "gen1_faithful" +RulesetDefaults.AUTO = false +function RulesetDefaults.selection(game) + local selected = game and game.save and game.save.options and game.save.options.ruleset + if selected == RulesetDefaults.AUTO then return nil end + return selected +end + -- The id this cartridge should use when the save has no choice of its own. function RulesetDefaults.defaultFor(game) local constants = game and game.data and game.data.constants diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index b8a4a4ca5..79a4efb9e 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -11,6 +11,7 @@ -- with their status lower right, a full-width message window, a split -- prompt/command window, and a 2x2 move menu with an attached PP/type panel. +local BattleQueuePhase = require("src.battle.BattleQueuePhase") local Font = require("src.render.Font") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") @@ -325,7 +326,7 @@ local function drawMoveDetails(battle, move) if not move then return end local def = battle.data.moves[move.id] if not def then return end - local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5) + local maxPP = require("src.pokemon.Pokemon").maxPP(def, move) love.graphics.setColor(0, 0, 0, 1) Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112) Font.draw(fitName(TypeChart.displayName(def.type), 64), 232, 128) @@ -359,7 +360,7 @@ local function drawMoveMenu(battle) end local function drawTextArea(battle) - if battle.phase == "messages" and (battle.current or battle.animPlaying) then + if BattleQueuePhase.active(battle) and (battle.current or battle.animPlaying) then drawMessageBox(battle) elseif battle.phase == "menu" then drawCommandMenu(battle) @@ -447,7 +448,7 @@ local function drawAnimationLayer(battle) end -- The whole 304x144 composition for one frame. -function WideBattle.draw(battle) +local function drawPanel(battle,withObjects) local g = love.graphics -- The field is the display mode's paper. Under a forced-mono mode the -- whole surface is remapped downstream (WideBattle.zones), so the field @@ -503,9 +504,20 @@ function WideBattle.draw(battle) g.pop() end shaken(function() drawHUDs(battle, slide) end) - drawAnimationLayer(battle) + if withObjects then drawAnimationLayer(battle) end shaken(function() drawTextArea(battle) end) +end +function WideBattle.draw(battle) + if require('src.core.GameVersion').isGen2() then + return battle:drawScene(function() + battle:drawSceneBody(function()drawPanel(battle,false)end, + function()drawAnimationLayer(battle)end,WideBattle.WIDTH,WideBattle.HEIGHT) + end) + end + drawPanel(battle,true) + if battle.blankForAskName then return end + local g,fx=love.graphics,battle.fx if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then g.setColor(1, 1, 1, 0.85) g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) diff --git a/src/battle/rulesets/gen2_finite_pp.lua b/src/battle/rulesets/gen2_finite_pp.lua new file mode 100644 index 000000000..78325ee61 --- /dev/null +++ b/src/battle/rulesets/gen2_finite_pp.lua @@ -0,0 +1,9 @@ +-- Current Gen2Recomped Gen-2 rules with finite enemy PP. This is deliberately +-- not named faithful: other differences from the reference Gold engine remain. +local out = {} +for key, value in pairs(require("src.battle.rulesets.gen1_faithful")) do + out[key] = value +end +out.name = "gen2_finite_pp" +out.enemyUnlimitedPP = false +return out diff --git a/src/compat/gen2/Decorations.lua b/src/compat/gen2/Decorations.lua new file mode 100644 index 000000000..952a801a6 --- /dev/null +++ b/src/compat/gen2/Decorations.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Legacy argument/result contracts over the native cartridge and placed bytes. +-- No fallback catalogue, ownership bitfield or second decoration save exists. +local Native=require('src.world.Gen2Decorations') +local Version=require('src.core.GameVersion') +local Flags=require('src.script.Flags') +local Gen2Flags=require('src.script.Gen2Flags') +local Copy=require('src.mods.Merge').deepCopy +local M={SLOTS=Copy(Native.slots),OBJECT_SLOTS=Copy(Native.objectSlots),ACTIONS={}, + EVENT_PLAYERS_ROOM_POSTER=716,DECOFLAG_GOLD_TROPHY_DOLL=43,DECOFLAG_SILVER_TROPHY_DOLL=44, + DESC_SLOTS={DECODESC_POSTER={slot='poster'},DECODESC_LEFT_DOLL={slot='leftOrnament',named=true}, + DECODESC_RIGHT_DOLL={slot='rightOrnament',named=true},DECODESC_BIG_DOLL={slot='bigDoll'}, + DECODESC_CONSOLE={slot='console',named=true}}} +for n=1,14 do + local name=Native.actionName(n);local action=Native.action(name) + if not action.put then action.put=nil end + M.ACTIONS[name]=action +end +local bound,edition,epoch=nil,nil,0 +local function data() + return edition==Version.get()and Native.ready(bound)and bound or nil +end +function M.useRegistry(value) + epoch=epoch+1;bound=nil;edition=nil + if Native.ready(value)then bound=value;edition=Version.get()end + return bound~=nil +end +function M.idFor(id)return 'deco:'..tostring(id)end +function M.idForFlag(n) + n=n==nil and 0 or tonumber(n) + local d=data();local info=d and Native.pool(d).decorationInfo + if not(n and n%1==0 and n>=0 and n<=255 and info and info.giftsComplete)then return nil end + return info.gifts and info.gifts[n+1] +end +function M.attributes(id)return Native.attributes(data(),id)end +function M.state(save)return Native.state(data(),save)end +function M.name(id,monName)return Native.name(data(),id,monName)end +function M.registerInto(registry,source,owner) + local d=Native.ready(source)and source or data();local count=0 + if not d then return count end + local seen={} + for raw in pairs(Native.pool(d).decorations)do + local id=Native.byte(raw) + if id and not seen[id]then + seen[id]=true;registry:register(M.idFor(id),Copy(Native.attributes(d,id)),owner);count=count+1 + end + end + return count +end +-- Optional native adapter for callers of the numeric Events protocol. Its +-- get/set operations reach the same canonical flags as scripts and SRAM. +-- A retained adapter expires on rebind/unload; it never captures a new game. +function M.events(save) + if not data()or type(save)~='table'then return nil end + local generation=epoch + local function current(id) + return generation==epoch and data()and type(id)=='number'and id%1==0 and id>=0 and id<65535 + end + return{get=function(_,id) + return current(id)and type(save.flags)=='table'and Flags.get(save,Gen2Flags.eventFlag(id))or false + end,set=function(_,id,value) + if not current(id)then return false end + save.flags=save.flags or{} + if value then Flags.set(save,Gen2Flags.eventFlag(id))else Flags.clear(save,Gen2Flags.eventFlag(id))end + return true + end} +end +local function usable(id) + id=Native.byte(id) + local row=M.attributes(id);local action=row and Native.action(row.action) + return id and id~=0 and action and not action.put and row or nil +end +function M.owns(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.get)=='function'then return events:get(row.flag)and true or false end + return Native.owns(data(),events,id) +end +function M.give(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.set)=='function'then return events:set(row.flag,true)~=false end + return Native.give(data(),events,id) +end +function M.giveFlag(events,n)return M.give(events,M.idForFlag(n))end +function M.ownedCategories(events) + local out={} + for _,category in ipairs(Native.categories(data()))do + for _,id in ipairs(category.members)do + if M.owns(events,id)then out[#out+1]=category;break end + end + end + return out +end +function M.rows(events,category) + local out={} + -- Rebuild membership rather than trusting a category retained across reload. + for _,current in ipairs(Native.categories(data()))do + if category and current.id==category.id and(current.slot==category.slot or + category.slot==nil and category.id~=nil)then + for _,id in ipairs(current.members)do if M.owns(events,id)then out[#out+1]=id end end + if #out>0 then + for _,id in ipairs(current.headers)do out[#out+1]=id end + out[#out+1]=0 + end + break + end + end + return out +end +function M.apply(state,id,side,monName) + local placement=Native.placement(data(),state,id,side,monName) + if not placement then return false,{}end + state[placement.slot]=placement.value + return placement.changed,Native.feedbackPages(data(),placement) +end +function M.clearOtherSide(state,id,side) + id=Native.byte(id) + if not data()or type(state)~='table'or not id or id==0 or(side~='left'and side~='right')then return end + local other=side=='right'and 'leftOrnament'or 'rightOrnament' + if state[other]==id then state[other]=0 end +end +function M.visibility(state)return data()and Native.layoutVisibility(data(),state)or{}end +function M.tiles(state)return data()and Native.layoutTiles(data(),state)or{}end +function M.posterVisible(state)return data()~=nil and Native.posterVisible(state)end +return setmetatable(M,{__index=function(_,key) + if key=='CATEGORIES'then return Native.categories(data())end + if key=='ATTRIBUTES'then + local out={};local d=data() + for raw in pairs(d and Native.pool(d).decorations or{})do + local id=Native.byte(raw);if id then out[id]=Native.attributes(d,id)end + end + return out + end + if key=='NOTHING_TO_CHOOSE'then + local pages=Native.feedbackPages(data(),{role='noChoice',buffers={}}) + return pages[1] + end +end}) diff --git a/src/core/Data.lua b/src/core/Data.lua index 02984c906..83004708d 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -691,6 +691,9 @@ local function gen2PaletteRows(tileset) end function Data:publishGen2Palettes() + if require('src.core.GameVersion').isGen2()then + for _,def in pairs(self.maps or{})do require('src.render.Gen2PaletteContext').publish(def)end + end local tilesets = self.tilesets if type(tilesets) ~= "table" then return false end @@ -741,6 +744,7 @@ function Data:publishGen2Palettes() self.gen2Palettes = setmetatable({}, { __index = function(_, id) return gen2PaletteRows(tilesets[id]) end, }) + require('src.render.Gen2PaletteContext').bind(self.gen2Palettes,self) return true end @@ -1893,6 +1897,8 @@ function Data:seedDefaults() end seedMissingGen2Tilesets(self) self:publishGen2Palettes() + require("src.pokemon.Gen2Mail").load(self) + require("src.compat.gen2.Decorations").useRegistry(self) ensureGen2WarpFallbacks(self) ensureGen2HomeTextFallbacks(self) end diff --git a/src/core/Game.lua b/src/core/Game.lua index fc5db6c50..39d3e4a53 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -13,6 +13,7 @@ local FrameProfile = require("src.core.FrameProfile") local TouchControls = require("src.core.TouchControls") local ModLoader = require("src.mods.Loader") local ModRuntime = require("src.mods.Runtime") +local NativeScreenOverlay = require("src.render.NativeScreenOverlay") local Screens = require("src.ui.Screens") local Game = {} @@ -1013,6 +1014,13 @@ function Game:_draw() -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) + local ScreenSpaceUI=require('src.render.ScreenSpaceUI') + local scene=self.stack + local windowUI,windowUIIndex=ScreenSpaceUI.select(self.stack,scene,drawFrom) + local windowUIOverlay=false + local nativeWindowOverlays=windowUI and require('src.core.GameVersion').isGen2() + local nativeWindowStack=not windowUI and require('src.core.GameVersion').isGen2() + and ScreenSpaceUI.hasNativeStack(self.stack,scene,drawFrom,self.overworld) local worldDrawn = self.stack.states[drawFrom] == self.overworld -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred @@ -1095,7 +1103,13 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if nativeWindowOverlays and windowUIIndex and i>windowUIIndex then + if state and state.draw then windowUIOverlay=true end + elseif nativeWindowStack and state~=self.overworld then + -- Native controllers draw once on the full output surface below. + elseif state==windowUI then + love.graphics.clear(0,0,0,0) + elseif state and state.draw then -- ...and it is centred by ITS OWN size, not by the Game Boy's (see -- Game.homeOffset). classicOffset still gates it: a surface that is -- the Game Boy's centres nothing, which is every frame outside a wide @@ -1107,17 +1121,17 @@ function Game:_draw() if ox ~= 0 or oy ~= 0 then love.graphics.push() love.graphics.translate(ox, oy) - state:draw() + NativeScreenOverlay.screen(state) love.graphics.pop() else - state:draw() + NativeScreenOverlay.screen(state) end end 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() + if not windowUI and not worldDrawn and baseState and require("src.core.GameVersion").isNds() and SuperSample.singleScreen(self) and not SuperSample.active then SuperSample.draw(self, self.stack:top(), drawStates) else @@ -1171,7 +1185,11 @@ function Game:_draw() end -- 14's render.zones: weather/lighting overlays and custom colorization -- recolor or add zones before the blit - if ModRuntime.wantsHook("render.zones") then + if require("src.core.GameVersion").isGen2() then + Renderer.gen2FrameManaged = true + Renderer.gen2FrameGame = self + Renderer.gen2FrameZones = require("src.render.Gen2Presentation").zones(self) + elseif ModRuntime.wantsHook("render.zones") then zones = ModRuntime.call("render.zones", sameZones, self, zones) end -- Keyed to whether the map actually DREW, not to whether it is the clear's @@ -1183,7 +1201,20 @@ function Game:_draw() if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end + if Renderer.gen2FrameManaged and (windowUI or nativeWindowStack) then + Renderer.gen2WindowDraw=function(viewport) + if windowUI then + ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,false) + if windowUIOverlay then ScreenSpaceUI.drawNativeStack(self.stack,scene,windowUIIndex+1, + self.overworld,Renderer,viewport,false)end + else ScreenSpaceUI.drawNativeStack(self.stack,scene,drawFrom,self.overworld, + Renderer,viewport,not worldDrawn)end + end + end local viewport = Renderer:endFrame(zones, worldZones) + if windowUI and not Renderer.gen2FrameManaged then + ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,windowUIOverlay) + end -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. @@ -1408,7 +1439,7 @@ function Game:keypressed(key) or ow.engaging or ow.emote))) if not busy then local PaletteFX = require("src.render.PaletteFX") - self.save.options.colors = PaletteFX.cycleMode() + PaletteFX.writeColorOption(self.save.options, PaletteFX.cycleMode()) self:writeOptions() end return diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..f7cc29848 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1082,7 +1082,16 @@ GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", GameVersion.current = "red" function GameVersion.set(id) + local previous=GameVersion.current GameVersion.current = GameVersion.VERSIONS[id] and id or "red" + if previous~=GameVersion.current then + local decorations=package.loaded['src.compat.gen2.Decorations'] + if decorations then decorations.useRegistry(nil) end + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context() end + local clock=package.loaded['src.core.Gen2Clock'] + if clock then clock.resetObservations() end + end return GameVersion.current end diff --git a/src/core/Gen2Clock.lua b/src/core/Gen2Clock.lua new file mode 100644 index 000000000..dc7d75d17 --- /dev/null +++ b/src/core/Gen2Clock.lua @@ -0,0 +1,128 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +-- Shared native GSC RTC reads. The public Clock spelling follows the +-- reference contract; no private mod implementation or clock is embedded. +local Clock = { MINUTES_PER_DAY = 1440, DAYS = 7, DEFAULT_HOUR = 10, DEFAULT_MINUTE = 0 } +local Strings = require("src.core.Strings") +Clock.DAY_NAMES = { "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY" } +local observed = setmetatable({}, { __mode = "k" }) +local function integer(value) + local n = tonumber(value) + if n and n == n and n ~= math.huge and n ~= -math.huge then return math.floor(n) end +end +local function rtc(save) + return type(save) == "table" and type(save.rtc) == "table" and save.rtc or nil +end +function Clock.hostMinutes(now) + return ((integer(os.date("%H", now)) or 0) * 60 + (integer(os.date("%M", now)) or 0)) % 1440 +end +function Clock.hostWeekday(now) + return (integer(os.date("%w", now)) or 0) % 7 +end +-- Newly set clocks advance by elapsed RTC seconds, including while closed. +-- The old host-local offsets remain readable without migrating a save. Host +-- DST/time-zone changes must not silently change an explicitly set game clock. +local function anchored(state, now) + if not state then return nil end + local at, minute, day = integer(state.anchorTime), integer(state.anchorMinute), integer(state.anchorDay) + if not at or not minute or minute < 0 or minute >= 1440 or not day or day < 0 or day >= 7 then return nil end + now = integer(now or os.time()) + if not now then return nil end + return minute + math.floor((now - at) / 60), day +end +function Clock.minutes(save, now) + local state = rtc(save) + local total = anchored(state, now) + if total then return total % 1440 end + return (Clock.hostMinutes(now) + (state and integer(state.startMinute) or 0)) % 1440 +end +function Clock.hour(save, now) return math.floor(Clock.minutes(save, now) / 60) end +function Clock.minute(save, now) return Clock.minutes(save, now) % 60 end +function Clock.isSet(save) + local state = rtc(save) + return state ~= nil and integer(state.startMinute) ~= nil +end +function Clock.setTime(save, hour, minute) + if type(save) ~= "table" then return false end + hour, minute = integer(hour or 0), integer(minute or 0) + if not hour or not minute then return false end + local now = os.time() + local day, date = Clock.weekday(save, now), Clock.dateKey(save, now) + local wanted = (hour % 24) * 60 + minute % 60 + save.rtc = rtc(save) or {} + save.rtc.startMinute = (wanted - Clock.hostMinutes(now)) % 1440 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 + save.g2DayOffset = save.rtc.startDay + save.rtc.anchorTime, save.rtc.anchorMinute = now, wanted + save.rtc.anchorDay, save.rtc.anchorDate = day, date + return true +end +local function note(save, day, reason, now) + if type(save) ~= "table" then return day end + local Runtime = require("src.mods.Runtime") + if not require("src.core.GameVersion").isGen2() or not Runtime.wants("clock.day_changed") then + observed[save] = nil + return day + end + local previous, date = observed[save], Clock.dateKey(save, now) + observed[save] = { day=day, date=date } -- Commit before dispatch; reentrant reads are silent. + if previous ~= nil and (previous.day ~= day or previous.date ~= date) then + Runtime.emit("clock.day_changed", { day = day, previous = previous.day, reason = reason }) + end + return day +end +function Clock.weekday(save, now) + local state = rtc(save) + local total, day = anchored(state, now) + if total then return note(save, (day + math.floor(total / 1440)) % 7, "rollover", now) end + local offset = state and integer(state.startDay) + if offset == nil then offset = type(save) == "table" and integer(save.g2DayOffset) or 0 end + return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover", now) +end +function Clock.setWeekday(save, day) + day = integer(day) + if type(save) ~= "table" or not day then return false end + local now = os.time() + save.rtc = rtc(save) or {} + day = day % 7 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 + save.rtc.dayOfWeek = day + local total = anchored(save.rtc, now) + if total then save.rtc.anchorDay = (day - math.floor(total / 1440)) % 7 end + -- Keep the existing native field in sync for older native consumers/saves. + save.g2DayOffset = save.rtc.startDay + note(save, day, "set", now) + return true +end +-- Calendar identity for daily/weekly timers. Only date arithmetic uses the +-- host calendar library; elapsed game days come from the same RTC as minutes +-- and weekday. Noon normalization avoids missing/duplicated local DST hours. +function Clock.calendarDate(save, now) + local state = rtc(save) + local total = anchored(state, now) + local year, month, day + if total and type(state.anchorDate) == "string" then + year, month, day = state.anchorDate:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") + year, month, day = tonumber(year), tonumber(month), tonumber(day) + end + if year and month >= 1 and month <= 12 and day >= 1 and day <= 31 then + local ok, stamp = pcall(os.time, { year=year, month=month, + day=day + math.floor(total / 1440), hour=12, min=0, sec=0, isdst=nil }) + if ok and stamp then return os.date("*t", stamp) end + end + return os.date("*t", now) +end +function Clock.dateKey(save, now) + local date = Clock.calendarDate(save, now) + return ("%04d-%02d-%02d"):format(date.year, date.month, date.day) +end +function Clock.weekdayName(day) + local name = Clock.DAY_NAMES[day] + return name and Strings(name) +end +function Clock.daytimeLabel(hour) + hour = integer(hour) or Clock.hour() + hour = hour % 24 + return Strings(hour < 4 and "NITE" or hour < 10 and "MORN" or hour < 18 and "DAY" or "NITE") +end +function Clock.resetObservations() observed = setmetatable({}, { __mode = "k" }) end +return Clock diff --git a/src/core/Gen2CommonText.lua b/src/core/Gen2CommonText.lua new file mode 100644 index 000000000..d4b837b61 --- /dev/null +++ b/src/core/Gen2CommonText.lua @@ -0,0 +1,126 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- The strings an ENGINE routine prints, looked up by their pokegold label. +-- +-- data/generated/text.lua is keyed by "bank:addr", because every string in it +-- was found by following a script pointer. The Day-Care, the POKeMART and the +-- Hall of Fame are printed by asm instead (`ld hl, .SomeText / call +-- PrintText`), so RomExtractorGen2's NAMED_TEXT seeds the walker at those +-- symbols by name and writes text.labels[label] -> that key. This is the +-- lookup on the other side of that table: a screen asks for +-- "_MartWelcomeText" and never for an address, so a repointed string still +-- resolves and a cache built before the seed simply answers nil. +-- +-- `pages` puts the decoded stream back into the shape the Gen 2 speech box +-- draws it in -- up to two lines per screenful -- following home/text.asm: +-- +-- \n `line` / `next`: the box's second row. +-- \f `para`: PlaceString clears the box, so the next screenful starts empty. +-- \v `cont`: the box SCROLLS one row, so the line that was on the bottom +-- row is now on the top one and the new text lands under it. That is +-- why a `cont` shows up here as a page whose first line repeats the +-- previous page's second. +-- +-- `fill` substitutes the markers the decoder leaves behind for the values the +-- cart splices at runtime: {STRBUF} for a TX_RAM name and {NUM} for a +-- TX_DECIMAL PrintNum field, both in the order they appear, plus the named +-- {PLAYER} / {RIVAL}. + +local CommonText = {} + +-- The decoded string for a pokegold label, or nil when this cache predates +-- the seed (or the string is genuinely empty, like _DaycareDummyText). +function CommonText.get(text, label) + if type(text) ~= "table" or not label then return nil end + local labels = text.labels + local key = type(labels) == "table" and labels[label] + local body = key and text[key] + if type(body) ~= "string" or body == "" then return nil end + return body +end + +-- ../pokecrystal/home/text.asm:548 PromptText, :566 DoneText +function CommonText.plain(body) + if type(body) ~= "string" then return body end + return (body:gsub("{DONE}", ""):gsub("{PROMPT}", "")) +end + +-- One page is an array of one or two lines. +function CommonText.pages(body) + body = CommonText.plain(body) + if type(body) ~= "string" or body == "" then return nil end + local out = {} + local top, bottom = "", nil + local function flush() + if bottom then + out[#out + 1] = { top, bottom } + else + out[#out + 1] = { top } + end + end + local i = 1 + while i <= #body do + local marker = body:find("[\n\f\v]", i) + local chunk = body:sub(i, (marker or (#body + 1)) - 1) + if bottom then + bottom = bottom .. chunk + else + top = top .. chunk + end + if not marker then break end + local m = body:sub(marker, marker) + if m == "\n" then + bottom = bottom or "" + elseif m == "\f" then + flush() + top, bottom = "", nil + else -- "\v" + flush() + top, bottom = bottom or "", "" + end + i = marker + 1 + end + flush() + return out +end + +-- values: an array consumed in order by {STRBUF} and {NUM}, and optionally +-- values.player / values.rival for the two named markers. +function CommonText.fill(pages, values) + if not pages then return nil end + values = values or {} + local next_ = 1 + -- One pass over both markers, because the order they are CONSUMED in is the + -- order they appear in: _MartFinalPriceText opens on its {NUM} and + -- _BargainShopFinalPriceText on its {STRBUF}, and filling one kind before + -- the other would swap the price and the item name on one of them. + local function marker(name) + if name == "PLAYER" or name == "RIVAL" then + return values[name:lower()] or ("{" .. name .. "}") + end + if name ~= "STRBUF" and name ~= "NUM" then return "{" .. name .. "}" end + local v = values[next_] + next_ = next_ + 1 + return v ~= nil and tostring(v) or "" + end + local out = {} + for p, page in ipairs(pages) do + local lines = {} + for l, line in ipairs(page) do + lines[l] = (line:gsub("{(%u+)}", marker)) + end + out[p] = lines + end + return out +end + +-- The whole lookup in one call: nil when the cache has no such label, which +-- is every call site's cue to fall back to its own transcription. +function CommonText.of(text, label, values) + local pages = CommonText.pages(CommonText.get(text, label)) + if not pages then return nil end + if values then return CommonText.fill(pages, values) end + return pages +end + +return CommonText diff --git a/src/core/Gen2PokedexRating.lua b/src/core/Gen2PokedexRating.lua new file mode 100644 index 000000000..21d93c09d --- /dev/null +++ b/src/core/Gen2PokedexRating.lua @@ -0,0 +1,92 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +local Strings = require("src.core.Strings") +local OAK_PC_TEXT = { + completion = Strings.source("Current #DEX\ncompletion level:"), + counts = Strings.source( + "%d #MON seen\n%d #MON owned\fPROF.OAK's\nRating:"), +} + +-- OakRatings (data/events/pokedex_ratings.asm). Each row is (cap, sfx, +-- text); FindOakRating walks the table with `cp c / jr nc, .match` against +-- ascending caps, which is "the first row whose cap covers the caught +-- count" -- exactly what findOakRating below does. sfx is the Gold sfx +-- table's own label (audio/sfx_pointers.asm dba lines), not a pokered +-- fanfare name. +local OAK_RATINGS = { + { max = 9, sfx = "Sfx_DexFanfareLessThan20", text = Strings.source( + "Look for #MON\nin grassy areas!") }, + { max = 19, sfx = "Sfx_DexFanfareLessThan20", text = Strings.source( + "Good. I see you\nunderstand how to\vuse # BALLS.") }, + { max = 34, sfx = "Sfx_DexFanfare2049", text = Strings.source( + "You're getting\ngood at this.\fBut you have a\nlong way to go.") }, + { max = 49, sfx = "Sfx_DexFanfare2049", text = Strings.source( + "You need to fill\nup the #DEX.\fCatch different\nkinds of #MON!") }, + { max = 64, sfx = "Sfx_DexFanfare5079", text = Strings.source( + "You're trying--I\ncan see that.\fYour #DEX is\ncoming together.") }, + { max = 79, sfx = "Sfx_DexFanfare5079", text = Strings.source( + "To evolve, some\n#MON grow,\fothers use the\neffects of STONES.") }, + { max = 94, sfx = "Sfx_DexFanfare80109", text = Strings.source( + "Have you gotten a\nfishing ROD? You\fcan catch #MON\nby fishing.") }, + { max = 109, sfx = "Sfx_DexFanfare80109", text = Strings.source( + "Excellent! You\nseem to like col-\vlecting things!") }, + { max = 124, sfx = "Sfx_CaughtMon", text = Strings.source( + "Some #MON only\nappear during\fcertain times of\nthe day.") }, + { max = 139, sfx = "Sfx_CaughtMon", text = Strings.source( + "Your #DEX is\nfilling up. Keep\vup the good work!") }, + { max = 154, sfx = "Sfx_DexFanfare140169", text = Strings.source( + "I'm impressed.\nYou're evolving\f#MON, not just\ncatching them.") }, + { max = 169, sfx = "Sfx_DexFanfare140169", text = Strings.source( + "Have you met KURT?\nHis custom #\vBALLS should help.") }, + { max = 184, sfx = "Sfx_DexFanfare170199", text = Strings.source( + "Wow. You've found\nmore #MON than\fthe last #DEX\nresearch project.") }, + { max = 199, sfx = "Sfx_DexFanfare170199", text = Strings.source( + "Are you trading\nyour #MON?\fIt's tough to do\nthis alone!") }, + { max = 214, sfx = "Sfx_DexFanfare200229", text = Strings.source( + "Wow! You've hit\n200! Your #DEX\vis looking great!") }, + { max = 229, sfx = "Sfx_DexFanfare200229", text = Strings.source( + "You've found so\nmany #MON!\fYou've really\nhelped my studies!") }, + { max = 239, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( + "Magnificent! You\ncould become a\f#MON professor\nright now!") }, + { max = 248, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( + "Your #DEX is\namazing! You're\fready to turn\nprofessional!") }, + -- The top band (251 real species, the table's cap of 255 covers it): this + -- is the ONLY place the ROM checks "has the player finished the #DEX", and + -- it does it here rather than handing off to anything. The actual diploma + -- is not this special's business -- GameFreakGameDesignerScript in + -- Celadon Mansion 3F reads VAR_DEXCAUGHT for itself and is what runs + -- `special Diploma` (H.Diploma below, src/ui/gen2/Diploma.lua) and + -- then sets EVENT_ENABLE_DIPLOMA_PRINTING for the Graphic Artist's + -- `special PrintDiploma` (H.PrintDiploma, specials/crystal_extras.lua). + -- A finished #DEX here just means every rating after this one is this + -- same line. + { max = 255, sfx = "Sfx_DexFanfare230Plus", text = Strings.source( + "Whoa! A perfect\n#DEX! I've\fdreamt about this!\nCongratulations!") }, +} + +-- CountSetBits over wPokedexSeen/wPokedexCaught. The port's dex is a +-- species-keyed bool map (src/core/gen2/Save.lua), not a bitfield, so this +-- counts `true` entries the same way Save.summary counts wPokedexCaught for +-- the CONTINUE panel. +local function dexCounts(record) + local dex = record and record.pokedex + local seen, caught = 0, 0 + for _, has in pairs((dex and dex.seen) or {}) do + if has then seen = seen + 1 end + end + for _, has in pairs((dex and dex.owned) or {}) do + if has then caught = caught + 1 end + end + return seen, caught +end + +-- FindOakRating. NUM_POKEMON is 251, so `caught` never exceeds the table's +-- own top cap of 255 and this never falls off the end. +local function findOakRating(caught) + for _, row in ipairs(OAK_RATINGS) do + if caught <= row.max then return row end + end + return OAK_RATINGS[#OAK_RATINGS] +end + +return {OAK_PC_TEXT=OAK_PC_TEXT,dexCounts=dexCounts,findOakRating=findOakRating} diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..c309402b0 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -32,8 +32,6 @@ -- hook, not one module -- src.world.gen2.Follower PikachuFollower is Yellow's, not a general -- party follower --- src.ui.gen2.Chrome no shared window-chrome module --- src.ui.gen2.SaveMenu the save flow is part of StartMenu here -- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent -- src.ui.PartyPanel no equivalent @@ -66,7 +64,15 @@ ModCompat.ALIASES = { ["src.world.gen2.Palettes"] = "src.render.PaletteFX", -- save-side data - ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.core.gen2.Boxes"] = "src.pokemon.Gen2Boxes", + ["src.core.gen2.Mail"] = "src.pokemon.Gen2Mail", + ["src.ui.gen2.BoxMenu"] = "src.ui.Gen2BoxMenu", + ["src.ui.gen2.NamingScreen"] = "src.ui.Gen2NamingScreen", + ["src.ui.gen2.NamePick"] = "src.ui.Gen2NamePick", + ["src.ui.gen2.SaveMenu"] = "src.ui.Gen2SaveMenu", + ["src.core.gen2.Decorations"] = "src.compat.gen2.Decorations", + ["src.core.gen2.Clock"] = "src.core.Gen2Clock", + ["src.ui.gen2.InitClock"] = "src.ui.Gen2InitClock", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", @@ -80,24 +86,32 @@ ModCompat.ALIASES = { ["src.battle.gen2.Effects"] = "src.battle.MoveEffects", ["src.battle.gen2.Mon"] = "src.pokemon.Pokemon", ["src.ui.gen2.BattleState"] = "src.battle.BattleState", + ["src.ui.gen2.Chrome"] = "src.ui.Gen2Chrome", -- menus. The Gold port names two of these differently rather than -- namespacing them, so both spellings land on the one implementation. - ["src.ui.gen2.StartMenu"] = "src.ui.StartMenu", + ["src.ui.gen2.PcMenu"] = "src.ui.Gen2PcMenu", + ["src.ui.gen2.CenterPcMenu"] = "src.ui.Gen2CenterPcMenu", + ["src.ui.gen2.ItemPcMenu"] = "src.ui.Gen2ItemPcMenu", + ["src.ui.gen2.MailboxMenu"] = "src.ui.Gen2MailboxMenu", + ["src.ui.gen2.DecorationMenu"] = "src.ui.Gen2DecorationMenu", + ["src.ui.gen2.MailRead"] = "src.ui.Gen2MailRead", + ["src.ui.gen2.StartMenu"] = "src.ui.Gen2StartMenu", ["src.ui.gen2.OptionsMenu"] = "src.ui.OptionsMenu", ["src.ui.gen2.PartyMenu"] = "src.ui.PartyMenu", ["src.ui.gen2.PokedexMenu"] = "src.ui.PokedexMenu", ["src.ui.gen2.SummaryMenu"] = "src.ui.SummaryMenu", ["src.ui.gen2.TrainerCard"] = "src.ui.TrainerCard", ["src.ui.gen2.Pokegear"] = "src.ui.PokegearMenu", - ["src.ui.gen2.PackMenu"] = "src.ui.BagMenu", + ["src.ui.gen2.PackMenu"] = "src.ui.Gen2PackMenu", ["src.menu.PartyMenu"] = "src.ui.PartyMenu", ["src.ui.PartyScreen"] = "src.ui.PartyMenu", ["src.ui.party.PartyScreen"] = "src.ui.PartyMenu", -- the Gold port splits its Game class per generation; this one does not ["src.core.Game2"] = "src.core.Game", - ["src.render.GbcPalette"] = "src.render.GBCFX", + -- Indexed image palettes and LCD screen effects are different contracts. + ["src.render.GbcPalette"] = "src.render.Gen2PaletteDraw", } local seen = {} diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..387dbf6e1 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -445,7 +445,7 @@ function SaveData.defaultOptions() -- moves the screen furniture, so it is opt-in. -- See Game.dynamicUI, Renderer:setUIAnchor and Renderer:uiScale. uiLayout = "centered", - ruleset = "gen1_faithful", + ruleset = false, -- automatic cartridge default; explicit saved ids remain valid -- 0-7 like the GB's NR50 master volume musicVol = 7, sfxVol = 7, @@ -963,6 +963,30 @@ function SaveData.playSeconds(save) + ticks / 60 end +-- The native save owns a scalar play clock and an owned-species map. Expose +-- the reference UI summary without requiring callers to reinterpret either +-- representation, load a profile, or create a second save/time store. +function SaveData.summary(save, data) + if type(save) ~= "table" then return nil end + local caught = 0 + for _, has in pairs((save.pokedex and save.pokedex.owned) or {}) do + if has then caught = caught + 1 end + end + local seconds = SaveData.playSeconds(save) + if seconds ~= seconds or seconds == math.huge or seconds == -math.huge then seconds = 0 end + seconds = math.max(0, math.floor(seconds)) + local spawn = save.spawn + return { + name = save.player and save.player.name or "?", + badges = Badges.count(data, save, save.version), + caught = caught, + hours = math.floor(seconds / 3600), + minutes = math.floor(seconds / 60) % 60, + map = save.player and save.player.map or (save.position and save.position.map) + or (type(spawn) == "table" and spawn.map or spawn), + } +end + function SaveData.slotSummary(save, version) if type(save) ~= "table" then return nil, nil end local name = save.player and save.player.name or nil @@ -2030,6 +2054,13 @@ local function scrubMonList(list, where, save, data, report, slots) if mon ~= nil and (type(mon) ~= "table" or not known(data.pokemon, mon.species)) then list[i] = nil + if where=="party" and GameVersion.isGen2() and type(mon)=="table" + and save.mail and save.mail.party and save.mail.party[i] + and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then + -- Its letter moves to the mailbox/quarantine below; retaining a + -- second stationery on the quarantined carrier would duplicate it. + mon.item=nil + end ensureOrphaned(save) save.orphaned.mons[#save.orphaned.mons + 1] = mon report.lostMons[#report.lostMons + 1] = @@ -2160,6 +2191,10 @@ function SaveData.validate(save, data) end end scrubMaps(save, data, report) + if GameVersion.isGen2() then + local Mail=require("src.pokemon.Gen2Mail") + Mail.load(data);Mail.validate(save,report) + end local dex = save.pokedex if type(dex) == "table" then for _, key in ipairs({ "seen", "owned" }) do @@ -2228,7 +2263,8 @@ function SaveData.validate(save, data) -- an empty quarantine leaves no residue, so a vanilla save re-encodes -- byte-identically local orphaned = save.orphaned - if orphaned and #(orphaned.mons or {}) == 0 and #(orphaned.items or {}) == 0 then + if orphaned and #(orphaned.mons or {}) == 0 and #(orphaned.items or {}) == 0 + and #(orphaned.mail or {})==0 then save.orphaned = nil end return report @@ -2239,6 +2275,7 @@ function SaveData.emptyReport(report) -- restoreSave attaches one so a version bump alone still surfaces local diff = report.modsDiff return #report.lostMons == 0 and #report.lostItems == 0 + and #(report.lostMail or {})==0 and #(report.restoredMail or {})==0 and #report.remappedMaps == 0 and #report.restoredMons == 0 and #report.restoredItems == 0 and not report.recovered and (not diff or (#diff.added == 0 and #diff.removed == 0 and #diff.changed == 0)) diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index baf622c4c..c013795f5 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -2,6 +2,7 @@ -- (so a text box can overlay the overworld, a battle replaces it, etc). -- States are tables with optional enter/exit/update/draw/isOpaque. +local NativeScreenOverlay = require("src.render.NativeScreenOverlay") local Runtime = require("src.mods.Runtime") local Logger = require("src.core.Logger") @@ -191,7 +192,7 @@ end function StateStack:draw() for i = self:visibleBase(), #self.states do - if self.states[i].draw then self.states[i]:draw() end + if self.states[i].draw then NativeScreenOverlay.screen(self.states[i]) end end end diff --git a/src/import/Gen2ScriptOps.lua b/src/import/Gen2ScriptOps.lua index 7e7d90ce4..ec0ff1339 100644 --- a/src/import/Gen2ScriptOps.lua +++ b/src/import/Gen2ScriptOps.lua @@ -37,7 +37,7 @@ Gen2ScriptOps.COMMANDS = { { "takecoins", "w" }, { "checkcoins", "w" }, { "addcellnum", "b" }, { "delcellnum", "b" }, { "checkcellnum", "b" }, { "checktime", "b" }, { "checkpoke", "b" }, { "givepoke", "bbbbbD" }, { "giveegg", "bb" }, - { "givepokemail", "T" }, { "checkpokemail", "T" }, { "checkevent", "w" }, + { "givepokemail", "d" }, { "checkpokemail", "d" }, { "checkevent", "w" }, { "clearevent", "w" }, { "setevent", "w" }, { "checkflag", "w" }, { "clearflag", "w" }, { "setflag", "w" }, { "wildon", "" }, { "wildoff", "" }, { "xycompare", "d" }, { "warpmod", "bbb" }, @@ -95,7 +95,7 @@ Gen2ScriptOps.COMMANDS = { -- Crystal's ScriptCommandTable is NOT Gold's with entries appended. It -- inserts `farjumptext` at $52, which shifts all 88 commands after it down by --- one, and it changes the operand width of seven commands that kept their +-- one, and it changes the operand width of several commands that kept their -- names. Disassembling a Crystal ROM with the Gold table desyncs on the -- first shifted command and never recovers, so Crystal gets its own table. -- @@ -103,11 +103,9 @@ Gen2ScriptOps.COMMANDS = { -- _command` run gives the order, and each MACRO body gives the operand -- widths. Where a command kept both its name and its total width, Gold's -- spec letters are reused verbatim so the semantic distinctions (p vs t vs d) --- carry over; the seven that changed width are listed below. +-- carry over; the width differences are listed below. -- -- $10 memcallasm dba -> dw (D -> d) --- $2F givepokemail dba -> dw (T -> d) --- $30 checkpokemail dba -> dw (T -> d) -- $92 reloadend 0 args -> 1 ("" -> b) -- $98 phonecall dba -> dw (T -> w) -- $A0 swarm +map_id (bb -> bbb) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..0d0a655c1 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3317,6 +3317,179 @@ function RomExtractorGen2:gen2MovementTable() return out end +-- Six native bytes per attribute row: kind/name/action/event-word/sprite. +-- Names immediately follow attributes in stock GSC; that boundary supplies +-- the row count instead of a copied retail catalogue. +-- Native Mom tables have eight bytes: two big-endian money triples and +-- kind/item bytes. End symbols supply counts, never a copied retail list. +function RomExtractorGen2:gen2MomShopping(pool) + if self.version~="gold"and self.version~="silver"and self.version~="crystal"then return nil end + local result={complete=false} + local ok,why=pcall(function() + for role,name in pairs({random="MomItems_1",ladder="MomItems_2"})do + local first,last=self:symbol(name),self:symbol(name..".End") + assert(first and last and first.bank==last.bank,"missing Mom table bounds") + local count=(last.address-first.address)/8 + assert(count%1==0 and count>0 and count<=255 and first.address>=0x4000 and last.address<=0x8000,"invalid Mom table bounds") + local rows={} + for i=0,count-1 do + local b=self.rom:bytes(first.bank,first.address+i*8,8) + local trigger=b[1]*65536+b[2]*256+b[3];local cost=b[4]*65536+b[5]*256+b[6] + assert(trigger<=999999 and cost<=999999 and(b[7]==1 or b[7]==2)and b[8]>0 and b[8]<255,"invalid Mom row") + rows[#rows+1]={trigger=trigger,cost=cost,kind=b[7],item=b[8]} + end + result[role]=rows + end + local at,ram=self:symbol("CheckBalance_MomItem2.check_have_2300"),self:symbol("hMoneyTemp") + assert(at and ram,"missing Mom trigger instructions") + local b=self.rom:bytes(at.bank,at.address,11) + assert(b[1]==0x21 and b[2]+b[3]*256==ram.address and b[4]==0x36 and b[6]==0x23 and + b[7]==0x36 and b[9]==0x23 and b[10]==0x36,"unrecognized Mom trigger instructions") + result.step=b[5]*65536+b[8]*256+b[11] + assert(result.step>0 and result.step<=999999,"invalid Mom trigger amount") + end) + if not ok then return{complete=false,reason=tostring(why)}end + local found={} + for role,name in pairs({item="Mom_GetScriptPointer.ItemScript",doll="Mom_GetScriptPointer.DollScript"})do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return{complete=false,reason="missing Mom phone program"}end + found[role]=at + end + result.programs={} + for role,at in pairs(found)do result.programs[role]=self:gen2QueueScript(at.bank,at.address,pool)end + result.rewardTexts={} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for mode,name in pairs({[0]="GotMoneyForWinningText",[1]="SentSomeToMomText",[2]="SentHalfToMomText",[3]="SentAllToMomText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then result.rewardTexts[mode]=self:gen2RegisterScriptText(at.bank,at.address)end + end + result.complete=true;return result +end + +function RomExtractorGen2:gen2Decorations() + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local info={complete=false,rowsRead=0,defaultsComplete=false,giftsComplete=false} + local attr,names=self:symbol("DecorationAttributes"),self:symbol("DecorationNames") + local function fail(why)info.reason=why;return {},info end + if not attr or not names then return fail("missing decoration table symbols")end + local count=(names.address-attr.address)/6 + if attr.bank~=names.bank or count%1~=0 or count<1 or count>256 or + attr.address<0x4000 or names.address>0x8000 then return fail("invalid decoration table boundary")end + local Native=require("src.world.Gen2Decorations") + local ok,rows=pcall(function() + local raw,maxName={},0 + for i=0,count-1 do + local r=self.rom:bytes(attr.bank,attr.address+i*6,6) + assert(r[1]>=1 and r[1]<=6,"invalid decoration kind") + assert(r[3]==0 or Native.actionName(r[3]),"invalid decoration action") + assert(r[4]+r[5]*256<65535,"indirect decoration event") + raw[i]=r;if r[1]<=3 then maxName=math.max(maxName,r[2])end + end + local charmap=self:readSourceTable("charmap") + local words,pc={},names.address + for i=0,maxName do + local bytes,ended={},false + for _=1,128 do + assert(pc<0x8000,"decoration name crosses bank") + local b=self.rom:byte(names.bank,pc);pc=pc+1 + if b==0x50 then ended=true;break end + bytes[#bytes+1]=b + end + assert(ended,"unterminated decoration name") + words[i]=self:gen2GlyphBytes(bytes,charmap) + end + local pokemon=self:symbol("PokemonNames") + local out={} + for i=0,count-1 do + local r=raw[i];local name=words[r[2]] + if r[1]>=4 then + assert(pokemon and r[2]>=1,"missing decoration species name") + local address=pokemon.address+(r[2]-1)*10 + assert(address>=0x4000 and address+10<=0x8000,"decoration species name crosses bank") + local bytes={} + for n=0,9 do local b=self.rom:byte(pokemon.bank,address+n);if b==0x50 then break end;bytes[#bytes+1]=b end + name=self:gen2GlyphBytes(bytes,charmap) + info.species=info.species or{};info.sourceNames=info.sourceNames or{} + info.species[i]=string.format("SPECIES_%03d",r[2]);info.sourceNames[i]=name + end + assert(type(name)=="string" and name~="","missing decoration name") + out[i]={type=r[1],name=name,action=Native.actionName(r[3]),flag=r[4]+r[5]*256,sprite=r[6]} + end + return out + end) + if not ok then return fail(tostring(rows))end + local init=self:symbol("InitDecorations") + if init then + local valid,bytes=pcall(self.rom.bytes,self.rom,init.bank,init.address,11) + if valid and bytes[1]==0x3e and bytes[3]==0xea and bytes[6]==0x3e and bytes[8]==0xea and bytes[11]==0xc9 and + rows[bytes[2]] and rows[bytes[2]].action=="SET_UP_BED" and rows[bytes[7]] and rows[bytes[7]].action=="SET_UP_POSTER" then + local bed,poster=self:symbol("wDecoBed"),self:symbol("wDecoPoster") + if bed and poster and bytes[4]+bytes[5]*256==bed.address and bytes[9]+bytes[10]*256==poster.address then + info.defaults={bed=bytes[2],poster=bytes[7]};info.defaultsComplete=true + end + end + end + local gifts=self:symbol("DecorationIDs") + if gifts then + local valid,list=pcall(function() + local out={} + for n=0,255 do + assert(gifts.address+n<0x8000,"decoration gift table crosses bank") + local id=self.rom:byte(gifts.bank,gifts.address+n) + if id==255 then return out end + assert(rows[id] and Native.action(rows[id].action) and not Native.action(rows[id].action).put,"invalid gift decoration") + out[#out+1]=id + end + error("unterminated decoration gift table") + end) + if valid then info.gifts=list;info.giftsComplete=true end + end + info.complete=true;info.rowsRead=count + return rows,info +end + +function RomExtractorGen2:gen2QueueDecorationPrograms(pool) + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local roles={ornament="DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript", + big="DecorationDesc_GiantOrnament.BigDollScript",null="DecorationDesc_NullPoster"} + local found={} + for role,name in pairs(roles)do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return nil end + found[role]=at + end + local pointers=self:symbol("DecorationDesc_PosterPointers") + if not pointers then return nil end + local ok,posters=pcall(function() + local out={} + for n=0,255 do + local pc=pointers.address+n*3;assert(pc<0x8000,"poster pointers cross bank") + local id=self.rom:byte(pointers.bank,pc) + if id==255 then return out end + assert(pc+2<0x8000,"truncated poster pointer") + local address=self.rom:word(pointers.bank,pc+1) + assert(address>=0x4000 and self:gen2InRom(pointers.bank,address)and not out[id],"invalid poster pointer") + out[id]={bank=pointers.bank,address=address} + end + error("unterminated poster pointers") + end) + if not ok then return nil end + local out={posters={},texts={}} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for role,name in pairs({put="PutAwayTheDecoText",empty="NothingToPutAwayText", + setup="SetUpTheDecoText",replace="PutAwayAndSetUpText",same="AlreadySetUpText", + noChoice="PopulateDecoCategoryMenu.NothingToChooseText", + sideSetup="WhichSidePutOnText",sidePut="WhichSidePutAwayText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then + out.texts[role]=self:gen2RegisterScriptText(at.bank,at.address) + end + end + for role,at in pairs(found)do out[role]=self:gen2QueueScript(at.bank,at.address,pool)end + for id,at in pairs(posters)do out.posters[id]=self:gen2QueueScript(at.bank,at.address,pool)end + return out +end + -- Marts is a dw pointer table; each mart is `db count`, the item ids, `db -1`. -- The table ends where its own first entry starts. function RomExtractorGen2:gen2Marts() @@ -5522,6 +5695,8 @@ function RomExtractorGen2:gen2MapIndex() -- PALETTE_DARK, the only value ReplaceTimeOfDayPals answers with -- .NeedsFlash (23:$4400) palette = field(paletteAt, 0) % 16, + paletteBits = field(paletteAt,0), + phoneService = math.floor(field(paletteAt,0)/16)%16==0, } byGroupNumber[group * 256 + number] = entry byLabel[label] = byLabel[label] or entry @@ -6521,6 +6696,7 @@ function RomExtractorGen2:extractMapsFromRom() -- is why a Pokemon Center does not go dark at night on hardware -- and -- the Ruins of Alph chambers carry it too. def.mapPalette = header and header.palette or def.mapPalette + if header then def.mapPaletteBits=header.paletteBits;def.phoneService=header.phoneService end -- Byte 8 is the FISHGROUP_* constant, and 0 is FISHGROUP_NONE -- NOT -- FISHGROUP_SHORE, which is 1 (constants/map_data_constants.asm). -- gen2Fishing keys its table by that same constant, so 0 finds nothing @@ -7705,6 +7881,35 @@ function RomExtractorGen2:gen2StoneTable(bank, address, pool) return ok and rows and #rows > 0 and rows or nil end +-- Mail operands are raw charmap records, not dialogue streams or scripts. +-- Both retail mail commands use a word in the current script bank. +function RomExtractorGen2:gen2MailOperand(bank,address,gift) + if not self:gen2InRom(bank,address) then return nil end + bank=self:gen2Home(bank,address) + self._gen2MailCharmap=self._gen2MailCharmap or self:readSourceTable("charmap") + local limit=bank==0 and 0x4000 or 0x8000 + local ok,result=pcall(function() + local item + if gift then + item=string.format("ITEM_%03d",self.rom:byte(bank,address));address=address+1 + end + local out={} + for offset=0,31 do + if address+offset>=limit then return nil end + local byte=self.rom:byte(bank,address+offset) + if byte==0x50 then break end + if byte==0x4E or byte==0x4F then out[#out+1]="\n" + else + local ch=self._gen2MailCharmap[tostring(byte)] + if type(ch)~="string" or ch:sub(1,1)=="<" then return nil end + out[#out+1]=ch + end + end + return {item=item,message=table.concat(out)} + end) + return ok and result or nil +end + function RomExtractorGen2:gen2DecodeScript(bank, address, label, pool) -- Crystal inserts farjumptext at $52 and shifts 88 commands after it, so -- the table has to be chosen per version -- decoding Crystal with Gold's @@ -7754,7 +7959,11 @@ function RomExtractorGen2:gen2DecodeScript(bank, address, label, pool) for i = 1, #spec do local kind = spec:sub(i, i) local value - if kind == "b" then + if name=="givepokemail" or name=="checkpokemail" then + local from,at=bank,self.rom:word(bank,pc) + if kind=="T" then from,at=self.rom:byte(bank,pc),self.rom:word(bank,pc+1) end + value=self:gen2MailOperand(from,at,name=="givepokemail") or false + elseif kind == "b" then value = self.rom:byte(bank, pc) elseif kind == "w" or kind == "d" then value = self.rom:word(bank, pc) @@ -8126,7 +8335,10 @@ function RomExtractorGen2:gen2ResolveScriptMapIds(maps, keys, pool) if slot and type(row[slot]) == "number" and type(row[slot + 1]) == "number" then local entry = mapIndex[row[slot] * 256 + row[slot + 1]] local key = entry and keyByLabel[entry.label] - if key then + if (row[1]=="warp"or row[1]=="warpfacing") and row[slot]==0 and row[slot+1]==0 and + (self.version=="gold"or self.version=="silver"or self.version=="crystal")then + row[slot]="NONE";row[slot+1]=0 + elseif key then row[slot] = key row[slot + 1] = 0 end @@ -8490,7 +8702,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) entry = { class = class, trainer = trainer } elseif trainer ~= 0 and names[trainer] then local name, role = names[trainer]:match("^([^\n\f\v]+)[\n\f\v]%s*(.*)$") - entry = { name = (name or names[trainer]):gsub(":$", ""), role = role } + -- Keep the non-trainer's native identity; names are localizable. + entry = { name = (name or names[trainer]):gsub(":$", ""), role = role, + class = 0, contactNumber = trainer } end -- The contact's OWN map, bytes 3 and 4. GetAvailableCallers (36:$40DE) -- compares it against wMapGroup/wMapNumber and drops the contact from the @@ -8891,6 +9105,9 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts end out.stds = self:gen2QueueStdScripts(pool) + out.momShopping=self:gen2MomShopping(pool) + out.decorations,out.decorationInfo=self:gen2Decorations() + out.decorationPrograms=self:gen2QueueDecorationPrograms(pool) out.marts = self:gen2Marts() out.spawns = self:gen2SpawnPoints(maps, keys) -- extractField runs after this stage and needs both of these: GSC has no diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bbd959cfd..29bae50b3 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1316,7 +1316,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-field-encounters-v8:" + revision = "gen2-mom-shopping-native-v1:gen2-decorations-native-v1:gen2-field-encounters-v8:gen2-mail-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index b09bf37e8..8ea27f11a 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -138,6 +138,28 @@ function Bag.order(save) return order end +-- Move within one pocket while retaining the positions of every other +-- pocket in the shared acquisition order. Never move counts or item stores. +function Bag.move(save, id, pocket, targetIndex, data) + local order = Bag.order(save) + data = data or require("src.core.Data") + if pocketOf(id, data) ~= pocket then return false end + local positions, rows, from = {}, {}, nil + for i, item in ipairs(order) do + if pocketOf(item, data) == pocket then + positions[#positions+1], rows[#rows+1] = i, item + if item == id then from = #rows end + end + end + local n = tonumber(targetIndex) + if not from or not n or n ~= n or n == math.huge or n == -math.huge then return false end + local to = math.max(1, math.min(#rows, math.floor(n))) + if from == to then return false end + table.insert(rows, to, table.remove(rows, from)) + for i, index in ipairs(positions) do order[index] = rows[i] end + return true +end + -- Add qty of an item; returns false (and adds nothing) when a new slot -- is needed and the pocket (Gen2) or bag (Gen1) is full, or when the -- stack would pass 99 (AddItemToInventory's per-slot quantity cap). @@ -272,6 +294,9 @@ local function heldItemForm(mon, data) end function Bag.giveHeld(save, mon, id, data) + if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then + return nil,"mail" + end local previous = mon.item mon.item = id Bag.remove(save, id, 1) @@ -283,6 +308,9 @@ end -- TakeItem: nil when the mon holds nothing, or nil + "full" when the pack has -- no room for it (the mon keeps holding it). function Bag.takeHeld(save, mon, data) + if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then + return nil,"mail" + end local id = mon.item if not id then return nil end if not Bag.add(save, id, 1, data) then return nil, "full" end diff --git a/src/inventory/Gen2PcItems.lua b/src/inventory/Gen2PcItems.lua new file mode 100644 index 000000000..4f9ddf475 --- /dev/null +++ b/src/inventory/Gen2PcItems.lua @@ -0,0 +1,63 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- ../pokecrystal/engine/events/pokecenter_pc.asm:568 +-- ../pokecrystal/engine/items/switch_items.asm:1 + +local PcItems = {} + +local function indexOf(items, id) + local def = items and items[id] + return (def and def.index) or math.huge +end + +local function byIndex(items) + return function(a, b) + local ia, ib = indexOf(items, a), indexOf(items, b) + if ia ~= ib then return ia < ib end + return a < b + end +end + +function PcItems.order(save, items) + save.pcItems = save.pcItems or {} + local order = save.pcOrder + if type(order) ~= "table" then + order = {} + for id in pairs(save.pcItems) do order[#order + 1] = id end + table.sort(order, byIndex(items)) + save.pcOrder = order + end + local seen = {} + for i = #order, 1, -1 do + local id = order[i] + if not save.pcItems[id] or seen[id] then + table.remove(order, i) + else + seen[id] = true + end + end + local added = {} + for id in pairs(save.pcItems) do + if not seen[id] then added[#added + 1] = id end + end + table.sort(added, byIndex(items)) + for _, id in ipairs(added) do order[#order + 1] = id end + return order +end + +-- engine/items/switch_items.asm:38, :64 +function PcItems.move(save, id, toIndex, items) + local order = PcItems.order(save, items) + local from + for i = 1, #order do + if order[i] == id then from = i break end + end + if not from then return false end + local to = math.max(1, math.min(math.floor(tonumber(toIndex) or from), #order)) + -- engine/items/switch_items.asm:33 + if to == from then return false end + table.insert(order, to, table.remove(order, from)) + return true +end + +return PcItems diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index c9119139b..8e6fd9e3b 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -1118,7 +1118,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local amount = itemId == "MYSTERYBERRY" and 5 or 10 local function restore(mv) local mdef = data.moves[mv.id] - local maxPP = mdef and (mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5)) + local maxPP = mdef and require("src.pokemon.Pokemon").maxPP(mdef, mv) if maxPP and mv.pp < maxPP then mv.pp = full and maxPP or math.min(maxPP, mv.pp + amount) return true @@ -1316,10 +1316,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if not target then return "failed", { Strings("It won't have\nany effect.") } end local mv = target.moves[moveIndex or 1] local mdef = mv and data.moves[mv.id] - if mdef and (mv.ppUps or 0) < 3 then - mv.ppUps = (mv.ppUps or 0) + 1 - -- each PP UP adds maxPP/5 uses on top of the base maximum - mv.pp = mv.pp + math.floor(mdef.pp / 5) + local gen2 = require("src.core.GameVersion").isGen2() + local ups = gen2 and require("src.pokemon.Gen2PP").ups(mdef, mv) or mv and (mv.ppUps or 0) or 0 + if mdef and ups < 3 and not (gen2 and mv.id == "SKETCH") then + mv.ppUps = ups + 1 + local bonus = math.floor(mdef.pp / 5) + if gen2 then bonus = require("src.pokemon.Gen2PP").bonus(mdef.pp) end + mv.pp = mv.pp + bonus + if gen2 then mv.maxPp = require("src.pokemon.Pokemon").maxPP(mdef, mv, 2) end return "consumed", { Strings("%s's PP\nincreased!", mdef.name) } end return "failed", { Strings("It won't have\nany effect.") } diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 21881d63f..9b056f52e 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -14,6 +14,7 @@ -- boosts don't apply on either side (divergence: Gen 1 famously kept -- them in link battles). +local BattleQueuePhase = require("src.battle.BattleQueuePhase") local Fingerprint = require("src.link.Fingerprint") local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") @@ -271,7 +272,7 @@ function LinkBattle.new(game, net, opts) if s.linkEnded then return end s.result = "draw" s.afterQueue = "finish" - s.phase = "messages" + s.phase = BattleQueuePhase.name() if text then s:say(text) end end @@ -282,7 +283,7 @@ function LinkBattle.new(game, net, opts) if s.linkEnded then return end s.result = result s.afterQueue = "finish" - s.phase = "messages" + s.phase = BattleQueuePhase.name() if text then s:say(text) end end @@ -395,7 +396,7 @@ function LinkBattle.new(game, net, opts) endAsDraw(s, Strings("%s ran from\nthe battle!", who)) return end - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "linkNext" s.turnCount = (s.turnCount or 0) + 1 @@ -514,7 +515,7 @@ function LinkBattle.new(game, net, opts) -- the party menu must offer the clamped link copies self.openParty = function(s) - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "menu" s:ui(function() return s:buildScreen("PartyMenu", { @@ -535,7 +536,7 @@ function LinkBattle.new(game, net, opts) self.openItems = function(s) s:say(Strings("Items can't be\nused in a link\nbattle!")) - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "menu" end @@ -617,11 +618,12 @@ function LinkBattle.new(game, net, opts) s:tickFx() return -- the other side is still choosing end - if s.phase == "messages" and s.afterQueue == "linkNext" then + if BattleQueuePhase.active(s) and s.afterQueue == "linkNext" then s:tickFx() if not s:updateQueue() then s.afterQueue = "menu" s.phase = "menu" + s:clearQueueMessage() end return end @@ -734,7 +736,7 @@ function LinkBattle.newSpectator(game, net, opts) if s.linkEnded then return end s.result = s.result or "ended" s.afterQueue = "finish" - s.phase = "messages" + s.phase = BattleQueuePhase.name() if text then s:say(text) end end @@ -780,7 +782,7 @@ function LinkBattle.newSpectator(game, net, opts) endSpectate(s, "The match ended.") return end - s.phase = "messages" + s.phase = BattleQueuePhase.name() s.afterQueue = "linkNext" s.turnCount = (s.turnCount or 0) + 1 @@ -901,11 +903,12 @@ function LinkBattle.newSpectator(game, net, opts) -- same as the real-participant loop: every path that skips baseUpdate -- still has to advance the presentational clock, and a spectator sits in -- waitBoth between every single turn - if s.phase == "messages" and s.afterQueue == "linkNext" then + if BattleQueuePhase.active(s) and s.afterQueue == "linkNext" then s:tickFx() if not s:updateQueue() then s.afterQueue = "waitBoth" s.phase = "waitBoth" + s:clearQueueMessage() end return end diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index 787c37899..0e8398c8c 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -114,7 +114,7 @@ function Protocol.unpackMon(data, packed, opts) local mdef = data.moves[mv.id] if mdef and #moves < 4 then local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0))) - local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5) + local maxPP = require("src.pokemon.Pokemon").maxPP(mdef, { ppUps=ppUps }, data.constants and data.constants.gen) local entry = { id = mv.id, pp = math.max(0, math.min(maxPP, math.floor(mv.pp or 0))) } if mv.ppUps ~= nil then entry.ppUps = ppUps end diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua index 470f67ad7..1bde4bf0c 100644 --- a/src/mods/Builtins.lua +++ b/src/mods/Builtins.lua @@ -32,6 +32,7 @@ local REGISTRANTS = { { name = "tokens", from = "src.render.TextBox" }, -- plain data files with no owning module: registered from here { name = "rulesets", modules = { "src.battle.rulesets.gen1_faithful", + "src.battle.rulesets.gen2_finite_pp", "src.battle.rulesets.modern_clean", "src.battle.rulesets.gen3_emerald", "src.battle.rulesets.gen4_platinum" }, diff --git a/src/mods/DecorationContent.lua b/src/mods/DecorationContent.lua new file mode 100644 index 000000000..e6d1ddd16 --- /dev/null +++ b/src/mods/DecorationContent.lua @@ -0,0 +1,100 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- A DECO byte is identity, not the sorted position of a mod record. +local Merge=require('src.mods.Merge') +local Native=require('src.world.Gen2Decorations') +local Schemas=require('src.mods.Schemas') +local M={} +local copy=Merge.deepCopy +local mounted=setmetatable({},{__mode='k'}) +local previous=setmetatable({},{__mode='k'}) +local function equal(a,b) + if a==b then return true end + if type(a)~='table'or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function byte(id) + local digits=type(id)=='string'and id:match('^deco:(%d+)$') + local n=digits and tonumber(digits) + assert(n and n>=0 and n<=255 and 'deco:'..n==id,'decorations: expected canonical deco: ID') + return n +end +local function finite(value,seen) + if type(value)=='number'then assert(value==value and math.abs(value)=0 and n<=255 and type(row)=='table'then + state.base['deco:'..n]=copy(data.map_scripts.decorations[n]or row) + end + end + end + mounted[loader]=state + loader.content.decorations.base=function()return state.base end +end +function M.apply(loader,data) + local registry=loader.content.decorations;local state=mounted[loader] + if not(state and state.available)or not next(registry.ops)then return end + local pool=data.map_scripts;local before=pool.decorations;local out={} + for key,row in pairs(before)do if tonumber(key)==nil then out[key]=row end end + for id,value in registry:each()do out[byte(id)]=registry.ops[id]and record(registry,id,value)or copy(value)end + local changed={};local keys={} + for key in pairs(before)do keys[key]=true end;for key in pairs(out)do keys[key]=true end + for key in pairs(keys)do + if before[key]~=out[key]then changed[key]={before=before[key],value=out[key],snapshot=copy(out[key])}end + end + pool.decorations=out;previous[data]={pool=pool,before=before,after=out,changed=changed} +end +function M.api(loader,mod,native,generation) + local state=mounted[loader] + local available=generation==2 and state and state.available or false + local reason=not available and 'unsupported: decorations requires an imported native GSC attribute table'or nil + local registry=loader.content.decorations + local out={available=available,supports=function()return available,reason end} + function out:get(id)if not available then return nil,reason end;byte(id);return copy(native:get(id))end + function out:has(id)return self:get(id)~=nil end + function out:each() + if not available then return function()end end + local nextRow=native:each();return function()local id,value=nextRow();return id,copy(value)end + end + for _,verb in ipairs({'register','override','patch','remove'})do + out[verb]=function(_,id,value) + if not available then return nil,reason end + byte(id) + if verb=='remove'then return native:remove(id)end + finite(value);assert(type(value)=='table'and value~=Merge.DELETE,'decorations: expected record') + for key in pairs(value)do assert(registry.spec.fields[key],'decorations: unknown field '..tostring(key))end + local effective=verb=='patch'and Merge.deepMerge(copy(registry:get(id)or{}),copy(value),'record')or value + effective=record(registry,id,effective);native[verb](native,id,effective) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/Events.lua b/src/mods/Events.lua index 88d27e79b..ae3060b1d 100644 --- a/src/mods/Events.lua +++ b/src/mods/Events.lua @@ -15,8 +15,16 @@ function Events:on(name, callback, priority, owner) local list = self.listeners[name] or {} self.listeners[name] = list local entry = { callback = callback, priority = priority or 0, owner = owner } - list[#list + 1] = entry - table.sort(list, function(a, b) return a.priority > b.priority end) + -- Stable priority insertion: equally ranked listeners run in subscription + -- order. Re-sorting with a priority-only comparator permuted existing peers + -- whenever another listener joined, allowing an earlier generic decorator + -- to overwrite a later screen-specific one after a settled screen push. + local index = #list + 1 + while index > 1 and list[index - 1].priority < entry.priority do + list[index] = list[index - 1] + index = index - 1 + end + list[index] = entry return function() for i, candidate in ipairs(list) do if candidate == entry then table.remove(list, i) break end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..ccc5288e2 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -717,7 +717,7 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,11 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name=="decorations"then + return require("src.mods.DecorationContent").api(loader,mod,api,loader:_generation()) + end + return api + end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1310,12 +1315,18 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() self.baseData = data + require("src.pokemon.Gen2Mail").load(data) + require("src.compat.gen2.Decorations").useRegistry(data) -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later for _, registry in pairs(self.content) do @@ -1326,6 +1337,7 @@ function Loader:load(data) end end end + require("src.mods.DecorationContent").prepare(self,data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) @@ -1551,6 +1563,7 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.DecorationContent").apply(self,data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then @@ -1653,6 +1666,11 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + require("src.compat.gen2.Decorations").useRegistry(nil) + local palette=package.loaded['src.render.PaletteFX'] + if palette and palette.clearGen2Context then palette.clearGen2Context()end + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..dd69d2166 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1257,6 +1257,13 @@ R.text_pointers = { example = 'mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } })', } +R.decorations = { + semantics="record",generations="2", + fields={type=f.int(1,6),name=f.str,action=f.opt(f.str),flag=f.int(0,65534),sprite=f.int(0,255)}, + example='mod.content.decorations:patch("deco:2", {name="CUSTOM"})', + notes="Native imported GSC DECO bytes (deco:0..255), ownership event bits, room callbacks and house-PC selection. Action must be a known SET_UP_/PUT_AWAY_ native decoration action. No retail attribute catalogue is bundled. Requires a complete imported native attribute table.", +} + -- ------- persistence -- compose, keyed by the owning mod id: the runner walks each owner's chain diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 321387210..e9506843f 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -89,7 +89,7 @@ Evolution.METHODS = { return false end if not evo.timeOfDay then return true end - local night = Evolution.isNight() + local night = Evolution.isNight(game.save) if evo.timeOfDay == "NITE" then return night end return not night -- TR_MORNDAY: morn or day end, @@ -223,10 +223,11 @@ Evolution.HAPPINESS_TO_EVOLVE = 220 Evolution.BASE_HAPPINESS = 70 -- GSC time-of-day bands (constants/time_constants.asm): MORN 4-9, DAY --- 10-17, NITE 18-3. The port has no in-game clock, so the wall clock is --- what GSC's RTC would have been. -function Evolution.isNight() - local hour = tonumber(os.date("%H")) or 12 +-- 10-17, NITE 18-3. GSC reads the shared saved RTC offset; other editions +-- retain their existing host-clock behavior. +function Evolution.isNight(save) + local hour = require("src.core.GameVersion").isGen2() + and require("src.core.Gen2Clock").hour(save) or tonumber(os.date("%H")) or 12 return hour >= 18 or hour < 4 end diff --git a/src/pokemon/Gen2Boxes.lua b/src/pokemon/Gen2Boxes.lua new file mode 100644 index 000000000..a7272c589 --- /dev/null +++ b/src/pokemon/Gen2Boxes.lua @@ -0,0 +1,106 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Party/storage operations over the native save. The legacy Gen2 count() +-- counts residents, unlike the shared storage module's box-count query. +local Storage=require("src.pokemon.Boxes") +local Mail=require("src.pokemon.Gen2Mail") +local Party=require("src.pokemon.Party") +local Strings=require("src.core.Strings") +local B={PARTY_SIZE=6} +local function valid(index,max) return type(index)=="number" and index>=1 and index<=max and index%1==0 end +function B.defaultName(n) return "BOX"..tostring(n) end +function B.name(save,n) return save and save.boxNames and save.boxNames[n] or B.defaultName(n) end +function B.rename(save,n,name) + if not save or not valid(n,Storage.count()) or type(name)~="string" then return false end + save.boxNames=save.boxNames or {};save.boxNames[n]=name;return true +end +function B.box(save,n) + if not save then return {} end + n=n or save.currentBox or 1 + if not valid(n,Storage.count()) then return {} end + return Storage.ensure(save)[n] +end +function B.count(save,n) return Storage.used(B.box(save,n)) end +function B.isFull(save,n) return B.count(save,n)>=Storage.capacity() end +function B.setCurrent(save,n) + if not save or not valid(n,Storage.count()) then return false end + Storage.ensure(save);save.currentBox=n;return true +end +function B.healthyCount(party) + local n=0;for _,mon in ipairs(party or {}) do if (mon.hp or 0)>0 and not Party.isEgg(mon) then n=n+1 end end + return n +end +function B.canUsePc(save) + if not save or #(save.party or {})==0 then return false,Strings("You'll need a\nPOKéMON to call\fwith.") end + return true +end +function B.canDeposit(save,n,box) + local mon=save and save.party and save.party[n] + if not mon then return false,"There is no POKéMON there." end + box=box or save.currentBox or 1 + if not valid(box,Storage.count()) then return false,"Invalid BOX." end + if B.isFull(save,box) then return false,"The BOX is full." end + if (mon.hp or 0)>0 and not Party.isEgg(mon) and B.healthyCount(save.party)<=1 then return false,Strings("You can't deposit\nthe last POKéMON!") end + if Mail.monHoldsMail(mon) then return false,"Remove MAIL." end + return true +end +function B.restorePP(mon,data) + require("src.pokemon.Pokemon").restorePP(mon,data and data.moves) +end +local function recover(mon,data) + if not mon.stats and data and data.pokemon then require("src.pokemon.Stats").ensure(data.pokemon[mon.species],mon) end + mon.status,mon.statusTurns=nil,nil + mon.hp=Party.isEgg(mon) and 0 or (mon.stats and mon.stats.hp or mon.maxHp or mon.hp) +end +function B.enterBox(mon,data) + if mon then B.restorePP(mon,data);recover(mon,data) end + return mon +end +local function remove(box,n) + local mon=box[n];box[n]=nil + -- Shift the occupied suffix without trusting Lua's length on sparse imports. + local nextSlot=n+1 + while nextSlot<=Storage.capacity() do + if box[nextSlot]~=nil then box[n]=box[nextSlot];box[nextSlot]=nil;n=n+1 end + nextSlot=nextSlot+1 + end + return mon +end +function B.deposit(save,n,box,data) + local ok,why=B.canDeposit(save,n,box);if not ok then return false,why end + local target=B.box(save,box);local to=Storage.firstFree(target) + local mon=table.remove(save.party,n);Mail.removeSlot(save,n) + target[to]=mon;B.enterBox(mon,data);return true,mon +end +function B.canWithdraw(save,box,n) + if not save or not valid(n,Storage.capacity()) or not B.box(save,box)[n] then return false,"There is no POKéMON there." end + if #(save.party or {})>=6 then return false,Strings("You can't take\nany more POKéMON.") end + return true +end +function B.withdraw(save,box,n,data) + local ok,why=B.canWithdraw(save,box,n);if not ok then return false,why end + local mon=remove(B.box(save,box),n);recover(mon,data) + save.party=save.party or {};save.party[#save.party+1]=mon;return true,mon +end +function B.release(save,box,n) + local target=B.box(save,box);if not valid(n,Storage.capacity()) or not target[n] then return false,"There is no POKéMON there." end + if Party.isEgg(target[n]) then return false,"You can't release an EGG!" end + return true,remove(target,n) +end +function B.releaseFromParty(save,n) + local mon=save and save.party and save.party[n] + if not mon then return false,"There is no POKéMON there." end + if Party.isEgg(mon) then return false,"You can't release an EGG!" end + local removed=table.remove(save.party,n);Mail.removeSlot(save,n);return true,removed +end +function B.move(save,from,n,to) + if from==to then return false,"It's already there." end + if not valid(to,Storage.count()) then return false,"Invalid BOX." end + local source=B.box(save,from);if not source[n] then return false,"There is no POKéMON there." end + if B.isFull(save,to) then return false,"The BOX is full." end + local target=B.box(save,to);local mon=remove(source,n) + target[Storage.firstFree(target)]=mon;return true,mon +end +return setmetatable(B,{__index=function(_,key) + if key=="NUM_BOXES" then return Storage.count() end + if key=="MONS_PER_BOX" then return Storage.capacity() end +end}) diff --git a/src/pokemon/Gen2Mail.lua b/src/pokemon/Gen2Mail.lua new file mode 100644 index 000000000..e85eed17a --- /dev/null +++ b/src/pokemon/Gen2Mail.lua @@ -0,0 +1,224 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC letters belong to party slots; the PC mailbox holds detached letters. +-- Native item IDs are resolved by the imported semantic key, never a label. +local Runtime = require("src.mods.Runtime") +local Mail = { + MAIL_MSG_LENGTH=32, MAIL_LINE_LENGTH=16, MAILBOX_CAPACITY=10, + AUTHOR_LENGTH=10, PARTY_LENGTH=6, + POKEMAIL_WRONG_MAIL=0, POKEMAIL_CORRECT=1, POKEMAIL_REFUSED=2, + POKEMAIL_NO_MAIL=3, POKEMAIL_LAST_MON=4, + ITEMS={"FLOWER_MAIL","SURF_MAIL","LITEBLUEMAIL","PORTRAITMAIL","LOVELY_MAIL", + "EON_MAIL","MORPH_MAIL","BLUESKY_MAIL","MUSIC_MAIL","MIRAGE_MAIL"}, + INDEX={}, +} +local named, native, lastRead = {}, {}, nil +for i,id in ipairs(Mail.ITEMS) do named[id]=true;Mail.INDEX[id]=i-1 end +local function slot(n, max) + return type(n)=="number" and n==math.floor(n) and n>=1 and n<=max +end +function Mail.load(data) + for id in pairs(native) do Mail.INDEX[id]=nil end + native={};lastRead=nil + for i,id in ipairs(Mail.ITEMS) do Mail.INDEX[id]=i-1 end + for id,def in pairs(data and data.items or {}) do + if type(def)=="table" and named[def.key] then + native[id]=def.key;Mail.INDEX[id]=Mail.INDEX[def.key] + end + end +end +function Mail.isMail(id) return named[id]==true or native[id]~=nil end +function Mail.monHoldsMail(mon) return type(mon)=="table" and Mail.isMail(mon.item) end +function Mail.anyMonHoldingMail(save) + for _,mon in ipairs(save and save.party or {}) do if Mail.monHoldsMail(mon) then return true end end + return false +end +function Mail.state(save) + if type(save)~="table" then return {party={},box={}} end + if type(save.mail)~="table" then save.mail={party={},box={}} end + if type(save.mail.party)~="table" then save.mail.party={} end + if type(save.mail.box)~="table" then save.mail.box={} end + return save.mail +end +function Mail.characters(text) + local out={} + for ch in tostring(text or ""):gmatch("[%z\1-\127\194-\244][\128-\191]*") do out[#out+1]=ch end + return out +end +local function limit(text,n) + local chars=Mail.characters(text);return table.concat(chars,"",1,math.min(n,#chars)) +end +function Mail.trim(text) return limit(text,Mail.MAIL_MSG_LENGTH) end +function Mail.entry(item,message,author,authorId,species) + return {type=item,message=message or "",author=author or "",authorId=authorId or 0,species=species} +end +function Mail.armRead() lastRead=nil end +function Mail.get(save,n) Mail.armRead();return Mail.state(save).party[n] end +function Mail.set(save,n,entry) + if not slot(n,Mail.PARTY_LENGTH) then return false end + Mail.state(save).party[n]=entry;return true +end +function Mail.clear(save,n) if slot(n,Mail.PARTY_LENGTH) then Mail.state(save).party[n]=nil end end +function Mail.removeSlot(save,n) + if not slot(n,Mail.PARTY_LENGTH) or not (save and save.mail) then return end + local letters=Mail.state(save).party + for i=n,Mail.PARTY_LENGTH-1 do letters[i]=letters[i+1] end + letters[Mail.PARTY_LENGTH]=nil +end +function Mail.swapSlots(save,a,b) + if not slot(a,6) or not slot(b,6) or not (save and save.mail) then return end + local letters=Mail.state(save).party;letters[a],letters[b]=letters[b],letters[a] +end +local function written(save,n,mon,item,text,author,id,source) + if not save or not slot(n,6) or not Mail.isMail(item) then return false end + local entry=Mail.entry(item,Mail.trim(text),limit(author,10),id,mon and mon.species) + Mail.set(save,n,entry) + if Runtime.wants("mail.written") then + Runtime.emit("mail.written",{entry=entry,slot=n,mon=mon,message=entry.message,author=entry.author,source=source}) + end + return true +end +function Mail.compose(save,n,text,mon,item) + local player=save and save.player or {} + return written(save,n,mon,item,text,player.name or "",player.id or 0,"compose") +end +function Mail.give(save,item,text) + local party=save and save.party or {};local n=#party;local mon=party[n] + if not mon or not slot(n,6) or not Mail.isMail(item) then return false end + mon.item=item + return written(save,n,mon,item,text,mon.otName or mon.ot or "",mon.otId or 0,"script") +end +function Mail.lines(entry) + local text=type(entry)=="table" and tostring(entry.message or "") or "" + local top,bottom=text:match("^(.-)\n(.*)$") + if not top then + local chars=Mail.characters(text);top=table.concat(chars,"",1,math.min(16,#chars)) + bottom=#chars>16 and table.concat(chars,"",17) or "" + end + if type(entry)=="table" and Runtime.wants("mail.read") and lastRead~=entry then + lastRead=entry;Runtime.emit("mail.read",{entry=entry,message=entry.message,author=entry.author,top=top,bottom=bottom}) + end + return top,bottom +end +function Mail.mailbox(save) Mail.armRead();return Mail.state(save).box end +function Mail.mailboxCount(save) return #Mail.state(save).box end +function Mail.mailboxFull(save) return Mail.mailboxCount(save)>=10 end +function Mail.sendToPc(save,n) + local mon=save and save.party and save.party[n] + if not Mail.monHoldsMail(mon) or Mail.mailboxFull(save) then return false end + local state=Mail.state(save) + state.box[#state.box+1]=state.party[n] or Mail.entry(mon.item,"","",0,mon.species) + state.party[n]=nil;mon.item=nil;return true +end +function Mail.deleteFromPc(save,n) + local box=Mail.state(save).box + if not slot(n,#box) then return nil end + return table.remove(box,n) +end +function Mail.moveFromPcToParty(save,n,to) + local mon=save and save.party and save.party[to];local box=Mail.state(save).box + local letter=slot(n,#box) and box[n] + if not letter or not slot(to,6) or not mon or mon.item or require("src.pokemon.Party").isEgg(mon) then return false end + if not Mail.isMail(letter.type) then return false end + Mail.set(save,to,letter);mon.item=letter.type;table.remove(box,n);return true +end +function Mail.detachToBag(save,n,data) + local mon=save and save.party and save.party[n] + if not Mail.monHoldsMail(mon) then return false,"no_mail" end + if not require("src.inventory.Bag").add(save,mon.item,1,data) then return false,"full" end + mon.item=nil;Mail.clear(save,n);return true +end +function Mail.attachFromBag(save,n,item,text,data) + local mon=save and save.party and save.party[n] + if not mon or not slot(n,6) or require("src.pokemon.Party").isEgg(mon) then return false,"target" end + if Mail.monHoldsMail(mon) then return false,"mail" end + if not Mail.isMail(item) or not save.inventory or (save.inventory[item] or 0)<1 then return false,"item" end + local Bag=require("src.inventory.Bag") + -- Bag rules are pure store operations. Check the complete exchange before + -- changing either held state or the live acquisition-ordered inventory. + local shadow={inventory={}} + for id,count in pairs(save.inventory)do shadow.inventory[id]=count end + if save.bagOrder then shadow.bagOrder={};for i,id in ipairs(save.bagOrder)do shadow.bagOrder[i]=id end end + Bag.remove(shadow,item,1) + if mon.item and not Bag.add(shadow,mon.item,1,data) then return false,"full" end + for id in pairs(save.inventory)do save.inventory[id]=nil end + for id,count in pairs(shadow.inventory)do save.inventory[id]=count end + if save.bagOrder then + for i=#save.bagOrder,1,-1 do save.bagOrder[i]=nil end + for i,id in ipairs(shadow.bagOrder or {})do save.bagOrder[i]=id end + else save.bagOrder=shadow.bagOrder end + mon.item=item;Mail.compose(save,n,text,mon,item);return true +end +-- A carrier absent from the loaded dataset cannot keep a live party slot. +-- Retain its detached letter in the existing quarantine until mailbox room +-- is available, rather than attaching it to a later occupant of that slot. +function Mail.reclaimQuarantine(save,report) + local pending=save and save.orphaned and save.orphaned.mail + if type(pending)~="table" or #pending==0 then return end + local box=Mail.state(save).box;local i=1 + while i<=#pending and #box<10 do + local letter=pending[i] + if type(letter)=="table" and Mail.isMail(letter.type) then + box[#box+1]=table.remove(pending,i) + if report then report.restoredMail=report.restoredMail or {};report.restoredMail[#report.restoredMail+1]={author=letter.author} end + else i=i+1 end + end +end +function Mail.checkPokeMail(save,n,expected) + if n==nil then return Mail.POKEMAIL_REFUSED end + local mon=save and save.party and save.party[n] + if not Mail.monHoldsMail(mon) then return Mail.POKEMAIL_NO_MAIL end + local entry=Mail.get(save,n);local text=entry and entry.message or "" + if type(expected)~="string" or expected=="" or text:sub(1,#expected)~=expected then return Mail.POKEMAIL_WRONG_MAIL end + local other=false + for i,member in ipairs(save.party) do + if i~=n and (member.hp or 0)>0 and not require("src.pokemon.Party").isEgg(member) then other=true end + end + if (mon.hp or 0)>0 and not other then return Mail.POKEMAIL_LAST_MON end + table.remove(save.party,n);Mail.removeSlot(save,n);return Mail.POKEMAIL_CORRECT +end +function Mail.validate(save,report) + local lost={};if report then report.lostMail=lost end + if not save then return lost end + if save.mail==nil then Mail.reclaimQuarantine(save,report);return lost end + local raw=save.mail + local function reject(where,n,why,entry) + lost[#lost+1]={where=where,slot=n,why=why,entry=entry} + end + if type(raw)~="table" then reject("mail",nil,"not a table",raw);save.mail=nil;return lost end + local function clean(entry,where,n) + if type(entry)~="table" or not Mail.isMail(entry.type) then reject(where,n,"invalid letter",entry);return end + local out={};for k,v in pairs(entry) do out[k]=v end + out.message=Mail.trim(entry.message);out.author=limit(entry.author,10) + local id=tonumber(entry.authorId) or 0 + out.authorId=slot(id+1,65536) and id or 0 + if out.message~=entry.message or out.author~=entry.author or out.authorId~=entry.authorId then reject(where,n,"letter repaired",entry) end + return out + end + local party,box={},{} + if type(raw.party)=="table" then + for key,entry in pairs(raw.party) do + local n=tonumber(key) + if not slot(n,6) or not (save.party and save.party[n]) then + local letter=clean(entry,"party",key) + reject("party",key,"carrier unavailable",entry) + if letter then + save.orphaned=save.orphaned or {mons={},items={}} + save.orphaned.mail=save.orphaned.mail or {} + save.orphaned.mail[#save.orphaned.mail+1]=letter + end + else party[n]=clean(entry,"party",n) end + end + elseif raw.party~=nil then reject("party",nil,"not a table",raw.party) end + if type(raw.box)=="table" then + local keys={};for n in pairs(raw.box) do + if type(n)=="number" and n>=1 and n%1==0 then keys[#keys+1]=n else reject("box",n,"invalid slot",raw.box[n]) end + end + table.sort(keys) + for _,n in ipairs(keys) do + if #box>=10 then reject("box",n,"MAILBOX full",raw.box[n]) + else local entry=clean(raw.box[n],"box",n);if entry then box[#box+1]=entry end end + end + elseif raw.box~=nil then reject("box",nil,"not a table",raw.box) end + raw.party,raw.box=party,box;Mail.reclaimQuarantine(save,report);return lost +end +return Mail diff --git a/src/pokemon/Gen2PP.lua b/src/pokemon/Gen2PP.lua new file mode 100644 index 000000000..9711420e3 --- /dev/null +++ b/src/pokemon/Gen2PP.lua @@ -0,0 +1,28 @@ +-- Native Gen2 PP arithmetic from Gen1Recomp 535a5ec0 Mon.lua. +-- Existing project rights/licences remain applicable. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +local P = {} +function P.bonus(base) return math.min(math.floor(base / 5), 7) end +function P.ups(def, slot) + if type(slot) ~= 'table' then return 0 end + local ups = tonumber(slot.ppUps) + if ups == nil then + local base, stored = def and tonumber(def.pp), tonumber(slot.maxPp) + ups = 0 + if base and stored and stored > base then + local bonus = P.bonus(base) + if bonus > 0 and (stored - base) % bonus == 0 then + ups = (stored - base) / bonus + end + end + end + return math.max(0, math.min(3, math.floor(ups))) +end +function P.maximum(def, slot) + local base = def and tonumber(def.pp) + if not base then + return type(slot) == 'table' and (tonumber(slot.maxPp) or tonumber(slot.pp)) or 0 + end + return base + P.ups(def, slot) * P.bonus(base) +end +return P diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 17c5685c0..e51f49d51 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -2,6 +2,7 @@ local Growth = require("src.pokemon.Growth") local Stats = require("src.pokemon.Stats") +local Gen2PP = require("src.pokemon.Gen2PP") local Pokemon = {} @@ -450,7 +451,15 @@ end -- Pokémon Center / blackout heal (engine/events/heal_party.asm -- HealParty): full HP, status cleared, and every move's PP restored to -- its base plus the PP-Up bonus (RestoreBonusPP adds maxPP/5 per PP UP). -function Pokemon.maxPP(def, slot) +function Pokemon.ppUps(def, slot, generation) + generation = tonumber(generation) or require('src.core.GameVersion').generation() + if generation == 2 then return Gen2PP.ups(def, slot) end + return type(slot)=='table' and math.min(3,math.max(0,math.floor(tonumber(slot.ppUps) or 0))) or 0 +end + +function Pokemon.maxPP(def, slot, generation) + generation = tonumber(generation) or require('src.core.GameVersion').generation() + if generation == 2 then return Gen2PP.maximum(def, slot) end local base = math.max(0,tonumber(def and def.pp) or 0) local ups = type(slot)=='table' and math.min(3,math.max(0,math.floor(tonumber(slot.ppUps) or 0))) or 0 return base + ups * math.floor(base / 5) diff --git a/src/render/Font.lua b/src/render/Font.lua index 30b2e4a78..8733aeac8 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -194,6 +194,16 @@ function Font.load(data) end end + -- Legacy screen cells spell ligatures with angle brackets. Bind only + -- to a sequence the active font actually owns, so relocated hack glyphs + -- and explicitly authored bracket spellings retain their authority. + for token, native in pairs({[""]="PK",[""]="MN",[""]="."}) do + if not seqCode(token) then + local code=seqCode(native) + if code then bucket({seq=token,code=code})end + end + end + for _, entries in pairs(state.byFirstByte) do table.sort(entries, function(a, b) return #a.seq > #b.seq end) end diff --git a/src/render/Gen2PaletteContext.lua b/src/render/Gen2PaletteContext.lua new file mode 100644 index 000000000..d5631b5f4 --- /dev/null +++ b/src/render/Gen2PaletteContext.lua @@ -0,0 +1,74 @@ +-- Native special-map/roof selection adapted from TileRenderer.lua: +-- Copyright (c) 2026 Cedric. All rights reserved. See LICENSE.md. +-- Additional context/view integration (c) 2026 Roxas2712. +-- See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md; existing upstream rights remain unchanged. +local M={} +local names={[0]='PALETTE_AUTO','PALETTE_DAY','PALETTE_NITE','PALETTE_MORN','PALETTE_DARK', + 'PALETTE_AUTO','PALETTE_AUTO','PALETTE_AUTO'} +local ids={PALETTE_AUTO=0,PALETTE_DAY=1,PALETTE_NITE=2,PALETTE_MORN=3,PALETTE_DARK=4} +local views=setmetatable({},{__mode='k'}) +function M.id(def) + if type(def)~='table'then return nil end + local authored=rawget(def,'palette') + if ids[authored]~=nil then return ids[authored]end + local n=tonumber(def.mapPalette) + return n and n%1==0 and n>=0 and n<=7 and n or nil +end +function M.publish(def) + if type(def)~='table'or rawget(def,'palette')~=nil or getmetatable(def)~=nil then return false end + setmetatable(def,{__index=function(t,key) + if key=='palette'then return names[M.id(t)]end + end}) + return true +end +function M.bind(view,data)views[view]={data=data,tilesets=data.tilesets}end +function M.owner(data) + if type(data)~='table'then return nil end + local binding=views[data] + if binding then + local owner=binding.data + return owner.gen2Palettes==data and owner.tilesets==binding.tilesets and owner or nil,true + end + return type(data.tilesets)=='table'and data or nil,false +end +function M.special(data,def,tileset,row) + if row=='DARK'then return nil end + local special=data and data.field and data.field.gen2MapPalettes + if type(def)~='table'or type(special)~='table'or type(special.rules)~='table'then return nil end + for _,rule in ipairs(special.rules)do + local hit + if rule.kind=='map'then hit=rule.group==def.group and rule.number==def.number + elseif rule.kind=='landmark'then hit=rule.landmark==def.landmark + elseif rule.kind=='tileset'then hit=rule.tileset==tileset end + if hit and rule.landmarkMin then hit=type(def.landmark)=='number'and def.landmark>=rule.landmarkMin end + if hit and rule.landmarkEq then hit=def.landmark==rule.landmarkEq end + if hit then + local set=special.sets and special.sets[rule.set] + local rows=set and(set.ALL or set[row]or set.DAY) + if rows and #rows>0 then return '#pal:'..tostring(rule.set),rows end + return nil -- The first matching rule owns the decision, even if unavailable. + end + end +end +function M.roof(data,def,tileset,row) + local roofs=data and data.field and data.field.gen2Roofs + if type(roofs)~='table'or type(tileset)~='table'or + type(roofs.tilesets)~='table'or not roofs.tilesets[tileset.id]then return nil end + local group=def and def.group + local id=group~=nil and type(roofs.byGroup)=='table'and roofs.byGroup[group]or nil + if id==nil then return nil end + local image=type(roofs.images)=='table'and roofs.images[id]or nil + if not image then return nil end + local byTod=type(roofs.colors)=='table'and roofs.colors[group]or nil + local pair=byTod and(byTod[row]or byTod.DAY)or nil + return{image=image,pair=pair,slot=roofs.slot or 10,count=roofs.count or 9,palIndex=roofs.palIndex or 6, + variants=tileset.tileVariants,key='#roof:'..tostring(id)..'.'..tostring(group)} +end +function M.withRoof(rows,roof) + if not(roof and roof.pair)then return rows end + local out={};for i=1,#rows do out[i]=rows[i]end + local slot=roof.palIndex+1;local row=out[slot] + if row then out[slot]={row[1],roof.pair[1],roof.pair[2],row[4]}end + return out +end +return M diff --git a/src/render/Gen2PaletteDraw.lua b/src/render/Gen2PaletteDraw.lua new file mode 100644 index 000000000..481165c12 --- /dev/null +++ b/src/render/Gen2PaletteDraw.lua @@ -0,0 +1,280 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Indexed image drawing, separate from the LCD post-process in GBCFX. +-- Palette resolution uses the native display/register state in PaletteFX. +local Palettes = require("src.render.PaletteFX") +local Version = require("src.core.GameVersion") +local M = {} +-- Display state belongs to the same owner as the native map/OBJ/pic bakes. +setmetatable(M, {__index = function(_, key) + if key == "mode" then + if Version.isGen2() and Palettes.mode == "custom" then return "custom" end + if Palettes.mode == "og" or Palettes.mode == "og_inv" then return "dmg" end + return Palettes.mode == "classic" and "classic" or "gbc" + end + if key == "bgp" then return Palettes.bgp() end + if key == "customRamp" then return Palettes.customRamp() end +end, __newindex = function(self, key, value) + if key == "bgp" then Palettes.setBgp(value) + elseif key == "mode" then M.setMode(value) + elseif key == "customRamp" then Palettes.setCustomRamp(value) + else rawset(self, key, value) end +end}) +local compiler, shaders, failed = nil, {}, {} +local sent = setmetatable({}, {__mode = "k"}) +local names = {"pal0", "pal1", "pal2", "pal3"} +local source = [[ +extern vec3 pal0; extern vec3 pal1; extern vec3 pal2; extern vec3 pal3; +vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { + vec4 pixel = Texel(image, uv); + float shade = floor((1.0 - pixel.r) * 3.0 + 0.5); + vec3 rgb = shade < 0.5 ? pal0 : (shade < 1.5 ? pal1 : (shade < 2.5 ? pal2 : pal3)); + return vec4(rgb, ALPHA) * tint; +} +]] + +-- A composited CGB frame has lost its palette indices. Match only colours +-- present in its BG/OBJ tables; unrelated RGB art must keep its own colour. +-- Constant loop bounds also work on GLSL ES uniform-array implementations. +M.REMAP_MAX = 64 +M.REMAP_TOLERANCE = (3 / 255) ^ 2 +local remapSource = [[ +extern int remapCount; +extern float remapTol; +extern vec3 remapSrc[64]; +extern vec3 remapDst[64]; +vec4 effect(vec4 tint, Image image, vec2 uv, vec2 screen) { + vec4 pixel = Texel(image, uv); + vec3 rgb = pixel.rgb; + float closest = remapTol; + for (int i = 0; i < 64; i++) { + if (i >= remapCount) break; + vec3 delta = pixel.rgb - remapSrc[i]; + float distanceSquared = dot(delta, delta); + if (distanceSquared < closest) { + closest = distanceSquared; + rgb = remapDst[i]; + } + } + return vec4(rgb, pixel.a) * tint; +} +]] + +local function graphics() return love and love.graphics end +local function compile(key, code, keyed) + local g = graphics() + local newShader = g and g.newShader + if type(newShader) ~= "function" then return nil end + if compiler ~= newShader then + compiler, shaders, failed = newShader, {}, {} + end + if failed[key] then return nil end + if not shaders[key] then + if key ~= "remap" then + local alpha = keyed and "(shade < 0.5 ? 0.0 : pixel.a)" or "pixel.a" + code = (code:gsub("ALPHA", alpha)) + end + local ok, value = pcall(newShader, code) + if not ok or not value then failed[key] = true; return nil end + shaders[key] = value + end + return shaders[key] +end +local function shader(keyed) + return compile(keyed and "keyed" or "normal", source, keyed) +end +function M.shader() return shader(false) end +function M.keyedShader() return shader(true) end +function M.remapShader() return compile("remap", remapSource) end +function M.available() return M.shader() ~= nil end + +local function copy(colors) + if type(colors) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + local color = colors[i] + if type(color) ~= "table" then return nil end + local row = {} + for c = 1, 3 do + local n = color[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + row[c] = n + end + out[i] = row + end + return out +end +M.BGP_IDENTITY = Palettes.BGP_IDENTITY +M.bgpShades = Palettes.bgpShades +M.remap = Palettes.remap +M.setBgp = Palettes.setBgp +M.MODES = {"gbc", "dmg", "classic"} +M.MODE_LABELS = {gbc="GEN 2", dmg="DMG", classic="CLASSIC", custom="CUSTOM"} +M.CUSTOM_MODE = "custom" +M.setCustomRamp = Palettes.setCustomRamp +M.presentColors = Palettes.presentColors +function M.setMode(mode) + if mode~="gbc" and mode~="dmg" and mode~="classic" and mode~="custom" then mode="gbc" end + Palettes.setMode(mode) + return M.mode +end +function M.cycle(delta) Palettes.cycleMode(delta); return M.mode end +function M.applyOptions(opts) Palettes.applyOptions(opts); return M.mode end +function M.modeLabel(mode) return M.MODE_LABELS[mode or M.mode] or "GEN 2" end +function M.resolve(colors) return Palettes.displayColors(colors) end +function M.color(colors, index) + local palette = Palettes.effectiveColors(colors or Palettes.GRAYS) + return palette and palette[index] or nil +end + +local function upload(sh, colors) + -- Public shader objects can be edited externally. Upload every public call; + -- a cached last-send comparison could otherwise trust foreign uniforms. + sent[sh] = nil + for i = 1, 4 do + local row = colors[i] + sh:send(names[i], {row[1]/255, row[2]/255, row[3]/255}) + end + sent[sh] = colors +end +local function use(colors, keyed, raw) + local g = graphics() + if not (g and g.setShader) then return false end + colors = copy(raw and colors or Palettes.effectiveColors(colors)) + if not colors then return false end + local sh = shader(keyed) + if not sh then return false end + upload(sh, colors) + g.setShader(sh) + return true +end +function M.use(colors) return use(colors, false, false) end +function M.useRaw(colors) return use(colors, false, true) end +function M.useKeyed(colors) return use(colors, true, false) end +function M.clear() + local g = graphics() + if g and g.setShader then g.setShader() end +end + +-- The first BG slot wins when the same RGB value has different meanings. +-- Report those collisions rather than pretending an RGB frame retains its +-- original indices. OBJ rows normally map to themselves, protecting sprites. +function M.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) + local src, dst, seen, ambiguous = {}, {}, {}, 0 + local function key(c) + return math.floor(c[1]) .. "," .. math.floor(c[2]) .. "," .. math.floor(c[3]) + end + local function add(colors, mapped) + colors, mapped = copy(colors), copy(mapped) + if not (colors and mapped) then return end + for i = 1, 4 do + local from, to = colors[i], mapped[i] + local k = key(from) + local at = seen[k] + if at then + if key(dst[at]) ~= key(to) then ambiguous = ambiguous + 1 end + elseif #src < M.REMAP_MAX then + src[#src + 1], dst[#dst + 1] = from, to + seen[k] = #src + end + end + end + local forced = ramp and M.resolve(copy(ramp)) + local forcedBg = forced and M.remap(forced, byte) + for _, colors in ipairs(bgPalettes or {}) do + local resolved = M.resolve(copy(colors)) + add(resolved, forcedBg or M.remap(resolved, byte)) + end + if forced then add(forced, forcedBg) end + for i, colors in ipairs(objPalettes or {}) do + local resolved = M.resolve(copy(colors)) + add(resolved, forced and objRamped and objRamped[i] and forced or resolved) + end + local count = #src + for i = count + 1, M.REMAP_MAX do + src[i] = src[1] and {unpack(src[1])} or {0, 0, 0} + dst[i] = dst[1] and {unpack(dst[1])} or {0, 0, 0} + end + return src, dst, count, ambiguous +end +function M.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped) + local src, dst, count, ambiguous = M.remapTable(bgPalettes, byte, objPalettes, ramp, objRamped) + if count == 0 then return nil end + for i = 1, M.REMAP_MAX do + for c = 1, 3 do src[i][c], dst[i][c] = src[i][c]/255, dst[i][c]/255 end + end + return {src=src, dst=dst, count=count, ambiguous=ambiguous} +end +local remapSent = setmetatable({}, {__mode = "k"}) +local function uploadRemap(sh, uniforms) + remapSent[sh] = nil + sh:send("remapCount", uniforms.count) + sh:send("remapTol", M.REMAP_TOLERANCE) + sh:send("remapSrc", unpack(uniforms.src)) + sh:send("remapDst", unpack(uniforms.dst)) + remapSent[sh] = uniforms +end +local function copyUniforms(uniforms) + if type(uniforms) ~= "table" or type(uniforms.count) ~= "number" + or uniforms.count < 1 or uniforms.count > M.REMAP_MAX + or uniforms.count % 1 ~= 0 then return nil end + local result = {count=uniforms.count, ambiguous=uniforms.ambiguous, src={}, dst={}} + for _, name in ipairs({"src", "dst"}) do + if type(uniforms[name]) ~= "table" then return nil end + for i = 1, M.REMAP_MAX do + local row = uniforms[name][i] + if type(row) ~= "table" then return nil end + result[name][i] = {} + for c = 1, 3 do + local n = row[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 1 then return nil end + result[name][i][c] = n + end + end + end + return result +end +function M.useRemapUniforms(uniforms) + local g = graphics() + if not (g and g.setShader) then return false end + local sh = M.remapShader() + uniforms = copyUniforms(uniforms) + if not (sh and uniforms) then return false end + local previous = remapSent[sh] + if not pcall(uploadRemap, sh, uniforms) then + if previous then pcall(uploadRemap, sh, previous) end + failed.remap, shaders.remap = true, nil + return false + end + g.setShader(sh) + return true, uniforms.ambiguous +end +function M.useRemap(bgPalettes, byte, objPalettes, ramp, objRamped) + return M.useRemapUniforms(M.remapUniforms(bgPalettes, byte, objPalettes, ramp, objRamped)) +end + +local function scoped(colors, body, apply) + local g = graphics() + local previous = g and g.getShader and g.getShader() or nil + -- Restoring only the shader object is insufficient: nested calls reuse it + -- and change its uniforms. Save the palette values as well as its identity. + local previousColors = previous and sent[previous] + local previousRemap = previous and remapSent[previous] + local ok, applied = pcall(function() + local active = apply(colors) + body() + return active + end) + local restored, why = pcall(function() + if previousColors then upload(previous, previousColors) end + if previousRemap then uploadRemap(previous, previousRemap) end + if g and g.setShader then g.setShader(previous) end + end) + if not ok then error(applied, 0) end + if not restored then error(why, 0) end + return applied +end +function M.with(colors, body) return scoped(colors, body, M.use) end +function M.withRaw(colors, body) return scoped(colors, body, M.useRaw) end +function M.keyedWith(colors, body) return scoped(colors, body, M.useKeyed) end +return M diff --git a/src/render/Gen2PokemonPalettes.lua b/src/render/Gen2PokemonPalettes.lua new file mode 100644 index 000000000..576e29589 --- /dev/null +++ b/src/render/Gen2PokemonPalettes.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Public Gen2 colour reads over the native palette pack. No duplicate +-- species catalogue, ROM colours or private renderer implementation. +local Context = require("src.render.Gen2PaletteContext") +local M = {} + +-- Return the native source table without allocation for the built-in renderer. +-- Public consumers receive detached RGB values through colors() below. +function M.native(data, species, shiny) + local owner = Context.owner(data) + local pokemon = owner and owner.pokemon + local def = type(pokemon) == "table" and pokemon[species] + local pack = owner and owner.palettes + local palettes = type(pack) == "table" and pack.palettes + if type(def) ~= "table" or type(palettes) ~= "table" then return nil end + if shiny and def.shinyPalette and palettes[def.shinyPalette] then + return palettes[def.shinyPalette] + end + if def.palette and palettes[def.palette] then return palettes[def.palette] end + local names = pack.pokemon + local name = type(names) == "table" and (names[def.id or species] or names[species]) + return name and palettes[name] or nil +end + +local function color(value) + if type(value) ~= "table" then return nil end + local rgb = {} + for i = 1, 3 do + local n = value[i] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + rgb[i] = n + end + return rgb +end +local function detached(palette) + if type(palette) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + out[i] = color(palette[i]) + if not out[i] then return nil end + end + return out +end + +function M.colors(data, species, shiny) + if type(data) ~= "table" or type(species) ~= "string" then return nil end + local owner, bound = Context.owner(data) + if owner then return detached(M.native(data, species, shiny)) end + if bound then return nil end -- Never revive a retired dataset view. + -- Reference palette-only records carry the two ROM middle colours, rather + -- than native names into a four-colour pack. Preserve that public input. + local rows = data.pokemon + local entry = type(rows) == "table" and rows[species] + if type(entry) ~= "table" then return nil end + local pair = shiny and entry.shiny or entry.normal + if type(pair) ~= "table" then return nil end + local first, second = color(pair[1]), color(pair[2]) + if not first or not second then return nil end + return { {255,255,255}, first, second, {0,0,0} } +end +return M diff --git a/src/render/Gen2Presentation.lua b/src/render/Gen2Presentation.lua new file mode 100644 index 000000000..017a877da --- /dev/null +++ b/src/render/Gen2Presentation.lua @@ -0,0 +1,79 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Gen2 display zones belong to the completed colour frame, after indexed +-- native BG/OBJ/UI bakes and before present/output effects. In Classic those +-- bakes are grayscale and this pass supplies the green ramp exactly once. +local P = require("src.render.PaletteFX") +local Gbc = require("src.render.Gen2PaletteDraw") +local Runtime = require("src.mods.Runtime") +local M = {} +local target +local function identity(_, zones) return zones end +function M.zones(game) + local colors = P.presentColors() + local zones = colors and {{x=0,y=0,w=160,h=144,colors=colors}} or nil + if Runtime.wantsHook("render.zones") then + zones = Runtime.call("render.zones", identity, game, zones) + end + return zones +end +local function finite(n) return type(n)=="number" and n==n and math.abs(n)x1 and y2>y1 then + g.setScissor(x1,y1,x2-x1,y2-y1) + if zone.colors==false then g.setShader();g.draw(source,0,0) + else Gbc.withRaw(zone.colors,function()g.draw(source,0,0)end) end + end + end + end,debug.traceback) + g.pop() + if not ok then error(why,0) end + end) +end +function M.apply(source, zones, w, h) + if not (type(zones)=="table" and zones[1]) then return source end + local g=love.graphics + if not (g.newCanvas and Gbc.available()) then return source end + local dpi=source.getDPIScale and source:getDPIScale() or 1 + if not target or target:getWidth()~=w or target:getHeight()~=h + or (target.getDPIScale and target:getDPIScale()~=dpi) then + if target then target:release() end + target=g.newCanvas(w,h,{format="normal",dpiscale=dpi,msaa=0}) + target:setFilter("nearest","nearest") + end + local previous={g.getCanvas()} + Gbc.withRaw(nil,function() + g.push("all") + local ok,why=xpcall(function() + g.setCanvas(target);g.origin();g.setShader();g.setScissor() + g.setColorMask(true,true,true,true);g.setStencilTest();g.setDepthMode() + g.setBlendMode("alpha","premultiplied");g.clear(0,0,0,1) + M.blit(source,zones,w,h) + end,debug.traceback) + g.setCanvas(unpack(previous));g.pop() + if not ok then error(why,0) end + end) + return target +end +function M.reset() + if target then target:release();target=nil end +end +return M diff --git a/src/render/GraphicsScope.lua b/src/render/GraphicsScope.lua new file mode 100644 index 000000000..f4380d928 --- /dev/null +++ b/src/render/GraphicsScope.lua @@ -0,0 +1,57 @@ +-- Copyright (c) 2026 Roxas2712. See the contribution permission in PR271. +-- Fence a native render seam, including leaked pushes and canvas changes. +local S = {} +local current +-- Opaque lifetime for paired helpers inside a guarded render callback. +function S.token() return current end +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +function S.call(fn, ...) + local G = love.graphics + local canvas = pack(G.getCanvas()) + local realPush, realPop = G.push, G.pop + local before = G.getStackDepth and G.getStackDepth() + local pushed, why = pcall(realPush, "all") + if not pushed then + return false, why + end + local depth = 0 + G.push = function(...) + local result = pack(realPush(...)) + depth = depth + 1 + return unpack(result, 1, result.n) + end + G.pop = function(...) + if depth <= 0 then + error("render callback cannot pop caller graphics state", 0) + end + local result = pack(realPop(...)) + depth = depth - 1 + return unpack(result, 1, result.n) + end + local parent = current + current = {} + local out = pack(pcall(fn, ...)) + current = parent + G.push, G.pop = realPush, realPop + if before and G.getStackDepth then + local current = G.getStackDepth() + if current < before + 1 then + G.setCanvas(unpack(canvas, 1, canvas.n)) + return false, "render callback removed its graphics guard" + end + depth = current - before - 1 + end + for _ = 1, depth do + pcall(realPop) + end + local restored, err = pcall(realPop) + G.setCanvas(unpack(canvas, 1, canvas.n)) + if not restored then + return false, err + end + return unpack(out, 1, out.n) +end +return S diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua index 8cc908415..ae209c51a 100644 --- a/src/render/HudTiles.lua +++ b/src/render/HudTiles.lua @@ -72,6 +72,12 @@ local HUD_DEFAULTS = { hpBarYellowPixels = 10, expBarTiles = 8, } +local GSC_DEFAULTS = { + hpBarTiles = 6, + hpBarGreenPixels = 24, + hpBarYellowPixels = 10, + expBarTiles = 8, +} -- ASKED SIX TIMES A ROW. drawHPBar alone calls this twice per bar, and a -- party screen draws six of them a frame -- so this built the same nine-field @@ -79,18 +85,19 @@ local HUD_DEFAULTS = { -- answer that only changes when the font record is reloaded. It is memoised -- on the record it was built from, so a reload rebuilds it and HudTiles. -- invalidate (already registered with Assets) clears it outright. -local geoCache, geoFrom +local geoCache, geoFrom, geoDefaults local function hudGeometry() + local defaults = require('src.core.GameVersion').isGen2() and GSC_DEFAULTS or HUD_DEFAULTS local font = require("src.core.Data").font local hud = type(font) == "table" and font.hud or nil - if type(hud) ~= "table" then return HUD_DEFAULTS end - if geoCache and geoFrom == hud then return geoCache end - geoFrom = hud + if type(hud) ~= "table" then return defaults end + if geoCache and geoFrom == hud and geoDefaults == defaults then return geoCache end + geoFrom, geoDefaults = hud, defaults geoCache = { - hpBarTiles = hud.hpBarTiles or HUD_DEFAULTS.hpBarTiles, - hpBarGreenPixels = hud.hpBarGreenPixels or HUD_DEFAULTS.hpBarGreenPixels, - hpBarYellowPixels = hud.hpBarYellowPixels or HUD_DEFAULTS.hpBarYellowPixels, - expBarTiles = hud.expBarTiles or HUD_DEFAULTS.expBarTiles, + hpBarTiles = hud.hpBarTiles or defaults.hpBarTiles, + hpBarGreenPixels = hud.hpBarGreenPixels or defaults.hpBarGreenPixels, + hpBarYellowPixels = hud.hpBarYellowPixels or defaults.hpBarYellowPixels, + expBarTiles = hud.expBarTiles or defaults.expBarTiles, expBarEmptyTile = hud.expBarEmptyTile, -- the stats screen's page-tile block, and the two tiles in it that close -- the exp bar on that screen (RomExtractorGen2:gen2StatsExpCaps) @@ -250,6 +257,16 @@ function HudTiles.capTile(barType) return barType == 1 and 0x6D or 0x6C end +function HudTiles.hpPixels(hp, maximum, segments) + if maximum <= 0 or hp <= 0 then return 0 end + segments = segments or hudGeometry().hpBarTiles + if require('src.core.GameVersion').isGen2() then + return math.max(1, math.floor(require('src.battle.Gen2HpPresentation').pixels(hp, maximum) + * segments / 6)) + end + return math.max(1, math.floor(hp * segments * 8 / maximum)) +end + -- Tile HP bar (home/pokemon.asm DrawHPBar): "HP" ($71) + ":[" ($62), -- six 8px segments ($63 empty, +n partial, $6B full), then the -- wHPBarType right cap. A nonzero HP always shows at least a @@ -271,15 +288,15 @@ end -- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has -- red channel 0, so the tint zeroes the whole bar's red and the zone's -- red-channel-keyed shade shader then maps every pixel to color 3 = black. -function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments) +function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels) local x, y = tx * 8, ty * 8 segments = math.max(1, math.floor(segments or hudGeometry().hpBarTiles)) HudTiles.tile(0x71, x, y) HudTiles.tile(0x62, x + 8, y) - local px = 0 - if mon.stats.hp > 0 and mon.hp > 0 then - px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp)) - end + -- A short GSC loop moves pixels while its displayed HP number can repeat. + -- Recomputing the fill from that number would hide those animation beats. + local px = pixels ~= nil and math.max(0, math.min(segments * 8, math.floor(pixels))) + or HudTiles.hpPixels(mon.hp, mon.stats.hp, segments) local paint if not grayFill then local PaletteFX = require("src.render.PaletteFX") diff --git a/src/render/NativeScreenOverlay.lua b/src/render/NativeScreenOverlay.lua new file mode 100644 index 000000000..dc1a7a8d6 --- /dev/null +++ b/src/render/NativeScreenOverlay.lua @@ -0,0 +1,33 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Controller-owned feedback is painted after its replaceable screen view. +-- The same state keeps input/continuations; no extra modal is pushed. +local Scope = require('src.render.GraphicsScope') +local M = {} +function M.draw(state, width, height) + local draw = state.drawNativeOverlay + if type(draw) ~= 'function' then return end + if state.wantsNativeOverlay and not state:wantsNativeOverlay() then return end + local ok, why = Scope.call(draw, state, width, height) + if not ok then error(why, 0) end +end +local function view(state, fn, ...) + -- Ordinary screens keep their original route. A controller with feedback + -- fences its replaceable view before drawing its native continuation. + if type(state.drawNativeOverlay) == 'function' and + (not state.wantsNativeOverlay or state:wantsNativeOverlay()) then + local ok, why = Scope.call(fn, state, ...) + if not ok then error(why, 0) end + else + fn(state, ...) + end +end +function M.screen(state, width, height) + view(state, state.draw) + return M.draw(state, width, height) +end +function M.window(state, width, height) + view(state, state.drawWidescreen, width, height) + return M.draw(state, width, height) +end +return M diff --git a/src/render/Palette.lua b/src/render/Palette.lua new file mode 100644 index 000000000..6982156b2 --- /dev/null +++ b/src/render/Palette.lua @@ -0,0 +1,136 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Palette IDs and category vocabulary follow Gen1Recomp's public protocol. +-- An optional authored data.gb_palettes library supplies named ramps; this +-- module contains no cartridge palette catalogue or private renderer data. +local P = {} +P.MONO_COLOURS = { + {"GREEN",155,188,15}, {"LIME",168,216,56}, {"OLIVE",166,172,132}, + {"AMBER",255,176,0}, {"ORANGE",255,128,48}, {"RED",255,80,80}, + {"ROSE",255,144,176}, {"MAGENTA",240,96,208}, {"PURPLE",176,112,240}, + {"INDIGO",128,128,240}, {"BLUE",80,160,255}, {"CYAN",64,224,224}, + {"TEAL",64,192,160}, {"MINT",144,224,176}, {"SAND",224,200,144}, + {"BROWN",192,144,80}, {"WHITE",255,255,255}, {"SILVER",208,216,224}, +} +P.GREY_CHROMA, P.HUE_ARC = 12, 50 +P.CATEGORIES = {{key="full",label="FULL COLOUR"},{key="single",label="SINGLE COLOUR"},{key="grey",label="GREYSCALE"}} +local library, index, buckets, lastId, lastRamp +function P.packs() + if library == nil then + local ok, value = pcall(require, "data.gb_palettes") + library = ok and type(value) == "table" and value or false + end + return library or {} +end +function P.packId(group, name) return "p:" .. group .. "/" .. name end +function P.monoId(r,g,b) return ("m:%02x%02x%02x"):format(r,g,b) end +function P.monoRamp(r,g,b) + local out = {} + for i, scale in ipairs({1,0.72,0.38,0.12}) do + out[i] = {math.floor(r*scale+0.5),math.floor(g*scale+0.5),math.floor(b*scale+0.5)} + end + return out +end +function P.unhex(text) + if type(text) ~= "string" or #text ~= 24 or text:find("[^%x]") then return nil end + local out = {} + for i=1,4 do + out[i] = {} + for c=1,3 do local at=(i-1)*6+(c-1)*2+1;out[i][c]=tonumber(text:sub(at,at+1),16) end + end + return out +end +function P.hueChroma(color) + local r,g,b=color[1] or 0,color[2] or 0,color[3] or 0 + local hi,lo=math.max(r,g,b),math.min(r,g,b) + local c=hi-lo + if c<=0 then return 0,0 end + local h + if hi==r then h=((g-b)/c)%6 elseif hi==g then h=(b-r)/c+2 else h=(r-g)/c+4 end + return h*60,c +end +function P.hueArc(hues) + local span=360 + for _,start in ipairs(hues) do + local width=0 + for _,hue in ipairs(hues) do width=math.max(width,(hue-start)%360) end + span=math.min(span,width) + end + return span +end +function P.categoryOf(ramp) + if type(ramp)~="table" or not ramp[4] then return nil end + local hues={} + for i=1,4 do + local h,c=P.hueChroma(ramp[i]) + if c>P.GREY_CHROMA then hues[#hues+1]=h end + end + if #hues==0 then return "grey" end + return P.hueArc(hues)<=P.HUE_ARC and "single" or "full" +end +local function ensureIndex() + if index then return end + index,buckets={},{full={},single={},grey={}} + for _,pack in ipairs(P.packs()) do + for _,entry in ipairs(pack.palettes or {}) do + if type(entry[1])=="string" then + local id=P.packId(pack.name,entry[1]);index[id]=entry + local ramp=P.unhex(entry[2]);local category=ramp and P.categoryOf(ramp) + if category then + local bucket=buckets[category] + bucket[#bucket+1]={name=entry[1],pack=pack.name,id=id} + end + end + end + end +end +function P.findPack(group,name) ensureIndex();return index[P.packId(group,name)] end +function P.category(key) + ensureIndex() + for _,category in ipairs(P.CATEGORIES) do + if category.key==key then return {key=key,name=category.label,palettes=buckets[key]} end + end +end +function P.categories() + local out={} + for _,category in ipairs(P.CATEGORIES) do out[#out+1]=P.category(category.key) end + return out +end +function P.locate(id) + if type(id)~="string" or id=="" then return nil end + ensureIndex() + for _,category in ipairs(P.CATEGORIES) do + for i,palette in ipairs(buckets[category.key]) do if palette.id==id then return category.key,i end end + end +end +function P.label(id) + if type(id)~="string" or id=="" then return nil end + local hex=id:match("^m:(%x+)$") + if hex then + local r,g,b=tonumber(hex:sub(1,2),16),tonumber(hex:sub(3,4),16),tonumber(hex:sub(5,6),16) + for _,color in ipairs(P.MONO_COLOURS) do if r==color[2] and g==color[3] and b==color[4] then return color[1] end end + return "MONO" + end + return id:match("^p:.*/([^/]+)$") or id +end +function P.ramp(id) + if type(id)~="string" or id=="" then return nil end + if id==lastId then return lastRamp end + local ramp + local hex=id:match("^m:(%x%x%x%x%x%x)$") + if hex then ramp=P.monoRamp(tonumber(hex:sub(1,2),16),tonumber(hex:sub(3,4),16),tonumber(hex:sub(5,6),16)) + else + local group,name=id:match("^p:(.*)/([^/]+)$") + local entry=group and P.findPack(group,name) + local raw=entry and P.unhex(entry[2]) + if raw then ramp={raw[4],raw[3],raw[2],raw[1]} end + end + lastId,lastRamp=id,ramp + return ramp +end +function P.swatch(id) + local ramp=P.ramp(id) + if ramp then return {ramp[4],ramp[3],ramp[2],ramp[1]} end +end +function P.invalidate() index,buckets,lastId,lastRamp=nil,nil,nil,nil end +return P diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..aa39bc8a9 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -44,6 +44,31 @@ PaletteFX.MODE_LABELS = { } PaletteFX.mode = "gbc" +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +PaletteFX.GEN2_MODES = {"gbc", "og", "classic"} +local customRamp, customKey = nil, "" +function PaletteFX.customRamp() return customRamp end +function PaletteFX.displayKey() return tostring(PaletteFX.mode) .. customKey end +function PaletteFX.colorModes() + return GameVersion.isGen2() and PaletteFX.GEN2_MODES or PaletteFX.MODES +end +local function rampKey(ramp) + if ramp == nil then return "" end + if type(ramp) ~= "table" then return nil end + local out = {} + for i = 1, 4 do + local row = ramp[i] + if type(row) ~= "table" then return nil end + for c = 1, 3 do + local n = row[c] + if type(n) ~= "number" or n ~= n or n < 0 or n > 255 then return nil end + out[#out + 1] = ("%.17g"):format(n) + end + end + return "#ramp:" .. table.concat(out, ",") +end + -- ------- dark-cave state (wMapPalOffset) -- -- Unlike the per-frame shadeMap further down, this outlives a frame: ADVANCED @@ -421,6 +446,7 @@ local GEN2_SHADE_MODES = { } function PaletteFX.usesGen2ObjPal(mode) + if GameVersion.isGen2() then return true end mode = mode or PaletteFX.mode return not GEN2_SHADE_MODES[mode] end @@ -467,6 +493,10 @@ local gen2ClockTod = "DAY" -- what the clock alone says local gen2MapPalette = 0 -- PALETTE_* from the current map's header -- OverworldState:timeOfDay answers "MORNING"/"NITE"; a mod's world.tod hook may -- answer "NIGHT". Normalise to the four row names the importer writes. +-- Public ROM row order, shared with native Gen2 atlas consumers. +PaletteFX.DAYTIMES = { "MORN", "DAY", "NITE", "DARK" } +PaletteFX.DAYTIME_ID = { MORN = 1, DAY = 2, NITE = 3, DARK = 4 } + local GEN2_TOD_ALIAS = { MORN = "MORN", MORNING = "MORN", DAY = "DAY", DAYTIME = "DAY", NITE = "NITE", NIGHT = "NITE", EVE = "NITE", EVENING = "NITE", @@ -485,6 +515,29 @@ local GEN2_TOD_ALIAS = { local GEN2_MAP_PALETTE_ROW = { [1] = "DAY", [2] = "NITE", [3] = "MORN", [4] = "NITE", } +local gen2FlashUsed=false +local gen2CurrentDef +function PaletteFX.clearGen2Context()gen2CurrentDef=nil;gen2FlashUsed=false end +function PaletteFX.bindGen2Context(def,flashUsed) + gen2CurrentDef=type(def)=='table'and def or nil;gen2FlashUsed=flashUsed==true +end +function PaletteFX.setGen2FlashUsed(on)gen2FlashUsed=on==true end +function PaletteFX.effectiveGen2Tod()return darkWorld and 'DARK'or gen2Tod end +-- AUTO neighbours share the entered AUTO scene's presentation override. +-- A pinned current room/cave has no outdoor clock presentation to share: +-- neighbouring AUTO maps read the saved RTC instead of its unused hook row. +local function gen2NeighbourClock() + local id=require('src.render.Gen2PaletteContext').id(gen2CurrentDef) + if GEN2_MAP_PALETTE_ROW[id] then return PaletteFX.clockDaytime() end + return gen2ClockTod +end +function PaletteFX.gen2RowFor(def) + if def~=nil and def==gen2CurrentDef then return PaletteFX.effectiveGen2Tod()end + local id=require('src.render.Gen2PaletteContext').id(def) + if id==nil then return PaletteFX.effectiveGen2Tod() end + if id==4 then return gen2FlashUsed and 'NITE'or 'DARK'end + return GEN2_MAP_PALETTE_ROW[id]or gen2NeighbourClock() +end local function gen2RecomputeTod() local key = GEN2_MAP_PALETTE_ROW[gen2MapPalette] or gen2ClockTod @@ -502,7 +555,8 @@ end -- Called on every map change with the map's PALETTE_* byte (0 = AUTO). -- Same contract as setGen2Tod: true means the atlases are stale. -function PaletteFX.setGen2MapPalette(palette) +function PaletteFX.setGen2MapPalette(palette, mapDef) + gen2CurrentDef=type(mapDef)=='table'and mapDef or nil local value = tonumber(palette) or 0 if value < 0 or value > 7 then value = 0 end -- PALETTE_5..7 are unused slots that ReplaceTimeOfDayPals maps to $E4, the @@ -535,6 +589,22 @@ function PaletteFX.gen2Tod() return gen2Tod end -- bgSet fetches it (LoadMapPals). Neither reads or writes the live state, so -- calling them cannot disturb what the 2D atlas was baked with. +-- Public RTC-only query used by Gen2 scene renderers (including train rides). +-- It must not inherit the live map override, darkness, or atlas bake state. +function PaletteFX.clockDaytime(hour) + hour = tonumber(hour) + if not hour and GameVersion.isGen2() then + local game = package.loaded["src.core.Game"] + hour = require("src.core.Gen2Clock").hour(game and game.save) + end + if not hour then hour = tonumber(os.date("%H")) end + hour = math.floor(hour or 12) % 24 + if hour < 4 then return "NITE" end + if hour < 10 then return "MORN" end + if hour < 18 then return "DAY" end + return "NITE" +end + -- Which of MORN / DAY / NITE / DARK a map takes. -- -- mapDef a record out of data.maps; its `mapPalette` is map header @@ -546,6 +616,11 @@ function PaletteFX.gen2Tod() return gen2Tod end -- a caller that has no flash flag to offer gets the engine's own -- darkWorld state, which already folds in save.flashLit. function PaletteFX.daytimeFor(mapDef, hour, flashUsed) + local id=require('src.render.Gen2PaletteContext').id(mapDef) + if id~=nil then + if id==4 then return flashUsed==true and 'NITE'or 'DARK'end + return GEN2_MAP_PALETTE_ROW[id]or(hour==nil and gen2CurrentDef and gen2NeighbourClock())or PaletteFX.clockDaytime(hour) + end -- Darkness wins outright and the clock stops mattering, which is what -- .NeedsFlash does: an unlit cave is the DARKNESS row at noon. if flashUsed ~= true and darkWorld then return "DARK" end @@ -559,13 +634,7 @@ function PaletteFX.daytimeFor(mapDef, hour, flashUsed) if override then return override end -- AUTO: GetTimeOfDay's own table (5:$4032 .TimeOfDayTable). - hour = tonumber(hour) - if not hour then hour = tonumber(os.date("%H")) end - hour = math.floor(hour or 12) % 24 - if hour < 4 then return "NITE" end - if hour < 10 then return "MORN" end - if hour < 18 then return "DAY" end - return "NITE" + return PaletteFX.clockDaytime(hour) end -- The eight BG palettes a map wears on a given row. @@ -596,9 +665,18 @@ function PaletteFX.bgSet(data, mapDef, daytime) end if type(rows) ~= "table" then return nil end - local row = GEN2_TOD_ALIAS[tostring(daytime or ""):upper()] or gen2Tod + local row = GEN2_TOD_ALIAS[tostring(daytime or ""):upper()] or PaletteFX.gen2RowFor(mapDef) local set = rows[row] or rows.DAY if type(set) ~= "table" or set[1] == nil then return nil end + local context=require('src.render.Gen2PaletteContext') + local owner,bound=context.owner(data) + if bound and not owner then return nil end + if owner then + local _,special=context.special(owner,mapDef,tilesetId,row) + if special then set=special end + local tileset=owner.tilesets and owner.tilesets[tilesetId] + set=context.withRoof(set,context.roof(owner,mapDef,tileset,row)) + end return set, row end @@ -787,6 +865,10 @@ function PaletteFX.monPal(data, species, transformed, shiny) return p.palettes.GRAYMON or (data and data.palettes and data.palettes.palettes.GRAYMON) end + if GameVersion.isGen2() then + local native = require("src.render.Gen2PokemonPalettes").native(data, species, shiny) + if native then return native end + end local def = data and data.pokemon and data.pokemon[species] if shiny and def and def.shinyPalette then local pal = p.palettes[def.shinyPalette] @@ -828,6 +910,12 @@ function PaletteFX.monPal(data, species, transformed, shiny) return nil end +-- Legacy external Gen2 consumers ask the published palette view directly. +-- Resolve the same native row as monPal and return fresh RGB values. +function PaletteFX.monColors(data, species, shiny) + return require("src.render.Gen2PokemonPalettes").colors(data, species, shiny) +end + -- palette name a species currently resolves to (for image-cache keys) function PaletteFX.monPalName(data, species, transformed, shiny) if transformed then return "GRAYMON" end @@ -995,13 +1083,12 @@ function PaletteFX.spriteObp(spriteDef, seed) end -- GetHPPal (home/hp_pal.asm) on this cartridge's bar. Crystal's is 48 pixels --- wide and turns green at 27 and yellow at 10; a cartridge that widened the +-- wide and turns green at 24 and yellow at 10; a cartridge that widened the -- bar moved both, and reading Prism's 56-pixel bar through Crystal's numbers -- showed yellow between half and 56% health where the cartridge shows green. -function PaletteFX.barPalName(hp, maxHp) +function PaletteFX.barPalName(hp, maxHp, pixels) local geo = require("src.render.HudTiles").geometry() - local px = maxHp > 0 and math.floor(hp * geo.hpBarTiles * 8 / maxHp) or 0 - if hp > 0 and px < 1 then px = 1 end + local px = pixels ~= nil and pixels or require('src.render.HudTiles').hpPixels(hp, maxHp) return px >= geo.hpBarGreenPixels and "GREENBAR" or px >= geo.hpBarYellowPixels and "YELLOWBAR" or "REDBAR" end @@ -1067,54 +1154,124 @@ function PaletteFX.shadeMap() return shadeMap end -function PaletteFX.setMode(mode) - local prev = PaletteFX.mode - local ok = false - for _, m in ipairs(PaletteFX.MODES) do - if m == mode then - PaletteFX.mode = mode - ok = true - break +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Public byte-register access and native frame drawing share one state. +-- Renderer.beginFrame's existing reset therefore also clears a mod's BGP; +-- a byte cannot silently survive a menu, map or battle frame. +PaletteFX.BGP_IDENTITY = 0xe4 +function PaletteFX.bgpShades(byte) + byte = byte or PaletteFX.BGP_IDENTITY + local out = {} + for i = 0, 3 do out[i + 1] = math.floor(byte / 4 ^ i) % 4 end + return out +end +function PaletteFX.bgp() + if not shadeMap then return nil end + local byte = 0 + for i = 0, 3 do byte = byte + shadeMap[i] * 4 ^ i end + return byte ~= PaletteFX.BGP_IDENTITY and byte or nil +end +function PaletteFX.setBgp(byte) + local previous = PaletteFX.bgp() + if byte == nil or byte == PaletteFX.BGP_IDENTITY then + PaletteFX.setShadeMap(nil) + else + local shades = PaletteFX.bgpShades(byte) + PaletteFX.setShadeMap({[0]=shades[1], shades[2], shades[3], shades[4]}) + end + return previous +end +function PaletteFX.remap(colors, byte) + if not colors or not byte or byte == PaletteFX.BGP_IDENTITY then return colors end + local shades, out = PaletteFX.bgpShades(byte), {} + for i = 1, 4 do out[i] = colors[shades[i] + 1] or colors[4] end + return out +end + +local function invalidateDisplay() + -- Register native producers before the shared flush. External producers + -- subscribe to the same Assets.register bus; private cache calls alone + -- leave their atlases stale when a second ramp keeps the same mode ID. + for _, name in ipairs({"src.battle.BattleState","src.render.SpriteRenderer", + "src.render.TileRenderer","src.world.MapLoader"}) do + pcall(require, name) + end + require("src.render.Assets").invalidate() + pcall(function() + local Game = require("src.core.Game") + if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then + Game.overworld:reloadMap(Game.overworld.map.id, "colors") end + end) +end +local function normalizeMode(mode) + if GameVersion.isGen2() then + if mode == "dmg" then return "og" end + if mode == "custom" then return mode end end - if not ok then PaletteFX.mode = "gbc" end - -- battle pics and overworld sprites bake the active pack into ImageData; - -- drop those caches when the pack (or any COLORS mode) changes so the - -- next draw re-tints - if prev ~= PaletteFX.mode then - pcall(function() require("src.battle.BattleState").invalidate() end) - pcall(function() require("src.render.SpriteRenderer").invalidate() end) - -- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once - -- per loaded map, so a mode toggle needs every cached Map/TileRenderer - -- dropped and the currently-visible one rebuilt in place -- otherwise - -- the on-screen map keeps its stale (wrong-mode) atlas until the next - -- map transition happens to reload it. - pcall(function() - require("src.world.MapLoader").invalidateAll() - local Game = require("src.core.Game") - if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then - Game.overworld:reloadMap(Game.overworld.map.id, "colors") - end - end) + for _, m in ipairs(PaletteFX.MODES) do + if m == mode then return mode end end + return "gbc" +end +-- Set a complete selection atomically: applying persisted mode + ramp must +-- rebuild the entered map once, never briefly in the previous custom ramp. +function PaletteFX.setDisplay(mode, ramp) + local key = rampKey(ramp) + if key == nil then ramp, key = nil, "" end + mode = normalizeMode(mode) + local changed = mode ~= PaletteFX.mode or ramp ~= customRamp or key ~= customKey + PaletteFX.mode, customRamp, customKey = mode, ramp, key + if changed then invalidateDisplay() end + return mode +end +function PaletteFX.setMode(mode) + return PaletteFX.setDisplay(mode, customRamp) +end +function PaletteFX.setCustomRamp(ramp) + PaletteFX.setDisplay(PaletteFX.mode, ramp) + return customRamp end -function PaletteFX.cycleMode() +function PaletteFX.cycleMode(delta) local cur = PaletteFX.mode or "gbc" + if GameVersion.isGen2() and cur == "og_inv" then cur = "og" end + local modes = PaletteFX.colorModes() local idx = 1 - for i, m in ipairs(PaletteFX.MODES) do + for i, m in ipairs(modes) do if m == cur then idx = i; break end end - PaletteFX.setMode(PaletteFX.MODES[idx % #PaletteFX.MODES + 1]) + local nextMode = modes[(idx - 1 + (delta or 1)) % #modes + 1] + local ramp = customRamp + if GameVersion.isGen2() then ramp = nil end + PaletteFX.setDisplay(nextMode, ramp) return PaletteFX.mode end function PaletteFX.applyOptions(opts) - PaletteFX.setMode(opts and opts.colors or "gbc") + if GameVersion.isGen2() then + local ramp = opts and opts.palette and require("src.render.Palette").ramp(opts.palette) + return PaletteFX.setDisplay(opts and (opts.color or opts.colors) or "gbc", ramp) + end + return PaletteFX.setMode(opts and opts.colors or "gbc") +end +function PaletteFX.writeColorOption(opts, mode) + opts.colors = mode + if GameVersion.isGen2() then + opts.color = mode == "og" and "dmg" or mode + opts.palette = nil + end +end +function PaletteFX.presentColors() + if GameVersion.isGen2() and PaletteFX.mode == "classic" then return PaletteFX.CLASSIC end end function PaletteFX.modeLabel(mode) mode = mode or PaletteFX.mode + if GameVersion.isGen2() then + return ({gbc="GEN 2", og="DMG", dmg="DMG", classic="CLASSIC", custom="CUSTOM"})[mode] or "GEN 2" + end -- The GBC hardware mode wears the running game's name: OG RED / OG BLUE -- (boot-ROM auto-palette) or OG YELLOW (pokeyellow CGBBasePalettes). if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end @@ -1155,27 +1312,48 @@ function PaletteFX.paperShade(data) return c[1] / 255, c[2] / 255, c[3] / 255 end -function PaletteFX.effectiveColors(c) +function PaletteFX.displayColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c - if gen2Native() then + if GameVersion.isGen3() then mode = "gbc" end - if mode == "og" then + if GameVersion.isGen2() and customRamp then return customRamp end + if mode == "og" or (GameVersion.isGen2() and mode == "custom") then out = PaletteFX.GRAYS elseif mode == "og_inv" then out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) elseif mode == "classic" then - out = PaletteFX.CLASSIC + out = GameVersion.isGen2() and PaletteFX.GRAYS or PaletteFX.CLASSIC elseif mode == "gbc_inv" then out = PaletteFX.permute(c, INV_MAP) end + return out +end +-- Static native BG/OBJ bakes resolve the display without a transient BGP. +-- DARK rows lose their darkness if replaced by a mono/custom ramp; fold the +-- world shift explicitly only for such replacements. Menus stay independent. +function PaletteFX.gen2BakeColors(c, dark) + local out = PaletteFX.displayColors(c) + local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" or PaletteFX.mode == "classic" or PaletteFX.mode == "custom" + if GameVersion.isGen2() and dark and (mono or customRamp) then + out = PaletteFX.permute(out, PaletteFX.DARK_BGP) + end + return out +end +function PaletteFX.effectiveColors(c, invert) + local out = PaletteFX.displayColors(c) + if not out then return nil end -- The shade map goes on LAST: rBGP is a hardware register write, so it -- composes on top of whatever colors the display mode settled on -- a dark -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too -- (#322). permute() is the identity (and returns `out` itself, which the -- mod-graphics parity check leans on) while nothing has armed one. + -- An inverted font page reverses its settled display ramp before the + -- hardware shade register permutes it. Keep this on the native path so + -- Gen2 menu chrome cannot carry a second palette mode/register state. + if invert then out = { out[4], out[3], out[2], out[1] } end return PaletteFX.permute(out, shadeMap) end diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 4d8143548..4b8336df7 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +local Gen2Presentation = require("src.render.Gen2Presentation") -- RESOLVED ONCE, NOT EVERY FRAME. These three were `require`d from inside -- beginFrame and endFrame -- three string hashes into package.loaded on every @@ -109,6 +110,7 @@ local function displayMetrics() end function Renderer:init() + Gen2Presentation.reset() -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- (#208). Every canvas below is sized in framebuffer pixels for the same -- reason -- worldViewSize() already works in drawable pixels. @@ -320,6 +322,8 @@ end -- transparent: the world pass shows through (UI pass draws overlays only) function Renderer:beginFrame(transparent) + self.gen2FrameManaged, self.gen2FrameGame, self.gen2FrameZones = nil, nil, nil + self.gen2WindowDraw = nil -- a frame that ends early -- a state that returns before endFrame, an error -- caught upstream -- must not leak a queued native-resolution draw into the -- next frame's composite @@ -715,6 +719,10 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, love.graphics.setShader() end +function Renderer:blitZones(canvas,zones,w,h) + return Gen2Presentation.blit(canvas,zones,w,h) +end + -- THE BARS, FILLED WITH THE EDGE THEY FRAME. -- -- Asked for directly: "for all gen3 menus take the outside border 1px and @@ -892,7 +900,7 @@ function Renderer:endFrame(zones, worldZones) -- stacked Game Boy screens, or driving one onto a second physical display. -- The mod returns true to take over the whole window; anything else (or no -- mod wrapping the hook) falls through to the normal composite below. - if Runtime.wantsHook("render.compose") then + if not self.gen2FrameManaged and Runtime.wantsHook("render.compose") then local ctx = { renderer = self, worldCanvas = self.worldCanvas, uiCanvas = self.canvas, @@ -949,6 +957,9 @@ function Renderer:endFrame(zones, worldZones) and self.battleWipe.screenDraw local needPresent = GBCFX.active() or Pipelines.wantsPresent() or battleNeedsSource + or (self.gen2FrameManaged and (self.gen2WindowDraw + or (type(self.gen2FrameZones)=="table" and self.gen2FrameZones[1]) + or Runtime.wantsHook("render.compose"))) local present = nil if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww @@ -1389,6 +1400,10 @@ function Renderer:endFrame(zones, worldZones) -- over the finished composite rather than under the UI blit. On hardware -- it is the tilemap being overwritten -- there is nothing it does not cover. local battleSource = nil + if self.gen2FrameManaged and self.gen2WindowDraw then + self.gen2WindowDraw({width=ww,height=wh,gameX=ox,gameY=oy,gameWidth=vpw,gameHeight=vph,scale=Sp,dpiX=dpiX,dpiY=dpiY}) + end + if battleNeedsSource and present then -- Snapshot the untouched finished frame into a second screen-sized canvas. -- `present` is the current render target here, so sampling it directly in @@ -1432,21 +1447,25 @@ function Renderer:endFrame(zones, worldZones) if present then love.graphics.setCanvas() - -- Post-process pipelines run over the finished composite -- world, UI - -- and all -- and before GBC FX, so a blur or colour grade is what the - -- LCD grid is then drawn over rather than something that smears the - -- grid itself. Each pass hands back a canvas; with none registered - -- this returns `present` unchanged and the frame is byte-identical. - local composed = Pipelines.present(present, - { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present - if GBCFX.active() then - -- shader grid/shadow math is in framebuffer pixels - GBCFX.present(composed, Sp) - else - -- the present canvas only existed for the post-process, so put the - -- result on the screen at the same 1:1 unit mapping it was built at - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(composed, 0, 0) + local compositionHandled=false + if self.gen2FrameManaged and Runtime.wantsHook("render.compose") then + local ctx={renderer=self,game=self.gen2FrameGame,worldCanvas=present,uiCanvas=present,sceneCanvas=present, + worldActive=self.worldActive and true or false,generation=2,zones=self.gen2FrameZones,worldZones=nil, + ww=ww,wh=wh,pw=pw,ph=ph,ox=ox,oy=oy,vpw=vpw,vph=vph,uiw=uiw,uih=uih, + scale=Sp,Sx=Sx,Sy=Sy,dpiX=dpiX,dpiY=dpiY,secondScreen=require("src.render.SecondScreen")} + love.graphics.push("all") + local ok,value=xpcall(function() + return Runtime.call("render.compose",function()return false end,self,ctx)==true + end,debug.traceback) + love.graphics.pop() + if not ok then error(value,0)end + compositionHandled=value + end + if not compositionHandled then + local source=self.gen2FrameManaged and Gen2Presentation.apply(present,self.gen2FrameZones,ww,wh)or present + local composed=Pipelines.present(source,{width=ww,height=wh,scale=Sp,dpi=dpiY,dpiX=dpiX,dpiY=dpiY})or source + if GBCFX.active()then GBCFX.present(composed,Sp) + else love.graphics.setColor(1,1,1,1);love.graphics.draw(composed,0,0)end end end self.worldActive = false diff --git a/src/render/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua new file mode 100644 index 000000000..14609b1aa --- /dev/null +++ b/src/render/ScreenSpaceUI.lua @@ -0,0 +1,94 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Optional window-resolution UI. Simulation/input remain native; only an +-- explicitly advertised drawer leaves the cartridge-sized UI canvas. +local NativeScreenOverlay = require('src.render.NativeScreenOverlay') +local M = {} +local function visible(stack, state) + return not stack.renderVisible or stack:renderVisible(state) +end +function M.select(stack, scene, first) + for i = #scene.states, first, -1 do + local state = scene.states[i] + if visible(stack, state) and type(state.drawsWidescreen) == 'function' + and type(state.drawWidescreen) == 'function' and state:drawsWidescreen() then + return state, i + end + end +end +function M.classic(draw, renderer, width, height) + local G = love.graphics + local uw, uh = renderer:uiSize() + local p = renderer.uiPresentation + if not p then + local scale = math.max(1, math.floor(math.min(width / uw, height / uh))) + p = { x=(width-uw*scale)/2, y=(height-uh*scale)/2, scaleX=scale, scaleY=scale } + end + G.push('all') + G.origin() + G.translate(p.x+(uw-renderer.WIDTH)*p.scaleX/2, p.y+(uh-renderer.HEIGHT)*p.scaleY/2) + G.scale(p.scaleX,p.scaleY) + local ok, err = pcall(draw) + G.pop() + if not ok then error(err,0) end +end +-- Gen2's reference renderer paints native overlays on its full-resolution +-- scene, under a cartridge-grid transform. A drawer that resets origin() +-- therefore gets real window pixels, not a 160x144 intermediate texture. +function M.hasNativeStack(stack, scene, first, world) + for i=first,#scene.states do + local state=scene.states[i] + if state and state~=world and visible(stack,state) and type(state.draw)=='function' then return true end + end + return false +end +function M.drawNativeStack(stack, scene, first, world, renderer, viewport, opaque) + local G=love.graphics + G.push('all');G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok,err=pcall(function() + if opaque then G.rectangle('fill',0,0,viewport.width,viewport.height) end + M.classic(function() + for i=first,#scene.states do + local state=scene.states[i] + if state and state~=world and visible(stack,state) and type(state.draw)=='function' then + G.push('all') + -- Declared larger native surfaces retain their own origin. Plain + -- GB states stay centred in a parent's expanded surface. + if type(state.uiSize)=='function' then + local sized,w,h=pcall(state.uiSize,state) + if sized and type(w)=='number' and w>0 and type(h)=='number' and h>0 then + G.translate((renderer.WIDTH-w)/2,(renderer.HEIGHT-h)/2) + end + end + local drawn,why=pcall(NativeScreenOverlay.screen,state,viewport.width,viewport.height) + G.pop() + if not drawn then error(why,0) end + end + end + end,renderer,viewport.width,viewport.height) + end) + G.pop() + if not ok then error(err,0) end +end +function M.draw(owner, renderer, viewport, zones, overlay) + local G = love.graphics + G.push('all') + G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok, err = pcall(NativeScreenOverlay.window, owner, viewport.width, viewport.height) + G.pop() + if not ok then error(err,0) end + -- The classic canvas contains only states above this owner. Reapply it + -- after the high-resolution drawer so a native textbox keeps its parent. + if overlay then + local p = renderer.uiPresentation + if not p then + p = {x=viewport.gameX,y=viewport.gameY,scaleX=viewport.scale/(viewport.dpiX or 1),scaleY=viewport.scale/(viewport.dpiY or 1)} + end + G.push('all');G.origin();G.setShader();G.setColor(1,1,1,1);G.setBlendMode('alpha','premultiplied') + local overlayOK, overlayError = pcall(renderer.blitCanvas,renderer,renderer.canvas,p.scaleX,p.scaleY,zones,p.scaleX,p.scaleY, + p.x,p.y,0,0,viewport.width,viewport.height,viewport.dpiX or 1,viewport.dpiY or 1) + G.pop() + if not overlayOK then error(overlayError,0) end + end +end +return M diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index f22e1907b..bd36be9f2 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -5,6 +5,7 @@ local Assets = require("src.render.Assets") local PaletteFX = require("src.render.PaletteFX") +local GameVersion = require("src.core.GameVersion") local SpriteRenderer = {} SpriteRenderer.__index = SpriteRenderer @@ -46,6 +47,10 @@ end local obpCache = {} local function getObpImage(path, colors, group) + if GameVersion.isGen2() then + colors = PaletteFX.gen2BakeColors(colors, PaletteFX.darkWorld()) + group = tostring(group) .. "#" .. PaletteFX.displayKey() .. PaletteFX.darkKey() + end local key = path .. "#obp" .. group if not obpCache[key] then local img diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 07f605295..c19ed59a5 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -55,6 +55,8 @@ function TextBox.new(game, text, onDone, opts) self.choiceNoSound = opts.noSound self.auto = opts.auto self.stay = opts.stay + self.textSpeed = opts.textSpeed + self.allowTextAcceleration = opts.allowTextAcceleration ~= false -- A few cartridge screens print through the same text engine without using -- the field's framed dialogue window. Teachy TV is one: teachy_tv.c gives -- it a 26x4 raw window at (2,15), fills it with palette colour $C and calls @@ -559,9 +561,9 @@ function TextBox:update(dt) -- typewriter cadence: one character every N frames, N = the OPTION -- text speed (TextSpeedOptionData frame delays 1/3/5); holding A/B -- prints every frame like the original's held-button fast path - local delay = (self.game.save.options and self.game.save.options.textSpeed) or 3 + local delay = self.textSpeed or (self.game.save.options and self.game.save.options.textSpeed) or 3 if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end - if input:isDown("a") or input:isDown("b") then delay = 1 end + if self.allowTextAcceleration and (input:isDown("a") or input:isDown("b")) then delay = 1 end self.charTimer = (self.charTimer or 0) + 1 while self.charTimer >= delay do self.charTimer = self.charTimer - delay diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 5f7a007a5..30429842d 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -568,19 +568,27 @@ end -- palColorsByTod is what the Gen2 importer now writes; palColors alone is the -- older single-row shape, kept working so a dataset extracted before this -- change still renders instead of falling back to grey. -local function gen2PalColors(tileset) +local function gen2PalColors(tileset, row) local byTod = tileset.palColorsByTod if type(byTod) ~= "table" then return tileset.palColors end - if PaletteFX.darkWorld() then return byTod.DARK or tileset.palColors end - return byTod[PaletteFX.gen2Tod()] or byTod.DAY or tileset.palColors + row=row or PaletteFX.effectiveGen2Tod() + if row=='DARK'then return byTod.DARK or tileset.palColors end + return byTod[row] or byTod.DAY or tileset.palColors +end + +local function gen2DisplayRows(rows, row) + if not (rows and require("src.core.GameVersion").isGen2()) then return rows end + local out = {} + for i, colors in ipairs(rows) do out[i] = PaletteFX.gen2BakeColors(colors, row == "DARK") end + return out end -- One atlas per (tileset image, COLORS mode, time of day). Leaving the mode -- out of this key is why switching COLORS did nothing out in the Gen2 -- overworld: the first bake won and every later mode kept being handed it. -local function gen2AtlasKey(imagePath, roof, special) - return imagePath .. "#gen2pal:" .. tostring(PaletteFX.mode) .. ":" - .. (PaletteFX.darkWorld() and "DARK" or tostring(PaletteFX.gen2Tod())) +local function gen2AtlasKey(imagePath, roof, special, row) + return imagePath .. "#gen2pal:" .. PaletteFX.displayKey() .. ":" + .. tostring(row or PaletteFX.effectiveGen2Tod()) -- TWO MAPS ON ONE TILESET ARE TWO DIFFERENT PICTURES once the roof is in -- it. Violet and Azalea share TilesetJohto1 and wear different roofs, so -- leaving the roof out of the key would hand the second town the first @@ -601,36 +609,10 @@ end -- -- Skipped outright while the world is dark, which keeps the ROM's ordering: -- the darkness row is the first row in that table and beats every other. -local function gen2SpecialPalette(map, data) - if PaletteFX.darkWorld() then return nil end - local special = data and data.field and data.field.gen2MapPalettes - if type(special) ~= "table" or type(special.rules) ~= "table" then return nil end - local def = data.maps and map.id and data.maps[map.id] - if type(def) ~= "table" then return nil end - local tileset = map.tileset and map.tileset.id - for _, rule in ipairs(special.rules) do - local hit - if rule.kind == "map" then - hit = rule.group == def.group and rule.number == def.number - elseif rule.kind == "landmark" then - hit = rule.landmark == def.landmark - elseif rule.kind == "tileset" then - hit = rule.tileset == tileset - end - -- RegionCheck, as PokeCenterSpecialCase applies it - if hit and rule.landmarkMin then - hit = type(def.landmark) == "number" and def.landmark >= rule.landmarkMin - end - if hit and rule.landmarkEq then hit = def.landmark == rule.landmarkEq end - if hit then - local set = special.sets and special.sets[rule.set] - local rows = set and (set.ALL - or set[PaletteFX.gen2Tod()] or set.DAY) - if rows and #rows > 0 then return "#pal:" .. tostring(rule.set), rows end - return nil - end - end - return nil +local function gen2SpecialPalette(map, data, row) + local def=data.maps and map.id and data.maps[map.id] or map.def + return require('src.render.Gen2PaletteContext').special(data,def, + map.tileset and map.tileset.id,row or PaletteFX.effectiveGen2Tod()) end -- THE ROOF A MAP WEARS, or nil when it wears none. @@ -639,33 +621,10 @@ end -- tileset (see RomExtractorGen2:gen2Roofs for the cartridge routines). The -- art is a nine-tile strip on disk and the colour is a PAIR -- colours 1 and 2 -- of the roof palette row, which is all LoadMapPals ever writes. -local function gen2RoofFor(map, data) - local roofs = data and data.field and data.field.gen2Roofs - if type(roofs) ~= "table" then return nil end - local tileset = map and map.tileset - if not (tileset and type(roofs.tilesets) == "table" - and roofs.tilesets[tileset.id]) then - return nil - end - local def = data.maps and map.id and data.maps[map.id] - local group = (def and def.group) or map.group - local id = group ~= nil and type(roofs.byGroup) == "table" - and roofs.byGroup[group] or nil - if id == nil then return nil end - local image = type(roofs.images) == "table" and roofs.images[id] or nil - if not image then return nil end - local byTod = type(roofs.colors) == "table" and roofs.colors[group] or nil - local pair = byTod and (PaletteFX.darkWorld() and byTod.DARK - or byTod[PaletteFX.gen2Tod()] or byTod.DAY) or nil - return { - image = image, - pair = pair, - slot = roofs.slot or 10, - count = roofs.count or 9, - palIndex = roofs.palIndex or 6, - variants = tileset.tileVariants, - key = "#roof:" .. tostring(id) .. "." .. tostring(group), - } +local function gen2RoofFor(map, data, row) + local def=data.maps and map.id and data.maps[map.id] or map.def or map + return require('src.render.Gen2PaletteContext').roof(data,def,map.tileset, + row or PaletteFX.effectiveGen2Tod()) end local gen2AtlasCache = {} @@ -792,6 +751,7 @@ function TileRenderer.gen2AtlasFor(tileset) if not (tileset.palMap and #tileset.palMap > 0) then return nil end if not PaletteFX.usesGen2BgPal() then return nil end local colors = gen2PalColors(tileset) + colors = gen2DisplayRows(colors, PaletteFX.effectiveGen2Tod()) if not (colors and #colors > 0) then return nil end local perRow = tileset.tilesPerRow if not perRow or perRow < 1 then return nil end @@ -1368,14 +1328,17 @@ function TileRenderer.new(map, data) -- "show me the hardware", and the DMG/SGB-flavoured modes have to fall -- through to the raw sheet and their own shade treatment. Unconditional, it -- made COLORS a no-op for every Gen2 tile on screen. - local gen2Colors = PaletteFX.usesGen2BgPal() and gen2PalColors(map.tileset) + local gen2Def=data and data.maps and map.id and data.maps[map.id] or map.def + local gen2Enabled=PaletteFX.usesGen2BgPal() + local gen2Row=gen2Enabled and PaletteFX.gen2RowFor(gen2Def) + local gen2Colors = gen2Enabled and gen2PalColors(map.tileset,gen2Row) -- ...and this map's own seven, where the cartridge gives it seven of its -- own. Before the roof swap below, because the ROM does it in that order: -- LoadMapPals picks the palettes, special or environment, and only then -- writes the map group's roof pair into whichever set it ended up with. local specialKey if gen2Colors then - local key, rows = gen2SpecialPalette(map, data) + local key, rows = gen2SpecialPalette(map, data,gen2Row) if rows then gen2Colors, specialKey = rows, key end end if not self.gbcAtlas and map.tileset.palMap and #map.tileset.palMap > 0 @@ -1386,18 +1349,10 @@ function TileRenderer.new(map, data) -- list with just that pair replaced is the whole of it. A copy, because -- `gen2Colors` is the tileset def's own table and two groups sharing a -- tileset would otherwise overwrite each other's roof. - local roof = gen2RoofFor(map, data) - if roof and roof.pair then - local swapped = {} - for i = 1, #gen2Colors do swapped[i] = gen2Colors[i] end - local row = swapped[roof.palIndex + 1] - if row then - swapped[roof.palIndex + 1] = - { row[1], roof.pair[1], roof.pair[2], row[4] } - end - gen2Colors = swapped - end - local key = gen2AtlasKey(map.tileset.image, roof, specialKey) + local roof = gen2RoofFor(map, data,gen2Row) + gen2Colors=require('src.render.Gen2PaletteContext').withRoof(gen2Colors,roof) + gen2Colors=gen2DisplayRows(gen2Colors,gen2Row) + local key = gen2AtlasKey(map.tileset.image, roof, specialKey,gen2Row) local gen2img = getGen2Atlas(key, map.tileset.image, map.tileset.tilesPerRow, map.tileset.palMap, gen2Colors, TileRenderer.borrowStart(map.tileset), roof) diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 79a875bd7..d313bddef 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -141,6 +141,8 @@ local LAYOUT_GEN2 = { numBalls = 0xD5FC, balls = 0xD5FD, numPCItems = 0xD616, pcItems = 0xD617, pokegearFlags = 0xD67C, + momMoney=0xD576,momSaving=0xD579,momWhich=0xD961,momSet=0xD962,momTrigger=0xD963, + decorations = 0xD959, -- wDecoBed; eight bytes, big doll is last eventFlags = 0xD7B7, curBox = 0xD8BC, visitedSpawns = 0xD9EE, mapGroup = 0xDA00, mapNumber = 0xDA01, yCoord = 0xDA02, xCoord = 0xDA03, partyCount = 0xDA22, partySpecies = 0xDA23, partyMons = 0xDA2A, @@ -268,6 +270,8 @@ local LAYOUT_CRYSTAL = { numBalls = 0xD8D7, balls = 0xD8D8, numPCItems = 0xD8F1, pcItems = 0xD8F2, pokegearFlags = 0xD957, + momMoney=0xD851,momSaving=0xD854,momWhich=0xDC17,momSet=0xDC18,momTrigger=0xDC19, + decorations = 0xDC0F, -- wDecoBed, distinct from Gold/Silver eventFlags = 0xDA72, curBox = 0xDB72, visitedSpawns = 0xDCA5, mapGroup = 0xDCB5, mapNumber = 0xDCB6, yCoord = 0xDCB7, xCoord = 0xDCB8, partyCount = 0xDCD7, partySpecies = 0xDCD8, partyMons = 0xDCDF, @@ -365,6 +369,12 @@ end function Gen2Save.setLayout(version) applyLayout((type(version) == "table" and version) or LAYOUTS[tostring(version)] or LAYOUT_GEN2) + -- An unreviewed hack's legacy fallback layout does not prove where its + -- decoration bytes live. Explicit custom layouts may name their own run. + if type(version)~="table" and version~="gold" and version~="silver" and version~="crystal" then + O.decorations=nil + O.momMoney=nil;O.momSaving=nil;O.momWhich=nil;O.momSet=nil;O.momTrigger=nil + end end applyLayout(LAYOUT_GEN2) @@ -1024,6 +1034,9 @@ end -- decode: raw 32768-byte SRAM string -> save.lua-shaped table -- ------------------------------------------------------------------ +-- Physical WRAM order differs from the public semantic slot order. +local DECO_SLOTS={"bed","carpet","plant","poster","console","leftOrnament","rightOrnament","bigDoll"} + function Gen2Save.decode(bytes, data) assert(#bytes == Gen2Save.SAVE_SIZE, "expected a 32768-byte save") assert(charmap, "Gen2Save.setCharmap must be called before decode") @@ -1060,6 +1073,11 @@ function Gen2Save.decode(bytes, data) currentBox = 1, } + if O.decorations then + save.decorations={} + for i,key in ipairs(DECO_SLOTS)do save.decorations[key]=u8(bytes,O.decorations+i-1)end + end + -- bag: four pockets folded into one flat inventory local bagOrder = {} decodePairList(bytes, O.items, MAX_ITEMS, cw, save.inventory, bagOrder) @@ -1115,6 +1133,18 @@ function Gen2Save.decode(bytes, data) end end + if O.momMoney then + save.g2MomMoney=u24be(bytes,O.momMoney) + local mode=u8(bytes,O.momSaving) + save.g2MomSavingMode=mode%8 + save.g2MomSaving=save.g2MomSavingMode~=0 + save.g2MomBankOpen=hasBit(mode,7)or nil + save.flags.ENGINE_MOM_SAVING_MONEY=hasBit(mode,0) + save.flags.ENGINE_MOM_ACTIVE=hasBit(mode,7) + save.g2MomShopping={whichItem=u8(bytes,O.momWhich),whichSet=u8(bytes,O.momSet), + triggerBalance=u24be(bytes,O.momTrigger)} + end + -- wVisitedSpawns -> the FLY list. HasVisitedSpawn tests bit [spawn id]; -- this port spells the same set save.visited[mapId] (src/ui/TownMap.lua), -- so without this an imported save could not fly anywhere. @@ -1240,6 +1270,15 @@ function Gen2Save.encode(save, data, template) for i = 1, Gen2Save.SAVE_SIZE do buf[i] = zero end end + if O.decorations and type(save.decorations)=="table"then + for i,key in ipairs(DECO_SLOTS)do + local value=save.decorations[key]or 0 + assert(type(value)=="number"and value>=0 and value<=255 and value%1==0, + "invalid native decoration byte: "..key) + setByte(buf,O.decorations+i-1,value) + end + end + local player = save.player or {} local playerName = player.name or "GOLD" encodeName(buf, O.playerName, NAME_LENGTH, playerName) @@ -1296,6 +1335,30 @@ function Gen2Save.encode(save, data, template) end end + if O.momMoney then + local function nativeNumber(value,limit,label) + assert(type(value)=="number"and value%1==0 and value>=0 and value<=limit,"invalid native Mom "..label) + return value + end + if save.g2MomMoney~=nil then setU24be(buf,O.momMoney,nativeNumber(save.g2MomMoney,999999,"balance"))end + local value=buf[O.momSaving+1]:byte() + if save.g2MomSavingMode~=nil or save.g2MomSaving~=nil then + local mode=save.g2MomSavingMode~=nil and nativeNumber(save.g2MomSavingMode,7,"saving mode")or(save.g2MomSaving and 1 or 0) + if save.flags and save.flags.ENGINE_MOM_SAVING_MONEY~=nil then + mode=mode-mode%2+(save.flags.ENGINE_MOM_SAVING_MONEY and 1 or 0) + end + value=value-value%8+mode + end + if save.g2MomBankOpen~=nil then value=withBit(value,7,save.g2MomBankOpen==true)end + setByte(buf,O.momSaving,value) + if type(save.g2MomShopping)=="table"then + local state=save.g2MomShopping + setByte(buf,O.momWhich,nativeNumber(state.whichItem,255,"ladder index")) + setByte(buf,O.momSet,nativeNumber(state.whichSet or 0,255,"random row")) + setU24be(buf,O.momTrigger,nativeNumber(state.triggerBalance,999999,"random threshold")) + end + end + -- wVisitedSpawns: the FLY destinations this save has reached local spawnFlags = data and data.field and data.field.spawnFlags if type(spawnFlags) == "table" then diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..da1451f84 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -23,6 +23,30 @@ local Strings = require("src.core.Strings") local Gen2Commands = {} +function Commands.g2_give_poke_mail(ctx,letter) + if type(letter)~="table" or type(letter.message)~="string" then + Logger.warn("givepokemail: unresolved native letter; import this cartridge again") + return + end + require("src.pokemon.Gen2Mail").give(ctx.save,letter.item,letter.message) +end + +function Commands.g2_check_poke_mail(ctx,letter) + local Mail=require("src.pokemon.Gen2Mail") + local picked + require("src.ui.Screens").push(ctx.game,"PartyMenu",{ + pickOnly=true, + onSwitch=function(mon) picked=mon;ctx.runner:resume() end, + onCancel=function() ctx.runner:resume() end, + }) + ctx.runner:yield() + local slot + for i,mon in ipairs(ctx.save.party or {}) do if mon==picked then slot=i;break end end + local expected=type(letter)=="table" and letter.message or nil + local result=Mail.checkPokeMail(ctx.save,slot,expected) + ctx.g2Var,ctx.lastCheck=result,result~=0 +end + local function scriptVar(ctx) return ctx.g2Var or 0 end @@ -896,6 +920,20 @@ function Gen2Commands.scriptVarValue(ctx, var) return 1 end +function Commands.g2_deco_visibility(ctx) + require('src.world.Gen2Decorations').visibility(ctx) +end +function Commands.g2_deco_tiles(ctx) + require('src.world.Gen2Decorations').tiles(ctx) +end +function Commands.g2_deco_description(ctx,kind,allowed) + local target,name=require('src.world.Gen2Decorations').description(ctx.game.data,ctx.save,kind) + if target and allowed and allowed[target]then + if name then setBuffer(ctx.game,3,name)end + return target + end +end + -- Script_loadmem (25:$7495): write an immediate byte to a WRAM address. The -- same address-keyed mirror readmem/writemem use -- keyed by the raw -- address number exactly as they key it, so a script that pokes a byte here @@ -1779,6 +1817,19 @@ end -- --------------------------------------------------------------------------- function Commands.g2_warp(ctx, mapId, x, y, facing) + if mapId=="NONE" then + local ow=ctx.overworld + if ow and ow.map and type(ow.reloadMap)=="function"then + local map=ow.map.id + ctx.afterScript=ctx.afterScript or{} + ctx.afterScript[#ctx.afterScript+1]=function() + if ow.player and DIRS[facing]then ow.player.facing=DIRS[facing]end + ow:reloadMap(map,"native-script") + end + return math.huge + end + return + end if type(mapId) ~= "string" or mapId == "" then return end Commands.warp(ctx, mapId, x, y, facing) end @@ -2290,16 +2341,18 @@ end function Commands.g2_bank_of_mom(ctx) local save = ctx.save + local Mom=require("src.world.Gen2MomShopping") + Mom.prepareBank(save) -- CheckIfBankInitialized: the first visit opens the account and asks if save.g2MomBankOpen == nil then - save.g2MomBankOpen = true + Mom.openBank(save) Commands.ask(ctx, momLine(ctx, "saveMoney", "Should I save money for you?")) if ctx.lastCheck then - save.g2MomSaving = true + Mom.setSaving(save,true) return momSay(ctx, "startSavingMoney", "OK, I'll save your money.") end - save.g2MomSaving = false + Mom.setSaving(save,false) return momSay(ctx, "justDoWhatYouCan", "Just do what you can.") end @@ -2364,7 +2417,7 @@ function Commands.g2_bank_of_mom(ctx) momSay(ctx, "takenMoney", "Took ¥" .. amount .. ".") end) elseif choice == 3 then - save.g2MomSaving = not save.g2MomSaving + Mom.setSaving(save,not save.g2MomSaving) if save.g2MomSaving then momSay(ctx, "startSavingMoney", "OK, I'll save your money.") else @@ -2628,12 +2681,9 @@ function Commands.g2_readvar(ctx, var) elseif var == 6 then ctx.g2Var = countKeys(ctx.save.pokedex and ctx.save.pokedex.seen) elseif var == 10 then - ctx.g2Var = tonumber(os.date("%H")) or 0 + ctx.g2Var = require("src.core.Gen2Clock").hour(ctx.save) elseif var == 11 then - -- g2DayOffset is what mom's SetDayOfWeek picker answered, as a shift off - -- the host day, so Arthur still shows up on a real Thursday. - ctx.g2Var = ((tonumber(os.date("%w")) or 0) - + (ctx.save.g2DayOffset or 0)) % 7 + ctx.g2Var = require("src.core.Gen2Clock").weekday(ctx.save) elseif var == 16 then -- BoxFreeSpace counts only the current box, not all available PC slots. local Boxes = require("src.pokemon.Boxes") @@ -2882,44 +2932,21 @@ function Commands.g2_clear_dst(ctx) Commands.show_text(ctx, "InitialClearDSTFlag.Text") end --- The ROM spins these with the d-pad on a graphical clock-set background; --- a list menu is the port's equivalent and needs no ROM tiles. The names --- are engine text, not ROM strings: SetDayOfWeek.WeekdayStrings is raw --- charmap data, and the extractor only harvests symbols matching "Text". -local WEEKDAYS = { - "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", -} - --- `special SetDayOfWeek` ($25), the day picker in mom's clock talk. The GB --- has to ask because its RTC has no idea what day it started on; here --- readvar 11 reads the host clock, which already knows. So the answer is --- kept as an OFFSET from the host day rather than as the day itself: the --- cursor opens on today, a truthful answer stores 0 and changes nothing, and --- either way the day still rolls over at real midnight instead of freezing. +-- Native SetDayOfWeek shares the clock-setting screen and RTC owner with +-- the introduction. The script remains suspended through both selection +-- and confirmation; declining does not write the save or resume the script. function Commands.g2_set_day_of_week(ctx) local game, runner = ctx.game, ctx.runner - local host = tonumber(os.date("%w")) or 0 - local picked - repeat - Commands.show_text(ctx, "SetDayOfWeek.OakTimeWhatDayIsItText") - local items = {} - for index, day in ipairs(WEEKDAYS) do - items[index] = { - label = Strings(day), - onSelect = function() picked = index - 1 runner:resume() end, - } - end - -- no cancel: the ROM's loop watches A only, and onCancel is the only - -- other path back to runner:resume() - local menu = require("src.ui.Menu").new(game, items, { cancelable = false }) - menu.index = host + 1 - menu:clampScroll() - game.stack:push(menu) - runner:yield() - Commands.ask(ctx, Strings(WEEKDAYS[picked + 1]) - .. resolvedText(ctx, "SetDayOfWeek.OakTimeIsItText")) - until ctx.lastCheck - ctx.save.g2DayOffset = (picked - host) % 7 + local screen + screen = require("src.ui.Gen2InitClock").new(game, { mode="day", save=ctx.save, + onDone=function() + if game.stack:top() == screen then game.stack:pop() end + ctx.lastCheck = true + runner:resume() + end, + }) + game.stack:push(screen) + runner:yield() end -- UnownPuzzle (SpecialsPointers row 41). The chamber scripts run @@ -3138,7 +3165,9 @@ function Commands.g2_return_nobus_aggron(ctx) end if not othersHealthy then return result(3) end for i, mon in ipairs(ctx.save.party) do - if mon == picked then table.remove(ctx.save.party, i) break end + if mon == picked then + table.remove(ctx.save.party,i);require("src.pokemon.Gen2Mail").removeSlot(ctx.save,i);break + end end result(2) end @@ -3335,6 +3364,7 @@ function Commands.g2_delete_party_mon(ctx) local _, index = orphanChoice(ctx) if not index then return end table.remove(ctx.save.party, index) + require("src.pokemon.Gen2Mail").removeSlot(ctx.save,index) ctx.g2PartyMon = nil end @@ -3724,6 +3754,7 @@ function Commands.g2_trade(ctx, index) -- reading at all -- so they all came over empty-handed. if trade.item then newMon.item = trade.item end table.remove(party, slot) + require("src.pokemon.Gen2Mail").removeSlot(ctx.save,slot) table.insert(party, newMon) local dex = ctx.save.pokedex if dex then @@ -3783,7 +3814,9 @@ function Commands.g2_return_shuckie(ctx) if not othersHealthy then return result(4) end if (picked.happiness or 0) >= 150 then return result(3) end for i, mon in ipairs(ctx.save.party) do - if mon == picked then table.remove(ctx.save.party, i) break end + if mon == picked then + table.remove(ctx.save.party,i);require("src.pokemon.Gen2Mail").removeSlot(ctx.save,i);break + end end result(2) end @@ -4141,6 +4174,7 @@ function Commands.g2_name_rival(ctx) local presets = boot and boot.namePresets and boot.namePresets.rival game.stack:push(require("src.ui.NamingScreen").new(game, { title = Strings("RIVAL'S NAME?"), + kind = "rival", maxLen = 7, -- PLAYER_NAME_LENGTH - 1 -- NamingScreen_InitNameEntry opens the grid blank and confirming nothing -- would leave the name empty, so fall back to the canonical one @@ -4214,7 +4248,7 @@ function Commands.g2_name_rater(ctx) or Strings("All right. What\nname should we\vgive it, then?")) local chosen game.stack:push(require("src.ui.NamingScreen").new(game, { - title = Strings("%s's NICKNAME?", nickOf(picked)), + title = Strings("NICKNAME?"), kind = "mon", mon = picked, maxLen = 10, default = nickOf(picked), onDone = function(name) chosen = name runner:resume() end, @@ -4290,7 +4324,7 @@ function Gen2Commands.dayCareDeposit(ctx, which, introText, leftText) return Commands.show_text(ctx, t._CantAcceptEggText or Strings("Sorry, but I can't\naccept an EGG.")) end - if picked.mail then + if picked.mail or require("src.pokemon.Gen2Mail").monHoldsMail(picked) then return Commands.show_text(ctx, t._RemoveMailText or Strings("Remove the MAIL\nfrom that #MON.")) end @@ -4305,7 +4339,9 @@ function Gen2Commands.dayCareDeposit(ctx, which, introText, leftText) end for i, mon in ipairs(save.party) do - if mon == picked then table.remove(save.party, i) break end + if mon == picked then + table.remove(save.party,i);require("src.pokemon.Gen2Mail").removeSlot(save,i);break + end end DayCare.deposit(save, which, picked) game.stringBuffer = Gen2Commands.dayCareName(game, picked) @@ -4384,7 +4420,7 @@ function Gen2Commands.dayCareWithdraw(ctx, which) mon.status = nil for _, move in ipairs(mon.moves or {}) do local moveDef = game.data.moves and game.data.moves[move.id] - if moveDef then move.pp = moveDef.pp + (move.ppUps or 0) * math.floor(moveDef.pp / 5) end + if moveDef then move.pp = Pokemon.maxPP(moveDef, move, 2) end end end table.insert(save.party, mon) diff --git a/src/script/Gen2Daily.lua b/src/script/Gen2Daily.lua index 96e54c666..36c041447 100644 --- a/src/script/Gen2Daily.lua +++ b/src/script/Gen2Daily.lua @@ -18,6 +18,9 @@ local Gen2Flags = require("src.script.Gen2Flags") local Gen2Daily = {} +-- Native RTC/calendar integration by Roxas2712; see contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +local Clock = require("src.core.Gen2Clock") local ENGINE_LUCKY_NUMBER_SHOW = 77 local GameVersion = require("src.core.GameVersion") @@ -39,19 +42,15 @@ local INITIAL_HIDDEN_EVENTS = { 1915, -- EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL } -local function todayKey(now) - return os.date("%Y-%m-%d", now) -end - local function nextFriday(save, now) - local date = os.date("*t", now) - local weekday = (date.wday - 1 + (tonumber(save.g2DayOffset) or 0)) % 7 + local date = Clock.calendarDate(save, now) + local weekday = Clock.weekday(save, now) local days = (5 - weekday) % 7 -- Match the weekday chosen at Mom's clock. if days == 0 then days = 7 end -- Calendar arithmetic, rather than seconds, also handles daylight saving. date.day, date.hour, date.min, date.sec = date.day + days, 12, 0, 0 date.isdst = nil - return todayKey(os.time(date)) + return os.date("%Y-%m-%d", os.time(date)) end function Gen2Daily.lotteryExpired(save, now) @@ -59,7 +58,7 @@ function Gen2Daily.lotteryExpired(save, now) -- Old saves already have a drawn ID; retain it when adding the timer. save.g2LuckyNextFriday = nextFriday(save, now) end - return todayKey(now) >= save.g2LuckyNextFriday + return Clock.dateKey(save, now) >= save.g2LuckyNextFriday end function Gen2Daily.resetLottery(save, now) @@ -121,7 +120,7 @@ function Gen2Daily.poll(save, now) Gen2Daily.ensureRoam(save, nil) if not save then return end Gen2Daily.seedInitialObjectFlags(save) - local today = todayKey(now) + local today = Clock.dateKey(save, now) if save.g2DailyDay == today then return end save.g2DailyDay = today Gen2Daily.onNewDay(save) diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..400360a7c 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -261,11 +261,8 @@ L.phonecall = function(ir, s) emit(s, { "show_text", ir[2] }) end -- box closes itself. L.hangup = function() end --- Script_checkpokemail (25:$7608): does the party hold this mail? The port --- has no mail model, so answer "no" rather than leaving the branch to fall --- through on a stale flag. -L.checkpokemail = function(_, s) emit(s, { "g2_false" }) end -L.givepokemail = function() end +L.checkpokemail = function(ir,s) emit(s,{"g2_check_poke_mail",ir[2]}) end +L.givepokemail = function(ir,s) emit(s,{"g2_give_poke_mail",ir[2]}) end -- ------------------------------------------------------------------ no-ops -- Each of these was verified to touch only state the port does not model. @@ -278,19 +275,30 @@ L.givepokemail = function() end -- NPC instead. -- loadtemptrainer/randomwildmon/loadpikachudata build a battle out of WRAM -- scratch the port fills from its own encounter tables. --- _2dmenu/describedecoration are presentation the port has no +-- _2dmenu is presentation the port has no -- equivalent for; the surrounding dialogue still runs. -- autoinput replays a canned joypad script over a cutscene. -- (catchtutorial used to live here too; it is implemented now, below.) local function vmNoop() end for _, name in ipairs({ "xycompare", "writeobjectxy", "loadtemptrainer", "randomwildmon", - "loadpikachudata", "_2dmenu", "describedecoration", + "loadpikachudata", "_2dmenu", "autoinput", }) do L[name] = vmNoop end +L.describedecoration=function(ir,s) + local p=s.decorationPrograms + if type(p)~='table'then return end + local allowed={} + for _,label in pairs(p.posters or{})do local target=branch(s,label);if target then allowed[target]=true end end + for _,role in ipairs({'ornament','big','null'})do + local target=branch(s,p[role]);if target then allowed[target]=true end + end + emit(s,{'g2_deco_description',ir[2],allowed}) +end + L["end"] = function(_, s) emit(s, { "g2_return" }) end @@ -1080,6 +1088,12 @@ local SPECIALS = { PokemonCenterPC = "g2_pokemon_center_pc", Special_PokemonCenterPC = "g2_pokemon_center_pc", PlayersHousePC = "g2_players_house_pc", + ToggleDecorationsVisibility = "g2_deco_visibility", + ToggleDecorationsVisibilitySpecial = "g2_deco_visibility", + Special_ToggleDecorationsVisibility = "g2_deco_visibility", + ToggleMaptileDecorations = "g2_deco_tiles", + ToggleMaptileDecorationsSpecial = "g2_deco_tiles", + Special_ToggleMaptileDecorations = "g2_deco_tiles", Special_PlayersHousePC = "g2_players_house_pc", Special_KrissHousePC = "g2_players_house_pc", MoveTutor = "g2_move_tutor", @@ -1215,9 +1229,6 @@ local SPECIALS_NOOP = { PlayCurMonCry = true, PlaySlowCry = true, SurfStartStep = true, - -- decorations are not modelled, so toggling their visibility does nothing - ToggleDecorationsVisibility = true, - ToggleMaptileDecorations = true, UnusedDummySpecial = true, UnusedBattleTowerDummySpecial1 = true, UnusedBattleTowerDummySpecial2 = true, @@ -2380,6 +2391,7 @@ function Gen2ScriptVM.compile(data, entry) local state = { out = out, stds = pool.stds, + decorationPrograms = pool.decorationPrograms, has = function(label) return type(label) == "string" and scripts[label] ~= nil end, diff --git a/src/script/Gen2Specials.lua b/src/script/Gen2Specials.lua index b554b340e..f3aabecc7 100644 --- a/src/script/Gen2Specials.lua +++ b/src/script/Gen2Specials.lua @@ -700,28 +700,25 @@ local function pcRows(ctx, finish) end function Commands.g2_pokemon_center_pc(ctx) - -- PC_CheckPartyForPokemon: a player with no party gets nothing at all - if #(ctx.save.party or {}) == 0 then return answer(ctx, 0) end - local runner = ctx.runner - local done - local function finish() - if done then return end - done = true - runner:resume() - end - require("src.ui.Screens").push(ctx.game, "Menu", pcRows(ctx, finish), { - tx = 0, ty = 0, tw = 16, noSound = true, onCancel = finish, - }) + local runner, done = ctx.runner, false + require("src.ui.Screens").push(ctx.game, "Gen2CenterPcMenu", {onClose=function() + if done then return end;done=true + ctx.game.stack:pop();runner:resume() + end}) runner:yield() answer(ctx, 0) end --- PlayerPC is a Menu of its own, so it closes on its LOG OFF row and on B. --- Neither reaches this script, so there is nothing to wait on: push it and --- answer, exactly as g2_card_flip does. +-- The house script branches on the accumulated decoration-change bit only +-- after LOG OFF/B. Returning before the menu closes silently skips its reload. function Commands.g2_players_house_pc(ctx) - require("src.ui.Screens").push(ctx.game, "PlayerPC") - answer(ctx, 0) + local runner=ctx.runner + local done,changed=false,false + require("src.ui.Screens").push(ctx.game,"PlayerPC",{house=true,onDone=function(value) + if done then return end;done=true;changed=value==true;runner:resume() + end}) + runner:yield() + answer(ctx,changed and 1 or 0) end -- --------------------------------------------------------------------------- diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 13566a36a..e397cfe5c 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -2,6 +2,7 @@ -- opts.battle = BattleState when opened mid-battle (balls throwable, -- using an item consumes the turn). +local BattleQueuePhase = require("src.battle.BattleQueuePhase") local ItemEffects = require("src.inventory.ItemEffects") local ListMenu = require("src.ui.ListMenu") local TextBox = require("src.render.TextBox") @@ -253,7 +254,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) battle.pokeDollEscape = true battle.result = "run" battle.afterQueue = "finish" - battle.phase = "messages" + battle.phase = BattleQueuePhase.name() end) return end @@ -719,21 +720,30 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) end -- refresh counts on whichever screen asked refreshCount(game, list, id) - -- HP medicine: fill the bar in the still-open picker first, then print - -- and close, the order item_effects.asm .doneHealing runs in - -- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message). - -- Only a keepOpen picker is still on the stack to animate: every other - -- item, and every in-battle use, popped it in PartyMenu before onSwitch, - -- and takes the pop-then-print path below -- which is the path that - -- spends the battle turn. #252, #379 - -- ...AND ONLY A PICKER THAT CAN ANIMATE IS ASKED TO. - -- - -- This screen is whichever party picker the generation opened, and it - -- took `animateTo` on faith: Hoenn's opens Gen3PartyMenu, which had no - -- such method, and every out-of-battle potion in the game crashed here. - -- Gen3PartyMenu has one now -- but the message must print either way, so - -- a picker that cannot animate falls through to the plain path rather - -- than taking the game down with it. + -- Native Gen2 keeps the party screen through HP fill, result text + -- and acknowledgment before the normal enemy action can be queued. + if battle and require("src.core.GameVersion").isGen2() + and picker and picker.keepOpen and type(picker.showItemResult) == "function" then + local slot + for i, mon in ipairs(picker.party or game.save.party) do + if mon == target then slot = i; break end + end + local before = extra and extra.healedFrom + local climbs = before ~= nil and target and before ~= target.hp + if climbs then battle:beginPartyItemHeal() end + picker:showItemResult(slot, { + fromHp = climbs and before or nil, + toHp = climbs and target.hp or nil, + -- ItemEffects already played Heal_HP (the imported Sfx_Potion alias). + -- Replaying it here would double the same native cue. + text = table.concat(payload or {}, "\n"), + onDone = function() + closePicker() + battle:finishPartyItemTurn(id, target) + end, + }) + return + end if picker and picker.keepOpen and extra and extra.healedFrom and target and type(picker.animateTo) == "function" then picker:animateTo(target, extra.healedFrom, function() @@ -743,7 +753,12 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) end if battle then list:close() - showMessages(game, payload, function() battle:itemUsed({}) end) + if require("src.core.GameVersion").isGen2() then + -- The native battle queue owns this message and its composition. + battle:itemUsed(payload) + else + showMessages(game, payload, function() battle:itemUsed({}) end) + end else showMessages(game, payload, closePicker) end @@ -752,29 +767,50 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) -- .healingItemNoEffect prints over the still-drawn party menu too, so the -- refusal closes the picker the same way (#252) - showMessages(game, payload, closePicker) -- failed + if battle and require("src.core.GameVersion").isGen2() then + closePicker() + list:close() + for _, message in ipairs(payload or {}) do battle:say(message) end + else + showMessages(game, payload, closePicker) -- failed + end end -local function pickTargetAndUse(game, battle, id, list) +local function pickTargetAndUse(game, battle, id, list, options) -- pick a target from the party -- the ETHERs and PP UP open the move menu after picking a mon -- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move local def = game.data.items[id] local key = ItemEffects.alias(id, def) local wantsMove = key == "ETHER" or key == "MAX_ETHER" or key == "PP_UP" + or (key == "MYSTERYBERRY" and require("src.core.GameVersion").isGen2()) local opts = { pickOnly = true, - -- HP medicine animates its bar with the picker still up (#252). Only - -- out of battle: the in-battle tail closes the bag list underneath - -- first, which needs the picker already gone. - keepOpen = (not battle) and (ItemEffects.healsHP(id) - or (id == "RARE_CANDY" - and require("src.core.GameVersion").get() == "firered")), + battle = require("src.core.GameVersion").isGen2() and battle or nil, + onCancel = options and options.onCancel, + -- Native Gen2 keeps the same party owner through its result and + -- confirmation. Other editions retain their existing field flow. + keepOpen = ((battle or wantsMove) and require("src.core.GameVersion").isGen2()) + or ((not battle) and (ItemEffects.healsHP(id) + or (id == "RARE_CANDY" and require("src.core.GameVersion").isFRLG()))), onSwitch = function(mon, picker) if not wantsMove then useOn(game, battle, id, mon, list, nil, picker) return end + if require("src.core.GameVersion").isGen2() then + -- RestorePPEffect uses ChooseMoveToDelete's real native four-slot + -- window. Cancel drops only this move list and preserves the party. + require("src.ui.Screens").push(game, "Gen2MoveDeleter", { + mon = mon, moves = game.data.moves, + onCancel = function() game.stack:pop() end, + onChoose = function(slot) + game.stack:pop() + useOn(game, battle, id, mon, list, slot, picker) + end, + }) + return + end local rows = {} for mi, mv in ipairs(mon.moves) do local mdef = game.data.moves[mv.id] @@ -814,7 +850,7 @@ local function pickTargetAndUse(game, battle, id, list) game.stack:push(ListMenu.new(game, "Which move?", rows, { onChoose = function(row, l) l:close() - useOn(game, battle, id, mon, list, row.value) + useOn(game, battle, id, mon, list, row.value, picker) end, })) end, @@ -854,7 +890,7 @@ local BAG_TEXT_BOOTED_TM = 58 local BAG_TEXT_BOOTED_HM = 59 local BAG_TEXT_CONTAINED = 60 -local function useItem(game, battle, id, list) +local function useItem(game, battle, id, list, options) -- HGSS: the Escape Rope / Water Stone at a Ruins of Alph chamber wall -- (src/world/HgssAlphChambers.lua) runs the chamber's script instead if not battle and game.data and game.data.isGen4Cache @@ -937,7 +973,7 @@ local function useItem(game, battle, id, list) -- which is where "Teach {MOVE} to a POKeMON?" died. game.stack:push(TextBox2.new(game, asks, nil, { choice = function(yes) - if yes then pickTargetAndUse(game, battle, id, list) end + if yes then pickTargetAndUse(game, battle, id, list, options) end end, })) end @@ -947,10 +983,10 @@ local function useItem(game, battle, id, list) local booted = def.machine.kind == "HM" and "Booted up an HM!" or Strings("Booted up a TM!") showMessages(game, { booted, Strings("It contained\n%s!", moveName) }, - function() pickTargetAndUse(game, battle, id, list) end) + function() pickTargetAndUse(game, battle, id, list, options) end) return end - pickTargetAndUse(game, battle, id, list) + pickTargetAndUse(game, battle, id, list, options) else useOn(game, battle, id, nil, list) end @@ -986,6 +1022,15 @@ local function handOver(game, mon, id, onChanged, opts) return end local held = mon.item + if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then + showMessages(game,{Strings("Remove MAIL first.")});return + end + if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").isMail(id) then + game.stack:push(require("src.ui.Gen2MailCompose").new(game,{mon=mon,item=id,onDone=function() + if onChanged then onChanged() end + showMessages(game,{Strings("%s is now holding\n%s.",monName,name)}) + end}));return + end local function hand() local changed if opts and opts.giveHeld then diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 7547c03cb..aedc36314 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -12,6 +12,8 @@ local TextBox = require("src.render.TextBox") local Strings = require("src.core.Strings") local BoxMenu = {} +local function gen2() return require("src.core.GameVersion").isGen2() end +local Gen2Boxes = require("src.pokemon.Gen2Boxes") local function monLabel(game, mon) local def = game.data.pokemon[mon.species] @@ -127,9 +129,14 @@ local function withdraw(game) -- reaches the party menu with mon.stats nil and the HP bar draw -- nil-indexes it (#304, same family as #233). Already-shaped mons -- (everything the engine itself put in a box) pass through. - Stats.ensure(game.data.pokemon[mon.species], mon) - table.remove(box, index) - table.insert(game.save.party, mon) + if gen2() then + local ok,why=Gen2Boxes.withdraw(game.save,game.save.currentBox,index,game.data) + if not ok then list.footer=why;return end + else + Stats.ensure(game.data.pokemon[mon.species], mon) + table.remove(box, index) + table.insert(game.save.party, mon) + end local name = monName(game, mon) game.stringBuffer = name require("src.core.Sound").playCry(game.data, mon.species) @@ -142,7 +149,7 @@ end local function deposit(game) local t = game.data.text - if #game.save.party <= 1 then + if not gen2() and #game.save.party <= 1 then game.stack:push(TextBox.new(game, t._CantDepositLastMonText or Strings("You can't deposit\nthe last POKéMON!"))) return @@ -167,7 +174,7 @@ local function deposit(game) local index = list.index local mon = game.save.party[index] if not mon then return end - if #game.save.party <= 1 then + if not gen2() and #game.save.party <= 1 then list.footer = Strings("You need at least\none POKéMON!") return end @@ -176,8 +183,13 @@ local function deposit(game) list.footer = Strings("BOX %d is full!", game.save.currentBox) return end - table.remove(game.save.party, index) - table.insert(active, mon) + if gen2() then + local ok,why=Gen2Boxes.deposit(game.save,index,game.save.currentBox,game.data) + if not ok then list.footer=why;return end + else + table.remove(game.save.party, index) + table.insert(active, mon) + end -- PIKAHAPPY_DEPOSITED (engine/pokemon/bills_pc.asm:247) require("src.world.PikachuFollower") .modifyHappiness(game.save, "DEPOSITED", mon) @@ -218,7 +230,10 @@ local function release(game) defaultNo = true, noSound = true, choice = function(yes) if not yes then return end - table.remove(box, list.index) + if gen2() then + local ok,why=Gen2Boxes.release(game.save,game.save.currentBox,list.index) + if not ok then list.footer=why;return end + else table.remove(box, list.index) end require("src.core.Sound").playCry(game.data, mon.species) game.stack:push(TextBox.new(game, Strings("%s was\nreleased outside.\fBye %s!", name, name))) @@ -306,8 +321,16 @@ local function printBox(game) or Strings("Printer error!\n%s", tostring(err)))) end -function BoxMenu.new(game) +function BoxMenu.new(game, opts) + opts=opts or {} + if opts.save and opts.save~=game.save then + game=setmetatable({save=opts.save,writeSave=false},{__index=game}) + end Boxes.ensure(game.save) + if gen2() then + local ok,why=Gen2Boxes.canUsePc(game.save) + if not ok then return TextBox.new(game,why,opts.onClose or opts.onCancel or opts.onDone) end + end -- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior -- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW " need the -- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/ @@ -329,11 +352,30 @@ function BoxMenu.new(game) items[#items + 1] = { label = Strings("PRINT BOX"), keepOpen = true, onSelect = function() printBox(game) end } end - items[#items + 1] = { label = Strings("SEE YA!") } + local callerCloses=gen2() and type(opts.onClose)=="function" + local closed=false + local function onClose() + if closed then return end + closed=true + local callback=opts.onClose or opts.onCancel or opts.onDone + if callback then callback() end + end + items[#items + 1] = { label = Strings("SEE YA!"),keepOpen=callerCloses,onSelect=onClose } local menu = Menu.new(game, items, -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND, -- engine/menus/pokemon_pc.asm) - { tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, noSound = true }) + { tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, noSound = true,onCancel=onClose }) + menu.save,menu.opts=game.save,opts + if callerCloses then + -- Legacy Gen2 callers own dismissal. Native callers keep Menu's + -- normal pop-before-callback contract. + local update=menu.update + function menu:update(dt) + if self.game.input:wasPressed("b") then onClose();return end + return update(self,dt) + end + function menu:cancel()onClose()end + end local baseDraw = menu.draw function menu:draw() baseDraw(self) diff --git a/src/ui/Gen2BoxMenu.lua b/src/ui/Gen2BoxMenu.lua new file mode 100644 index 000000000..fe4f35753 --- /dev/null +++ b/src/ui/Gen2BoxMenu.lua @@ -0,0 +1,1076 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- Bill's PC withdraw / deposit list (engine/pokemon/bills_pc.asm). +-- +-- The screen is transcribed from the ASM's own coordinates rather than laid +-- out by eye, because that is what makes it line up on the 8px grid: +-- +-- BillsPC_BoxName Textbox at (8,0), interior 10x1 -- the box name +-- BillsPC_RefreshTextboxes +-- Textbox at (8,2), interior 10x10, then the two top +-- corners are overwritten with └ and ┘ so the list +-- box reads as hanging off the name box above it +-- .PlaceNickname five nicknames from (9,4), two rows apart +-- PCMonInfo clears (0,0) 8 wide x 15 tall for the left panel, +-- puts the front pic at (1,4) as 7x7 tiles, the level +-- at (1,12), the gender at (5,12) and the species +-- name at (1,14) +-- +-- `mode` picks which list is being browsed: "withdraw" reads the current box, +-- "deposit" reads the party, and "move" walks BOTH -- see below. +-- +-- MOVE POKéMON W/O MAIL (_MovePKMNWithoutMail, engine/pokemon/bills_pc.asm:480) +-- is not a one-press operation on the cart and must not be one here. Its +-- jumptable is .Init -> .Joypad -> .PrepSubmenu -> .MoveMonWOMailSubmenu -> +-- .PrepInsertCursor -> .Joypad2, i.e. +-- +-- 1. "Choose a ." over a list that left/right walks across the PARTY +-- (wBillsPC_LoadedBox == 0, BillsPC_BoxName's `.party` arm) and all +-- fourteen boxes, wrapping at both ends (BillsPC_PressLeft/PressRight) +-- 2. A on a mon opens MOVE / STATS / CANCEL under "What's up?" +-- (PCString_WhatsUp, .MoveMonWOMailSubmenu) +-- 3. MOVE backs the position up and asks "Move to where?" +-- (PCString_MoveToWhere) with an insert cursor the player drives to the +-- destination list AND the slot inside it +-- 4. A there is BillsPC_CheckSpaceInDestination then +-- MovePKMNWithoutMail_InsertMon, which prints "Saving… Leave ON!" and +-- writes the mon into its new home; B restores the backed-up position and +-- goes back to step 1 +-- +-- Collapsing that into "A sends the mon to the next box with room" -- which is +-- what this screen used to do -- reads as the PC EATING the mon: it vanishes +-- from the list with no destination named and no line of text, and the player +-- has fourteen boxes to search to find out it still exists. + +local Assets = require("src.render.Assets") +local Boxes = require("src.pokemon.Gen2Boxes") +local Chrome = require("src.ui.Gen2Chrome") +local CommonText = require("src.core.Gen2CommonText") +local Font = require("src.render.Font") +local GameVersion = require("src.core.GameVersion") +local GbcPalette = require("src.render.GbcPalette") +local Mail = require("src.pokemon.Gen2Mail") +local Palettes = require("src.render.PaletteFX") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Sprites = require("src.pokemon.Sprites") +local Strings = require("src.core.Strings") + +local BoxMenu = {} +BoxMenu.__index = BoxMenu +BoxMenu.isOpaque = true + +-- BillsPC_NumMonsOnScreen is 5 for the withdraw/deposit lists. +local VISIBLE_ROWS = 5 +local LIST_X, LIST_Y = 9, 4 +local LIST_SPACING = 2 +local PIC_X, PIC_Y = 1, 4 + +-- PadFrontpic pads a 5x5 or 6x6 pic into the 7x7 block, one blank column then +-- 7-size blank tiles per column (engine/gfx/load_pics.asm:342-386). +local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } } + +-- gfx/pc/orange.pal +local BILLS_PC_ORANGE = { + { 255, 123, 0 }, { 189, 99, 0 }, { 123, 58, 0 }, { 0, 0, 0 }, +} + +-- PCMonInfo prints the held-item icon at hlcoord 7, 12 +-- (engine/pokemon/bills_pc.asm:1093). +local ICON_X, ICON_Y = 7, 12 + +-- $5f at hlcoord 8, 1 and $5e at hlcoord 19, 1, off a PCMailGFX sheet that +-- starts at $5c (engine/pokemon/bills_pc.asm:957-963). +local ARROW_ROW = 1 +local ARROW_LEFT = { 3, 8 } +local ARROW_RIGHT = { 2, 19 } + +-- wBillsPC_LoadedBox: 0 is the PARTY, 1..NUM_BOXES are the boxes. Only the +-- MOVE screen ever loads box 0; the withdraw and deposit lists are one list +-- each (BillsPC_BoxName reads the same byte for all three). +local PARTY_BOX = 0 + +-- .MoveMonWOMailSubmenu's .MenuData, verbatim. RELEASE is NOT one of these -- +-- it belongs to BillsPC_WithdrawMenu's four rows -- so nothing on this screen +-- can destroy a mon. +local MOVE_SUBMENU = { + Strings.source("MOVE"), Strings.source("STATS"), Strings.source("CANCEL"), +} + +-- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows. +local WITHDRAW_SUBMENU = { + Strings.source("WITHDRAW"), Strings.source("STATS"), + Strings.source("RELEASE"), Strings.source("CANCEL"), +} + +-- BillsPCDepositMenuHeader's .MenuData (engine/pokemon/bills_pc.asm:234-240). +local DEPOSIT_SUBMENU = { + Strings.source("DEPOSIT"), Strings.source("STATS"), + Strings.source("RELEASE"), Strings.source("CANCEL"), +} + +function BoxMenu:submenuRows() + if self.mode == "move" then return MOVE_SUBMENU end + if self.mode == "deposit" then return DEPOSIT_SUBMENU end + return WITHDRAW_SUBMENU +end + +-- MovePKMNWithoutMail_InsertMon's .Saving_LeaveOn, printed for 20 frames while +-- the mon is written into its new home. It stays up here until a button +-- clears it, because it is also the only confirmation the player gets that the +-- mon moved and where it went. +local SAVING_LEAVE_ON = Strings.source("Saving… Leave ON!") + +-- PCString_NoReleasingEGGS, printed by BillsPC_IsMonAnEgg with SFX_WRONG +-- (engine/pokemon/bills_pc.asm:1615-1631, string at :2200). +local NO_RELEASING_EGGS = Strings.source("No releasing EGGS!") +local PARTY_TITLE = Strings.source("PARTY ") +local DEFAULT_BOX_TITLE = Strings.source("BOX%d") +local CHOOSE_PROMPT = Strings.source("Choose a .") +local WHATS_UP_PROMPT = Strings.source("What's up?") +local MOVE_WHERE_PROMPT = Strings.source("Move to where?") +local LAST_MON = Strings.source("It's your last !") +local NO_USABLE_MON = Strings.source("No more usable !") +local REMOVE_MAIL = Strings.source("Remove MAIL.") +local NO_ROOM = Strings.source("There's no room!") +local RELEASE_PROMPT = Strings.source("Release ?") +local RELEASED = Strings.source("Released .\fBye,\n%s!") +local GOT_MON = Strings.source("Got %s!") +local STORED_MON = Strings.source("Stored %s!") + +-- engine/pokemon/bills_pc.asm:1804 `ld c, 50 / call DelayFrames` +local STORE_MESSAGE_FRAMES = 50 + +-- Boxes.lua owns the storage mutation, so its finite refusals arrive here as +-- return values. Mark their complete text for the catalog and look them up +-- when they cross this UI boundary. +local BOX_FAILURE_SOURCES = { + Strings.source("No save."), + Strings.source("There is no POKéMON there."), + Strings.source("The BOX is full."), + Strings.source("You can't deposit\nthe last POKéMON!"), + Strings.source("You can't take\nany more POKéMON."), + Strings.source("It's already there."), + -- CommonText.pages() (below) only treats \n as the box's second row, not + -- a page break: a THIRD line needs \f (or \v) like every other multi-page + -- message in this file, not a second bare \n -- which used to just glue + -- this line onto the one above with no separator. + Strings.source("You'll need a\nPOKéMON to call\fwith."), +} + +-- \v (scroll-continue) needs the same page-break handling CommonText.pages +-- already gives every other Gen 2 screen (PackMenu/PrizeMenu route their +-- messages through it); this used to split only on \n/\f, so a translated +-- message that needed a \v scroll break rendered wrong here. +local function messagePages(text) + return CommonText.pages(text) or {} +end + +function BoxMenu:wantsFillScale() return true end +function BoxMenu:drawsWidescreen() return true end + +-- opts: save, mode ("withdraw" | "deposit" | "move"), onClose() +function BoxMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, BoxMenu) + self.game = game + self.save = opts.save or (game and game.save) + if self.save ~= (game and game.save) then + self.game = setmetatable({save=self.save,writeSave=false},{__index=game}) + end + self.mode = opts.mode or "withdraw" + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + local data = game and game.data or {} + self.pokemon = opts.pokemon or data.pokemon + self.palettes = opts.palettes or data.gen2Palettes or data + -- EggPic has no data.pokemon row; it rides menu_gfx.eggHatch, and ICON_EGG + -- stands in for a cache built before the extractor learned it. + self.menuGfx = opts.menuGfx or data.gen2MenuGfx + self.icons = opts.icons or data.gen2Icons + self.boxIndex = self.save and self.save.currentBox or 1 + self.index = 1 + self.scroll = 0 + self.picCache = {} + self.message = nil + self.messageFrames = nil + self.cryWait = nil + -- nil while the list is being browsed; "submenu" while MOVE/STATS/CANCEL is + -- up, "insert" while the insert cursor is picking a destination. Only the + -- move screen has phases -- the other two lists act on A. + self.phase = nil + self.submenuIndex = 1 + return self +end + +-- The PARTY is box 0 on the move screen and nowhere else. +function BoxMenu:isParty(index) + if index == nil then index = self.boxIndex end + return self.mode == "move" and index == PARTY_BOX +end + +-- The mon list a given loaded box stands for. Boxes.box hands back a live +-- table (and creates it on demand), so an insert here lands on the save. +function BoxMenu:listAt(index) + if self:isParty(index) then + self.save.party = self.save.party or {} + return self.save.party + end + return Boxes.box(self.save, index) +end + +-- PARTY_LENGTH for box 0, MONS_PER_BOX for the rest +-- (BillsPC_CheckSpaceInDestination's `.party` arm). +function BoxMenu:capacityAt(index) + if self:isParty(index) then return Boxes.PARTY_SIZE end + return Boxes.MONS_PER_BOX +end + +-- BillsPC_BoxName: the party's name is a string of its own, a box's comes out +-- of wBoxNames. +function BoxMenu:nameAt(index) + -- .PartyPKMN is "PARTY @" -- eight tiles, because and are + -- one font glyph each. + if self:isParty(index) then return Strings(PARTY_TITLE) end + local names = self.save and self.save.boxNames + local custom = names and names[index] + if type(custom) == "string" and custom ~= "" then return custom end + return Strings(DEFAULT_BOX_TITLE, index) +end + +-- Which list this mode browses. +function BoxMenu:list() + if self.mode == "deposit" then return self.save.party or {} end + return self:listAt(self.boxIndex) +end + +function BoxMenu:title() + if self.mode == "deposit" then return Strings(PARTY_TITLE) end + -- While the insert cursor is up the header names the DESTINATION: the whole + -- screen has moved there (.PrepInsertCursor calls the same + -- BillsPC_MoveMonWOMail_BoxNameAndArrows with the new wBillsPC_LoadedBox). + return self:nameAt(self.boxIndex) +end + +-- The cart's own prompts (PCString_*): short, because the box they print in +-- is one row of 18 columns. +function BoxMenu:prompt() + -- engine/pokemon/bills_pc.asm:356-369: PrepSubmenu places PCString_WhatsUp. + if self.phase == "submenu" then return Strings(WHATS_UP_PROMPT) end + if self.mode == "move" then + -- .Init and .PrepInsertCursor each place their own string. + if self.phase == "insert" then return Strings(MOVE_WHERE_PROMPT) end + return Strings(CHOOSE_PROMPT) + end + -- PCString_ChooseaPKMN: _DepositPKMN.Init and BillsPC_Withdraw.Init both + -- place this exact string (engine/pokemon/bills_pc.asm:2185). + return Strings(CHOOSE_PROMPT) +end + +function BoxMenu:total() + return #self:list() + 1 -- CANCEL +end + +function BoxMenu:isCancel() + return self.index > #self:list() +end + +function BoxMenu:selected() + return self:list()[self.index] +end + +function BoxMenu:ensureVisible() + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:total() - VISIBLE_ROWS))) +end + +function BoxMenu:clampIndex() + local total = self:total() + if self.index > total then self.index = total end + if self.index < 1 then self.index = 1 end + self:ensureVisible() +end + +function BoxMenu:act() + if self:isCancel() then + if self.onClose then self.onClose() end + return + end + -- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu, + -- and _DepositPKMN's .a_button steps to .WhatsUp the same way (:94-102). + if not self:selected() then return end + self.phase = "submenu" + -- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on its top row. + self.submenuIndex = 1 +end + +-- ------------------------------------------------------------ MOVE, step 2 + +-- BillsPC_CheckMail_PreventBlackout (engine/pokemon/bills_pc.asm:1575), which +-- .Move runs BEFORE it backs up the cursor: three refusals, all of them about +-- the PARTY, so a boxed mon walks straight past. Returns true, or false and +-- the string the cart places. +function BoxMenu:checkMailPreventBlackout() + -- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`; _DepositPKMN zeroes + -- that byte too, so its list is the party (bills_pc.asm:17-18). + if not (self:isParty() or self.mode == "deposit") then return true end + local party = self.save.party or {} + -- `cp $3 / jr c, .ItsYourLastPokemon`: a party of one or two may not send + -- one away at all, however healthy the rest of it is. + if #party < 3 then return false, LAST_MON end + -- CheckCurPartyMonFainted (engine/pokemon/bills_pc_top.asm:171) walks the + -- party skipping wCurPartyMon and answers carry when everything ELSE has + -- fainted -- taking this one out would white the player out on the next step. + local othersUsable = false + for i, mon in ipairs(party) do + if i ~= self.index and (mon.hp or 0) > 0 then othersUsable = true break end + end + if not othersUsable then return false, NO_USABLE_MON end + -- wBillsPC_MonHasMail, the byte PCMonInfo set while drawing the row. The + -- top menu already refused to open this screen at all while any party mon + -- holds a letter (BillsPC_MovePKMNMenu's IsAnyMonHoldingMail, + -- src/ui/gen2/PcMenu.lua), so this is the second of two nets. + if Mail.monHoldsMail(party[self.index]) then + return false, REMOVE_MAIL + end + return true +end + +-- .Move: back the position up (so B can restore it), keep the loaded box and +-- step to $4, .PrepInsertCursor. +function BoxMenu:beginMove() + local ok, reason = self:checkMailPreventBlackout() + if not ok then + -- BillsPC_PlaceString + SFX_WRONG + 50 frames, then `dec [hl]` drops back + -- to the submenu; here the message holds until a button clears it and the + -- list comes back, which is the same place the player ends up. + self.phase = nil + -- engine/pokemon/bills_pc.asm:1607 + self:playRefusalSfx("Sfx_Wrong") + self.message = Strings(reason) + return + end + self.moveFrom = { box = self.boxIndex, slot = self.index } + self.backup = { box = self.boxIndex, index = self.index, scroll = self.scroll } + self.phase = "insert" + self:clampInsert() +end + +-- BillsPC_StatsScreen: the stats screen over the selected mon, then back to +-- the submenu (.Stats ends in PCMonInfo, not in a jumptable step). +function BoxMenu:openStats() + local mon = self:selected() + local game = self.game + if not (mon and game and game.stack) then return end + if not pcall(Screens.get, game, "Gen2SummaryMenu") then return end + Screens.push(game, "Gen2SummaryMenu", { + mon = mon, + save = self.save, + onClose = function() game.stack:pop() end, + }) +end + +-- engine/pokemon/bills_pc.asm:1785-1787 ClearBox over the left panel, then the +-- timed hold at :1804; .Init (:161) only runs after it. +function BoxMenu:holdMessage(text) + self.message = text + self.messagePage = 1 + self.messageFrames = STORE_MESSAGE_FRAMES + self.panelCleared = true +end + +function BoxMenu:isCrystal() + local version = (self.save and self.save.version) or GameVersion.get() + return GameVersion.isCrystal(version) +end + +-- ../pokecrystal/home/pokemon.asm:124-127; ../pokegold/home/pokemon.asm:101-107 +function BoxMenu:cryThenHold(src, mon, text) + if not (self:isCrystal() and src and src.isPlaying) then + return self:holdMessage(text) + end + self.cryWait = { src = src, mon = mon, text = text, t = 0 } +end + +-- engine/pokemon/bills_pc.asm:397-411: failed withdraw stays on the submenu. +function BoxMenu:doWithdraw() + local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index, self.game and self.game.data) + if not ok then + -- engine/pokemon/bills_pc.asm:1845 + self:playRefusalSfx("Sfx_Wrong") + self.message = Strings(result) + return + end + -- engine/pokemon/bills_pc.asm:1817 + local name = result.nickname or result.name or result.species or "?" + self:cryThenHold(self:playMonCry(result), result, Strings(GOT_MON, name)) +end + +-- engine/pokemon/bills_pc.asm:155 BillsPCDepositFuncDeposit +function BoxMenu:doDeposit() + local mon = self:selected() + local name = mon and (mon.nickname or mon.name or mon.species) or "?" + local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex, + self.game and self.game.data) + if not ok then + -- engine/pokemon/bills_pc.asm:1790 + self:playRefusalSfx("Sfx_Wrong") + self.message = Strings(result) + return + end + -- engine/pokemon/bills_pc.asm:1762 + self:cryThenHold(self:playMonCry(result), result, Strings(STORED_MON, name)) +end + +function BoxMenu:chooseSubmenu() + local row = self:submenuRows()[self.submenuIndex] + if row == "MOVE" then + self:beginMove() + elseif row == "DEPOSIT" then + self:doDeposit() + elseif row == "WITHDRAW" then + self:doWithdraw() + elseif row == "STATS" then + self:openStats() + elseif row == "RELEASE" then + self:askRelease() + else + -- .Cancel: `ld a, $0 / ld [wJumptableIndex], a`. + self.phase = nil + end +end + +-- ------------------------------------------------------------ MOVE, step 3 + +-- How many insert positions the destination offers. BillsPC_PressDown stops +-- at wBillsPC_NumMonsInBox - 1, so the cursor always sits ON a mon (or on row +-- one of an empty list) and InsertSpeciesIntoBoxOrParty pushes the rest down. +function BoxMenu:insertPositions() + return math.max(1, #self:listAt(self.boxIndex)) +end + +function BoxMenu:clampInsert() + self.index = math.max(1, math.min(self.index, self:insertPositions())) + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:insertPositions() - VISIBLE_ROWS))) + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end +end + +-- BillsPC_CheckSpaceInDestination: a move inside one list is always allowed +-- (`.same_box`), and a move into another one needs a free slot there. The +-- ASM compares against MONS_PER_BOX + 1 / PARTY_LENGTH + 1, which can never be +-- reached -- the port refuses at the real capacity instead, because a 21st mon +-- in a box is a mon the save cannot hold. +function BoxMenu:checkSpaceInDestination() + local from = self.moveFrom + if from and from.box == self.boxIndex then return true end + if #self:listAt(self.boxIndex) >= self:capacityAt(self.boxIndex) then + return false, NO_ROOM + end + return true +end + +-- MovePKMNWithoutMail_InsertMon's .Jumptable: .BoxToBox / .PartyToBox / +-- .BoxToParty / .PartyToParty, all four of them .CopyFrom* (which is +-- RemoveMonFromPartyOrBox) followed by .CopyTo* (InsertPokemonIntoBox or +-- InsertPokemonIntoParty). This port keeps a mon as one Lua table in exactly +-- one list, so the four cases are one remove and one insert; what differs is +-- WHICH list each end names, and the mail bookkeeping on the party end. +function BoxMenu:insertMon() + local from = self.moveFrom + if not from then return end + local source = self:listAt(from.box) + local mon = source[from.slot] + if not mon then + self.phase = nil + self.moveFrom, self.backup = nil, nil + return + end + local destIndex = self.boxIndex + local target = math.min(self.index, self:insertPositions()) + table.remove(source, from.slot) + -- sPartyMail is keyed by party SLOT, so a mon leaving the party drags every + -- letter behind it up one (RemoveMonFromPartyOrBox's "Mail time!" tail, the + -- same call Boxes.deposit makes). No letter can actually be in the party + -- here -- IsAnyMonHoldingMail refused the whole screen -- so this keeps the + -- slots honest rather than moving anything. + if self:isParty(from.box) then Mail.removeSlot(self.save, from.slot) end + local dest = self:listAt(destIndex) + if from.box == destIndex and target > from.slot then + -- .CheckTrivialMove: the source was taken out first, so a destination slot + -- below it has already shuffled up one. + target = target - 1 + end + table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon) + -- .CopyToBox is InsertPokemonIntoBox, which tails into + -- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37). + if not self:isParty(destIndex) then + Boxes.enterBox(mon, self.game and self.game.data) + end + self.phase = nil + self.moveFrom, self.backup = nil, nil + self.index, self.scroll = 1, 0 + self:clampIndex() + self.message = Strings(SAVING_LEAVE_ON) +end + +-- .b_button_2: the backed-up scroll, cursor and loaded box all go back, and +-- the screen returns to step 1 with nothing moved. +function BoxMenu:cancelMove() + local backup = self.backup + if backup then + self.boxIndex, self.index, self.scroll = + backup.box, backup.index, backup.scroll + end + self.phase = nil + self.moveFrom, self.backup = nil, nil + self:clampIndex() +end + +-- BillsPC_PressLeft / BillsPC_PressRight, reached only from +-- MoveMonWithoutMail_DPad: the move screen wraps through box 0 (the PARTY). +function BoxMenu:stepBox(delta) + local low = self.mode == "move" and PARTY_BOX or 1 + local span = Boxes.NUM_BOXES - low + 1 + self.boxIndex = (self.boxIndex - low + delta) % span + low + -- .dpad / .dpad_2: both arms zero the cursor and the scroll. + self.index, self.scroll = 1, 0 + if self.phase == "insert" then self:clampInsert() end +end + +function BoxMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if self.cryWait then + local w = self.cryWait + w.t = w.t + 1 + local playing = w.src:isPlaying() + if w.t < 3 or (playing and w.t <= 180) then return end + self.cryWait = nil + self:holdMessage(w.text) + return + end + + -- engine/pokemon/bills_pc.asm:1804 DelayFrames reads no joypad; :161 zeroes + -- wJumptableIndex / cursor / scroll only once it returns. + if self.messageFrames then + self.messageFrames = self.messageFrames - 1 + if self.messageFrames > 0 then return end + self.messageFrames = nil + self.message, self.messagePage = nil, nil + self.panelCleared = nil + self.phase = nil + self.index, self.scroll = 1, 0 + self:clampIndex() + return + end + + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then + local page = (self.messagePage or 1) + 1 + if page <= #messagePages(self.message) then + self.messagePage = page + else + self.message, self.messagePage = nil, nil + end + end + return + end + + -- .MoveMonWOMailSubmenu, a VerticalMenu: up/down, A picks, B is its carry. + if self.phase == "submenu" then + local submenu = self:submenuRows() + if input:wasPressed("up") then + self.submenuIndex = self.submenuIndex > 1 and self.submenuIndex - 1 + or #submenu + elseif input:wasPressed("down") then + self.submenuIndex = self.submenuIndex < #submenu + and self.submenuIndex + 1 or 1 + elseif input:wasPressed("a") then + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") + self:chooseSubmenu() + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.phase = nil + end + return + end + + -- .Joypad2: the insert cursor. Up/down walk the destination's slots, + -- left/right walk the destinations themselves, A inserts, B goes back. + if self.phase == "insert" then + local positions = self:insertPositions() + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or positions + self:clampInsert() + elseif input:wasPressed("down") then + self.index = self.index < positions and self.index + 1 or 1 + self:clampInsert() + elseif input:wasPressed("left") then + self:stepBox(-1) + elseif input:wasPressed("right") then + self:stepBox(1) + elseif input:wasPressed("a") then + local ok, reason = self:checkSpaceInDestination() + if not ok then + -- .no_space: `dec [hl]` puts the jumptable back on .PrepInsertCursor, + -- so the refusal leaves the cursor exactly where it was. + -- engine/pokemon/bills_pc.asm:1567 + self:playRefusalSfx("Sfx_Wrong") + self.message = Strings(reason) + else + self:insertMon() + end + elseif input:wasPressed("b") then + self:cancelMove() + end + return + end + + local total = self:total() + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + self:ensureVisible() + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + self:ensureVisible() + -- Withdraw_UpDown reads PAD_UP and PAD_DOWN and nothing else; only + -- MoveMonWithoutMail_DPad walks the boxes (bills_pc.asm:806-820, :822-845). + elseif input:wasPressed("left") and self.mode == "move" then + self:stepBox(-1) + elseif input:wasPressed("right") and self.mode == "move" then + self:stepBox(1) + elseif input:wasPressed("a") then + self:act() + elseif input:wasPressed("b") then + if self.onClose then self.onClose() end + end +end + +function BoxMenu:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then Sound.play(data, name) end +end + +-- engine/pokemon/bills_pc.asm:1608 +function BoxMenu:playRefusalSfx(name) + self:playSfx(name) +end + +-- PlayMonCry: `call GetCryIndex / jr c, .done` (home/pokemon.asm:113-114) +function BoxMenu:playMonCry(mon) + local data = self.game and self.game.data + if not (data and mon and mon.species) or mon.isEgg then return end + local cries = data.audio and data.audio.cries + if cries and cries[mon.species] then + return Sound.playCry(data, mon.species) + end +end + +function BoxMenu:askRelease() + if self:isCancel() then return end + local mon = self:selected() + if not mon then return end + -- BillsPCDepositFuncRelease runs the mail/blackout net BEFORE the egg check + -- (engine/pokemon/bills_pc.asm:183-187). + if self.mode == "deposit" then + local allowed, refusal = self:checkMailPreventBlackout() + if not allowed then + self.phase = nil + -- engine/pokemon/bills_pc.asm:1607 + self:playRefusalSfx("Sfx_Wrong") + self.message = Strings(refusal) + return + end + end + -- Both release paths run BillsPC_IsMonAnEgg first, so the question is never + -- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428). + if mon.isEgg then + self.message = Strings(NO_RELEASING_EGGS) + self:playRefusalSfx("Sfx_Wrong") + return + end + local game = self.game + if not (game and game.stack) then return end + local ChoiceBox = require("src.ui.ChoiceBox") + local name = mon.nickname or mon.name or mon.species or "?" + self.message = Strings(RELEASE_PROMPT) + game.stack:push(ChoiceBox.new(game, function(yes) + self.message = nil + if not yes then return end + local ok, err + if self.mode == "deposit" then + ok, err = Boxes.releaseFromParty(self.save, self.index) + else + ok, err = Boxes.release(self.save, self.boxIndex, self.index) + end + if not ok then + self.message = Strings(err) + return + end + -- engine/pokemon/bills_pc.asm:1866 + self:playMonCry(mon) + self.message = Strings(RELEASED, name) + self.phase = nil + self:clampIndex() + end, { defaultNo = true })) +end + +function BoxMenu:image(path) + if not path then return nil end + local cached = self.picCache[path] + if cached == nil then + -- `and` truncates a multi-return, so the pcall has to stand alone. + local ok, image = pcall(Assets.image, path) + cached = (ok and image) or false + self.picCache[path] = cached + end + return cached or nil +end + +-- The selected mon's front pic, cached per species. +function BoxMenu:picFor(mon) + if not (mon and mon.species and self.pokemon) then return nil end + local data = self.game and self.game.data or {pokemon=self.pokemon} + local path, trueColor = Sprites.path(data, mon.species, "front", {mon=mon,kind="box"}) + return self:image(path), trueColor +end + +-- engine/gfx/cgb_layouts.asm:284-300, engine/pokemon/bills_pc.asm:356-369 +function BoxMenu:panelColors(speciesId, shiny) + if self.phase == "submenu" or self.phase == "insert" then + return self.palettes + and Palettes.monColors(self.palettes, speciesId, shiny) + end + local gfx = (self.menuGfx or {}).billsPc + return (gfx and gfx.orangePalette) or BILLS_PC_ORANGE +end + +-- ClearBox runs before `cp -1 / ret z` (engine/pokemon/bills_pc.asm:1009-1021) +function BoxMenu:fillPicBlock(colors) + local G = love.graphics + local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } + G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1) + G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8) + G.setColor(1, 1, 1, 1) +end + +-- PCMonInfo lays the padded pic as one 7x7 block at hlcoord 1, 4 +-- (engine/pokemon/bills_pc.asm:1023-1042), the pad tiles at the palette's 0. +function BoxMenu:drawPicBlock(image, colors, trueColor) + if not image then return end + local G = love.graphics + self:fillPicBlock(colors) + + local pad = PIC_PAD[math.floor(image:getWidth() / 8)] or PIC_PAD[7] + G.setColor(1, 1, 1, 1) + local function body() + G.draw(image, (PIC_X + pad[1]) * 8, (PIC_Y + pad[2]) * 8) + end + if colors and not (trueColor and GbcPalette.mode == "gbc") + and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + G.setColor(1, 1, 1, 1) +end + +function BoxMenu:drawPic(mon) + -- _CGB_BillsPC hands wTempMonDVs to GetPlayerOrMonPalettePointer, so the box + -- pic takes the shiny row (engine/gfx/cgb_layouts.asm:292-293). + local colors = self:panelColors(mon.species, mon.shiny) + local image, trueColor = self:picFor(mon) + -- engine/pokemon/bills_pc.asm:1009-1011 + if not image then return self:fillPicBlock(colors) end + self:drawPicBlock(image, colors, trueColor) +end + +-- GetFrontpic's `cp EGG / jr nz, .not_egg` arm hands back EggPic, never the +-- hatchling's pic (engine/gfx/load_pics.asm:88-91); it rides menu_gfx.eggHatch, +-- with the party list's ICON_EGG standing in for a cache built before that. +-- One reusable quad per role, re-aimed per draw rather than a new Quad a +-- frame. nil when the backend cannot make one (the old pcall's degrade). +local function reusedQuad(self, slot, x, y, w, h, sw, sh) + local quads = self.reusedQuads + if not quads then + quads = {} + self.reusedQuads = quads + end + local quad = quads[slot] + if quad then + quad:setViewport(x, y, w, h, sw, sh) + return quad + end + local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh) + if not ok then return nil end + quads[slot] = made + return made +end + +-- GbcPalette.with without a closure: set `colors` (when there is a shader), +-- returning what finishShade needs to put the caller's shader back. +local function startShade(colors) + if not (colors and GbcPalette.available()) then return false end + local G = love.graphics + local previous = G.getShader and G.getShader() or nil + GbcPalette.use(colors) + return true, previous +end + +local function finishShade(shaded, previous) + if shaded then love.graphics.setShader(previous) end +end + +function BoxMenu:drawEggPic(mon) + local G = love.graphics + local colors = self:panelColors("EGG", mon and mon.shiny) + local gfx = (self.menuGfx or {}).eggHatch + local image = self:image(gfx and gfx.egg) + if image then return self:drawPicBlock(image, colors) end + self:fillPicBlock(colors) + local entry = self.icons and self.icons.icons and self.icons.icons.ICON_EGG + image = self:image(entry and entry.image) + if not image then return end + -- The ICON_EGG sheet stacks its frames; the first is the egg at rest. + local w = entry.width or 16 + local h = math.min(entry.height or 16, image:getHeight()) + if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end + local quad = reusedQuad(self, "egg", 0, 0, w, h, + image:getWidth(), image:getHeight()) + if not quad then return end + local x = PIC_X * 8 + math.floor((7 * 8 - w * 2) / 2) + local y = PIC_Y * 8 + math.floor((7 * 8 - h * 2) / 2) + G.setColor(1, 1, 1, 1) + local shaded, previous = startShade(colors) + G.draw(image, quad, x, y, 0, 2, 2) + finishShade(shaded, previous) + G.setColor(1, 1, 1, 1) +end + +-- ItemIsMail picks $5c over $5d at hlcoord 7, 12 +-- (engine/pokemon/bills_pc.asm:1079-1094) +function BoxMenu:drawHeldIcon(mon) + local row = mon and mon.item and (Mail.monHoldsMail(mon) and 0 or 1) + if not row then return end + local gfx = (self.menuGfx or {}).billsPc + local image = self:image(gfx and gfx.icons) + if not image then return end + local iw, ih = image:getDimensions() + local quad = reusedQuad(self, "held", row * 8, 0, 8, 8, iw, ih) + if not quad then return end + local G = love.graphics + G.setColor(1, 1, 1, 1) + local shaded, previous = startShade(gfx and gfx.palette) + G.draw(image, quad, ICON_X * 8, ICON_Y * 8) + finishShade(shaded, previous) + G.setColor(1, 1, 1, 1) +end + +-- _MovePKMNWithoutMail only (engine/pokemon/bills_pc.asm:545, :698) +function BoxMenu:drawBoxArrows() + if self.mode ~= "move" then return end + local gfx = (self.menuGfx or {}).billsPc + local image = self:image(gfx and gfx.icons) + if not image then return end + local G = love.graphics + local iw, ih = image:getDimensions() + local left = reusedQuad(self, "arrowLeft", ARROW_LEFT[1] * 8, 0, 8, 8, iw, ih) + if not left then return end + local right = reusedQuad(self, "arrowRight", ARROW_RIGHT[1] * 8, 0, 8, 8, + iw, ih) + if not right then return end + G.setColor(1, 1, 1, 1) + local shaded, previous = startShade(gfx and gfx.palette) + G.draw(image, left, ARROW_LEFT[2] * 8, ARROW_ROW * 8) + G.draw(image, right, ARROW_RIGHT[2] * 8, ARROW_ROW * 8) + finishShade(shaded, previous) + G.setColor(1, 1, 1, 1) +end + +-- ../pokegold/engine/pokemon/bills_pc.asm:1439-1490 +-- ../pokecrystal/engine/pokemon/bills_pc.asm:1479-1503 +function BoxMenu:selectionFrameRect(row) + if self:isCrystal() then + return { x = 70, y = 29 + (row - 1) * 16, w = 83, h = 13, rounded = true } + end + return { x = 71, y = 25 + (row - 1) * 16, w = 80, h = 16, rounded = false } +end + +function BoxMenu:drawSelectionFrame(row) + local G = love.graphics + local r = self:selectionFrameRect(row) + G.setColor(0, 0, 0, 1) + if not r.rounded then + G.setLineWidth(1) + G.rectangle("line", r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1) + G.setLineWidth(1) + return + end + local x, y = r.x, r.y + G.rectangle("fill", x + 2, y, r.w - 4, 1) + G.rectangle("fill", x + 2, y + r.h - 1, r.w - 4, 1) + G.rectangle("fill", x, y + 2, 1, r.h - 4) + G.rectangle("fill", x + r.w - 1, y + 2, 1, r.h - 4) + G.rectangle("fill", x + 1, y + 1, 1, 1) + G.rectangle("fill", x + r.w - 2, y + 1, 1, 1) + G.rectangle("fill", x + 1, y + r.h - 2, 1, 1) + G.rectangle("fill", x + r.w - 2, y + r.h - 2, 1, 1) +end + +-- ../pokecrystal/engine/pokemon/bills_pc.asm:68, :117 +function BoxMenu:selectionFrameVisible() + return self.phase == nil and not self.message and not self.cryWait +end + +-- BillsPC_UpdateInsertCursor lays a DIFFERENT sprite frame from the selection +-- one -- a wedge between the rows rather than a box around one -- so the +-- insert cursor is drawn as a rule along the top edge of the row the mon is +-- going in front of. +function BoxMenu:drawInsertCursor(row) + local G = love.graphics + local x, y = 71, 31 + (row - 1) * 16 + G.setColor(0, 0, 0, 1) + G.rectangle("fill", x, y, 80, 2) +end + +-- The mon the whole screen is about while the insert cursor is up: .PrepSubmenu +-- ran PCMonInfo over it and .PrepInsertCursor does NOT run it again, so the +-- left panel keeps showing the mon in flight. +function BoxMenu:panelMon() + if self.cryWait then return self.cryWait.mon end + local from = self.moveFrom + if self.phase == "insert" and from then + return self:listAt(from.box)[from.slot] + end + return self:selected() +end + +-- engine/pokemon/bills_pc.asm:962-970, :1791-1793 +function BoxMenu:messageBox(page) + if #page <= 1 then return { 0, 15, 20, 3 } end + return { 0, 12, 20, 6 } +end + +function BoxMenu:drawPanel() + -- BillsPC_InitGFX loads FontsBattleExtra once for the whole screen and + -- never restores the standard font (engine/pokemon/bills_pc.asm:2169). + Chrome.clear() + + -- engine/pokemon/bills_pc.asm:1220-1227 + Chrome.box(8, 2, 12, 12) + -- engine/pokemon/bills_pc.asm:981-983 + Chrome.box(8, 0, 12, 3) + Chrome.print(self:title(), 10, 1) + self:drawBoxArrows() + + local list = self:list() + local inserting = self.phase == "insert" + local frameUp = self:selectionFrameVisible() + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local ty = LIST_Y + (row - 1) * LIST_SPACING + if i <= #list then + local mon = list[i] + if i == self.index then + if inserting then + self:drawInsertCursor(row) + elseif frameUp then + self:drawSelectionFrame(row) + end + end + -- .PlaceNickname prints the stored nickname bytes verbatim, with no egg + -- check of its own (engine/pokemon/bills_pc.asm:1245-1356). + local label = mon.nickname or mon.name or mon.species or "?" + Chrome.print(label, LIST_X, ty) + elseif inserting then + -- An empty destination: the cursor is the only thing on the list. + if i == self.index then self:drawInsertCursor(row) end + elseif i == self:total() then + if i == self.index and frameUp then self:drawSelectionFrame(row) end + Chrome.print(Strings("CANCEL"), LIST_X, ty) + end + end + + -- The left panel: pic, level, gender, species -- blank on CANCEL, the way + -- PCMonInfo clears it when the selection is not a mon. + local mon = not self.panelCleared and self:panelMon() or nil + if mon then + -- `cp EGG / ret z` right after the frontpic: no name, no level, no gender + -- (engine/pokemon/bills_pc.asm:1057-1058). + if mon.isEgg then + self:drawEggPic(mon) + else + self:drawPic(mon) + -- PrintLevel always writes the single bold glyph, not ":L" + -- (home/pokemon.asm:178-183). + Chrome.print("" .. tostring(mon.level or 1), PIC_X, 12) + if mon.gender == "male" then + Chrome.print("\xe2\x99\x82", 5, 12) + elseif mon.gender == "female" then + Chrome.print("\xe2\x99\x80", 5, 12) + end + Chrome.print(mon.name or mon.species or "?", PIC_X, 14) + self:drawHeldIcon(mon) + end + else + self:fillPicBlock(self:panelColors()) + end + + -- BillsPC_PlaceString: Textbox at (0,15) with a one-row interior, string at + -- (1,16). A refusal is two lines on the cart, so those get a taller box of + -- their own rather than being cut to fit this one. + if self.message then + local page = messagePages(self.message)[self.messagePage or 1] or {} + local box = self:messageBox(page) + Chrome.box(box[1], box[2], box[3], box[4]) + for i, part in ipairs(page) do + Chrome.print(part, 1, box[2] + 1 + (i - 1) * 2) + end + else + Chrome.box(0, 15, 20, 3) + Chrome.print(self:prompt(), 1, 16) + end + + -- .MoveMonWOMailSubmenu's .MenuHeader is `menu_coords 9, 4, SCREEN_WIDTH - 1, + -- 13` -- it covers the nickname list, which is why the list is still drawn + -- underneath it and not instead of it. STATICMENU_CURSOR with the default + -- top spacing puts MOVE at (11,6), one row per two tiles. + if self.phase == "submenu" then + Chrome.box(9, 4, 11, 10) + for i, label in ipairs(self:submenuRows()) do + local ty = 6 + (i - 1) * 2 + if i == self.submenuIndex then Chrome.cursor(10, ty) end + Chrome.print(Strings(label), 11, ty) + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +function BoxMenu:draw() + self:drawPanel() +end + +function BoxMenu:drawWidescreen(winW, winH) + local G = love.graphics + Chrome.letterbox(winW, winH, 1, 1, 1) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return BoxMenu diff --git a/src/ui/Gen2CenterPcMenu.lua b/src/ui/Gen2CenterPcMenu.lua new file mode 100644 index 000000000..38c2e0ef1 --- /dev/null +++ b/src/ui/Gen2CenterPcMenu.lua @@ -0,0 +1,371 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- The Pokemon Center PC's whose-PC menu (engine/events/pokecenter_pc.asm +-- PokemonCenterPC). Every Pokecenter reaches it the same way: the PC is a +-- COLL_PC tile, the A press runs PCScript (engine/events/std_scripts.asm) and +-- its `special PokemonCenterPC` opens this screen through World:openPc. +-- +-- .ChooseWhichPCListToUse picks the row list: +-- +-- PCPC_BEFORE_POKEDEX BILL's PC / 's PC / TURN OFF +-- PCPC_BEFORE_HOF + PROF.OAK's PC (CheckReceivedDex) +-- PCPC_POSTGAME + HALL OF FAME (wHallOfFameCount > 0) +-- +-- BILL's PC opens the storage system (src/ui/gen2/PcMenu.lua, _BillsPC's own +-- five rows), 's PC the item PC (src/ui/gen2/ItemPcMenu.lua, +-- PLAYERSPC_NORMAL), PROF.OAK's PC the #DEX rating (ProfOaksPC, +-- engine/events/prof_oaks_pc.asm) and HALL OF FAME the roster viewer +-- (src/ui/gen2/HallOfFame.lua "view" mode, _HallOfFamePC). + +local Chrome = require("src.ui.Gen2Chrome") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Specials = require("src.core.Gen2PokedexRating") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") + +local CenterPcMenu = {} +CenterPcMenu.__index = CenterPcMenu +-- ../pokecrystal/engine/events/pokecenter_pc.asm:15 +CenterPcMenu.isOpaque = false + +-- ENGINE_POKEDEX (constants/engine_flags.asm, index 11): the flag +-- CheckReceivedDex reads and Mr.Pokemon's `setflag ENGINE_POKEDEX` writes. +-- The port lands plain ENGINE_* ids on save.engineFlags +-- (World:setEngineFlag), so the same store answers here. +local ENGINE_POKEDEX = 11 + +-- Text owned by PokemonCenterPC/ProfOaksPC. Keep each cart message as one +-- catalog key so a translation may reflow or reorder it without inheriting +-- the English line fragments. +local TEXT = { + -- data/text/common_2.asm:725 _PokecenterPCCantUseText ends in `cont` + -- (\v scrolls "have a #MON to" up and lands "use this!" under it), not a + -- third bare \n line (which pagesOf() renders as its own one-line page + -- instead of a scroll). + noMon = Strings.source("Bzzzzt! You must\nhave a #MON to\vuse this!"), + turnedOn = Strings.source("{PLAYER} turned on\nthe PC."), + billsPc = Strings.source("BILL's PC"), + playersPc = Strings.source("%s's PC"), + oaksPc = Strings.source("PROF.OAK's PC"), + hallOfFame = Strings.source("HALL OF FAME"), + turnOff = Strings.source("TURN OFF"), + oakClosed = Strings.source("The link to PROF.\nOAK's PC closed."), + linkClosed = Strings.source("…\nLink closed…"), + billsOpened = Strings.source( + "BILL's PC\naccessed.\n\n#MON Storage\nSystem opened."), + ownOpened = Strings.source( + "Accessed own PC.\n\nItem Storage\nSystem opened."), + oakOpened = Strings.source( + "PROF.OAK's PC\naccessed.\n\n#DEX Rating\nSystem opened."), + rateDex = Strings.source("Want to get your\n#DEX rated?"), + accessWhose = Strings.source("Access whose PC?"), +} + +function CenterPcMenu:wantsFillScale() return true end + +-- `\f` = para (home/text.asm:403 Paragraph), `\v` = cont (home/text.asm:442 +-- _ContTextNoPause); two rows to a page (constants/text_constants.asm:32). +local function pagesOf(body) + local pages = {} + for chunk in (tostring(body) .. "\f"):gmatch("(.-)\f") do + local flat, pos, scrolled = {}, 1, false + while true do + local brk = chunk:find("[\n\v]", pos) + local line = brk and chunk:sub(pos, brk - 1) or chunk:sub(pos) + if line ~= "" then flat[#flat + 1] = { line, scrolled } end + if not brk then break end + scrolled = chunk:sub(brk, brk) == "\v" + pos = brk + 1 + end + local page + for _, entry in ipairs(flat) do + if not page then + page = { entry[1] } + pages[#pages + 1] = page + elseif entry[2] then + page = { page[#page], entry[1] } + pages[#pages + 1] = page + elseif #page >= 2 then + page = { entry[1] } + pages[#pages + 1] = page + else + page[#page + 1] = entry[1] + end + end + end + return pages +end + +local function translatedPages(source, ...) + return pagesOf(Strings(source, ...)) +end + +-- opts: save, events, items (items.lua), onClose() +function CenterPcMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, CenterPcMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.items = opts.items or (game and game.data and game.data.items) + self.data = game and game.data + self.events = opts.events + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + self.index = 1 + self.message = nil + self.confirm = nil + self.closed = false + self.booted = false + self:buildEntries() + local party = self.save and self.save.party + if not (party and #party > 0) then + -- PC_CheckPartyForPokemon: SFX_CHOOSE_PC_OPTION, the refusal, and the PC + -- never boots (`ret c` before PC_PlayBootSound). + -- data/text/common_2.asm:725 _PokecenterPCCantUseText ends in cont. + self:playSfx("Sfx_ChoosePcOption") + self:say(translatedPages(TEXT.noMon), + function() self:close() end) + else + -- PC_PlayBootSound + _PokecenterPCTurnOnText. + self:playSfx("Sfx_BootPc") + -- ../pokecrystal/engine/events/pokecenter_pc.asm:22 + self:say(translatedPages(TEXT.turnedOn), + function() self.booted = true end) + end + return self +end + +function CenterPcMenu:playSfx(name) + local data = self.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then + Sound.play(data, name) + end +end + +function CenterPcMenu:playerName() + local player = self.save and self.save.player + return (player and player.name) or "GOLD" +end + +-- .WhichPC, gated the way .ChooseWhichPCListToUse gates it. TURN OFF is +-- always last; the extra rows slot in above it in PCPCITEM_* order. +function CenterPcMenu:buildEntries() + local save = self.save + local hasDex = save and save.flags and save.flags.ENGINE_POKEDEX == true + local hofCount = (save and (type(save.hallOfFame)=="table" and (save.hallOfFame.count or #save.hallOfFame) or save.hallOfFameCount or save.hallOfFame)) or 0 + local entries = { + { id = "bills", label = Strings(TEXT.billsPc) }, + { id = "players", label = Strings(TEXT.playersPc, self:playerName()) }, + } + if hasDex then + entries[#entries + 1] = { id = "oaks", label = Strings(TEXT.oaksPc) } + if hofCount > 0 then + entries[#entries + 1] = { id = "hof", label = Strings(TEXT.hallOfFame) } + end + end + entries[#entries + 1] = { id = "turnoff", label = Strings(TEXT.turnOff) } + self.entries = entries +end + +-- pages is a list of pages, each a list of lines; a page may carry `sfx`, +-- played the moment it comes up (FindOakRating hands PlaySFX its fanfare +-- right before the rating text prints). +function CenterPcMenu:say(pages, onDone) + Typer.say(self, pages, onDone, { expand = function(line) + return (line:gsub("{PLAYER}", self:playerName())) + end }) + local first = pages[1] + if first and first.sfx then self:playSfx(first.sfx) end +end + +function CenterPcMenu:close() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose() end +end + +-- .shutdown: PC_PlayShutdownSound, then the menu is gone. The party refusal +-- never reaches this -- the PC never booted. +function CenterPcMenu:shutdown() + self:playSfx("Sfx_ShutDownPc") + self:close() +end + +-- ProfOaksPC's `.shutdown`: _OakPCText4 either way, then back to the menu +-- loop (`jr nc, .loop` in PokemonCenterPC -- the OaksPC row answers nc). +function CenterPcMenu:oakClosed() + self:say(translatedPages(TEXT.oakClosed)) +end + +-- ProfOaksPCBoot, inside a screen rather than a script: the counts, the +-- rating pick and the texts come from src/script/gen2/Specials.lua so the two +-- callers cannot drift apart. +function CenterPcMenu:oakRate() + local seen, caught = Specials.dexCounts(self.save) + local rating = Specials.findOakRating(caught) + local pages = pagesOf(Strings(Specials.OAK_PC_TEXT.completion)) + for _, page in ipairs(pagesOf( + Strings(Specials.OAK_PC_TEXT.counts, seen, caught))) do + pages[#pages + 1] = page + end + local ratingPages = pagesOf(Strings(rating.text)) + if ratingPages[1] then ratingPages[1].sfx = rating.sfx end + for _, page in ipairs(ratingPages) do pages[#pages + 1] = page end + self:say(pages, function() self:oakClosed() end) +end + +function CenterPcMenu:choose() + local entry = self.entries[self.index] + if not entry then return end + local game = self.game + if entry.id == "turnoff" then + -- TurnOffPC: PokecenterPCOaksClosedText, then carry into .shutdown. + self:say(translatedPages(TEXT.linkClosed), + function() self:shutdown() end) + return + end + -- PC_PlayChoosePCSound opens all four of the other rows. + self:playSfx("Sfx_ChoosePcOption") + if entry.id == "bills" then + self:say(translatedPages(TEXT.billsOpened), function() + if not (game and game.stack) then return end + Screens.push(game, "Gen2PcMenu", { + save = self.save, + bills = true, + onClose = function() game.stack:pop() end, + }) + end) + elseif entry.id == "players" then + self:say(translatedPages(TEXT.ownOpened), function() + if not (game and game.stack) then return end + Screens.push(game, "Gen2ItemPcMenu", { + save = self.save, + items = self.items, + onClose = function() game.stack:pop() end, + }) + end) + elseif entry.id == "oaks" then + self:say(translatedPages(TEXT.oakOpened), function() + -- _OakPCText1's yes/no; NO is the same `.shutdown` as a finished rating. + self.confirm = { + prompt = translatedPages(TEXT.rateDex)[1], + choice = 1, + onYes = function() self:oakRate() end, + onNo = function() self:oakClosed() end, + } + end) + elseif entry.id == "hof" then + if not (game and game.stack) then return end + -- HallOfFamePC: FadeToMenu, _HallOfFamePC, CloseSubmenu. + Screens.push(game, "HallOfFame", { + save = self.save, + mode = "view", + onDone = function() game.stack:pop() end, + }) + end +end + +function CenterPcMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if self.message then + Typer.step(self) + if Typer.typing(self) then return end + if input:wasPressed("a") or input:wasPressed("b") then + local m = self.message + if m.page < #m.pages then + Typer.turn(self, m) + local page = m.pages[m.page] + if page and page.sfx then self:playSfx(page.sfx) end + return + end + self.message = nil + if m.onDone then m.onDone() end + end + return + end + + if self.confirm then + local c = self.confirm + if input:wasPressed("up") or input:wasPressed("down") then + c.choice = c.choice == 1 and 2 or 1 + elseif input:wasPressed("b") then + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") + self.confirm = nil + if c.onNo then c.onNo() end + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self.confirm = nil + if c.choice == 1 then + if c.onYes then c.onYes() end + elseif c.onNo then + c.onNo() + end + end + return + end + + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.entries + elseif input:wasPressed("down") then + self.index = self.index < #self.entries and self.index + 1 or 1 + elseif input:wasPressed("a") then + self:choose() + elseif input:wasPressed("b") then + -- DoNthMenu's carry lands straight in .shutdown, no text. + self:shutdown() + end +end + +function CenterPcMenu:drawBottomLines(lines) + Chrome.box(0, 12, 20, 6) + if not lines then return end + local name = self:playerName() + -- constants/text_constants.asm:32 TEXTBOX_INNERY: rows 14 and 16 only. + for i = 1, math.min(#lines, 2) do + Chrome.print((lines[i]:gsub("{PLAYER}", name)), 1, 14 + (i - 1) * 2) + end +end + +function CenterPcMenu:drawPanel() + if self.booted then + -- _PokecenterPCWhoseText stays up under the menu + -- (PC_DisplayTextWaitMenu leaves it there); the menu window is drawn on + -- top of it, the way the cart's windows stack. + self:drawBottomLines(translatedPages(TEXT.accessWhose)[1]) + -- .TopMenu is menu_coords 0, 0, 15, 12. + Chrome.box(0, 0, 16, math.max(12, #self.entries * 2 + 2)) + for i, entry in ipairs(self.entries) do + local ty = i * 2 + if i == self.index then Chrome.cursor(1, ty) end + Chrome.print(entry.label, 2, ty) + end + end + + if self.message then + -- ../pokecrystal/engine/events/pokecenter_pc.asm:652 + self:drawBottomLines( + Typer.text(self, self.message.pages[self.message.page])) + elseif self.confirm then + self:drawBottomLines(self.confirm.prompt) + Chrome.box(14, 7, 6, 5) + Chrome.print(Strings("YES"), 16, 8) + Chrome.print(Strings("NO"), 16, 10) + Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10) + elseif not self.booted then + self:drawBottomLines(nil) + end + + love.graphics.setColor(1, 1, 1, 1) +end + +function CenterPcMenu:draw() + self:drawPanel() +end + +CenterPcMenu.ENGINE_POKEDEX = ENGINE_POKEDEX + +return CenterPcMenu diff --git a/src/ui/Gen2Chrome.lua b/src/ui/Gen2Chrome.lua new file mode 100644 index 000000000..b36f65206 --- /dev/null +++ b/src/ui/Gen2Chrome.lua @@ -0,0 +1,670 @@ +-- Ported from Gen1Recomp 535a5ec0; palette, font and DPI use this engine. +-- Shared Gen 2 menu chrome: boxes, tile-grid text, and the scrolling cursor +-- list that nearly every Gold screen is built out of. +-- +-- Gold draws its menus through one routine (engine/menus/menu.asm SetUpMenu + +-- GetScrollingMenuJoypad), so the behaviour every screen inherits -- wrap at +-- the ends, a ▶ cursor in a one-tile gutter, B to back out, START as an +-- accept on some menus -- lives here once instead of in each screen. +-- +-- Everything is in 8px tile coordinates on the 20x18 GB grid, because that is +-- how the ASM addresses the screen (hlcoord x, y) and it makes a layout +-- transcribed from pokegold land on the same pixels. + +local Font = require("src.render.Font") +local PaletteFX = require("src.render.PaletteFX") +-- A view of the native palette renderer, with no extra mode or BGP state. +local Colors = {} +function Colors.available() return PaletteFX.shader() ~= nil end +function Colors.resolve(colors) return PaletteFX.effectiveColors(colors) end +function Colors.useRaw(colors) + local shader = PaletteFX.shader() + if not shader then return end + for i = 1, 4 do + local c = colors[i] + shader:send("c" .. (i - 1), { c[1] / 255, c[2] / 255, c[3] / 255 }) + end + love.graphics.setShader(shader) +end +function Colors.use(colors) Colors.useRaw(PaletteFX.effectiveColors(colors)) end + +local function drawGlyphCode(code, x, y, ink) + if code >= 0 then return Font.drawCode(code, x, y) end + -- This engine's UnicodeFont encodes rasterized glyphs as negative codes. + ink = ink or { 0, 0, 0 } + Font.pushStyle({ text = { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 } }) + local ok, err = pcall(Font.drawCode, code, x, y) + Font.popStyle() + if not ok then error(err, 0) end +end + +local WHITE = { 255, 255, 255 } +local BLACK = { 0, 0, 0 } + +local Chrome = {} + +-- charmap.asm: ▶ is the menu cursor, ▷ its hollow "held" form, ▼ the +-- text-advance arrow. +Chrome.CURSOR = 0xED +Chrome.CURSOR_HOLLOW = 0xEC +Chrome.DOWN_ARROW = 0xEE + +Chrome.SCREEN_W = 20 +Chrome.SCREEN_H = 18 + +-- charmap.asm "¥", the money field's own prefix tile. +local YEN = "\xc2\xa5" + +function Chrome.paletteFill(px, py, pw, ph, palette) + palette = palette or Chrome.DEFAULT_BOX_PALETTE + local G = love.graphics + G.setColor(1, 1, 1, 1) + if Colors.available() then + -- Colors.with without the closure: set, draw, restore. + local previous = G.getShader and G.getShader() or nil + Colors.use(palette) + G.rectangle("fill", px, py, pw, ph) + G.setShader(previous) + else + G.rectangle("fill", px, py, pw, ph) + end + G.setColor(0, 0, 0, 1) +end + +-- Every drawWidescreen paints the window around its 160x144 panel through +-- here, so UI LETTERBOX has exactly one place to override. r/g/b is what the +-- screen was authored with and is what AUTO keeps. +Chrome.worldSurround = false + +function Chrome.letterbox(winW, winH, r, g, b) + if Chrome.worldSurround then return end + love.graphics.setColor(r or 1, g or 1, b or 1, 1) + love.graphics.rectangle("fill", 0, 0, winW, winH) +end + +function Chrome.clear() + Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) +end + +-- The blit scale every `drawWidescreen` paints its 160x144 panel at. +-- +-- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid +-- everything here is laid out on stops landing on tile boundaries: at a +-- fractional scale some rows of a tile get one device pixel and their +-- neighbours get two, which is what breaks a box border into steps and eats +-- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua +-- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen +-- paints still fills the window, the PANEL is what stays on the grid. +local function playfieldRect(winW, winH) + local ok, Playfield = pcall(require, "src.render.Playfield") + if ok and Playfield.rect then + local okv, x, y, w, h = pcall(Playfield.rect, winW, winH) + if okv and w and w >= 1 and h and h >= 1 then + return x, y, w, h + end + end + return 0, 0, winW or 0, winH or 0 +end + +Chrome.playfieldRect = playfieldRect + +local function pixelRatio() + local get = love.graphics.getDPIScale or (love.window and love.window.getDPIScale) + local d = get and get() or 1 + return type(d) == "number" and d > 0 and d or 1 +end + +Chrome.pixelRatio = pixelRatio + +function Chrome.fitScaleFor(winW, winH, tilesW, tilesH) + local _, _, w, h = playfieldRect(winW, winH) + local d = pixelRatio() + return math.max(1, math.floor(math.min(w * d / (tilesW * 8), + h * d / (tilesH * 8)) + 1e-6)) / d +end + +function Chrome.fitOriginFor(winW, winH, scale, tilesW, tilesH) + scale = scale or Chrome.fitScaleFor(winW, winH, tilesW, tilesH) + local x, y, w, h = playfieldRect(winW, winH) + local d = pixelRatio() + return x + math.floor((w - tilesW * 8 * scale) * d / 2 + 1e-6) / d, + y + math.floor((h - tilesH * 8 * scale) * d / 2 + 1e-6) / d + - Chrome.positionLift(winW, winH, scale) +end + +function Chrome.fitScale(winW, winH) + return Chrome.fitScaleFor(winW, winH, Chrome.SCREEN_W, Chrome.SCREEN_H) +end + +-- The centred origin that goes with it, so a caller does not re-derive it. +function Chrome.fitOrigin(winW, winH, scale) + scale = scale or Chrome.fitScale(winW, winH) + return Chrome.fitOriginFor(winW, winH, scale, Chrome.SCREEN_W, + Chrome.SCREEN_H) +end + +function Chrome.positionLift(winW, winH, scale) + local ok, ScreenPosition = pcall(require, "src.core.ScreenPosition") + if not ok or ScreenPosition.skinActive(winW, winH) then return 0 end + local _, _, _, h = playfieldRect(winW, winH) + local d = pixelRatio() + return ScreenPosition.lift(h * d, Chrome.SCREEN_H * 8 * (scale + or Chrome.fitScale(winW, winH)) * d, ScreenPosition.safeTop() * d) / d +end + +-- pokegold engine/battle/core.asm:8646, engine/events/halloffame.asm:270 +local function clipTo(x, y, w, h) + local G = love.graphics + if G.transformPoint then + local x1, y1 = G.transformPoint(x, y) + local x2, y2 = G.transformPoint(x + w, y + h) + x, y = math.floor(math.min(x1, x2)), math.floor(math.min(y1, y2)) + w, h = math.ceil(math.abs(x2 - x1)), math.ceil(math.abs(y2 - y1)) + end + if G.intersectScissor then G.intersectScissor(x, y, w, h) + else G.setScissor(x, y, w, h) end +end + +Chrome.clipTo = clipTo + +-- Draw the native 20x18 panel without painting over its live surroundings. +-- Its grid is independent of any higher-resolution presentation provider. +function Chrome.withOverlayPanel(winW, winH, drawFn, scale) + local G = love.graphics + scale = scale or Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + G.push("all") + clipTo(ox, oy, Chrome.SCREEN_W * 8 * scale, Chrome.SCREEN_H * 8 * scale) + G.translate(ox, oy) + G.scale(scale, scale) + local ok, err = pcall(drawFn) + G.pop() + if not ok then error(err, 0) end +end + +function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale) + Chrome.letterbox(winW, winH, r, g, b) + return Chrome.withOverlayPanel(winW, winH, drawFn, scale) +end + +function Chrome.withClip(drawFn) + local G = love.graphics + G.push("all") + clipTo(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + local ok, err = pcall(drawFn) + G.pop() + if not ok then error(err, 0) end +end + +Chrome.DEFAULT_BOX_PALETTE = { + { 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, +} + +function Chrome.paletteBox(tx, ty, tw, th, palette) + palette = palette or Chrome.DEFAULT_BOX_PALETTE + if Colors.available() then + local G = love.graphics + G.setColor(1, 1, 1, 1) + -- Colors.with, restoring the caller's shader even if the box throws. + local previous = G.getShader and G.getShader() or nil + Colors.use(palette) + local ok, err = pcall(Font.drawBox, tx, ty, tw, th) + G.setShader(previous) + if not ok then error(err, 0) end + else + Font.drawBox(tx, ty, tw, th) + end + love.graphics.setColor(0, 0, 0, 1) +end + +-- A bordered box, tile coords. Leaves the draw color black for text. +function Chrome.box(tx, ty, tw, th) + Chrome.paletteBox(tx, ty, tw, th) +end + +-- Gold's Textbox helper takes an interior width/height and draws the border +-- around it; b/c in the ASM are interior rows/columns, so a `lb bc, 4, 13` +-- box is 6 rows by 15 columns on screen. +function Chrome.textbox(tx, ty, interiorW, interiorH) + Chrome.box(tx, ty, interiorW + 2, interiorH + 2) +end + +local function flatPrint(text, tx, ty) + love.graphics.setColor(0, 0, 0, 1) + return Font.draw(text, tx * 8, ty * 8) +end + +function Chrome.print(text, tx, ty) + if not Colors.available() then return flatPrint(text, tx, ty) end + return Chrome.printThrough(text, tx, ty, Chrome.DEFAULT_BOX_PALETTE) +end + +-- Prints a string the way a tilemap screen does: the glyph tiles replace the +-- cells they land on, and both their ink and their blank pixels go through +-- that screen's own BG palette. +-- +-- The font sheets here are black ink (shade 3) on transparent, and setColor +-- multiplies -- black times anything is still black -- so the ink colour +-- cannot come from a tint. Drawing through GbcPalette maps shade 3 to the +-- palette's colour 3, and painting colour 0 behind the string first supplies +-- the cell background the sheet does not carry. Without a shader this +-- degrades to the ordinary black print rather than drawing nothing. +-- +-- The palette printThrough actually draws with: COLOR mode resolved, +-- inverted if asked, and the active rBGP byte folded in last -- the same +-- order Colors.use follows. Split out so the fold can be checked +-- without a real shader (Colors.available() is false in a driverless +-- test harness, which used to hide this from every suite). +-- +-- The substitution has to happen before the reversal or an inverted string +-- would come back through an un-reversed grey ramp and print black on white, +-- and the rBGP fold has to come after both: it REORDERS the four entries +-- this palette already settled on (colour 0 in the low bits, so +-- `dc 3,2,1,0` packs to $e4), it does not tint them, so folding it before +-- the invert reversal would permute the wrong four colours. +function Chrome.throughPalette(palette, invert) + return PaletteFX.effectiveColors(palette, invert) +end + +function Chrome.rawPalette(palette, invert) + if invert then return { palette[4], palette[3], palette[2], palette[1] } end + return palette +end + +-- The per-glyph shader/tint switch printThrough needs, factored out so a +-- caller that positions its own glyphs (TextBox's typewriter/scroll +-- animation) gets the same shaded ink without re-deriving the TTF-vs-tile +-- split. +-- +-- Returns (pal, drawGlyph, finish): +-- pal the resolved ramp, or nil if there's no shader to draw +-- through; a caller that also draws a paper rect should skip +-- it and take the plain Chrome.print degrade instead. +-- drawGlyph (code, x, y): draws one glyph, shaded through `pal` for a +-- tile glyph or tinted with pal[4] for a TTF one (see the +-- inline comment below, gen1recomp#1642). +-- finish call once after the last glyph to restore the previous +-- shader and draw colour. +function Chrome.paletteGlyphs(palette, invert, raw) + if not (palette and Colors.available()) then + return nil, Font.drawCode, function() end + end + -- `pal` is used below and never touches the caller's palette again -- + -- including useRaw for the draw, since useRaw skips the fold Colors.use + -- would otherwise apply and folding it a second time would undo this. + local pal = raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) + local ink = pal[4] or { 0, 0, 0 } + local previous = love.graphics.getShader() + local shaded = false + local function drawGlyph(code, x, y) + if code < 0 then + -- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not. + -- LÖVE's font rasterizer stores glyph coverage as alpha over a plain + -- white texture, which this shader reads back as shade 0 no matter how + -- solid the glyph looks, painting the character the SAME colour as the + -- paper rect just drawn above it: invisible (reported against a real + -- Gold build with a TTF translation mod active, gen1recomp#1642). A TTF + -- glyph has no discrete shade to recover in the first place, so skip + -- the shader for it and tint it with the palette's own ink colour + -- (shade 3, the same entry `rgb = pal3` would have mapped a black tile + -- pixel to) directly. + if shaded then love.graphics.setShader(previous); shaded = false end + love.graphics.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1) + elseif not shaded then + love.graphics.setColor(1, 1, 1, 1) + Colors.useRaw(pal) + shaded = true + end + drawGlyphCode(code, x, y, ink) + end + local function finish() + if shaded then love.graphics.setShader(previous) end + love.graphics.setColor(0, 0, 0, 1) + end + return pal, drawGlyph, finish +end + +-- printThrough's resolved palette, or nil when it has to take the plain +-- degrade: paletteGlyphs' own test and fold, without its two closures. +local function glyphPalette(palette, invert, raw) + if not (palette and Colors.available()) then return nil end + return raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) +end + +-- The string's glyph codes and its width, off one encode: the width is the +-- sum of the same advances the pen walks, which is all Font.width is. +local function measure(text) + local codes = Font.encode(text) + local width = 0 + for i = 1, #codes do width = width + Font.advanceOf(codes[i]) end + return codes, width +end + +-- paper rect, then every glyph through `pal` -- paletteGlyphs' drawGlyph and +-- finish inlined (see there for the TTF split, gen1recomp#1642). +local function drawThrough(codes, width, x, y, pal) + local G = love.graphics + local paper = pal[1] or WHITE + local ink = pal[4] or BLACK + local previous = G.getShader() + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + G.rectangle("fill", x, y, width, 8) + local shaded = false + local pen = x + for i = 1, #codes do + local code = codes[i] + if code < 0 then + if shaded then G.setShader(previous); shaded = false end + G.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1) + elseif not shaded then + G.setColor(1, 1, 1, 1) + Colors.useRaw(pal) + shaded = true + end + drawGlyphCode(code, pen, y, ink) + pen = pen + Font.advanceOf(code) + end + if shaded then G.setShader(previous) end + G.setColor(0, 0, 0, 1) +end + +function Chrome.printThrough(text, tx, ty, palette, invert, raw) + local pal = glyphPalette(palette, invert, raw) + if not pal then return Chrome.print(text, tx, ty) end + local codes, width = measure(text) + drawThrough(codes, width, tx * 8, ty * 8, pal) + return width +end + +-- The same, through an *inverted* font page. Pokedex_LoadInvertedFont xors +-- both bitplanes of the standard font, so a glyph pixel of shade s becomes +-- shade 3 - s before the palette sees it -- which is exactly the palette read +-- backwards, and is what makes the dex white on black. +function Chrome.printInverted(text, tx, ty, palette) + if not palette then return Chrome.print(text, tx, ty) end + return Chrome.printThrough(text, tx, ty, palette, true) +end + +-- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is +-- how Gold prints numbers (PrintNum fills from the right). +local function flatPrintRight(text, txEnd, ty) + local width = Font.width(text) + love.graphics.setColor(0, 0, 0, 1) + return Font.draw(text, txEnd * 8 - width, ty * 8) +end + +function Chrome.printRight(text, txEnd, ty) + if not Colors.available() then return flatPrintRight(text, txEnd, ty) end + return Chrome.printRightThrough(text, txEnd, ty, Chrome.DEFAULT_BOX_PALETTE) +end + +function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw) + local pal = glyphPalette(palette, invert, raw) + if not pal then return Chrome.printRight(text, txEnd, ty) end + local codes, width = measure(text) + drawThrough(codes, width, txEnd * 8 - width, ty * 8, pal) + return width +end + +-- Wrap text to `width` tiles, measuring with the real font so a proportional +-- page wraps where it actually overflows. Nothing on a 160px screen may print +-- past tile 20: text that does is drawn outside the GB frame entirely. +-- A "\n" in the text is the cart's OWN line break (data/text's `line` / `next` +-- control byte, e.g. "\nused !"), so it is a HARD break: each +-- segment wraps on its own and the break survives, rather than being eaten by +-- the %S+ tokenizer and re-wrapped by width. +function Chrome.wrap(text, width) + local budget = (width or Chrome.SCREEN_W) * 8 + local lines = {} + for segment in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do + local line = nil + for word in segment:gmatch("%S+") do + local candidate = line and (line .. " " .. word) or word + if line and Font.width(candidate) > budget then + lines[#lines + 1] = line + line = word + else + line = candidate + end + end + if line then lines[#lines + 1] = line end + end + return lines +end + +-- Print wrapped text from (tx, ty) downward, at most `rows` lines. +-- ../pokecrystal/home/text.asm:473 +function Chrome.printWrapped(text, tx, ty, width, rows, step, palette) + step = step or 1 + local lines = Chrome.wrap(text, width) + for i = 1, math.min(#lines, rows or #lines) do + if palette then + Chrome.printThrough(lines[i], tx, ty + (i - 1) * step, palette) + else + Chrome.print(lines[i], tx, ty + (i - 1) * step) + end + end + return #lines +end + +local function flatCursor(tx, ty, hollow) + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8) +end + +function Chrome.cursor(tx, ty, hollow) + if not Colors.available() then return flatCursor(tx, ty, hollow) end + return Chrome.cursorThrough(tx, ty, Chrome.DEFAULT_BOX_PALETTE, nil, hollow) +end + +-- The cursor glyph through a palette, the way Chrome.printThrough draws text. +-- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont +-- xors both bitplanes, so it runs white on black) needs its cursor inverted +-- with everything else -- Chrome.cursor's flat black is invisible against that +-- ground, which is what made the dex's action arrow look absent. +function Chrome.cursorThrough(tx, ty, palette, invert, hollow, raw) + if not (palette and Colors.available()) then + return Chrome.cursor(tx, ty, hollow) + end + local pal = raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) + local paper = pal[1] or { 255, 255, 255 } + love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8) + love.graphics.setColor(1, 1, 1, 1) + local previous = love.graphics.getShader() + Colors.useRaw(pal) + Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8) + love.graphics.setShader(previous) + love.graphics.setColor(0, 0, 0, 1) +end + +-- Gold pads a numeric field with spaces, not zeroes, unless +-- PRINTNUM_LEADINGZEROS is set. +function Chrome.number(value, width, leadingZeros) + local text = tostring(math.floor(value or 0)) + local pad = math.max(0, (width or 0) - #text) + return (leadingZeros and ("0"):rep(pad) or (" "):rep(pad)) .. text +end + +-- PrintNum with PRINTNUM_MONEY and without PRINTNUM_LEADINGZEROS +-- (home/print_num.asm .PrintYen): the ¥ is emitted just before the FIRST +-- significant digit rather than at a fixed column, and the field is six digits +-- wide, so the string is always seven tiles and the yen sign floats. +function Chrome.money(amount) + local digits = ("%06d"):format(math.max(0, math.floor(amount or 0))) + local first = digits:find("[1-9]") or #digits + return (" "):rep(first - 1) .. YEN .. digits:sub(first) +end + +-- ------------------------------------------------------- balance boxes +-- +-- engine/menus/menu_2.asm. These three are the boxes a SCRIPT puts up with a +-- `special` and then leaves standing: every one of them is immediately +-- followed by `loadmenu`, so the box is on screen for the whole of the static +-- menu that answers. They live here rather than in one menu module because +-- three different screens print them (the Game Corner prize counters and the +-- coin vendor through src/ui/gen2/ScriptMenu.lua, the prize counter screen +-- itself) and all three are laid out by the same two routines. +-- +-- DisplayCoinCaseBalance: Textbox at (11,0) with a 7x1 interior, "COIN" at +-- (12,0) -- yes, in the border row -- and the four-digit count at (13,1) with +-- PRINTNUM_LEADINGZEROS. +function Chrome.coinBalanceBox(coins) + Chrome.textbox(11, 0, 7, 1) + Chrome.printThrough("COIN", 12, 0, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 13, 1, Chrome.DEFAULT_BOX_PALETTE) +end + +-- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior +-- holding both fields, "MONEY" at (6,1) with the yen field at (12,1) and +-- "COIN" at (6,3) with the four-digit count at (15,3). +function Chrome.moneyAndCoinBalanceBox(money, coins) + Chrome.textbox(5, 0, 13, 3) + Chrome.printThrough("MONEY", 6, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("COIN", 6, 3, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 15, 3, Chrome.DEFAULT_BOX_PALETTE) +end + +-- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0, +-- SCREEN_WIDTH - 1, 2`, a MenuBox rather than a Textbox, and PlaceMoneyTextbox +-- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1). +function Chrome.moneyBalanceBox(money) + Chrome.box(11, 0, 9, 3) + Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) +end + +-- A vertical cursor list. +-- +-- opts: +-- items array of strings, or of { label = , value = , disabled = } +-- x, y tile coords of the first row's *label* (cursor sits at x - 1) +-- spacing tile rows between entries (Gold uses 2 for most menus) +-- rows visible rows; a longer list scrolls (default: all) +-- wrap wrap past the ends (STATICMENU_WRAP) +-- startAccepts treat START like A (STATICMENU_ENABLE_START) +-- onChoose(value, index) +-- onCancel() +-- onMove(value, index) -- for the start menu's description box +local List = {} +List.__index = List +Chrome.List = List + +function List.new(opts) + local self = setmetatable({}, List) + self.items = {} + for i, entry in ipairs(opts.items or {}) do + if type(entry) == "table" then + self.items[i] = entry + else + self.items[i] = { label = tostring(entry), value = entry } + end + end + self.x = opts.x or 1 + self.y = opts.y or 1 + self.spacing = opts.spacing or 2 + self.rows = math.min(opts.rows or #self.items, #self.items) + self.wrap = opts.wrap ~= false + self.startAccepts = opts.startAccepts or false + self.onChoose = opts.onChoose + self.onCancel = opts.onCancel + self.onMove = opts.onMove + self.palette = opts.palette == nil and Chrome.DEFAULT_BOX_PALETTE or opts.palette + self.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items))) + self.scroll = 0 + self:ensureVisible() + return self +end + +function List:current() + return self.items[self.index] +end + +function List:ensureVisible() + if self.rows <= 0 then return end + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + self.rows then + self.scroll = self.index - self.rows + end + self.scroll = math.max(0, math.min(self.scroll, #self.items - self.rows)) +end + +function List:move(delta) + if #self.items == 0 then return end + local next_ = self.index + delta + if next_ < 1 then + if not self.wrap then return end + next_ = #self.items + elseif next_ > #self.items then + if not self.wrap then return end + next_ = 1 + end + self.index = next_ + self:ensureVisible() + if self.onMove then self.onMove(self:current() and self:current().value, self.index) end +end + +-- Returns true when the press was consumed, so a screen can layer its own +-- handling (left/right on the options rows) behind this. +function List:update(input) + if not input then return false end + if input:wasPressed("up") then + self:move(-1) + return true + elseif input:wasPressed("down") then + self:move(1) + return true + elseif input:wasPressed("a") + or (self.startAccepts and input:wasPressed("start")) then + local item = self:current() + if item and not item.disabled and self.onChoose then + self.onChoose(item.value, self.index) + end + return true + elseif input:wasPressed("b") then + if self.onCancel then self.onCancel() end + return true + end + return false +end + +function List:draw() + for row = 1, self.rows do + local i = row + self.scroll + local item = self.items[i] + if item then + local ty = self.y + (row - 1) * self.spacing + if i == self.index then + if self.palette then + Chrome.cursorThrough(self.x - 1, ty, self.palette) + else + flatCursor(self.x - 1, ty) + end + end + if self.palette then + Chrome.printThrough(item.label, self.x, ty, self.palette) + else + flatPrint(item.label, self.x, ty) + end + end + end + -- Scrolling lists get the ▼ hint Gold shows when there is more below. + if self.rows < #self.items and self.scroll + self.rows < #self.items then + if self.palette then + local _, drawGlyph, finish = Chrome.paletteGlyphs(self.palette) + drawGlyph(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + finish() + else + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + end + end +end + +return Chrome diff --git a/src/ui/Gen2DecorationMenu.lua b/src/ui/Gen2DecorationMenu.lua new file mode 100644 index 000000000..237de04a0 --- /dev/null +++ b/src/ui/Gen2DecorationMenu.lua @@ -0,0 +1,326 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- The bedroom PC's DECORATION option: _PlayerDecorationMenu +-- (engine/overworld/decorations.asm), reached from PLAYERSPCITEM_DECORATION in +-- the PLAYERSPC_HOUSE list (engine/events/pokecenter_pc.asm). +-- +-- Two menus stacked, plus one question: +-- +-- .MenuHeader the categories the player owns something in, at +-- menu_coords 5, 0, 19, 17, with EXIT always last +-- .NonscrollingMenuHeader / .ScrollingMenuHeader +-- that category's decorations, then its PUT IT AWAY +-- row, then CANCEL. The cart swaps to the scrolling +-- menu past eight rows; one list that scrolls covers +-- both, since the rows are identical either way +-- DecoSideMenuHeader RIGHT SIDE / LEFT SIDE / CANCEL, for the two +-- ornament slots +-- +-- Nothing here touches the map. wChangedDecorations is carried back through +-- onDone so the PC can answer TRUE to `special PlayersHousePC`, whose script +-- then does `warp NONE, 0, 0` -- the map reload that runs the two callbacks +-- and is the only thing that makes a placement visible. + +local Native = require("src.world.Gen2Decorations") +local Runner = require("src.script.ScriptRunner") +local Menu = require("src.ui.Menu") +local Chrome = require("src.ui.Gen2Chrome") +local Decorations = require("src.compat.gen2.Decorations") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") + +local DecorationMenu = {} +DecorationMenu.__index = DecorationMenu +-- ../pokecrystal/engine/overworld/decorations.asm:8 +DecorationMenu.isOpaque = false + +-- .category_pointers' eighth row, which is not a category and is always shown. +local EXIT = Strings.source("EXIT") +-- DecoSideMenuHeader's three items, in its order. +local SIDES = { + { id = "right", label = Strings.source("RIGHT SIDE") }, + { id = "left", label = Strings.source("LEFT SIDE") }, + { id = nil, label = Strings.source("CANCEL") }, +} + +-- The scrolling list shows eight rows (.ScrollingMenuData's `db 8, 0`). +local VISIBLE = 8 + +function DecorationMenu:wantsFillScale() return true end + +-- opts: save, events (the wEventFlags bitfield), onDone(changed) +function DecorationMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, DecorationMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.events = opts.events or Decorations.events(self.save) + self.onDone = opts.onDone or function() game.stack:pop() end + self.state = Native.state(game and game.data, self.save) + -- wChangedDecorations, cleared once on the way in. + self.changed = false + self.mode = "category" + self.index = 1 + self.scroll = 0 + self.pages = nil + self:buildCategories() + return self +end + +function DecorationMenu:buildCategories() + self.categories = {} + for _, category in ipairs(Native.categories(self.game.data)) do + for _, id in ipairs(category.members) do + if Native.owns(self.game.data, self.save, id) then + self.categories[#self.categories+1] = category;break + end + end + end + self.index = math.min(self.index, #self.categories + 1) +end + +function DecorationMenu:finish() + if self.done then return end + self.done = true + if self.onDone then self.onDone(self.changed) end +end + +-- The name of one row of the item list. Row 0 is CANCEL and the category's +-- own row is PUT IT AWAY; both are plain DecorationAttributes rows, so they +-- spell themselves. +function DecorationMenu:rowName(decoId) + return Native.name(self.game.data, decoId) +end + +function DecorationMenu:openCategory(category) + local rows = {} + for _,id in ipairs(category.members or {}) do + if Native.owns(self.game.data, self.save, id) then rows[#rows+1]=id end + end + if #rows>0 then + for _,id in ipairs(category.headers or {}) do rows[#rows+1]=id end + rows[#rows+1]=0 + end + if #rows == 0 then + -- PopulateDecoCategoryMenu .empty. Cannot happen from the filtered + -- category list, but the routine is reachable from a driver that sets a + -- flag by hand, and the cart answers rather than opening an empty menu. + self:say(Native.feedbackPages(self.game.data,{role="noChoice",buffers={}})) + return + end + self.category = category + self.rows = rows + self.mode = "items" + self.index = 1 + self.scroll = 0 +end + +function DecorationMenu:say(pages) + if not pages or #pages == 0 then return end + self.pages = pages + self.pageIndex = 1 + -- ../pokecrystal/home/menu.asm:328 MenuTextboxBackup + self.typer = Typer.new(self.game) + self.typer:start(pages[1]) +end + +-- MenuTextboxBackup returns to whatever menu was underneath, so a message is +-- not a mode of its own: it sits over the list it was printed from. +function DecorationMenu:advanceMessage() + self.pageIndex = self.pageIndex + 1 + if self.pageIndex <= #self.pages then + if self.typer then self.typer:start(self.pages[self.pageIndex]) end + return + end + self.pages = nil + self.typer = nil +end + +-- One row of the item list chosen: DoDecorationAction2. An ornament row asks +-- which side first, and only then applies. +function DecorationMenu:chooseRow(decoId) + local attr = Native.attributes(self.game.data, decoId) + local action = attr and attr.action and Native.action(attr.action) + if not action then + -- DecoAction_nothing sets carry, which drops the player back to the + -- category list without a word. + self.mode = "category" + self:buildCategories() + return + end + if action.ornament then + local selected, question + local function close(side) + selected = side + if question then self.game.stack:pop();question:resume() + elseif side then self:applyRow(decoId, side) end + end + local function sides() + local rows={} + for _,side in ipairs(SIDES) do + local value=side.id + rows[#rows+1]={label=Strings(side.label),onSelect=function()close(value)end} + end + self.game.stack:push(Menu.new(self.game,rows,{tx=6,ty=6,tw=14,th=8, + noSound=true,onCancel=function()close()end})) + end + local programs=Native.pool(self.game.data) + local texts=programs and programs.decorationPrograms and programs.decorationPrograms.texts or {} + local id=texts[action.put and 'sidePut' or 'sideSetup'] + if id then + question=Runner.new(self.game,self.game.overworld) + question:run({{'show_text',id,nil,{stay={onShown=sides}}}}, {onDone=function() + if selected then self:applyRow(decoId,selected) end + end}) + else sides() end + return + end + self:applyRow(decoId, nil) +end + +function DecorationMenu:applyRow(decoId, side) + local row=Native.attributes(self.game.data,decoId) + local action=row and Native.action(row.action) + if not action or (not action.put and not Native.owns(self.game.data,self.save,decoId)) then return end + local placement=Native.placement(self.game.data,self.state,decoId,side) + if not placement then return end + self.game.stringBuffers=self.game.stringBuffers or {} + for n,name in pairs(placement.buffers) do self.game.stringBuffers[n]=name end + local function commit() + self.changed=Native.choose(self.game.data,self.save,decoId,side) or self.changed + end + local programs=Native.pool(self.game.data) + local texts=programs and programs.decorationPrograms and programs.decorationPrograms.texts or {} + if texts[placement.role] then + local runner=Runner.new(self.game,self.game.overworld) + runner:run({{'show_text',texts[placement.role]}},{onDone=commit}) + else commit() end +end + +function DecorationMenu:update(_dt) + local input = self.game and self.game.input + if not (input and not self.done) then return end + + if self.pages then + Typer.step(self) + if Typer.typing(self) then return end + if input:wasPressed("a") or input:wasPressed("b") then + self:advanceMessage() + end + return + end + + if self.mode == "side" then + if input:wasPressed("up") then + self.sideIndex = self.sideIndex > 1 and self.sideIndex - 1 or #SIDES + elseif input:wasPressed("down") then + self.sideIndex = self.sideIndex < #SIDES and self.sideIndex + 1 or 1 + elseif input:wasPressed("a") then + local pick = SIDES[self.sideIndex] + self.mode = "items" + -- .nope: CANCEL and B are the same arm, and neither prints anything. + self:applyRow(self.pendingDeco, pick and pick.id) + self.pendingDeco = nil + elseif input:wasPressed("b") then + self.mode = "items" + self.pendingDeco = nil + end + return + end + + if self.mode == "items" then + local total = #self.rows + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + elseif input:wasPressed("a") then + self:chooseRow(self.rows[self.index]) + elseif input:wasPressed("b") then + self.mode = "category" + self:buildCategories() + end + self.scroll = math.max(0, + math.min(self.scroll, math.max(0, total - VISIBLE))) + if self.index - 1 < self.scroll then self.scroll = self.index - 1 end + if self.index > self.scroll + VISIBLE then + self.scroll = self.index - VISIBLE + end + return + end + + local total = #self.categories + 1 + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + elseif input:wasPressed("a") then + local category = self.categories[self.index] + if category then + self:openCategory(category) + else + self:finish() -- DecoExitMenu: `scf`, which leaves the whole menu + end + elseif input:wasPressed("b") then + self:finish() + end +end + +function DecorationMenu:drawList(x, y, w, h, labels, index, scroll) + Chrome.box(x, y, w, h) + -- GetMenuTextStartCoord: the first label sits one row inside the border and + -- one more down for STATICMENU_CURSOR, and rows are two apart -- the same + -- spacing every other Gold list menu in this port draws with. + for row = 1, math.min(#labels - scroll, VISIBLE) do + local i = row + scroll + local ty = y + row * 2 + if i == index then Chrome.cursor(x + 1, ty) end + Chrome.print(labels[i], x + 2, ty) + end +end + +function DecorationMenu:drawPanel() + if self.mode == "category" then + local labels = {} + for i, category in ipairs(self.categories) do labels[i] = category.label end + labels[#labels + 1] = Strings(EXIT) + -- menu_coords 5, 0, 19, 17: the list hugs the right edge and the room + -- stays visible down the left, which is the whole point of the offset. + self:drawList(5, 0, 15, 18, labels, self.index, 0) + elseif self.mode == "items" then + local labels = {} + for i, decoId in ipairs(self.rows) do labels[i] = self:rowName(decoId) end + self:drawList(0, 0, 20, 18, labels, self.index, self.scroll) + end + + if self.mode == "side" then + local labels = {} + for i, side in ipairs(SIDES) do labels[i] = Strings(side.label) end + -- menu_coords 0, 0, 12, 7 + self:drawList(0, 0, 13, 8, labels, self.sideIndex, 0) + end + + if self.pages then + Chrome.box(0, 12, 20, 6) + local line = 14 + local shown = self.typer and self.typer:lines() + if not shown then + shown = {} + for part in ((self.pages[self.pageIndex] or "") .. "\n"):gmatch("(.-)\n") do + shown[#shown + 1] = part + end + end + for _, part in ipairs(shown) do + Chrome.print(part, 1, line) + line = line + 2 + end + end + + love.graphics.setColor(1, 1, 1, 1) +end + +function DecorationMenu:draw() + self:drawPanel() +end + +return DecorationMenu diff --git a/src/ui/Gen2ForgetMoveList.lua b/src/ui/Gen2ForgetMoveList.lua new file mode 100644 index 000000000..79dfa770d --- /dev/null +++ b/src/ui/Gen2ForgetMoveList.lua @@ -0,0 +1,44 @@ +-- Adapted from Gen1Recomp 535a5ec0 native Gen2 ForgetMoveList. +-- Existing project rights/licences remain applicable. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- engine/pokemon/learn.asm:135-166 + +local Chrome = require("src.ui.Gen2Chrome") + +local ForgetMoveList = { + x = 5, y = 2, interiorW = 13, interiorH = 8, + cursorX = 6, nameX = 7, y0 = 4, step = 2, rows = 4, +} + +function ForgetMoveList.rowY(slot) + return ForgetMoveList.y0 + (slot - 1) * ForgetMoveList.step +end + +function ForgetMoveList.label(entry, moveData) + if not entry then return "-" end + local def = moveData and moveData[entry.id] + return (def and def.name) or entry.id +end + +function ForgetMoveList.draw(moves, cursor, moveData, palette) + Chrome.textbox(ForgetMoveList.x, ForgetMoveList.y, + ForgetMoveList.interiorW, ForgetMoveList.interiorH) + for slot = 1, ForgetMoveList.rows do + local ty = ForgetMoveList.rowY(slot) + local label = ForgetMoveList.label(moves and moves[slot], moveData) + if palette then + Chrome.printThrough(label, ForgetMoveList.nameX, ty, palette) + else + Chrome.print(label, ForgetMoveList.nameX, ty) + end + if slot == cursor then + if palette then + Chrome.cursorThrough(ForgetMoveList.cursorX, ty, palette) + else + Chrome.cursor(ForgetMoveList.cursorX, ty) + end + end + end +end + +return ForgetMoveList diff --git a/src/ui/Gen2InitClock.lua b/src/ui/Gen2InitClock.lua new file mode 100644 index 000000000..bf37c8436 --- /dev/null +++ b/src/ui/Gen2InitClock.lua @@ -0,0 +1,185 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +-- Native clock/day selection over the shared saved RTC and native widgets. +-- Dialogue comes from the active ROM cache; this layout is engine-owned. +local Clock = require("src.core.Gen2Clock") +local Font = require("src.render.Font") +local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") +local InitClock = {} +InitClock.__index = InitClock +InitClock.isOpaque = true +local TEXT = { + intro=Strings.source("SET THE CLOCK"), hour=Strings.source("CHOOSE HOUR"), + minute=Strings.source("CHOOSE MINUTE"), day=Strings.source("CHOOSE DAY"), + confirmHour=Strings.source("CONFIRM HOUR"), confirmMinute=Strings.source("CONFIRM MINUTE"), + questionMark=Strings.source("?"), response=Strings.source("\nCLOCK SET"), +} +local function value(n, fallback, limit) + n = tonumber(n) + if not n or n ~= n or n == math.huge or n == -math.huge then return fallback end + return math.floor(n) % limit +end +local function text(self, key, fallback) + local bank = self.game and self.game.data and self.game.data.text or {} + local found = bank[key] or bank["_" .. key:match("[^.]+$")] + if type(found) == "string" and found ~= "" then return found end + return Strings(fallback) +end +function InitClock.hourString(hour) + hour = value(hour, 0, 24) + local shown = hour % 12 + return ("%s %d"):format(Clock.daytimeLabel(hour), shown == 0 and 12 or shown) +end +function InitClock.oclockString(hour) return Strings("%s o'clock", InitClock.hourString(hour)) end +function InitClock.timeString(hour, minute) + return ("%s:%02d"):format(InitClock.hourString(hour), value(minute, 0, 60)) +end +function InitClock.responseKey(hour) + hour = value(hour, 0, 24) + if hour < 4 or hour >= 18 then return "soDark" end + return hour <= 10 and "overslept" or "yikes" +end +function InitClock.new(game, opts) + opts = opts or {} + local day = opts.mode == "day" + return setmetatable({ game=game, save=opts.save or game.save, + mode=day and "day" or "clock", phase=day and "day" or "intro", + isOpaque=not day, onDone=opts.onDone, autoConfirm=opts.autoConfirm == true, + fades=opts.fades == true and not day and not opts.autoConfirm, + hour=value(opts.hour, Clock.DEFAULT_HOUR, 24), + minute=value(opts.minute, Clock.DEFAULT_MINUTE, 60), day=value(opts.day, 0, 7), + }, InitClock) +end +function InitClock:wantsFillScale() return self.mode ~= "day" end +function InitClock:question() + if self.phase == "intro" then return text(self, "OakTimeWokeUpText", TEXT.intro) end + if self.phase == "hour" then return text(self, "OakTimeWhatTimeIsItText", TEXT.hour) end + if self.phase == "minute" then return text(self, "OakTimeHowManyMinutesText", TEXT.minute) end + if self.phase == "day" then return text(self, "SetDayOfWeek.OakTimeWhatDayIsItText", TEXT.day) end + if self.phase == "confirm-hour" then + local lines = { text(self, "OakTimeWhatHoursText", TEXT.confirmHour), + InitClock.oclockString(self.hour) + .. text(self, "OakTimeWhatHoursText.OakTimeHoursQuestionMarkText", TEXT.questionMark) } + return table.concat(lines, "\n") + end + if self.phase == "confirm-minute" then + local lines = { text(self, "OakTimeWhoaMinutesText", TEXT.confirmMinute), + Strings("%d min.", self.minute) + .. text(self, "OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText", TEXT.questionMark) } + return table.concat(lines, "\n") + end + if self.phase == "confirm-day" then + return Clock.weekdayName(self.day + 1) + .. text(self, "SetDayOfWeek.OakTimeIsItText", TEXT.questionMark) + end + local response = { soDark="OakTimeSoDarkText", overslept="OakTimeOversleptText", yikes="OakTimeYikesText" } + if self.phase == "response" then + return InitClock.timeString(self.hour, self.minute) + .. text(self, "OakText_ResponseToSetTime." .. response[InitClock.responseKey(self.hour)], TEXT.response) + end + return "" +end +function InitClock:display() + if self.phase == "hour" then return InitClock.oclockString(self.hour) end + if self.phase == "minute" then return Strings("%d min.", self.minute) end + if self.phase == "day" then return Clock.weekdayName(self.day + 1) end +end +function InitClock:confirming() return self.phase:match("^confirm%-") ~= nil end +function InitClock:showPhase() + if self.autoConfirm or self.done then return end + if self.phase == "intro" or self.phase == "response" then + self.game.stack:push(TextBox.new(self.game, self:question(), function() self:accept() end)) + elseif self:confirming() then + self.game.stack:push(TextBox.new(self.game, self:question(), nil, { + choice=function(yes) if yes then self:accept() else self:decline() end end, + })) + end +end +function InitClock:enter() + if self.fades then self.fade = { out=false, frame=0 } else self:showPhase() end +end +function InitClock:complete() + if self.done then return end + self.done = true + if self.onDone then + if self.mode == "day" then self.onDone(self.day) else self.onDone(self.hour, self.minute) end + elseif self.game.stack:top() == self then + self.game.stack:pop() + end +end +function InitClock:finish() + if self.done or self.finishing then return end + local ok + if self.mode == "day" then ok = Clock.setWeekday(self.save, self.day) + else ok = Clock.setTime(self.save, self.hour, self.minute) end + if not ok then return end + self.finishing = true + if self.fades then self.fade = { out=true, frame=0 } else self:complete() end +end +function InitClock:accept() + if self.done or self.finishing then return end + local nextPhase = { intro="hour", hour="confirm-hour", ["confirm-hour"]="minute", + minute="confirm-minute", ["confirm-minute"]="response", day="confirm-day" } + local next = nextPhase[self.phase] + if next then self.phase=next;self:showPhase() else self:finish() end +end +function InitClock:decline() + if self.done or self.finishing then return end + local picker = self.phase:match("^confirm%-(.+)$") + if picker then self.phase=picker;self:showPhase() end +end +function InitClock:value() + if self.phase == "hour" then return self.hour, 23 end + if self.phase == "minute" then return self.minute, 59 end + return self.day, 6 +end +function InitClock:step(delta) + local current, last = self:value() + local key = self.phase + if key == "hour" or key == "minute" or key == "day" then self[key]=(current+delta)%(last+1) end +end +function InitClock:update() + if self.done then return end + if self.fade then + local fade = self.fade + fade.frame = fade.frame + 1 + if fade.frame >= 8 then + self.fade = nil + if fade.out then self:complete() else self:showPhase() end + end + return + end + if self.finishing then return end + if self.autoConfirm then self:accept();return end + local input = self.game.input + if input:wasPressed("up") then self:step(1) + elseif input:wasPressed("down") then self:step(-1) + elseif input:wasPressed("a") then self:accept() end +end +function InitClock:sgbPalettes() + local P = require("src.render.PaletteFX") + if self.isOpaque then return { P.trueColorZone(0, 0, 19, 17) } end + return { P.trueColorZone(1, 4, 18, 11), P.trueColorZone(0, 12, 19, 17) } +end +function InitClock:draw() + local G = love.graphics + G.setColor(1, 1, 1, 1) + if self.isOpaque then G.rectangle("fill", 0, 0, 160, 144) end + Font.drawBox(1, 4, 18, 8) + local picker = self:display() + local shown = picker or InitClock.timeString(self.hour, self.minute) + Font.draw(shown, 16, 48) + if picker then + Font.draw(Strings("UP/DOWN: CHANGE"), 16, 64) + Font.draw(Strings("A: CONFIRM"), 16, 80) + Font.drawBox(0, 12, 20, 6) + Font.draw(self:question(), 8, 105) + end + if self.fade then + local alpha = self.fade.frame / 8 + G.setColor(0, 0, 0, self.fade.out and alpha or 1-alpha) + G.rectangle("fill", 0, 0, 160, 144) + end + G.setColor(1, 1, 1, 1) +end +return InitClock diff --git a/src/ui/Gen2ItemPcMenu.lua b/src/ui/Gen2ItemPcMenu.lua new file mode 100644 index 000000000..3a7bec076 --- /dev/null +++ b/src/ui/Gen2ItemPcMenu.lua @@ -0,0 +1,754 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- The player's item PC: _PlayersPC and the three item rows behind it +-- (engine/events/pokecenter_pc.asm PlayerWithdrawItemMenu / +-- PlayerDepositItemMenu / PlayerTossItemMenu), plus TossItemFromPC +-- (engine/pokemon/mon_menu.asm). Both of the cart's callers land here: +-- +-- PlayersPC PLAYERSPC_NORMAL -- the 's PC row of the +-- Pokecenter's whose-PC menu (src/ui/gen2/CenterPcMenu.lua): +-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / MAIL BOX / +-- LOG OFF +-- _PlayersHousePC PLAYERSPC_HOUSE -- the bedroom PC's whole screen: the +-- boot sound and PlayersPCTurnOnText first, DECORATION on +-- the list, TURN OFF instead of LOG OFF, and the answer +-- carried back out (TRUE only when a decoration moved) so +-- PlayersHousePCScript can take its `.Warp` arm +-- +-- The menu's rows run through the ui.pc.items hook -- the same name and the +-- same (game, items) payload the Gen 1 PC uses +-- (src/world/OverworldController.lua openPC) -- with the exit row appended +-- after it, the way that site appends LOG OFF. +-- +-- Items live on save.pcItems, the id -> count map ReceiveItem's PC half +-- already writes (src/core/gen2/MomShopping.lua receiveItemToPc): fifty +-- distinct stacks of at most 99, like wPCItems. DEPOSIT opens the PACK as a +-- chooser held by this screen, exactly the arrangement the mart's sell flow +-- uses (src/ui/gen2/MartMenu.lua enterSell), because DepositSellPack is the +-- same routine on the cart. + +local Bag = require("src.inventory.Bag") +local Chrome = require("src.ui.Gen2Chrome") +local Logger = require("src.core.Logger") +local PcItems = require("src.inventory.Gen2PcItems") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") +local WaitPlaySFX = require("src.ui.Gen2WaitPlaySFX") + +local ItemPcMenu = {} +ItemPcMenu.__index = ItemPcMenu +-- ../pokecrystal/engine/events/pokecenter_pc.asm:231 +ItemPcMenu.isOpaque = false + +-- ../pokecrystal/engine/events/pokecenter_pc.asm:330 +local CLEARS_SCREEN = { withdraw = true, deposit = true, toss = true } + +-- MAX_PC_ITEMS stacks of at most MAX_ITEM_STACK (constants/item_constants.asm), +-- the same pair src/core/gen2/MomShopping.lua enforces for Mom's deliveries. +local PC_ITEM_CAPACITY = 50 +local MAX_STACK = 99 + +local function stacksFor(n) return math.ceil((n or 0) / MAX_STACK) end + +-- PlayersPCMenuData .PlayersPCMenuPointers strings, verbatim. .WhichPC picks +-- which rows a caller sees: PLAYERSPC_NORMAL ends on LOG OFF, PLAYERSPC_HOUSE +-- carries DECORATION and ends on TURN OFF. +local ENTRIES = { + { id = "withdraw", label = Strings.source("WITHDRAW ITEM"), builtin = true }, + { id = "deposit", label = Strings.source("DEPOSIT ITEM"), builtin = true }, + { id = "toss", label = Strings.source("TOSS ITEM"), builtin = true }, + { id = "mailbox", label = Strings.source("MAIL BOX"), builtin = true }, +} +local LOG_OFF = { + id = "logoff", label = Strings.source("LOG OFF"), builtin = true, +} +local DECORATION = { + id = "decoration", label = Strings.source("DECORATION"), builtin = true, +} +local TURN_OFF = { + id = "turnoff", label = Strings.source("TURN OFF"), builtin = true, +} + +-- _PlayersPC's player-facing text. Dynamic item names are format arguments, +-- not catalog keys, so registry-provided names remain untouched while a +-- language may reorder the quantity/name around them. +local TEXT = { + turnedOn = Strings.source("{PLAYER} turned on\nthe PC."), + noBagRoom = Strings.source("There's no room\nfor more items."), + withdrew = Strings.source("Withdrew %d\n%s(S)."), + withdrawHowMany = Strings.source("How many do you\nwant to withdraw?"), + noPcRoom = Strings.source("There's no room to\nstore items."), + deposited = Strings.source("Deposited %d\n%s(S)."), + noItems = Strings.source("No items here!"), + depositHowMany = Strings.source("How many do you\nwant to deposit?"), + tooImportant = Strings.source("That's too impor-\ntant to toss out!"), + tossHowMany = Strings.source("Toss out how many\n%s(S)?"), + throwAway = Strings.source("Throw away %d\n%s(S)?"), + discarded = Strings.source("Discarded\n%s(S)."), + whatDo = Strings.source("What do you want\nto do?"), +} + +local function translatedLines(source, ...) + local lines = {} + for line in (Strings(source, ...) .. "\n"):gmatch("(.-)\n") do + lines[#lines + 1] = line + end + return lines +end + +local function translatedPages(source, ...) + return { translatedLines(source, ...) } +end + +-- ui.pc.items identity: an unhooked build hands its own list back. +local function sameItems(_, items) return items end + +-- PCItemsJoypad's ScrollingMenu is `db 4, 8 ; rows, columns`. +local VISIBLE_ROWS = 4 + +-- .PCItemsMenuData is menu_coords 4, 1 (engine/events/pokecenter_pc.asm:641), +-- and ScrollingMenu_UpdateDisplay (engine/menus/scrolling_menu.asm:359) adds +local LIST_X = 5 + +-- charmap.asm: the quantity glyph. +local TIMES = "\xc3\x97" + +function ItemPcMenu:wantsFillScale() return true end + +function ItemPcMenu:setPhase(phase) + self.phase = phase + self.isOpaque = CLEARS_SCREEN[phase] or false +end + +-- opts: save, items (items.lua), house (PLAYERSPC_HOUSE: boot text, +-- DECORATION row, TURN OFF), events (wEventFlags, for the decoration +-- menu), onClose(changedDecorations) +function ItemPcMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, ItemPcMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.items = opts.items or (game and game.data and game.data.items) + self.data = game and game.data + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + self.house = opts.house and true or false + self.events = opts.events + -- wChangedDecorations, carried out so `special PlayersHousePC` can answer + -- TRUE and PlayersHousePCScript can take its `.Warp` arm. + self.changedDecorations = false + if self.save then self.save.pcItems = self.save.pcItems or {} end + local entries = {} + for i, entry in ipairs(ENTRIES) do entries[i] = entry end + if self.house then entries[#entries + 1] = DECORATION end + -- Same hook name and same (game, items) payload as the Gen 1 PC menu + -- (src/world/OverworldController.lua openPC), so one mod source can add a + -- row to both generations' PCs. Unguarded, like that site: the list is + -- built once per session, not per frame. A hook that answers with anything + -- but a table is degraded to the vanilla list. + local hooked = Runtime.call("ui.pc.items", sameItems, game, entries) + if type(hooked) == "table" then + entries = hooked + else + Logger.error("ui.pc.items returned %s; keeping the vanilla items", + type(hooked)) + end + -- LOG OFF / TURN OFF is appended AFTER the hook, exactly as the Gen 1 site + -- appends LOG OFF: a mod cannot orphan the way out of the PC. + entries[#entries + 1] = self.house and TURN_OFF or LOG_OFF + self.entries = entries + self.index = 1 + self:setPhase("menu") + self.rows = {} + self.listIndex = 1 + self.scroll = 0 + self.message = nil + self.qtyState = nil + self.confirm = nil + if self.house then + -- _PlayersHousePC: PC_PlayBootSound, then PlayersPCTurnOnText. + self:playPcSfx("Sfx_BootPc") + self:say(translatedPages(TEXT.turnedOn)) + end + return self +end + +function ItemPcMenu:playSfx(name) + local data = self.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then + Sound.play(data, name) + end +end + +-- engine/events/pokecenter_pc.asm:200 +function ItemPcMenu:playPcSfx(name) + self:playSfx(name) +end + +-- engine/events/pokecenter_pc.asm:195 +function ItemPcMenu:playPcSfxTwice(name) + self:playPcSfx(name) + self.repeatSfx = WaitPlaySFX.arm(name) +end + +function ItemPcMenu:tickRepeatSfx() + local pending = self.repeatSfx + if not pending then return false end + if WaitPlaySFX.waiting(pending, self.game) then return true end + self.repeatSfx = nil + self:playPcSfx(pending.name) + return false +end + +function ItemPcMenu:playerName() + local player = self.save and self.save.player + return (player and player.name) or "GOLD" +end + +-- A queue of text pages, each a list of lines; A or B turns them, and the last +-- one runs onDone. The item PC's messages never log off by themselves, which +-- is _PlayersPC's `.loop`: a refusal drops back into the same menu. +function ItemPcMenu:say(pages, onDone) + Typer.say(self, pages, onDone, { expand = function(line) + return (line:gsub("{PLAYER}", self:playerName())) + end }) +end + +function ItemPcMenu:close() + if self.closed then return end + self.closed = true + -- _PlayersHousePC plays PC_PlayShutdownSound only on the unchanged arm; + -- `.changed_deco_tiles` leaves for the map reload without it. + if self.house and not self.changedDecorations then + self:playPcSfx("Sfx_ShutDownPc") + end + if self.onClose then self.onClose(self.changedDecorations) end +end + +-- ---------------------------------------------------------------- the items + +function ItemPcMenu:def(id) + return self.items and self.items[id] +end + +-- _CheckTossableItem: KEY ITEMs and HMs answer non-zero. The extractor +-- carries that as `canToss = false`; an id with no attributes row counts as +-- tossable, the way the PACK's sell gate treats it. +function ItemPcMenu:cantToss(id) + local def = self:def(id) + return def ~= nil and (def.canToss == false or def.cantToss == true or def.keyItem == true + or (def.machine and def.machine.kind == "HM")) +end + +-- engine/events/pokecenter_pc.asm:647 +function ItemPcMenu:rebuild() + local pc = (self.save and self.save.pcItems) or {} + local order = self.save and PcItems.order(self.save, self.items) or {} + local rows = {} + for slot = 1, #order do + local id = order[slot] + local count = pc[id] or 0 + if count > 0 then + local def = self:def(id) + local remaining = count + while remaining > 0 do + local n = math.min(remaining, MAX_STACK) + rows[#rows + 1] = { + id = id, count = n, + name = (def and def.name) or id, + slot = slot, + } + remaining = remaining - n + end + end + end + self.rows = rows + if self.listIndex > #rows + 1 then self.listIndex = #rows + 1 end + if self.listIndex < 1 then self.listIndex = 1 end + self:ensureVisible() +end + +function ItemPcMenu:listTotal() + return #self.rows + 1 -- CANCEL +end + +function ItemPcMenu:ensureVisible() + if self.listIndex <= self.scroll then + self.scroll = self.listIndex - 1 + elseif self.listIndex > self.scroll + VISIBLE_ROWS then + self.scroll = self.listIndex - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:listTotal() - VISIBLE_ROWS))) +end + +-- ReceiveItem over wPCItems: the add tops up every existing stack of that id +-- and spills the rest into a new one, so it only needs a free stack when the +-- room in place is short. False is the no-carry the deposit turns into +-- _PlayersPCNoRoomDepositText. +-- engine/items/items.asm:156 PutItemInPocket +function ItemPcMenu:pcAdd(id, qty) + local pc = self.save.pcItems + local held = pc[id] or 0 + local used = 0 + for _, count in pairs(pc) do used = used + stacksFor(count) end + local need = stacksFor(held + qty) - stacksFor(held) + if used + need > ((self.data and self.data.field and self.data.field.pcItemCap) or PC_ITEM_CAPACITY) then return false end + pc[id] = held + qty + return true +end + +function ItemPcMenu:pcRemove(id, qty) + local pc = self.save.pcItems + local held = (pc[id] or 0) - qty + pc[id] = held > 0 and held or nil +end + +-- HasNoItems (engine/pokemon/mon_menu.asm): every pocket, TM/HMs included -- +-- which the flat inventory answers in one walk. Badges share the table but +-- are not bag items. +local function bagIsEmpty(save) + for id, count in pairs((save and save.inventory) or {}) do + if (count or 0) > 0 and not Bag.isBadge(id) then return false end + end + return true +end + +-- BuySellToss_InterpretJoypad (engine/items/buy_sell_toss.asm): up and down +-- wrap through the ends, left and right step by ten and clamp. +local function qtyStep(qty, max, delta) + local n = qty + delta + if delta == 1 then + if n > max then n = 1 end + elseif delta == -1 then + if n < 1 then n = max end + elseif delta > 0 then + if n > max then n = max end + else + if n <= 0 then n = 1 end + end + return n +end + +-- prompt is the two lines under the selector; onAccept(qty) commits. +function ItemPcMenu:askQuantity(max, prompt, onAccept) + self.qtyState = { qty = 1, max = max, prompt = prompt, onAccept = onAccept } +end + +-- ------------------------------------------------------------ the three rows + +function ItemPcMenu:withdraw(row, qty) + -- PlayerWithdrawItemMenu .withdraw: ReceiveItem into the bag first; only a + -- carry tosses the stack out of the PC. + if not Bag.add(self.save, row.id, qty, self.data) then + self:say(translatedPages(TEXT.noBagRoom)) + return + end + self:pcRemove(row.id, qty) + self:rebuild() + self:say(translatedPages(TEXT.withdrew, qty, row.name)) +end + +function ItemPcMenu:chooseWithdraw() + local row = self.rows[self.listIndex] + if not row then + self:setPhase("menu") + return + end + -- .Submenu: an item without a quantity attribute (a KEY ITEM in the PC) is + -- always x1; everything else asks _PlayersPCHowManyWithdrawText. + if self:cantToss(row.id) then + self:withdraw(row, 1) + return + end + self:askQuantity(row.count, + translatedLines(TEXT.withdrawHowMany), + function(qty) self:withdraw(row, qty) end) +end + +function ItemPcMenu:deposit(id, name, qty) + if not self:pcAdd(id, qty) then + self:say(translatedPages(TEXT.noPcRoom)) + return + end + Bag.remove(self.save, id, qty) + if self.pack then self.pack:rebuild() end + self:say(translatedPages(TEXT.deposited, qty, name)) +end + +function ItemPcMenu:enterDeposit() + -- .CheckItemsInBag: an empty bag never opens the PACK. + if bagIsEmpty(self.save) then + self:say(translatedPages(TEXT.noItems)) + return + end + self:setPhase("deposit") + -- DepositSellPack: the PACK as a chooser, held and drawn by this screen the + -- way the mart holds its sell PACK. `world = {}` keeps field items inert. + self.pack = Screens.build(self.game, "Gen2PackMenu", { + save = self.save, + items = self.items, + world = {}, + onChoose = function(id, count) self:offerToDeposit(id, count) end, + onClose = function() self:leaveDeposit() end, + }) +end + +function ItemPcMenu:leaveDeposit() + self.pack = nil + self:setPhase("menu") +end + +function ItemPcMenu:offerToDeposit(id, count) + if (count or 0) < 1 then return end + local def = self:def(id) + local name = (def and def.name) or id + -- .DepositItem (engine/events/pokecenter_pc.asm:504): an item with no + -- quantity is always x1 and never reaches .AskQuantity. + if self:cantToss(id) then + self:deposit(id, name, 1) + return + end + self:askQuantity(count, + translatedLines(TEXT.depositHowMany), + function(qty) self:deposit(id, name, qty) end) +end + +function ItemPcMenu:chooseToss() + local row = self.rows[self.listIndex] + if not row then + self:setPhase("menu") + return + end + -- TossItemFromPC .key_item -> .CantToss. + if self:cantToss(row.id) then + self:say(translatedPages(TEXT.tooImportant)) + return + end + self:askQuantity(row.count, + translatedLines(TEXT.tossHowMany, row.name), + function(qty) + -- .ItemsThrowAwayText's yes/no sits between the count and the toss. + self.confirm = { + prompt = translatedLines(TEXT.throwAway, qty, row.name), + choice = 1, + onYes = function() + self:pcRemove(row.id, qty) + self:rebuild() + self:say(translatedPages(TEXT.discarded, row.name)) + end, + } + end) +end + +-- ------------------------------------------------------------------ the menu + +function ItemPcMenu:choose() + local entry = self.entries[self.index] + if not entry then return end + local game = self.game + if entry.id == "withdraw" or entry.id == "toss" then + self:setPhase(entry.id) + self.listIndex = 1 + self.scroll = 0 + -- engine/events/pokecenter_pc.asm:569 + self.switching = nil + self:rebuild() + return + end + if entry.id == "deposit" then + self:enterDeposit() + return + end + if entry.id == "mailbox" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2MailboxMenu", { + save = self.save, + onClose = function() game.stack:pop() end, + }) + return + end + if entry.id == "decoration" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2DecorationMenu", { + save = self.save, + events = self.events, + onDone = function(changed) + self.changedDecorations = self.changedDecorations or changed or false + game.stack:pop() + end, + }) + return + end + -- logoff / turnoff: PlayerLogOffMenu serves both rows. + self:close() +end + + +-- engine/events/pokecenter_pc.asm:622 +-- engine/items/switch_items.asm:27 +function ItemPcMenu:armSwitch() + if not self.rows[self.listIndex] then return end + self.switching = self.listIndex +end + +-- engine/events/pokecenter_pc.asm:604 +function ItemPcMenu:updateSwitch(input) + if input:wasPressed("up") then + self.listIndex = self.listIndex > 1 and self.listIndex - 1 + or self:listTotal() + self:ensureVisible() + elseif input:wasPressed("down") then + self.listIndex = self.listIndex < self:listTotal() and self.listIndex + 1 + or 1 + self:ensureVisible() + elseif input:wasPressed("a") or input:wasPressed("select") then + self:placeSwitch() + elseif input:wasPressed("b") then + -- engine/events/pokecenter_pc.asm:615 + self.switching = nil + end +end + +-- engine/events/pokecenter_pc.asm:620 +-- engine/items/switch_items.asm:12 +function ItemPcMenu:placeSwitch() + local held = self.rows[self.switching] + local target = self.rows[self.listIndex] + self:playPcSfxTwice("Sfx_SwitchPokemon") + if not target then return end + if held and self.save and target.slot ~= held.slot then + PcItems.move(self.save, held.id, target.slot, self.items) + self:rebuild() + end + self.switching = nil +end + +-- ------------------------------------------------------------------- update + +function ItemPcMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + -- engine/events/pokecenter_pc.asm:195 + if self:tickRepeatSfx() then return end + + if self.message then + Typer.step(self) + if Typer.typing(self) then return end + if input:wasPressed("a") or input:wasPressed("b") then + local m = self.message + if m.page < #m.pages then + Typer.turn(self, m) + return + end + self.message = nil + if m.onDone then m.onDone() end + end + return + end + + if self.qtyState then + local q = self.qtyState + if input:wasPressed("up") then + q.qty = qtyStep(q.qty, q.max, 1) + elseif input:wasPressed("down") then + q.qty = qtyStep(q.qty, q.max, -1) + elseif input:wasPressed("right") then + q.qty = qtyStep(q.qty, q.max, 10) + elseif input:wasPressed("left") then + q.qty = qtyStep(q.qty, q.max, -10) + elseif input:wasPressed("b") then + self.qtyState = nil + elseif input:wasPressed("a") then + self.qtyState = nil + q.onAccept(q.qty) + end + return + end + + if self.confirm then + local c = self.confirm + if input:wasPressed("up") or input:wasPressed("down") then + c.choice = c.choice == 1 and 2 or 1 + elseif input:wasPressed("b") then + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") + self.confirm = nil + if c.onNo then c.onNo() end + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self.confirm = nil + if c.choice == 1 then + if c.onYes then c.onYes() end + elseif c.onNo then + c.onNo() + end + end + return + end + + if self.phase == "deposit" then + if self.pack then + self.pack:update(_dt) + else + self:setPhase("menu") + end + return + end + + if self.phase == "withdraw" or self.phase == "toss" then + if self.switching then + self:updateSwitch(input) + return + end + if input:wasPressed("up") then + self.listIndex = self.listIndex > 1 and self.listIndex - 1 + or self:listTotal() + self:ensureVisible() + elseif input:wasPressed("down") then + self.listIndex = self.listIndex < self:listTotal() and self.listIndex + 1 + or 1 + self:ensureVisible() + elseif input:wasPressed("b") then + -- engine/menus/scrolling_menu.asm:24 + self:playSfx("Sfx_ReadText2") + self:setPhase("menu") + self.switching = nil + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + if self.phase == "withdraw" then + self:chooseWithdraw() + else + self:chooseToss() + end + elseif input:wasPressed("select") then + self:armSwitch() + end + return + end + + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.entries + elseif input:wasPressed("down") then + self.index = self.index < #self.entries and self.index + 1 or 1 + elseif input:wasPressed("a") then + -- home/menu.asm:476 + self:playSfx("Sfx_ReadText2") + self:choose() + elseif input:wasPressed("b") then + -- DoNthMenu's carry is `.turn_off`. + self:playSfx("Sfx_ReadText2") + self:close() + end +end + +-- --------------------------------------------------------------------- draw + +function ItemPcMenu:drawBottomLines(lines) + Chrome.box(0, 12, 20, 6) + if not lines then return end + local name = self:playerName() + local startY = #lines >= 3 and 13 or 14 + for i, line in ipairs(lines) do + Chrome.print((line:gsub("{PLAYER}", name)), 1, startY + (i - 1) * 2) + end +end + +function ItemPcMenu:drawList() + Chrome.box(0, 0, 20, 12) + -- engine/events/pokecenter_pc.asm:628 .a_1 -> home/menu.asm:50 + -- engine/events/pokecenter_pc.asm:605 .moving_stuff_around + local picked = not self.switching + and (self.message or self.qtyState or self.confirm) and true or false + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local ty = row * 2 + if i <= #self.rows then + local entry = self.rows[i] + -- home/menu.asm:50 + if i == self.listIndex then + Chrome.cursor(LIST_X - 1, ty, picked) + elseif i == self.switching then + Chrome.cursor(LIST_X - 1, ty, true) + end + Chrome.print(entry.name, LIST_X, ty) + -- PlaceMenuItemQuantity (engine/menus/menu_2.asm:18, :24) + if not self:cantToss(entry.id) then + Chrome.print(TIMES .. Chrome.number(entry.count, 2), LIST_X + 9, ty + 1) + end + elseif i == self:listTotal() then + if i == self.listIndex then Chrome.cursor(LIST_X - 1, ty, picked) end + Chrome.print(Strings("CANCEL"), LIST_X, ty) + end + end + -- UpdateItemDescription under the list. + local row = self.rows[self.listIndex] + local def = row and self:def(row.id) + local description = def and def.description + if description then + local first, second = description:match("^(.-)(.*)$") + if not first then first, second = description:match("^(.-)\n(.*)$") end + Chrome.box(0, 12, 20, 6) + Chrome.print(first or description, 1, 14) + if second then Chrome.print(second, 1, 16) end + else + Chrome.box(0, 12, 20, 6) + end +end + +function ItemPcMenu:drawPanel() + -- ../pokecrystal/engine/pokemon/bills_pc_top.asm:231 + if self.isOpaque then Chrome.clear() end + + if self.phase == "deposit" and self.pack then + self.pack:drawPanel() + elseif self.phase == "withdraw" or self.phase == "toss" then + self:drawList() + else + -- _PlayersPCAskWhatDoText, printed under the list the whole time. The + -- box goes down first: the menu window overlays it where the house's + -- six-row list runs past row 12, the way the cart's windows stack. + self:drawBottomLines(translatedLines(TEXT.whatDo)) + -- PlayersPCMenuData is menu_coords 0, 0, 15, 12; the house list is one + -- row taller than that box, so size it to the entries. + Chrome.box(0, 0, 16, math.max(12, #self.entries * 2 + 2)) + for i, entry in ipairs(self.entries) do + local ty = i * 2 + if i == self.index then Chrome.cursor(1, ty) end + Chrome.print(entry.builtin and Strings(entry.label) or entry.label, 2, ty) + end + end + + if self.qtyState then + local q = self.qtyState + self:drawBottomLines(q.prompt) + -- engine/items/buy_sell_toss.asm:205 TossItem_MenuHeader, :133 + Chrome.box(15, 9, 5, 3) + Chrome.print(TIMES, 16, 10) + Chrome.print(Chrome.number(q.qty, 2, true), 17, 10) + elseif self.confirm then + self:drawBottomLines(self.confirm.prompt) + Chrome.box(14, 7, 6, 5) + Chrome.print(Strings("YES"), 16, 8) + Chrome.print(Strings("NO"), 16, 10) + Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10) + elseif self.message then + self:drawBottomLines( + Typer.text(self, self.message.pages[self.message.page])) + end + + love.graphics.setColor(1, 1, 1, 1) +end + +function ItemPcMenu:draw() + self:drawPanel() +end + +ItemPcMenu.ENTRIES = ENTRIES +ItemPcMenu.PC_ITEM_CAPACITY = PC_ITEM_CAPACITY + +return ItemPcMenu diff --git a/src/ui/Gen2MailCompose.lua b/src/ui/Gen2MailCompose.lua new file mode 100644 index 000000000..966e15895 --- /dev/null +++ b/src/ui/Gen2MailCompose.lua @@ -0,0 +1,37 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Uses the native keyboard with two 16-character rows. Accepting commits +-- the stationery exchange and letter together; cancelling changes neither. +local Mail=require("src.pokemon.Gen2Mail") +local Strings=require("src.core.Strings") +local M={} +function M.new(game,opts) + opts=opts or {} + local screen + local function close() + if game.stack:top()==screen then game.stack:pop() end + end + screen=require("src.ui.Gen2NamingScreen").new(game,{ + type="box",maxLength=32,prompt=require("src.core.Strings")("MAIL SELECT:QUIT"),initial="", + onDone=function(text) + local slot + for i,mon in ipairs(game.save.party or {})do if mon==opts.mon then slot=i;break end end + local ok,why=Mail.attachFromBag(game.save,slot,opts.item,text,game.data) + if not ok then + local message=why=="full" and Strings("The PACK is full.") or Strings("The MAIL couldn't\nbe attached.") + game.stack:push(require("src.render.TextBox").new(game,message));return + end + close();if opts.onDone then opts.onDone(true) end + end, + }) + screen.mailMode=true + local update=screen.update + function screen:update(dt) + local input=game.input + if input:wasPressed("select") or (input:wasPressed("b") and #self.glyphs==0) then + close();if opts.onCancel then opts.onCancel() end;return + end + return update(self,dt) + end + return screen +end +return M diff --git a/src/ui/Gen2MailMenu.lua b/src/ui/Gen2MailMenu.lua new file mode 100644 index 000000000..7996569ff --- /dev/null +++ b/src/ui/Gen2MailMenu.lua @@ -0,0 +1,44 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Mail=require("src.pokemon.Gen2Mail") +local Menu=require("src.ui.Menu") +local TextBox=require("src.render.TextBox") +local Strings=require("src.core.Strings") +local M={} +function M.read(game,entry) + local top,bottom=Mail.lines(entry) + local text=top.."\n"..bottom.."\f"..Strings("FROM:").."\n"..tostring(entry and entry.author or "") -- strings: authored letter data and localized sender heading + game.stack:push(TextBox.new(game,text)) +end +local function partySlot(game,mon) + for i,member in ipairs(game.save.party or {}) do if member==mon then return i end end +end +function M.new(game,opts) + opts=opts or {};local mon=opts.mon or game.save.party[opts.slot] + local function say(text)game.stack:push(TextBox.new(game,Strings(text)))end + return Menu.new(game,{ + {label=Strings("READ"),keepOpen=true,onSelect=function() + local slot=partySlot(game,mon) + if slot then M.read(game,Mail.get(game.save,slot) or Mail.entry(mon.item)) end + end}, + {label=Strings("TAKE"),onSelect=function() + game.stack:push(TextBox.new(game,Strings("Send the removed\nMAIL to your PC?"),nil,{ + choice=function(yes) + local slot=partySlot(game,mon);if not slot then return end + if yes then + if Mail.sendToPc(game.save,slot) then say(Strings("The MAIL was sent\nto your PC.")) + else say(Strings("Your PC's MAILBOX\nis full.")) end + else + game.stack:push(TextBox.new(game,Strings("The MAIL will lose\nits message. OK?"),nil,{ + defaultNo=true,choice=function(confirm) + if not confirm then return end + local current=partySlot(game,mon);if not current then return end + if Mail.detachToBag(game.save,current,game.data) then say("MAIL detached.") + else say(Strings("There's no space\nfor removing MAIL.")) end + end})) + end + end})) + end}, + {label=Strings("QUIT")}, + },{tx=9,ty=10,tw=11,th=8}) +end +return M diff --git a/src/ui/Gen2MailRead.lua b/src/ui/Gen2MailRead.lua new file mode 100644 index 000000000..63e014a9f --- /dev/null +++ b/src/ui/Gen2MailRead.lua @@ -0,0 +1,106 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- Reading a letter: ReadAnyMail / ReadPartyMonMail (engine/pokemon/mail_2.asm). +-- +-- The screen is one full-page piece of stationery -- ten of them, one per mail +-- item, each its own Load*MailGFX routine painting a border, a mon and a +-- scattering of icons out of gfx/mail.asm's 1bpp art. None of that art is in +-- the cache (the extractor has never followed gfx/mail.asm), so this draws the +-- page as a plain full-screen box and puts the two pieces that are DATA rather +-- than tiles exactly where MailGFX_PlaceMessage puts them: +-- +-- message hlcoord 2, 7 -- one PlaceString, so the '' stored at offset +-- MAIL_LINE_LENGTH lands the second line on row 8 +-- author hlcoord 5, 14, except hlcoord 8, 14 for PORTRAITMAIL_INDEX and +-- hlcoord 6, 14 for MORPH_MAIL_INDEX -- those two stationeries have +-- art where the name would otherwise sit +-- +-- .loop reads A, B and START and exits on any of them (START is the printer, +-- which this port has no path for; the VC builds mask it off entirely with +-- the Forbid_printing_mail patch, so treating it as an exit is the VC +-- behaviour rather than an invention). + +local Chrome = require("src.ui.Gen2Chrome") +local Mail = require("src.pokemon.Gen2Mail") + +local MailRead = {} +MailRead.__index = MailRead +MailRead.isOpaque = true + +local MESSAGE_X, MESSAGE_Y = 2, 7 +local AUTHOR_Y = 14 + +function MailRead:wantsFillScale() return true end +function MailRead:drawsWidescreen() return true end + +-- opts: entry (a `mailmsg` from src/core/gen2/Mail.lua), onClose() +function MailRead.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MailRead) + self.game = game + self.entry = opts.entry + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + return self +end + +-- MailGFX_PlaceMessage's three author columns, picked by wCurMailIndex -- +-- which .LoadGFX set from the mail's TYPE byte by walking MailGFXPointers, so +-- it is the *_MAIL_INDEX of the stationery and nothing else. +function MailRead:authorColumn() + local index = Mail.INDEX[self.entry and self.entry.type or ""] + if index == Mail.INDEX.PORTRAITMAIL then return 8 end + if index == Mail.INDEX.MORPH_MAIL then return 6 end + return 5 +end + +function MailRead:close() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose() end +end + +function MailRead:update(_dt) + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("a") or input:wasPressed("b") + or input:wasPressed("start") then + self:close() + end +end + +function MailRead:drawPanel() + Chrome.clear() + -- DrawMailBorder frames the whole 20x18 page; without the stationery tiles + -- the shared box frame is the honest stand-in. + Chrome.box(0, 0, Chrome.SCREEN_W, Chrome.SCREEN_H) + + local top, bottom = Mail.lines(self.entry) + Chrome.print(top, MESSAGE_X, MESSAGE_Y) + if bottom ~= "" then Chrome.print(bottom, MESSAGE_X, MESSAGE_Y + 1) end + + -- MailGFX_PlaceMessage returns early when the author field is empty + -- (`ld a, [de] / and a / ret z`), so a blank author draws nothing at all + -- rather than an empty line under the message. + local author = self.entry and self.entry.author or "" + if author ~= "" then + Chrome.print(author, self:authorColumn(), AUTHOR_Y) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function MailRead:draw() + self:drawPanel() +end + +function MailRead:drawWidescreen(winW, winH) + local G = love.graphics + Chrome.letterbox(winW, winH, 1, 1, 1) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return MailRead diff --git a/src/ui/Gen2MailboxMenu.lua b/src/ui/Gen2MailboxMenu.lua new file mode 100644 index 000000000..69410f544 --- /dev/null +++ b/src/ui/Gen2MailboxMenu.lua @@ -0,0 +1,426 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- The MAILBOX in the player's PC: _PlayerMailBoxMenu and MailboxPC +-- (engine/pokemon/mail.asm). +-- +-- InitMail runs first and answers z when sMailboxCount is 0, which is the one +-- branch that never opens a menu at all -- an empty MAILBOX is +-- _EmptyMailboxText and nothing else. +-- +-- Layout, from the two headers: +-- .TopMenuHeader menu_coords 8, 1, 18, 10 -- a scrolling menu, four rows, +-- each printed by MailboxPC_PrintMailAuthor, i.e. the +-- AUTHOR of the letter and not its message +-- .SubMenuHeader menu_coords 0, 0, 13, 9 with STATICMENU_CURSOR, so its +-- four labels start at (2,2) and step two rows +-- +-- The submenu's four rows are .Jumptable's four routines: +-- +-- READ MAIL ReadMailMessage, which is the Gen2MailRead screen +-- PUT IN PACK "message will be lost. OK?" -> ReceiveItem, then +-- DeleteMailFromPC. The letter is destroyed and the +-- STATIONERY goes back in the bag, which is why the question +-- is asked before the bag is even checked +-- ATTACH MAIL the party list, refusing an EGG and a mon that is already +-- holding anything, then MoveMailFromPCToParty. The cart +-- LOOPS on both refusals (`jr .try_again`) rather than backing +-- out, so the list comes straight back up +-- CANCEL `ret` +-- +-- Drawn over whatever opened the PC, so this state is not opaque. + +local Bag = require("src.inventory.Bag") +local Breeding = require("src.pokemon.Party") +local Chrome = require("src.ui.Gen2Chrome") +local CommonText = require("src.core.Gen2CommonText") +local Mail = require("src.pokemon.Gen2Mail") +local Screens = require("src.ui.Screens") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") + +local MailboxMenu = {} +MailboxMenu.__index = MailboxMenu +MailboxMenu.isOpaque = false + +local LIST_X, LIST_Y, LIST_W, LIST_H = 8, 1, 11, 10 +-- InitScrollingMenu lays its rows two inside the box's corner and steps two. +local ROW_X, ROW_Y, ROW_STEP = LIST_X + 2, LIST_Y + 2, 2 +local VISIBLE_ROWS = 4 + +local SUB_X, SUB_Y, SUB_W, SUB_H = 0, 0, 14, 10 +local SUB_LABEL_X, SUB_LABEL_Y = SUB_X + 2, SUB_Y + 2 + +local TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H = 0, 12, 20, 6 +local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 +local DOWN_ARROW = "\xe2\x96\xbc" +local ARROW_X, ARROW_Y = 18, 17 + +-- .SubMenuData, verbatim. +local SUB_ENTRIES = { + { id = "read", label = Strings.source("READ MAIL") }, + { id = "pack", label = Strings.source("PUT IN PACK") }, + { id = "attach", label = Strings.source("ATTACH MAIL") }, + { id = "cancel", label = Strings.source("CANCEL") }, +} + +local function page(...) return { ... } end +local function pages(...) return { ... } end + +-- data/text/common_2.asm's _EmptyMailboxText .. _MailMovedFromBoxText block. +local TEXT = { + empty = pages(page("There's no MAIL", "here.")), + messageLost = pages(page("The MAIL's message", "will be lost. OK?")), + packFull = pages(page("The PACK is full.")), + putAway = pages(page("The cleared MAIL", "was put away.")), + alreadyHolding = pages(page("It's already hold-", "ing an item.")), + egg = pages(page("An EGG can't hold", "any MAIL.")), + moved = pages(page("The MAIL was moved", "from the MAILBOX.")), +} + +local LABELS = { + empty = "_EmptyMailboxText", + messageLost = "_MailMessageLostText", + packFull = "_MailPackFullText", + putAway = "_MailClearedPutAwayText", + alreadyHolding = "_MailAlreadyHoldingItemText", + egg = "_MailEggText", + moved = "_MailMovedFromBoxText", +} + +MailboxMenu.TEXT = TEXT +MailboxMenu.LABELS = LABELS +MailboxMenu.SUB_ENTRIES = SUB_ENTRIES + +local function extractedText(text) + local out = setmetatable({}, { __index = TEXT }) + for key, label in pairs(LABELS) do + local list = CommonText.of(text, label) + if list then out[key] = list end + end + return out +end + +-- opts: save, text (text.lua), onClose() +function MailboxMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MailboxMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.textData = opts.text or (game and game.data and game.data.text) + Mail.reclaimQuarantine(self.save) + self.TEXT = extractedText(self.textData) + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + -- wCurMessageIndex / wCurMessageScrollPosition, both reset by MailboxPC + -- before its loop. + self.index = 1 + self.scroll = 0 + self.submenu = nil + self.message = nil + self.confirm = nil + self.picking = false + -- InitMail's z branch: no menu, one line, gone. + if Mail.mailboxCount(self.save) == 0 then + self:say(self.TEXT.empty, function() self:close() end) + end + return self +end + +function MailboxMenu:box() + return Mail.mailbox(self.save) +end + +function MailboxMenu:count() + return Mail.mailboxCount(self.save) +end + +function MailboxMenu:selected() + return self:box()[self.index] +end + +function MailboxMenu:close() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose() end +end + +function MailboxMenu:say(list, onDone) + self.message = { pages = list or {}, page = 1, onDone = onDone } +end + +function MailboxMenu:ask(list, onYes, onNo) + self.confirm = { pages = list or {}, page = 1, choice = 1, + onYes = onYes, onNo = onNo } +end + +function MailboxMenu:clampIndex() + local total = self:count() + if total == 0 then + self.index, self.scroll = 1, 0 + return + end + if self.index > total then self.index = total end + if self.index < 1 then self.index = 1 end + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, total - VISIBLE_ROWS))) +end + +-- ------------------------------------------------------------- submenu rows + +function MailboxMenu:readMail() + local game = self.game + local entry = self:selected() + if not (game and game.stack and entry) then return end + self.picking = true + Screens.push(game, "Gen2MailRead", { + entry = entry, + onClose = function() + game.stack:pop() + self.picking = false + self.submenu = nil + end, + }) +end + +-- .PutInPack. The yes/no comes first, then ReceiveItem -- and only a bag that +-- accepted the stationery gets as far as DeleteMailFromPC, so a full PACK +-- leaves the letter in the MAILBOX intact. +function MailboxMenu:putInPack() + self:ask(self.TEXT.messageLost, function() + local entry = self:selected() + if not entry then + self.submenu = nil + return + end + local data = self.game and self.game.data + if not Bag.add(self.save, entry.type, 1, data) then + return self:say(self.TEXT.packFull, function() self.submenu = nil end) + end + Mail.deleteFromPc(self.save, self.index) + self:clampIndex() + self:say(self.TEXT.putAway, function() + self.submenu = nil + if self:count() == 0 then + -- The next .loop calls InitMail again, which now answers z. + self:say(self.TEXT.empty, function() self:close() end) + end + end) + end, function() + -- `ret c`: the question was the whole thing. + self.submenu = nil + end) +end + +-- .AttachMail's loop. Both refusals go back to the party list rather than out +-- of the submenu, which is exactly what `jr .try_again` does. +function MailboxMenu:attachMail() + local game = self.game + if not (game and game.stack) then + self.submenu = nil + return + end + self.picking = true + Screens.push(game, "PartyMenu", { + pickOnly = true, + save = self.save, + party = self.save.party, + prompt = "choose", + onSwitch = function(mon) + local slot + for i,member in ipairs(self.save.party or {}) do if member==mon then slot=i;break end end + self.picking = false + if Breeding.isEgg(mon) then + return self:say(self.TEXT.egg, function() self:attachMail() end) + end + if mon and mon.item then + return self:say(self.TEXT.alreadyHolding, + function() self:attachMail() end) + end + if not Mail.moveFromPcToParty(self.save, self.index, slot) then + self.submenu = nil + return + end + self:clampIndex() + self:say(self.TEXT.moved, function() + self.submenu = nil + if self:count() == 0 then + self:say(self.TEXT.empty, function() self:close() end) + end + end) + end, + onCancel = function() + self.picking = false + -- `.exit2` -> CloseSubmenu: back to the letter list, not out of the PC. + self.submenu = nil + end, + }) +end + +function MailboxMenu:chooseSub() + local entry = SUB_ENTRIES[self.submenu.index] + if not entry then return end + if entry.id == "read" then return self:readMail() end + if entry.id == "pack" then return self:putInPack() end + if entry.id == "attach" then return self:attachMail() end + self.submenu = nil +end + +-- ------------------------------------------------------------------ update + +function MailboxMenu:updateMessage(input) + Typer.step(self) + if not (input:wasPressed("a") or input:wasPressed("b")) then return end + local message = self.message + if message.page < #message.pages then + message.page = message.page + 1 + return + end + self.message = nil + if message.onDone then message.onDone() end +end + +function MailboxMenu:updateConfirm(input) + Typer.step(self) + local confirm = self.confirm + if confirm.page < #confirm.pages then + if input:wasPressed("a") or input:wasPressed("b") then + confirm.page = confirm.page + 1 + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + return + end + if input:wasPressed("b") then + self.confirm = nil + if confirm.onNo then confirm.onNo() end + return + end + if input:wasPressed("a") then + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +function MailboxMenu:updateSubmenu(input) + local total = #SUB_ENTRIES + if input:wasPressed("up") then + self.submenu.index = self.submenu.index > 1 and self.submenu.index - 1 + or total + elseif input:wasPressed("down") then + self.submenu.index = self.submenu.index < total and self.submenu.index + 1 + or 1 + elseif input:wasPressed("a") then + self:chooseSub() + elseif input:wasPressed("b") then + self.submenu = nil + end +end + +function MailboxMenu:update(_dt) + if self.picking then return end + local input = self.game and self.game.input + if not input then return end + if self.message then return self:updateMessage(input) end + if self.confirm then return self:updateConfirm(input) end + if self.submenu then return self:updateSubmenu(input) end + + local total = self:count() + if total == 0 then return self:close() end + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or total + self:clampIndex() + elseif input:wasPressed("down") then + self.index = self.index < total and self.index + 1 or 1 + self:clampIndex() + elseif input:wasPressed("a") then + self.submenu = { index = 1 } + elseif input:wasPressed("b") then + -- .exit: PAD_B out of the scrolling menu ends _PlayerMailBoxMenu. + self:close() + end +end + +-- -------------------------------------------------------------------- draw + +function MailboxMenu:drawTextBox(lines) + Chrome.box(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W, TEXT_BOX_H) + for i, line in ipairs(lines or {}) do + Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE) + end +end + +function MailboxMenu:drawYesNo(choice) + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) + Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, YESNO_Y + (choice == 1 and 1 or 3)) +end + +function MailboxMenu:drawList() + Chrome.box(LIST_X, LIST_Y, LIST_W, LIST_H) + local box = self:box() + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local entry = box[i] + if entry then + local ty = ROW_Y + (row - 1) * ROW_STEP + if i == self.index then Chrome.cursor(ROW_X - 1, ty) end + -- MailboxPC_PrintMailAuthor copies NAME_LENGTH - 1 bytes out of the + -- struct's Author field and terminates it; a blank author draws a blank + -- row rather than the message. + Chrome.print(entry.author or "", ROW_X, ty) + end + end +end + +function MailboxMenu:drawSubmenu() + Chrome.box(SUB_X, SUB_Y, SUB_W, SUB_H) + for row, entry in ipairs(SUB_ENTRIES) do + local ty = SUB_LABEL_Y + (row - 1) * 2 + if row == self.submenu.index then Chrome.cursor(SUB_LABEL_X - 1, ty) end + Chrome.print(Strings(entry.label), SUB_LABEL_X, ty) + end +end + +function MailboxMenu:drawPanel() + if self.message then + self:drawTextBox(self.message.pages[self.message.page]) + if self.message.page < #self.message.pages + and Typer.arrowOn(self) then + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + if self.confirm then + self:drawTextBox(self.confirm.pages[self.confirm.page]) + if self.confirm.page >= #self.confirm.pages then + self:drawYesNo(self.confirm.choice) + elseif Typer.arrowOn(self) then + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + self:drawList() + if self.submenu then self:drawSubmenu() end + love.graphics.setColor(1, 1, 1, 1) +end + +function MailboxMenu:draw() + self:drawPanel() +end + +return MailboxMenu diff --git a/src/ui/Gen2MoveDeleter.lua b/src/ui/Gen2MoveDeleter.lua new file mode 100644 index 000000000..a1c1a536f --- /dev/null +++ b/src/ui/Gen2MoveDeleter.lua @@ -0,0 +1,137 @@ +-- Adapted from Gen1Recomp 535a5ec0 native Gen2 MoveDeleter. +-- Existing project rights/licences remain applicable. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- The Blackthorn move deleter's move list (engine/events/move_deleter.asm +-- ChooseMoveToDelete, engine/pokemon/mon_menu.asm). ChooseMoveToDelete shares +-- SetUpMoveScreenBG / SetUpMoveList with the summary screen's move page +-- (src/ui/gen2/SummaryMenu.lua moveDetailPlacements), so the four-slot list +-- geometry is the same box: names at (2, 3 + 2*slot), PP at +-- (10/13/15/16, nameY + 1). What ChooseMoveToDelete does NOT share is the +-- type/power/accuracy plaque under it -- DeleteMoveScreen2DMenuData is a bare +-- `db 3, 1` / `dn 2, 0` menu (3 rows, cursor offset column 2), so only the +-- move list itself is drawn here. +-- +-- The special that opens this (src/script/gen2/Specials.lua H.MoveDeletion) +-- has already run the "which move should it forget" line and refused a mon +-- with only one move before this screen is ever pushed, so this only has to +-- answer a row. + +local Chrome = require("src.ui.Gen2Chrome") +local ForgetMoveList = require("src.ui.Gen2ForgetMoveList") +local Pokemon = require("src.pokemon.Pokemon") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") + +local MoveDeleter = {} +MoveDeleter.__index = MoveDeleter +MoveDeleter.isOpaque = false + +function MoveDeleter:wantsFillScale() return true end + +-- opts: mon (the party mon whose moves are listed), moves (data.moves), +-- onChoose(index) -- 1-based move slot, onCancel() +function MoveDeleter.new(game, opts) + opts = opts or {} + local self = setmetatable({}, MoveDeleter) + self.game = game + local data = (game and game.data) or {} + self.forget = opts.layout == "forget" + self.mon = opts.mon + self.moves = opts.moves or data.moves + self.onChoose = opts.onChoose + self.onCancel = opts.onCancel + -- Shared battlers keep transformed moves in curMoves; party mon.moves is + -- already the actual original party list used by ItemEffects. + self.list = self.mon and self.mon.moves or {} + self.row = 1 + return self +end + +function MoveDeleter:moveName(entry) + if not entry then return "-" end + local def = self.moves and self.moves[entry.id] + return (def and def.name) or entry.id +end + +function MoveDeleter:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then + Sound.play(data, name) + end +end + +function MoveDeleter:finish(index) + if self.done then return end + self.done = true + if index then + if self.onChoose then self.onChoose(index) end + elseif self.onCancel then + self.onCancel() + end +end + +-- ScrollingMenuJoypad, no wrap: DeleteMoveScreen2DMenuData sets no +-- _2DMENU_WRAP bit, so the cursor stops at the ends the way every other +-- unwrapped 2D menu in Gold does. +function MoveDeleter:update(_dt) + if self.done then return end + local input = self.game and self.game.input + if not input then return end + local n = #self.list + if n == 0 then + if input:wasPressed("a") or input:wasPressed("b") then self:finish(nil) end + return + end + -- engine/pokemon/learn.asm:160 + if input:wasPressed("up") then + if self.row > 1 then + self.row = self.row - 1 + elseif self.forget then + self.row = n + end + elseif input:wasPressed("down") then + if self.row < n then + self.row = self.row + 1 + elseif self.forget then + self.row = 1 + end + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self:finish(self.row) + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self:finish(nil) + end +end + +function MoveDeleter:draw() + if self.forget then + ForgetMoveList.draw(self.list, self.row, self.moves) + love.graphics.setColor(1, 1, 1, 1) + return + end + Chrome.textbox(0, 1, 18, 9) + for slot = 1, 4 do + local nameY = 3 + (slot - 1) * 2 + local ppY = nameY + 1 + local entry = self.list[slot] + if entry then + Chrome.print(self:moveName(entry), 2, nameY) + Chrome.print(Strings("PP"), 10, ppY) + Chrome.print(Chrome.number(entry.pp, 2, true), 13, ppY) + Chrome.print("/", 15, ppY) + Chrome.print(Chrome.number(Pokemon.maxPP(self.moves and self.moves[entry.id], entry, 2), + 2, true), 16, ppY) + else + Chrome.print("-", 2, nameY) + Chrome.print("--", 10, ppY) + end + end + if self.row >= 1 and self.row <= #self.list then + Chrome.cursor(1, 3 + (self.row - 1) * 2) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return MoveDeleter diff --git a/src/ui/Gen2NamePick.lua b/src/ui/Gen2NamePick.lua new file mode 100644 index 000000000..3b0496841 --- /dev/null +++ b/src/ui/Gen2NamePick.lua @@ -0,0 +1,105 @@ +-- Gen2 name selection and blocking player portrait movement. +-- Adapted from Gen1Recomp src/ui/gen2/NamePick.lua at +-- 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91; existing provenance/licenses apply. +-- Original integration contributions (c) 2026 Roxas2712; offered to UNDERdecoded under +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +local Chrome = require('src.ui.Gen2Chrome') +local Font = require('src.render.Font') +local Strings = require('src.core.Strings') +local Screens = require('src.ui.Screens') +local Palette = require('src.render.Gen2PaletteDraw') +local M = {isOpaque=true}; M.__index=M +local PRESETS={gold={'GOLD','HIRO','TAYLOR','KARL'},silver={'SILVER','KAMON','OSCAR','MAX'},crystal={'CHRIS','MAT','ALLAN','JON'}} +local FEMALE={crystal={'KRIS','AMANDA','JUANA','JODI'}} +function M.presetsFor(gender,data) + local field=data and data.field or {} + local forms=field.playerForms + local form=forms and forms[gender] + if form and type(form.names)=='table' and #form.names>0 then return form.names end + local names=field.boot and field.boot.namePresets and field.boot.namePresets.player + if type(names)=='table' and #names>0 then return names end + local version=require('src.core.GameVersion').get() + return ((gender=='female' or gender=='girl') and FEMALE[version]) or PRESETS[version] or PRESETS.gold +end +function M.new(game,opts) + opts=opts or {} + local self=setmetatable({game=game,onDone=opts.onDone,pic=opts.pic,picColors=opts.picColors, + picTrueColor=opts.picTrueColor,gender=opts.gender or (game and game.save and game.save.player and game.save.player.gender), + title=opts.title,prompt=opts.prompt,maxLength=opts.maxLength,cursor=1,picX=6,slide='in'},M) + self.items={Strings.source('NEW NAME')} + for _,name in ipairs(opts.presets or M.presetsFor(self.gender,game and game.data))do self.items[#self.items+1]=name end + local font=opts.font or (game and game.data and game.data.font) + self.fontOk=font and pcall(Font.load,{font=font}) or false + return self +end +function M:choose(name) + if self._completed then return false end + self._completed=true + if self.onDone then self.onDone(name)end + return true +end +function M:defaultName() + return self.items[2] or M.presetsFor(self.gender,self.game and self.game.data)[1] +end +function M:openNaming() + if self._completed or self.game.stack:top()~=self then return end + local screen + screen=Screens.push(self.game,'Gen2NamingScreen',{ + type='player',gender=self.gender,prompt=self.prompt,maxLength=self.maxLength, + onDone=function(name) + if self._completed or self.game.stack:top()~=screen then return end + self.game.stack:pop() + if self.game.stack:top()~=self then return end + self:choose(name and name~='' and name or self:defaultName()) + end, + }) + return screen +end +function M:update() + if self._completed then return end + if self.slide=='in' then + if self.picX<13 then self.picX=self.picX+1 else self.slide=nil end + return + elseif self.slide=='out' then + if self.picX>6 then self.picX=self.picX-1 + else self.slide=nil;self:choose(self.pendingName)end + return + end + local input=self.game and self.game.input;if not input then return end + if input:wasPressed('up')then self.cursor=(self.cursor-2)%#self.items+1 + elseif input:wasPressed('down')then self.cursor=self.cursor%#self.items+1 + elseif input:wasPressed('a') or input:wasPressed('start')then + if self.cursor==1 then + if self.fontOk then self:openNaming()else self:choose(self:defaultName())end + else self.pendingName=self.items[self.cursor];self.slide='out'end + end +end +function M:wantsFillScale()return true end +function M:drawsWidescreen()return true end +function M:drawPic() + if not self.pic then return end + local G=love.graphics;local w,h=self.pic:getDimensions() + local x,y=self.picX*8,32+56-h + local function body()G.setColor(1,1,1,1);G.draw(self.pic,x,y)end + if self.picColors and not self.picTrueColor and Palette.available()then Palette.with(self.picColors,body)else body()end + if self.picTrueColor then require('src.render.PaletteFX').markTrueColor(x,y,w,h)end +end +function M:drawPanel() + Chrome.clear();self:drawPic() + if self.slide=='in' then return end + local title=Strings(self.title or 'NAME') + Chrome.textbox(0,0,9,10) + Chrome.paletteFill(16,0,Font.width(title),8) + Chrome.print(title,2,0) + for i,label in ipairs(self.items)do Chrome.print(i==1 and Strings(label)or label,2,2+(i-1)*2)end + Chrome.cursor(1,2+(self.cursor-1)*2) +end +function M:draw()self:drawPanel()end +function M:drawWidescreen(w,h) + Chrome.letterbox(w,h,1,1,1) + local G=love.graphics;local scale=Chrome.fitScale(w,h) + G.push('all');G.translate(Chrome.fitOrigin(w,h,scale));G.scale(scale,scale) + self:drawPanel();G.pop() +end +M.PRESETS=PRESETS;M.PRESETS_FEMALE=FEMALE +return M diff --git a/src/ui/Gen2NamingScreen.lua b/src/ui/Gen2NamingScreen.lua new file mode 100644 index 000000000..f00d0bc5b --- /dev/null +++ b/src/ui/Gen2NamingScreen.lua @@ -0,0 +1,306 @@ +-- Original contributions (c) 2026 Roxas2712; offered to UNDERdecoded under +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native Gen2 name entry. The public screen leaves completion to its caller; +-- ordinary NamingScreen admissions use this same owner with native dismissal. +local Font = require('src.render.Font') +local Strings = require('src.core.Strings') +local Runtime = require('src.mods.Runtime') +local Chrome = require('src.ui.Gen2Chrome') +local Assets = require('src.render.Assets') +local M = { isOpaque=true } +M.TYPES = { + player={prompt=Strings.source('YOUR NAME?'),maxLength=7,sprite='SPRITE_CHRIS',spriteFemale='SPRITE_KRIS'}, + rival={prompt=Strings.source("RIVAL'S NAME?"),maxLength=7,sprite='SPRITE_RIVAL'}, + mom={prompt=Strings.source("MOTHER'S NAME?"),maxLength=7,sprite='SPRITE_MOM'}, + box={prompt=Strings.source('BOX NAME?'),maxLength=8,isBox=true}, + nickname={prompt=Strings.source('NICKNAME?'),maxLength=10}, +} +local function limit(n, fallback) + n=tonumber(n) + if not n or n~=n or n==math.huge or n==-math.huge then return fallback end + return math.max(1,math.floor(n)) +end +local function split(text, count) + local out={} + for _,span in ipairs(Font.split(type(text)=='string' and text or ''))do + if #out>=count then break end + out[#out+1]=text:sub(span.from,span.to) + end + return out +end +M.__index=function(self,key) + if key=='text'then return table.concat(self.glyphs) end + if key=='maxLen'then return self.maxLength end + if key=='title'then return self.prompt end + return M[key] +end +M.__newindex=function(self,key,value) + if key=='text'then rawset(self,'glyphs',split(value,self.maxLength)) + elseif key=='maxLen'then rawset(self,'maxLength',limit(value,self.maxLength)) + elseif key=='title'then rawset(self,'prompt',value) + else rawset(self,key,value)end +end +local function alphabet(lower) + local rows={{},{},{}} + for i=0,25 do rows[math.floor(i/9)+1][i%9+1]=string.char((lower and 97 or 65)+i)end + rows[3][9]=' ' + return rows +end +local function board(lower,box) + local rows=alphabet(lower) + local symbols={'×','(',')',':',';','[',']','',''} + if box then + rows[4]=lower and {'é',"'d","'l","'m","'r","'s","'t","'v",'0'} or symbols + rows[5]=lower and {'1','2','3','4','5','6','7','8','9'} or {'-','?','!','♂','♀','/','.',',','&'} + else rows[4]=lower and symbols or {'-','?','!','/','.',',',' ',' ',' '}end + return rows +end +local boards={ normal={board(false,false),board(true,false)},box={board(false,true),board(true,true)} } +local function sameGrid(grid)return grid end +function M:rows() + local data=self.game and self.game.data or {} + local authored=data.gen2Naming and data.gen2Naming[self.isBox and 'box' or 'normal'] + local base=authored and authored[self.lower and 2 or 1] or boards[self.isBox and 'box' or 'normal'][self.lower and 2 or 1] + if Runtime.wantsHook('ui.naming.grid')then + local hooked=Runtime.call('ui.naming.grid',sameGrid,base,{lower=self.lower,title=self.prompt,maxLen=self.maxLength,box=self.isBox,game=self.game}) + if type(hooked)=='table'and #hooked>0 then return hooked end + end + return base +end +function M:keyboardTop()return self.isBox and 6 or 8 end +function M:bottomRow()return #self:rows() end +function M:onBottomRow()return self.row==self:bottomRow() end +function M:bottomTarget()return self.col<3 and 1 or self.col<6 and 2 or 3 end +function M:characterAt(col,row) + local line=self:rows()[row+1] + local ch=line and line[col+1] + return ch~='' and ch or nil +end +function M:cursorCharacter() + if self:onBottomRow()then return ({'CASE','DEL','END'})[self:bottomTarget()]end + return self:characterAt(self.col,self.row) +end +function M:addCharacter(ch) + if type(ch)~='string' or ch=='' or #self.glyphs>=self.maxLength then return false end + -- One cell can carry a charmap ligature or a Unicode character. Never cut + -- its bytes or count it as several name slots. + local cells=split(ch,self.maxLength-#self.glyphs) + for _,cell in ipairs(cells)do self.glyphs[#self.glyphs+1]=cell end + return #cells>0 +end +function M:deleteCharacter()return table.remove(self.glyphs) end +function M:toggleCase()self.lower=not self.lower end +function M:moveHorizontal(delta) + if self:onBottomRow()then self.col=((self:bottomTarget()-1+delta)%3)*3 + else self.col=(self.col+delta)%9 end +end +function M:moveVertical(delta) + self.row=(self.row+delta)%(self:bottomRow()+1) + if self:onBottomRow()then self.col=(self:bottomTarget()-1)*3 end +end +function M:jumpToEnd()self.row=self:bottomRow();self.col=6 end +function M:accept() + local name=self.text + if self._popOnDone then + if self._completed or self.game.stack:top()~=self then return false end + self._completed=true;self.game.stack:pop() + if name==''then name=self.default or (self.presets and self.presets[1]) or ''end + end + if self.onDone then self.onDone(name)end + return true +end +M.confirm=M.accept +function M.playerSprite(gender) + return (gender=='female' or gender=='girl')and M.TYPES.player.spriteFemale or M.TYPES.player.sprite +end +function M.new(game,opts) + opts=opts or {};local kind=M.TYPES[opts.type or 'player']or M.TYPES.player + local self=setmetatable({game=game,kind=kind,glyphs={},maxLength=limit(opts.maxLength,kind.maxLength), + prompt=opts.prompt or Strings(kind.prompt),gender=opts.gender or (game and game.save and game.save.player and game.save.player.gender), + isBox=opts.isBox or kind.isBox or false,lower=false,row=0,col=0,clock=0, + onDone=opts.onDone,onCancel=opts.onCancel,mon=opts.mon,monName=opts.monName, + presets=opts.presets,default=opts.default,_popOnDone=opts._popOnDone==true, + iconColors=opts.iconColors,palette=opts.palette, + },M) + self.text=opts.initial or '' + local data=game and game.data or {} + self.gfx=opts.menuGfx or data.gen2MenuGfx + if self.gfx and self.gfx.naming then self.gfx=self.gfx.naming end + self.tiles={} + for _,key in ipairs({'border','middleLine','underLine','cursor'})do + local path=self.gfx and self.gfx[key] + if path then local ok,img=pcall(Assets.image,path);if ok then self.tiles[key]=img end end + end + if opts.iconPath then local ok,img=pcall(Assets.image,opts.iconPath);if ok then self.iconImage=img end end + if self.mon then self:loadMonHeader(data,self.mon)end + if not self.iconImage and not self.mon then + local form=(data.field and data.field.playerForms or {})[self.gender] + local id=kind==M.TYPES.player and ((form and form.walk)or M.playerSprite(self.gender))or kind.sprite + local def=data.sprites and data.sprites[id] + if def and def.image then + local ok,img=pcall(Assets.image,def.image) + if ok then self.iconImage=img;self.iconTrueColor=def.trueColor end + end + end + return self +end +-- Preserve the unqualified screen's pop-before-callback contract while giving +-- native scripts, catches and introductions the real Gen2 input model. +function M.native(game,opts) + opts=opts or {};local args={} + for k,v in pairs(opts)do args[k]=v end + args.type=args.type or (args.kind=='mon' and 'nickname')or args.kind + or (tonumber(args.maxLen)==10 and 'nickname')or (tonumber(args.maxLen)==8 and 'box')or 'player' + args.prompt=args.prompt or args.title;args.maxLength=args.maxLength or args.maxLen + args._popOnDone=true + return M.new(game,args) +end +function M:setChoosing(on) + if on then + self.choosing=true;self.isOpaque=false + -- A presenter may wrap the class draw methods without reading native + -- preset state. This owner has no keyboard surface until NEW NAME, so + -- suppress drawing on the instance, after constructor decoration. + self._presetDrawState={draw=rawget(self,'draw'),wide=rawget(self,'drawWidescreen')} + self.draw=function()end;self.drawWidescreen=function()end + else + self.choosing=nil;self.isOpaque=true + local previous=self._presetDrawState + if previous then + rawset(self,'draw',previous.draw);rawset(self,'drawWidescreen',previous.wide) + self._presetDrawState=nil + end + end +end +function M:enter() + if not(self.presets and #self.presets>0)then return end + self:setChoosing(true) + local items={{label=Strings('NEW NAME'),onSelect=function()self:setChoosing(false)end}} + for _,preset in ipairs(self.presets)do + items[#items+1]={label=preset,onSelect=function() + self:setChoosing(false);self.text=preset;self:accept() + end} + end + self.game.stack:push(require('src.ui.Menu').new(self.game,items,{tx=4,ty=0,tw=12,th=#items*2+2,cancelable=false})) +end +function M:update() + self.clock=self.clock+1 + local input=self.game and self.game.input;if not input or self._completed then return end + if input:wasPressed('left')then self:moveHorizontal(-1) + elseif input:wasPressed('right')then self:moveHorizontal(1) + elseif input:wasPressed('up')then self:moveVertical(-1) + elseif input:wasPressed('down')then self:moveVertical(1) + elseif input:wasPressed('select')then self:toggleCase() + elseif input:wasPressed('start')then self:jumpToEnd() + elseif input:wasPressed('b')then self:deleteCharacter() + elseif input:wasPressed('a')then + if self:onBottomRow()then + local target=self:bottomTarget() + if target==1 then self:toggleCase()elseif target==2 then self:deleteCharacter()else self:accept()end + else + self:addCharacter(self:characterAt(self.col,self.row)) + if #self.glyphs>=self.maxLength then self:jumpToEnd()end + end + end +end +function M:loadMonHeader(data,mon) + local def=data.pokemon and data.pokemon[mon.species] + self.monName=self.monName or mon.name or (def and def.name)or mon.species + self.monGender=mon.gender;self.monIcon=true + local entry=data.icons and data.icons.bySpecies and data.icons.bySpecies[mon.species] + if type(entry)=='table'then entry=entry.image end + if type(entry)=='string'then + local path,trueColor=require('src.pokemon.Sprites').iconPath(data,mon,entry,{kind='naming'}) + if path then local ok,img=pcall(Assets.image,path);if ok then self.iconImage=img end end + self.iconTrueColor=trueColor + end +end +function M:genderGlyph()return self.monGender=='male'and '♂'or self.monGender=='female'and '♀'or nil end +M.ICON_FRAME_STEPS=18 +function M:iconFrame()return self.monIcon and math.floor(self.clock/M.ICON_FRAME_STEPS)%2 or 0 end +function M:iconOrigin()return 16,self.monIcon and 12 or 16 end +function M:wantsFillScale()return not self.choosing end +function M:drawsWidescreen()return not self.choosing end +function M:paperColor()return self.palette and require('src.render.Gen2PaletteDraw').color(self.palette,1)or Chrome.DEFAULT_BOX_PALETTE[1]end +function M:drawBackdrop() + local G=love.graphics;local p=self:paperColor();G.setColor(p[1]/255,p[2]/255,p[3]/255,1) + G.rectangle('fill',0,0,160,144) + if self.tiles.border then + local function draw() + G.setColor(1,1,1,1) + for y=0,17 do for x=0,19 do G.draw(self.tiles.border,x*8,y*8)end end + end + local Pal=require('src.render.Gen2PaletteDraw') + if self.palette and Pal.available()then Pal.with(self.palette,draw)else draw()end + end +end +function M:clearPanel(tx,ty,tw,th) + local G=love.graphics;local p=self:paperColor();G.setColor(p[1]/255,p[2]/255,p[3]/255,1) + G.rectangle('fill',tx*8,ty*8,tw*8,th*8);G.setColor(0,0,0,1) +end +function M:drawEntry(tx,ty) + for i=1,self.maxLength do + local col=(i-1)%16;local row=math.floor((i-1)/16) + local x,y=(tx+col)*8,(ty+row*2)*8 + if self.mailMode then x,y=8+col*8,24+row*12 end + Font.draw(self.glyphs[i]or '-',x,y) + end +end +function M:cursorTile() + if self:onBottomRow()then return 2+(self:bottomTarget()-1)*6,self:keyboardTop()+self:bottomRow()*2,5 end + return 2+self.col*2,self:keyboardTop()+self.row*2,1 +end +function M:drawCursorTile(quad,x,y,flipX,flipY) + love.graphics.draw(self.tiles.cursor,quad,x,y,0,flipX and -1 or 1,flipY and -1 or 1,flipX and 8 or 0,flipY and 8 or 0) +end +function M:drawCursorBox(tx,ty,width) + local G=love.graphics;local image=self.tiles.cursor + if not image then Chrome.cursor(tx-1,ty);return end + local w,h=image:getDimensions() + if w<8 or h<8 then Chrome.cursor(tx-1,ty);return end + if not self.cursorQuads then + self.cursorQuads={corner=G.newQuad(0,0,8,8,w,h),edge=G.newQuad(0,h>=16 and 8 or 0,8,8,w,h)} + end + G.setColor(1,1,1,1) + local x,y=tx*8,ty*8;local corner,edge=self.cursorQuads.corner,self.cursorQuads.edge + local right=x+math.max(0,(width or 1)-1)*8 + self:drawCursorTile(corner,x-1,y-1,false,false);self:drawCursorTile(corner,right,y-1,true,false) + self:drawCursorTile(corner,x-1,y,false,true);self:drawCursorTile(corner,right,y,true,true) + for col=1,(width or 1)-2 do + self:drawCursorTile(edge,x+col*8,y-1,false,false);self:drawCursorTile(edge,x+col*8,y,false,true) + end +end +function M:drawPanel() + self:drawBackdrop();self:clearPanel(1,1,18,self.mailMode and 5 or self.isBox and 4 or 6) + self:clearPanel(1,self:keyboardTop(),18,self.isBox and 9 or 7);self:clearPanel(1,16,18,1) + if self.monName then + Chrome.printThrough(Strings("%s'S",self.monName),5,2,self.palette) + Chrome.printThrough(Strings(self.prompt),5,4,self.palette) + else Chrome.printThrough(Strings(self.prompt),5,2,self.palette)end + local gender=self:genderGlyph();if gender then Chrome.printThrough(gender,1,2,self.palette)end + self:drawEntry(5,self.isBox and 4 or 6) + if self.iconImage then + local G=love.graphics;local w,h=self.iconImage:getDimensions() + local y=self:iconFrame()*16;if y+16>h then y=0 end + local quad=self.iconQuad + if not quad then quad=G.newQuad(0,y,math.min(16,w),math.min(16,h),w,h);self.iconQuad=quad + elseif quad.setViewport then quad:setViewport(0,y,math.min(16,w),math.min(16,h),w,h)end + local function draw()G.setColor(1,1,1,1);G.draw(self.iconImage,quad,self:iconOrigin())end + local colors=self.iconColors;local Pal=require('src.render.Gen2PaletteDraw') + if colors and not(self.iconTrueColor and Pal.mode=='gbc')then Pal.with(colors,draw)else draw()end + end + for ri,row in ipairs(self:rows())do for ci,ch in ipairs(row)do + if ch~=''and ch~=' 'then Chrome.printThrough(Strings(ch),ci*2,self:keyboardTop()+(ri-1)*2,self.palette)end + end end + local labels={self.lower and Strings('UPPER')or Strings('lower'),Strings('DEL'),Strings('END')} + for i,label in ipairs(labels)do Chrome.printThrough(label,i==1 and 2 or 3+(i-1)*6,self:keyboardTop()+self:bottomRow()*2,self.palette)end + local function cursor()self:drawCursorBox(self:cursorTile())end + local Pal=require('src.render.Gen2PaletteDraw') + if self.palette and Pal.available()then Pal.with(self.palette,cursor)else cursor()end +end +function M:draw()if not self.choosing then self:drawPanel()end end +function M:drawWidescreen(w,h) + if self.choosing then return end + Chrome.withPanel(w,h,1,1,1,function()self:drawPanel()end) +end +return M diff --git a/src/ui/Gen2PackGfx.lua b/src/ui/Gen2PackGfx.lua new file mode 100644 index 000000000..0766dd977 --- /dev/null +++ b/src/ui/Gen2PackGfx.lua @@ -0,0 +1,161 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- The PACK screen's chrome, drawn from the cart's own tiles. +-- +-- engine/items/pack.asm builds this screen out of tiles, not rectangles, and +-- transcribing it that way is what makes the bag picture, the pocket plaque and +-- the ◀▶/▼▲ header land on the 8px grid by construction: +-- +-- Pack_InitGFX copies PackMenuGFX ($60 tiles) to vTiles2 tile $00, fills +-- rows 1-11 with the background tile $24, clears the item +-- area at (5,1) 15x11, and lays the header at (0,0) as the +-- 20 running tiles $28..$3b +-- PlacePackGFX lays $50..$5e as a 5x3 block at (0,3) -- the bag picture, +-- which DrawPackGFX swaps per pocket out of PackGFX +-- DrawPocketName lays a 5x3 block at (0,7) from its own 5x12 tilemap +-- _CGB_PackPals loads six BG palettes and colours five rectangles with +-- them: the two header halves, the CURSOR column (7,2) 1x9 +-- whose colour 3 is red, the pocket plaque (red) and the bag +-- picture (green) -- engine/gfx/cgb_layouts.asm:715-734 +-- +-- A cache from before the pack stage simply has no `pack` table; PackMenu +-- falls back to its plain boxes then. + +local Assets = require("src.render.Assets") +local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.Gen2Chrome") + +local PackGfx = {} +PackGfx.__index = PackGfx + +local SCREEN_W, SCREEN_H = 20, 18 +-- Textbox(0, TEXTBOX_Y - 2) with a 4-row interior: rows 12..17. +local DESCRIPTION_Y = 12 + +function PackGfx.new(menuGfx) + local self = setmetatable({}, PackGfx) + self.gfx = menuGfx and menuGfx.pack or nil + self.images = {} + self.quads = {} + if self.gfx then + -- paletteZones is a list of rectangles; flatten it to a per-cell lookup so + -- drawing a tile is one table read rather than a scan. + self.zone = {} + for _, z in ipairs(self.gfx.paletteZones or {}) do + local x0, y0, w, h, pal = z[1], z[2], z[3], z[4], z[5] + for y = y0, y0 + h - 1 do + for x = x0, x0 + w - 1 do + self.zone[y * SCREEN_W + x] = pal + end + end + end + end + return self +end + +function PackGfx:available() + return self.gfx ~= nil and self:image("menu") ~= nil +end + +function PackGfx:image(key) + local path = self.gfx and self.gfx[key] + if not path then return nil end + local cached = self.images[path] + if cached == nil then + local ok, image = pcall(Assets.image, path) + cached = ok and image or false + self.images[path] = cached + end + return cached or nil +end + +-- One 8x8 tile out of a sheet `tilesWide` tiles across. +function PackGfx:quad(image, tilesWide, index) + local key = tostring(image) .. ":" .. tilesWide .. ":" .. index + local quad = self.quads[key] + if not quad then + local w, h = image:getDimensions() + quad = love.graphics.newQuad( + (index % tilesWide) * 8, math.floor(index / tilesWide) * 8, 8, 8, w, h) + self.quads[key] = quad + end + return quad +end + +-- The palette a screen cell draws with: its attrmap zone, else palette 0. +function PackGfx:colorsAt(tx, ty) + local pals = self.gfx and self.gfx.palettes + if not pals then return nil end + local index = (self.zone and self.zone[ty * SCREEN_W + tx]) or 1 + return pals[index] +end + +function PackGfx:blit(image, tilesWide, index, tx, ty) + if not image then return end + local G = love.graphics + G.setColor(1, 1, 1, 1) + local colors = self:colorsAt(tx, ty) + local function body() + G.draw(image, self:quad(image, tilesWide, index), tx * 8, ty * 8) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end +end + +-- A tile out of PackMenuGFX, addressed by its VRAM id ($00 is the sheet's +-- first tile, so id and index are the same here). +function PackGfx:menuTile(tile, tx, ty) + self:blit(self:image("menu"), self.gfx.menuTilesWide or 16, tile, tx, ty) +end + +-- Everything behind the item list: the header strip, the background column, +-- the bag picture for this pocket, and the pocket plaque. +function PackGfx:draw(pocketId) + Chrome.paletteFill(0, 0, SCREEN_W * 8, SCREEN_H * 8, Chrome.DEFAULT_BOX_PALETTE) + + -- ◀▶ POCKET ▼▲ ITEMS: 20 running tiles from $28. + local header = self.gfx.headerFirstTile or 0x28 + for tx = 0, SCREEN_W - 1 do + self:menuTile(header + tx, tx, 0) + end + + -- Rows 1-11 are filled with $24; the item area at (5,1) is then cleared, so + -- only the left five columns keep the pattern. + local background = self.gfx.backgroundTile or 0x24 + for ty = 1, 11 do + for tx = 0, 4 do + self:menuTile(background, tx, ty) + end + end + + -- The bag picture: 15 tiles, 5 across, from this pocket's row in PackGFX. + local packImage = self:image("pack") + local firstRow = self.gfx.pocketPicture and self.gfx.pocketPicture[pocketId] + if packImage and firstRow then + local wide = self.gfx.packTilesWide or 5 + for i = 0, wide * (self.gfx.packTilesHigh or 3) - 1 do + self:blit(packImage, wide, firstRow + i, + i % wide, 3 + math.floor(i / wide)) + end + end + + -- The pocket plaque, from DrawPocketName's own tilemap. + local order = self.gfx.pocketOrder or {} + local block + for i, id in ipairs(order) do + if id == pocketId then block = self.gfx.pocketName[i] end + end + if block then + for i, tile in ipairs(block) do + self:menuTile(tile, (i - 1) % 5, 7 + math.floor((i - 1) / 5)) + end + end +end + +PackGfx.DESCRIPTION_Y = DESCRIPTION_Y +PackGfx.SCREEN_W = SCREEN_W + +return PackGfx diff --git a/src/ui/Gen2PackMenu.lua b/src/ui/Gen2PackMenu.lua new file mode 100644 index 000000000..e84f3a315 --- /dev/null +++ b/src/ui/Gen2PackMenu.lua @@ -0,0 +1,1164 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- Gen 2 PACK: four pockets instead of Gen 1's one bag. +-- +-- constants/item_data_constants.asm orders them ITEM, KEY_ITEM, BALL, TM_HM, +-- and each item's own ItemAttributes row says which pocket it lives in -- so +-- the flat id->count inventory the engine already keeps is bucketed here at +-- draw time rather than stored four ways. +-- +-- Left/right switch pockets, up/down scroll the list, A selects, B closes. +-- A TM/HM row shows the move it teaches (attributes carry `teaches`), which is +-- the whole reason the TM pocket is readable at all -- the item names are just +-- "TM01".."HM07". + +local Bag = require("src.inventory.Bag") +local Chrome = require("src.ui.Gen2Chrome") +local CommonText = require("src.core.Gen2CommonText") +local GameVersion = require("src.core.GameVersion") +local PackGfx = require("src.ui.Gen2PackGfx") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local WaitPlaySFX = require("src.ui.Gen2WaitPlaySFX") +local MenuRepeat = require("src.ui.MenuRepeat") + +-- constants/sfx_constants.asm:3, :28 +local SFX_DEX_FANFARE_50_79, SFX_WRONG = 0, 25 + +local LIST_DIRS = { "up", "down" } + +local PackMenu = {} +PackMenu.__index = PackMenu +PackMenu.isOpaque = true + +-- ItemsPocketMenuHeader (engine/items/pack.asm): menu_coords 7, 1, 19, 11 -- +-- so the list body starts one row and one column inside that box, five rows of +-- two, with the quantity on each entry's second line. +local LIST_X = 8 +local LIST_Y = 2 +local LIST_SPACING = 2 + +-- Display order and titles. The cart shows the pocket name in a tab strip +-- across the top; these are the strings it uses. +local POCKETS = { + { id = "ITEM", label = Strings.source("ITEMS") }, + { id = "BALL", label = Strings.source("POKé BALLS") }, + { id = "KEY_ITEM", label = Strings.source("KEY ITEMS") }, + { id = "TM_HM", label = Strings.source("TM/HM") }, +} + +-- Five item rows fit under the tab strip, two lines each. +local VISIBLE_ROWS = 5 + +-- The item submenu (.ItemBallsKey_LoadSubmenu, engine/items/pack.asm:243). +-- A on a row does NOT use the item on the cart: it opens a menu whose rows are +-- picked from the item's own ITEMATTR_PERMISSIONS bits and its field-menu +-- nibble, and the six headers between MenuHeader_UsableKeyItem and +-- MenuHeader_HoldableItem are every combination of them: +-- +-- CAN toss + CAN select + usable USE / GIVE / TOSS / SEL / QUIT +-- CAN toss + CAN select + NOUSE GIVE / TOSS / SEL / QUIT +-- CAN toss + cant select + usable USE / GIVE / TOSS / QUIT +-- CAN toss + cant select + NOUSE GIVE / TOSS / QUIT +-- cant toss + cant select USE / QUIT +-- cant toss + CAN select USE / SEL / QUIT +-- +-- (the labels read backwards against the header names -- _CheckTossableItem +-- and CheckSelectableItem both answer NON-zero for the item that CANNOT, so +-- pack.asm's `.tossable` arm is the untossable one.) The TM/HM pocket has a +-- pair of its own, .MenuHeader1 / .MenuHeader2 at pack.asm:160. +-- +-- Without this menu a TOSS is unreachable and the PACK is a one-verb screen, +-- which is what "the pack only offers USE" is. +local SUBMENU_LABEL = { + use = Strings.source("USE"), give = Strings.source("GIVE"), + toss = Strings.source("TOSS"), sel = Strings.source("SEL"), + quit = Strings.source("QUIT"), +} + +-- _AskThrowAwayText / _AskQuantityThrowAwayText / _ThrewAwayText +-- (data/text/common_2.asm), the three lines TossMenu prints in order. +local TOSS_HOW_MANY = Strings.source("Throw away how\nmany?") + +-- _AskItemMoveText (data/text/common_2.asm:322), printed while wSwitchItem +-- holds a row and the cursor is looking for its new home. +local ASK_ITEM_MOVE = Strings.source("Where should this\nbe moved to?") + +-- _YouDontHaveAMonText and .AnEggCantHoldAnItemText, GiveItem's two refusals. +local NO_POKEMON = Strings.source("You don't have a\n#MON!") +local EGG_CANT_HOLD = Strings.source("An EGG can't hold\nan item.") + +-- _CGB_PackPals' .KrisPackPals arm, and the BATTLETYPE_TUTORIAL test above it +-- that forces the DUDE's (../pokecrystal/engine/gfx/cgb_layouts.asm:770-786). +local function packGfxFor(menuGfx, save, tutorial) + local pack = menuGfx and menuGfx.pack + if not (pack and pack.palettesFemale) then return menuGfx end + if tutorial or not (save and save.player and save.player.gender == "girl") then return menuGfx end + local female = {} + for key, value in pairs(pack) do female[key] = value end + female.palettes = pack.palettesFemale + local out = {} + for key, value in pairs(menuGfx) do out[key] = value end + out.pack = female + return out +end + +-- The PACK's cursor bytes. Every pocket menu restores its own cursor and +-- scroll before ScrollingMenu and writes them back after -- `ld a, +-- [wItemsPocketCursor] / ld [wMenuCursorPosition], a` ... `ld a, [wMenuCursorY] +-- / ld [wItemsPocketCursor], a` (engine/items/pack.asm:76), and the same pair +-- for wKeyItemsPocketCursor, wBallsPocketCursor and wTMHMPocketCursor -- while +-- InitPackBuffers opens the PACK on wLastPocket, which Pack's own exit path +-- stored. They are WRAM, not save data: they last for the session and must not +-- survive a reload, and CleanUpBattleRAM is the only thing that clears them +-- (engine/battle/core.asm:7994, which pointedly leaves the TM/HM pair in place). +local function cursorStore(game) + if not game then return nil end + local mem = game.packCursor + if not mem then + mem = { cursor = {}, scroll = {} } + game.packCursor = mem + end + return mem +end + +-- home/text.asm:424 +local PAGE, SCROLL, LINE = "\f", "\v", "\n" -- strings: decoded text control bytes + +local function messageTokens(text) + if type(text) ~= "string" then return text end + local out, start = {}, 1 + while start <= #text + 1 do + local marker = text:find("[\n\f\v]", start) + out[#out + 1] = text:sub(start, (marker or (#text + 1)) - 1) + if not marker then break end + local code = text:sub(marker, marker) + if code == PAGE then out[#out + 1] = PAGE + elseif code == SCROLL then out[#out + 1] = SCROLL end + start = marker + 1 + end + return out +end + +-- home/text.asm:397 +local function messagePages(lines) + if type(lines) == "string" then return CommonText.pages(lines) or {} end + local pages, rows = {}, {} + local function flush(scroll) + if #rows > 0 then pages[#pages + 1] = rows end + rows = scroll and { rows[#rows] or "" } or {} + end + for _, line in ipairs(lines or {}) do + if line == PAGE then flush(false) + elseif line == SCROLL then flush(true) + else rows[#rows + 1] = line end + end + flush(false) + return pages +end + +-- data/text/common_2.asm:627 +local OAK_THIS_ISNT_THE_TIME = Strings.source( + "OAK: {PLAYER}!\nThis isn't the\vtime to use that!") + +-- RepelUsedEarlierIsStillInEffectText (data/text/common_3.asm): static, and +-- names REPEL no matter which of the three repel items is the one actually +-- still ticking down -- the cart never reads the active item back out to +-- print it. +-- ../pokecrystal/data/text/common_3.asm:1270 -- `cont` again, so two pages. +local REPEL_STILL_ACTIVE = Strings.source( + "The REPEL used\nearlier is still\vin effect.") + +function PackMenu:wantsFillScale() return true end +function PackMenu:drawsWidescreen() return true end + +-- opts: save, items (items.lua), onChoose(itemId, count), onClose(), +-- pocket (starting pocket id), world (the overworld a field item acts on; +-- defaults to game.world, which is where Game2 keeps it), +-- give (DepositSellPack: the PACK is a CHOOSER, so selecting a row hands the +-- id back instead of running the item's field effect), +-- battle (BattlePack rather than the field Pack: a different jumptable, which +-- dispatches on the item's BATTLE menu nibble and never runs a field effect) +function PackMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, PackMenu) + self.game = game + self.save = opts.save or (game and game.save) + if self.save ~= (game and game.save) then + self.game = setmetatable({save=self.save,writeSave=false},{__index=game}) + end + self.items = opts.items or (game and game.data and game.data.items) + -- Bag.order / Bag.move read `.items` off an injectable data table, and the + -- one this screen draws from is not always the Data singleton. + self.bagData = setmetatable({ items = self.items }, {__index=game and game.data or {}}) + self.world = opts.world or (self.save == (game and game.save) and game.overworld) or {} + self.onChoose = opts.onChoose + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + -- DepositSellPack rather than the PACK's own UseItem: a chooser must not run + -- a rod or the ITEMFINDER on the way past (src/ui/gen2/HeldItemMenu.lua). + self.give = opts.give and true or false + self.battle = opts.battle and true or false + -- engine/items/pack.asm:1068 TutorialPack + self.tutorial = opts.tutorial and true or false + self.cursorStore = cursorStore(game) + -- engine/menus/scrolling_menu.asm:6 + self.hold = MenuRepeat.new(MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE) + self.pocketIndex = 1 + -- wLastPocket, unless the caller names one: DepositSellInitPackBuffers writes + -- ITEM_POCKET over it, so an explicit pocket still wins. + local startPocket = opts.pocket + or (self.cursorStore and self.cursorStore.pocket) + if startPocket then + for i, p in ipairs(POCKETS) do + if p.id == startPocket then self.pocketIndex = i break end + end + end + self:restoreCursor() + -- The cart's own PACK tiles, when the cache has them. + self.gfx = PackGfx.new(packGfxFor( + game and game.data and game.data.gen2MenuGfx, self.save, opts.tutorial)) + self:rebuild() + return self +end + +function PackMenu:close() + self:storeCursor() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose() end +end + +function PackMenu:pocket() + return POCKETS[self.pocketIndex] +end + +-- The pair of loads each pocket menu runs before ScrollingMenu. rebuild() +-- clamps the row afterwards, so a pocket that shrank while the PACK was closed +-- lands on its last entry rather than past it. +function PackMenu:restoreCursor() + local mem = self.cursorStore + local pocketId = self:pocket().id + self.index = (mem and mem.cursor[pocketId]) or 1 + self.scroll = (mem and mem.scroll[pocketId]) or 0 +end + +-- And the pair of stores after it, plus Pack's `.done` writing wCurPocket into +-- wLastPocket. +function PackMenu:storeCursor() + local mem = self.cursorStore + if not mem then return end + local pocketId = self:pocket().id + mem.cursor[pocketId] = self.index + mem.scroll[pocketId] = self.scroll + mem.pocket = pocketId +end + +-- Which pocket an item belongs to. Items imported before attributes existed +-- have no `pocket`; treat those as general items rather than dropping them, +-- so an older cache still shows a full bag. +function PackMenu:pocketOf(itemId) + local def = self.items and self.items[itemId] + return (def and def.pocket) or "ITEM" +end + +-- engine/items/tmhm.asm:341 +function PackMenu:tmhmKey(itemId) + local def = self.items and self.items[itemId] + local n = def and tonumber(def.tmNumber or (def.machine and def.machine.number)) + if n then return n + ((def.machine and def.machine.kind == "HM") and 100 or 0) end + return 1000 + ((def and tonumber(def.index)) or 0) +end + +-- The name on the row. An inventory key with no ItemAttributes row behind it +-- (an older cache, a mod's own item, a driver seeding an id that is not in +-- items.lua) still has to draw something a person can read, so the id stands +-- in for the name with its underscores opened out. +function PackMenu.label(itemId, def) + if def and def.name then return def.name end + return (tostring(itemId):gsub("_", " ")) +end + +-- TMHM_DisplayPocketItems (engine/items/tmhm.asm:381-385) places GetMoveName's +-- string three tiles right of the row's number, so a TM/HM row reads as the +-- MOVE's name and the TM's own item name is never printed. +function PackMenu:moveLabel(moveId) + if not moveId then return nil end + local moves = self.game and self.game.data and self.game.data.moves + local def = moves and moves[moveId] + return (def and def.name) or (tostring(moveId):gsub("_", " ")) +end + +-- engine/items/tmhm.asm:357-375 -- the number a TM/HM row prints: a TM with +-- PRINTNUM_LEADINGZEROS, an HM as 'H' and its own left-aligned ordinal. +local function tmhmLabelFor(itemId, def) + local digits = tostring((def and def.tmLabel) or (def and def.name) + or itemId):match("(%d+)") + local n = tonumber(digits) + if not n then return nil end + if tostring(itemId):sub(1, 3) == "HM_" then return "H" .. n end + return ("%02d"):format(n) +end + +function PackMenu:rebuild() + local pocket = self:pocket().id + local rows = {} + local save = self.save + local inventory = (save and save.inventory) or {} + -- Row order IS wBagItems' order, which is what SELECT rewrites. + local order = (save and save.inventory) + and Bag.order(save, self.bagData) or {} + for _, itemId in ipairs(order) do + local raw = inventory[itemId] + -- A count that is not a number at all (a hand-written save, a mod, an old + -- migration) counts as one rather than raising out of the draw. + local count = tonumber(raw) or (raw and 1) or 0 + if count > 0 and self:pocketOf(itemId) == pocket then + local def = self.items and self.items[itemId] + rows[#rows + 1] = { + id = itemId, + count = count, + name = PackMenu.label(itemId, def), + teaches = self:moveLabel(self:moveOf(itemId)), + tmNumber = def and (def.tmNumber or (def.machine and def.machine.number)), + tmhmLabel = (pocket == "TM_HM" and tmhmLabelFor(itemId, def)) or nil, + -- A KEY_ITEM never shows one, and engine/items/tmhm.asm:390 skips the + -- count for an HM only -- a TM prints ×NN like any other stack. + showCount = pocket == "ITEM" or pocket == "BALL" + or (pocket == "TM_HM" and not (def and def.machine and def.machine.kind == "HM") and tostring(itemId):sub(1, 3) ~= "HM_"), + } + end + end + -- engine/items/tmhm.asm:341 + if pocket == "TM_HM" then + table.sort(rows, function(a, b) + local ka, kb = self:tmhmKey(a.id), self:tmhmKey(b.id) + if ka ~= kb then return ka < kb end + return a.id < b.id + end) + end + self.rows = rows + self.index = math.min(self.index, #rows + 1) + if self.index < 1 then self.index = 1 end + self:ensureVisible() +end + +function PackMenu:total() + return #self.rows + 1 -- CANCEL +end + +function PackMenu:isCancel() + return self.index > #self.rows +end + +-- home/menu.asm:746, :758 +function PackMenu:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then Sound.play(data, name) end +end + +-- engine/items/pack.asm:1312, home/audio.asm:220 +function PackMenu:playSfxTwice(name) + self:playSfx(name) + self.repeatSfx = WaitPlaySFX.arm(name) +end + +function PackMenu:tickRepeatSfx() + local pending = self.repeatSfx + if not pending then return false end + if WaitPlaySFX.waiting(pending, self.game) then return true end + self.repeatSfx = nil + self:playSfx(pending.name) + return false +end + +function PackMenu:showMessage(lines) + lines = messageTokens(lines) + self.message, self.messagePage = lines, 1 + self.pagesSource, self.pages = lines, messagePages(lines) +end + +function PackMenu:pagesFor(lines) + if self.pagesSource ~= lines then + self.pagesSource, self.pages = lines, messagePages(lines) + end + return self.pages +end + +function PackMenu:ensureVisible() + if self.index <= self.scroll then + self.scroll = self.index - 1 + elseif self.index > self.scroll + VISIBLE_ROWS then + self.scroll = self.index - VISIBLE_ROWS + end + self.scroll = math.max(0, math.min(self.scroll, + math.max(0, self:total() - VISIBLE_ROWS))) +end + +function PackMenu:switchPocket(delta) + -- The pocket being left keeps its own cursor and scroll; the one being + -- entered restores its own (pack.asm:76). + self:storeCursor() + self.pocketIndex = (self.pocketIndex - 1 + delta) % #POCKETS + 1 + self:restoreCursor() + self:rebuild() + self:storeCursor() + -- engine/items/pack.asm:1268 + self:playSfx("Sfx_SwitchPockets") +end + +-- The player name OakThisIsntTheTimeText addresses, same fallback the SAVE +-- screen uses when a driver runs without a named save. +function PackMenu:playerName() + return (self.save and self.save.player and self.save.player.name) or "GOLD" +end + +-- .Field (engine/items/pack.asm UseItem): a field-usable item runs its effect, +-- and only a NON-ZERO wItemEffectSucceeded sets PACKSTATE_QUITRUNSCRIPT -- +-- which quits the PACK, and with it the START menu it was opened from, so the +-- script the effect queued can run in the overworld. A zero drops into .Oak +-- instead, which prints inside the PACK and leaves it exactly where it was. +function PackMenu:exitToField() + self:storeCursor() + local stack = self.game and self.game.stack + if stack and stack.pop and stack.top then + while stack:top() and stack:top() ~= self.game.overworld do stack:pop() end + -- ../pokecrystal/home/map.asm:1927-1940 + local world = self.game.overworld + if world and world.exitMenusFade and not world.mapSetup then + world:exitMenusFade() + end + elseif self.onClose then + -- No clear on this stack (a test harness, or a screen pushed on its own): + -- at least give the pack back. + self.onClose() + end +end + +-- Whether this pack is BattlePack (engine/items/pack.asm:627) rather than the +-- field Pack. The two are separate jumptables: nothing opened over a battle +-- may reach a field effect, so the live overworld's own flag counts as well as +-- the caller saying so. +function PackMenu:inBattle() + if self.battle then return true end + return (self.world and self.world.battleActive) and true or false +end + +-- A on a row. A field item the world claims never reaches onChoose: the world +-- has already run its effect, and all that is left is which of UseItem's two +-- endings the PACK takes. +function PackMenu:useSelected() + local row = self.rows[self.index] + if not row then return end + -- ScrollingMenu has returned by the time a row is acted on, so the pocket's + -- cursor bytes are already written back before the submenu opens. + self:storeCursor() + if self.give then + if self.onChoose then self.onChoose(row.id, row.count) end + return + end + -- BattlePack's .Use dispatches on the item's BATTLE menu nibble, and the + -- first four entries of its .ItemFunctionJumptable are all .Oak: a battle- + -- NOUSE item prints OakThisIsntTheTimeText inside the pack and goes nowhere. + -- Everything else is handed to the screen that opened this one + -- (src/ui/gen2/BattleState.lua), which owns the balls, the X items and the + -- party-target heals. The FIELD jumptable is not on this path at all. + if self:inBattle() then + local def = self.items and self.items[row.id] + if def and def.battleMenu == "ITEMMENU_NOUSE" then + self:showMessage(Strings(OAK_THIS_ISNT_THE_TIME)) + return + end + if self.onChoose then + self.staleRows = true + self.onChoose(row.id, row.count) + end + return + end + if self.world == (self.game and self.game.overworld) then + self.staleRows = true + local owner = self + return require("src.ui.BagMenu").useItem(self.game, nil, row.id, { + close=function() owner:close() end, + rebuild=function() owner:rebuild() end, + items={}, index=1, + }) + end + local world = self.world + local result, extra = nil, nil + if world and world.useFieldItem then result, extra = world:useFieldItem(row.id) end + if result then + if result == "nowhere" then + self:showMessage(Strings(OAK_THIS_ISNT_THE_TIME)) + elseif result == "coin_case" then + -- _CoinCaseCountText (data/text/common_3.asm:336): "Coins:" then the + -- count, text_decimal 4 digits with PRINTNUM_LEFTALIGN_F so no padding. + self:showMessage(Strings(Strings.source("Coins:\n%d"), extra or 0)) + elseif result == "blue_card" then + -- _BlueCardBalanceText (../pokecrystal/data/text/common_3.asm:1297). + self:showMessage(Strings(Strings.source("You now have\n%d points."), + extra or 0)) + elseif result == "repel_used" then + -- ItemUsedText (data/text/common_3.asm): " used the\n." + -- World already wrote the counter and took the item out of the bag, so + -- the row list is rebuilt under the message the way a TOSS would. + self:showMessage(Strings(Strings.source("{PLAYER} used the\n%s."), row.name)) + self:rebuild() + elseif result == "repel_active" then + self:showMessage(Strings(REPEL_STILL_ACTIVE)) + elseif result == "trophy_sent" then + -- ../pokecrystal/data/text/common_3.asm:1338 + if world.playSfxNamed then + world:playSfxNamed("Sfx_DexFanfare5079", SFX_DEX_FANFARE_50_79) + end + self:showMessage(Strings(Strings.source( + "There was a trophy\ninside!\f{PLAYER} sent the\ntrophy home."))) + self:rebuild() + else + self:exitToField() + end + return + end + -- UseItem's FIELD-pack tail (engine/items/tmhm.asm:73): a TM/HM row opens + -- the party to teach, and a field-NOUSE item -- an X ATTACK or a POKé DOLL + -- used from the field PACK -- prints OakThisIsntTheTimeText and goes + -- nowhere (UseItem's jumptable's first four entries are all .Oak). Both + -- checks live behind `world.useFieldItem` on purpose: DepositSellPack (the + -- mart's SELL, the item PC's DEPOSIT) is a chooser whose jumptable is four + -- ScrollingMenus and never reaches tmhm.asm, so a TM picked there hands + -- its row to onChoose like every other item instead of opening the teach + -- party. The battle pack returned above, and the catch tutorial's DUDE + -- pack carries a stub world with no useFieldItem at all -- its POKE BALL + -- is field-NOUSE and must still reach the throw. + if world and world.useFieldItem then + local def = self.items and self.items[row.id] + if def and def.teaches then + self:openTeachParty(row) + return + end + if def and def.fieldMenu == "ITEMMENU_NOUSE" then + self:showMessage(Strings(OAK_THIS_ISNT_THE_TIME)) + return + end + end + if self.onChoose then + -- The .Party flow runs OVER this pack and UseDisposableItem spends the + -- item out from under the row list; rebuild on the first frame the pack + -- owns again, which is UseItem .Party's own Pack_InitGFX redraw. + self.staleRows = true + self.onChoose(row.id, row.count) + end +end + +-- ------------------------------------------------------------- the submenu + +-- +-- DepositSellPack (pack.asm:931) -- the mart's SELL, the item PC's DEPOSIT +-- and HeldItemMenu's GIVE. Its jumptable is four ScrollingMenus and +-- nothing else, which is why `give` and the empty-world callers answer +-- their chooser directly. +-- TutorialPack (pack.asm:1068) -- the DUDE's pack, same shape. +-- +-- engine/items/pack.asm:627 BattlePack +-- ItemPcMenu:enterDeposit both pass `world = {}` precisely so no field effect +-- can fire, and Game2's START-menu PACK passes the real overworld. +function PackMenu:hasSubmenu() + if self.give then return false end + if self.tutorial then return false end + if self:inBattle() then return true end + local world = self.world + return (world and (world == (self.game and self.game.overworld) or world.useFieldItem)) and true or false +end + +-- ITEMATTR_PERMISSIONS' two bits and the field-menu nibble, as the cart reads +-- them. An id with no attributes row at all (an older cache, a mod's item) +-- counts as tossable and unusable-for-SEL, which is the same lean the sell +-- gate and ItemPcMenu:cantToss take. +function PackMenu:submenuRows(itemId) + local def = self.items and self.items[itemId] + if self:inBattle() then + -- engine/items/pack.asm:783 ItemSubmenu, :745 .TMHMPocketMenu + local usable = not (def and def.battleMenu == "ITEMMENU_NOUSE") + if self:pocket().id == "TM_HM" then usable = false end + if usable then return { "use", "quit" } end + return { "quit" } + end + local canToss = not (def and (def.canToss == false or def.cantToss or def.keyItem + or (def.machine and def.machine.kind == "HM"))) + local canSelect = def ~= nil and (def.canSelect == true or def.registerable == true) + local usable = not (def and def.fieldMenu == "ITEMMENU_NOUSE") + local rows = {} + local function add(id) rows[#rows + 1] = id end + if self:pocket().id == "TM_HM" then + -- .TMHMPocketMenu's own pair: an HM cannot be tossed and gets USE / QUIT, + -- a TM gets USE / GIVE / QUIT. Neither has a TOSS row. + add("use") + if canToss then add("give") end + add("quit") + return rows + end + if not canToss then + -- MenuHeader_UnusableItem / MenuHeader_UnusableKeyItem: the untossable arm + -- never looks at the menu nibble, so a key item always offers USE. + add("use") + if canSelect then add("sel") end + add("quit") + return rows + end + if usable then add("use") end + add("give") + add("toss") + if canSelect then add("sel") end + add("quit") + return rows +end + +function PackMenu:openSubmenu() + local row = self.rows[self.index] + if not row then return end + -- ScrollingMenu has returned by the time the submenu opens, so the pocket's + -- cursor bytes are written back first (pack.asm:76). + self:storeCursor() + self.submenu = { + row = row, + rows = self:submenuRows(row.id), + index = 1, -- `db 1 ; default option` + } +end + +function PackMenu:closeSubmenu() + self.submenu = nil +end + +function PackMenu:chooseSubmenu() + local menu = self.submenu + if not menu then return end + local id = menu.rows[menu.index] + local row = menu.row + if id == "quit" then + -- QuitItemSubmenu: a bare `ret`, back to the pocket list. + self:closeSubmenu() + elseif id == "use" then + self:closeSubmenu() + self:useSelected() + elseif id == "sel" then + self:closeSubmenu() + self:registerSelected() + elseif id == "toss" then + self:closeSubmenu() + self:tossItem(row) + elseif id == "give" then + self:closeSubmenu() + self:giveItem(row) + end +end + +-- ------------------------------------------------------------------- TOSS + +-- BuySellToss_InterpretJoypad (engine/items/buy_sell_toss.asm): up and down +-- wrap through the ends, left and right step by ten and clamp. The same +-- stepper src/ui/gen2/ItemPcMenu.lua uses, because it is the same loop. +local function qtyStep(qty, max, delta) + local n = qty + delta + if delta == 1 then + if n > max then n = 1 end + elseif delta == -1 then + if n < 1 then n = max end + elseif delta > 0 then + if n > max then n = max end + else + if n <= 0 then n = 1 end + end + return n +end + +-- TossMenu (engine/items/pack.asm:477): "Throw away how many?" over +-- SelectQuantityToToss, then the count in a yes/no, then TossItem and +-- "Threw away (S)." Backing out of either question is `jr c, .finish` +-- -- the item is untouched and the PACK is exactly where it was. +function PackMenu:tossItem(row) + if not row then return end + self:showMessage(Strings(TOSS_HOW_MANY)) + self.qtyState = { + row = row, + qty = 1, + max = row.count or 1, + } +end + +function PackMenu:confirmToss() + local state = self.qtyState + if not state then return end + self.qtyState = nil + local row, qty = state.row, state.qty + self.confirm = { + prompt = messageTokens(Strings(Strings.source( + "Throw away %d\n%s(S)?"), qty, row.name)), + -- YesNoBox opens on YES; B and NO are the same `jr c, .finish`. + choice = 1, + onYes = function() + Bag.remove(self.save, row.id, qty) + self:rebuild() + self:showMessage(Strings(Strings.source("Threw away\n%s(S)."), row.name)) + end, + } +end + +-- ------------------------------------------------------------------- GIVE + +-- GiveItem (engine/items/pack.asm:562): the party list under +-- PARTYMENUACTION_GIVE_ITEM ("To which ?"), an EGG refused with +-- .AnEggCantHoldAnItemText, and everything else handed to +-- TryGiveItemToPartymon -- which is exactly what the party's own GIVE row runs +-- (src/ui/gen2/HeldItemMenu.lua), so the two doors share one routine rather +-- than each growing a copy of the swap question and the mail keyboard. +function PackMenu:giveItem(row) + self.staleRows = true + return require("src.ui.BagMenu").giveItem(self.game, row.id, function() self:rebuild() end) +end + +function PackMenu:giveToSlot(slot, row) + local mon = self.save and self.save.party and self.save.party[slot] + if not mon then return false end + return require("src.ui.BagMenu").handOver(self.game, mon, row.id, function() self:rebuild() end) +end + +function PackMenu:openTeachParty(row) + self.staleRows = true + local owner = self + return require("src.ui.BagMenu").useItem(self.game, nil, row.id, { + close=function() owner:close() end, rebuild=function() owner:rebuild() end, + items={}, index=1, + }) +end + +function PackMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + if self.staleRows then + self.staleRows = nil + self:rebuild() + end + -- engine/items/pack.asm:1312, home/audio.asm:225 + if self:tickRepeatSfx() then return end + -- Pack_PrintTextNoScroll ends on a `prompt`, so the message holds the PACK + -- until a button clears it and the list is untouchable underneath. The + -- quantity selector is the one thing drawn OVER a message rather than under + -- it: "Throw away how many?" is printed and SelectQuantityToToss runs on top + -- of it, so that pair is stepped before the message is cleared. + if self.qtyState then + self:updateQuantity(input) + return + end + -- engine/items/pack.asm:1237 Pack_InterpretJoypad .switching_item + if self.switching then + self:updateSwitch(input) + return + end + -- home/joypad.asm:383 + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then + local page = (self.messagePage or 1) + 1 + if page <= #self:pagesFor(self.message) then + self.messagePage = page + self:playSfx("Sfx_ReadText2") + else + self.message, self.messagePage = nil, nil + end + end + return + end + if self.confirm then + self:updateConfirm(input) + return + end + if self.submenu then + self:updateSubmenu(input) + return + end + local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS) + if input:wasPressed("left") then + self:switchPocket(-1) + return + elseif input:wasPressed("right") then + self:switchPocket(1) + return + elseif dir == "up" then + self:stepCursor(-1, edge) + return + elseif dir == "down" then + self:stepCursor(1, edge) + return + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self:storeCursor() + self:close() + return + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + if self:isCancel() then + self:storeCursor() + self:close() + elseif self:hasSubmenu() then + -- Pack_InterpretJoypad's A falls through to .ItemBallsKey_LoadSubmenu: + -- the row is chosen, not used. + self:openSubmenu() + else + self:useSelected() + end + return + elseif input:wasPressed("select") then + self:armSwitch() + return + end +end + +-- engine/menus/scrolling_menu.asm +function PackMenu:stepCursor(delta, edge) + local total = self:total() + local next = self.index + delta + if next < 1 then + next = edge and total or 1 + elseif next > total then + next = edge and 1 or total + end + self.index = next + self:ensureVisible() +end + +-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select +-- engine/items/tmhm.asm:207 -- the TM/HM pocket's joypad filter drops SELECT. +function PackMenu:armSwitch() + if self:pocket().id == "TM_HM" then return end + if self:isCancel() then return end + if not self.rows[self.index] then return end + self.switching = self.index + self:showMessage(Strings(ASK_ITEM_MOVE)) +end + +-- `.switching_item` (engine/items/pack.asm:1297): A or SELECT places, B backs +-- out, and left/right cannot leave the pocket mid-move. +function PackMenu:updateSwitch(input) + local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS) + if dir == "up" then + self:stepCursor(-1, edge) + elseif dir == "down" then + self:stepCursor(1, edge) + elseif input:wasPressed("a") or input:wasPressed("select") then + self:placeSwitch() + elseif input:wasPressed("b") then + self:endSwitch() + end +end + +-- engine/items/pack.asm:1307 .place_insert / .end_switch +function PackMenu:placeSwitch() + local from = self.switching + local row = self.rows[from] + if row and not self:isCancel() and self.index ~= from then + Bag.move(self.save, row.id, self:pocket().id, self.index, self.bagData) + self:rebuild() + self:storeCursor() + end + -- engine/items/pack.asm:1309, :1311 + self:playSfxTwice("Sfx_SwitchPokemon") + self:endSwitch() +end + +function PackMenu:endSwitch() + self.switching = nil + self.message, self.messagePage = nil, nil +end + +-- VerticalMenu over the submenu rows: up/down wrap, A picks, B is the carry +-- that ExitMenu answers with (`ret c`), which is QUIT by another name. +function PackMenu:updateSubmenu(input) + local menu = self.submenu + local total = #menu.rows + if input:wasPressed("up") then + menu.index = menu.index > 1 and menu.index - 1 or total + elseif input:wasPressed("down") then + menu.index = menu.index < total and menu.index + 1 or 1 + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self:chooseSubmenu() + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self:closeSubmenu() + end +end + +-- Toss_Sell_Loop: the count is stepped until A takes it or B backs out, and +-- backing out is the whole toss cancelled. +function PackMenu:updateQuantity(input) + local state = self.qtyState + if input:wasPressed("up") then + state.qty = qtyStep(state.qty, state.max, 1) + elseif input:wasPressed("down") then + state.qty = qtyStep(state.qty, state.max, -1) + elseif input:wasPressed("right") then + state.qty = qtyStep(state.qty, state.max, 10) + elseif input:wasPressed("left") then + state.qty = qtyStep(state.qty, state.max, -10) + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + self.message, self.messagePage = nil, nil + self:confirmToss() + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.qtyState = nil + self.message, self.messagePage = nil, nil + end +end + +-- YesNoBox: up/down flip, A takes the highlighted row, B is NO. +function PackMenu:updateConfirm(input) + local confirm = self.confirm + if input:wasPressed("up") or input:wasPressed("down") then + confirm.choice = confirm.choice == 1 and 2 or 1 + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.confirm = nil + if confirm.onNo then confirm.onNo() end + elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") + local yes = confirm.choice == 1 + self.confirm = nil + if yes then + if confirm.onYes then confirm.onYes() end + elseif confirm.onNo then + confirm.onNo() + end + end +end + +-- RegisterItem (engine/items/pack.asm), the submenu's SEL row and its ONLY +-- door -- the cart's SELECT is the bag's own item shuffle (see armSwitch). +-- World:registerItem re-runs CheckSelectableItem's gate (TM/HM and anything +-- CANT_SELECT_F refuses), so it cannot register what the cart would not. +function PackMenu:registerSelected() + if self:isCancel() then return end + local row = self.rows[self.index] + if not row then return end + -- BattlePack shares Pack_InterpretJoypad, whose SELECT arm is the bag's own + -- item shuffle rather than the field pack's item submenu, so nothing over a + -- battle registers anything. + local world = not self:inBattle() and self.world or nil + local ok + if world == (self.game and self.game.overworld) then + ok = require("src.ui.BagMenu").canRegister(self.game, row.id) + if ok then self.save.registeredItem = row.id end + elseif world and world.registerItem then ok = world:registerItem(row.id) end + if ok then + -- engine/items/pack.asm:551 + self:playSfx("Sfx_FullHeal") + -- RegisteredItemText: "Registered the\n." + self:showMessage(Strings(Strings.source("Registered the\n%s."), row.name)) + else + -- CantRegisterText: "You can't register\nthat item." + self:showMessage(Strings(Strings.source("You can't register\nthat item."))) + end +end + +-- A TM or HM's `move` is what it teaches; the extractor carries it on the item +-- record, and moves.lua carries that move's own description. +function PackMenu:moveOf(itemId) + local def = itemId and self.items and self.items[itemId] + -- The extractor calls it `teaches`. + return def and (def.teaches or (def.machine and def.machine.move)) or nil +end + +-- The description under the list. A TM shows the MOVE's description rather +-- than the item's -- which is what the cart's TM pocket does, and the whole +-- reason move descriptions are worth extracting. +function PackMenu:description() + if self:isCancel() then return nil end + local row = self.rows[self.index] + if not row then return nil end + local moveId = self:moveOf(row.id) + if moveId then + local moves = self.game and self.game.data and self.game.data.moves + local moveDef = moves and moves[moveId] + if moveDef and moveDef.description then return moveDef.description end + end + local def = self.items and self.items[row.id] + return def and def.description or nil +end + +-- engine/gfx/cgb_layouts.asm:723-726 -- the cursor column (7,2) 1x9 takes +-- palette $3, whose colour 3 is red (gfx/pack/pack.pal). +function PackMenu:cursorAt(tx, ty, hollow) + local palette = self.gfx and self.gfx:available() + and self.gfx:colorsAt(tx, ty) + if palette then + Chrome.cursorThrough(tx, ty, palette, false, hollow) + else + Chrome.cursor(tx, ty, hollow) + end +end + +-- The list, description and cursor, on top of whatever chrome was drawn. +-- +-- ScrollingMenu_CallFunctions1and2 (engine/menus/scrolling_menu.asm:424-429) +-- steps the coord on by the header's `db 5, 8` COLUMN count before +-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:19-25) adds SCREEN_WIDTH + 1, +-- so the ×N sits at name + 9 on the row BELOW the name, flush right in every +-- pocket (#1425, #1693). Its `lb bc, 1, 2` is a TWO-digit field with the +-- leading digit blanked. engine/items/tmhm.asm:392-403 writes that same +-- column for a TM. +-- +-- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the +-- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking. +function PackMenu:drawList(listX, listY) + -- engine/menus/scrolling_menu.asm:86 .a_button -> home/menu.asm:50 + -- engine/items/pack.asm:1301 .select + local picked = not self.switching + and (self.submenu or self.qtyState or self.confirm or self.message) + and true or false + for row = 1, VISIBLE_ROWS do + local i = row + self.scroll + local ty = listY + (row - 1) * LIST_SPACING + if i <= #self.rows then + local entry = self.rows[i] + -- engine/items/tmhm.asm:355-385 (#1695) + if entry.tmhmLabel then + Chrome.printThrough(entry.tmhmLabel, listX - 3, ty, Chrome.DEFAULT_BOX_PALETTE) + end + if i == self.index then + self:cursorAt(listX - 1, ty, picked) + elseif i == self.switching then + self:cursorAt(listX - 1, ty, true) + end + Chrome.printThrough((entry.tmhmLabel and entry.teaches) or entry.name, + listX, ty, Chrome.DEFAULT_BOX_PALETTE) + if entry.showCount then + Chrome.printThrough("\xc3\x97" .. Chrome.number(entry.count, 2), + listX + 9, ty + 1, Chrome.DEFAULT_BOX_PALETTE) + end + elseif i == self:total() then + if i == self.index then self:cursorAt(listX - 1, ty, picked) end + Chrome.printThrough(Strings("CANCEL"), listX, ty, Chrome.DEFAULT_BOX_PALETTE) + end + end +end + +function PackMenu:drawDescription(ty) + -- Pack_PrintTextNoScroll writes over the same box the description lives in, + -- so while a message is up it IS the box's contents. TossMenu's yes/no + -- (AskQuantityThrowAwayText through MenuTextbox) is the same box: the + -- question is printed there and the YES/NO window opens over the list. + local lines = self.message or (self.confirm and self.confirm.prompt) + if lines then + local name = self:playerName() + -- home/text.asm:397 + local page = self:pagesFor(lines)[self.messagePage or 1] or {} + for i, line in ipairs(page) do + Chrome.printThrough((line:gsub("{PLAYER}", name)), 1, ty + (i - 1) * 2, + Chrome.DEFAULT_BOX_PALETTE) + end + return + end + local description = self:description() + if not description then return end + -- Item descriptions join their two lines with the '' the extractor + -- leaves in place, and $4e steps SCREEN_WIDTH * 2 from the line's own + -- start (home/text.asm NextLineChar) -- TWO rows, the same metric every + -- text box uses. PrintItemDescription writes them from decoord 1, 14, so + -- the second line is row 16. '\n' covers hand-written data. + local first, second = description:match("^(.-)(.*)$") + if not first then first, second = description:match("^(.-)" .. LINE .. "(.*)$") end + Chrome.printThrough(first or description, 1, ty, Chrome.DEFAULT_BOX_PALETTE) + if second then Chrome.printThrough(second, 1, ty + 2, Chrome.DEFAULT_BOX_PALETTE) end +end + +-- ../pokecrystal/engine/items/pack.asm:162, :178, :313, :335, :355, :371, +-- TEXTBOX_Y - 1`; ../pokegold/engine/items/pack.asm has the same ten at +function PackMenu:submenuColumn() + local version = (self.save and self.save.version) or GameVersion.get() + return GameVersion.isCrystal(version) and 13 or 0 +end + +function PackMenu:drawSubmenu() + local menu = self.submenu + local count = #menu.rows + local x = self:submenuColumn() + -- ../pokegold/engine/items/pack.asm:313 ends on TEXTBOX_Y, pokecrystal:313 + local bottom = (count >= 5 and x == 0) and 12 or 11 + local top = bottom - count * 2 + Chrome.box(x, top, 7, bottom - top + 1) + for i, id in ipairs(menu.rows) do + local ty = top + 1 + (i - 1) * 2 + if i == menu.index then Chrome.cursorThrough(x + 1, ty, Chrome.DEFAULT_BOX_PALETTE) end + Chrome.printThrough(Strings(SUBMENU_LABEL[id] or id), x + 2, ty, + Chrome.DEFAULT_BOX_PALETTE) + end +end + +-- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay +function PackMenu:drawQuantity() + Chrome.box(15, 9, 5, 3) + Chrome.printThrough("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10, + Chrome.DEFAULT_BOX_PALETTE) +end + +-- YesNoBox's own coords, the same box every other Gen 2 screen here draws. +function PackMenu:drawYesNo() + Chrome.box(14, 7, 6, 5) + Chrome.printThrough(Strings("YES"), 16, 8, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Strings("NO"), 16, 10, Chrome.DEFAULT_BOX_PALETTE) + Chrome.cursorThrough(15, self.confirm.choice == 1 and 8 or 10, Chrome.DEFAULT_BOX_PALETTE) +end + +function PackMenu:drawOverlays() + if self.submenu then self:drawSubmenu() end + if self.qtyState then self:drawQuantity() end + if self.confirm then self:drawYesNo() end +end + +function PackMenu:drawPanel() + if self.gfx:available() then + -- Pack_InitGFX's screen: the header strip, the patterned left column, the + -- bag picture for this pocket and the pocket plaque, then Textbox at + -- (0,12) for the item description. + self.gfx:draw(self:pocket().id) + Chrome.box(0, PackGfx.DESCRIPTION_Y, 20, 6) + self:drawList(LIST_X, LIST_Y) + self:drawDescription(PackGfx.DESCRIPTION_Y + 2) + self:drawOverlays() + love.graphics.setColor(1, 1, 1, 1) + return + end + + -- No pack tiles in the cache (an import from before the pack stage): plain + -- boxes, the layout this screen shipped with. + Chrome.clear() + Chrome.box(0, 0, 20, 3) + Chrome.printThrough(Strings(self:pocket().label), 2, 1, + Chrome.DEFAULT_BOX_PALETTE) + Chrome.box(0, 3, 20, 12) + self:drawList(5, 4) + Chrome.box(0, 12, 20, 6) + self:drawDescription(14) + self:drawOverlays() + love.graphics.setColor(1, 1, 1, 1) +end + +function PackMenu:draw() + self:drawPanel() +end + +function PackMenu:drawWidescreen(winW, winH) + local G = love.graphics + Chrome.letterbox(winW, winH, 1, 1, 1) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +PackMenu.POCKETS = POCKETS + +return PackMenu diff --git a/src/ui/Gen2PartyItemResult.lua b/src/ui/Gen2PartyItemResult.lua new file mode 100644 index 000000000..57eff0cca --- /dev/null +++ b/src/ui/Gen2PartyItemResult.lua @@ -0,0 +1,95 @@ +-- Native Gen2 party-result presentation over the real shared party screen. +-- Contract/HP stepping adapted from Gen1Recomp 535a5ec0 +-- src/ui/gen2/PartyMenu.lua and src/battle/gen2/HpBar.lua. +-- Existing project rights/licences remain applicable. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +local Chrome = require('src.ui.Gen2Chrome') +local M = {} +local function maximum(mon) + return mon and (mon.maxHp or mon.stats and mon.stats.hp) or 0 +end +function M.install(Screen) + Screen.ACTION_TEXT_DELAY = 50 + function Screen:playSfx(name) + local data = self.game and self.game.data + -- The importer already owns native cue aliases. Do not invent a second + -- resolver (the shared Sound service has no Sound.resolve method). + if data and data.audio and data.audio.sfx and data.audio.sfx[name] then + return require('src.core.Sound').play(data, name) + end + end + function Screen:showItemResult(slot, opts) + opts = opts or {} + local mon = slot and self.party[slot] + local result = {slot=slot,mon=mon,from=opts.fromHp,shown=opts.fromHp, + target=opts.toHp,text=opts.text,delay=opts.delay or Screen.ACTION_TEXT_DELAY, + onDone=opts.onDone,auto=opts.auto,holdSlot=opts.holdSlot,holdHp=opts.holdHp, + lines=Chrome.wrap(opts.text,18),line=1} + self.itemResult = result + -- The shared party screen's existing display hold and the Gen2 result + -- are this same owner. Neither path may advance a second HP clock. + self.heal = result + if opts.sfx then self:playSfx(opts.sfx) end + end + function Screen:itemResultClimbing() + local r = self.itemResult + return r ~= nil and r.shown ~= nil and r.target ~= nil and r.shown ~= r.target + end + function Screen:shownHpFor(slot, mon) + local r = self.itemResult + if r then + if r.slot == slot and r.shown ~= nil then return r.shown end + if r.holdSlot == slot and r.holdHp ~= nil then return r.holdHp end + end + return mon and mon.hp + end + function Screen:updateItemResult(input) + local r = self.itemResult + if not r then return end + if self:itemResultClimbing() then + local maxHp = maximum(self.party[r.slot]) + local step = maxHp >= 48 and math.max(1, math.ceil(maxHp / 48)) or 1 + if r.shown < r.target then r.shown = math.min(r.target, r.shown + step) + else r.shown = math.max(r.target, r.shown - step) end + return + end + if not r.auto then + if r.delay > 0 then r.delay = r.delay - 1; return end + if not (input:wasPressed('a') or input:wasPressed('b')) then return end + -- Shared effects can produce more than the cartridge panel's two + -- rows. Keep their complete native text instead of dropping a tail. + if r.line + 1 < #r.lines then + r.line = r.line + 2 + r.delay = Screen.ACTION_TEXT_DELAY + return + end + end + self.itemResult = nil + if self.heal == r then self.heal = nil end + if r.onDone then r.onDone() end + end + function Screen:wantsNativeOverlay() + return self.itemResult ~= nil and self.itemResult.text ~= nil + and not self:itemResultClimbing() + end + function Screen:drawNativeOverlay(width, height) + if not self:wantsNativeOverlay() then return end + local function draw() + Chrome.textbox(0, 12, 18, 4) + local r = self.itemResult + for row=0,1 do + local text = r.lines[r.line+row] + if text then Chrome.print(text, 1, 14+row*2) end + end + end + if width and height then + love.graphics.origin() + return Chrome.withOverlayPanel(width, height, draw) + end + local w, h = 160, 144 + if self.uiSize then w, h = self:uiSize() end + if w ~= 160 or h ~= 144 then return Chrome.withOverlayPanel(w, h, draw) end + return draw() + end +end +return M diff --git a/src/ui/Gen2PcMenu.lua b/src/ui/Gen2PcMenu.lua new file mode 100644 index 000000000..3b9a2aabc --- /dev/null +++ b/src/ui/Gen2PcMenu.lua @@ -0,0 +1,561 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- The Pokemon PC's top menu (engine/pokemon/bills_pc_top.asm _BillsPC). +-- +-- WITHDRAW POKéMON / DEPOSIT POKéMON / CHANGE BOX / +-- MOVE POKéMON W/O MAIL / MAIL BOX / SEE YA! +-- +-- MAIL BOX is the item PC's PLAYERSPCITEM_MAIL_BOX row rather than one of +-- _BillsPC's five; it is here because this port used to fold both PCs into +-- one menu, and a directly-constructed PcMenu still folds them. The +-- Pokecenter's whose-PC menu (src/ui/gen2/CenterPcMenu.lua) opens this as +-- BILL's PC with `bills = true`, which shows the cart's own five rows and +-- leaves the MAIL BOX to 's PC (src/ui/gen2/ItemPcMenu.lua). +-- +-- .MenuHeader is menu_coords 0, 0, 19, 17 -- the menu owns the whole screen, +-- with "What do you want to do?" in a text box along the bottom. Choosing an +-- entry pushes BoxMenu (the withdraw/deposit list) or the box picker. +-- +-- The assembled rows run through the ui.pc.items hook, the same name and the +-- same (game, items) payload the Gen 1 PC uses +-- (src/world/OverworldController.lua openPC), with SEE YA! appended after it +-- the way that site appends LOG OFF. + +local Boxes = require("src.pokemon.Gen2Boxes") +local Chrome = require("src.ui.Gen2Chrome") +local Logger = require("src.core.Logger") +local Mail = require("src.pokemon.Gen2Mail") +local Runtime = require("src.mods.Runtime") +local Save = require("src.core.SaveData") +local SaveMenu = require("src.ui.Gen2SaveMenu") +local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") + +-- _PCMonHoldingMailText (data/text/common_2.asm), the refusal +-- BillsPC_MovePKMNMenu prints instead of opening the list. Two pages, because +-- the ASM has a `para` in the middle of it. Declared up here and looked up at +-- the call site, so Strings.source is what puts both in the catalog. +local MON_HOLDING_MAIL = { + Strings.source("There is a POKéMON\nholding MAIL."), + Strings.source("Please remove the\nMAIL."), +} + +-- ../pokecrystal/data/text/common_3.asm:219 _ChangeBoxSaveText +-- ../pokegold/data/text/common_2.asm:1306 _ChangeBoxSaveText +local CHANGE_BOX_SAVE_SOURCE = Strings.source("When you change a\n#MON BOX, data\vwill be saved. OK?") + +-- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383). +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 + +local PcMenu = {} +PcMenu.__index = PcMenu +PcMenu.isOpaque = true + +-- .strings, verbatim. and are real font glyphs (codes $e1/$e2) and +-- Font.split matches charmap sequences, so writing them the way the ASM does +-- draws two tiles rather than seven -- which is the only reason "MOVE +-- W/O MAIL" fits inside a 20-tile screen. +local ENTRIES = { + { id = "withdraw", label = Strings.source("WITHDRAW "), builtin = true }, + { id = "deposit", label = Strings.source("DEPOSIT "), builtin = true }, + { id = "changebox", label = Strings.source("CHANGE BOX"), builtin = true }, + { id = "move", label = Strings.source("MOVE W/O MAIL"), builtin = true }, + -- PLAYERSPCITEM_MAIL_BOX (engine/events/pokecenter_pc.asm), which BOTH + -- .WhichPC lists carry: the MAILBOX is on the item PC in a Pokecenter and in + -- the bedroom alike, unlike DECORATION below. It sits here because this + -- port folds the item PC's menu into the storage one. + { id = "mailbox", label = Strings.source("MAIL BOX"), builtin = true }, + { id = "seeya", label = Strings.source("SEE YA!"), builtin = true }, +} + +-- PLAYERSPCITEM_DECORATION, the one row the bedroom's PC has that a +-- Pokecenter's does not (engine/events/pokecenter_pc.asm: PLAYERSPC_HOUSE +-- carries it, PLAYERSPC_NORMAL does not). It belongs to the item PC's menu on +-- the cart, which this port folds into the storage menu the same way both PCs +-- are folded -- so it hangs off the same list, gated on `house`. +local DECORATION = { + id = "decoration", label = Strings.source("DECORATION"), builtin = true, +} + +-- The exit row. It is a member of ENTRIES (it is one of _BillsPC's five), but +-- the list is assembled without it and it is put back on the end AFTER the +-- ui.pc.items hook has run, exactly as the Gen 1 site appends LOG OFF +-- (src/world/OverworldController.lua openPC): a mod may add, drop or reorder +-- anything it likes and still cannot orphan the way out. +local EXIT_ID = "seeya" + +-- ui.pc.items identity: an unhooked build hands its own list back. +local function sameItems(_, items) return items end + +function PcMenu:wantsFillScale() return true end +function PcMenu:drawsWidescreen() return true end + +-- opts: save, house (the bedroom's PC, which also does decorations), events +-- (the wEventFlags bitfield the decoration menu reads ownership from), +-- bills (_BillsPC's own five rows, no MAIL BOX: what the whose-PC +-- menu's BILL's PC entry opens, src/ui/gen2/CenterPcMenu.lua), +-- onClose(changedDecorations) +function PcMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, PcMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.onClose = opts.onClose or opts.onCancel or function() game.stack:pop() end + self.house = opts.house and true or false + self.events = opts.events + -- The same route the start menu's SAVE row takes (src/core/Game2.lua:435), + -- so the save.write veto and the save.writing event fire here too. + self.writer = opts.writer + or (game and self.save == game.save and type(game.writeSave) == "function" + and function() return game:writeSave() end) + or function() return false end + self.saveExists = opts.saveExists + -- The folded MAIL BOX row belongs to the item PC, and the whose-PC menu + -- reaches that through 's PC (src/ui/gen2/ItemPcMenu.lua), so + -- BILL's PC shows the cart's own five rows. The bedroom's PC keeps it: the + -- MAILBOX is on both .WhichPC lists. + local dropMailbox = opts.bills and not self.house + local entries = {} + for _, entry in ipairs(ENTRIES) do + local drop = entry.id == EXIT_ID + or (dropMailbox and entry.id == "mailbox") + if not drop then entries[#entries + 1] = entry end + end + if self.house then + entries[#entries + 1] = DECORATION + end + -- Same hook name and same (game, items) payload as the Gen 1 PC menu + -- (src/world/OverworldController.lua openPC), so one mod source can add a + -- row to both generations' PCs. Unguarded, like that site: the list is + -- built once per session, not per frame. A hook that answers with anything + -- but a table is degraded to the vanilla list. + local hooked = Runtime.call("ui.pc.items", sameItems, game, entries) + if type(hooked) == "table" then + entries = hooked + else + Logger.error("ui.pc.items returned %s; keeping the vanilla items", + type(hooked)) + end + -- SEE YA! goes back on last: it is the row B lands on, and TURN OFF sits at + -- the bottom of the cart's house list too. + for _, entry in ipairs(ENTRIES) do + if entry.id == EXIT_ID then entries[#entries + 1] = entry end + end + self.entries = entries + -- wChangedDecorations, carried out to `special PlayersHousePC` so its + -- script's `iftrue` can reload the map. + self.changedDecorations = false + self.index = 1 + self.message = nil + -- Whether clearing the message also logs off. .CheckCanUsePC's does (the PC + -- never opened at all); BillsPC_MovePKMNMenu's mail refusal does not -- its + -- `.quit` returns with the carry clear, which drops back into the _BillsPC + -- loop and redraws this menu. + self.messageCloses = true + -- .CheckCanUsePC: an empty party gets the "You'll need a POKéMON" line and + -- the PC never opens. Kept here rather than at the call site so every route + -- into the PC (the overworld script, a driver, a mod) gets the same gate. + -- Boxes.lua's own \f is the same catalog key BoxMenu.lua's + -- BOX_FAILURE_SOURCES already declares for this string; split the + -- translated result on it into notice()'s page-per-string shape rather + -- than calling notice() itself, which would also reset messageCloses to + -- false -- this refusal closes the PC, the mail-holding one does not. + local ok, reason = Boxes.canUsePc(self.save) + if not ok then + local pages = {} + for page in (Strings(reason) .. "\f"):gmatch("(.-)\f") do + pages[#pages + 1] = page + end + self.message = pages[1] + self.messagePages = pages + self.messagePage = 1 + end + return self +end + +-- A refusal that leaves the PC open: the message replaces the menu until a +-- button clears it, and then the menu is back exactly where it was. `pages` +-- is a list because a `para` in the ASM is a screenful of its own -- the text +-- box holds two rows and the fourth line of _PCMonHoldingMailText would +-- otherwise be drawn off the bottom of the screen. +function PcMenu:notice(pages) + self.message = pages[1] + self.messagePages = pages + self.messagePage = 1 + self.messageCloses = false +end + +-- home/menu.asm:746 +function PcMenu:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[name] then Sound.play(data, name) end +end + +function PcMenu:close() + if self.closed then return end + self.closed = true + if self.onClose then self.onClose(self.changedDecorations) end +end + +-- engine/pokemon/bills_pc.asm:2403 BillsPC_ChangeBoxSubmenu .Switch +-- engine/menus/save.asm:40 ChangeBoxSaveGame +function PcMenu:beginChangeBox(index) + self.changeBox = index + self:setSavePhase("confirm") + self.saveChoice = 1 + self.saved = nil + local existed = self.saveExists + if existed == nil then existed = Save.saveFileExists(self.save.version) end + self.existed = existed +end + +-- .refused: `pop de / ret`, with wCurBox untouched and the picker still up. +function PcMenu:refuseChangeBox() + self.savePhase, self.changeBox = nil, nil + self.saveTimer = 0 + self.typer = nil +end + +-- ../pokecrystal/engine/menus/save.asm:336 SavingDontTurnOffThePower +-- ../pokecrystal/engine/menus/save.asm:241 SavedTheGame +function PcMenu:setSavePhase(phase) + self.savePhase = phase + self.savePage = 1 + self.saveTimer = 0 + local pinned = (phase == "saving" or phase == "done") and "MID" or nil + self.typer = Typer.new(self.game, { speed = pinned }) + self.typer:start(self:savePages()[1]) +end + +function PcMenu:acceptChangeBox() + if self.saveChoice == 2 then return self:refuseChangeBox() end + if self.savePhase == "confirm" and self.existed then + self:setSavePhase("overwrite") + self.saveChoice = 1 + return + end + self:setSavePhase("saving") +end + +-- `pop de / ld a, e / ld [wCurBox], a` sits between SaveBox and +-- SavingDontTurnOffThePower, so the new index rides the file that is written. +function PcMenu:writeChangeBox() + local previous = self.save.currentBox + if not Boxes.setCurrent(self.save, self.changeBox) then self.saved=false;return end + local called, result = pcall(self.writer, self.save) + local ok = called and result + self.saved = ok and true or false + if not ok then self.save.currentBox = previous end + -- engine/menus/save.asm:266 + if ok then + SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) + end +end + +function PcMenu:savePromptText() + if self.savePhase == "overwrite" then + return Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE) + end + if self.savePhase == "saving" then + return Strings(SaveMenu.SAVING_PROMPT_SOURCE) + end + if self.savePhase == "done" then + if self.saved then + local name = (self.save.player and self.save.player.name) or "GOLD" + return Strings("%s saved\nthe game.", name) + end + return Strings("Could not save.") + end + return Strings(CHANGE_BOX_SAVE_SOURCE) +end + +function PcMenu:savePages() + local text = self:savePromptText() + if self.savePagesText ~= text or not self.savePagesCache then + self.savePagesText = text + self.savePagesCache = SaveMenu.pagesOf(text) + end + return self.savePagesCache +end + +function PcMenu:savePrompt() + local pages = self:savePages() + return pages[self.savePage or 1] or pages[1] +end + +-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno +function PcMenu:saveYesNoVisible(pages) + if self.savePhase ~= "confirm" and self.savePhase ~= "overwrite" then + return false + end + if Typer.typing(self) then return false end + return (self.savePage or 1) >= #(pages or self:savePages()) +end + +function PcMenu:updateChangeBox() + local typed = Typer.step(self) + -- SavingDontTurnOffThePower is DelayFrames, not a prompt: no button does + -- anything until the sequence runs out (engine/menus/save.asm:55). + if self.savePhase == "saving" then + if not typed then return end + self.saveTimer = self.saveTimer + 1 + if self.saveTimer >= SaveMenu.SAVING_FRAMES then + self:writeChangeBox() + self:setSavePhase("done") + end + return + end + if self.savePhase == "done" then + if not typed then return end + self.saveTimer = self.saveTimer + 1 + if self.saveTimer >= SaveMenu.SAVED_FRAMES then + self.savePhase, self.changeBox = nil, nil + self.picking = false + self.typer = nil + end + return + end + + local input = self.game and self.game.input + if not input then return end + if Typer.typing(self) then return end + -- ../pokecrystal/home/text.asm:502 _ContText + if not self:saveYesNoVisible() then + if input:wasPressed("a") or input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.savePage = (self.savePage or 1) + 1 + if self.typer then self.typer:start(self:savePages()[self.savePage]) end + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + self.saveChoice = self.saveChoice == 1 and 2 or 1 + elseif input:wasPressed("a") then + -- home/menu.asm:345 + self:playSfx("Sfx_ReadText2") + self:acceptChangeBox() + elseif input:wasPressed("b") then + -- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry). + self:playSfx("Sfx_ReadText2") + self:refuseChangeBox() + end +end + +function PcMenu:choose() + local entry = self.entries[self.index] + if not entry then return end + local game = self.game + -- A hook- or monkey-patch-injected row carries label + onSelect and no id + -- the ladder below knows, so without this arm it draws and does nothing. + -- Same shape and same argument order as src/ui/gen2/PartyMenu.lua:433. + if type(entry.onSelect) == "function" then + entry.onSelect(self, game) + return + end + if entry.id == "seeya" then + self:close() + return + end + if entry.id == "decoration" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2DecorationMenu", { + save = self.save, + events = self.events, + onDone = function(changed) + self.changedDecorations = self.changedDecorations or changed or false + game.stack:pop() + end, + }) + return + end + if entry.id == "mailbox" then + if not (game and game.stack) then return end + Screens.push(game, "Gen2MailboxMenu", { + save = self.save, + onClose = function() game.stack:pop() end, + }) + return + end + if entry.id == "changebox" then + self.picking = true + self.pickIndex = self.save.currentBox or 1 + return + end + -- BillsPC_MovePKMNMenu asks IsAnyMonHoldingMail BEFORE it opens the list and + -- refuses outright: MOVE POKéMON W/O MAIL is a whole-party operation, so one + -- letter anywhere in the party stops it. + if entry.id == "move" and Mail.anyMonHoldingMail(self.save) then + self:notice({ Strings(MON_HOLDING_MAIL[1]), Strings(MON_HOLDING_MAIL[2]) }) + return + end + if not (game and game.stack) then return end + Screens.push(game, "Gen2BoxMenu", { + save = self.save, + mode = entry.id, -- "withdraw" | "deposit" | "move" + onClose = function() game.stack:pop() end, + }) +end + +function PcMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + + if self.message then + if input:wasPressed("a") or input:wasPressed("b") then + local pages = self.messagePages + if pages and self.messagePage < #pages then + self.messagePage = self.messagePage + 1 + self.message = pages[self.messagePage] + return + end + local closes = self.messageCloses + self.message, self.messagePages, self.messagePage = nil, nil, nil + self.messageCloses = true + if closes then self:close() end + end + return + end + + if self.savePhase then + self:updateChangeBox() + return + end + + if self.picking then + local total = Boxes.NUM_BOXES + if input:wasPressed("up") then + self.pickIndex = self.pickIndex > 1 and self.pickIndex - 1 or total + elseif input:wasPressed("down") then + self.pickIndex = self.pickIndex < total and self.pickIndex + 1 or 1 + elseif input:wasPressed("a") then + -- engine/menus/scrolling_menu.asm:24 + self:playSfx("Sfx_ReadText2") + if self.pickIndex == (self.save.currentBox or 1) then + self.picking = false + else + self:beginChangeBox(self.pickIndex) + end + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.picking = false + end + return + end + + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or #self.entries + elseif input:wasPressed("down") then + self.index = self.index < #self.entries and self.index + 1 or 1 + elseif input:wasPressed("a") then + -- home/menu.asm:476 + self:playSfx("Sfx_ReadText2") + self:choose() + elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self:close() + end +end + +function PcMenu:drawPanel() + Chrome.clear() + if self.message then + Chrome.box(0, 12, 20, 6) + -- A text box's two lines sit two tile rows apart, not one -- the same + -- spacing src/render/TextBox.lua uses (line1 = ty+2, line2 = ty+4). + local line = 14 + for part in (self.message .. "\n"):gmatch("(.-)\n") do + Chrome.print(part, 1, line) + line = line + 2 + end + love.graphics.setColor(1, 1, 1, 1) + return + end + + if self.picking then + -- CHANGE BOX: the 14 box names with how full each one is, six at a time. + Chrome.box(0, 0, 20, 14) + local rows = 6 + local scroll = math.max(0, math.min(self.pickIndex - rows, + Boxes.NUM_BOXES - rows)) + for row = 1, rows do + local i = row + scroll + local ty = row * 2 - 1 + if i == self.pickIndex then Chrome.cursor(1, ty) end + Chrome.print(Boxes.name(self.save, i), 2, ty) + Chrome.printRight( + ("%d/%d"):format(Boxes.count(self.save, i), Boxes.MONS_PER_BOX), + 18, ty) + end + if self.savePhase then + -- ChangeBoxSaveGame's MenuTextbox, then YesNoBox over the box list. + Chrome.box(0, 12, 20, 6) + local pages = self:savePages() + local lines = Typer.text(self, pages[self.savePage or 1] or pages[1]) + Chrome.print(lines[1] or "", 1, 14) + Chrome.print(lines[2] or "", 1, 16) + if not Typer.typing(self) and (self.savePage or 1) < #pages + and Typer.arrowOn(self) then + Chrome.print(SaveMenu.DOWN_ARROW, SaveMenu.ARROW_X, SaveMenu.ARROW_Y) + end + if self:saveYesNoVisible(pages) then + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) + Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, + YESNO_Y + (self.saveChoice == 1 and 1 or 3)) + end + love.graphics.setColor(1, 1, 1, 1) + return + end + Chrome.box(0, 14, 20, 4) + Chrome.print(Strings("Which BOX?"), 1, 16) + love.graphics.setColor(1, 1, 1, 1) + return + end + + -- .LogIn prints _PCWhatText into the (0,12) box, and that text is one word: + -- "What?". PrintText starts at (1,14). The box goes down first because + -- the menu window is drawn over it wherever a folded list runs past row 12, + -- the way the cart's windows stack (src/ui/gen2/ItemPcMenu.lua does the + -- same with the house's six-row item list). + Chrome.box(0, 12, 20, 6) + Chrome.print(Strings("What?"), 1, 14) + + -- ClearPCItemScreen: Textbox at (0,0) with a 10x18 interior, and a second + -- at (0,12) with a 4x18 one. GetMenuTextStartCoord then puts the first + -- label at (left+1+1, top+1+1) = (2,2) because STATICMENU_CURSOR is set and + -- the menu does not ask for NO_TOP_SPACING; rows are two apart and the + -- cursor sits one column left of the label. _BillsPC's own five rows end + -- at row 10 and fit that box exactly; the folded MAIL BOX (and the + -- bedroom's DECORATION) put rows below it, so the window is sized to the + -- list rather than to the five the cart ships. + Chrome.box(0, 0, 20, math.max(12, #self.entries * 2 + 2)) + for i, entry in ipairs(self.entries) do + local ty = i * 2 + if i == self.index then Chrome.cursor(1, ty) end + Chrome.print(entry.builtin and Strings(entry.label) or entry.label, 2, ty) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function PcMenu:draw() + self:drawPanel() +end + +function PcMenu:drawWidescreen(winW, winH) + local G = love.graphics + Chrome.letterbox(winW, winH, 1, 1, 1) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +PcMenu.ENTRIES = ENTRIES + +return PcMenu diff --git a/src/ui/Gen2PokedexMenu.lua b/src/ui/Gen2PokedexMenu.lua new file mode 100644 index 000000000..8fcafc57f --- /dev/null +++ b/src/ui/Gen2PokedexMenu.lua @@ -0,0 +1,9 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- The Gen2 host can request a stored mon's entry directly, or the whole dex. +local M={} +function M.new(game,opts) + opts=opts or {} + if opts.entrySpecies then return require("src.ui.DexEntryMenu").new(game,{species=opts.entrySpecies,forceOwned=true}) end + return require("src.ui.PokedexMenu").new(game,{onCancel=opts.onClose or opts.onCancel}) +end +return M diff --git a/src/ui/Gen2SaveMenu.lua b/src/ui/Gen2SaveMenu.lua new file mode 100644 index 000000000..9217a053a --- /dev/null +++ b/src/ui/Gen2SaveMenu.lua @@ -0,0 +1,163 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native GSC save dialogue over the shared text printer and save pipeline. +local TextBox = require('src.render.TextBox') +local Chrome = require('src.ui.Gen2Chrome') +local Strings = require('src.core.Strings') +local SaveData = require('src.core.SaveData') +local Sound = require('src.core.Sound') +local M = {isOpaque=false, SAVING_FRAMES=16, SAVED_GAP_FRAMES=32, + SAVED_TAIL_FRAMES=30, SFX_SAVE=0x25} +M.__index=M +M.SAVED_FRAMES=M.SAVED_GAP_FRAMES+M.SAVED_TAIL_FRAMES +M.OVERWRITE_PROMPT_SOURCE=Strings.source('There is already a\nsave file. Is it\vOK to overwrite?') +M.SAVING_PROMPT_SOURCE=Strings.source("SAVING… DON'T TURN\nOFF THE POWER.") +M.PANEL={x=4,y=0,w=16,h=10,labelX=5,labelDy=2,badgesX=17,badgesDy=4, + dexX=16,dexDy=6,timeX=13,timeDy=8} +M.DOWN_ARROW='▼';M.ARROW_X=18;M.ARROW_Y=17 +function M.pagesOf(text) + local result={} + for paragraph in (tostring(text)..'\f'):gmatch('(.-)\f') do + local visible;local pos=1;local scroll=false + while true do + local split=paragraph:find('[\n\v]',pos) + local line=paragraph:sub(pos,split and split-1 or #paragraph) + if line~='' then + if not visible then visible={line};result[#result+1]=visible + elseif scroll then visible={visible[#visible],line,scrolled=true};result[#result+1]=visible + elseif #visible==2 then visible={line};result[#result+1]=visible + else visible[#visible+1]=line end + end + if not split then break end + scroll=paragraph:sub(split,split)=='\v';pos=split+1 + end + end + return #result>0 and result or {{''}} +end +function M.new(game,opts) + opts=opts or {} + local existed=opts.existed + if existed==nil then existed=SaveData.saveFileExists((opts.save or game.save).version)~=false end + local self=setmetatable({game=game,save=opts.save or game.save, + onDone=opts.onDone,writer=opts.writer,existed=existed,choice=1,timer=0},M) + self:enterPhase('confirm') + return self +end +function M:wantsFillScale() return true end +function M:playerName() return (self.save.player or {}).name or 'GOLD' end +function M:promptText() + if self.phase=='overwrite' then return Strings(M.OVERWRITE_PROMPT_SOURCE) end + if self.phase=='saving' then return Strings(M.SAVING_PROMPT_SOURCE) end + if self.phase=='done' then + return self.saved and Strings('%s saved\nthe game.',self:playerName()) or Strings('Could not save.') + end + return Strings('Would you like to\nsave the game?') +end +function M:enterPhase(phase) + self.phase=phase;self.timer=0;self.typedPhase=phase + -- Embedded stay text never pushes or pops a state. The menu owns its + -- lifetime, while ordinary native glyph, page and scroll timing is shared. + local timed=phase=='saving' or phase=='done' + self.message=TextBox.new(self.game,self:promptText(),nil,{stay={}, + textSpeed=timed and 3 or nil,allowTextAcceleration=not timed}) + self.pages=M.pagesOf(self:promptText());self.page=1 +end +function M:prompt() return self.pages[self.page] or self.pages[1] end +function M:yesNoVisible() + return not self.finished and (self.phase=='confirm' or self.phase=='overwrite') and self.message.done==true +end +function M:finish(saved) + if self.finished then return false end + self.finished=true + if self.onDone then self.onDone(saved==true) end + return true +end +function M:accept() + if not self:yesNoVisible() then return false end + if self.choice==2 then return self:finish(false) end + if self.phase=='confirm' and self.existed then self.choice=1;self:enterPhase('overwrite') + else self:enterPhase('saving') end + return true +end +function M:writeNow() + if self.wrote then return self.saved end + self.wrote=true + local ok,result=pcall(function() + if self.writer then return self.writer(self.save) end + if self.game and self.game.save==self.save and self.game.writeSave then return self.game:writeSave() end + return SaveData.save(self.save) + end) + self.saved=ok and not not result + if not ok then + self.writeError=tostring(result) + require('src.core.Logger').warn('Gen2 save failed: %s',self.writeError) + end + return self.saved +end +function M.playSaveSfx(game,id) + local data=game and game.data;local audio=data and data.audio + local name=audio and audio.sfxOrder and audio.sfxOrder[(id or M.SFX_SAVE)+1] + if name and audio.sfx and audio.sfx[name] then return Sound.play(data,name) end +end +function M:playSfx(id) return M.playSaveSfx(self.game,id) end +function M:playSfxNamed(name) return Sound.play(self.game.data,name) end +function M:update(dt) + if self.finished then return end + local alreadyTyped=self.message.done + self.message:update(dt) + local page=0 + for p=1,self.message.pageIndex do + page=page+1 + local cont=(self.message.pages.contBefore or {})[p] or {} + local limit=p==self.message.pageIndex and self.message.lineIndex or #self.message.pages[p] + for line=2,limit do if cont[line] then page=page+1 end end + end + self.page=math.min(page,#self.pages) + if not alreadyTyped then return end + if self.phase=='saving' then + self.timer=self.timer+1 + if self.timer==M.SAVING_FRAMES then self:writeNow() end + if self.timer>=M.SAVING_FRAMES+M.SAVED_GAP_FRAMES then self:enterPhase('done') end + return + elseif self.phase=='done' then + if self.saved and not self.rang then + self.rang=true;self.saveSound=self:playSfx(M.SFX_SAVE);return + end + if self.saveSound and self.saveSound.isPlaying and self.saveSound:isPlaying() then + self.soundWait=(self.soundWait or 0)+1 + -- A broken host audio source cannot imprison the save menu forever. + if self.soundWait<600 then return end + end + self.timer=self.timer+1 + if self.timer>=M.SAVED_TAIL_FRAMES then self:finish(self.saved) end + return + end + local input=self.game.input + if input:wasPressed('up') or input:wasPressed('down') then self.choice=3-self.choice + elseif input:wasPressed('a') then self:accept() + elseif input:wasPressed('b') then self:finish(false) end +end +function M:drawPanel() + local save=self.save;local badges=require('src.inventory.Badges').count(self.game.data,save) + local caught=0 + for _ in pairs((save.pokedex or {}).owned or {}) do caught=caught+1 end + local seconds=math.floor(SaveData.playSeconds(save)) + Chrome.box(4,0,16,10) + Chrome.print(Strings('PLAYER %s',self:playerName()),5,2) + Chrome.print(Strings('BADGES'),5,4);Chrome.print(Chrome.number(badges,2),17,4) + Chrome.print(Strings('POKéDEX'),5,6);Chrome.print(Chrome.number(caught,3),16,6) + Chrome.print(Strings('TIME'),5,8) + Chrome.print(('%3d:%02d'):format(math.floor(seconds/3600),math.floor(seconds/60)%60),13,8) + self.message:draw() + if self:yesNoVisible() then + Chrome.box(0,7,6,5);Chrome.print(Strings('YES'),2,8);Chrome.print(Strings('NO'),2,10) + Chrome.cursor(1,self.choice==1 and 8 or 10) + end +end +function M:draw() self:drawPanel() end +-- Native fallback also works when a presentation provider draws in window +-- coordinates. Keep the world visible around the cartridge-sized panel. +function M:drawsWidescreen() return false end +function M:drawWidescreen(width,height) + return Chrome.withOverlayPanel(width,height,function() self:drawPanel() end) +end +return M diff --git a/src/ui/Gen2StartMenu.lua b/src/ui/Gen2StartMenu.lua new file mode 100644 index 000000000..47d8301fb --- /dev/null +++ b/src/ui/Gen2StartMenu.lua @@ -0,0 +1,481 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Original project rights and notices remain applicable; see NOTICE.md. +-- Gold's START menu (engine/menus/start_menu.asm StartMenu). +-- +-- The box is right-aligned -- menu_coords 10, 0, SCREEN_WIDTH - 1, +-- SCREEN_HEIGHT - 1 -- and grows to fit however many entries are currently +-- unlocked. #DEX and POKEGEAR only appear once the player owns them, so early +-- in the game the menu is short; the ASM builds wMenuItemsList each time it +-- opens for exactly that reason (.SetUpMenuItems). +-- +-- With MENU ACCOUNT on, a second box at the bottom-left describes the +-- highlighted entry (.MenuDesc), which is why every item carries two lines of +-- description text here. +-- +-- The cursor position is remembered between openings +-- (wBattleMenuCursorPosition), so reopening the menu lands where you left it. +-- +-- The assembled list runs through the ui.start_menu.items hook before the menu +-- opens, exactly as the Gen 1 port's does (src/ui/StartMenu.lua), so mods +-- insert, drop or reorder rows without patching this file. It is the SAME +-- hook name and the same (game, items) payload: Gold's POKEGEAR row is simply +-- one more entry in the list the hook receives. + +local BugContest = require("src.world.BugContest") +local Chrome = require("src.ui.Gen2Chrome") +local Font = require("src.render.Font") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local Typer = require("src.ui.Gen2Typer") + +local StartMenu = {} +StartMenu.__index = StartMenu +-- Not opaque: the overworld keeps drawing underneath, the way the real menu +-- slides a window over the map. +StartMenu.isOpaque = false + +-- SFX_MENU on open, SFX_READ_TEXT_2 (the click) on select +-- (StartMenu_Show's `ld de, SFX_MENU / call PlaySFX` and .Select's +-- PlayClickSFX). By LABEL, not index: the old numeric ids (2 and 3) landed +-- on whatever the sfx table had there -- the item-get jingle on every menu +-- open -- and a repointed table would drift again. + +-- STARTMENUITEM_* in the order .SetUpMenuItems appends them. `need` is the +-- save flag that unlocks the entry; nil means always shown. +-- +-- Labels are the ASM's own strings, with the compression bytes expanded the +-- way the cart expands them at print time: "#DEX" is POKé + DEX (seven tiles), +-- while "GEAR" is GEAR -- the and glyphs ($70/$71) are +-- one tile each, so that row is six tiles wide, not eight. That difference is +-- exactly what kept POKéGEAR hanging off the menu box's right edge. +local ITEMS = { + { + id = "pokedex", label = Strings.source("POKéDEX"), need = "pokedex", + desc = Strings.source("POKéMON\ndatabase"), + }, + { + id = "pokemon", label = Strings.source("POKéMON"), need = "party", + desc = Strings.source("Party \nstatus"), + }, + { + id = "pack", label = Strings.source("PACK"), need = "pack", + desc = Strings.source("Contains\nitems"), + }, + { + id = "pokegear", label = Strings.source("GEAR"), need = "pokegear", + desc = Strings.source("Trainer's\nkey device"), + }, + { + -- The player's own name is the label (.StatusString is ""). + id = "status", label = nil, + desc = Strings.source("Your own\nstatus"), + }, + { + id = "save", label = Strings.source("SAVE"), + desc = Strings.source("Save your\nprogress"), + }, + { + id = "option", label = Strings.source("OPTION"), + desc = Strings.source("Change\nsettings"), + }, + { + -- The mod manager's discoverable home, exactly as the Gen 1 start menu + -- carries it: the row only appears once at least one mod has been + -- discovered, so a vanilla install's menu is the cart's. + id = "mods", label = Strings.source("MODS"), need = "mods", + desc = Strings.source("Installed\nadd-ons"), + }, + { + -- The cart's EXIT just closed the menu (CloseStartMenu). A window with a + -- close button already covers that, so -- exactly as the Gen 1 port does + -- (src/ui/StartMenu.lua) -- this row is QUIT and power-cycles back to the + -- title after a confirmation that defaults to NO. + id = "quit", label = Strings.source("QUIT"), + desc = Strings.source("Return to\nthe title"), + }, +} + +-- The confirmation's yes/no box. A bare YesNoBox lands at YesNoMenuHeader's +-- own menu_coords 10, 5, 15, 9, which is exactly where the start menu is, so +-- this uses the other position the cart already places one at: +-- SaveTheGame_yesorno's `lb bc, 0, 7`, a 6x5 box at (0,7) clear of the menu. +-- Labels sit at (2,8) and (2,10) -- border + 1, plus a column for +-- STATICMENU_CURSOR and no extra row because STATICMENU_NO_TOP_SPACING is set. +local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 + +-- Persisted across openings, like wBattleMenuCursorPosition. +StartMenu.lastIndex = 1 + +-- ui.start_menu.items identity: an unhooked build hands its own list back. +local function sameItems(_, items) return items end + +-- opts: save, onChoose(id), onClose(), unlocked (override table for tests) +function StartMenu.new(game, opts) + opts = opts or {} + local self = setmetatable({}, StartMenu) + self.game = game + self.save = opts.save or (game and game.save) + self.onQuit = opts.onQuit + self.onChoose = opts.onChoose or function(id) self:open(id) end + self.onClose = opts.onClose or function() + if self.game.stack:top() == self then self.game.stack:pop() end + end + self.unlocked = opts.unlocked + StartMenu.lastIndex = self.save.startMenuIndex or StartMenu.lastIndex + local items = self:visibleItems() + -- Unguarded, like the Gen 1 site: the menu is built once per opening, not + -- per frame, so there is nothing here worth a wantsHook fast path. A hook + -- that answers with anything but a table is degraded to the vanilla list + -- rather than emptying the player's menu. + local hooked = Runtime.call("ui.start_menu.items", sameItems, game, items) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.start_menu.items returned %s; keeping the vanilla items", + type(hooked)) + end + -- Let hooks inspect the cart's stable source labels. Translate only the + -- built-in rows afterwards; mod-supplied labels are content and stay raw. + for _, item in ipairs(items) do + if item.translateLabel then item.label = Strings(item.label) end + if item.translateDesc then + local translated = Strings(item.desc) + item.desc = {} + for line in (translated .. "\n"):gmatch("(.-)\n") do + item.desc[#item.desc + 1] = line + end + end + end + self.items = items + local options = (self.save and self.save.options) or {} + self.showDescription = options.menuAccount ~= false + + self.list = Chrome.List.new({ + items = self.items, + -- GetMenuTextStartCoord: the box's left/top plus one for the border, plus + -- one more for the cursor column (STATICMENU_CURSOR) and one more for the + -- top spacing this menu does not opt out of -- so (10,0) becomes (12,2), + -- and Chrome.List puts the cursor at x - 1 = 11. + -- pokecrystal engine/menus/start_menu.asm:164 + x = 12, y = self.contest and 4 or 2, spacing = 2, + -- Two rows per entry inside an 18-row screen leaves room for eight, which + -- is exactly the vanilla count. A mod that adds a row scrolls rather than + -- drawing off the bottom of the frame; Chrome.List puts the ▼ hint on. + rows = math.min(#self.items, 8), + wrap = true, + startAccepts = true, + index = math.min(StartMenu.lastIndex, math.max(1, #self.items)), + onChoose = function(value, index) self:choose(value, index) end, + onCancel = function() self:close() end, + }) + return self +end + +-- Which entries the player has unlocked. Derived from the save so the menu +-- grows as the game does; `unlocked` overrides it wholesale for tests. +-- .SetUpMenuItems tests two ENGINE flag bits, and both arrive as `setflag` +-- ids in save.engineFlags (constants/engine_flags.asm const order, via +-- World:setEngineFlag): `bit STATUSFLAGS_POKEDEX_F, [wStatusFlags]` is +-- ENGINE_POKEDEX = 11, written by Oak at Mr. Pokemon's house, and +-- `bit POKEGEAR_OBTAINED_F, [wPokegearFlags]` is ENGINE_POKEGEAR = 4, +-- written by Mom on the way out the door. +local ENGINE_POKEGEAR, ENGINE_POKEDEX = 4, 11 + +function StartMenu:availability() + if self.unlocked then return self.unlocked end + local save = self.save or {} + local inventory = save.inventory or {} + local engine = save.engineFlags or {} + local status = self.game and self.game.modStatus + return { + mods = status ~= nil and #(status.available or {}) > 0, + -- ENGINE_POKEDEX first; pokedexReceived stays as the test/driver override. + pokedex = require("src.script.Flags").hasPokedex(save) + or engine[ENGINE_POKEDEX] == true or save.pokedexReceived == true, + party = #(save.party or {}) > 0, + -- The PACK exists from the start; the cart gates it on nothing. + pack = true, + pokegear = (save.flags or {}).EVENT_GOT_POKEGEAR == true + or (save.flags or {}).ENGINE_POKEGEAR == true or engine[ENGINE_POKEGEAR] == true + or (inventory.POKEGEAR or 0) > 0 or save.pokegearReceived == true, + } +end + +function StartMenu:visibleItems() + local available = self:availability() + local contest = BugContest.active(self.save) + self.contest = contest + local playerName = (self.save and self.save.player and self.save.player.name) + or "GOLD" + local out = {} + for _, item in ipairs(ITEMS) do + local id = item.id + -- pokecrystal engine/menus/start_menu.asm:309 + local hidden = contest and (id == "pack" or id == "quit") + if not hidden and (not item.need or available[item.need]) then + if contest and id == "save" then + -- pokecrystal engine/menus/start_menu.asm:330 + out[#out + 1] = { + label = Strings.source("QUIT"), value = "quitContest", + -- pokecrystal engine/menus/start_menu.asm:231 + desc = Strings.source("Quit and\nbe judged."), + translateLabel = true, + translateDesc = true, + } + else + out[#out + 1] = { + label = item.label or playerName, + value = id, + desc = item.desc, + translateLabel = item.label ~= nil, + translateDesc = item.desc ~= nil, + } + end + end + end + return out +end + +function StartMenu:playSfx(name) + local data = self.game and self.game.data + local audio = data and data.audio + if not audio then return end + if audio.sfx and audio.sfx[name] then Sound.play(data, name) end +end + +function StartMenu:enter() + self:playSfx("Sfx_Menu") +end + +-- index comes from Chrome.List (src/ui/gen2/Chrome.lua:346) so a hook-injected +-- entry can be found by position: a mod's row carries an onSelect callback and +-- no `value`, the way the Gen 1 menu's rows do (src/ui/StartMenu.lua:30), and +-- without this arm it falls off the end of the id chain in Game2:switch. +function StartMenu:choose(id, index) + StartMenu.lastIndex = self.list.index + self.save.startMenuIndex = self.list.index + local item = index and self.items[index] + if item and item.onSelect and item.value == nil then + self:playSfx("Sfx_ReadText2") + item.onSelect(self.game) + return + end + self:playSfx("Sfx_ReadText2") + if id == "quit" then + -- Ask before throwing away everything since the last save. NO is the + -- default, the way the Gen 1 port's QUIT is. + self.phase = "confirm" + self.confirmChoice = 2 + self:beginPrompt() + return + end + if id == "quitContest" then + -- pokecrystal engine/menus/start_menu.asm:411 + self.phase = "confirmContest" + self.confirmChoice = 1 + self:beginPrompt() + return + end + if self.onChoose then self.onChoose(id) end +end + +function StartMenu:confirmQuit() + self.phase = nil + if self.onQuit then + self.onQuit() + elseif self.game and self.game.returnToTitle then + self.game:returnToTitle() + end +end + +-- pokecrystal engine/events/bug_contest/contest.asm:31 +function StartMenu:confirmQuitContest() + self.phase = nil + self:close() + local world = self.game and self.game.overworld + if world and world.bugContestReturnToGate then world:bugContestReturnToGate() end +end + +function StartMenu:close() + if self.closed then return end + self.closed = true + StartMenu.lastIndex = self.list.index + self.save.startMenuIndex = self.list.index + if self.onClose then self.onClose() end +end + +function StartMenu:update(_dt) + local input = self.game and self.game.input + if not input then return end + if self.phase == "confirm" or self.phase == "confirmContest" then + if self.typedPhase ~= self.phase then self:beginPrompt() end + Typer.step(self) + if Typer.typing(self) then return end + if input:wasPressed("up") or input:wasPressed("down") then + self.confirmChoice = self.confirmChoice == 1 and 2 or 1 + elseif input:wasPressed("a") then + if self.confirmChoice ~= 1 then + self.phase = nil + elseif self.phase == "confirmContest" then + self:confirmQuitContest() + else + self:confirmQuit() + end + elseif input:wasPressed("b") or input:wasPressed("start") then + self.phase = nil + end + return + end + self.typedPhase = nil + -- START closes the menu as well as opening it. + if input:wasPressed("start") then + self:close() + return + end + self.list:update(input) +end + +local promptWarned = {} +local function twoRowPrompt(text, what) + local lines = Chrome.wrap(text, 18) + if #lines > 2 and not promptWarned[what] then + promptWarned[what] = true + Logger.warn("%s prompt needs %d lines, the box holds 2: %s", + what, #lines, tostring(text)) + end + return { lines[1] or "", lines[2] } +end + +function StartMenu:promptText() + if self.phase == "confirmContest" then + -- pokecrystal data/text/common_2.asm:1381 + return Strings("Would you like to\nend the Contest?"), "contest end" + end + return Strings(Strings.source("Return to the\ntitle screen?")), "title screen" +end + +-- pokecrystal engine/pokemon/mon_menu.asm:441 +function StartMenu:beginPrompt() + self.typedPhase = self.phase + self.promptLines = twoRowPrompt(self:promptText()) + self.typer = Typer.new(self.game) + self.typer:start(self.promptLines) +end + +function StartMenu:promptTyping() + return self.typedPhase ~= self.phase or Typer.typing(self) +end + +function StartMenu:drawPrompt() + Chrome.textbox(0, 12, 18, 4) + if self.typedPhase ~= self.phase then return end + local lines = Typer.text(self, self.promptLines) + Chrome.print(lines[1] or "", 1, 14) + Chrome.print(lines[2] or "", 1, 16) +end + +-- pokecrystal data/text/common_2.asm ContestStatus: the labels and the +-- "None" placeholder are text, so they go through the catalog like the +-- prompt below. The caught mon's own name does not: it is a nickname or a +-- species the `pokemon` registry already renames. +-- The cart pins each value to its own column (x=8, 7, 8) because it knows how +-- wide its own labels are. A translated label can be wider -- "ATTRAPÉ" fills +-- the 7 tiles CAUGHT leaves, "GEFANGEN" and "ATRAPADO" one more -- so the value +-- starts after the label instead of drawing on top of it, keeping the cart's +-- column whenever the label fits. +local function valueColumn(label, cartColumn) + local labelTiles = math.ceil(Font.width(label) / 8) + return math.max(cartColumn, 1 + labelTiles + 1) +end + +function StartMenu:drawContestStatus() + Chrome.textbox(0, 0, 17, 5) + local caught = Strings("CAUGHT") + local balls = Strings("BALLS:") + Chrome.print(caught, 1, 1) + local mon = BugContest.caught(self.save) + Chrome.print(mon and (mon.nickname or mon.name or mon.species) + or Strings("None", "contest.caught"), valueColumn(caught, 8), 1) + if mon then + local level = Strings("LEVEL") + Chrome.print(level, 1, 3) + Chrome.print(tostring(mon.level or 1), valueColumn(level, 7), 3) + end + Chrome.print(balls, 1, 5) + Chrome.print(tostring(BugContest.ballsLeft(self.save)), valueColumn(balls, 8), 5) +end + +function StartMenu:draw() + -- AutomaticGetMenuBottomCoord: bottom = top + 2 * items + 1, so the box is + -- two rows per entry plus its two border rows. A menu that a mod has grown + -- past the screen scrolls instead of overflowing (Chrome.List draws the ▼ + -- hint when there is more below). + local top = self.contest and 2 or 0 + local height = math.min(#self.items * 2 + 2, Chrome.SCREEN_H - top) + if self.contest then self:drawContestStatus() end + Chrome.box(10, top, 10, height) + self.list:draw() + + if self.phase == "confirmContest" then + self:drawPrompt() + -- pokecrystal engine/pokemon/mon_menu.asm:442 + if self:promptTyping() then return end + -- pokecrystal home/menu.asm:418 + Chrome.box(14, 7, 6, 5) + Chrome.print(Strings("YES"), 16, 8) + Chrome.print(Strings("NO"), 16, 10) + Chrome.cursor(15, self.confirmChoice == 1 and 8 or 10) + return + end + + if self.phase == "confirm" then + self:drawPrompt() + if self:promptTyping() then return end + Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) + Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) + Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) + Chrome.cursor(YESNO_X + 1, + YESNO_Y + (self.confirmChoice == 1 and 1 or 3)) + return + end + + if not self.showDescription then return end + local item = self.list:current() + local desc = item and item.desc + if not desc then return end + -- ._DrawMenuAccount ClearBox (0,13) 5 rows by 10, .PrintMenuAccount decoord + -- 0, 14 and the desc's `next` steps two rows (start_menu.asm:366-382). + Chrome.paletteFill(0, 13 * 8, 10 * 8, 5 * 8) + Chrome.print(desc[1] or "", 0, 14) + Chrome.print(desc[2] or "", 0, 16) +end + +-- The native START owner remains on the stack while its submenu runs. +-- This is also the public class that optional presentation modules decorate. +function StartMenu:open(id) + local Screens = require('src.ui.Screens') + local game = self.game + if id == 'save' then + local saving + saving = Screens.push(game, 'Gen2SaveMenu', {onDone=function() + if game.stack:top() == saving then game.stack:pop() end + self:close() + end}) + return saving + end + local target = {pokedex='PokedexMenu', pokemon='PartyMenu', pack='Gen2PackMenu', + pokegear='PokegearMenu', status='TrainerCard', option='OptionsMenu', mods='ManagerState'} + if id == 'pack' then return Screens.push(game, target[id]) end + if target[id] then return Screens.push(game, target[id], {onCancel=function() end}) end + if id == 'exit' then return self:close() end +end +function StartMenu:drawsWidescreen() return false end +function StartMenu:drawWidescreen(width, height) + return Chrome.withOverlayPanel(width,height,function() self:draw() end) +end + +StartMenu.ITEMS = ITEMS + +return StartMenu diff --git a/src/ui/Gen2SummaryView.lua b/src/ui/Gen2SummaryView.lua new file mode 100644 index 000000000..b758ca853 --- /dev/null +++ b/src/ui/Gen2SummaryView.lua @@ -0,0 +1,154 @@ +-- Gen2 status/move-screen contracts over this engine's actual Pokemon. +-- Layout and input follow Gen1Recomp 535a5ec0 src/ui/gen2/SummaryMenu.lua; +-- existing engine/Gen1 rights remain applicable (NOTICE.md). +-- Native-owner integration: Roxas2712 (LICENSE-ROXAS2712.md). +local Font = require('src.render.Font') +local Strings = require('src.core.Strings') +local TypeChart = require('src.battle.TypeChart') +local Pokemon = require('src.pokemon.Pokemon') +local Growth = require('src.pokemon.Growth') +local WaitSfx = require('src.ui.Gen2WaitPlaySFX') +local M = {} +function M.install(Summary) + function Summary:speciesDef() + return self.mon and self.pokemon and self.pokemon[self.mon.species] + end + function Summary:moveDef(id) + if type(id)=='table' then id=id.id end + return id and self.moves and self.moves[id] or nil + end + function Summary:moveList() return self.mon and self.mon.moves or {} end + function Summary:moveName(entry) + local def=entry and self:moveDef(entry.id) + return def and def.name or entry and entry.id + end + function Summary:growth() + local def=self:speciesDef() + return def and self.game.data.growth_rates and self.game.data.growth_rates[def.growthRate] + end + function Summary:expToNext() + local mon,def=self.mon,self:speciesDef() + if not(mon and def)or mon.level>=100 then return 0 end + return math.max(0,Growth.expForLevel(def.growthRate,mon.level+1,self.game.data.growth_rates)-(mon.exp or 0)) + end + function Summary:otName() + return self.mon.ot or self.mon.otName or (self.save and self.save.player or {}).name or 'GOLD' + end + function Summary:otId() return self.mon.otId or (self.save and self.save.player or {}).id or 0 end + function Summary:itemName() + local item=self.mon.item;local def=item and self.items and self.items[item] + return def and def.name or item + end + function Summary:typeNames() + local def=self:speciesDef();local types=self.mon.types or def and def.types or {} + local a,b=types[1],types[2] + return a and TypeChart.displayName(a,self.game.data),b and b~=a and TypeChart.displayName(b,self.game.data)or nil + end + function Summary:close() + if self.closed then return end + self.closed=true + if self.onClose then self.onClose() else self.game.stack:pop() end + end + function Summary:turnPage(delta) self.page=(self.page-1+delta)%3+1 end + function Summary:switchMon(delta) return self:moveMon(delta) end + function Summary:switchMonPastEggs(delta) + local target=self.index+delta + while self.party[target] and require('src.pokemon.Party').isEgg(self.party[target])do target=target+delta end + if not self.party[target]then return false end + return self:moveMon(target-self.index) + end + function Summary:swapMoves(from,to) + local moves=self:moveList() + if from==to or not(moves[from] and moves[to])then return false end + moves[from],moves[to]=moves[to],moves[from] + return true + end + function Summary:playSwapSfx() + local Sound=require('src.core.Sound');local data=self.game.data + local sfx=data.audio and data.audio.sfx + -- The importer already installs native labels and shared-role aliases. + if sfx and sfx.Sfx_SwitchPokemon then Sound.play(data,'Sfx_SwitchPokemon')end + end + function Summary:tickRepeatSfx() + if not self.repeatSfx then return false end + if WaitSfx.waiting(self.repeatSfx,self.game)then return true end + self.repeatSfx=nil;self:playSwapSfx();return false + end + function Summary:updateMoveDetail(input) + local moves=self:moveList();local count=math.max(1,#moves) + if input:wasPressed('up')then self.moveIndex=self.moveIndex>1 and self.moveIndex-1 or count + elseif input:wasPressed('down')then self.moveIndex=self.moveIndex'..tostring(mon.level),5+Font.width(name)/8,1) + end + local moves=self:moveList() + for slot=1,4 do + local move=moves[slot];local y=3+(slot-1)*2 + put(move and self:moveName(move)or'-',2,y) + if move then + put(Strings('PP'),10,y+1);put(('%2d/%2d'):format(move.pp or 0,Pokemon.maxPP(self:moveDef(move.id),move)),13,y+1) + else put('--',10,y+1)end + end + put('┌─────┐',0,10);put(self.swapFrom and'│ └'or'│'..Strings('TYPE/')..'└',0,11) + if self.swapFrom then put(Strings('Where?'),1,12);return out end + local move=moves[self.moveIndex];local def=move and self:moveDef(move.id) + put(def and TypeChart.displayName(def.type,self.game.data)or'---',2,12) + put(Strings('ATTACK/'),11,12) + put(def and(def.power or 0)>=2 and('%3d'):format(def.power)or'---',16,12) + local y=14 + for line in ((def and def.description or'')..''):gmatch('(.-)')do + if y>16 then break end + if line~=''then put(line,1,y)end;y=y+2 + end + return out + end + function Summary:drawMoveDetail() + local Chrome=require('src.ui.Gen2Chrome');Chrome.clear() + Chrome.textbox(0,1,18,9);Chrome.textbox(0,11,18,5) + local placements=self:moveDetailPlacements() + -- Name/level and the TYPE plaque deliberately replace border cells. + Chrome.paletteFill(40,8,math.min(120,Font.width(placements[1].text)+32),8) + Chrome.paletteFill(0,80,56,16) + if self.index>1 then Chrome.print('◀',16,0)end + if self.index<#self.party then Chrome.print('▶',18,0)end + for _,v in ipairs(placements)do + -- The native border table owns these cells. A reduced font need not + -- advertise Unicode box characters in its text charmap. + if v.x==0 and v.y==10 then + local b=Font.BORDER;love.graphics.setColor(1,1,1,1) + Font.drawCode(b.tl,0,80) + for x=1,5 do Font.drawCode(b.t or b.h,x*8,80)end + Font.drawCode(b.tr,48,80) + elseif v.x==0 and v.y==11 then + local b=Font.BORDER;love.graphics.setColor(1,1,1,1) + Font.drawCode(b.l or b.v,0,88);Font.drawCode(b.bl,48,88) + if not self.swapFrom then Chrome.print(Strings('TYPE/'),1,11)end + else Chrome.print(v.text,v.x,v.y)end + end + local count=#self:moveList() + if count>0 then + if self.swapFrom then Chrome.cursor(1,3+(self.swapFrom-1)*2,true)end + Chrome.cursor(1,3+(math.min(self.moveIndex,count)-1)*2) + end + end +end +return M diff --git a/src/ui/Gen2Typer.lua b/src/ui/Gen2Typer.lua new file mode 100644 index 000000000..e038a76fc --- /dev/null +++ b/src/ui/Gen2Typer.lua @@ -0,0 +1,186 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Original project rights and notices remain applicable; see NOTICE.md. +-- ../pokecrystal/home/print_text.asm:1 PrintLetterDelay +-- ../pokecrystal/home/text.asm:660 PrintTextboxTextAt + +local Font = require("src.render.Font") + +local Typer = {} +Typer.__index = Typer + +-- ../pokecrystal/home/print_text.asm:5 +local DELAYS = { FAST = 1, MID = 3, SLOW = 5, fast = 1, medium = 3, slow = 5 } + +local function spansOf(text) + if Font.split then return Font.split(text) end + local spans = {} + local i, n = 1, #text + while i <= n do + local last = i + if text:byte(i) >= 0xC0 then + local k = i + 1 + while k <= n do + local b = text:byte(k) + if b < 0x80 or b > 0xBF then break end + last, k = k, k + 1 + end + end + spans[#spans + 1] = { from = i, to = last } + i = last + 1 + end + return spans +end + +local function linesOf(page) + if page == nil then return {} end + if type(page) == "string" then + local lines = {} + for part in (page .. "\n"):gmatch("(.-)\n") do lines[#lines + 1] = part end + return lines + end + return page +end + +-- ../pokecrystal/engine/events/pokecenter_pc.asm:314 +function Typer.new(game, opts) + opts = opts or {} + return setmetatable({ + game = game, + speed = opts.speed, + instant = opts.instant and true or false, + expand = opts.expand, + shown = 0, + total = 0, + timer = 0, + page = {}, + }, Typer) +end + +-- ../pokecrystal/home/text.asm:520 _ContTextNoPause +function Typer:start(page) + local out, spans, total, kept = {}, {}, 0, 0 + local scrolled = type(page) == "table" and page.scrolled + for i, line in ipairs(linesOf(page)) do + local text = self.expand and self.expand(line) or line + out[i] = text + -- Split once here: lines() runs every frame while the page types out. + local lineSpans = spansOf(text) + spans[i] = lineSpans + local n = #lineSpans + total = total + n + if scrolled and i == 1 then kept = n end + end + self.page = out + self.spans = spans + self.spansPage = out + self.linesOut, self.linesShown = nil, nil + self.total = total + self.shown = self.instant and total or kept + self.timer = 0 +end + +-- ../pokecrystal/home/print_text.asm:52 +function Typer:delay() + if self.instant then return 0 end + local options = self.game and self.game.save and self.game.save.options + local raw = self.speed or (options and options.textSpeed) + local delay = DELAYS[raw] or tonumber(raw) or 3 + if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end + local input = self.game and self.game.input + if input and input.isDown + and (input:isDown("a") or input:isDown("b")) then + delay = 1 + end + return delay +end + +function Typer:done() + return self.shown >= self.total +end + +function Typer:tick() + if self:done() then return true end + local delay = self:delay() + if delay <= 0 then + self.shown = self.total + return true + end + if self.timer >= delay then self.timer = delay - 1 end + self.timer = self.timer + 1 + while self.timer >= delay and self.shown < self.total do + self.timer = self.timer - delay + self.shown = self.shown + 1 + end + return self:done() +end + +function Typer:lines() + if self:done() then return self.page end + -- The same partial page is asked for every frame between two letters. + if self.linesOut and self.linesShown == self.shown + and self.linesPage == self.page then + return self.linesOut + end + local cached = self.spansPage == self.page and self.spans or nil + local out, left = {}, self.shown + self.linesOut, self.linesShown, self.linesPage = out, self.shown, self.page + for i, line in ipairs(self.page) do + local spans = cached and cached[i] or spansOf(line) + if left >= #spans then + out[i] = line + elseif left <= 0 then + out[i] = "" + else + out[i] = line:sub(1, spans[left].to) + end + left = left - #spans + end + return out +end + +-- ../pokecrystal/home/menu.asm:328 +function Typer.say(screen, pages, onDone, opts) + screen.message = { pages = pages or {}, page = 1, onDone = onDone } + screen.typer = Typer.new(screen.game, opts) + screen.typer:start(screen.message.pages[1]) + return screen.message +end + +function Typer.begin(screen, record, opts) + screen.typer = Typer.new(screen.game, opts) + screen.typer:start(record.pages and record.pages[record.page or 1]) + return record +end + +function Typer.turn(screen, record) + record.page = record.page + 1 + if screen.typer then screen.typer:start(record.pages[record.page]) end +end + +function Typer.step(screen) + screen.arrowBlink = ((screen.arrowBlink or 0) + 1) % 32 + local typer = screen.typer + if not typer then return true end + return typer:tick() +end + +-- ../pokegold/home/joypad.asm:430 +function Typer.arrowOn(screen) + return ((screen.arrowBlink or 0) % 32) < 16 +end + +-- ../pokecrystal/home/print_text.asm:59 +function Typer.typing(screen) + local typer = screen.typer + return typer ~= nil and not typer:done() +end + +function Typer.text(screen, fallback) + local typer = screen.typer + if not typer then return fallback end + return typer:lines() +end + +Typer.DELAYS = DELAYS + +return Typer diff --git a/src/ui/Gen2WaitPlaySFX.lua b/src/ui/Gen2WaitPlaySFX.lua new file mode 100644 index 000000000..65b9ed2d0 --- /dev/null +++ b/src/ui/Gen2WaitPlaySFX.lua @@ -0,0 +1,36 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +local WaitPlaySFX = {} + +function WaitPlaySFX.step(game) + local speed = game and type(game.logicSpeed) == "function" and game:logicSpeed() or 1 + if type(speed) ~= "number" or speed ~= speed or speed < 1 or speed == math.huge then speed = 1 end + return 1 / speed +end + +local function sound() + local ok, mod = pcall(require, "src.core.Sound") + return ok and type(mod) == "table" and mod or nil +end + +-- home/audio.asm:220 WaitPlaySFX +-- home/delay.asm:15 +function WaitPlaySFX.arm(name, fallback) + local Sound = sound() + -- Wait on the engine's actual source; the frame budget bounds a stuck + -- device. Missing/headless effects finish immediately. + return { name = name, left = math.max(1, tonumber(fallback) or 180) } +end + +-- home/audio.asm:225 WaitSFX +function WaitPlaySFX.waiting(pending, game) + if not pending then return false end + pending.left = (pending.left or 0) - WaitPlaySFX.step(game) + if pending.left <= 0 then return false end + local Sound = sound() + if not Sound then return false end + if Sound.sfxBusy and Sound.sfxBusy() then return true end + return (Sound.isPlaying and Sound.isPlaying(pending.name)) or false +end + +return WaitPlaySFX diff --git a/src/ui/LevelDisplay.lua b/src/ui/LevelDisplay.lua new file mode 100644 index 000000000..0092e81c8 --- /dev/null +++ b/src/ui/LevelDisplay.lua @@ -0,0 +1,16 @@ +-- Shared level-display predicate for native GB/GBA screens. Only literal +-- false hides the complete level label; callers retain status/egg eligibility. +local Runtime = require("src.mods.Runtime") +local LevelDisplay = {} +local function visibleByDefault() + return true +end + +-- where: "battle.enemy" | "battle.player" | "party" | "summary" +function LevelDisplay.visible(mon, where, game) + if not Runtime.wantsHook("pokemon.level_visible") then + return true + end + return Runtime.call("pokemon.level_visible", visibleByDefault, mon, { where = where, game = game }) ~= false +end +return LevelDisplay diff --git a/src/ui/MenuRepeat.lua b/src/ui/MenuRepeat.lua new file mode 100644 index 000000000..ce8110bb4 --- /dev/null +++ b/src/ui/MenuRepeat.lua @@ -0,0 +1,45 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Existing project rights and Gen1Recomp provenance remain applicable; see LICENSE.MD. +-- home/joypad2.asm:16-53 JoypadLowSensitivity (hJoy7 = 1, 30 then 5) +-- pokegold home/joypad.asm:313-340 JoyTextDelay (hInMenu, 15 then 5) + +local MenuRepeat = {} + +MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE = 30, 5 +MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE = 15, 5 + +local ALL_DIRS = { "up", "down", "left", "right" } + +function MenuRepeat.new(delay, rate, enabled) + return { + delay = tonumber(delay) or MenuRepeat.GEN1_DELAY, + rate = math.max(1, tonumber(rate) or MenuRepeat.GEN1_RATE), + enabled = enabled ~= false, + dir = nil, + frames = 0, + } +end + +function MenuRepeat.reset(state) + state.dir, state.frames = nil, 0 +end + +function MenuRepeat.direction(state, input, dirs) + for _, dir in ipairs(dirs or ALL_DIRS) do + if input:wasPressed(dir) then + state.dir, state.frames = dir, 0 + return dir, true + end + end + local dir = state.dir + if not (dir and state.enabled and input.isDown and input:isDown(dir)) then + MenuRepeat.reset(state) + return nil, false + end + state.frames = state.frames + 1 + local afterDelay = state.frames - state.delay + if afterDelay >= 0 and afterDelay % state.rate == 0 then return dir, false end + return nil, false +end + +return MenuRepeat diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index 3fbcfc349..ad6fa8cb9 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -321,6 +321,9 @@ end function NamingScreen.new(game, opts) opts = opts or {} + if require("src.core.GameVersion").isGen2() then + return require("src.ui.Gen2NamingScreen").native(game, opts) + end local self = setmetatable({}, NamingScreen) self.game = game self.title = opts.title or Strings("YOUR NAME?") @@ -654,7 +657,9 @@ function NamingScreen:drawClassic() -- typed name with dashes for the empty slots, on the font's own pitch local slot = math.max(8, Font.width("W") + 2) for i = 1, self.maxLen do - Font.draw(self.glyphs[i] or "-", m.left + 40 + (i - 1) * slot, 26) + if self.mailMode then + Font.draw(self.glyphs[i] or "-",8+((i-1)%16)*8,24+math.floor((i-1)/16)*12) + else Font.draw(self.glyphs[i] or "-", m.left + 40 + (i - 1) * slot, 26) end end for r, row in ipairs(m.rows) do for c, cell in ipairs(row) do diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index ed0f11d16..6d720f825 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -281,7 +281,7 @@ end -- over a Gen2 save. The keys are the ROM's own text labels, so textOr finds -- them in data.text instead of falling through. function OakSpeech.gen2Steps() - return { + local steps = { { id = "oak_welcome", kind = "say", @@ -384,6 +384,11 @@ function OakSpeech.gen2Steps() kind = "shrink", }, } + local version = GameVersion.get() + if version == "gold" or version == "silver" or version == "crystal" then + table.insert(steps, 1, { id="init_clock", kind="initclock" }) + end + return steps end -- list helpers for intro.oak_speech.build wrappers (also on ModUI) @@ -489,10 +494,16 @@ function OakSpeech:buildSteps() end function OakSpeech:enter() - -- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48) - Music.play(self.game.data, self.cfg.music or "Music_Routes2") + local version = GameVersion.get() + local clockIntro = version == "gold" or version == "silver" or version == "crystal" + -- Other introductions retain their original music-before-build ordering. + if not clockIntro then Music.play(self.game.data, self.cfg.music or "Music_Routes2") end self.answers = {} self.steps = self:buildSteps() + -- The retail clock comes before the professor and his music. + if clockIntro and not (self.steps[1] and self.steps[1].kind == "initclock") then + Music.play(self.game.data, self.cfg.music or "Music_Routes2") + end if Runtime.wants("intro.oak_speech.started") then Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps }) end @@ -660,7 +671,17 @@ end function OakSpeech:runStep(step) local kind = step.kind or "say" - if kind == "say" then + if kind == "initclock" then + local screen + screen = require("src.ui.Screens").push(self.game, "Gen2InitClock", { + save=self.game.save, fades=true, + onDone=function() + if self.game.stack:top() == screen then self.game.stack:pop() end + Music.play(self.game.data, self.cfg.music or "Music_Routes2") + self:advance() + end, + }) + elseif kind == "say" then self:applyPic(step) self:afterReveal(step, function() self:runCry(step) @@ -690,8 +711,26 @@ function OakSpeech:runStep(step) presets = presets or namePresets(self.game, step.presetsWho or who, step.presetsFallback or { "RED" }) + if GameVersion.isGen2() and who == "player" and #presets > 0 then + local screen + local gender = self.game.save.player.gender + local pic, trueColor = self:formPic(gender) + screen = require("src.ui.Screens").push(self.game, "Gen2NamePick", { + gender = gender, presets = presets, pic = pic, picTrueColor = trueColor, + font = self.game.fontData, prompt = step.title, maxLength = step.maxLen or self.nameLen, + onDone = function(name) + if self.game.stack:top() ~= screen then return end + self.game.stack:pop() + self.game.save.player.name = name + self:recordAnswer(step, 1, name, name) + self:advance() + end, + }) + return + end require("src.ui.Screens").push(self.game, "NamingScreen", { title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")), + kind = who == "rival" and "rival" or "player", presets = presets, maxLen = step.maxLen or self.nameLen, onDone = function(name) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..ede47dda6 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -45,6 +45,7 @@ local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } } -- no-loader fallback for the ruleset row, same pair BattleState keeps local Rulesets = { gen1_faithful = require("src.battle.rulesets.gen1_faithful"), + gen2_finite_pp = require("src.battle.rulesets.gen2_finite_pp"), modern_clean = require("src.battle.rulesets.modern_clean"), } local FILTERS = { "OFF", "1X", "2X", "3X" } @@ -68,15 +69,12 @@ local function rulesetIds(game) if not record.hidden then ids[#ids + 1] = id end end table.sort(ids) + table.insert(ids, 1, false) return ids end local function rulesetIndex(game, ids) - -- The same fallback the battle uses, from the same place. When these two - -- disagreed the row showed GEN 1 FAITHFUL on a cartridge that was about to - -- fight under it -- which was true, and was the bug rather than the display. - local cur = game.save.options.ruleset - or require("src.battle.RulesetDefaults").defaultFor(game) + local cur = require("src.battle.RulesetDefaults").selection(game) or false for i, id in ipairs(ids) do if id == cur then return i end end @@ -86,7 +84,8 @@ end local function rulesetName(game) local rulesets = game.data and game.data.rulesets or Rulesets local ids = rulesetIds(game) - local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset + local id = ids[rulesetIndex(game, ids)] + if id == false then return Strings("AUTO") end local record = id and rulesets[id] return Strings(record and record.name or id or "----") end @@ -104,8 +103,8 @@ local function stepVolume(v, dir) end local function colorIndex(opts) - local cur = opts.colors or "gbc" - for i, m in ipairs(PaletteFX.MODES) do + local cur = GameVersion.isGen2() and PaletteFX.mode or opts.colors or "gbc" + for i, m in ipairs(PaletteFX.colorModes()) do if m == cur then return i end end return 1 @@ -370,14 +369,17 @@ local function buildRows(game) end }, { id = "colors", label = Strings("COLORS"), value = function(g) - return PaletteFX.modeLabel(g.save.options.colors or "gbc") + local mode = GameVersion.isGen2() and PaletteFX.mode or g.save.options.colors or "gbc" + return PaletteFX.modeLabel(mode) end, step = function(g, dir) local o = g.save.options local i = colorIndex(o) - i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1 - o.colors = PaletteFX.MODES[i] - PaletteFX.setMode(o.colors) + local modes = PaletteFX.colorModes() + i = wrapIndex(i - 1 + dir, #modes) + 1 + PaletteFX.writeColorOption(o, modes[i]) + if GameVersion.isGen2() then PaletteFX.setDisplay(o.colors,nil) + else PaletteFX.setMode(o.colors) end return true end }, { id = "tilt", label = Strings("TILT"), @@ -824,11 +826,24 @@ function OptionsMenu:update(dt) #rows, cancelRow) end -function OptionsMenu:draw() +local function drawPanel(self) OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0, "CANCEL", #self.rows + 1) end +function OptionsMenu:draw() + return drawPanel(self) +end + +-- Expose the actual window-sized presentation seam for Gen2 providers, +-- retaining the existing native panel as a DPI-aware fallback. +function OptionsMenu:drawsWidescreen() return GameVersion.isGen2() end +function OptionsMenu:wantsFillScale() return GameVersion.isGen2() end +function OptionsMenu:drawWidescreen(width, height) + return require("src.ui.Gen2Chrome").withPanel(width, height, 1, 1, 1, + function() drawPanel(self) end) +end + -- see the note above buildRows: the Gen 3 OPTION screen appends these OptionsMenu.buildRows = buildRows diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..d6531a7ad 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -71,7 +71,7 @@ function PartyMenu:sgbPalettes(game) -- RedrawPartyMenu snaps it green when the message prints. Hold the -- starting HP here for exactly that window (#252). local hp = mon.hp - if self.heal and self.heal.mon == mon then hp = self.heal.from end + if self.heal and self.heal.mon == mon and self.heal.from ~= nil then hp = self.heal.from end local bar = P.pal(game.data, P.barPalName(hp, mon.stats.hp)) if bar then zones[#zones + 1] = P.zone(bar, 6, i * 2 - 1, 12, i * 2 - 1) @@ -386,6 +386,9 @@ end -- player picks what to hand over; TAKE moves the held item back. function PartyMenu:openItemMenu(mon) local game = self.game + if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then + game.stack:push(require("src.ui.Gen2MailMenu").new(game,{mon=mon}));return + end local TextBox = require("src.render.TextBox") local Bag = require("src.inventory.Bag") local Menu = require("src.ui.Menu") @@ -438,7 +441,20 @@ function PartyMenu.new(game, opts) self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch self.battle = opts.battle - self.party = opts.party -- link battles pass their clamped copies + -- Public renderers and native input must see the same live party. Wild + -- battles normally use save.party; playerParty exists only for a scoped + -- battle selection (for example a link copy). Leaving this field nil made + -- native input work via its fallback while a presentation saw no Pokemon. + local battleParty = type(opts.battle) == "table" and opts.battle.playerParty + self.party = opts.party or battleParty or (game.save and game.save.party) or {} + if require("src.core.GameVersion").isGen2() then + -- Gen2's native party boundary refreshes the species-name copy, just as + -- its Mon.refreshStats does. Keep nicknames and the actual mon identity. + for _, mon in ipairs(self.party) do + local def = game.data and game.data.pokemon and game.data.pokemon[mon.species] + if def then mon.name = def.name or mon.species end + end + end self.swapFrom = nil self.submenu = nil self.subIndex = 1 @@ -477,6 +493,10 @@ function PartyMenu:update(dt) self.blink = ((self.blink or 0) + 1) % 320 -- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking -- predef in item_effects.asm, so no button is read until it lands (#252). + if self.itemResult then + self:updateItemResult(self.game.input) + return + end local heal = self.heal if heal then heal.shown = math.min(heal.mon.hp, @@ -778,6 +798,9 @@ function PartyMenu:update(dt) elseif self.swapFrom then if self.swapFrom ~= self.index then party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] + if require("src.core.GameVersion").isGen2() and party==self.game.save.party then + require("src.pokemon.Gen2Mail").swapSlots(self.game.save,self.swapFrom,self.index) + end require("src.core.Sound").play(self.game.data, "Swap") end self.swapFrom = nil @@ -882,7 +905,7 @@ function PartyMenu:update(dt) local stats, switch = table.remove(items, 1), table.remove(items, 1) items[#items + 1] = stats items[#items + 1] = switch - items[#items + 1] = { label = Strings("ITEM"), action = "item" } + items[#items + 1] = { label = Strings(require("src.pokemon.Gen2Mail").monHoldsMail(mon) and "MAIL" or "ITEM"), action = "item" } items[#items + 1] = { label = Strings("QUIT"), action = "quit" } end end @@ -1008,7 +1031,9 @@ function PartyMenu:draw() -- only .hp and .stats, so a shim table is enough and the real mon is -- never mutated for display (#252). local shown = mon - if self.heal and self.heal.mon == mon then + if self.itemResult then + shown = { hp = self:shownHpFor(i, mon), stats = mon.stats } + elseif self.heal and self.heal.mon == mon then shown = { hp = math.floor(self.heal.shown), stats = mon.stats } end love.graphics.setColor(1, 1, 1, 1) @@ -1073,4 +1098,5 @@ function PartyMenu:draw() love.graphics.setColor(1, 1, 1, 1) end +require('src.ui.Gen2PartyItemResult').install(PartyMenu) return PartyMenu diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 8203e0800..d891e7778 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -169,19 +169,44 @@ PlayerPC.withdraw = withdraw PlayerPC.deposit = deposit PlayerPC.toss = toss -function PlayerPC.new(game) +function PlayerPC.new(game,opts) + opts=opts or{} + if require("src.core.GameVersion").isGen2() then + local arg={};for key,value in pairs(opts) do arg[key]=value end + arg.onClose=opts.onClose or function(changed) + game.stack:pop() + if opts.onDone then opts.onDone(changed) end + end + return require("src.ui.Gen2ItemPcMenu").new(game,arg) + end + local changed,done=false,false + local function finish() + if done then return end;done=true + if opts.onDone then opts.onDone(changed)end + end game.save.pcItems = game.save.pcItems or {} - return Menu.new(game, { + local items={ -- keepOpen so B in the item lists returns here instead of dropping the -- whole PC session (players_pc.asm re-shows the PC menu); same pattern -- as BoxMenu's rows { label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end }, { label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end }, { label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end }, - { label = Strings("LOG OFF") }, + { label = Strings("LOG OFF"),onSelect=finish }, -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 - }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) + } + if require("src.core.GameVersion").isGen2() then + table.insert(items,#items,{label=Strings("MAILBOX"),keepOpen=true,onSelect=function() + game.stack:push(require("src.ui.Gen2MailboxMenu").new(game)) + end}) + end + if opts.house and require("src.world.Gen2Decorations").ready(game.data)then + table.insert(items,#items,{label=Strings("DECORATION"),keepOpen=true,onSelect=function() + require("src.ui.Screens").push(game,"Gen2DecorationMenu",{onDone=function(value)changed=value or changed end}) + end}) + end + return Menu.new(game,items,{tx=0,ty=0,tw=16,maxVisible=8,noSound=true,onCancel=finish}) end return PlayerPC diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..a87e3392d 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -370,6 +370,25 @@ function PokegearMenu:current() return self.cards[self.index] end +-- Public clock presentation, shared by the native card and source renderers. +-- Keep the native save/world as the owner; weekday is one-based for UI lists. +function PokegearMenu:clockParts() + if type(self.clock) == "table" then + return self.clock.hour or 0, self.clock.minute or 0, self.clock.weekday or 1 + end + local game = self.game + local world = game and (game.overworld or game.world) + if world and type(world.hour) == "function" and type(world.minute) == "function" + and type(world.weekday) == "function" then + return world:hour(), world:minute(), world:weekday() % 7 + 1 + end + local save = game and game.save or self.save + local Clock = require("src.core.Gen2Clock") + local minutes = Clock.minutes(save) + return math.floor(minutes / 60), minutes % 60, Clock.weekday(save) + 1 +end + + -- The registered phone numbers, newest id last. -- -- Gen2 keeps two halves of this and the card used to read neither: which ids @@ -689,11 +708,8 @@ function PokegearMenu:drawClock() self:drawStrip() self:text("SWITCH", 13, 1) - local save = self.game.save - local t = math.floor(require("src.core.SaveData").playSeconds(save)) - local hour = math.floor(t / 3600) % 24 - local minute = math.floor(t / 60) % 60 - local day = DAYS[(math.floor(t / 86400) % 7) + 1] + local hour, minute, weekday = self:clockParts() + local day = require("src.core.Gen2Clock").weekdayName(weekday) local display = hour % 12 if display == 0 then display = 12 end self:text(day, 6, 6) diff --git a/src/ui/QuarantineReport.lua b/src/ui/QuarantineReport.lua index 741114eda..075ab4a1f 100644 --- a/src/ui/QuarantineReport.lua +++ b/src/ui/QuarantineReport.lua @@ -33,6 +33,15 @@ end -- recovered, modsDiff } local function buildLines(report, meta) local lines = {} + if #(report.lostMail or {})>0 then + lines[#lines+1]=Strings("MAIL repairs: %d",#report.lostMail) + for _,entry in ipairs(report.lostMail)do + lines[#lines+1]=clip(" "..tostring(entry.why or "Invalid letter")) + end + end + if #(report.restoredMail or {})>0 then + lines[#lines+1]=Strings("MAIL restored: %d",#report.restoredMail) + end if report.recovered then lines[#lines + 1] = "Save recovered from" lines[#lines + 1] = clip(" the ." .. tostring(report.recovered) .. " backup copy") diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 704c642e3..ee9f6831d 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -11,7 +11,7 @@ local Screens = {} -- ids whose builtin module is not under src/ui/ local BUILTIN = { ManagerState = "src.mods.ManagerState", - Gen2StartMenu = "src.ui.StartMenu", + Gen2SummaryMenu = "src.ui.SummaryMenu", } local cache = {} @@ -400,6 +400,11 @@ end -- The id a push should really open, and the arguments it should open with. -- Public so the suite can state the rule without driving a whole game. function Screens.resolveId(game, id, arg) + -- GSC owns a native START state, including its live submenu parent. + if id == 'StartMenu' and require('src.core.GameVersion').isGen2() then + local screens = game and game.data and game.data.screens + if not (screens and screens[id]) then return 'Gen2StartMenu', arg end + end -- BLACK AND WHITE'S OWN SCREENS, ahead of everything (a Gen 5 cache is not -- a Gen 4 one, so the table below would hand it the Game Boy's): so far the -- evolution scene, positional like Platinum's @@ -550,7 +555,7 @@ function Screens.get(game, id) return resolve(game, id) end -local function pushWith(game, id, ...) +local function buildWith(game, id, ...) local factory = resolve(game, id) local inst if factory.__modOwned then @@ -577,24 +582,31 @@ local function pushWith(game, id, ...) inst = factory.new(game, ...) end inst.screenId = inst.screenId or id - game.stack:push(inst) return inst end -function Screens.push(game, id, ...) +function Screens.build(game, id, ...) -- A POSITIONAL alias keeps every argument, because its screen is not an -- options-table screen -- MoveLearnMenu takes (mon, moveId, onDone) and -- dropping the last two would open it with nothing to teach. if Screens.positionalAlias(id) then local resolvedId = Screens.resolveId(game, id, ...) - return pushWith(game, resolvedId, ...) + return buildWith(game, resolvedId, ...) end -- Otherwise only the FIRST argument decides: every other screen in the -- alias table takes one options table and nothing else, and a caller -- passing more is by that fact not talking to one of them. local resolvedId, resolvedArg = Screens.resolveId(game, id, ...) - if resolvedId ~= id then return pushWith(game, resolvedId, resolvedArg) end - return pushWith(game, id, ...) + if resolvedId ~= id then return buildWith(game, resolvedId, resolvedArg) end + return buildWith(game, id, ...) +end + +-- Embedded screens use the identical factory/registry/error policy without +-- changing stack ownership or emitting a premature screen.pushed event. +function Screens.push(game, id, ...) + local inst = Screens.build(game, id, ...) + game.stack:push(inst) + return inst end function Screens.invalidate() diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index b898636a1..999db2f14 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -17,6 +17,9 @@ local StartMenu = {} local function sameItems(_, items) return items end function StartMenu.new(game) + if require("src.core.GameVersion").isGen2() then + return require("src.ui.Gen2StartMenu").new(game) + end local flags = game.save.flags or {} local items = {} @@ -83,6 +86,13 @@ function StartMenu.new(game) -- below rather than here. if not inContest then table.insert(items, { label = Strings("SAVE"), onSelect = function() + if require('src.core.GameVersion').isGen2() then + local screen + screen=Screens.push(game,'Gen2SaveMenu',{onDone=function() + if game.stack:top()==screen then game.stack:pop() end + end}) + return + end local TextBox = require("src.render.TextBox") local badges = require("src.inventory.Badges").count(game.data, game.save) local owned = 0 diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 090401b37..b465b9694 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -115,29 +115,9 @@ function SummaryMenu:sgbPalettes(game) return out end -function SummaryMenu.new(game, mon, opts) - -- A FALLBACK MUST BE ABLE TO READ WHAT IT IS HANDED. - -- - -- This screen is the end of the line for `Screens.push("SummaryMenu", ...)`: - -- when the Gen 3 or Gen 4 alias declines a push, the arguments arrive here - -- unchanged. But those generations' screens take `(game, opts)` while this - -- one takes `(game, mon, opts)` POSITIONALLY, so a declined push arrives - -- with its options table sitting in `mon` -- `mon.species` nil, - -- `data.pokemon[nil]` nil, and the draw dies on "attempt to index local - -- 'def' (a nil value)" several frames later, miles from the cause. - -- - -- That is exactly what the Gen 4 party menu's SUMMARY row did until the - -- alias learned `readOnlyMoves` (see src/ui/Screens.lua). The routing bug - -- is fixed there; this is the other half, because the `choose` push still - -- falls through here BY DESIGN and a fallback that cannot read its argument - -- is not a fallback. - -- - -- Unwrapped rather than refused: the options table carries the Pokemon in - -- `mon`, which is everything this screen needs. - if type(mon) == "table" and mon.species == nil and type(mon.mon) == "table" then - opts, mon = mon, mon.mon - end - opts = opts or {} +local function loadMon(self) + local game, mon = self.game, self.mon + self.sprite, self.spriteTrueColor, self.picAnim = nil, nil, nil -- status_screen.asm:66-76: StatusScreen recalculates the stat block before -- it draws anything when the mon came from a box or the daycare ("mon is -- in a box or daycare" -> CalcStats), because box_struct carries none. @@ -148,14 +128,6 @@ function SummaryMenu.new(game, mon, opts) -- SaveData.validate has run over a loaded save, but this is the site the -- original recomputes at, and it also covers a mon handed in by a mod. Stats.ensure(game.data.pokemon[mon.species], mon) - local self = setmetatable({ - game = game, - mon = mon, - page = 1, - mons = opts.mons, - monIndex = opts.index, - onMonChange = opts.onMonChange, - }, SummaryMenu) self.isEgg = require("src.pokemon.Party").isEgg(mon) and true or false local Sprites = require("src.pokemon.Sprites") if self.isEgg then @@ -189,6 +161,49 @@ function SummaryMenu.new(game, mon, opts) return self end +function SummaryMenu.new(game, mon, opts) + -- Gen2's native constructor accepts a party/index or an isolated mon. + -- The positional shared signature remains valid for existing callers. + if isGen2() and type(mon) == "table" and mon.species == nil then + opts = mon + local save = opts.save or game.save + local party = opts.party or opts.mons or (save and save.party) or {} + if opts.mon and not (opts.party or opts.mons) then party = { opts.mon } end + local index = math.max(1, math.min(opts.index or 1, math.max(1, #party))) + mon = party[index] + opts = setmetatable({ mons = party, index = index }, { __index = opts }) + elseif type(mon) == "table" and mon.species == nil and type(mon.mon) == "table" then + opts, mon = mon, mon.mon + end + opts = opts or {} + assert(type(mon) == "table" and mon.species, "summary requires an actual Pokemon") + local party = opts.mons or opts.party or { mon } + local index = opts.index + if not index then + for i, candidate in ipairs(party) do if candidate == mon then index = i; break end end + end + index = index or 1 + local self = setmetatable({ + game = game, + mon = mon, + page = isGen2() and math.max(1, math.min(3, opts.page or 1)) or 1, + mons = party, + party = party, + monIndex = index, + index = index, + save = opts.save or game.save, + pokemon = opts.pokemon or game.data.pokemon, + moves = opts.moves or game.data.moves, + items = opts.items or game.data.items, + onMonChange = opts.onMonChange, + onClose = opts.onClose or opts.onCancel, + moveScreen = isGen2() and opts.moveScreen == true, + moveDetail = isGen2() and opts.moveScreen == true, + moveIndex = 1, + }, SummaryMenu) + return loadMon(self) +end + local function sourceIndex(self) local mons = self.mons if type(mons) ~= "table" then return nil end @@ -208,17 +223,11 @@ function SummaryMenu:moveMon(delta) local nextIndex = index + delta if nextIndex < 1 or nextIndex > #self.mons then return false end - local page, screenId = self.page, self.screenId - local fresh = SummaryMenu.new(self.game, self.mons[nextIndex], { - mons = self.mons, - index = nextIndex, - onMonChange = self.onMonChange, - }) - fresh.page = page - fresh.screenId = screenId - - for key in pairs(self) do self[key] = nil end - for key, value in pairs(fresh) do self[key] = value end + -- Refresh native Pokemon resources in place. Presentation methods and + -- callback ownership belong to this instance and must survive navigation. + self.mon, self.monIndex, self.index = self.mons[nextIndex], nextIndex, nextIndex + self.moveIndex = 1 + loadMon(self) if self.onMonChange then self.onMonChange(nextIndex, self.mon) end return true @@ -226,10 +235,12 @@ end function SummaryMenu:update(dt) local input = self.game.input + if isGen2() and self:tickRepeatSfx() then return end + if isGen2() and self.moveDetail then self:updateMoveDetail(input); return end if self.picAnim then self.picAnim:update(dt) end if self.isEgg then if input:wasPressed("a") or input:wasPressed("b") then - self.game.stack:pop() + self:close() elseif isGen2() and input:wasPressed("up") then self:moveMon(-1) elseif isGen2() and input:wasPressed("down") then @@ -241,15 +252,19 @@ function SummaryMenu:update(dt) -- StatsScreen_JoypadAction: LEFT/RIGHT wrap through the three pages, A -- steps forward, B leaves. if input:wasPressed("b") then - self.game.stack:pop() - elseif input:wasPressed("right") or input:wasPressed("a") then - self.page = self.page % 3 + 1 + self:close() + elseif input:wasPressed("a") then + if self.page == 3 then self:close() else self:turnPage(1) end + elseif input:wasPressed("right") then + self:turnPage(1) elseif input:wasPressed("left") then - self.page = (self.page + 1) % 3 + 1 + self:turnPage(-1) elseif input:wasPressed("up") then self:moveMon(-1) elseif input:wasPressed("down") then self:moveMon(1) + elseif input:wasPressed("select") and self.page == 2 then + self.moveDetail, self.moveIndex = true, 1 end return end @@ -524,6 +539,7 @@ function SummaryMenu:drawGen2BluePage() end function SummaryMenu:drawGen2() + if self.moveDetail then return self:drawMoveDetail() end -- The stats pages are white on hardware: _CGB_StatsScreenHPPals wipes the -- attrmap to the HP palette and only the three page squares get a colour. -- There is no page title either -- the squares are the whole indicator. @@ -671,4 +687,6 @@ function SummaryMenu:draw() love.graphics.setColor(1, 1, 1, 1) end +require("src.ui.Gen2SummaryView").install(SummaryMenu) + return SummaryMenu diff --git a/src/world/Gen2Decorations.lua b/src/world/Gen2Decorations.lua new file mode 100644 index 000000000..3f87b4942 --- /dev/null +++ b/src/world/Gen2Decorations.lua @@ -0,0 +1,221 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native ownership flags, placed bytes and callback presentation are distinct. +-- Attribute/name rows come from the current cartridge, never a retail table. +local V=require('src.core.GameVersion') +local Strings=require('src.core.Strings') +local M={} +M.slots={'bed','carpet','plant','poster','console','bigDoll','leftOrnament','rightOrnament'} +M.objectSlots={ + {slot='console',sprite=0,flag=1857},{slot='leftOrnament',sprite=1,flag=1858}, + {slot='rightOrnament',sprite=2,flag=1859},{slot='bigDoll',sprite=3,flag=1860}, +} +local groups={ + {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, + {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, +} +function M.byte(id) + local n=(type(id)=='number'or type(id)=='string')and tonumber(id) + return n and n%1==0 and n>=0 and n<=255 and n or nil +end +local kinds={'BED','CARPET','PLANT','POSTER','CONSOLE','BIG_DOLL','DOLL'} +local slotFor={BED='bed',CARPET='carpet',PLANT='plant',POSTER='poster',CONSOLE='console',BIG_DOLL='bigDoll'} +local actions={} +for _,kind in ipairs(kinds)do + actions[#actions+1]='SET_UP_'..kind;actions[#actions+1]='PUT_AWAY_'..kind +end +function M.actionName(n)return actions[n]end +function M.action(name) + if type(name)~='string'then return nil end + local kind=name:match('^SET_UP_(.+)$');local put=false + if not kind then kind=name:match('^PUT_AWAY_(.+)$');put=true end + if kind=='DOLL'then return{ornament=true,put=put}end + return slotFor[kind] and {slot=slotFor[kind],put=put}or nil +end +function M.pool(data)return data and data.map_scripts end +function M.ready(data) + local p=M.pool(data);local info=p and p.decorationInfo + return V.generation(V.get())==2 and type(p)=='table' and type(p.decorations)=='table' and + type(info)=='table' and info.complete==true or false +end +function M.attributes(data,id) + if not M.ready(data)then return nil end + id=M.byte(id);if not id then return nil end + local rows=M.pool(data).decorations + return rows[id]or rows[tostring(id)] +end +function M.state(data,save) + if not M.ready(data)or type(save)~='table'then return nil end + if save.decorations==nil then + local info=M.pool(data).decorationInfo + if not info.defaultsComplete then return nil end + save.decorations=require('src.mods.Merge').deepCopy(info.defaults or{}) + end + return type(save.decorations)=='table'and save.decorations or nil +end +local names={ + [2]=Strings.source('%s BED'),[3]=Strings.source('%s CARPET'), + [4]=Strings.source('%s POSTER'),[5]=Strings.source('%s DOLL'),[6]=Strings.source('BIG %s'), +} +function M.name(data,id,monName) + id=M.byte(id) + local row=M.attributes(data,id) + if not row then return ''end + local name=row.name + if row.type>=4 then + if monName then name=monName(name)or name + else + local info=M.pool(data).decorationInfo + local key=info.sourceNames and info.sourceNames[id]==name and info.species and info.species[id]or name + local mon=data.pokemon and data.pokemon[key] + name=mon and mon.name or name + end + end + return names[row.type] and Strings(names[row.type],name)or name +end +function M.owns(data,save,id) + id=M.byte(id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + return type(save)=='table' and type(save.flags)=='table' and id~=0 and action and not action.put and require('src.script.Flags').get(save, + require('src.script.Gen2Flags').eventFlag(row.flag))or false +end +function M.give(data,save,id) + id=M.byte(id) + local row=M.attributes(data,id) + if type(save)~='table' or id==0 or not row or not M.action(row.action)or M.action(row.action).put then return false end + save.flags=save.flags or{} + require('src.script.Flags').set(save,require('src.script.Gen2Flags').eventFlag(row.flag));return true +end +function M.giveFlag(data,save,n) + local info=M.pool(data)and M.pool(data).decorationInfo + local id=info and info.gifts and info.gifts[(tonumber(n)or -1)+1] + return id~=nil and M.give(data,save,id)or false +end +function M.choose(data,save,id,side) + id=M.byte(id) + local state=M.state(data,save);local row=M.attributes(data,id) + local action=row and M.action(row.action) + if id==0 or not(state and action)then return false end + if not action.put and not M.owns(data,save,id)then return false end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return false end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local value=action.put and 0 or id + local changed=(state[slot]or 0)~=value + state[slot]=value + if changed and action.ornament and not action.put then + local other=side=='left'and 'rightOrnament'or 'leftOrnament' + if state[other]==id then state[other]=0 end + end + return changed +end +-- Public menu metadata is derived from the current native rows, including +-- registry additions/removals. No independent retail catalogue is seeded. +function M.categories(data) + if not M.ready(data)then return{}end + local indexed={} + for raw in pairs(M.pool(data).decorations)do + local id=M.byte(raw);if id and id~=0 then indexed[id]=true end + end + local out={} + for _,group in ipairs(groups)do + local category={label=group[1],slot=group[2],members={},headers={}} + for id in pairs(indexed)do + local row=M.attributes(data,id);local action=row and M.action(row.action) + if action and(action.slot or 'ornament')==category.slot then + local list=action.put and category.headers or category.members;list[#list+1]=id + end + end + table.sort(category.members);table.sort(category.headers) + category.id=category.headers[1];out[#out+1]=category + end + return out +end +function M.placement(data,state,id,side,monName) + id=M.byte(id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + if type(state)~='table'or not action or id==0 then return nil end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return nil end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local old=M.byte(state[slot])or 0 + local value=action.put and 0 or id + local role=action.put and(old==0 and 'empty'or 'put')or + old==id and 'same'or old==0 and 'setup'or 'replace' + return{slot=slot,value=value,changed=old~=value,role=role, + buffers={[3]=M.name(data,role=='setup'and id or old,monName),[4]=M.name(data,id,monName)}} +end +function M.feedbackPages(data,placement) + if not placement then return{}end + local programs=M.pool(data)and M.pool(data).decorationPrograms + local id=programs and programs.texts and programs.texts[placement.role] + if not id then return{}end + local private=data._portableScriptText and data._portableScriptText[id] + local text=private and private.value or not private and data.text and data.text[id] + if type(text)~='string'then return{}end + text=text:gsub('{RAM:wStringBuffer([34])}',function(n)return placement.buffers[tonumber(n)]end) + text=text:gsub('{DONE}',''):gsub('{PROMPT}',''):gsub('\v','\n') + local pages={} + for page in(text..'\f'):gmatch('(.-)\f')do if page~=''then pages[#pages+1]=page end end + return pages +end +function M.layoutVisibility(data,state) + local out={} + for _,binding in ipairs(M.objectSlots)do + local id=type(state)=='table'and M.byte(state[binding.slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + out[#out+1]={sprite=binding.sprite,flag=binding.flag,hidden=not row,byte=row and row.sprite or nil} + end + return out +end +function M.layoutTiles(data,state) + local out={} + local function paint(slot,x,y) + local id=type(state)=='table'and M.byte(state[slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + if row then out[#out+1]={x=x,y=y,block=row.sprite};return row.sprite end + end + paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) + local carpet=paint('carpet',0,0) + if carpet then + out[#out+1]={x=0,y=1,block=(carpet+1)%256} + out[#out+1]={x=1,y=1,block=(carpet+2)%256} + out[#out+1]={x=2,y=1,block=(carpet+1)%256} + end + return out +end +function M.posterVisible(state)return((state and state.poster)or 0)~=0 end +-- These callbacks deliberately preserve the VM's current script/check vars. +function M.visibility(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') + for _,row in ipairs(M.layoutVisibility(data,state))do + if not row.hidden then + C.g2_variablesprite(ctx,row.sprite,row.byte);C.clear_flag(ctx,Flags.eventFlag(row.flag)) + else C.set_flag(ctx,Flags.eventFlag(row.flag))end + end +end +function M.tiles(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands') + for _,row in ipairs(M.layoutTiles(data,state))do C.replace_block(ctx,row.x,row.y,row.block)end + local flag=require('src.script.Gen2Flags').eventFlag(716) + if M.posterVisible(state)then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end +end +function M.description(data,save,kind) + local state=M.state(data,save);local programs=M.pool(data)and M.pool(data).decorationPrograms + if not state or type(programs)~='table'then return nil end + if kind==0 then return programs.posters and programs.posters[state.poster or 0]or programs.null end + if kind==3 then return programs.big end + local slot=({[1]='leftOrnament',[2]='rightOrnament',[4]='console'})[kind] + if slot then return programs.ornament,M.name(data,state[slot]or 0)end +end +return M diff --git a/src/world/Gen2MomShopping.lua b/src/world/Gen2MomShopping.lua new file mode 100644 index 000000000..ab075abdf --- /dev/null +++ b/src/world/Gen2MomShopping.lua @@ -0,0 +1,139 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native accounts, ROM shopping rows and original phone programs share one save. +local Version=require('src.core.GameVersion') +local M={MAX_MONEY=999999} +local function integer(value,lo,hi) + return type(value)=='number'and value%1==0 and value>=lo and value<=hi and value or nil +end +local function money(value)return integer(value,0,M.MAX_MONEY)or 0 end +function M.stock() + local v=Version.get();return v=='gold'or v=='silver'or v=='crystal' +end +function M.mode(save) + local mode=integer(save.g2MomSavingMode,0,3) + local flag=save.flags and save.flags.ENGINE_MOM_SAVING_MONEY + if mode==nil then mode=flag~=nil and(flag and 1 or 0)or(save.g2MomSaving and 1 or 0) + elseif flag~=nil then mode=mode-mode%2+(flag and 1 or 0)end + return mode +end +function M.prepareBank(save) + if not M.stock()then return end + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + local active=save.flags.ENGINE_MOM_ACTIVE + if active==nil and save.g2MomBankOpen==true then Flags.set(save,'ENGINE_MOM_ACTIVE') + elseif active==true then save.g2MomBankOpen=true + elseif active==false then save.g2MomBankOpen=nil end + if save.flags.ENGINE_MOM_SAVING_MONEY==nil and(save.g2MomSaving~=nil or save.g2MomSavingMode~=nil)then + if M.mode(save)%2==1 then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end + end + save.g2MomSaving=M.mode(save)~=0 +end +function M.setSaving(save,on) + save.g2MomSaving=on==true + if not M.stock()then return end + save.g2MomSavingMode=on and 1 or 0 + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + if on then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end +end +function M.openBank(save) + save.g2MomBankOpen=true + if M.stock()then + save.flags=save.flags or{};require('src.script.Flags').set(save,'ENGINE_MOM_ACTIVE') + end +end +-- WinTrainerBattle deals four quarters; near-cap overflow is lost in the +-- selected account, not redirected to the other one. +function M.reward(save,total) + if not M.stock()or not integer(total,0,0xffffff*4)then return nil end + M.prepareBank(save) + local quarter=math.floor(total/4) + local mode=money(save.g2MomMoney)0 and type(p.ladder)=='table'and #p.ladder>0 +end +function M.caller(data) + local found + for id,row in pairs(data and data.map_scripts and data.map_scripts.phone or{})do + if integer(id,1,255)and type(row)=='table'and(row.class==nil or row.class==0)and(row.contactNumber or row.trainer)==1 then + if found then return nil end;found=id + end + end + return found +end +function M.state(data,save) + if not M.ready(data)then return nil end + if save.g2MomShopping==nil then save.g2MomShopping={whichItem=0,whichSet=0,triggerBalance=M.pool(data).step}end + local state=save.g2MomShopping + if type(state)~='table'or not integer(state.whichItem,0,255)or not integer(state.triggerBalance,0,M.MAX_MONEY)then return nil end + return state +end +function M.pick(data,save,random) + local state=M.state(data,save);if not state then return nil end + local p=M.pool(data);local saved=money(save.g2MomMoney) + state.whichSet=0 + local row=p.ladder[state.whichItem+1] + if row and saved>=row.trigger then return{row=row,set=2}end + while state.triggerBalancecap then return false end + save.pcItems=pc;pc[id]=held+1;return true +end +function M.tryBuy(data,save,opts) + opts=opts or{} + if not M.ready(data)or opts.phoneService~=true or opts.queued then return nil end + local caller=M.caller(data);if not caller then return nil end + local p=M.pool(data);local VM=require('src.script.Gen2ScriptVM') + local itemScript=p.programs and VM.compile(data,p.programs.item) + local dollScript=p.programs and VM.compile(data,p.programs.doll) + if not(itemScript and dollScript)then return nil end + local choice=M.pick(data,save,opts.random);if not choice then return nil end + if not receive(data,save,choice.row)then return nil end + save.g2MomMoney=math.max(0,money(save.g2MomMoney)-choice.row.cost) + local state=M.state(data,save) + if choice.set==2 then state.whichItem=(state.whichItem+1)%256 end + return{kind=choice.row.kind,item=choice.row.item,cost=choice.row.cost,set=choice.set, + saved=save.g2MomMoney,caller=caller,script=choice.row.kind==2 and dollScript or itemScript} +end +return M diff --git a/src/world/Gen2PhoneService.lua b/src/world/Gen2PhoneService.lua new file mode 100644 index 000000000..bd9d96c66 --- /dev/null +++ b/src/world/Gen2PhoneService.lua @@ -0,0 +1,11 @@ +local PhoneService = {} +function PhoneService.available(def) + if not def then return false end + if def.phoneService~=nil then return def.phoneService==true end + -- New imports retain the complete byte. Older authored datasets may have + -- stored it in mapPalette itself; old ROM caches retain their legacy rule + -- until reimport because their discarded bits cannot be reconstructed. + local raw=tonumber(def.mapPaletteBits) or tonumber(def.mapPalette) or 0 + return math.floor(raw/16)%16==0 +end +return PhoneService diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a17b2dd6e..fb6138bc0 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -28,6 +28,7 @@ local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") local GameVersion = require("src.core.GameVersion") +local Gen2Clock = require("src.core.Gen2Clock") local Tilt = require("src.render.Tilt") local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") @@ -67,6 +68,13 @@ end local OverworldState = { isOpaque = true, isOverworld = true } local Game -- set on enter (avoids circular require at load time) +-- Read the active native bake/Flash fields rather than copying another world. +setmetatable(OverworldState,{__index=function(world,key) + if(key=='daytime'or key=='flashUsed')and GameVersion.isGen2()and world.map then + if key=='daytime'then return PaletteFX.effectiveGen2Tod()end + return Game and Game.save and Game.save.flashLit==true or false + end +end}) -- AN EMPTY PLOT DRAWS NOTHING, asked once per entity per frame. Written -- with `pcall(function() ... require(...) ... end)` this was a closure, a @@ -167,6 +175,8 @@ local function isDarkMap(mapId) -- ReplaceTimeOfDayPals sends down .NeedsFlash. Reading Gen1's pokered -- list here matched nothing, so Dark Cave and the Whirl Islands were lit. local def = Game.data.maps[mapId] + local palette=require('src.render.Gen2PaletteContext').id(def) + if palette~=nil then return palette==4 end local dark = Game.data.field.gen2DarkMaps if (def and dark and dark[def.label]) == true then return true end -- Fallback when gen2DarkMaps was not extracted (stale cache): maps @@ -283,7 +293,7 @@ local function objectTodBit() local ow = Game and Game.overworld local bit = ow and tonumber(ow.objectTodBit) if bit then return bit end - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) if period and tonumber(period.bit) then return tonumber(period.bit) end return TOD_BITS[objectTimeOfDay()] or TOD_BITS.DAY end @@ -1226,7 +1236,8 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- and the Ruins of Alph chambers taking the NITE row after 6pm. if GameVersion.isGen2() then local paletteDef = Game.data.maps[mapId] - if PaletteFX.setGen2MapPalette(paletteDef and paletteDef.mapPalette or 0) + PaletteFX.setGen2FlashUsed(Game.save.flashLit == true) + if PaletteFX.setGen2MapPalette(require('src.render.Gen2PaletteContext').id(paletteDef) or 0,paletteDef) and PaletteFX.usesGen2BgPal() then MapLoader.invalidateAll() end @@ -2391,7 +2402,7 @@ local function sameTod(tod) return tod end -- < 4 NITE, < 10 MORN, < 18 DAY, else NITE. Gen 1 has no clock at all, so -- only Gen 2 consults the host one. local function clockTimeOfDay() - local hour = tonumber(os.date("%H")) or 12 + local hour = Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) if hour < 4 then return "NITE" end if hour < 10 then return "MORNING" end if hour < 18 then return "DAY" end @@ -2404,7 +2415,7 @@ OverworldState.clockTimeOfDay = clockTimeOfDay -- The cartridge's MAPOBJECT_TIMEOFDAY bit for the hour on the clock right now. function OverworldState.clockTimeOfDayBit() - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) return (period and tonumber(period.bit)) or TOD_BITS.DAY end @@ -2418,6 +2429,9 @@ end -- modes that do not use the ROM's palettes at all. function OverworldState:syncGen2Tod(tod) local PaletteFX = require("src.render.PaletteFX") + if GameVersion.isGen2() then + PaletteFX.bindGen2Context(self.map and self.map.def,Game and Game.save and Game.save.flashLit == true) + end if not PaletteFX.setGen2Tod(tod) then return end if not (GameVersion.isGen2() and PaletteFX.usesGen2BgPal()) then return end pcall(function() @@ -2427,6 +2441,7 @@ function OverworldState:syncGen2Tod(tod) end function OverworldState:timeOfDay() + if GameVersion.isGen2() then Gen2Clock.weekday(Game and Game.save) end local tod = GameVersion.isGen2() and clockTimeOfDay() or (self.tod or "DAY") if not Runtime.wantsHook("world.tod") then if tod ~= self.tod then @@ -2463,9 +2478,17 @@ function OverworldState:timeOfDay() return self.tod end +-- Shared native saved RTC views; no second clock state on World. +function OverworldState.hour() return Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.minute() return Gen2Clock.minute(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.weekday() return Gen2Clock.weekday(GameVersion.isGen2() and Game and Game.save or nil) end + function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") - local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset) + -- Gen2's PALETTE_* identity is not a Gen1/SGB colour-pack name. + local authored = map.def.palette + if GameVersion.isGen2() and type(authored)=='string'and authored:match('^PALETTE_')then authored=nil end + local name = authored or paletteLookup(palettes, map.id, map.def.tileset) if not name then -- Interiors inherit the outdoor map they sit in. Before the player has -- been outdoors at all, that is wLastMap's zero-fill -- map 0, @@ -2593,6 +2616,7 @@ end function OverworldState:setDark(on) on = on and true or false self.dark = on + if GameVersion.isGen2() then PaletteFX.setGen2FlashUsed(Game and Game.save and Game.save.flashLit == true) end if PaletteFX.setDarkWorld(on) and PaletteFX.bakesDarkness() and self.map then MapLoader.invalidateAll() self:reloadMap(self.map.id, "dark") @@ -2981,9 +3005,18 @@ end local STUCK_WARN_FRAMES = 360 -- six seconds at sixty local STUCK_GIVE_UP_FRAMES = 720 +local function foregroundOwnsField(world) + local stack = Game and Game.stack + local top = stack and stack.top and stack:top() + return top ~= nil and top ~= world +end + function OverworldState:watchStuckScript() local runner = self.runner - if not runner:isRunning() or self.transitioning + -- PicBox and FadeOverlay deliberately pump the field while they own the + -- foreground. Waiting for their callback is legitimate even though update + -- reaches this watchdog; a player may inspect a starter for any duration. + if foregroundOwnsField(self) or not runner:isRunning() or self.transitioning or runner.waitingFrames or runner.waitingCheck or #self.scriptMoves > 0 or (self.player and self.player.moving) @@ -3426,6 +3459,33 @@ function OverworldState:checkSpecialPhoneCall() onDone = function() Game.save.g2CurCaller = nil end }) end +-- A purchase is already committed at trainer reload. Its original phone +-- program waits for the trainer's queued dialogue and overlays to finish. +function OverworldState:checkMomShoppingCall() + local pending=self.g2MomCallPending + if not pending then return false end + if pending.save~=Game.save or pending.data~=Game.data then self.g2MomCallPending=nil;return false end + local top=Game.stack and Game.stack.top and Game.stack:top() + if top and top~=self then return false end + if self.player and(self.player.moving or self.player.targetX or self.player.stairExit or self.player.inputLocked)then return false end + if self.transitioning or self.runner:isRunning()or #(self.scriptMoves or{})>0 or self.engaging or self.emote or + self.teleportOut or self.whirlSpin or self.gen4Waterfall or self.flyAnim or self.fishing or + self.pendingScripts and self.pendingScripts[1]then return false end + self.g2MomCallPending=nil + local purchase=pending.purchase + Game.save.g2CurCaller=purchase.caller + pcall(function()require("src.core.Sound").play(Game.data,"Call")end) + self:queueScript(purchase.script,{phoneCaller=purchase.caller,onDone=function() + if Game.save~=pending.save or Game.data~=pending.data then return end + Game.save.g2CurCaller=nil + require("src.script.Gen2Commands").resetCallDelay(Game.save,math.floor(require("src.core.SaveData").playSeconds(Game.save)/60)) + pcall(function()require("src.core.Sound").play(Game.data,"Hang_Up")end) + end}) + local commands=require("src.script.Gen2Commands") + if commands.announceIncomingCall then commands.announceIncomingCall(Game.data,purchase.caller)end + return true +end + -- CheckPhoneCall (36:$4074): the random incoming call, rolled on every step -- the player finishes off a warp tile. Gen2Commands.rollIncomingCall carries -- the ROM's own gating -- the receive-call delay, the one-in-two coin flip, the @@ -3793,6 +3853,7 @@ function OverworldState:update(dt) -- not an event, and the check is two flag reads. self:applyForcedBike() self:checkSpecialPhoneCall() + self:checkMomShoppingCall() self:checkBugContestClock() -- world.tick: the per-frame seam for mods that simulate something in the -- overworld rather than draw it. @@ -4230,7 +4291,7 @@ function OverworldState:update(dt) -- it, name the condition that is holding it, once. Ten seconds is far -- past any real cutscene beat in either cartridge and still short enough -- that the line is in the log next to whatever the player last did. - self.lockedFrames = (self.lockedFrames or 0) + 1 + self.lockedFrames = foregroundOwnsField(self) and 0 or (self.lockedFrames or 0) + 1 if self.lockedFrames == 600 then local why = {} if self.runner:isRunning() then @@ -11484,6 +11545,12 @@ function OverworldState:openPC(onDone) if GameVersion.isGen3() then return self:openGen3PC(onDone) end + if GameVersion.isGen2() then + return Screens.push(Game, "Gen2CenterPcMenu", {onClose=function() + Game.stack:pop() + if onDone then onDone() end + end}) + end require("src.core.Sound").play(Game.data, "Turn_On_PC") local Menu = require("src.ui.Menu") local done = onDone or function() end @@ -14827,6 +14894,19 @@ function OverworldState:gen4FieldResumed() end function OverworldState:afterBattle(result, battle) + if result=="win" and battle and battle.kind=="trainer" and not battle.linkBattle and not battle.battleTower and + not battle.g2MomHandled and require("src.world.Gen2MomShopping").stock()then + battle.g2MomHandled=true + if not self.g2MomCallPending then + local purchase=require("src.world.Gen2MomShopping").tryBuy(Game.data,Game.save,{ + phoneService=require("src.world.Gen2PhoneService").available(self.map and self.map.def), + queued=self.pendingScripts and self.pendingScripts[1]~=nil, + }) + if purchase then self.g2MomCallPending={save=Game.save,data=Game.data,purchase=purchase}end + end + end + + self.gen4EncounterAttempts = 0 -- a battle a script did not start (a wild one, a trainer's sight) comes -- back to the field here; a scripted one already did in its onFinish diff --git a/tests/engine/event_subscription_order.lua b/tests/engine/event_subscription_order.lua new file mode 100644 index 000000000..089971b1e --- /dev/null +++ b/tests/engine/event_subscription_order.lua @@ -0,0 +1,34 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Equal-priority UI decorators must not change order when peers subscribe. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Events=require('src.mods.Events') +for count=2,40 do + local bus=Events.new();local seen={} + for n=1,count do + local id=n + bus:on('screen',function()seen[#seen+1]=id end,0,'owner'..n) + end + bus:on('screen',function()seen[#seen+1]='high'end,10,'high') + bus:on('screen',function()seen[#seen+1]='low'end,-10,'low') + bus:emit('screen',{}) + T.eq(seen[1],'high','explicit higher priority remains first') + for n=1,count do T.eq(seen[n+1],n,'same-priority callbacks retain subscription order with '..count..' peers') end + T.eq(seen[#seen],'low','explicit lower priority remains last') +end +local bus=Events.new();local state={};local seen={};local retire +bus:on('screen',function(s)s.panel='generic';seen[#seen+1]='first'end) +retire=bus:on('screen',function(s)s.panel='specific';seen[#seen+1]='second'end) +bus:once('screen',function() + seen[#seen+1]='once';retire() + bus:on('screen',function()seen[#seen+1]='late'end) +end) +bus:emit('screen',state) +T.eq(state.panel,'specific','the later specific decorator settles after the earlier generic decorator') +T.eq(table.concat(seen,','),'first,second,once','dispatch retains its snapshot during retirement and subscription') +seen={};bus:emit('screen',state) +T.eq(table.concat(seen,','),'first,late','new callbacks participate in the following emission only') +bus:removeOwner('missing');seen={};bus:emit('screen',state) +T.eq(table.concat(seen,','),'first,late','owner removal does not reorder retained callbacks') +T.finish('stable event subscription order') diff --git a/tests/engine/gen2_battle_live_view.lua b/tests/engine/gen2_battle_live_view.lua new file mode 100644 index 000000000..1f6d25801 --- /dev/null +++ b/tests/engine/gen2_battle_live_view.lua @@ -0,0 +1,138 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local V = require('src.core.GameVersion') +local Battle = require('src.battle.BattleState') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local before = V.get() +local function fixture(version) + V.set(version) + local data = T.fixtures.fresh(); data.constants.gen = V.generation(version) + require('src.render.Font').load(data) + local save = Save.newGame() + save.party = {Pokemon.new(data, 'FIXMON_A', 30), Pokemon.new(data, 'FIXMON_B', 18)} + local game = {data=data, save=save, stack={top=function()end}} + return Battle.newWild(game, 'FIXMON_C', 23), game +end +for _, version in ipairs({'gold','silver','crystal','prism','polishedcrystal'}) do + local b, game = fixture(version) + local model, hp = rawget(b,'battle'), b.shownHp + T.check(model ~= b, version .. ' publishes a distinct native model view') + T.eq(model.player, game.save.party[1], 'model player is the actual saved Pokemon') + T.eq(model.enemy, b.enemy.mon, 'model enemy is actual generated wild Pokemon') + T.eq(model.enemy.level, 23, 'enemy level is live, not a wrapper default') + T.eq(model.player.level, 30, 'player level is live') + T.eq(model.party, game.save.party, 'no copied party') + T.eq(model.save, game.save, 'actual save owner') + T.eq(model.data, game.data, 'actual data owner') + T.eq(model.wild, true, 'native wild classification') + T.eq(model.playerIndex, 1, 'actual active party index') + local owner = {} + local attached = pcall(function() model._presentationOwner = owner end) + T.check(attached, 'native model admits consumer-owned transient presentation state') + T.eq(model._presentationOwner, owner, 'presentation owner retains identity') + local released = pcall(function() model._presentationOwner = nil end) + T.check(released, 'presentation can release its own owner') + T.eq(model._presentationOwner, nil, 'released metadata does not survive') + T.eq(game.save._presentationOwner, nil, 'presentation metadata never enters the save') + T.eq(b._presentationOwner, nil, 'presentation metadata does not change native command state') + T.raises(function() model.over = true end, 'read-only', 'nil and false native readers stay owned') + T.raises(function() model.moveDef = function()end end, 'read-only', 'extensions cannot replace the catalog authority') + T.eq(b:activeMon('enemy'), model.enemy, 'presentation follows current native slot') + T.eq(b:activeMon('unknown'), nil, 'unknown side is not another active mon') + T.eq(hp.enemy, b.enemy.shownHP, 'shown HP reads native drain') + T.eq(type(model.moveDef),'function',version .. ' exposes the native model definition reader') + if type(model.moveDef)=='function' then + local move=b.player.curMoves[1] + local def=b:moveDef(move) + local reader=model.moveDef + T.eq(model:moveDef(move.id),def,'model ID lookup is the actual native move catalog row') + T.eq(model:moveDef(move),def,'native instance lookup shares the same authority') + T.eq(model.moveDef,reader,'drawing does not allocate a new reader each access') + T.eq(model:moveDef('UNKNOWN_NATIVE_MOVE'),nil,'unknown move has no invented presentation record') + T.eq(model:moveDef(nil),nil,'missing move has no invented presentation record') + T.check(type(def.pp)=='number' and type(def.type)=='string','cards receive actual maximum PP and type') + local pp=move.pp + local replacement={name='LIVE CATALOG',pp=17,type='WATER'} + game.data.moves[move.id]=replacement + T.eq(model:moveDef(move.id),replacement,'catalog updates are live rather than cached at battle entry') + T.eq(move.pp,pp,'metadata reads cannot spend native PP') + game.data.moves[move.id]=def + end + local initial = b.enemy.mon.hp + b:performMove(b.player, b.enemy, {id='FIX_TACKLE',pp=10}) + T.check(b.enemy.mon.hp < initial, 'actual native move reduces HP') + T.eq(model.enemy.hp, b.enemy.mon.hp, 'model exposes resolved HP') + T.eq(hp.enemy, initial, 'presentation retains pre-drain HP') + local frames = 0 + while b:stepHPDrain() and frames < 2000 do + frames=frames+1 + T.eq(hp.enemy, b.enemy.shownHP, 'every displayed HP step is native owned') + end + T.check(frames > 0 and frames < 2000, 'actual drain completes') + T.eq(hp.enemy, model.enemy.hp, 'shown HP reaches actual HP') + local old = b.player + b:switchPlayerInto(Battle.POS.PLAYER_LEFT, game.save.party[2]) + T.eq(b.battle, model, 'model identity survives actual native switch') + T.eq(b.shownHp, hp, 'HP view identity survives switch') + T.eq(model.player, game.save.party[2], 'model follows incoming actual Pokemon') + T.eq(model.playerIndex, 2, 'live party index follows switch') + T.eq(b:activeMon('player'), game.save.party[2], 'drawn mon follows native deployment') + T.eq(hp.player, b.player.shownHP, 'new slot uses its own HP drain') + T.neq(b.player, old, 'wrapper actually replaced') + T.eq(game.save.party[1].shownHP, nil, 'no presentation fields added to saved Pokemon') + T.raises(function() model.player = old.mon end, 'read-only', 'model cannot write a detached Pokemon') + T.raises(function() hp.player = 0 end, 'stepHPDrain', 'view cannot race native HP clock') + b.result='run' + T.eq(model.over, true, 'actual result closes model') + T.eq(model.outcome, 'run', 'actual outcome exposed') +end + +-- The new render boundary must use front art for isolated player captures +-- without changing the native rear, slot identity, or animation ownership. +do + local b = fixture('crystal'); b.introSlide=0; b.sendingOut=false + local original, player = b.player.sprite, b.player + local draws, draw = {}, love.graphics.draw + love.graphics.draw=function(...) draws[#draws+1]={...} end -- output only + b:drawPic(player.mon, false) + T.eq(#draws, 1, 'player front capture draws real native sprite') + local front, _, path = b:pic(player.mon, false) + T.eq(draws[1][1], front, 'front capture uses actual front image') + T.check(path:find('front',1,true) ~= nil, 'front request resolves front asset') + T.eq(b.player, player, 'rendering preserves native battler identity') + T.eq(player.sprite, original, 'native rear image is retained') + T.eq(draws[1][3] + front:getHeight(), 56, 'front feet end at native seven-tile baseline') + draws={}; b:drawPic(player.mon, true) + T.eq(draws[1][1], b:picImage(original), 'native rear remains available') + local pf=b:picFxFor(player);pf.hidden=true + draws={};b:drawPic(player.mon,false) + T.eq(#draws,0,'native vanish effect hides isolated front too') + pf.hidden=nil;b.fx={blink={target=player,frames=10}};b.frame=0 + draws={};b:drawPic(player.mon,false) + T.eq(#draws,0,'native damage blink stays keyed to real player') + b.fx={faint={battler=player,frames=7}};player.fainted=true + draws={};b:drawPic(player.mon,false) + T.eq(#draws,1,'real native faint sink can still draw its remaining rows') + b.fx.faint.frames=0;draws={};b:drawPic(player.mon,false) + T.eq(#draws,0,'completed native faint stays hidden') + player.fainted=nil;b.fx=nil;pf.kind='shakeBF' + local intersect,get=love.graphics.intersectScissor,love.graphics.getScissor + local clips={} + love.graphics.intersectScissor=function(...)clips[#clips+1]={...}end + love.graphics.getScissor=function()end + b:drawPic(player.mon,false) + T.check(#clips>0,'effect capture has an actual bounded picture window') + for _,clip in ipairs(clips)do + T.check(clip[1]>=96,'front player effect is never clipped by native rear-side window') + end + love.graphics.intersectScissor,love.graphics.getScissor=intersect,get + love.graphics.draw=draw +end +for _, version in ipairs({'red','firered','emerald'}) do + local b = fixture(version) + T.eq(b.battle,b,version .. ' retains its existing model identity') + T.eq(b.shownHp,nil,'other generations retain existing HP presentation') +end +V.set(before) +T.finish('native Gen2 live battle model and sprite capture') diff --git a/tests/engine/gen2_battle_pack.lua b/tests/engine/gen2_battle_pack.lua new file mode 100644 index 000000000..bf727a6f9 --- /dev/null +++ b/tests/engine/gen2_battle_pack.lua @@ -0,0 +1,190 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pack=require('src.ui.Gen2PackMenu') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Bag=require('src.inventory.Bag') +local Screens=require('src.ui.Screens') +local oldVersion=V.get() +local Sound=require('src.core.Sound');local soundPlay=Sound.play;local heals=0 +Sound.play=function(data,name,...) + if name=='Heal_HP'or name=='Sfx_Potion'then heals=heals+1 end + return soundPlay(data,name,...) +end + +local function fixture(version, itemId) + V.set(version);Screens.invalidate() + local data=T.fixtures.fresh();data.constants.gen=V.generation() + itemId=itemId or 'POTION' + data.items[itemId]={id=itemId,name=itemId,index=18,pocket='ITEM',battleMenu='ITEMMENU_PARTY'} + require('src.render.Font').load(data) + local save=Save.newGame();save.options.battleAnimations=false + save.party={Pokemon.new(data,'FIXMON_A',30)} + Bag.add(save,itemId,1,data) + local input={pressed={}} + function input:wasPressed(key)return self.pressed[key]==true end + function input:isDown()return false end + local stack={states={}} + function stack:top()return self.states[#self.states]end + function stack:push(s)self.states[#self.states+1]=s end + function stack:pop()return table.remove(self.states)end + local world={useFieldItem=function()error('battle PACK reached field effects')end} + local game={data=data,save=save,input=input,stack=stack,overworld=world,world=world} + local b=Battle.newWild(game,'FIXMON_C',3) + b.phase,b.queue,b.current,b.introSlide,b.sendingOut='menu',{},nil,0,false + b.player.mon.hp=1;b.player.shownHP=1 + b.enemy.mon.moves={{id='FIX_TACKLE',pp=35}};b.enemy.curMoves=b.enemy.mon.moves + stack:push(b) + local function step(key) + input.pressed=key and{[key]=true}or{} + stack:top():update(1/60) + input.pressed={} + end + local function untilTrue(predicate) + for tick=1,8000 do + if predicate()then return true end + local top=stack:top() + local key;if not(top==b and b.phase=='menu')then key='a'end + step(key) + end + return false + end + local function open() + for i,choice in ipairs(b:menuActions())do if choice=='item'then b.menuIndex=i end end -- fixture starts at native ITEM + step('a') + T.check(untilTrue(function()return stack:top()~=b end),'actual ITEM input opens a child screen') + return stack:top() + end + return b,game,step,untilTrue,open +end + +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local b,game,step,wait,open=fixture(version) + local pp,enemyPP=b.player.curMoves[1].pp,b.enemy.curMoves[1].pp + local pack=open() + T.eq(getmetatable(pack),Pack,version..' opens the actual native Gen2 PACK controller') + T.eq(pack.screenId,'Gen2PackMenu','native screen identity matches the Gen2 contract') + if getmetatable(pack)==Pack then + T.eq(pack:inBattle(),true,'native PACK uses battle eligibility') + T.neq(pack.world,game.overworld,'battle PACK cannot invoke the live field world') + T.eq(type(pack.onChoose),'function','native PACK has a native item-selection continuation') + T.eq(pack.rows[1].id,'POTION','rows come from the actual native bag') + local pocket=pack.pocketIndex + step('right');T.neq(pack.pocketIndex,pocket,'actual input switches native pockets') + step('left');T.eq(pack.pocketIndex,pocket,'actual input returns to the item pocket') + step('b') + T.check(wait(function()return game.stack:top()==b and b.phase=='menu'end),'native cancel releases the queued UI owner') + T.eq(game.save.inventory.POTION,1,'cancel keeps inventory') + T.eq(b.player.mon.hp,1,'cancel cannot heal or spend a turn') + T.eq(b.player.curMoves[1].pp,pp,'cancel keeps move PP') + pack=open();step('a') -- native USE row + if game.stack:top()==pack and pack.submenu then step('a')end + T.check(game.stack:top()~=pack,'actual USE opens native target selection') + local picker=game.stack:top() + T.eq(game.stack.states[#game.stack.states-1],b,'target is built directly over the actual native battle') + T.eq(picker.battle,b,'target receives the actual battle context') + T.eq(pack.closed,true,'native PACK has released its owner before target selection') + local pocket=pack.pocketIndex + step('b') + T.check(wait(function()return game.stack:top()~=b end),'target cancellation reopens native PACK') + pack=game.stack:top() + T.eq(getmetatable(pack),Pack,'cancel constructs the actual native PACK again') + T.eq(pack.pocketIndex,pocket,'cancel preserves the native pocket cursor') + T.eq(game.save.inventory.POTION,1,'target cancellation consumes no item') + T.eq(b.player.mon.hp,1,'target cancellation changes no HP') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'target cancellation grants no enemy action') + step('a');if game.stack:top()==pack and pack.submenu then step('a')end + picker=game.stack:top() + local beforeCue=heals + step('a') -- choose the actual first party Pokemon + T.eq(heals,beforeCue+1,'native healing plays its imported cue exactly once') + local resultOwner=game.stack:top() + T.eq(resultOwner,picker,'successful medicine retains the actual native party controller') + T.check(resultOwner.itemResult~=nil,'native party owns the result until confirmation') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'enemy cannot act before native result confirmation') + if resultOwner.itemResult then + local targetHP=b.player.mon.hp + T.eq(resultOwner.itemResult.shown,1,'native HP display starts at pre-effect HP') + T.eq(resultOwner.itemResult.target,targetHP,'native result targets the actual healed HP') + for tick=1,12 do step('a') end + T.eq(game.stack:top(),resultOwner,'early A cannot skip HP fill or native result delay') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'early confirmation cannot spend an extra enemy turn') + T.eq(b.player.mon.hp,targetHP,'presentation never rewrites real HP') + T.check(wait(function()return game.stack:top()==b end),'native acknowledgment releases only the party owner') + T.eq(b.player.shownHP,targetHP,'battle resumes with the HP already displayed by the native party result') + T.eq(b.phase,'resolving','actual native acknowledgment resumes the committed turn queue') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'enemy action starts after actual acknowledgment, not during its callback') + T.eq(b.partyItemHealing,nil,'result acknowledgment releases low-health alarm suppression') + end + T.check(wait(function()return game.stack:top()==b and b.phase=='menu'end),'actual item turn returns to the battle owner') + T.eq(game.save.inventory.POTION,nil,'actual healing consumes exactly the owned potion') + T.check(b.player.mon.hp>1,'actual native item effect heals the chosen party Pokemon') + T.eq(b.player.curMoves[1].pp,pp,'item use does not spend a move PP') + T.eq(b.enemy.curMoves[1].pp,enemyPP-1,'item use gives the native enemy exactly one action') + T.eq(game.stack:top(),b,'item/target/message screens leave no orphaned owner') + end +end +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local b,game,step,wait,open=fixture(version) + b.player.mon.hp=b.player.mon.stats.hp;b.player.shownHP=b.player.mon.hp + local enemyPP=b.enemy.curMoves[1].pp + local pack=open();step('a');if game.stack:top()==pack and pack.submenu then step('a')end + step('a') + T.eq(game.stack:top(),b,version..' no-effect medicine uses the actual battle text owner') + T.check(wait(function()return game.stack:top()==b and b.phase=='menu'end),'no-effect medicine returns to native commands') + T.eq(game.save.inventory.POTION,1,'no-effect medicine preserves inventory') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'no-effect medicine grants no enemy action') +end +-- Other native party item families must use the same result owner. Their +-- actual effects, inventory and enemy continuation remain the shared engine's. +for _,id in ipairs({'FULL_HEAL','REVIVE','ETHER','ELIXER','MYSTERYBERRY'})do + local b,game,step,wait,open=fixture('crystal',id) + local target=b.player.mon + target.hp=target.stats.hp;b.player.shownHP=target.hp + if id=='REVIVE'then + target=Pokemon.new(game.data,'FIXMON_B',30);target.hp=0 + game.save.party[2]=target + elseif id=='FULL_HEAL'then target.status='PSN' + else target.moves[1].pp=1 end + local enemyPP=b.enemy.curMoves[1].pp + local pack=open();step('a');if game.stack:top()==pack and pack.submenu then step('a')end + local picker=game.stack:top() + if id=='REVIVE'then step('down')end + step('a') + if id=='ETHER'or id=='MYSTERYBERRY'then + T.neq(game.stack:top(),picker,'ETHER opens its real move selector above the same party') + T.eq(game.stack:top().screenId,'Gen2MoveDeleter','native ETHER uses the cartridge move-list controller') + step('b');T.eq(game.stack:top(),picker,'PP move cancel returns to the retained native party') + T.eq(game.save.inventory[id],1,'PP move cancellation preserves inventory') + T.eq(target.moves[1].pp,1,'PP move cancellation preserves PP') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'PP cancellation spends no enemy action') + step('a');step('a') + end + T.eq(game.stack:top(),picker,id..' keeps the original native result owner') + T.check(picker.itemResult~=nil,id..' exposes actual native result state') + T.eq(picker.party[ picker.index ],target,'result keeps the selected real party Pokemon') + T.eq(game.save.inventory[id],nil,'successful native item consumes one owned copy') + T.eq(b.enemy.curMoves[1].pp,enemyPP,'item effect alone cannot start an enemy turn') + if picker.itemResult then + local hp=target.hp;local pp=target.moves[1].pp + for tick=1,50 do step()end + T.eq(game.stack:top(),picker,'time alone does not confirm the party result') + T.eq(target.hp,hp,'result hold preserves real HP') + T.eq(target.moves[1].pp,pp,'result hold preserves restored PP') + end + T.check(wait(function()return game.stack:top()==b and b.phase=='menu'end),id..' result acknowledgment completes the native item turn') + T.eq(b.enemy.curMoves[1].pp,enemyPP-1,'acknowledgment gives exactly one native enemy action') + T.eq(game.save.inventory[id],nil,'acknowledgment cannot consume a second item') + if id=='FULL_HEAL'then T.eq(target.status,nil,'real status effect remains cured')end + if id=='REVIVE'then T.check(target.hp>0,'actual bench Pokemon remains revived')end +end +for _,version in ipairs({'red','firered','emerald'})do + local b,game,step,wait,open=fixture(version) + local screen=open() + T.neq(getmetatable(screen),Pack,version..' retains its existing bag controller') +end +Sound.play=soundPlay +V.set(oldVersion);Screens.invalidate() +T.finish('native Gen2 battle PACK selection and item turn ownership') diff --git a/tests/engine/gen2_battle_queue_view.lua b/tests/engine/gen2_battle_queue_view.lua new file mode 100644 index 000000000..31a1975cb --- /dev/null +++ b/tests/engine/gen2_battle_queue_view.lua @@ -0,0 +1,147 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native queue transitions and screen fields, without a second controller, +-- a second typewriter or consumer-side phase changes. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Version = require('src.core.GameVersion') +local Battle = require('src.battle.BattleState') +local QueuePhase = require('src.battle.BattleQueuePhase') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local Font = require('src.render.Font') +local before = Version.get() + +local function fixture(version) + Version.set(version) + local data = T.fixtures.fresh(); data.constants.gen = Version.generation() + local run = T.sdk.loadNone({data=data}) + Font.load(data) + local save = Save.newGame(); save.options.battleAnimations = false; save.options.ruleset = "modern_clean" + save.party = {Pokemon.new(data, 'FIXMON_A', 30)} + save.party[1].moves = {{id='FIX_TACKLE',pp=0},{id='FIX_SCRATCH',pp=40}} + local input = {pressed={}} + function input:wasPressed(key) return self.pressed[key] == true end + function input:isDown() return false end + local stack = {states={}} + function stack:top() return self.states[#self.states] end + function stack:push(state) self.states[#self.states+1] = state end + function stack:pop() return table.remove(self.states) end + local game = {data=data,save=save,input=input,stack=stack} + local b = Battle.newWild(game, 'FIXMON_C', 20) + stack:push(b) + b.phase,b.queue,b.current,b.introSlide,b.sendingOut = 'menu',{},nil,0,false + b.menuIndex,b.moveIndex = 1,1 + local function step(key) + input.pressed = key and {[key]=true} or {} + stack:top():update(1/60) + input.pressed = {} + end + local function wait(predicate) + for _=1,6000 do if predicate() then return true end; step() end + return predicate() + end + return b,game,step,wait +end + +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal','red','firered','emerald'}) do + local b,game,step,wait = fixture(version) + local gen2 = Version.isGen2() + local phase = gen2 and 'resolving' or 'messages' + local hp,enemyHp,pp = b.player.mon.hp,b.enemy.mon.hp,b.player.curMoves[2].pp + local turn = b.turnCount + step('a'); step('a') -- actual FIGHT, then the empty-PP learned move + T.eq(b.phase,phase,version .. ' actual native refusal enters the edition queue phase') + T.eq(b.turnCount,turn,'selection refusal spends no turn') + step() + local current = b.current + T.check(current and type(current.text)=='string','actual queue starts its native refusal row') + if current then + if gen2 then + T.eq(b.message,current.text,'public Gen2 message is the actual current source text') + T.eq(b.messageTimer,48,'native message hold is visible while typing') + else + T.eq(rawget(b,'message'),nil,'other editions keep their existing screen fields') + T.eq(rawget(b,'messageTimer'),nil,'Gen2 message hold does not leak to other editions') + end + if gen2 then + -- Earlier queue records can already be mid-type when resumed. + b.phase,b.message,b.messageTimer = 'messages',nil,nil + local chars = b.charIndex + step() + T.eq(b.phase,'resolving','an already active legacy row resumes at its native owner') + T.eq(b.current,current,'resuming does not replace or duplicate the current row') + T.eq(b.message,current.text,'an already active legacy row exposes its actual text') + T.eq(b.messageTimer,48,'resuming restores the same native text hold') + T.check(b.charIndex-chars<=1,'resuming advances only the one native typewriter tick') + end + local index,queue = b.charIndex,#b.queue + b:drawTextArea() + require('src.battle.WideBattle').draw(b) + T.eq(b.charIndex,index,'classic/wide drawing cannot type another glyph') + T.eq(#b.queue,queue,'drawing cannot advance a native queue row') + T.eq(b.current,current,'drawing retains the actual current row') + T.check(wait(function()return b.msgPrompt and (b.msgPromptWait or 0)<=0 end),'native text reaches its own A/B prompt') + for _=1,60 do step() end + T.eq(b.current,current,'message cannot time out without native input') + if gen2 then T.eq(b.messageTimer,48,'public hold does not independently count down') end + step('b') + T.eq(b.current,nil,'actual B retires exactly the native text row') + if gen2 then T.eq(b.messageTimer,0,'the native acknowledgment retires its public hold') end + local decision = b.afterQueue=='moveSelect' and (gen2 and 'moves' or 'moveSelect') or 'menu' + T.check(wait(function()return b.phase==decision end),'refusal returns to its native edition decision') + T.eq(b.message,nil,'no stale source text covers the restored menu') + if gen2 then T.eq(b.messageTimer,0,'restored native menu has no message hold') end + end + T.eq(b.player.mon.hp,hp,'refusal does not mutate own HP') + T.eq(b.enemy.mon.hp,enemyHp,'refusal does not damage the opponent') + T.eq(b.player.curMoves[2].pp,pp,'refusal spends no other move PP') + T.eq(b.turnCount,turn,'reading, drawing and acknowledging spend no battle turn') + + if gen2 then + -- Resume a native queue stored by earlier shared-engine checkpoints. + b.phase,b.afterQueue,b.queue,b.current = 'messages','menu',{},nil + b:say('First native page') + local child + b:ui(function() + child = require('src.ui.ChoiceBox').new(game,function()end) + return child + end) + b:say('Second native page') + step() + T.eq(b.phase,'resolving','legacy message queue resumes on its real Gen2 owner') + T.eq(b.message,'First native page','legacy row publishes the actual source text') + T.check(wait(function()return b.msgPrompt and (b.msgPromptWait or 0)<=0 end),'first queued page finishes typing') + step('a'); step() + T.eq(b.phase,'submenu','actual queued UI retains its native child phase') + T.eq(game.stack:top(),child,'actual queue pushes the same native child') + step('b') + T.check(wait(function()return game.stack:top()==b end),'native choice child releases its parent') + step() + T.eq(b.phase,'resolving','the same queue resumes after the child') + T.eq(b.message,'Second native page','next actual source row replaces the retired text') + T.check(wait(function()return b.msgPrompt and (b.msgPromptWait or 0)<=0 end),'second queued page finishes typing') + step('a'); step() + T.eq(b.phase,'menu','child continuation returns to the actual native menu') + T.eq(b.message,nil,'child continuation leaves no stale message on the menu') + T.eq(b.messageTimer,0,'child continuation releases the public text hold') + + -- Native battle YES/NO uses its real answer delay and queue callback. + local answer + b.phase,b.afterQueue = 'messages','menu' + b:sayChoice('Continue?',function(yes)answer=yes end) + T.check(wait(function()return game.stack:top()~=b end),'actual typed choice opens the native YES/NO owner') + T.eq(b.message,'Continue?','choice keeps its actual text under the child') + step('b') + T.check(wait(function()return game.stack:top()==b end),'actual B answers after the native YES/NO hold') + T.eq(answer,false,'the actual callback receives the chosen native answer') + T.eq(b.messageTimer,0,'choice completion releases the same public text hold') + step() + T.eq(b.phase,'menu','choice returns to the same native decision') + T.eq(b.message,nil,'choice text clears at the decision boundary') + else + b.phase = 'resolving' + T.eq(QueuePhase.active(b),false,'Gen2 resolution spelling cannot admit other editions') + end +end +Version.set(before) +T.finish('native Gen2 queue phase, message ownership and shared regressions') diff --git a/tests/engine/gen2_battle_scene.lua b/tests/engine/gen2_battle_scene.lua new file mode 100644 index 000000000..5cf2d0ae4 --- /dev/null +++ b/tests/engine/gen2_battle_scene.lua @@ -0,0 +1,65 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Wide=require('src.battle.WideBattle') +local Runtime=require('src.mods.Runtime') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local before=Version.get() +local wants,call=Runtime.wantsHook,Runtime.call +local overlays,expected=0 +Runtime.wantsHook=function(name)if name=='battle.overlay'then return true end;return wants(name)end +Runtime.call=function(name,vanilla,screen,...) + if name=='battle.overlay'then overlays=overlays+1;T.eq(screen,expected,'overlay receives actual native screen');return vanilla(screen)end + return call(name,vanilla,screen,...) +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local data=T.fixtures.fresh();require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local s=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + expected=s + local layers={};local scene,body=s.drawScene,s.drawSceneBody + s.drawScene=function(self,...)layers[#layers+1]='scene';return scene(self,...)end + s.drawSceneBody=function(self,...)layers[#layers+1]='body';return body(self,...)end + s.drawPicsLayer=function()layers[#layers+1]='pics'end + s.drawHUDs=function()layers[#layers+1]='hud'end + s.drawTextArea=function()layers[#layers+1]='text'end + s.drawAnimLayer=function()layers[#layers+1]='objects'end + s.colorMode=function()return false end + local count=overlays;s:draw() + T.same(layers,{'scene','body','pics','hud','text','objects'},'actual classic draw reaches native scene/BG/OAM owners once') + T.eq(overlays,count+1,'actual scene emits one final overlay') + layers={};count=overlays + s:drawSceneBody(function()layers[#layers+1]='custom'end) + T.same(layers,{'body','custom','objects'},'replacement panel retains native object pass') + T.eq(overlays,count,'scene body does not emit duplicate final overlay') + layers={};s:drawScene(function()s:drawSceneBody(function()layers[#layers+1]='supplied'end)end) + T.same(layers,{'scene','body','supplied','objects'},'supplied scene body stays inside actual outer owner') + layers={};s.blankForAskName=true;s:drawSceneBody() + T.same(layers,{'body'},'native naming blank suppresses battle pictures and objects') + s.blankForAskName=nil + local g=love.graphics;local sentinel={};g.setShader(sentinel);g.setColor(.2,.3,.4,.5) + T.raises(function()s:drawSceneBody(function()g.setShader();g.setColor(0,0,0,1);error('panel draw failed')end)end,'panel draw failed','native scene propagates panel error') + T.eq(g.getShader(),sentinel,'failed panel restores graphics owner') + T.same({g.getColor()},{.2,.3,.4,.5},'failed panel restores caller tint') + g.setShader();g.setColor(1,1,1,1) + local nativeBody=s.drawSceneBody;local widths={};layers={};count=overlays + s.drawSceneBody=function(self,panel,objects,w,h) + widths={w,h};return nativeBody(self,panel,objects,w,h) + end + Wide.draw(s) + T.same(widths,{304,144},'actual native widescreen enters same composition owner with real surface') + T.eq(layers[1],'scene','wide layout enters native outer scene') + T.eq(layers[2],'body','wide layout enters native BG/OAM scene') + T.eq(overlays,count+1,'wide layout emits one final overlay') + -- Red keeps its existing layout route. Presence of a Gen2 method must not + -- redirect other games simply because they share the native screen class. + Version.set('red');layers={};s:drawClassic() + T.check(layers[1]~='scene'and layers[1]~='body','Gen1 classic route remains native original route') + layers={};Wide.draw(s) + T.check(layers[1]~='scene'and layers[1]~='body','Gen1 wide route remains native original route') +end +Runtime.wantsHook,Runtime.call=wants,call;Version.set(before) +T.finish('native Gen2 classic/wide scene owner, panel replacement and layer lifecycle') diff --git a/tests/engine/gen2_battle_submenu.lua b/tests/engine/gen2_battle_submenu.lua new file mode 100644 index 000000000..a81e80636 --- /dev/null +++ b/tests/engine/gen2_battle_submenu.lua @@ -0,0 +1,44 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +for _,version in ipairs{'gold','silver','crystal','prism','polishedcrystal','red','emerald'}do + V.set(version) + local data=T.fixtures.fresh();data.constants.gen=V.generation() + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',5)} + local stack={states={}} + function stack:top()return self.states[#self.states]end + function stack:push(s)self.states[#self.states+1]=s end + function stack:pop()return table.remove(self.states)end + local game={data=data,save=save,stack=stack,input={wasPressed=function()return false end,isDown=function()return false end}} + local b=Battle.newWild(game,'FIXMON_C',3) + b.phase,b.queue,b.current,b.introSlide,b.sendingOut='messages',{},nil,0,false + b.afterQueue='menu';stack:push(b) + local before,child,nested,actions + actions=0 + b:ui(function() + before=b.phase;child={};return child + end) + b:act(function()actions=actions+1 end) + b:update(1/60) + T.eq(before,V.isGen2()and'submenu'or'messages',version..' constructor sees the native interactive phase') + T.eq(b.phase,before,'the public phase stays stable while the child owns input') + T.eq(stack:top(),child,'the actual queue pushes its child') + T.eq(actions,0,'queue does not run a battle action during construction') + T.eq(b:updateQueue(),true,'queue remains suspended while the child owns the stack') + T.eq(actions,0,'child input cannot spend the continuation') + nested={};stack:push(nested) + T.eq(b:updateQueue(),true,'nested target picker keeps the same native queue suspended') + stack:pop();T.eq(b:updateQueue(),true,'returning from the nested picker does not resume the battle') + stack:pop() + for _=1,5 do b:update(1/60)end + T.eq(actions,1,'the native continuation resumes exactly once after all children close') + T.eq(b.phase,'menu','the native queue returns to the decision menu') + T.eq(b.waitingUI,nil,'no orphaned UI owner survives cancel/selection') + T.eq(stack:top(),b,'the battle remains the real stack owner') +end +V.set(old) +T.finish('native Gen2 interactive submenu queue ownership') diff --git a/tests/engine/gen2_battle_window.lua b/tests/engine/gen2_battle_window.lua new file mode 100644 index 000000000..303ff9863 --- /dev/null +++ b/tests/engine/gen2_battle_window.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local Chrome=require('src.ui.Gen2Chrome') +local UI=require('src.render.ScreenSpaceUI') +local before=V.get() +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal','red','firered','emerald'})do + V.set(version) + local data=T.fixtures.fresh();require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local game={data=data,save=save,stack={states={}}} + local b=Battle.newWild(game,'FIXMON_C',20);game.stack.states={b} + T.eq(type(b.drawWidescreen),'function',version..' actual native battle exposes its window drawer') + T.eq(type(b.drawsWidescreen),'function','window admission is explicit on the actual screen') + if type(b.drawWidescreen)=='function'and type(b.drawsWidescreen)=='function'then + T.eq(b:drawsWidescreen(),V.isGen2(),'only Gen2 opts into native window composition') + T.eq(UI.select(game.stack,game.stack,1),V.isGen2()and b or nil,'actual compositor selection preserves other edition routes') + if V.isGen2()then + local native=b.drawScene;local scenes=0 + b.drawScene=function(self,...)scenes=scenes+1;T.eq(self,b,'window drawing retains the actual battle owner');return native(self,...)end + b.colorMode=function()return false end + local g=love.graphics;local dpi=g.getDPIScale + for _,ratio in ipairs({1,2})do + g.getDPIScale=function()return ratio end + for _,layout in ipairs({'classic','wide'})do + save.options.battleLayout=layout + local pw,ph=b:uiSize() + T.same({b:panelSize()},{pw,ph},'window uses the actual native layout dimensions') + for _,size in ipairs({{1280,960},{900,1500},{640,480}})do + local w,h=size[1],size[2] + save.options.battleFit='fixed' + T.eq(b:battlePanelScale(w,h),Chrome.fitScaleFor(w,h,pw/8,ph/8),'fixed native scale obeys physical pixel grid and layout') + local count=scenes;b:drawWidescreen(w,h) + T.eq(scenes,count+1,'actual classic/wide window enters the native scene exactly once') + save.options.battleFit='fill' + local _,_,fw,fh=Chrome.playfieldRect(w,h) + T.eq(b:battlePanelScale(w,h),math.min(fw/pw,fh/ph),'existing fill option fits the real panel') + count=scenes;b:drawWidescreen(w,h) + T.eq(scenes,count+1,'fill keeps the same native scene owner') + end + end + end + g.getDPIScale=dpi + save.options.battleLayout='classic';save.options.battleFit='fixed' + local shader={};local canvas=g.newCanvas(17,19);local other=g.newCanvas(11,13) + g.setCanvas(canvas);g.setShader(shader);g.setColor(.2,.3,.4,.5) + b.drawScene=function()g.push('all');g.setCanvas(other);g.setShader();g.setColor(0,0,0,1);error('native window scene failure')end + T.raises(function()b:drawWidescreen(1280,960)end,'native window scene failure','real native window propagates a failed scene') + T.eq(g.getCanvas(),canvas,'failed scene releases its canvas and caller owner') + T.eq(g.getShader(),shader,'failed scene restores caller shader') + T.same({g.getColor()},{.2,.3,.4,.5},'failed scene restores caller tint despite a leaked push') + g.setCanvas();g.setShader();g.setColor(1,1,1,1) + end + end +end +V.set(before) +T.finish('native Gen2 battle window, physical scale, scene owner and error boundary') diff --git a/tests/engine/gen2_clock.lua b/tests/engine/gen2_clock.lua new file mode 100644 index 000000000..ecfe737fe --- /dev/null +++ b/tests/engine/gen2_clock.lua @@ -0,0 +1,120 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local V = require('src.core.GameVersion');local version = V.get() +require('src.core.ModCompat').install() +local Clock = require('src.core.gen2.Clock') +local P = require('src.render.PaletteFX');local W = require('src.world.OverworldController') +require('src.script.Gen2Commands');local Commands = require('src.script.Commands');local Loader = require('src.mods.Loader') +local Json = require('src.link.Json');local Save = require('src.core.SaveData') +local date,time = os.date,os.time;local elapsed=0;local baseTime=time() +os.time=function(value)if value then return time(value)end;return baseTime+elapsed end +local host = { hour=13, minute=20, day=2 } +os.date = function(format, now) + if now and now~=os.time() then return date(format,now) end + if format=='%H' then return tostring(host.hour) end + if format=='%M' then return tostring(host.minute) end + if format=='%w' then return tostring(host.day) end + return date(format,now) +end +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.timeOfDay,i) + if name=='Game' then slot=i;oldGame=value;break end + if not name then break end +end +assert(slot) +local nativeGame=require('src.core.Game');local oldSave=nativeGame.save +T.eq(Clock,require('src.core.Gen2Clock'),'public name shares actual native clock') +T.eq(Clock.minutes(),800,'no save reads host RTC') +T.eq(Clock.isSet({}),false,'old save has no fabricated clock anchor') +local malformed={rtc='invalid'} +T.eq(Clock.hour(malformed),13,'malformed optional clock falls back to host') +T.eq(Clock.setTime(malformed,0/0,10),false,'NaN cannot poison saved clock') +T.eq(malformed.rtc,'invalid','refused write is atomic') +T.eq(Clock.setTime(false,10,0),false,'no saved state means no write') +T.eq(Clock.setWeekday({},math.huge),false,'infinite weekday rejected') +T.eq(Clock.weekdayName(0),nil,'weekday label API is one-based') +for _,edition in ipairs({'gold','silver','crystal'}) do + V.set(edition);host.hour=13;host.minute=20;host.day=2;elapsed=0 + local save={playTime=0,g2DayOffset=3};local untouched=Json.encode(save) + T.eq(Clock.hour(save),13,edition..' legacy hour retains host behavior') + T.eq(Clock.weekday(save),5,edition..' legacy native weekday offset retained') + T.eq(Json.encode(save),untouched,edition..' clock reads do not seed saved state') + T.eq(Clock.setTime(save,23,55),true,edition..' clock setting anchors native RTC') + T.eq(Clock.setWeekday(save,6),true,edition..' day picker stores native RTC base') + T.eq(Clock.hour(save),23,edition..' saved hour is observed immediately') + T.eq(Clock.minute(save),55,edition..' saved minute is observed immediately') + T.eq(Clock.weekday(save),6,edition..' weekday is observed immediately') + T.eq(Clock.isSet(save),true,edition..' explicit clock setting is distinguishable') + T.eq(save.g2DayOffset,save.rtc.startDay,edition..' old native day readers retain shared offset') + host.minute=30;elapsed=600 + T.eq(Clock.minutes(save),5,edition..' RTC keeps ticking and wraps at midnight') + T.eq(Clock.weekday(save),0,edition..' saved weekday rolls over at game midnight before host midnight') + local round=assert(Save.decode(Save.encode(save))) + T.eq(Clock.minutes(round),5,edition..' save roundtrip retains RTC minute base') + T.eq(Clock.weekday(round),0,edition..' save roundtrip retains RTC weekday base') + local game={data=T.fixtures.fresh(),save=save} + debug.setupvalue(W.timeOfDay,slot,game);nativeGame.save=save + local world=setmetatable({map={id='RTC',def={mapPalette=0}},player={cellX=0,cellY=0}}, {__index=W}) + -- Control only graphics IO; native clock, period selection and commands run. + world.syncGen2Tod=function()end + for _,case in ipairs({{3,59,'NITE',4},{4,0,'MORNING',1},{9,59,'MORNING',1}, + {10,0,'DAY',2},{17,59,'DAY',2},{18,0,'NITE',4}})do + Clock.setTime(save,case[1],case[2]) + T.eq(world:timeOfDay(),case[3],edition..' native world reads saved boundary '..case[1]) + T.eq(W.clockTimeOfDayBit(),case[4],edition..' object-load bit uses same saved boundary '..case[1]) + T.eq(world.hour(),case[1],edition..' legacy world dot hour agrees') + T.eq(world:minute(),case[2],edition..' legacy world colon minute agrees') + T.eq(P.clockDaytime(),case[3]=='MORNING' and 'MORN' or case[3],edition..' scene palette raw RTC agrees') + T.eq(P.clockDaytime(12),'DAY',edition..' explicit scene hour remains authoritative') + local ctx={save=save,overworld=world} + Commands.g2_readvar(ctx,10);T.eq(ctx.g2Var,case[1],edition..' actual VAR_HOUR agrees') + Commands.g2_readvar(ctx,11);T.eq(ctx.g2Var,Clock.weekday(save),edition..' actual VAR_WEEKDAY agrees') + Commands.g2_checktime(ctx,case[4]);T.eq(ctx.lastCheck,true,edition..' actual checktime agrees') + T.eq(require('src.pokemon.Evolution').isNight(save),case[4]==4,edition..' happiness time gate agrees') + end + -- Native clock-card composition must show RTC, even with zero play time. + Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local text={};local Gear=require('src.ui.PokegearMenu') + local gear=setmetatable({game=game,blink=0,gfx={cards={clock={}}}}, {__index=Gear}) + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.text=function(_,value,x,y)text[x..':'..y]=value end + gear:drawClock() + T.eq(text['6:6'],'THURSDAY',edition..' actual clock card shows selected weekday') + T.eq(text['6:8'],' 9',edition..' actual clock card shows RTC hour rather than play hours') + T.eq(text['9:8'],'47',edition..' actual clock card shows RTC minute rather than play minutes') + T.eq(text['12:8'],'PM',edition..' actual clock card shows RTC half-day') + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local files={['mods/rtc/manifest.json']=Json.encode({id='rtc',name='Native RTC observer',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/rtc/main.lua']=[[ + local mod=...; + local Clock=require('src.core.gen2.Clock');mod.exports.events={} + mod.exports.stop=mod.events:on("clock.day_changed",function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99;return false + end) + ]]} + local result=T.sdk.loadMods({'mods/rtc'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' actual loader admits public clock module/event') + local e=result.loader.exports.rtc;local own={};e.save=own + Clock.weekday(own);T.eq(#e.events,0,label..' first read is not a fictitious rollover') + host.day=(host.day+1)%7 + Clock.weekday(own);T.eq(#e.events,1,label..' real read observes rollover exactly once') + T.eq(e.events[1].reason,'rollover',label..' rollover reason supplied') + Clock.weekday(own);T.eq(#e.events,1,label..' reentrant/repeated reads do not repeat event') + Clock.weekday({});T.eq(#e.events,1,label..' another profile starts silently') + local selected=(Clock.weekday(own)+2)%7 + Clock.setWeekday(own,selected) + T.eq(#e.events,2,label..' native day setter observes one change') + T.eq(e.events[2].reason,'set',label..' explicit setter reason supplied') + T.eq(Clock.weekday(own),selected,label..' listener mutation cannot corrupt native weekday') + e.stop();host.day=(host.day+1)%7;Clock.weekday(own) + T.eq(#e.events,2,label..' unsubscribe retires actual observation') + Loader.teardown();result.release() + end +end +nativeGame.save=oldSave;debug.setupvalue(W.timeOfDay,slot,oldGame) +Clock.resetObservations();os.date=date;os.time=time;V.set(version) +T.finish('shared native Gen2 saved RTC, actual world/scripts/clock-card and public event') diff --git a/tests/engine/gen2_clock_card.lua b/tests/engine/gen2_clock_card.lua new file mode 100644 index 000000000..a36d76005 --- /dev/null +++ b/tests/engine/gen2_clock_card.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local Gear=require('src.ui.gen2.Pokegear');local Clock=require('src.core.Gen2Clock') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local date=os.date +os.date=function(format,...)if format=='%H'then return '12'end;if format=='%M'then return '05'end;if format=='%w'then return '1'end;return date(format,...)end +local function parts(screen)return {screen:clockParts()}end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition) + local save={playTime=0};Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local game={save=save,data=T.fixtures.fresh()} + local gear=setmetatable({game=game},{__index=Gear}) + local before=Json.encode(save) + T.same(parts(gear),{21,47,5},edition..' external card reads shared saved RTC, weekday one-based') + T.eq(Json.encode(save),before,edition..' clock presentation does not mutate saved state') + Clock.setTime(save,7,13);Clock.setWeekday(save,0) + T.same(parts(gear),{7,13,1},edition..' native clock edits are immediately visible') + local seen={};local current={hour=22,minute=8,day=6} + game.overworld={hour=function(self)seen.hour=self;return current.hour end, + minute=function(self)seen.minute=self;return current.minute end, + weekday=function(self)seen.weekday=self;return current.day end} + T.same(parts(gear),{22,8,7},edition..' live native world owns presentation') + T.eq(seen.hour,game.overworld,edition..' actual world hour receives its owner') + T.eq(seen.minute,game.overworld,edition..' actual world minute receives its owner') + T.eq(seen.weekday,game.overworld,edition..' actual world weekday receives its owner') + current.hour,current.minute,current.day=4,59,0 + T.same(parts(gear),{4,59,1},edition..' live world changes are not cached as UI state') + game.world=game.overworld;game.overworld=nil + T.same(parts(gear),{4,59,1},edition..' reference game.world spelling uses same read protocol') + game.overworld={hour=function()error('partial clock must not be mixed with saved clock')end} + T.same(parts(gear),{7,13,1},edition..' incomplete world falls back to one complete saved clock') + gear.clock={hour=10,minute=34,weekday=3} + T.same(parts(gear),{10,34,3},edition..' authored card clock override retains precedence') + gear.clock={} + T.same(parts(gear),{0,0,1},edition..' authored empty override retains reference defaults') + gear.clock=nil;game.overworld=nil;game.world=nil + local detached=setmetatable({save=save},{__index=Gear}) + T.same(parts(detached),{7,13,1},edition..' save-only reference card is supported') + -- The native draw and an unchanged external call use the same getter. + local values={};gear.blink=0;gear.gfx={cards={clock={}}} + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.clockParts=function()return 23,56,6 end + gear.text=function(_,value,x,y)values[x..':'..y]=value end + gear:drawClock() + T.eq(values['6:6'],'FRIDAY',edition..' native card uses public weekday projection') + T.eq(values['6:8'],'11',edition..' native card uses public hour projection') + T.eq(values['9:8'],'56',edition..' native card uses public minute projection') + T.eq(values['12:8'],'PM',edition..' native card keeps cartridge half-day display') + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/card/manifest.json']=Json.encode({id='card',name='Native clock card reader',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/card/main.lua']=[[ + local mod=...;local Gear=require('src.ui.gen2.Pokegear') + mod.exports.read=function(game) + local screen=setmetatable({game=game},{__index=Gear}) + return screen:clockParts() + end + ]]} + local result=T.sdk.loadMods({'mods/card'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' public clock-card consumer loads unchanged') + T.same({result.loader.exports.card.read(game)},{7,13,1},label..' real loaded consumer reads native RTC rather than fallback zeroes') + Loader.teardown();result.release() + end +end +Clock.resetObservations();os.date=date;V.set(version) +T.finish('native and external Gen2 clock-card presentation over shared RTC') diff --git a/tests/engine/gen2_clock_lifecycle.lua b/tests/engine/gen2_clock_lifecycle.lua new file mode 100644 index 000000000..ab38d51c8 --- /dev/null +++ b/tests/engine/gen2_clock_lifecycle.lua @@ -0,0 +1,119 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +local C=require('src.core.Gen2Clock');local D=require('src.script.Gen2Daily') +local Save=require('src.core.SaveData');local Json=require('src.link.Json') +local date,time=os.date,os.time +-- A deterministic host clock. Date-table normalization still uses the OS +-- calendar, including real DST rules; elapsed time is independently advanced. +local now=time({year=2026,month=10,day=20,hour=13,min=20,sec=0}) +local origin=now;local shift=0 +os.time=function(value)if value then return time(value)end;return now end +os.date=function(format,stamp)return date(format,(stamp or now)+shift)end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);now=origin;shift=0 + local save={flags={},g2FruitTrees={used=true}} + T.eq(C.setTime(save,23,59),true,edition..' player can anchor before midnight') + T.eq(C.setWeekday(save,4),true,edition..' player chooses Thursday independently of host') + local startDate=C.dateKey(save);local offset=save.rtc.startMinute + T.eq(C.hour(save),23,edition..' explicit hour visible immediately') + T.eq(C.minute(save),59,edition..' explicit minute visible immediately') + D.poll(save);save.g2FruitTrees.used=true + local before=save.g2DailyDay + now=now+59;D.poll(save) + T.eq(C.minute(save),59,edition..' RTC waits for the full elapsed minute') + T.eq(C.weekday(save),4,edition..' weekday remains before game midnight') + T.eq(save.g2FruitTrees.used,true,edition..' daily use retained before midnight') + now=now+1;D.poll(save) + T.eq(C.hour(save),0,edition..' elapsed RTC crosses midnight') + T.eq(C.minute(save),0,edition..' minute wraps with the day') + T.eq(C.weekday(save),5,edition..' weekday crosses at same game midnight') + T.eq(C.hostWeekday(),C.hostWeekday(origin),edition..' host has not crossed midnight') + T.eq(save.g2FruitTrees.used,nil,edition..' native daily reset occurs at game midnight') + T.eq(save.g2DailyDay~=before,true,edition..' daily calendar uses the game boundary') + T.eq(C.dateKey(save)~=startDate,true,edition..' RTC calendar advances at same boundary') + save.g2FruitTrees.used=true;D.poll(save) + T.eq(save.g2FruitTrees.used,true,edition..' repeated poll cannot repeat daily reset') + local round=assert(Save.decode(Save.encode(save))) + now=now+7*86400+123*60 + T.eq(C.weekday(round),5,edition..' offline week retains proper weekday') + T.eq(C.hour(round),2,edition..' offline elapsed hours retained after save reload') + T.eq(C.minute(round),3,edition..' offline elapsed minutes retained after save reload') + local stale=round.g2DailyDay;D.poll(round) + T.eq(round.g2DailyDay~=stale,true,edition..' offline whole week still resets daily identity') + T.eq(round.g2FruitTrees.used,nil,edition..' offline reset does not depend on changed weekday') + local expected=C.minutes(round);local day=C.weekday(round);local calendar=C.dateKey(round) + shift=3600 + T.eq(C.minutes(round),expected,edition..' automatic host DST jump cannot double-advance game RTC') + T.eq(C.weekday(round),day,edition..' host DST cannot independently shift game weekday') + T.eq(C.dateKey(round),calendar,edition..' host DST cannot expire daily identity') + shift=-3600 + T.eq(C.minutes(round),expected,edition..' backward host DST jump cannot freeze game RTC') + shift=0 + T.eq(C.setTime(round,7,15),true,edition..' explicit edit reanchors current native clock') + T.eq(C.weekday(round),day,edition..' editing time preserves the chosen current weekday') + T.eq(C.dateKey(round),calendar,edition..' editing time preserves current game calendar date') + T.eq(C.hour(round),7,edition..' edited hour visible') + T.eq(C.minute(round),15,edition..' edited minute visible') + now=now+2*86400+45*60 + T.eq(C.weekday(round),(day+2)%7,edition..' future days follow new anchor') + T.eq(C.minutes(round),8*60,edition..' new anchor keeps ticking') + local oldDate=C.dateKey(round);C.setWeekday(round,0) + T.eq(C.weekday(round),0,edition..' weekday editing after offline elapsed days is immediate') + T.eq(C.dateKey(round),oldDate,edition..' weekday wheel does not rewrite calendar age') + C.setTime(round,23,59);now=now+60 + T.eq(C.weekday(round),1,edition..' edited weekday and time cross next midnight together') + -- The weekly service has to expire on the selected in-game Friday, not a + -- host Friday that is several hours out of phase. + now=origin;local lottery={flags={}};C.setTime(lottery,23,59);C.setWeekday(lottery,4) + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' fresh lottery remains active Thursday') + now=now+60 + T.eq(C.weekday(lottery),5,edition..' weekly service reaches selected Friday') + T.eq(D.lotteryExpired(lottery),true,edition..' native lottery expires at game Friday midnight') + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' Friday draw waits for next Friday') + now=now+6*86400 + T.eq(D.lotteryExpired(lottery),false,edition..' lottery does not expire a day early') + now=now+86400 + T.eq(D.lotteryExpired(lottery),true,edition..' next game Friday expires weekly draw') + -- Actual subscription dispatch must observe a game midnight even when + -- the host weekday is unchanged, and an offline full week too. + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/day/manifest.json']=Json.encode({id='day',name='Native midnight observer',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/day/main.lua']=[[ + local mod=...;mod.exports.events={} + local Clock=require('src.core.gen2.Clock') + mod.events:on('clock.day_changed',function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99 + end) + ]]} + local loaded=T.sdk.loadMods({'mods/day'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,edition..' real loader admits midnight observer') + local export=loaded.loader.exports.day;local own={};export.save=own + now=origin;C.setTime(own,23,59);C.setWeekday(own,4) + -- Explicit staging changes may emit; only changes after the first settled + -- observation are under test here. + C.weekday(own);local n=#export.events;now=now+60;C.weekday(own) + T.eq(#export.events,n+1,edition..' midnight event dispatched once through actual runtime') + T.eq(export.events[n+1].day,5,edition..' midnight event carries new game weekday') + T.eq(export.events[n+1].previous,4,edition..' midnight event retains previous game weekday') + C.weekday(own);T.eq(#export.events,n+1,edition..' reentrant/repeated reads cannot repeat midnight') + now=now+7*86400;C.weekday(own) + T.eq(#export.events,n+2,edition..' offline full week still emits one observed day change') + T.eq(export.events[n+2].day,5,edition..' full-week event correctly has same weekday') + require('src.mods.Loader').teardown();loaded.release() + end + -- Old offsets and old saves remain read-only on load, without introducing + -- an epoch guessed from today's time into another player's save. + local old={rtc={startMinute=offset,startDay=2},g2DayOffset=2} + local bytes=Json.encode(old) + T.eq(C.minutes(old),(C.hostMinutes()+offset)%1440,edition..' old minute offset retains exact semantics') + T.eq(C.weekday(old),(C.hostWeekday()+2)%7,edition..' old weekday offset retains exact semantics') + C.dateKey(old);T.eq(Json.encode(old),bytes,edition..' old save reads do not migrate or mutate state') + local malformed={rtc={anchorTime='bad',anchorMinute=0,anchorDay=0,startMinute=0}} + T.eq(C.minutes(malformed),C.hostMinutes(),edition..' malformed optional anchor falls back to old clock') +end +os.date=date;os.time=time;C.resetObservations();V.set(version) +T.finish('native RTC midnight, offline calendar, host DST isolation and actual daily/lottery lifecycle') diff --git a/tests/engine/gen2_decoration_content.lua b/tests/engine/gen2_decoration_content.lua new file mode 100644 index 000000000..0e6f06550 --- /dev/null +++ b/tests/engine/gen2_decoration_content.lua @@ -0,0 +1,227 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local old=V.get() +local Native=require('src.world.Gen2Decorations');local E=require('src.import.RomExtractorGen2') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Commands=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') +local Stack=require('src.core.StateStack');local TextBox=require('src.render.TextBox') +local Merge=require('src.mods.Merge') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='CANCEL',flag=0,sprite=0}, + [1]={type=1,name='STORE BED',action='PUT_AWAY_BED',flag=0,sprite=0}, + [2]={type=2,name='SYNTHETIC',action='SET_UP_BED',flag=500,sprite=0}, + [3]={type=2,name='OTHER',action='SET_UP_BED',flag=501,sprite=44}, + [4]={type=1,name='STORE DOLL',action='PUT_AWAY_DOLL',flag=0,sprite=0}, + [5]={type=5,name='MODEL',action='SET_UP_DOLL',flag=502,sprite=18}, + [6]={type=1,name='CONSOLE',action='SET_UP_CONSOLE',flag=503,sprite=20}, + [7]={type=1,name='MAP',action='SET_UP_POSTER',flag=504,sprite=22}, + [8]={type=1,name='RUG',action='SET_UP_CARPET',flag=505,sprite=255}, + [9]={type=1,name='PLANT',action='SET_UP_PLANT',flag=506,sprite=24}, + [10]={type=6,name='MODEL',action='SET_UP_BIG_DOLL',flag=507,sprite=26}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=2,poster=7}, + giftsComplete=true,gifts={2,3,5},sourceNames={[5]='MODEL'},species={[5]='SPECIES_001'}}, + decorationPrograms={posters={[7]='POSTER'},null='NULL',ornament='ORNAMENT',big='BIG', + texts={put='PUT',empty='EMPTY',setup='SET',replace='REPLACE',same='SAME',sidePut='SIDE_PUT',sideSetup='SIDE_SET'}}, + scripts={CALLBACK={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'endcallback'}}, + DESC={{'describedecoration',1}},POSTER={{'setval',70},{'end'}},NULL={{'setval',71},{'end'}}, + ORNAMENT={{'setval',72},{'end'}},BIG={{'setval',73},{'end'}}, + PC={{'special',0,'PlayersHousePC'},{'iftrue','RELOAD'},{'end'}},RELOAD={{'warp','NONE',0,0,0},{'end'}}}} + d.text={PUT='{RAM:wStringBuffer3}',SET='{RAM:wStringBuffer3}',REPLACE='{RAM:wStringBuffer3}/{RAM:wStringBuffer4}', + SAME='SYNTHETIC SAME',EMPTY='SYNTHETIC EMPTY',SIDE_SET='SYNTHETIC SIDE SET',SIDE_PUT='SYNTHETIC SIDE PUT'} + d.pokemon.SPECIES_001={name='RENAMED'} + return d +end +local function load(d,code,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/deco/manifest.json']=Json.encode({id='deco',name='Synthetic decorations',version='1.0.0', + api=mode and mode[1]or 2,content_format=mode and mode[2]or 'native',entry='main.lua',affects_link=false}) + files['mods/deco/main.lua']='local mod=...;mod.exports.api=mod;'..code;paths={'mods/deco'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d) + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='map'}) + local g={data=d,save={flags={},inventory={}},stack=stack,input={wasPressed=function()return false end}} + g.overworld={map={id='ROOM'},player={cellX=4,cellY=5,facing='left'},paint={},reloaded=0} + function g.overworld:replaceBlock(x,y,n)self.paint[x..','..y]=n end + function g.overworld:reloadMap(id)assert(id=='ROOM');self.reloaded=self.reloaded+1;Native.visibility({game=g,save=g.save});Native.tiles({game=g,save=g.save,overworld=self})end + return g +end +local function run(g,label) + local r=Runner.new(g,g.overworld);r:run(assert(VM.compile(g.data,label)));return r +end +local function choose(g,label) + local menu=g.stack:top() + -- Drive the native house PC boot message before selecting a row. It owns + -- its typewriter rather than a separate generic TextBox on the stack. + for _=1,3000 do + if not menu.message then break end + local original=g.input.wasPressed + g.input.wasPressed=function(_,key)return key=='a'end + menu:update(1/60);g.input.wasPressed=original + end + if menu.entries then + for i,row in ipairs(menu.entries)do + if row.label==label then menu.index=i;menu:choose();return end + end + elseif menu.categories then + if menu.mode=='items'then + local leave=label=='EXIT' + for _,category in ipairs(menu.categories)do if category.label==label then leave=true end end + if leave then + local original=g.input.wasPressed;g.input.wasPressed=function(_,key)return key=='b'end + menu:update(1/60);g.input.wasPressed=original + end + end + if menu.mode=='category'then + if label=='EXIT'then menu:finish();return end + for _,category in ipairs(menu.categories)do if category.label==label then menu:openCategory(category);return end end + else + for _,id in ipairs(menu.rows or{})do + if menu:rowName(id)==label then menu:chooseRow(id);return end + end + end + else + for i,row in ipairs(menu.items or{})do if row.label==label then + menu.index=i;local original=g.input.wasPressed;g.input.wasPressed=function(_,key)return key=='a'end + g.stack:update(0);g.input.wasPressed=original;return end end + end + error('missing menu choice '..label) +end + +local function dismiss(g) + local box=g.stack:top();assert(getmetatable(box)==TextBox);box.done=true;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0);g.input={wasPressed=function()return false end} +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture();local before=d.map_scripts.decorations + local r=load(d,[[ + local p=mod.content["decorations"] + assert(p:supports()) + local copy=p:get('deco:2');copy.sprite=199;assert(p:get('deco:2').sprite==0) + p:patch('deco:2',{sprite=88}) + p:register('deco:200',{type=1,name='ADDED',flag=900,action='SET_UP_PLANT',sprite=77}) + ]],mode) + local label=version..' API '..mode[1]..' '..mode[2] + T.eq(#r.errors,0,label..' actual Loader accepts native DECO registry') + T.eq(d.map_scripts.decorations[2].sprite,88,label..' patch reaches actual native table') + T.eq(d.map_scripts.decorations[200].flag,900,label..' new byte reaches actual native table') + local g=game(d);T.eq(Native.state(d,g.save).bed,2,label..' initializes cartridge default once') + T.check(Native.give(d,g.save,200),label..' give owns authored record through actual event') + T.eq(g.save.flags[Flags.eventFlag(900)],true,label..' ownership is the authored flag') + T.check(Native.choose(d,g.save,200),label..' owned authored placement succeeds') + run(g,'CALLBACK') + T.eq(g.overworld.paint['3,2'],77,label..' actual VM callback paints authored block') + T.eq(g.overworld.paint['0,2'],88,label..' actual VM callback paints patched bed') + T.eq(g.save.flags[Flags.eventFlag(1857)],true,label..' empty console object is hidden') + T.eq(g.save.flags[Flags.eventFlag(716)],true,label..' placed poster enables native event') + finish(r) + local clean=load(d) + T.eq(d.map_scripts.decorations,before,label..' disabling restores pristine table identity') + T.eq(d.map_scripts.decorations[200],nil,label..' disabling removes owned addition') + finish(clean) + end + local d=fixture();local g=game(d) + T.eq(Native.choose(d,g.save,3),false,version..' unowned placement fails') + T.eq(Native.give(d,g.save,0),false,version..' CANCEL cannot be awarded') + T.eq(Native.give(d,g.save,1),false,version..' PUT header cannot be awarded') + T.check(Native.giveFlag(d,g.save,1),version..' actual gift ordinal awards real DECO byte') + T.eq(Native.owns(d,g.save,3),true,version..' gift ownership reaches event bit') + T.eq(Native.name(d,5),'RENAMED DOLL',version..' imported species identity follows actual name') + d.map_scripts.decorations[5].name='AUTHORED';T.eq(Native.name(d,5),'AUTHORED DOLL',version..' authored name wins old imported source') + for id=2,10 do Native.give(d,g.save,id)end + local state=Native.state(d,g.save) + state.leftOrnament=5;state.rightOrnament=5 + T.eq(Native.choose(d,g.save,5,'left'),false,version..' already placed doll is not a change') + T.eq(state.rightOrnament,5,version..' already placed doll does not mutate opposite side') + state.leftOrnament=0 + T.eq(Native.choose(d,g.save,5,'left'),true,version..' owned doll moves sides') + T.eq(state.rightOrnament,0,version..' real move clears opposite duplicate') + Native.choose(d,g.save,6);Native.choose(d,g.save,8);Native.choose(d,g.save,10) + local callback=run(g,'CALLBACK') + T.eq(g.save.gen2VarSprites[0],20,version..' console writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[1],18,version..' left doll writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[3],26,version..' big doll writes actual native variable sprite') + T.eq(g.overworld.paint['0,2'],0,version..' zero is valid block for nonzero placed DECO') + T.eq(g.overworld.paint['0,1'],0,version..' carpet offsets wrap at byte boundary') + T.eq(g.overworld.paint['1,1'],1,version..' carpet second offset wraps at byte boundary') + local desc=run(g,'DESC') + T.eq(desc.ctx.g2Var,72,version..' native describedecoration jumps to imported ornament script') + T.eq(g.stringBuffers[3],'AUTHORED DOLL',version..' native description fills real buffer three') + -- Real menus, native VM, TextBox and StateStack; only render/audio are harness IO. + g=game(fixture());Native.give(g.data,g.save,3);Native.give(g.data,g.save,5) + local pc=run(g,'PC') + T.check(pc:isRunning(),version..' native house-PC script waits for menu closure') + T.eq(pc.ctx.g2Var,nil,version..' native result is not guessed before selection') + choose(g,'DECORATION');choose(g,'BED');choose(g,'OTHER BED') + T.eq(Native.state(g.data,g.save).bed,2,version..' selection waits for original feedback box') + T.eq(g.stringBuffers[3],'SYNTHETIC BED',version..' replacement feedback buffers old name') + T.eq(g.stringBuffers[4],'OTHER BED',version..' replacement feedback buffers new name') + dismiss(g) + T.eq(g.save.decorations.bed,3,version..' acknowledging feedback commits native placed byte') + T.eq(g.overworld.reloaded,0,version..' furniture remains unchanged before PC exit') + choose(g,'ORNAMENT');choose(g,'RENAMED DOLL') + local prompt=g.stack:top();prompt.done=true;prompt:update(0) + choose(g,'RIGHT SIDE');dismiss(g) + T.eq(g.save.decorations.rightOrnament,5,version..' actual native side menu commits right doll') + choose(g,'EXIT') + T.check(pc:isRunning(),version..' leaving decoration submenu does not resume PC script') + choose(g,'TURN OFF') + T.eq(pc:isRunning(),false,version..' LOG OFF completes actual native PC script') + T.eq(pc.ctx.g2Var,1,version..' changed bit returns after menu closes') + T.eq(g.overworld.reloaded,1,version..' native NONE warp reloads exactly once') + T.eq(g.overworld.player.cellX,4,version..' NONE reload retains current cell rather than zero coordinates') + T.eq(g.overworld.paint['0,2'],44,version..' reload callback uses final selected bed') + T.eq(g.save.gen2VarSprites[2],18,version..' reload callback uses selected right doll') + g.data.map_scripts.scripts.FACE={{'warpfacing',3,'NONE',0,0,0},{'end'}} + g.overworld.player.facing='down';run(g,'FACE') + T.eq(g.overworld.player.facing,'right',version..' native NONE warpfacing applies its explicit direction') + g=game(fixture());pc=run(g,'PC');choose(g,'TURN OFF') + T.eq(pc.ctx.g2Var,0,version..' PC cancellation returns unchanged') + T.eq(g.overworld.reloaded,0,version..' PC cancellation cannot reload') +end +V.set('crystal') +for _,code in ipairs({ + "p:patch('deco:2',{sprite=256})", "p:patch('deco:2',{flag=65535})", "p:patch('deco:2',{type=0})", + "p:patch('deco:2',{action='GUESS'})", "p:patch('deco:2',{sprite=0/0})", "p:patch('deco:2',{flag=math.huge})", + "p:patch('deco:02',{sprite=3})", "p:patch('deco:256',{sprite=3})", "p:patch('deco:2',{unknown=true})", + "p:patch('deco:2',{sprite=44});error('entry failed')", +})do + local d=fixture();local r=load(d,'local p=mod.content["decorations"];'..code) + T.check(#r.errors>0,'invalid or failed decoration contribution is rejected') + T.eq(d.map_scripts.decorations[2].sprite,0,'failed entry rolls back all native decoration writes') + finish(r) +end +do + local d=fixture();local original=d.map_scripts.decorations + original['2']=Merge.deepCopy(original[2]) + local r=load(d,[[local p=mod.content["decorations"];p:remove('deco:2');p:patch('deco:3',{sprite=66}); + p:register('deco:200',{type=1,name='EXTERNAL',flag=901,action='SET_UP_PLANT',sprite=77})]]) + T.eq(d.map_scripts.decorations[2],nil,'remove clears numeric DECO alias') + T.eq(d.map_scripts.decorations['2'],nil,'remove clears string DECO alias') + d.map_scripts.decorations[200].sprite=99 + finish(r);local clean=load(d) + T.eq(d.map_scripts.decorations[2].sprite,0,'reload restores removed native numeric record') + T.eq(d.map_scripts.decorations['2'].sprite,0,'reload restores removed native string alias') + T.eq(d.map_scripts.decorations[3].sprite,44,'reload retires an unchanged owned patch') + T.eq(d.map_scripts.decorations[200].sprite,99,'reload preserves externally edited authored record independently') + finish(clean) +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local d=fixture();if version=='prism'or version=='polishedcrystal'then d.map_scripts.decorationInfo.complete=false end + local before=d.map_scripts.decorations + local r=load(d,[[local p=mod.content["decorations"];assert(not p:supports());local v,why=p:patch('deco:2',{sprite=99});assert(v==nil and why)]]) + T.eq(#r.errors,0,version..' unsupported native decoration target declines explicitly') + T.eq(d.map_scripts.decorations,before,version..' unsupported generation does not write native namespace') + finish(r) +end +V.set(old);T.finish('native Gen2 decoration content, room callbacks and house PC') diff --git a/tests/engine/gen2_decoration_facade.lua b/tests/engine/gen2_decoration_facade.lua new file mode 100644 index 000000000..108caedad --- /dev/null +++ b/tests/engine/gen2_decoration_facade.lua @@ -0,0 +1,160 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local D=require('src.core.gen2.Decorations') +local N=require('src.world.Gen2Decorations') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local F=require('src.script.Gen2Flags') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='BACK',flag=0,sprite=0}, + [40]={type=1,name='STORE',flag=0,action='PUT_AWAY_BED',sprite=0}, + [41]={type=2,name='ONE',flag=620,action='SET_UP_BED',sprite=0}, + [42]={type=2,name='TWO',flag=621,action='SET_UP_BED',sprite=22}, + [80]={type=1,name='STORE DOLL',flag=0,action='PUT_AWAY_DOLL',sprite=0}, + [81]={type=5,name='MODEL',flag=622,action='SET_UP_DOLL',sprite=31}, + [90]={type=1,name='RUG',flag=623,action='SET_UP_CARPET',sprite=255}, + [91]={type=1,name='SCREEN',flag=624,action='SET_UP_CONSOLE',sprite=33}, + [92]={type=4,name='MODEL',flag=625,action='SET_UP_POSTER',sprite=34}, + [93]={type=6,name='MODEL',flag=626,action='SET_UP_BIG_DOLL',sprite=35}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=41,poster=92}, + giftsComplete=true,gifts={42,81},species={[81]='SPECIES_001',[92]='SPECIES_001',[93]='SPECIES_001'}, + sourceNames={[81]='MODEL',[92]='MODEL',[93]='MODEL'}}, + decorationPrograms={texts={setup='SET',put='PUT',empty='EMPTY',same='SAME',replace='REPLACE',noChoice='NONE'}}, + scripts={ROOM={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'end'}}}} + d.pokemon.SPECIES_001={name='LIVE'} + d.text={SET='A:{RAM:wStringBuffer3}{DONE}',PUT='B:{RAM:wStringBuffer3}{PROMPT}', + EMPTY='C',SAME='D',REPLACE='E:{RAM:wStringBuffer3}\fF:{RAM:wStringBuffer4}{DONE}',NONE='G'} + return d +end +local function load(d,code) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/test/manifest.json']=Json.encode({id='test',name='Synthetic',version='1.0.0',api=2,entry='main.lua',affects_link=false}) + files['mods/test/main.lua']='local mod=...;mod.exports.deco=require("src.core.gen2.Decorations");'..code + paths={'mods/test'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function done(r)Loader.teardown();r.release()end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local d=fixture();local pristine=d.map_scripts.decorations + local r=load(d,[[ + assert(mod.exports.deco.ATTRIBUTES[41].sprite==0) + mod.content.decorations:patch('deco:42',{name='PATCHED',sprite=72}) + mod.content.decorations:register('deco:201',{type=1,name='AUTHORED',flag=900,action='SET_UP_BED',sprite=73}) + mod.content.decorations:remove('deco:41') + ]]) + T.eq(#r.errors,0,version..' real Loader imports public contract before entry executes') + T.eq(D,require('src.compat.gen2.Decorations'),version..' legacy module identity is the adapter') + T.check(D~=N,version..' incompatible signature is not a native alias') + T.eq(D.attributes(41),nil,version..' removed record never resurrects from retail fallback') + T.eq(D.ATTRIBUTES[41],nil,version..' current catalogue agrees with registry removal') + T.eq(D.attributes('42'),d.map_scripts.decorations[42],version..' string byte reads the one actual native record') + T.eq(D.name(42),'PATCHED BED',version..' merged name reaches public function') + T.eq(D.name('81'),'LIVE DOLL',version..' string identity still resolves imported species') + T.eq(D.name(81,function(base)return base=='MODEL'and 'CUSTOM'end),'CUSTOM DOLL',version..' legacy resolver is honored') + T.eq(D.name(92),'LIVE POSTER',version..' poster grammar') + T.eq(D.name(93),'BIG LIVE',version..' large doll grammar') + T.eq(D.name(999),'',version..' invalid byte has no invented name') + local save={flags={}};local state=D.state(save) + T.eq(state,save.decorations,version..' public state is the actual native placed-byte table') + T.same(state,{bed=41,poster=92},version..' defaults come from ROM despite non-retail identities') + T.same(D.state({decorations={}}),{},version..' explicitly empty native placement stays empty') + local events=D.events(save) + T.eq(D.owns(events,201),false,version..' new decoration starts unowned') + T.check(D.give(events,201),version..' numeric public event protocol awards authored record') + T.eq(save.flags[F.eventFlag(900)],true,version..' award reaches canonical native save flag') + T.eq(N.owns(d,save,201),true,version..' native ownership sees legacy award') + T.eq(D.give(events,0),false,version..' cancel cannot award flag zero') + T.eq(D.give(events,40),false,version..' put-away header cannot award flag zero') + T.eq(save.flags[F.eventFlag(0)],nil,version..' rejected award does not mutate map buffer') + T.eq(D.idForFlag(0),42,version..' gift ordinal follows native non-retail mapping') + T.check(D.giveFlag(events,1),version..' native ordinal awards doll') + T.eq(D.idForFlag(-1),nil,version..' invalid ordinal does not guess') + T.check(D.give(save,'42'),version..' direct native-save argument also grants ownership') + T.eq(N.owns(d,save,42),true,version..' direct award reaches actual native ownership') + local category=D.ownedCategories(events)[1] + T.same(category.members,{42,201},version..' categories contain actual merged IDs without removed member') + T.same(D.rows(events,category),{42,201,40,0},version..' owned members then actual header then cancel') + T.same(D.rows(events,{id=40,members={2}}),{42,201,40,0},version..' legacy plain category shape resolves native members') + T.eq(D.idFor(201),'deco:201',version..' canonical registry identity') + T.eq(D.NOTHING_TO_CHOOSE,'G',version..' feedback derives from actual ROM text ID') + local seen={};local owner={} + local count=D.registerInto({register=function(_,id,row,who)seen[id]=row;T.eq(who,owner,version..' seed keeps ownership')end},nil,owner) + T.eq(count,10,version..' seed uses current rows including additions and removals') + seen['deco:42'].name='DETACHED' + T.eq(D.name(42),'PATCHED BED',version..' seeding another registry cannot mutate native catalogue') + local changed,pages=D.apply(state,'42') + T.eq(changed,true,version..' public apply replaces placed native byte') + T.eq(state.bed,42,version..' numeric string never writes a string into SRAM state') + T.same(pages,{'E:','F:PATCHED BED'},version..' replacement uses both actual pages; removed old name is empty') + changed,pages=D.apply(state,42) + T.eq(changed,false,version..' same placement is not a change') + T.same(pages,{'D'},version..' same-placement feedback uses ROM role') + changed,pages=D.apply(state,40) + T.check(changed,version..' put-away changes filled slot') + T.same(pages,{'B:PATCHED BED'},version..' put-away names old item and removes end marker') + changed,pages=D.apply(state,40) + T.eq(changed,false,version..' empty put-away is not a change') + T.same(pages,{'C'},version..' empty feedback') + changed,pages=D.apply(state,201) + T.check(changed,version..' setup in empty slot') + T.same(pages,{'A:AUTHORED'},version..' setup uses original role with new item in buffer3') + changed,pages=D.apply(state,81,nil) + T.eq(changed,false,version..' cancelled side prompt has no placement') + T.same(pages,{},version..' cancelled side prompt has no feedback') + state.rightOrnament=81 + changed,pages=D.apply(state,81,'left') + T.check(changed,version..' chosen doll side changes') + T.eq(state.rightOrnament,81,version..' apply keeps separate legacy clearOtherSide contract') + D.clearOtherSide(state,81,'left');T.eq(state.rightOrnament,0,version..' explicit clear removes opposite duplicate') + state.console=91;state.bigDoll=93;state.carpet=90 + local paint={};local game={data=d,save=save};local world={replaceBlock=function(_,x,y,block)paint[x..','..y]=block end} + local runner=Runner.new(game,world);runner:run(assert(VM.compile(d,'ROOM'))) + for _,row in ipairs(D.tiles(state))do T.eq(paint[row.x..','..row.y],row.block,version..' actual VM tiles agree with public layout')end + T.eq(paint['0,2'],73,version..' legacy placement is visible to actual room callback') + T.eq(paint['0,1'],0,version..' wrapped carpet block remains a valid zero byte') + for _,row in ipairs(D.visibility(state))do + T.eq(save.flags[F.eventFlag(row.flag)],row.hidden,version..' actual room visibility matches public plan') + if not row.hidden then T.eq(save.gen2VarSprites[row.sprite],row.byte,version..' public sprite byte matches native callback')end + end + T.eq(D.posterVisible(state),true,version..' poster visibility agrees with native event') + T.eq(save.flags[F.eventFlag(716)],true,version..' native poster callback reads same placed state') + d.text.SET='H:{RAM:wStringBuffer3}{DONE}' + state.bed=0;local _,fresh=D.apply(state,42) + T.same(fresh,{'H:PATCHED BED'},version..' text edits after binding are live') + d._portableScriptText={SET={}};state.bed=0 + local _,masked=D.apply(state,42) + T.same(masked,{},version..' explicitly removed script text does not fall back') + done(r) + T.eq(D.attributes(42),nil,version..' teardown retires data binding') + T.eq(events:set(900,false),false,version..' retained numeric adapter cannot mutate after unload') + T.eq(save.flags[F.eventFlag(900)],true,version..' stale adapter leaves prior save intact') + local nextData=fixture();nextData.map_scripts.decorationInfo.defaults={bed=42} + local clean=load(nextData) + T.same(D.state({}),{bed=42},version..' next native dataset supplies its own defaults') + T.eq(D.attributes(201),nil,version..' old mod addition does not leak into new dataset') + T.eq(events:set(900,false),false,version..' old adapter cannot become live after rebinding') + V.set('red');V.set(version) + T.eq(D.attributes(42),nil,version..' edition switch and return cannot revive stale binding') + done(clean) + local reset=load(d) + T.eq(d.map_scripts.decorations,pristine,version..' reload preserves real registry rollback identity') + T.eq(D.attributes(41),pristine[41],version..' removed record returns only when native registry restores it') + done(reset) +end +V.set('crystal') +local unsupported={map_scripts={decorations={[2]={name='NOT IMPORTED'}}}} +T.eq(D.useRegistry(unsupported),false,'partial cache does not enable legacy native facade') +T.eq(D.state({}),nil,'missing native defaults do not invent retail bedroom') +T.same(D.CATEGORIES,{},'unbound catalogue has no copied retail categories') +V.set(original) +T.finish('native Gen2 decoration facade') diff --git a/tests/engine/gen2_decoration_import.lua b/tests/engine/gen2_decoration_import.lua new file mode 100644 index 000000000..fe2409fb1 --- /dev/null +++ b/tests/engine/gen2_decoration_import.lua @@ -0,0 +1,113 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Native=require('src.world.Gen2Decorations') +local S=require('src.save_convert.Gen2Save');local Json=require('src.link.Json') +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={DecorationAttributes={1,0x4000},DecorationNames={1,0x401e}, + PokemonNames={1,0x4300},InitDecorations={1,0x4100},DecorationIDs={1,0x4200}, + wDecoBed={1,0xd000},wDecoPoster={1,0xd003},DecorationDesc_PosterPointers={1,0x4400}, + ['DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript']={1,0x4500}, + ['DecorationDesc_GiantOrnament.BigDollScript']={1,0x4501},DecorationDesc_NullPoster={1,0x4502}, + PutAwayTheDecoText={1,0x4600},NothingToPutAwayText={1,0x4602},SetUpTheDecoText={1,0x4604}} + -- Authored test cartridge, not a retail attribute catalogue. + put(0x4000,string.char(1,0,0,0,0,0,1,1,2,0,0,0,2,2,1,0x34,0x12,0, + 1,1,8,0,0,0,4,1,7,0x35,0x12,77)) + put(0x401e,string.char(128,0x50,129,0x50,130,0x50)) + put(0x4100,string.char(0x3e,2,0xea,0,0xd0,0x3e,4,0xea,3,0xd0,0xc9)) + put(0x4200,string.char(2,4,255));put(0x4300,string.char(131,0x50)) + put(0x4400,string.char(4,3,0x45,255));put(0x4500,string.char(0x91,0x91,0x91,0x91)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols}) + function e:readSourceTable(k)assert(k=='charmap');return{['128']='X',['129']='Y',['130']='Z',['131']='Q'}end + return e +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local rows,info=e:gen2Decorations() + T.check(info.complete,version..' native attribute boundary defines valid table') + T.eq(info.rowsRead,5,version..' reader imports exact authored row count') + T.eq(rows[0].name,'X',version..' DECO zero remains in actual native table') + T.eq(rows[2].name,'Z',version..' names use actual character decoder') + T.eq(rows[2].flag,0x1234,version..' event identity is native little-endian word') + T.eq(rows[2].sprite,0,version..' block zero survives real import') + T.eq(rows[4].name,'Q',version..' species name comes from cartridge indexed table') + T.eq(info.species[4],'SPECIES_001',version..' raw species identity has no National Dex inference') + T.eq(info.defaults.bed,2,version..' bed default comes from native init operands') + T.eq(info.defaults.poster,4,version..' poster default comes from native init operands') + T.eq(#info.gifts,2,version..' gift ordinal table is ROM-derived') + T.eq(info.gifts[2],4,version..' gift ordinal retains actual DECO byte') + local q={scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0} + local roles=e:gen2QueueDecorationPrograms(q) + T.eq(#q.queue,4,version..' actual queue includes three description roles and poster') + T.check(roles.texts.setup:match('^TEXT_S'),version..' native feedback enters shared text import') + T.check(e._gen2ScriptTexts[roles.texts.setup]~=nil,version..' feedback text is registered for actual extractor') + e:gen2QueueDecorationPrograms(q);T.eq(#q.queue,4,version..' duplicate queue calls are idempotent') + local n=1;while n<=#q.queue do local job=q.queue[n];e:gen2DecodeScript(job[1],job[2],job[3],q);n=n+1 end + T.eq(q.desyncs,0,version..' actual bytecode decoder accepts queued description programs') + for _,edit in ipairs({ + function(s)s.DecorationNames[2]=0x401f end, + function(s)s.DecorationNames[1]=2 end, + function(s)s.DecorationAttributes=nil end, + function(s,put)put(0x4000,string.char(0))end, + function(s,put)put(0x4002,string.char(15))end, + function(s,put)put(0x4003,string.char(255,255))end, + function(s,put)put(0x401e,string.rep(string.char(128),129))end, + function(s)s.PokemonNames=nil end, + })do + local bad,why=fixture(version,edit):gen2Decorations() + T.eq(why.complete,false,version..' malformed attribute/name input cannot become ready') + T.eq(next(bad),nil,version..' malformed attribute/name input publishes no prefix') + end + local _,missing=fixture(version,function(s)s.InitDecorations=nil end):gen2Decorations() + T.eq(missing.complete,true,version..' missing init does not erase valid attribute table') + T.eq(missing.defaultsComplete,false,version..' missing init cannot invent retail defaults') + local _,wrong=fixture(version,function(s)s.wDecoBed[2]=0xd100 end):gen2Decorations() + T.eq(wrong.defaultsComplete,false,version..' init stores must target actual WRAM symbols') + local _,badGifts=fixture(version,function(_,put)put(0x4200,string.rep(string.char(2),256))end):gen2Decorations() + T.eq(badGifts.giftsComplete,false,version..' unterminated gifts cannot be claimed complete') + T.eq(badGifts.gifts,nil,version..' malformed gifts publish no prefix') + q={scripts={},queue={}} + T.eq(fixture(version,function(s)s.DecorationDesc_NullPoster=nil end):gen2QueueDecorationPrograms(q),nil,version..' missing description role declines whole binding') + T.eq(#q.queue,0,version..' missing role is rejected before queue mutation') + q={scripts={},queue={}} + T.eq(fixture(version,function(_,put)put(0x4400,string.char(4,0,0))end):gen2QueueDecorationPrograms(q),nil,version..' invalid poster pointer declines binding') + T.eq(#q.queue,0,version..' invalid poster table leaves queue untouched') + -- Resolve the precise native NONE pair, without accepting an unknown map. + e=fixture(version);function e:gen2MapIndex()return{}end;function e:gen2Trainers()return{}end + local p={scripts={TEST={{'warp',0,0,0,0},{'warpfacing',2,0,0,0,0},{'warp',0,1,0,0},{'blackoutmod',0,0}}}} + e:gen2ResolveScriptMapIds({}, {},p) + T.eq(p.scripts.TEST[1][2],'NONE',version..' only actual NONE warp becomes reload') + T.eq(p.scripts.TEST[2][3],'NONE',version..' warpfacing uses its own map-pair slot') + T.eq(p.scripts.TEST[3][2],0,version..' unknown map is not turned into a reload') + T.eq(p.scripts.TEST[4][2],0,version..' non-warp zero pair is not reinterpreted') + -- Independent manifest WRAM addresses verify the save codec's physical order. + local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(Json.decode(f:read('*a')));f:close() + S.setLayout(version);S.setCharmap({['128']='A'}) + local d=T.fixtures.fresh();local save={player={name='A'},decorations={bed=1,carpet=2,plant=3,poster=4, + console=5,leftOrnament=6,rightOrnament=7,bigDoll=255}} + local raw=S.encode(save,d) + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + for _,key in ipairs(Native.slots)do + local symbol='wDeco'..key:sub(1,1):upper()..key:sub(2) + local address=manifest.symbols[symbol][2];local offset=0x2009+address-anchor + T.eq(raw:byte(offset+1),save.decorations[key],version..' export matches independent '..symbol..' address') + end + local decoded=S.decode(raw,d) + for _,key in ipairs(Native.slots)do T.eq(decoded.decorations[key],save.decorations[key],version..' import preserves exact '..key..' byte')end + local round=S.encode(decoded,d,raw);local warnings=S.decode(round,d).warnings + local checksumOk=true;for _,w in ipairs(warnings)do if w:match('checksum')then checksumOk=false end end + T.check(checksumOk,version..' decoration export retains valid native checksum') + local saved=decoded.decorations;decoded.decorations=nil + local untouched=S.encode(decoded,d,raw) + for i=0,7 do T.eq(untouched:byte(S.OFFSETS.decorations+i+1),raw:byte(S.OFFSETS.decorations+i+1),version..' absent legacy state preserves imported decoration template')end + decoded.decorations=saved;decoded.decorations.bed=256 + T.check(not pcall(S.encode,decoded,d,raw),version..' invalid placed byte cannot wrap and corrupt native export') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);T.eq(fixture(version):gen2Decorations(),nil,version..' unreviewed layout imports no stock catalogue') + S.setLayout(version);T.eq(S.OFFSETS.decorations,nil,version..' unknown save layout has no guessed decoration offset') +end +V.set(old);S.setLayout(old);T.finish('native Gen2 decoration ROM producers and eight-byte SRAM codec') diff --git a/tests/engine/gen2_display_loader.lua b/tests/engine/gen2_display_loader.lua new file mode 100644 index 000000000..9130c5514 --- /dev/null +++ b/tests/engine/gen2_display_loader.lua @@ -0,0 +1,45 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion') +require('src.core.ModCompat').install() +local P=require('src.render.PaletteFX');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local A={{255,255,255},{193,41,71},{31,139,83},{0,0,0}} +local R={{239,223,197},{173,137,91},{97,59,29},{19,13,7}} +local S={{217,231,243},{137,171,209},{61,103,151},{11,23,41}} +V.set('crystal') +for _,api in ipairs({1,2})do + local files={['mods/palette-cache/manifest.json']=Json.encode({id='palette_cache',name='Registered indexed texture producer',version='1.0.0',api=api,content_format=api==3 and 'unified'or'native',entry='main.lua',affects_link=false}), + ['mods/palette-cache/main.lua']=[[ + local mod=...;local G=require('src.render.GbcPalette');local Assets=require('src.render.Assets') + local cache,flushes={},0 + Assets.register(function()cache={};flushes=flushes+1 end) + mod.exports.palette=function(source) + local key=G.mode + if not cache[key]then + local resolved=G.resolve(source);local rows={} + for i=1,4 do rows[i]={resolved[i][1],resolved[i][2],resolved[i][3]}end + cache[key]=rows + end + return cache[key],flushes,key + end + ]]} + P.setDisplay('gbc',nil) + local loaded=T.sdk.loadMods({'mods/palette-cache'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,'API'..api..' actual texture producer loads') + local consumer=loaded.loader.exports.palette_cache + local initial=consumer.palette(A);T.same(initial,A,'API'..api..' initial native row') + P.setDisplay('custom',R) + local first,count,mode=consumer.palette(A) + T.same(first,R,'API'..api..' first display ramp reaches registered producer') + T.eq(mode,'custom','API'..api..' public mode follows native owner') + P.setCustomRamp(S) + local second,newCount,sameMode=consumer.palette(A) + T.eq(sameMode,mode,'API'..api..' second ramp retains public mode ID') + T.eq(newCount,count+1,'API'..api..' native change flushes registered cache once') + T.neq(second,first,'API'..api..' producer rebuilds baked colour rows') + T.same(second,S,'API'..api..' second ramp replaces stale atlas colours') + Loader.teardown();loaded.release() +end +P.setDisplay('gbc',nil) +T.finish('native Gen2 display changes invalidate loaded API 1/2 texture producers') diff --git a/tests/engine/gen2_display_modes.lua b/tests/engine/gen2_display_modes.lua new file mode 100644 index 000000000..a9b181046 --- /dev/null +++ b/tests/engine/gen2_display_modes.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local V=require('src.core.GameVersion') +local P=require('src.render.PaletteFX');local G=require('src.render.GbcPalette') +local TR=require('src.render.TileRenderer');local SR=require('src.render.SpriteRenderer') +local ML=require('src.world.MapLoader');local Assets=require('src.render.Assets') +local Data=require('src.core.Data');local Game=require('src.core.Game') +local Battle=require('src.battle.BattleState') +local assetChanges=0 +Assets.register(function()assetChanges=assetChanges+1 end) +local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} +local B={{245,239,227},{31,97,193},{167,59,131},{7,13,19}} +local R={{231,219,207},{181,151,111},{101,67,37},{23,17,11}} +local GRAY={{255,255,255},{170,170,170},{85,85,85},{0,0,0}} +local function buffer(w,h,source) + local b={w=w,h=h,pixels={}} + function b:getDimensions()return self.w,self.h end + function b:getWidth()return self.w end;function b:getHeight()return self.h end + function b:getPixel(x,y) + local p=self.pixels[y*self.w+x];if p then return unpack(p)end + local n=source and 1-x%4/3 or 0;return n,n,n,source and 1 or 0 + end + function b:setPixel(x,y,...)self.pixels[y*self.w+x]={...}end + function b:mapPixel(f)for y=0,self.h-1 do for x=0,self.w-1 do self:setPixel(x,y,f(x,y,self:getPixel(x,y)))end end end + function b:setFilter()end;function b:setWrap()end;function b:release()end + return b +end +love.image={newImageData=function(w,h)return buffer(w,h)end} +love.graphics.newImage=function(b)return b end +local function source(path) + if path=='display-tiles' then return buffer(16,8,true)end + if path=='display-roof' then return buffer(8,8,true)end + return buffer(16,96,true) +end +Assets.image=source;Assets.imageData=source +local function pixels(image,colors,start,keyed,label) + for i=1,4 do + local r,g,b,a=image:getPixel((start or 0)+i-1,0) + if keyed and i==1 then T.eq(a,0,label..' OBJ shade zero remains transparent') + else T.same({r,g,b,a},{colors[i][1]/255,colors[i][2]/255,colors[i][3]/255,1},label..' shade '..i)end + end +end +local function fixture() + local d=T.fixtures.fresh() + local ts={id='DISPLAY',image='display-tiles',tilesPerRow=2,palMap={0,1},palColorsByTod={DAY={A,B},NITE={B,A},DARK={A,B}}, + animatedTiles={{tile=0,kind='hshift',offsets={0,1},period=20}},blockCells=2,blocks={{0,1,0,1}},walkable={0},grassTiles={},waterTiles={}} + d.tilesets={DISPLAY=ts} + d.maps={DISPLAY={id='DISPLAY',label='DISPLAY',width=1,height=1,blocks={0},tileset='DISPLAY',environment=1,mapPalette=1,group=1,number=1}} + d.pokemon={TEST={id='TEST',name='TEST',index=1,dex=1,palette='QA_TEST',spriteFront='display-mon'}} + d.palettes={palettes={QA_TEST=A,MEWMON=A,GRAYMON=GRAY,BLACK={{0,0,0},{0,0,0},{0,0,0},{0,0,0}}},pokemon={TEST='QA_TEST'}} + d.field.gen2Roofs={tilesets={DISPLAY=true},byGroup={[1]=0},images={[0]='display-roof'},slot=1,count=1,palIndex=1, + colors={[1]={DAY={{53,63,73},{83,93,103}}}}} + Data.publishGen2Palettes(d) + return d,ts +end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);P.setDisplay('gbc',nil);P.setShadeMap(nil);P.setDarkWorld(false);P.setGen2MapPalette(1);P.setGen2Tod('DAY') + local d,ts=fixture();ML.invalidateAll() + local mon={species='TEST',dvs={attack=1,defense=1,speed=1,special=1}} + local battle=setmetatable({data=d,game={save={party={}}}},Battle) + local factory + for i=1,100 do local name,value=debug.getupvalue(Battle.speciesSprite,i);if name=='getImage'then factory=value;break end end + assert(factory,'native battle image factory unavailable') + local originalPic=factory('display-mon',{name='QA_TEST',colors=A},false) + local sprite=SR.new({id='DISPLAY',image='display-sprite',gen2ObjPal=A}) + local reloads=0 + Game.overworld={map=ML.load(d,'DISPLAY'),reloadMap=function(self,id,reason) + T.eq(reason,'colors',edition..' native map reload identifies display change') + self.map=ML.load(d,id);self.tiles=TR.new(self.map,d);reloads=reloads+1 + end} + for _,choice in ipairs({{'gbc',A},{'dmg',GRAY},{'classic',GRAY},{'custom',R}})do + local name,wanted=choice[1],choice[2] + local oldMap=Game.overworld.map + if name=='custom' then P.setDisplay(name,R)else P.setDisplay(name,nil)end + T.eq(G.mode,name,edition..' native/public mode agrees '..name) + T.eq(G.customRamp,name=='custom'and R or nil,edition..' public ramp is actual native ramp') + if name~='gbc' then T.neq(Game.overworld.map,oldMap,edition..' current map cache replaced '..name)end + local tiles=TR.new(ML.load(d,'DISPLAY'),d) + pixels(tiles.image,wanted,0,false,edition..' actual atlas '..name) + local roof=name=='gbc'and{B[1],{53,63,73},{83,93,103},B[4]}or wanted + pixels(tiles.image,roof,8,false,edition..' actual roof '..name) + T.check(tiles.anims and tiles.anims[1],edition..' native water animation exists') + pixels(tiles.anims[1].textures[1],wanted,0,false,edition..' actual animated tile '..name) + pixels(sprite:resolveImage(),wanted,0,true,edition..' retained native sprite '..name) + pixels(battle:picImage(originalPic),wanted,0,false,edition..' retained native battle picture '..name) + battle.fx={bgp={[0]=3,2,1,0}} + pixels(battle:picImage(originalPic),{wanted[4],wanted[3],wanted[2],wanted[1]},0,false,edition..' battle fade after display '..name) + battle.fx=nil;battle.blackedOut=true + pixels(battle:picImage(originalPic),{wanted[4],wanted[4],wanted[4],wanted[4]},0,false,edition..' battle blackout '..name) + battle.blackedOut=false + if name=='classic' then T.eq(G.presentColors(),P.CLASSIC,edition..' Classic has one final green ramp') + else T.eq(G.presentColors(),nil,edition..' mode has no extra green pass '..name)end + end + local count=reloads;local assetCount=assetChanges;G.setCustomRamp(R);T.eq(reloads,count,edition..' identical ramp avoids reload') + T.eq(assetChanges,assetCount,edition..' identical ramp avoids shared asset churn') + local oldKey=P.displayKey();R[2][1]=R[2][1]+1;G.setCustomRamp(R) + T.neq(P.displayKey(),oldKey,edition..' edited ramp changes native cache key') + T.eq(reloads,count+1,edition..' reapplying edited ramp refreshes current map once') + T.eq(assetChanges,assetCount+1,edition..' edited ramp notifies registered external caches once') + pixels(sprite:resolveImage(),R,0,true,edition..' sprite rebakes edited ramp') + G.setBgp(0xff) + pixels(TR.new(ML.load(d,'DISPLAY'),d).image,R,0,false,edition..' transient BGP never baked into atlas') + G.setBgp(nil);P.setDarkWorld(true) + pixels(sprite:resolveImage(),{R[3],R[4],R[4],R[4]},0,true,edition..' custom world darkness preserved') + P.setDarkWorld(false) + T.eq(G.cycle(), 'dmg',edition..' custom cycle returns to native ladder') + T.eq(G.customRamp,nil,edition..' cycling removes custom ramp') + T.eq(G.cycle(),'classic',edition..' DMG advances to Classic') + T.eq(G.cycle(),'gbc',edition..' Classic advances to GBC') + T.eq(G.cycle(-1),'classic',edition..' backward cycle supported') + count=reloads;assetCount=assetChanges;G.applyOptions({color='custom',palette='m:80a0c0'}) + T.eq(reloads,count+1,edition..' saved mode and palette applied atomically') + T.eq(assetChanges,assetCount+1,edition..' saved mode and palette flush shared assets once') + T.same(G.customRamp,{{128,160,192},{92,115,138},{49,61,73},{15,19,23}},edition..' saved RGB palette reaches native owner') + local opts={color='custom',colors='custom',palette='m:80a0c0'} + P.writeColorOption(opts,P.cycleMode());T.eq(opts.palette,nil,edition..' manual cycle clears saved palette') + T.eq(opts.color,'dmg',edition..' legacy option tracks actual new mode');T.eq(opts.colors,'og',edition..' native option tracks same mode') + G.applyOptions(opts);T.eq(G.mode,'dmg',edition..' reapply cannot revive stale custom selection') + local menuGame={data=d,save={options=opts}} + local menu=require('src.ui.OptionsMenu').new(menuGame) + local row;for _,candidate in ipairs(menu.rows)do if candidate.id=='colors'then row=candidate end end + T.check(row,edition..' actual native options has display row') + T.eq(row.value(menuGame),'DMG',edition..' options reports actual native selection') + row.step(menuGame,1) + T.eq(opts.color,'classic',edition..' options persists legacy Classic selection') + T.eq(opts.colors,'classic',edition..' options persists same native selection') + T.eq(G.mode,'classic',edition..' actual options changes native renderer mode') + T.eq(row.value(menuGame),'CLASSIC',edition..' options reports switched mode') + row.step(menuGame,-1);G.applyOptions(opts) + T.eq(G.mode,'dmg',edition..' backwards options choice survives apply') + Game.overworld=nil +end +V.set('emerald');P.setDisplay('classic',R);T.eq(P.displayColors(A),A,'Gen3 palette behavior preserved');T.eq(P.presentColors(),nil,'Gen3 receives no Classic pass') +V.set('red');P.setDisplay('classic',nil);T.eq(P.displayColors(A),P.CLASSIC,'Gen1 Classic remains its original palette path') +T.finish('native Gen2 modes, cached atlas/roof/animation/OBJ/battle bakes and saved options') diff --git a/tests/engine/gen2_hp_pixels.lua b/tests/engine/gen2_hp_pixels.lua new file mode 100644 index 000000000..5be0e452d --- /dev/null +++ b/tests/engine/gen2_hp_pixels.lua @@ -0,0 +1,117 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local Data=require('src.core.Data') +local Hud=require('src.render.HudTiles') +local Palette=require('src.render.PaletteFX') +local Battle=require('src.battle.BattleState') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local previous,oldFont=Version.get(),Data.font +local function fixture(version) + Version.set(version) + local data=T.fixtures.fresh();data.constants.gen=Version.generation(version) + require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + local b=Battle.newWild({data=data,save=save,stack={top=function()end}},'FIXMON_C',23) + b.queue,b.current={},nil + b.introSlide,b.introBalls,b.enemySendingOut,b.showEnemyTrainer=0,nil,nil,nil + b.showPlayerBack,b.sendingOut=nil,nil + return b +end +local function tiles(screen) + local actual=Hud.tile;local rows={} + -- Capture only the final native tile output. HP, palette selection and + -- queue stepping are real; no battle or HP implementation is mocked. + Hud.tile=function(code,x,y,_,paint) rows[#rows+1]={code=code,x=x,y=y,paint=paint}end + local ok,why=pcall(screen.drawHUDs,screen,0) + Hud.tile=actual;assert(ok,why) + return rows +end +local function fill(rows,side) + local x,y=side=='enemy' and 32 or 96,side=='enemy' and 16 or 72 + local pixels=0 + for _,row in ipairs(rows)do + if row.y==y and row.x>=x and row.x=0x63 and row.code<=0x6b then + pixels=pixels+row.code-0x63 + end + end + return pixels +end +for _,edition in ipairs({'gold','silver','crystal'})do + local b=fixture(edition) + local mon=b.enemy.mon + mon.stats.hp,mon.hp,b.enemy.shownHP=19,16,19 + b:drainNext(b.enemy);b:updateQueue() + T.eq(b:hudHp(mon,'enemy'),19,'public HUD reads real pre-drain displayed HP') + for i,expected in ipairs({47,47,46,46,45,45})do + b:updateQueuePresentation() + T.eq(b:hudHpPixels(mon,'enemy'),expected,edition..' public pixel view follows real animation') + T.eq(fill(tiles(b),'enemy'),expected,edition..' native HUD writes every short-loop pixel') + if i==1 then T.eq(b:hudHp(mon,'enemy'),19,'number repeats while actual fill moves')end + end + local frames=0 + while b:updateQueuePresentation()and frames<2000 do frames=frames+1 end + T.eq(b:hudHp(mon,'enemy'),16,'retired HUD keeps displayed native target') + T.eq(fill(tiles(b),'enemy'),40,'retired bar uses exact GSC pixel ratio') + mon.stats.hp,mon.hp,b.enemy.shownHP=383,190,190 + T.eq(b:hudHpPixels(mon,'enemy'),24,'public view retains high-HP byte-divisor rounding') + T.eq(fill(tiles(b),'enemy'),24,'native HUD retains high-HP byte-divisor rounding') + T.eq(Palette.barPalName(190,383),'GREENBAR','party/summary and battle agree on GSC half-HP green') + T.eq(Palette.barPalName(9,48),'REDBAR','GSC red ends below ten pixels') + T.eq(Palette.barPalName(10,48),'YELLOWBAR','GSC yellow begins at ten pixels') + T.eq(Palette.barPalName(23,48),'YELLOWBAR','GSC yellow ends below half HP') + T.eq(Palette.barPalName(24,48),'GREENBAR','GSC green begins at half HP') + T.eq(Palette.barPalName(19,19,23),'YELLOWBAR','zone color uses animated pixels while the number repeats') + -- Reading/drawing cannot run damage, spend PP, or advance the HP timeline. + local hp,anim=b.enemy.shownHP,b.hpAnim + tiles(b);tiles(b) + T.eq(b.enemy.shownHP,hp,'draws leave the displayed HP clock alone') + T.eq(b.hpAnim,anim,'draws preserve native animation identity') +end +do + local b=fixture('crystal');local mon=b.enemy.mon + mon.stats.hp,mon.hp,b.enemy.shownHP=19,3,19 + b:drainNext(b.enemy);b:updateQueue();b:updateQueuePresentation() + T.eq(b:hudHpPixels(mon,'enemy'),47,'native short loop starts its actual pixel cursor') + local native=b.stepHpAnim + b.stepHpAnim=function(self) + self.shownHp.enemy=self.shownHp.enemy-2 + return true + end + b:updateQueuePresentation() + T.eq(b:hudHp(mon,'enemy'),17,'wide decorator owns its displayed increment') + T.eq(b.hpAnim.bar,nil,'wide increment retires the stale cartridge pixel cursor') + T.eq(fill(tiles(b),'enemy'),42,'fallback HUD follows the wider displayed value') + b.stepHpAnim=native;b:updateQueuePresentation() + T.check(b:hudHp(mon,'enemy')<=17,'returning to native stepping never reverses damage display') + T.eq(fill(tiles(b),'enemy'),41,'native cursor resumes from the actual wider displayed value') + local frames=0 + while b:updateQueuePresentation()and frames<2000 do frames=frames+1 end + T.check(frames<2000,'display owner handoff still completes its actual queue beat') + T.eq(mon.hp,3,'handoff never changes resolved damage') +end +do + Version.set('crystal') + -- Keep imported hack geometry, including its explicit palette thresholds. + Data.font={hud={hpBarTiles=7,hpBarGreenPixels=28,hpBarYellowPixels=12}} + T.eq(Hud.hpPixels(383,383),56,'wider imported HUD retains all seven full segments') + T.eq(Hud.hpPixels(1,999),1,'wider living bar retains one pixel') + T.eq(Palette.barPalName(190,383),'GREENBAR','explicit hack threshold uses its scaled fill') + T.eq(Hud.geometry().hpBarYellowPixels,12,'explicit imported yellow threshold retained') + -- Changing editions with the SAME font object must not keep GSC defaults. + Data.font={hud={}} + T.eq(Hud.geometry().hpBarGreenPixels,24,'GSC defaults apply without imported override') + Version.set('red') + T.eq(Hud.geometry().hpBarGreenPixels,27,'Gen1 cache switches back to its own defaults') + T.eq(Palette.barPalName(24,48),'YELLOWBAR','Gen1 existing threshold retained') + Version.set('crystal') + T.eq(Hud.geometry().hpBarGreenPixels,24,'returning to GSC restores native defaults') +end +for _,edition in ipairs({'red','firered','emerald'})do + Version.set(edition);Data.font=nil + T.eq(Hud.hpPixels(190,383),23,edition..' existing ratio remains unchanged') + T.eq(Palette.barPalName(24,48),'YELLOWBAR',edition..' existing threshold remains unchanged') +end +Data.font=oldFont;Hud.invalidate();Version.set(previous) +T.finish('native GSC HUD pixel output, palette boundaries and live HP view') diff --git a/tests/engine/gen2_hp_presentation.lua b/tests/engine/gen2_hp_presentation.lua new file mode 100644 index 000000000..dc876db42 --- /dev/null +++ b/tests/engine/gen2_hp_presentation.lua @@ -0,0 +1,152 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Version = require('src.core.GameVersion') +local Battle = require('src.battle.BattleState') +local Pokemon = require('src.pokemon.Pokemon') +local Save = require('src.core.SaveData') +local Hp = require('src.battle.Gen2HpPresentation') +local previous = Version.get() +local function fixture(version) + Version.set(version or 'crystal') + local data = T.fixtures.fresh() + data.constants.gen = Version.generation(Version.get()) + require('src.render.Font').load(data) + local save = Save.newGame() + save.party = {Pokemon.new(data, 'FIXMON_A', 30), Pokemon.new(data, 'FIXMON_B', 18)} + local game = {data=data, save=save, stack={top=function()end}} + local screen = Battle.newWild(game, 'FIXMON_C', 23) + screen.queue, screen.current, screen.waitFrames = {}, nil, nil + return screen +end +local function start(screen, side, from, to, maximum, floor) + local b = screen[side] + b.mon.stats.hp, b.mon.hp, b.shownHP = maximum, to, from + screen:drainNext(b, floor) + T.eq(screen:updateQueue(), true, 'actual native queue dispatches the HP row') + T.eq(screen._gen2HpDrainBattler, b, 'drain row owns its actual battler') + return b +end +local function finish(screen) + local frames = 0 + while screen:updateQueuePresentation() and frames < 2000 do frames=frames+1 end + T.check(frames < 2000, 'native HP queue terminates') + T.eq(screen.hpAnim, nil, 'completed native row retires its presentation') + T.eq(screen._gen2HpDrainBattler, nil, 'completed row releases admission owner') + return frames +end + +T.eq(Hp.pixels(0, 19), 0, 'fainted bar is empty') +T.eq(Hp.pixels(1, 999), 1, 'living bar retains at least one pixel') +T.eq(Hp.pixels(191, 383), 24, 'high-HP one-byte divisor retains cartridge rounding') +T.eq(Hp.pixels(190, 383), 24, 'coarse high-HP divisor differs from exact ratio') + +-- These are actual shared-engine battlers and queue rows, not a second HP +-- controller. The short loop advances pixels even while the number repeats. +do + local s = fixture() + local enemy = start(s, 'enemy', 19, 16, 19) + local trace = {} + for i=1,6 do + T.eq(s:updateQueuePresentation(), true, 'native row holds each pixel beat') + trace[i]=s.shownHp.enemy + end + T.same(trace, {19,19,19,19,18,18}, 'short bar preserves two-frame pixel cadence') + finish(s) + T.eq(enemy.shownHP, 16, 'short bar reaches exact authoritative HP') + T.eq(enemy.mon.hp, 16, 'presentation never changes resolved damage') +end +for _, version in ipairs({'gold','silver','crystal','prism','polishedcrystal'}) do + for _, case in ipairs({{383,0,383}, {0,383,383}, {1,0,19}, {16,19,19}, {255,256,256}}) do + local s=fixture(version) + local b=start(s,'enemy',case[1],case[2],case[3]) + local exp=s.player.mon.exp + finish(s) + T.eq(b.shownHP,case[2],version..' damage/heal/faint reaches exact target') + T.eq(b.mon.hp,case[2],'animation does not resolve another damage command') + T.eq(s.player.mon.exp,exp,'HP presentation cannot award EXP') + end +end + +-- Later damage may already be resolved. Each queued multi-hit beat must stop +-- at its own floor; the other side waits for its own row as well. +do + local s=fixture() + s.player.mon.hp,s.player.shownHP=2,10 + local b=s.enemy + b.mon.stats.hp,b.mon.hp,b.shownHP=19,3,19 + s:drainNext(b,12) + s:drainNext(b,3) + s:drainNext(s.player) + s:updateQueue() + finish(s) + T.eq(b.shownHP,12,'first multi-hit row stops at its queued floor') + T.eq(s.player.shownHP,10,'enemy row cannot drain a later player row') + s:updateQueue();finish(s) + T.eq(b.shownHP,3,'next native row drains remaining multi-hit damage') + s:updateQueue();finish(s) + T.eq(s.player.shownHP,2,'player row owns its own drain') +end + +-- A mod may choose a wider display step only during the real admitted HP +-- callback. Its write changes the display, never the saved Pokemon. +do + local s=fixture() + local b=start(s,'enemy',383,0,383) + local calls=0 + s.stepHpAnim=function(self) + calls=calls+1 + local anim=self.hpAnim + T.eq(anim.side,'enemy','decorator sees the real queued side') + T.eq(anim.to,0,'decorator sees authoritative target') + T.raises(function() self.shownHp.player=0 end,'owned','cannot write another side') + T.raises(function() self.shownHp.enemy=0/0 end,'invalid','cannot write NaN') + T.raises(function() self.shownHp.enemy=384 end,'invalid','cannot exceed actual maximum') + self.shownHp.enemy=math.max(anim.to,self.shownHp.enemy-383/72) + if self.shownHp.enemy==anim.to then self.hpAnim=nil end + return true + end + finish(s) + T.check(calls>=72 and calls<=73,'wide display uses one native callback per frame') + T.eq(b.shownHP,0,'fractional wide display ends exactly') + T.eq(b.mon.hp,0,'wide display cannot undo resolved damage') + T.raises(function() s.shownHp.enemy=1 end,'owned','no write admission after row') +end +do + local s=fixture() + local b=start(s,'enemy',19,3,19) + local native=s.stepHpAnim + s.stepHpAnim=function(self) self.hpAnim=nil;return false end + T.eq(s:updateQueuePresentation(),true,'early decorator return cannot release damage row') + T.eq(b.shownHP,19,'early return did not fabricate display progress') + s.stepHpAnim=function() error('decorator fault') end + T.raises(function() s:updateQueuePresentation() end,'decorator fault','callback error remains visible') + T.raises(function() s.shownHp.enemy=3 end,'owned','error always releases write admission') + s.stepHpAnim=native + b.shownHP=18.5 + finish(s) + T.eq(b.shownHP,3,'returning from fractional display to cartridge stepping terminates') +end +do + local s=fixture() + local outgoing=start(s,'player',19,3,19) + s:updateQueuePresentation() + s:switchPlayerInto(Battle.POS.PLAYER_LEFT,s.game.save.party[2]) + local incoming=s.player.shownHP + finish(s) + T.neq(s.player,outgoing,'native switch replaces the actual battler') + T.eq(s.player.shownHP,incoming,'stale HP row cannot drain the incoming Pokemon') +end +for _,version in ipairs({'red','firered','emerald'}) do + local s=fixture(version) + local called=false + s.stepHpAnim=function() called=true;error('Gen2 callback used by another generation') end + s.enemy.shownHP=s.enemy.mon.hp + s.enemy.mon.hp=s.enemy.mon.hp-3 + local frames=0 + while s:stepHPDrain() and frames<2000 do frames=frames+1 end + T.check(frames>0 and frames<2000,version..' existing native drain completes') + T.eq(called,false,version..' retains its native presentation owner') + T.eq(s.shownHp,nil,version..' gets no Gen2 HP view') +end +Version.set(previous) +T.finish('native Gen2 admitted HP presentation and queue ownership') diff --git a/tests/engine/gen2_init_clock.lua b/tests/engine/gen2_init_clock.lua new file mode 100644 index 000000000..15e36e5cb --- /dev/null +++ b/tests/engine/gen2_init_clock.lua @@ -0,0 +1,148 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local UI=require('src.ui.Gen2InitClock');local Clock=require('src.core.Gen2Clock') +local Stack=require('src.core.StateStack');local Box=require('src.render.TextBox');local Choice=require('src.ui.ChoiceBox') +local Timing=require('src.core.Timing');local Screens=require('src.ui.Screens') +local Json=require('src.link.Json');local Save=require('src.core.SaveData') +local function game() + local g={data=T.fixtures.fresh(),save={player={name='QA'},flags={},options={}},input={queue={}}} + g.input.wasPressed=function(self,k)return self.queue[k]==true end;g.input.isDown=function()return false end + g.stack=setmetatable({}, {__index=Stack});g.stack:init() + g.data.text={OakTimeWokeUpText='QA opening page.\fQA next page.',OakTimeWhatTimeIsItText='QA choose hour.', + OakTimeHowManyMinutesText='QA choose minute.',OakTimeWhatHoursText='QA hour',OakTimeWhoaMinutesText='QA minute', + ['OakTimeWhatHoursText.OakTimeHoursQuestionMarkText']='?', + ['OakTimeWhoaMinutesText.OakTimeMinutesQuestionMarkText']='?', + ['OakText_ResponseToSetTime.OakTimeSoDarkText']=' QA night response.', + ['SetDayOfWeek.OakTimeWhatDayIsItText']='QA choose weekday.',['SetDayOfWeek.OakTimeIsItText']=' QA confirm?'} + return g +end +local function tick(g,key,n) + g.input.queue=key and {[key]=true}or{} + for _=1,n or 1 do g.stack:update(1/60)end + g.input.queue={} +end +local function untilState(g,fn,label) + for _=1,2500 do if fn()then return end;tick(g,'a')end + error('native widgets did not reach '..label) +end +local function picker(g,screen,phase) + untilState(g,function()return g.stack:top()==screen and screen.phase==phase and not screen.fade end,phase) +end +local function choice(g) + untilState(g,function()return getmetatable(g.stack:top())==Choice end,'YES/NO over native question') + return g.stack:top() +end +local function answer(g,yes) + choice(g);tick(g,yes and 'a'or'b');tick(g,nil,Timing.YES_NO_ANSWER) +end +T.eq(require('src.ui.gen2.InitClock'),UI,'public reference spelling resolves actual native screen') +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);local label=edition..' ' + T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'init_clock',label..' native speech opens with clock') + local g=game();local n=0;local parent={};g.stack:push(parent) + local ui=Screens.push(g,'Gen2InitClock',{fades=true,onDone=function()n=n+1;g.stack:pop()end}) + T.eq(getmetatable(ui),UI,label..' actual native screen factory used') + T.eq(Clock.isSet(g.save),false,label..' opening screen cannot set clock') + picker(g,ui,'hour') + T.eq(ui:question(),'QA choose hour.',label..' question uses imported native text') + T.eq(ui.hour,10,label..' initial picker has native default') + tick(g,'b');T.eq(g.stack:top(),ui,label..' B cannot abandon required picker') + for _=1,10 do tick(g,'down')end + tick(g,'down');T.eq(ui.hour,23,label..' hour wraps backwards through midnight') + tick(g,'up');T.eq(ui.hour,0,label..' hour wraps forward through midnight') + tick(g,'down');tick(g,'a');local c=choice(g) + T.eq(getmetatable(g.stack.states[#g.stack.states-1]),Box,label..' confirmation retains question under choice') + T.eq(Clock.isSet(g.save),false,label..' hour confirmation has not committed clock') + answer(g,false);T.eq(ui.phase,'hour',label..' declining returns to picker') + T.eq(ui.hour,23,label..' declining retains selected hour') + tick(g,'a');answer(g,true);picker(g,ui,'minute') + tick(g,'down');T.eq(ui.minute,59,label..' minute wraps backwards') + tick(g,'up');T.eq(ui.minute,0,label..' minute wraps forwards') + tick(g,'down');tick(g,'a');answer(g,false) + T.eq(ui.phase,'minute',label..' declining minutes returns without saving') + T.eq(Clock.isSet(g.save),false,label..' declining leaves save unchanged') + tick(g,'a');answer(g,true) + T.eq(ui.phase,'response',label..' both confirmed values are read back') + T.eq(ui:question():find('QA night response.',1,true)~=nil,true,label..' response uses imported native fragment') + T.eq(Clock.isSet(g.save),false,label..' response acknowledgement still owns commitment') + untilState(g,function()return n==1 end,'clock completion') + T.eq(g.stack:top(),parent,label..' completion restores exact parent') + T.eq(Clock.hour(g.save),23,label..' confirmed hour reaches native RTC') + T.eq(Clock.minute(g.save),59,label..' confirmed minute reaches native RTC') + local round=assert(Save.decode(Save.encode(g.save))) + T.eq(Clock.minute(round),59,label..' normal save codec preserves confirmed RTC') + ui:finish();ui:complete();T.eq(n,1,label..' duplicate finish cannot repeat callback') + -- Real special lowering and coroutine runner stay suspended through NO. + g=game();local Runner=require('src.script.ScriptRunner');local runner=Runner.new(g,{}) + local rows=assert(require('src.script.Gen2ScriptVM').compile({map_scripts={scripts={qa={ + {'special',37,'SetDayOfWeek'},{'end'}}}}},'qa')) + T.eq(#Runner.validate(rows),0,label..' native special lowers to valid production commands') + local completed=0;runner:run(rows,{onDone=function()completed=completed+1 end}) + local day=g.stack:top();T.eq(getmetatable(day),UI,label..' actual special opens shared native day mode') + T.eq(runner:isRunning(),true,label..' actual script waits for selection') + T.eq(day.day,0,label..' native day picker opens on Sunday') + tick(g,'down');T.eq(day.day,6,label..' weekday wraps backwards') + tick(g,'a');answer(g,false) + T.eq(runner:isRunning(),true,label..' NO cannot resume original script') + T.eq(g.save.rtc,nil,label..' NO cannot write weekday anchor') + tick(g,'up');T.eq(day.day,0,label..' weekday wraps forwards') + tick(g,'a');answer(g,true) + T.eq(Clock.weekday(g.save),0,label..' accepted special writes shared weekday') + T.eq(runner:isRunning(),false,label..' accepted special resumes real runner') + T.eq(completed,1,label..' real runner completes once') + day:finish();T.eq(completed,1,label..' repeated special finish cannot resume twice') + -- The production Oak step must yield to the same child and advance once. + g=game();local advanced=0;local speech={game=g,cfg={},advance=function()advanced=advanced+1 end} + g.stack:push(parent);require('src.ui.OakSpeech').runStep(speech,{kind='initclock'}) + local child=g.stack:top();T.eq(getmetatable(child),UI,label..' real Oak step opens native clock') + picker(g,child,'hour');tick(g,'a');answer(g,true);picker(g,child,'minute') + tick(g,'a');answer(g,true);untilState(g,function()return advanced==1 end,'Oak resumes') + T.eq(g.stack:top(),parent,label..' Oak clock completion preserves parent ownership') + T.eq(Clock.isSet(g.save),true,label..' actual Oak continuation follows committed RTC') + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/setclock/manifest.json']=Json.encode({id='setclock',name='Native clock UI client',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/setclock/main.lua']=[[ + local mod=...;local Screen=require('src.ui.gen2.InitClock') + mod.exports.make=function(game)return Screen.new(game,{autoConfirm=true})end + ]]} + local loaded=T.sdk.loadMods({'mods/setclock'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,label..' actual loader admits native clock UI client') + local own=game();local screen=loaded.loader.exports.setclock.make(own) + T.eq(getmetatable(screen),UI,label..' loaded client uses native owner instead of a stub') + own.stack:push(screen) + untilState(own,function()return screen.done end,'loaded clock UI completion') + T.eq(Clock.isSet(own.save),true,label..' loaded UI client commits native RTC') + T.eq(Clock.hour(own.save),10,label..' loaded UI client keeps native default hour') + require('src.mods.Loader').teardown();loaded.release() + end +end +-- The shared Oak class also serves other editions. Preserve their existing +-- music-before-build contract rather than changing Gen1 while adding GSC. +local Music=require('src.core.Music');local play=Music.play;local order +Music.play=function(...)order[#order+1]='music';return play(...)end +for _,edition in ipairs({'red','blue','yellow','prism','polishedcrystal','gold','silver','crystal'})do + V.set(edition);order={} + local native=edition=='gold'or edition=='silver'or edition=='crystal' + local speech={game=game(),cfg={},buildSteps=function() + order[#order+1]='build';return {{kind=native and'initclock'or'say'}} + end,advance=function()order[#order+1]='advance'end} + require('src.ui.OakSpeech').enter(speech) + T.same(order,native and{'build','advance'}or{'music','build','advance'},edition..' preserves appropriate native music/build ordering') + if native then + order={};speech.buildSteps=function()order[#order+1]='build';return {{kind='say'}}end + require('src.ui.OakSpeech').enter(speech) + T.same(order,{'build','music','advance'},edition..' explicit clock removal still starts professor music') + end +end +Music.play=play +for _,edition in ipairs({'prism','polishedcrystal'})do + V.set(edition);T.eq(require('src.ui.OakSpeech').gen2Steps()[1].id,'oak_welcome',edition..' keeps its own introduction') +end +for _,hour in ipairs({0,3,4,10,11,17,18,23})do + local response=UI.responseKey(hour) + T.eq(response,(hour<4 or hour>=18)and'soDark'or(hour<=10 and'overslept'or'yikes'),'hour response boundary '..hour) +end +V.set(version);Clock.resetObservations() +T.finish('native initial clock/day widgets, original Oak admission and actual script continuation') diff --git a/tests/engine/gen2_mail_bytecode.lua b/tests/engine/gen2_mail_bytecode.lua new file mode 100644 index 000000000..c876925a8 --- /dev/null +++ b/tests/engine/gen2_mail_bytecode.lua @@ -0,0 +1,108 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local original=V.get() +V.set('crystal') +local data=T.fixtures.fresh() +require('src.render.Font').load(data) +data.items.ITEM_158={id='ITEM_158',key='FLOWER_MAIL',name='Letter',pocket='ITEM'} +local Mail=require('src.pokemon.Gen2Mail');Mail.load(data) +local Pokemon=require('src.pokemon.Pokemon') +local function mon() + return Pokemon.new(data,T.fixtures.ids.species[1],5,function(lo)return lo end) +end +local game={data=data,save={},stack={states={}}} +function game.stack:push(screen)self.states[#self.states+1]=screen;if screen.enter then screen:enter()end end +function game.stack:pop()return table.remove(self.states)end +function game.stack:top()return self.states[#self.states]end +local function press(screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local function reset() + game.save={party={mon(),mon()},player={name='AUTHOR',id=17},inventory={ITEM_158=2},bagOrder={'ITEM_158'}} + game.stack.states={{parent=true}} +end +-- Exercise real party-menu callbacks and coroutine continuation, not a +-- substitute handler or a fabricated result variable. +local Runner=require('src.script.ScriptRunner') +local VM=require('src.script.Gen2ScriptVM') +for _,case in ipairs({ + {name='cancel',result=2}, {name='missing',result=3,pick=true}, + {name='wrong',result=0,pick=true,text='OTHER'}, + {name='last',result=4,pick=true,text='MATCH',last=true}, + {name='correct',result=1,pick=true,text='MATCH'}, +})do + reset();local carrier=game.save.party[1] + if case.last then game.save.party[2].hp=0 end + if case.text then carrier.item='ITEM_158';Mail.set(game.save,1,Mail.entry('ITEM_158',case.text,'R',2,carrier.species))end + local finish=0 + local scriptData={map_scripts={scripts={probe={{'checkpokemail',{message='MATCH'}},{'end'}}}}} + local rows=assert(VM.compile(scriptData,'probe')) + local runner=Runner.new(game,{}) + runner:run(rows,{onDone=function()finish=finish+1 end}) + T.check(runner:isRunning(),'mail check suspends for real party input: '..case.name) + T.eq(finish,0,'script does not continue before selection: '..case.name) + local picker=game.stack:top() + press(picker,case.pick and 'a' or 'b') + T.eq(runner.ctx.g2Var,case.result,'native bytecode result: '..case.name) + T.eq(finish,1,'script resumes once after real picker: '..case.name) + T.eq(runner:isRunning(),false,'mail script terminates: '..case.name) + T.eq(#game.save.party,case.result==1 and 1 or 2,'delivery changes party only for CORRECT: '..case.name) +end +reset() +local runner=Runner.new(game,{}) +local rows=assert(VM.compile({map_scripts={scripts={gift={{'givepokemail',{item='ITEM_158',message='GIFT'}},{'end'}}}}},'gift')) +runner:run(rows,{g2Var=91}) +T.eq(game.save.mail.party[2].message,'GIFT','compiled native gift targets the last party member') +T.eq(runner.ctx.g2Var,91,'gift does not overwrite the script result variable') + +-- Binary fixtures cover the pointer width which previously desynchronised +-- Gold/Silver and ensure mail data never enters the dialogue/script queues. +local Extractor=require('src.import.RomExtractorGen2') +local Rom=require('src.import.Rom');local Ops=require('src.import.Gen2ScriptOps') +local Writer=require('src.import.LuaWriter') +for _,version in ipairs({'gold','silver','crystal'})do + local codes={};for i,row in ipairs(Ops.commandsFor(version))do codes[row[1]]=i-1 end + local bytes={};for i=1,0x8000 do bytes[i]=0 end + local function put(at,values)for i,v in ipairs(values)do bytes[at+i]=v end end + put(0x4000,{codes.givepokemail,0x00,0x41,codes.checkpokemail,0x01,0x41,codes.setval,77,codes['end']}) + put(0x4100,{158,0x80,0x4e,0x81,0x50}) + local chunks={};for i=1,#bytes,256 do chunks[#chunks+1]=string.char(unpack(bytes,i,math.min(i+255,#bytes)))end + local extractor=setmetatable({version=version,rom=Rom.new(table.concat(chunks)),_gen2MailCharmap={['128']='A',['129']='B'}},Extractor) + local pool={scripts={},queue={},instructions=0,desyncs=0} + extractor:gen2DecodeScript(1,0x4000,'probe',pool) + local decoded=pool.scripts.probe + T.eq(#decoded,4,'word-sized mail operands retain following commands: '..version) + T.eq(decoded[1][2].item,'ITEM_158','gift stationery resolves to native ID: '..version) + T.eq(decoded[1][2].message,'A\nB','gift reads raw line break and terminator: '..version) + T.eq(decoded[2][2].message,'A\nB','expected message skips no first character: '..version) + T.eq(decoded[3][1],'setval','decoder remains aligned after both operands: '..version) + T.eq(decoded[3][2],77,'following command retains its actual byte: '..version) + T.eq(#pool.queue,0,'raw letters are not queued as scripts: '..version) + T.eq(pool.desyncs,0,'native mail bytecode has no desync: '..version) + local round=assert(loadstring(Writer.encodeSplit(pool)))() + T.eq(round.scripts.probe[2][2].message,'A\nB','actual LuaWriter retains semantic letter data: '..version) + T.eq(extractor:gen2MailOperand(1,0x8000,true),nil,'out-of-bank pointer is rejected: '..version) + T.eq(extractor:gen2MailOperand(1,0x7fff,true),nil,'truncated gift cannot read across bank boundary: '..version) +end +local run=T.sdk.loadMods({'mods/mail_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/mail_probe/manifest.json']='{"id":"mail_probe","name":"Mail probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/mail_probe/main.lua']=[[ + local mod=... + mod.exports.reads=0;mod.exports.writes=0 + mod.events:on("mail.read",function(e)mod.exports.reads=mod.exports.reads+1;mod.exports.message=e.message end) + mod.events:on("mail.written",function(e)mod.exports.writes=mod.exports.writes+1 end) + ]], +})}) +T.eq(#run.errors,0,'actual SDK mail event subscriptions load') +reset();Mail.load(data);Mail.compose(game.save,1,'PUBLIC',game.save.party[1],'ITEM_158') +Mail.lines(Mail.get(game.save,1));Mail.lines(game.save.mail.party[1]) +local exports=run.loader.exports.mail_probe +T.eq(exports.writes,1,'public SDK receives the native write once') +T.eq(exports.reads,1,'public SDK receives the native read once, not every redraw') +T.eq(exports.message,'PUBLIC','public SDK receives actual letter contents') +run.release() +V.set(original);Mail.load(nil) +T.finish('native GSC mail ROM bytecode, actual delivery runner and public SDK events') diff --git a/tests/engine/gen2_mail_ui.lua b/tests/engine/gen2_mail_ui.lua new file mode 100644 index 000000000..595126241 --- /dev/null +++ b/tests/engine/gen2_mail_ui.lua @@ -0,0 +1,153 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local original=V.get() +V.set('crystal') +local data=T.fixtures.fresh() +require('src.render.Font').load(data) +data.items.ITEM_158={id='ITEM_158',key='FLOWER_MAIL',name='Letter',pocket='ITEM'} +local Mail=require('src.pokemon.Gen2Mail');Mail.load(data) +local Pokemon=require('src.pokemon.Pokemon') +local function mon() + return Pokemon.new(data,T.fixtures.ids.species[1],5,function(lo)return lo end) +end +local game={data=data,save={},stack={states={}}} +function game.stack:push(screen)self.states[#self.states+1]=screen;if screen.enter then screen:enter()end end +function game.stack:pop()return table.remove(self.states)end +function game.stack:top()return self.states[#self.states]end +local function press(screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local function reset() + game.save={party={mon(),mon()},player={name='AUTHOR',id=17},inventory={ITEM_158=2},bagOrder={'ITEM_158'}} + game.stack.states={{parent=true}} +end +local Compose=require('src.ui.Gen2MailCompose') +reset() +local parent=game.stack:top();local inv,order=game.save.inventory,game.save.bagOrder +local cancelled=0 +local screen=Compose.new(game,{mon=game.save.party[1],item='ITEM_158',onCancel=function()cancelled=cancelled+1 end}) +game.stack:push(screen);screen.text='UNSENT';press(screen,'select') +T.eq(game.stack:top(),parent,'SELECT cancels the actual keyboard') +T.eq(game.save.inventory.ITEM_158,2,'cancel does not consume stationery') +T.eq(game.save.party[1].item,nil,'cancel leaves the held item unchanged') +T.eq(game.save.mail,nil,'cancel creates no pending letter') +T.eq(cancelled,1,'cancel callback runs once') +local completed=0 +screen=Compose.new(game,{mon=game.save.party[1],item='ITEM_158',onDone=function()completed=completed+1 end}) +game.stack:push(screen);screen.text=string.rep('X',33) +T.eq(#screen.glyphs,32,'actual native keyboard caps the message at 32 glyphs') +press(screen,'start') +T.eq(completed,0,'START selects END before completing the letter') +press(screen,'a') +T.eq(completed,1,'A on END completes once') +T.eq(game.stack:top(),parent,'composition closes its own screen exactly once') +T.eq(game.save.inventory,inv,'composition preserves live inventory identity') +T.eq(game.save.bagOrder,order,'composition preserves live acquisition order identity') +T.eq(game.save.inventory.ITEM_158,1,'composition consumes one stationery') +T.eq(game.save.party[1].item,'ITEM_158','composition gives the actual native held item') +T.eq(game.save.mail.party[1].message,string.rep('X',32),'actual keyboard result becomes the native letter') +T.eq(game.save.mail.party[1].authorId,17,'composition records the real trainer ID') +local Bag=require('src.inventory.Bag') +T.eq(Bag.takeHeld(game.save,game.save.party[1],data),nil,'ordinary take cannot destroy a held letter') +T.eq(game.save.mail.party[1].message,string.rep('X',32),'refused take keeps message') +reset() +local held='PREVIOUS_ITEM';data.items[held]={id=held,name='Held item',pocket='ITEM'} +game.save.party[1].item=held;game.save.inventory[held]=99 +local before=require('src.core.SaveData').encode(game.save) +T.eq(Mail.attachFromBag(game.save,1,'ITEM_158','FAIL',data),false,'full previous-item stack refuses the exchange') +T.eq(require('src.core.SaveData').encode(game.save),before,'failed exchange changes no persisted fields') +game.save.inventory[held]=98 +T.check(Mail.attachFromBag(game.save,1,'ITEM_158','SWAP',data),'previous held item can return to bag') +T.eq(game.save.inventory[held],99,'previous held item is returned exactly once') +reset() +local gone=game.save.party[1] +screen=Compose.new(game,{mon=gone,item='ITEM_158'});game.stack:push(screen) +game.save.party[1]=mon();screen.text='STALE';press(screen,'start');press(screen,'a') +T.eq(game.save.inventory.ITEM_158,2,'stale keyboard target consumes no stationery') +T.eq(game.save.party[1].item,nil,'stale target cannot affect a replacement mon') +T.eq(game.save.mail,nil,'stale target creates no letter') + +-- Native party switches keep letters pinned to their actual carriers. +reset() +local PartyMenu=require('src.ui.PartyMenu') +local carrier=game.save.party[1];carrier.item='ITEM_158' +Mail.set(game.save,1,Mail.entry('ITEM_158','CARRIER')) +local partyScreen=PartyMenu.new(game,{}) +game.stack:push(partyScreen);partyScreen.swapFrom=1;partyScreen.index=2;press(partyScreen,'a') +T.eq(game.save.party[2],carrier,'actual party screen reorders the carrier') +T.eq(game.save.mail.party[2].message,'CARRIER','actual party screen moves its letter') +T.eq(game.save.mail.party[1],nil,'actual party screen clears the old mail slot') + +-- Mailbox actions use the same save and preserve entries on failed moves. +local Mailbox=require('src.ui.Gen2MailboxMenu') +reset();game.save.mail={party={},box={Mail.entry('ITEM_158','ONE','A'),Mail.entry('ITEM_158','TWO','B'),Mail.entry('ITEM_158','THREE','C')}} +local list=Mailbox.new(game);game.stack:push(list);list.index=2 +list:attachMail() +local picker=game.stack:top();press(picker,'a') +T.eq(game.save.mail.party[1].message,'TWO','real mailbox GIVE attaches the selected letter') +T.eq(game.save.mail.box[2].message,'THREE','real mailbox compacts after middle deletion') +T.eq(list:selected().message,'THREE','native cursor follows the compacted mailbox index') +game.stack.states={parent,list};list.message=nil;list.index=2;list:attachMail() +picker=game.stack:top();press(picker,'a') +T.eq(#game.save.mail.box,2,'occupied target preserves the mailbox') +T.eq(game.save.mail.party[1].message,'TWO','failed mailbox attachment preserves held letter') +game.stack.states={parent,list};list.message=nil;list.index=2;list:putInPack() +press(list,'b') +T.eq(#game.save.mail.box,2,'declining message loss retains mailbox contents') +T.eq(game.save.inventory.ITEM_158,2,'declining message loss adds no stationery') + +-- The compatibility PC's dismissal is caller-owned; its close callback may +-- pop exactly one screen, as existing Gen2 hosts do. +require('src.core.ModCompat').install() +local BoxMenu=require('src.ui.gen2.BoxMenu') +T.eq(BoxMenu,require('src.ui.Gen2BoxMenu'),'legacy Gen2 PC namespace reaches the actual native factory') +reset();parent=game.stack:top();local calls=0;local pc +pc=BoxMenu.new(game,{onClose=function() + calls=calls+1;T.eq(game.stack:top(),pc,'PC remains on stack during caller-owned close');game.stack:pop() +end}) +game.stack:push(pc);press(pc,'b') +T.eq(calls,1,'PC B invokes one close callback') +T.eq(game.stack:top(),parent,'PC does not pop the parent below its caller') +T.eq(pc.save,game.save,'native PC exposes the actual save required by Gen2 hosts') +local alternate={party={mon(),mon()}} +pc=BoxMenu.new(game,{save=alternate}) +T.eq(pc.save,alternate,'alternate-save native PC uses the supplied save') +T.eq(pc.game.writeSave,false,'alternate-save native PC cannot write the live profile') + +-- Unknown carriers are quarantined by the production save validator. Their +-- detached letters survive the real codec without duplicating stationery. +reset();local unknown=mon();unknown.species='REMOVED_MOD_SPECIES';unknown.item='ITEM_158' +game.save.party[2]=unknown +Mail.set(game.save,2,Mail.entry('ITEM_158','KEEP','MOD',9,unknown.species)) +local Save=require('src.core.SaveData') +local report=Save.validate(game.save,data) +T.eq(game.save.mail.box[1].message,'KEEP','missing-carrier letter is retained in native mailbox') +T.eq(unknown.item,nil,'quarantined carrier cannot duplicate detached stationery') +T.eq(#report.restoredMail,1,'mail recovery is reported') +T.eq(assert(Save.decode(Save.encode(game.save))).mail.box[1].message,'KEEP','quarantined letter survives actual save encoding') +local recovered={orphaned={mail={Mail.entry('ITEM_158','RECOVER')}}} +Mail.validate(recovered,{}) +T.eq(recovered.mail.box[1].message,'RECOVER','pending letters reclaim even when no live mail table exists') + +local run=T.sdk.loadMods({'mods/mail_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/mail_probe/manifest.json']='{"id":"mail_probe","name":"Mail probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/mail_probe/main.lua']=[[ + local mod=... + mod.exports.reads=0;mod.exports.writes=0 + mod.events:on("mail.read",function(e)mod.exports.reads=mod.exports.reads+1;mod.exports.message=e.message end) + mod.events:on("mail.written",function(e)mod.exports.writes=mod.exports.writes+1 end) + ]], +})}) +T.eq(#run.errors,0,'actual SDK mail event subscriptions load') +reset();Mail.load(data);Mail.compose(game.save,1,'PUBLIC',game.save.party[1],'ITEM_158') +Mail.lines(Mail.get(game.save,1));Mail.lines(game.save.mail.party[1]) +local exports=run.loader.exports.mail_probe +T.eq(exports.writes,1,'public SDK receives the native write once') +T.eq(exports.reads,1,'public SDK receives the native read once, not every redraw') +T.eq(exports.message,'PUBLIC','public SDK receives actual letter contents') +run.release() +V.set(original);Mail.load(nil) +T.finish('native GSC mail keyboard, mailbox, party and PC menu transactions') diff --git a/tests/engine/gen2_menu_chrome.lua b/tests/engine/gen2_menu_chrome.lua new file mode 100644 index 000000000..f1fd4be61 --- /dev/null +++ b/tests/engine/gen2_menu_chrome.lua @@ -0,0 +1,79 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local P = require('src.render.PaletteFX') +local Font = require('src.render.Font') +local V = require('src.core.GameVersion') +local C = require('src.ui.Gen2Chrome') +local before = {mode=P.mode, shade=P.shadeMap(), version=V.get()} +require('src.core.ModCompat').install() +T.eq(require('src.ui.gen2.Chrome'),C,'public Gen2 name resolves actual native menu renderer') +Font.load(T.fixtures.fresh()) +local ramp={{250,240,230},{180,170,160},{100,90,80},{20,10,0}} +local map={[0]=1,[1]=3,[2]=0,[3]=2} +P.setShadeMap(map) +for _,version in ipairs({'gold','silver','crystal','red','firered','emerald'})do + V.set(version) + for _,mode in ipairs({'gbc','gbc_inv','og','og_inv','classic'})do + P.setMode(mode) + T.same(C.throughPalette(ramp),P.effectiveColors(ramp),'menu uses actual native mode/register '..version..'/'..mode) + -- Inversion belongs BEFORE the noncommuting shade map. Compare against + -- the native resolved display ramp with the shade register disarmed. + P.setShadeMap(nil);local settled=P.effectiveColors(ramp);P.setShadeMap(map) + local reversed={settled[4],settled[3],settled[2],settled[1]} + T.same(C.throughPalette(ramp,true),P.permute(reversed,map),'inverted text then hardware shade register '..version..'/'..mode) + T.same(C.rawPalette(ramp,true),{ramp[4],ramp[3],ramp[2],ramp[1]},'raw palette skips native display substitutions') + end +end +P.setShadeMap(nil);P.setMode('gbc');V.set('crystal') +local dpi=love.graphics.getDPIScale +for _,d in ipairs({1,1.5,2})do + love.graphics.getDPIScale=function()return d end -- output metrics only + local scale=C.fitScale(801,601) + T.eq(scale*d,math.floor(scale*d),'native panel pixels land on framebuffer grid') + local x,y=C.fitOrigin(801,601,scale) + T.check(x>=0 and y>=0 and x+160*scale<=801 and y+144*scale<=601,'native panel fits its actual viewport') +end +love.graphics.getDPIScale=dpi +T.eq(C.number(23,4,false),' 23','native padded number') +T.eq(C.number(23,4,true),'0023','native zero-filled number') +T.eq(C.money(23),' ¥23','native floating yen field') +T.same(C.wrap('ONE TWO\nTHREE',7),{'ONE TWO','THREE'},'cart hard line break retained') +local chosen,cancel,moved +local list=C.List.new({items={{label='ONE',value=1},{label='TWO',value=2,disabled=true},{label='THREE',value=3}},rows=2,onChoose=function(v)chosen=v end,onCancel=function()cancel=true end,onMove=function(v)moved=v end}) +local input=require('src.core.Input');input:init() +local function tap(key)input:keypressed(key);input:step();local out=list:update(input);input:keyreleased(key);input:step();return out end +T.check(tap('down'),'real native down edge consumed') +T.eq(list.index,2,'list moves once per native edge');T.eq(moved,2,'movement callback follows actual item') +tap('z');T.eq(chosen,nil,'disabled entry cannot be selected') +tap('down');T.eq(list.scroll,1,'native list scrolls into actual item') +tap('z');T.eq(chosen,3,'real native A selects current item') +tap('down');T.eq(list.index,1,'native list wraps at end') +tap('x');T.eq(cancel,true,'real native B cancels list') + +-- Real native box and font calls, with only the output APIs recorded. +local draw,rect=love.graphics.draw,love.graphics.rectangle +local drawn,filled=0,0 +love.graphics.draw=function(...)drawn=drawn+1;return draw(...)end +love.graphics.rectangle=function(...)filled=filled+1;return rect(...)end +C.clear();C.textbox(0,12,18,4);C.print('ONE',1,13);C.cursor(1,1);list:draw() +T.check(drawn>0 and filled>0,'native chrome actually renders font, box and paper') +local shader={tag='caller'};love.graphics.setShader(shader) +T.raises(function()C.withPanel(800,600,1,1,1,function()love.graphics.setShader({});error('panel body probe')end)end,'panel body probe','panel propagates actual draw failure') +T.eq(love.graphics.getShader(),shader,'panel restores native graphics state on failure') +love.graphics.setShader();love.graphics.draw,love.graphics.rectangle=draw,rect +-- Exercise the shaded path too; only the GPU uniform sink is replaced. +local nativeShader=P.shader +local uniforms={} +local output={send=function(_,key,value)uniforms[key]=value end} +P.shader=function()return output end +P.setShadeMap(map) +local caller={tag='native caller'};love.graphics.setShader(caller) +local expected=P.effectiveColors(ramp,true) +C.printThrough('ONE',1,1,ramp,true) +T.same(uniforms.c0,{expected[1][1]/255,expected[1][2]/255,expected[1][3]/255},'glyph shader receives once-resolved native paper') +T.same(uniforms.c3,{expected[4][1]/255,expected[4][2]/255,expected[4][3]/255},'glyph shader receives once-resolved native ink') +T.eq(love.graphics.getShader(),caller,'palette text restores actual caller shader') +T.eq(P.shadeMap(),map,'chrome does not replace native shade register') +P.shader=nativeShader;love.graphics.setShader() +P.setMode(before.mode);P.setShadeMap(before.shade);V.set(before.version) +T.finish('native Gen2 shared menu chrome') diff --git a/tests/engine/gen2_mod_naming.lua b/tests/engine/gen2_mod_naming.lua new file mode 100644 index 000000000..630d1ba21 --- /dev/null +++ b/tests/engine/gen2_mod_naming.lua @@ -0,0 +1,166 @@ +-- The public Gen1Recomp ID and caller-owned completion used by Anytime Rename. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Screens = require("src.ui.Screens") +local Font = require("src.render.Font") +local V = require("src.core.GameVersion") +local original = V.get() +V.set("gold") +local data = T.fixtures.fresh() +data.font.charmap[#data.font.charmap + 1] = { seq = "", code = 0xe1 } +Font.load(data) +local run = T.sdk.loadMods( + { "mods/rename_probe" }, + { + data = data, + fs = T.sdk.memfs({ + ["mods/rename_probe/manifest.json"] = [[{ + "id":"rename_probe", "name":"Rename probe", "version":"1.0.0", + "api":2, "entry":"main.lua", "affects_link":false + }]], + ["mods/rename_probe/main.lua"] = [[ + local mod = ... + mod.exports.open = function(game, opts) + return mod.ui.push(game, "Gen2NamingScreen", opts) + end + mod.hooks:wrap("ui.naming.grid", function(nextFn, grid, ctx) + local out = nextFn(grid, ctx) + if ctx.title == "LOCALIZED?" then + local copy = {} + for i, row in ipairs(out) do + copy[i] = {} + for j, cell in ipairs(row) do copy[i][j] = cell end + end + copy[1][1] = "Ä" + return copy + end + return out + end) + ]], + }), + } +) +T.eq(#run.errors, 0, "the API-2 screen probe loads") +local game = { data = data, save = { player = {} }, stack = { states = {} } } +function game.stack:push(s) + self.states[#self.states + 1] = s + if s.enter then + s:enter() + end +end +function game.stack:pop() + return table.remove(self.states) +end +function game.stack:top() + return self.states[#self.states] +end +local function press(screen, key) + game.input = { + wasPressed = function(_, k) + return k == key + end, + } + screen:update(1 / 60) +end +local open = run.loader.exports.rename_probe.open +for _, spec in ipairs({ + { "player", 7 }, + { "rival", 7 }, + { "mom", 7 }, + { "box", 8 }, + { "nickname", 10 }, +}) do + Screens.invalidate() + local parent = { name = "parent" } + game.stack.states = { parent } + local calls, result = 0, nil + local screen + screen = open(game, { + type = spec[1], + initial = "AB", + onDone = function(name) + calls, result = calls + 1, name + T.eq(game.stack:top(), screen, "the caller still owns the naming screen at completion") + game.stack:pop() -- exactly what the existing Gen1Recomp mod does + end, + }) + T.eq(screen.screenId, "Gen2NamingScreen", "the public screen ID resolves") + T.eq(screen.maxLength, spec[2], spec[1] .. " keeps its name limit") + T.eq(screen.text, "AB", "the initial name is editable") + press(screen, "b") + T.eq(screen.text, "A", "B deletes one initial glyph") + screen.row, screen.col = 0, 1 + press(screen, "a") + T.eq(screen.text, "AB", "native keyboard input updates the public text") + -- Gen2 START parks on END; only A on END completes the entry. + press(screen, "start") + T.eq(calls, 0, "START does not complete Gen2 name entry") + T.eq(screen:cursorCharacter(), "END", "START selects the native END target") + press(screen, "a") + T.eq(calls, 1, "completion calls the caller once") + T.eq(result, "AB", "completion returns the edited name") + T.eq(game.stack:top(), parent, "completion preserves the parent state") +end + +Screens.invalidate() +local parent = {} +game.stack.states = { parent } +local result +local screen = open(game, { + type = "nickname", + prompt = "LOCALIZED?", + initial = "", + maxLength = 2, + monName = "FIXMON", + onDone = function(name) + result = name + end, +}) +T.eq(screen.prompt, "LOCALIZED?", "explicit localized prompt survives") +T.eq(screen.monName, "FIXMON", "the nickname header is retained") +press(screen, "a") +T.eq(screen.text, "Ä", "the public grid hook supplies a Unicode glyph") +screen.row, screen.col = 0, 1 +press(screen, "a") +T.eq(screen.text, "ÄB", "the name limit counts glyphs rather than bytes") +press(screen, "start") +press(screen, "a") +T.eq(result, "ÄB", "Unicode completion keeps the full glyph") +T.eq(game.stack:top(), screen, "an observing callback does not implicitly close the screen") +press(screen, "b") +T.eq(screen.text, "Ä", "deletion keeps a multi-byte glyph whole") +screen.text = "ÄZ" +T.eq(screen.text, "Ä", "direct text assignment respects charmap glyphs and the limit") +T.eq(#screen.glyphs, 2, "the native keyboard receives two actual glyphs") + +-- The original, unqualified screen keeps its existing pop-before-callback API. +Screens.invalidate() +game.stack.states = { parent } +local ordinary = Screens.push(game, "NamingScreen", { + default = "RED", + onDone = function(name) + T.eq(game.stack:top(), parent, "ordinary NamingScreen still pops before callback") + T.eq(name, "RED", "ordinary NamingScreen retains its default-name behavior") + end, +}) +press(ordinary, "start") +T.eq(game.stack:top(), ordinary, "native START selects END before completion") +press(ordinary, "a") +T.eq(game.stack:top(), parent, "ordinary screen closes exactly once") + +-- A registry override remains authoritative for the compatibility ID. +Screens.invalidate() +local custom = { custom = true } +data.screens = data.screens or {} +data.screens.Gen2NamingScreen = { + new = function(_, opts) + T.eq(opts.initial, "OVERRIDE", "screen overrides receive original Gen2 options") + return custom + end, +} +T.eq(open(game, { initial = "OVERRIDE" }), custom, "registered screen overrides win") +T.eq(#run.errors, 0, "the native flows produce no mod errors") +run.release() +Screens.invalidate() +V.set(original) +T.finish("public Gen2 mod naming compatibility") diff --git a/tests/engine/gen2_mom_import.lua b/tests/engine/gen2_mom_import.lua new file mode 100644 index 000000000..5824fb69b --- /dev/null +++ b/tests/engine/gen2_mom_import.lua @@ -0,0 +1,104 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i);if name=='markerFor'then markerFor=value;break end;if not name then break end end +assert(markerFor) +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={MomItems_1={1,0x4000},['MomItems_1.End']={1,0x4010},MomItems_2={1,0x4010},['MomItems_2.End']={1,0x4028}, + ['CheckBalance_MomItem2.check_have_2300']={1,0x4100},hMoneyTemp={0,0xff80}, + ['Mom_GetScriptPointer.ItemScript']={1,0x4200},['Mom_GetScriptPointer.DollScript']={1,0x4201}, + PhoneContacts={1,0x4400},SpecialPhoneCallList={1,0x4418},NonTrainerCallerNames={1,0x4500}, + GotMoneyForWinningText={1,0x4300},SentSomeToMomText={1,0x4301},SentHalfToMomText={1,0x4302},SentAllToMomText={1,0x4303}} + put(0x4000,string.char(0,0,0,0,0,3,1,17,0,0,0,0,0,5,1,18, + 0,0,100,0,0,20,1,17,0,0,200,0,0,70,2,14,0,1,244,0,0,30,1,18)) + put(0x4100,string.char(0x21,0x80,0xff,0x36,0,0x23,0x36,0,0x23,0x36,17)) + put(0x4200,string.char(0x91,0x91)) + put(0x440c,string.char(0,1));put(0x4502,string.char(0x10,0x45));put(0x4510,string.char(128,0x50)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols});e._gen2ScriptTexts={} + function e:readSourceTable(key)assert(key=='charmap');return{['128']='Q'}end + return e +end +local function pool()return{scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local q=pool();local rows=e:gen2MomShopping(q) + T.eq(rows.complete,true,version..' actual Mom importer accepts bounded native tables') + local phone=e:gen2PhoneContacts(nil) + T.eq(phone[1].contactNumber,1,version..' actual non-trainer contact identity survives localized name extraction') + T.eq(phone[1].class,0,version..' actual imported Mom contact retains native non-trainer class') + T.eq(#rows.random,2,version..' random count comes from its end symbol') + T.eq(#rows.ladder,3,version..' ladder count comes from its end symbol') + T.eq(rows.ladder[3].trigger,500,version..' money trigger is a big-endian triple') + T.eq(rows.ladder[2].kind,2,version..' kind distinguishes real DECO byte from native item') + T.eq(rows.ladder[2].item,14,version..' row retains actual byte rather than a guessed species') + T.eq(rows.step,17,version..' random threshold is read from actual instructions, not a retail literal') + T.eq(#q.queue,2,version..' both original phone scripts enter native decode queue') + T.check(e._gen2ScriptTexts[rows.rewardTexts[1]]~=nil,version..' native reward prose reaches shared text importer') + e:gen2MomShopping(q);T.eq(#q.queue,2,version..' repeated import queues each script once') + for _,job in ipairs(q.queue)do e:gen2DecodeScript(job[1],job[2],job[3],q)end + T.eq(q.desyncs,0,version..' native queued scripts decode without desync') + T.check(markerFor(version):find('gen2%-mom%-shopping%-native%-v1:')~=nil,version..' new cache requires purchase data') + for _,edit in ipairs({ + function(s)s.MomItems_1=nil end, + function(s)s['MomItems_2.End']=nil end, + function(s)s['MomItems_1.End'][2]=0x4011 end, + function(s)s['MomItems_1.End'][1]=2 end, + function(s)s['MomItems_2.End'][2]=0x400f end, + function(s,put)put(0x4000,string.char(255,255,255))end, + function(s,put)put(0x4003,string.char(255,255,255))end, + function(s,put)put(0x4006,string.char(3))end, + function(s,put)put(0x4007,string.char(0))end, + function(s,put)put(0x4104,string.char(0))put(0x4107,string.char(0))put(0x410a,string.char(0))end, + function(s)s.hMoneyTemp[2]=0xff81 end, + function(s)s['CheckBalance_MomItem2.check_have_2300']=nil end, + function(s)s['Mom_GetScriptPointer.DollScript']=nil end, + function(s)s['Mom_GetScriptPointer.ItemScript'][2]=0x100 end, + })do + local bad=fixture(version,edit);local q=pool();local result=bad:gen2MomShopping(q) + T.eq(result.complete,false,version..' malformed or incomplete input cannot claim a native purchase binding') + T.eq(result.random,nil,version..' malformed binding publishes no partial retail table') + T.eq(#q.queue,0,version..' invalid binding does not partially mutate script queue') + end +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + local q=pool();T.eq(fixture(version):gen2MomShopping(q),nil,version..' unreviewed layout has no inferred retail shopping') + T.eq(#q.queue,0,version..' unrelated script queue stays unchanged') + T.check(not markerFor(version):find('gen2%-mom%-shopping%-native%-v1:'),version..' unrelated cache remains unchanged') +end +local Service=require('src.world.Gen2PhoneService');local Writer=require('src.import.LuaWriter') +local function extractor(version,palette,compact) + local raw=string.rep('\0',0x8000) + local function put(at,v)raw=raw:sub(1,at)..string.char(v)..raw:sub(at+2)end + put(0x4000,2);put(0x4001,0x40) + local header=compact and {1,2,0,0x50,1,1,palette}or{1,1,2,0,0x50,1,1,palette,0} + for i,v in ipairs(header)do put(0x4001+i,v)end + local layout=compact and {mapHeaderBytes=7,mapHeaderAttrAt=2,mapHeaderTilesetAt=0,mapHeaderEnvAt=1,mapHeaderLandmarkAt=4,mapHeaderMusicAt=5,mapHeaderPaletteAt=6,mapHeaderFishAt=-1,mapAttributesBank=2}or{} + return E.new(raw,version,{symbols={MapGroupPointers={1,0x4000},Native_MapAttributes={compact and 2 or 1,0x5000}},layout=layout}) +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for raw=0,255 do + local e=extractor(version,raw);local _,labels=e:gen2MapIndex();local header=assert(labels.Native) + T.eq(header.palette,raw%16,'native header retains the unchanged palette nibble') + T.eq(header.paletteBits,raw,'native header retains the complete real byte') + T.eq(header.phoneService,raw<16,'native header decodes the actual no-service nibble') + T.eq(Service.available({mapPalette=header.palette,mapPaletteBits=header.paletteBits,phoneService=header.phoneService}),raw<16,'runtime consumes the native imported no-service verdict') + end + for _,raw in ipairs({1,0x11,0xF4})do + local e=extractor(version,raw);local maps={NATIVE={id='NATIVE',label='Native',width=1,height=1,warps={}}};local encoded + -- Input table/file output are IO seams. Header lookup, map lowering and + -- Lua serialization are the actual importer, including false booleans. + function e:readSourceTable(name)return name=='maps'and maps or{}end + function e:write(name,value)if name=='maps'then encoded=Writer.encode(value)end end + e:extractMapsFromRom();local decoded=assert(loadstring(assert(encoded)))().NATIVE + T.eq(decoded.mapPalette,raw%16,'full map lowering preserves the palette rule') + T.eq(decoded.mapPaletteBits,raw,'full map lowering retains header bits') + T.eq(decoded.phoneService,raw<16,'serialization does not lose an explicit no-service false') + end +end +V.set(old);T.finish('native Mom table boundaries, threshold instruction operands, script queues and cache revision') diff --git a/tests/engine/gen2_mom_shopping.lua b/tests/engine/gen2_mom_shopping.lua new file mode 100644 index 000000000..a140cce25 --- /dev/null +++ b/tests/engine/gen2_mom_shopping.lua @@ -0,0 +1,201 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Mom=require('src.world.Gen2MomShopping');local Native=require('src.world.Gen2Decorations') +local Flags=require('src.script.Gen2Flags');local Runner=require('src.script.ScriptRunner') +local W=require('src.world.OverworldController');local Stack=require('src.core.StateStack') +local Map=require('src.world.Map');local Pokemon=require('src.pokemon.Pokemon') +local Battle=require('src.battle.BattleState');local S=require('src.save_convert.Gen2Save') +local E=require('src.import.RomExtractorGen2');local J=require('src.link.Json') +local Sound=require('src.core.Sound');local oldSound=Sound.play;Sound.play=function()end -- audio device IO +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.afterBattle,i);if name=='Game'then slot=i;oldGame=value;break end end +assert(slot) +local function data() + local d=T.fixtures.fresh() + d.items.ITEM_017={id='ITEM_017',index=17,name='SYNTHETIC A',pocket='ITEM'} + d.items.ITEM_018={id='ITEM_018',index=18,name='SYNTHETIC B',pocket='ITEM'} + d.map_scripts={momShopping={complete=true,step=17, + random={{trigger=0,cost=3,kind=1,item=17},{trigger=0,cost=5,kind=1,item=18}}, + ladder={{trigger=100,cost=20,kind=1,item=17},{trigger=200,cost=70,kind=2,item=14},{trigger=500,cost=30,kind=1,item=18}}, + programs={item='ITEM_PHONE',doll='DOLL_PHONE'},rewardTexts={[0]='REWARD0',[1]='REWARD1',[2]='REWARD2',[3]='REWARD3'}}, + phone={[3]={class=0,contactNumber=1,name='SYNTHETIC MOM'}}, + decorations={[14]={type=1,name='SYNTHETIC DOLL',action='SET_UP_DOLL',flag=900,sprite=18}}, + decorationInfo={complete=true},scripts={BANK={{'special',0,'BankOfMom'},{'end'}},ITEM_PHONE={{'setevent',901},{'end'}},DOLL_PHONE={{'setevent',902},{'end'}}}} + d.text={REWARD0='SYNTHETIC {PLAYER} TOTAL {NUM}',REWARD1='SYNTHETIC SOME {PLAYER} TOTAL {NUM}', + REWARD2='SYNTHETIC HALF {PLAYER} TOTAL {NUM}',REWARD3='SYNTHETIC ALL {PLAYER} TOTAL {NUM}'} + return d +end +local function save(balance) + return{flags={},money=0,g2MomMoney=balance or 0,inventory={},pcItems={},party={},player={name='QA'},pokedex={seen={},owned={}}} +end +local function buy(d,s,opts) + opts=opts or{};opts.phoneService=opts.phoneService~=false;return Mom.tryBuy(d,s,opts) +end +local function game(d,s) + local def={id='QA_MOM',generation=2,width=2,height=2,blocks={1,1,1,1},warps={},mapPalette=1,phoneService=true} + local ts={id='FLOOR',blockCells=2,collision={255,255,255,255,0,0,0,0},walkable={0},grassTiles={},waterTiles={}} + d.maps.QA_MOM=def + local w=setmetatable({map=Map.new(def,ts),player={cellX=0,cellY=0,facing='down'},scriptMoves={},pendingScripts={},npcs={}},{__index=W}) + local stack=setmetatable({states={w}},{__index=Stack}) -- world already entered in this fixture + local g={data=d,save=s,stack=stack,world=w,overworld=w,input={wasPressed=function()return false end}};w.runner=Runner.new(g,w) + debug.setupvalue(W.afterBattle,slot,g);return g,w +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for mode=0,3 do + for _,coin in ipairs({1,2})do + local s=save(100);s.money=10;s.g2MomSavingMode=mode + local quarters=({[0]=0,1,2,4})[mode];local award=Mom.reward(s,900*coin) + T.eq(s.g2MomMoney,100+225*coin*quarters,version..' native mode '..mode..' sends actual quarters to Mom') + T.eq(s.money,10+225*coin*(4-quarters),version..' wallet receives only its actual share') + T.eq(award.total,900*coin,version..' displayed total remains complete prize') + T.eq(award.mode,mode,version..' native text uses actual savings mode') + end + end + local s=save(999999);s.g2MomSaving=true + T.eq(Mom.reward(s,900).mode,0,version..' maxed savings disables skimming') + T.eq(s.money,900,version..' maxed savings sends all quarters to wallet') + s=save(999998);s.g2MomSaving=true;s.money=999998 + Mom.reward(s,900) + T.eq(s.g2MomMoney,999999,version..' near-cap Mom account saturates') + T.eq(s.money,999999,version..' wallet saturates separately') + s=save();s.g2MomSaving=true;s.flags.ENGINE_MOM_SAVING_MONEY=false + T.eq(Mom.mode(s),0,version..' explicit native flag overrides legacy saving boolean') + s.g2MomSavingMode=2;T.eq(Mom.mode(s),2,version..' HALF bit survives an unset SOME bit') + s.flags.ENGINE_MOM_SAVING_MONEY=true;T.eq(Mom.mode(s),3,version..' native setflag changes only bit zero') + Mom.setSaving(s,false);T.eq(Mom.mode(s),0,version..' bank stop clears saving mode') + Mom.openBank(s);Mom.setSaving(s,true) + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' bank opening reaches native active flag') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' bank start reaches native SOME flag') + local previous=save();previous.g2MomBankOpen=true;previous.g2MomSaving=true + Mom.prepareBank(previous) + T.eq(previous.flags.ENGINE_MOM_ACTIVE,true,version..' earlier native preview account migrates to active flag') + T.eq(previous.flags.ENGINE_MOM_SAVING_MONEY,true,version..' earlier native preview saving choice migrates to native flag') + previous.flags.ENGINE_MOM_ACTIVE=false;Mom.prepareBank(previous) + T.eq(previous.g2MomBankOpen,nil,version..' explicit native reset overrides legacy open boolean') + local legacy=data();legacy.map_scripts.phone[3].contactNumber=nil;legacy.map_scripts.phone[3].trainer=1 + T.eq(Mom.caller(legacy),3,version..' original non-trainer contact byte remains supported') + local d=data();s=save() + local bankGame,bankWorld=game(d,s) + local bank=Runner.new(bankGame,bankWorld);bank:run(assert(require('src.script.Gen2ScriptVM').compile(d,'BANK'))) + local box=bankGame.stack:top();box.done=true;box:update(0) + local yes=bankGame.stack:top();bankGame.input={wasPressed=function(_,key)return key=='a'end};yes.index=1;yes:update(0);bankGame.input={wasPressed=function()return false end};for _=1,20 do if bankGame.stack:top()==yes then yes:update(0)end end + local feedback=bankGame.stack:top();feedback.done=true;bankGame.input={wasPressed=function(_,key)return key=='a'end};feedback:update(0) + T.eq(bank:isRunning(),false,version..' actual BankOfMom UI completes first saving choice') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' actual BankOfMom UI writes native SOME flag') + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' actual BankOfMom UI opens native account') + T.eq(Mom.mode(s),1,version..' actual BankOfMom UI affects reward allocation mode') + local d=data();s=save(100) + local purchase=buy(d,s) + T.eq(purchase.kind,1,version..' first ladder purchase is native item') + T.eq(s.pcItems.ITEM_017,1,version..' native item byte resolves current dataset') + T.eq(s.g2MomMoney,80,version..' price deducted once') + T.eq(s.g2MomShopping.whichItem,1,version..' ladder advances only on delivery') + T.eq(s.g2MomShopping.whichSet,0,version..' ladder uses native zero set') + T.eq(purchase.caller,3,version..' Mom caller is current native contact identity') + s.g2MomMoney=200;purchase=buy(d,s) + T.eq(s.flags[Flags.eventFlag(900)],true,version..' doll purchase grants actual ownership flag') + T.eq(s.g2MomMoney,130,version..' doll price is deducted') + T.eq(s.g2MomShopping.whichItem,2,version..' doll advances ladder once') + T.eq(s.decorations,nil,version..' doll ownership does not place it in the room') + local g,w=game(d,s);local r=Runner.new(g,w);r:run(purchase.script) + T.eq(s.flags[Flags.eventFlag(902)],true,version..' doll notification runs native compiled role') + s=save(101);purchase=buy(d,s) + T.check(purchase~=nil,version..' ladder takes precedence over random threshold') + s=save(34);s.g2MomShopping={whichItem=255,triggerBalance=17} + local called=0;purchase=buy(d,s,{random=function(n)called=called+1;return n-1 end}) + T.eq(purchase.item,18,version..' exact multiple selects current random table row') + T.eq(called,1,version..' random purchase consumes one bounded roll') + T.eq(s.g2MomShopping.triggerBalance,51,version..' consumed exact threshold advances once') + T.eq(s.g2MomShopping.whichSet,2,version..' native random set is selected row plus one') + T.eq(s.g2MomShopping.whichItem,255,version..' random buy cannot consume ladder index') + T.eq(s.g2MomMoney,29,version..' random purchase deducts its own price') + s=save(35);s.g2MomShopping={whichItem=255,triggerBalance=17} + T.eq(buy(d,s),nil,version..' overshot multiple cannot buy') + T.eq(s.g2MomShopping.triggerBalance,51,version..' overshot threshold advances above current balance') + s=save(100);d.field.pcItemCap=1;s.pcItems.ITEM_018=99 + T.eq(buy(d,s),nil,version..' full PC rejects new stack') + T.eq(s.g2MomMoney,100,version..' refusal does not spend money') + T.eq(s.g2MomShopping.whichItem,0,version..' refusal does not advance ladder') + s.pcItems={ITEM_017=98};T.check(buy(d,s)~=nil,version..' existing stack with room accepts purchase') + T.eq(s.pcItems.ITEM_017,99,version..' item reaches last slot in actual stack') + s=save(100);s.pcItems.ITEM_017=99 + T.eq(buy(d,s),nil,version..' a full same-item stack needs another native PC stack') + s=save(200);s.g2MomShopping={whichItem=1,triggerBalance=17};s.pcItems.ITEM_018=99 + T.check(buy(d,s)~=nil,version..' doll ownership is independent of PC capacity') + s=save(100);T.eq(buy(d,s,{phoneService=false}),nil,version..' no reception suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' no reception cannot consume threshold or initialize purchase state') + T.eq(buy(d,s,{queued=true}),nil,version..' occupied native map-reentry queue suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' occupied queue cannot mutate purchase state') + d.map_scripts.scripts.ITEM_PHONE=nil + T.eq(buy(d,s),nil,version..' missing original phone program cannot grant a silent purchase') + T.eq(s.pcItems.ITEM_017,nil,version..' missing native program leaves inventory unchanged') + -- Actual battle win uses this account split and the cartridge-selected text. + d=data();require('src.battle.TypeChart').load(d);s=save(10) + s.party={Pokemon.new(d,'FIXMON_A',10)};s.g2MomSavingMode=1 + g,w=game(d,s) + local battle=Battle.newTrainer(g,'OPP_FIX_YOUNGSTER',1);battle.participants={} + for _,mon in ipairs(battle.enemyParty)do mon.hp=0 end + battle.enemyIndex=#battle.enemyParty;battle.enemy.mon=battle.enemyParty[battle.enemyIndex] + battle:enemyMonFainted() + T.eq(battle.result,'win',version..' real battle native win branch completes') + T.check(s.g2MomMoney>10,version..' real battle win actually increases Mom account') + T.eq(battle.g2MomAward.mode,1,version..' real battle records applied savings mode') + local found=false + for _,row in ipairs(battle.queue)do if row.text and row.text:find('SYNTHETIC SOME QA TOTAL ',1,true)then found=true end end + T.check(found,version..' real battle presents actual imported SOME reward text') + -- Actual world post-battle path commits once and waits behind native dialogue. + d=data();s=save(200);s.party={Pokemon.new(d,'FIXMON_A',10)} + s.g2MomShopping={whichItem=1,triggerBalance=17};g,w=game(d,s) + battle={kind='trainer',leveledUp={}} + w:afterBattle('win',battle) + T.eq(s.g2MomMoney,130,version..' actual trainer return purchases eligible doll') + T.check(w.g2MomCallPending~=nil,version..' actual trainer return defers original notification') + w:afterBattle('win',battle);T.eq(s.g2MomMoney,130,version..' repeated callback cannot buy twice') + w.pendingScripts={{script={{'end'}}}} + T.eq(w:checkMomShoppingCall(),false,version..' trainer after-dialogue queue keeps phone waiting') + w.pendingScripts={};w.player.moving=true + T.eq(w:checkMomShoppingCall(),false,version..' native phone does not interrupt a partial movement') + w.player.moving=false;g.stack:push({name='dialogue'}) + T.eq(w:checkMomShoppingCall(),false,version..' actual overlay owner keeps phone waiting') + g.stack:pop();T.eq(w:checkMomShoppingCall(),true,version..' idle native world queues original doll script') + T.eq(s.g2CurCaller,3,version..' deferred native call sets actual caller') + T.eq(w:checkMomShoppingCall(),false,version..' notification queues once') + local entry=w.pendingScripts[1];T.check(entry~=nil,version..' actual native FIFO receives notification') + if entry then w.runner:run(entry.script,entry.extra)end + T.eq(s.flags[Flags.eventFlag(902)],true,version..' actual queued original role executes') + T.eq(s.g2CurCaller,nil,version..' completed native call releases caller') + T.eq(s.g2CallCycles,0,version..' completed native call resets receive timer') + -- Physical SRAM offsets checked against the independent shipped symbols. + S.setLayout(version);S.setCharmap({['128']='A'}) + s.g2MomMoney=123456;s.g2MomSavingMode=2;s.g2MomSaving=true;s.g2MomBankOpen=true + s.flags.ENGINE_MOM_SAVING_MONEY=false;s.flags.ENGINE_MOM_ACTIVE=true + s.g2MomShopping={whichItem=2,whichSet=1,triggerBalance=98765} + local bytes=S.encode(s,d);local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(J.decode(f:read('*a')));f:close() + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + local function offset(key)return 0x2009+manifest.symbols[key][2]-anchor end + local function three(at)local a,b,c=bytes:byte(at+1,at+3);return a*65536+b*256+c end + T.eq(three(offset('wMomsMoney')),123456,version..' Mom account uses actual three-byte SRAM field') + T.eq(bytes:byte(offset('wMomSavingMoney')+1),130,version..' saving mode and active bit preserve native byte') + T.eq(bytes:byte(offset('wWhichMomItem')+1),2,version..' native ladder index exports at independent address') + T.eq(three(offset('wMomItemTriggerBalance')),98765,version..' native threshold exports at independent address') + local back=S.decode(bytes,d) + T.eq(back.g2MomMoney,123456,version..' native balance round-trips') + T.eq(Mom.mode(back),2,version..' HALF mode round-trips without becoming SOME') + T.eq(back.g2MomBankOpen,true,version..' bank active state round-trips') + T.eq(back.g2MomShopping.whichItem,2,version..' ladder progress round-trips') + T.eq(back.g2MomShopping.whichSet,1,version..' random row round-trips') + T.eq(back.g2MomShopping.triggerBalance,98765,version..' threshold progress round-trips') + local again=S.encode(back,d,bytes);local decoded=S.decode(again,d) + local good=true;for _,warning in ipairs(decoded.warnings)do if warning:find('checksum')then good=false end end + T.check(good,version..' Mom fields remain covered by valid native checksum') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local s=save(10);T.eq(Mom.reward(s,900),nil,version..' native GSC account split is isolated') + T.eq(Mom.tryBuy(data(),s,{phoneService=true}),nil,version..' foreign edition cannot buy retail GSC gifts') + S.setLayout(version);T.eq(S.OFFSETS.momMoney,nil,version..' unreviewed SRAM layout has no guessed Mom fields') +end +Sound.play=oldSound;debug.setupvalue(W.afterBattle,slot,oldGame);V.set(original);S.setLayout(original) +T.finish('native GSC Mom accounts, purchases, trainer return, phone queue and SRAM') diff --git a/tests/engine/gen2_native_namepick.lua b/tests/engine/gen2_native_namepick.lua new file mode 100644 index 000000000..d91a726e5 --- /dev/null +++ b/tests/engine/gen2_native_namepick.lua @@ -0,0 +1,69 @@ +-- Original integration contributions (c) 2026 Roxas2712; offered to UNDERdecoded under +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local edition=Version.get() +local Pick=require('src.ui.Gen2NamePick');local Naming=require('src.ui.Gen2NamingScreen') +local Stack=require('src.core.StateStack');local Screens=require('src.ui.Screens') +local data=T.fixtures.fresh() +local function game() + local g={data=data,save={player={gender='girl'}},stack=setmetatable({},{__index=Stack})} + g.stack:init();return g +end +local function tap(g,s,key)g.input={wasPressed=function(_,k)return key==k end};s:update()end +local function settle(g,s) + for _=1,8 do tap(g,s,'a')end + T.eq(s.picX,13);T.eq(s.slide,nil);T.eq(g.stack:top(),s,'slide blocks keyboard/menu input') +end +for _,edition in ipairs({'gold','silver','crystal'})do + Version.set(edition) + local g=game();local parent={};g.stack:push(parent);local done=0 + local s;s=Pick.new(g,{presets={'ONE','TWO'},onDone=function(name) + done=done+1;T.eq(name,'TWO');T.eq(g.stack:top(),s,'public picker leaves dismissal to its caller') + end});g.stack:push(s) + T.eq(s.picX,6);T.eq(s.cursor,1);settle(g,s) + tap(g,s,'b');T.eq(g.stack:top(),s);T.eq(done,0,'B cannot cancel the native name choice') + tap(g,s,'up');T.eq(s.cursor,3);tap(g,s,'a');T.eq(s.slide,'out');T.eq(done,0) + for x=12,6,-1 do tap(g,s,'a');T.eq(s.picX,x);T.eq(done,0,'preset waits for the return walk')end + tap(g,s,'a');T.eq(done,1);tap(g,s,'a');T.eq(done,1,'selection is once-only') + g=game();g.stack:push(parent);local received + s=Pick.new(g,{presets={'FALLBACK'},maxLength=8,onDone=function(name)received=name end});g.stack:push(s);settle(g,s) + tap(g,s,'start');local keyboard=g.stack:top() + T.eq(getmetatable(keyboard),Naming,'NEW NAME opens the real native Gen2 keyboard') + T.eq(keyboard.kind,Naming.TYPES.player);T.eq(keyboard.maxLength,8);T.eq(keyboard.isBox,false) + tap(g,keyboard,'a');tap(g,keyboard,'start');T.eq(received,nil);tap(g,keyboard,'a') + T.eq(received,'A');T.eq(g.stack:top(),s) + -- Empty input chooses the import-provided default, never an unrelated name. + g=game();s=Pick.new(g,{presets={'LOCAL DEFAULT'},onDone=function(name)received=name end}) + g.stack:push(s);settle(g,s);tap(g,s,'a');keyboard=g.stack:top();received=nil + tap(g,keyboard,'start');tap(g,keyboard,'a');T.eq(received,'LOCAL DEFAULT') + -- Real Oak dispatch owns the picker and its continuation, including a + -- changed field length, with no second naming/preset simulation. + g=game();g.stack:push(parent);local advanced=0 + local speech=setmetatable({game=g,nameLen=8,recordAnswer=function()end,advance=function()advanced=advanced+1 end},{__index=require('src.ui.OakSpeech')}) + speech:runStep({kind='name',who='player',presets={'LOCAL DEFAULT'}}) + s=g.stack:top();T.eq(getmetatable(s),Pick);settle(g,s) + tap(g,s,'a');keyboard=g.stack:top();tap(g,keyboard,'a');tap(g,keyboard,'start');tap(g,keyboard,'a') + T.eq(g.stack:top(),parent);T.eq(g.save.player.name,'A');T.eq(advanced,1) + T.eq(require('src.core.ModCompat').ALIASES['src.ui.gen2.NamePick'],'src.ui.Gen2NamePick') +end +Version.set('crystal') +T.eq(Pick.presetsFor('girl')[1],'KRIS');T.eq(Pick.presetsFor('female')[1],'KRIS') +T.eq(Pick.presetsFor('boy',{field={playerForms={boy={names={'CUSTOM'}}}}})[1],'CUSTOM') +T.eq(Pick.presetsFor('boy',{field={boot={namePresets={player={'BOOT'}}}}})[1],'BOOT') +local g=game();local completed=0 +local stale=Pick.new(g,{presets={'X'},onDone=function()completed=completed+1 end}) +g.stack:push(stale);settle(g,stale);tap(g,stale,'a') +local child=g.stack:pop();g.stack:pop();g.stack:push({});g.stack:push(child) +tap(g,child,'start');tap(g,child,'a') +T.eq(completed,0,'a keyboard whose original caller was removed cannot commit to it') +-- Rendering uses the actual supplied portrait at native tile positions and +-- retains true-colour ownership. No private picture data is needed. +local s=Pick.new(nil,{presets={'X'}});local draws={};local draw=love.graphics.draw +s.pic={getDimensions=function()return 56,40 end} +love.graphics.draw=function(image,x,y)draws[#draws+1]={image,x,y}end +s:drawPic();T.eq(draws[1][1],s.pic);T.eq(draws[1][2],48);T.eq(draws[1][3],48) +s.picX=13;s:drawPic();T.eq(draws[2][2],104) +love.graphics.draw=draw +Version.set(edition);Screens.invalidate() +T.finish('native Gen2 name choice, blocking portrait walk and original Oak continuation') diff --git a/tests/engine/gen2_native_naming.lua b/tests/engine/gen2_native_naming.lua new file mode 100644 index 000000000..23457562e --- /dev/null +++ b/tests/engine/gen2_native_naming.lua @@ -0,0 +1,143 @@ +-- Original contributions (c) 2026 Roxas2712; offered to UNDERdecoded under +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local edition=Version.get() +local UI=require('src.ui.Gen2NamingScreen');local Font=require('src.render.Font') +local Screens=require('src.ui.Screens');local Json=require('src.link.Json') +local data=T.fixtures.fresh() +-- Synthetic, relocated glyphs. Bracket aliases follow the font, never a +-- fixed retail tile ordinal, and a font's explicit alias has precedence. +data.font.charmap[#data.font.charmap+1]={seq='PK',code=0xd1} +data.font.charmap[#data.font.charmap+1]={seq='MN',code=0xd2} +Font.load(data) +T.eq(#Font.split(''),2,'legacy ligatures are two native glyphs') +T.eq(Font.split('')[1].code,0xd1,'PK follows its actual active font') +T.eq(Font.split('')[1].code,0xd2,'MN follows its actual active font') +T.eq(Font.width(''),Font.width('PK'),'measurement uses the same ligature as drawing') +data.font.charmap[#data.font.charmap+1]={seq='',code=0xd3} +Font.load(data);T.eq(Font.split('')[1].code,0xd3,'authored bracket glyph retains precedence') +local preview=UI.new(nil,{initial='AB'}) +T.eq(preview.text,'AB','constructing a public preview does not require a live game') +T.eq(preview:characterAt(0,0),'A');preview:update();T.eq(preview.clock,1) +-- Mail entry must keep both sixteen-glyph rows above its keyboard. +local entry=UI.new({data=data},{type='box',maxLength=32}) +entry.mailMode=true;entry.text=string.rep('X',32) +local drawn={};local draw=Font.draw +Font.draw=function(text,x,y)drawn[#drawn+1]={text,x,y}end +entry:drawEntry(5,4);Font.draw=draw +T.eq(#drawn,32);T.eq(drawn[1][2],8);T.eq(drawn[16][2],128) +T.eq(drawn[17][2],8);T.eq(drawn[17][3],36) +for _,p in ipairs(drawn)do T.check(p[2]>=8 and p[2]+8<=152 and p[3]+8<=entry:keyboardTop()*8,'native mail glyph fits above keyboard')end +local function game() + local g={data=data,save={player={gender='girl'}}} + g.stack=setmetatable({},{__index=require('src.core.StateStack')});g.stack:init() + return g +end +local function tap(g,s,key) + g.input={wasPressed=function(_,k)return key==k end};s:update() +end +for _,v in ipairs({'gold','silver','crystal'})do + Version.set(v) + for _,name in ipairs({'player','rival','mom','box','nickname'})do + local g=game();local parent={};g.stack:push(parent);local called=0 + local s;s=UI.new(g,{type=name,initial='Aé',onDone=function(text) + called=called+1;T.eq(g.stack:top(),s,'public completion leaves the native owner to its caller');g.stack:pop() + T.eq(text,'A',v..' glyph deletion retains complete ligature') + end});g.stack:push(s) + T.eq(s.row,0,'Gen2 cursor starts at zero-based first row');T.eq(s.col,0) + T.eq(s.maxLength,UI.TYPES[name].maxLength,name..' native field limit') + T.eq(#s.glyphs,3,'entry counts font glyphs, not encoded bytes') + tap(g,s,'b');T.eq(s.text,'A') + tap(g,s,'start');T.eq(s:cursorCharacter(),'END');T.eq(called,0,'START selects END without completing') + tap(g,s,'a');T.eq(called,1);T.eq(g.stack:top(),parent) + end + local g=game();local s=UI.new(g,{}) + T.eq(#s:rows(),4,'ordinary Gen2 board has four letter rows') + T.eq(s:characterAt(8,2),' ','a real trailing blank remains an input cell') + s.col=8;s.row=2;tap(g,s,'a');T.eq(s.text,' ') + tap(g,s,'select');T.eq(s.lower,true);T.eq(s:characterAt(7,3),'') + s.row=3;s.col=7;tap(g,s,'a');T.eq(s.text,' ') + tap(g,s,'start');tap(g,s,'left');T.eq(s:cursorCharacter(),'DEL');tap(g,s,'a');T.eq(s.text,' ') + tap(g,s,'left');T.eq(s:cursorCharacter(),'CASE');tap(g,s,'a');T.eq(s.lower,false) + tap(g,s,'left');T.eq(s:cursorCharacter(),'END','bottom targets wrap as three fat cells') + s.row=0;s.col=8;tap(g,s,'up');T.eq(s:cursorCharacter(),'END');tap(g,s,'down');T.eq(s.row,0) + s=UI.new(g,{type='box'});T.eq(#s:rows(),5,'box names have an extra character row') + s.text='12345678';tap(g,s,'b');T.eq(s.text,'1234567');tap(g,s,'select');T.eq(s:characterAt(8,4),'9') + s=UI.new(g,{maxLength=2});s:addCharacter('é');T.eq(s.text,'é');T.eq(s:addCharacter('X'),false) + s.row=0;s.col=0;tap(g,s,'a');T.eq(s:cursorCharacter(),'END','filling the name selects END but does not accept') + -- Actual ordinary constructor uses this same native screen while retaining + -- its distinct pop-before-callback contract, including empty nickname entry. + local popped=0;local parent={};g.stack:push(parent) + s=require('src.ui.NamingScreen').new(g,{kind='mon',maxLen=10,onDone=function(text) + popped=popped+1;T.eq(text,'');T.eq(g.stack:top(),parent) + end});g.stack:push(s) + T.eq(getmetatable(s),UI,'native admission is not a secondary keyboard simulation') + tap(g,s,'start');T.eq(popped,0);tap(g,s,'a');T.eq(popped,1) + tap(g,s,'a');T.eq(popped,1,'completed native admission cannot run again') + -- Presets draw the original parent, without a ghost keyboard behind them. + local keyboard=0;local old=UI.drawPanel;UI.drawPanel=function()keyboard=keyboard+1 end + local parent={};g.stack:init();g.stack:push(parent) + s=require('src.ui.NamingScreen').new(g,{presets={'FIXNAME'},onDone=function(n)T.eq(n,'A')end}) + g.stack:push(s);s:draw();s:drawWidescreen(800,720) + T.eq(s.choosing,true);T.eq(s.isOpaque,false);T.eq(keyboard,0,'no keyboard is drawn under name presets') + local menu=g.stack:top();tap(g,menu,'a');T.eq(g.stack:top(),s);T.eq(s.choosing,nil);T.eq(s.isOpaque,true) + s:draw();T.eq(keyboard,1,'NEW NAME activates the native keyboard') + tap(g,s,'a');tap(g,s,'start');tap(g,s,'a');T.eq(g.stack:top(),parent) + UI.drawPanel=old + -- Actual Oak name-step dispatch retains its semantic role even when a + -- mod changes the player field length to the usual box-name length. + for _,who in ipairs({'player','rival'})do + local g=game();local parent={};g.stack:push(parent);local advanced=0 + local speech={game=g,nameLen=8,recordAnswer=function()end,advance=function()advanced=advanced+1 end} + require('src.ui.OakSpeech').runStep(speech,{kind='name',who=who,presets={}}) + local own=g.stack:top();T.eq(getmetatable(own),UI) + T.eq(own.kind,UI.TYPES[who],'native Oak passes the actual naming role') + T.eq(own.isBox,false,'eight-letter player/rival fields do not become box keyboards') + tap(g,own,'a');tap(g,own,'start');tap(g,own,'a') + T.eq(g.save.player[who=='rival'and'rival'or'name'],'A') + T.eq(advanced,1);T.eq(g.stack:top(),parent) + end + for _,api in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/naming/manifest.json']=Json.encode({id='naming',name='Native name client',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/naming/main.lua']=[[ + local mod=...;local Screen=require('src.ui.gen2.NamingScreen') + mod.exports.make=function(game)return Screen.new(game,{type='box',initial='AB'})end + ]]} + local loaded=T.sdk.loadMods({'mods/naming'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,'actual loader admits native naming client') + local s=loaded.loader.exports.naming.make(game()) + T.eq(getmetatable(s),UI,'legacy class spelling targets the native Gen2 owner') + T.eq(s.text,'AB');T.eq(s:bottomRow(),5) + require('src.mods.Loader').teardown();loaded.release() + end +end +-- Imported cursor sheets are actually used and their quads are retained. +local cursor=UI.new(nil,{}) +cursor.tiles.cursor={getDimensions=function()return 8,16 end} +local newQuad,draw=love.graphics.newQuad,love.graphics.draw +local quads,draws=0,0 +love.graphics.newQuad=function(...)quads=quads+1;return newQuad(...)end +love.graphics.draw=function(image,...)T.eq(image,cursor.tiles.cursor);draws=draws+1 end +cursor:drawCursorBox(2,8,1);cursor:drawCursorBox(2,8,1);cursor:drawCursorBox(8,16,5) +T.eq(quads,2,'cursor quads are retained across frames and target sizes') +T.eq(draws,18,'letter corners and wide-target edges use the imported sheet') +love.graphics.newQuad,love.graphics.draw=newQuad,draw +-- A real external presenter can replace both class draw methods. Preset +-- ownership must suppress those too, without discarding the presenter once +-- NEW NAME makes the native keyboard active. +local oldDraw,oldWide=UI.draw,UI.drawWidescreen +local presented=0 +UI.draw=function()presented=presented+1 end +UI.drawWidescreen=function()presented=presented+1 end +local g=game();local parent={};g.stack:push(parent) +local s=require('src.ui.NamingScreen').new(g,{presets={'ONE'}});g.stack:push(s) +s:draw();s:drawWidescreen(800,720);T.eq(presented,0,'external keyboard presenter is suppressed below presets') +tap(g,g.stack:top(),'a');s:draw();s:drawWidescreen(800,720) +T.eq(presented,2,'the original external presenter resumes for actual name entry') +T.eq(rawget(s,'draw'),nil);T.eq(rawget(s,'drawWidescreen'),nil) +UI.draw,UI.drawWidescreen=oldDraw,oldWide +Version.set('red') +T.eq(getmetatable(require('src.ui.NamingScreen').new(game(),{})),require('src.ui.NamingScreen'),'Gen1 retains its own keyboard') +Version.set(edition);Screens.invalidate() +T.finish('native Gen2 keyboard input, glyphs, original admission and loaded class owner') diff --git a/tests/engine/gen2_option_window.lua b/tests/engine/gen2_option_window.lua new file mode 100644 index 000000000..4f828b1bc --- /dev/null +++ b/tests/engine/gen2_option_window.lua @@ -0,0 +1,32 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local before=Version.get() +local Menu=require('src.ui.OptionsMenu') +local UI=require('src.render.ScreenSpaceUI') +local Stack=require('src.core.StateStack') +local Save=require('src.core.SaveData') +require('src.render.Font').load(T.fixtures.fresh()) +for _,edition in ipairs({'gold','silver','crystal','red','emerald'})do + Version.set(edition) + local game={data=T.fixtures.fresh(),save=Save.newGame()} + game.stack=setmetatable({},{__index=Stack});game.stack:init() + local menu=Menu.new(game);game.stack:push(menu) + local gen2=Version.isGen2() + T.eq(menu:drawsWidescreen(),gen2,edition..' window drawing is edition scoped') + T.eq(menu:wantsFillScale(),gen2,edition..' fill contract is edition scoped') + T.eq(UI.select(game.stack,{states={menu}},1),gen2 and menu or nil,edition..' actual compositor selects declared owner') + if gen2 then + local glyphs=0;local draw=love.graphics.draw;local wide=Menu.drawWidescreen + love.graphics.draw=function(...)glyphs=glyphs+1;return draw(...)end + Menu.drawWidescreen=function(self,w,h) + self.windowReceipt={w,h};return wide(self,w,h) + end + UI.draw(menu,{}, {width=800,height=720},nil,false) + love.graphics.draw=draw;Menu.drawWidescreen=wide + T.same(menu.windowReceipt,{800,720},edition..' provider wrapper receives actual window dimensions') + T.check(glyphs>0,edition..' fallback renders native panel glyphs') + end +end +Version.set(before) +T.finish('native Gen2 options window presentation') diff --git a/tests/engine/gen2_palette_clock.lua b/tests/engine/gen2_palette_clock.lua new file mode 100644 index 000000000..c9d0dc01b --- /dev/null +++ b/tests/engine/gen2_palette_clock.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes') +local previous={dark=P.darkWorld(),tod=P.gen2Tod(),map=P.gen2MapPalette(),date=os.date} +T.eq(P,require('src.render.PaletteFX'),'public Gen2 palette spelling shares actual native module') +P.setDarkWorld(false);P.setGen2MapPalette(0) +local cases={{0,'NITE'},{3.99,'NITE'},{4,'MORN'},{9.99,'MORN'},{10,'DAY'},{17.99,'DAY'}, + {18,'NITE'},{23.99,'NITE'},{24,'NITE'},{28,'MORN'},{34,'DAY'},{-1,'NITE'}} +for _,case in ipairs(cases)do + T.eq(P.clockDaytime(case[1]),case[2],'RTC query boundary '..case[1]) + T.eq(P.daytimeFor({mapPalette=0},case[1],false),case[2],'native AUTO query shares boundary '..case[1]) +end +for _,v in ipairs({'gold','silver','crystal'})do + V.set(v) + os.date=function(format,...)if format=='%H'then return '19'end;return previous.date(format,...)end + P.setGen2Tod('MORN');P.setGen2MapPalette(1);P.setDarkWorld(true) + T.eq(P.clockDaytime(),'NITE',v..' scene RTC query ignores map DAY and darkness') + T.eq(P.daytimeFor({mapPalette=1},nil,true),'DAY',v..' map override still selects its own row') + T.eq(P.daytimeFor({mapPalette=1},nil,false),'DAY',v..' another map does not inherit live cave darkness') + T.eq(P.gen2Tod(),'DAY',v..' public clock query never changes live bake state') + local Json=require('src.link.Json');local d=T.fixtures.fresh() + local files={['mods/clock/manifest.json']=Json.encode({id='clock',name='Clock contract',version='1.0.0',api=2,entry='main.lua',affects_link=false}), + ['mods/clock/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + -- Authored scene contract: select a sky from the raw RTC, independently + -- of the current room's forced palette. No renderer/mod art is included. + local sky={MORN='early',DAY='sunny',NITE='late'} + mod.exports.sky=sky[Palettes.clockDaytime()] + mod.exports.override=Palettes.daytimeFor({mapPalette=1},nil,true) + ]]} + local r=T.sdk.loadMods({'mods/clock'},{data=d,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,v..' actual Loader accepts unchanged public clock call') + local exports=r.loader.exports.clock + T.eq(exports.sky,'late',v..' loaded scene gets RTC sky instead of nil-method failure') + T.eq(exports.override,'DAY',v..' loaded scene can separately query map palette') + require('src.mods.Loader').teardown();r.release() +end +os.date=previous.date;P.setDarkWorld(previous.dark);P.setGen2MapPalette(previous.map);P.setGen2Tod(previous.tod);V.set(version) +T.finish('Gen2 public scene RTC palette query') diff --git a/tests/engine/gen2_palette_context.lua b/tests/engine/gen2_palette_context.lua new file mode 100644 index 000000000..34082161f --- /dev/null +++ b/tests/engine/gen2_palette_context.lua @@ -0,0 +1,165 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes');local Data=require('src.core.Data') +local W=require('src.world.gen2.World');local TR=require('src.render.TileRenderer') +local Context=require('src.render.Gen2PaletteContext');local Assets=require('src.render.Assets') +local Map=require('src.world.Map');local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local saved={mode=P.mode,dark=P.darkWorld(),tod=P.gen2Tod(),palette=P.gen2MapPalette(),map=W.map,game=W.game,worldDark=W.dark, + image=Assets.image,imageData=Assets.imageData,loveImage=love.image,newImage=love.graphics.newImage,date=os.date} +os.date=function(format,...)if format=='%H'then return '20'end;return saved.date(format,...)end +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.setDark,i);if name=='Game'then slot=i;oldGame=value;break end end +assert(slot) +local function buffer(w,h,source) + local b={w=w,h=h,pixels={}} + function b:getDimensions()return self.w,self.h end + function b:getWidth()return self.w end + function b:getHeight()return self.h end + function b:getPixel(x,y) + local p=self.pixels[y*self.w+x] + if p then return unpack(p)end + local c=source and(1-x%4/3)or 0;return c,c,c,1 + end + function b:setPixel(x,y,...)self.pixels[y*self.w+x]={...}end + function b:setFilter()end + function b:setWrap()end + function b:release()end + return b +end +love.image={newImageData=function(w,h)return buffer(w,h)end} +love.graphics.newImage=function(value)assert(type(value)=='table');return value end +Assets.image=function()return buffer(16,8,true)end +Assets.imageData=function()return buffer(16,8,true)end +local function palette(seed) + local set={} + for slot=1,8 do set[slot]={};for c=1,4 do set[slot][c]={seed+slot+c,slot*10+c,c*20}end end + return set +end +local function fixture(version) + local d=T.fixtures.fresh();local rows={} + for i,row in ipairs({'MORN','DAY','NITE','DARK'})do rows[row]=palette(i*20)end + local ts={id='QA_CONTEXT',image='qa-context-'..version,tilesPerRow=2,palMap={0,6},palColorsByTod=rows, + animatedTiles={},blockCells=2,blocks={{0,1,0,1}},walkable={0},grassTiles={},waterTiles={}} + d.tilesets={QA_CONTEXT=ts} + d.maps={} + for id,p in pairs({AUTO=0,ROOM=1,NIGHT=2,MORNING=3,CAVE=4,UNUSED=7})do + d.maps[id]={id=id,label=id,width=1,height=1,blocks={0},tileset=ts.id,environment=1,mapPalette=p,group=1,number=1} + end + d.maps.CENTER={id='CENTER',label='CENTER',width=1,height=1,blocks={0},tileset=ts.id,environment=3,mapPalette=1,group=2,number=1} + d.field.gen2MapPalettes={rules={{kind='map',group=2,number=1,set='CENTER'},{kind='landmark',landmark=99,set='OTHER'}}, + sets={CENTER={DAY=palette(120)},OTHER={ALL=palette(160)}}} + d.field.gen2Roofs={tilesets={[ts.id]=true},byGroup={[1]=0},images={[0]='synthetic-roof'},slot=10,count=0,palIndex=6, + colors={[1]={DAY={{201,2,3},{204,5,6}},NITE={{211,12,13},{214,15,16}},DARK={{221,22,23},{224,25,26}}}}} + Data.publishGen2Palettes(d) + return d,ts,rows +end +local function samePixels(renderer,colors,label) + for tile,slot in ipairs({1,7})do + for c=1,4 do + local r,g,b=renderer.image:getPixel((tile-1)*8+c-1,0) + T.same({r,g,b},{colors[slot][c][1]/255,colors[slot][c][2]/255,colors[slot][c][3]/255},label..' native baked tile '..tile..' shade '..c) + end + end +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);P.setMode('gbc');P.setGen2Tod('NITE');P.setGen2MapPalette(1);P.setGen2FlashUsed(false);P.setDarkWorld(false) + local d,ts,rows=fixture(version) + T.eq(d.maps.AUTO.palette,'PALETTE_AUTO',version..' publishes native clock identity') + T.eq(d.maps.ROOM.palette,'PALETTE_DAY',version..' publishes forced room identity') + T.eq(rawget(d.maps.ROOM,'palette'),nil,version..' projection never serializes a copied palette field') + d.maps.ROOM.mapPalette=3;T.eq(d.maps.ROOM.palette,'PALETTE_MORN',version..' byte edit updates public identity immediately') + d.maps.ROOM.palette='PALETTE_DAY';T.eq(Context.id(d.maps.ROOM),1,version..' explicit authored identity remains authoritative') + d.maps.ROOM.mapPalette=1 + local authored={mapPalette=1,palette='CUSTOM'};Context.publish(authored);T.eq(authored.palette,'CUSTOM','authored non-native name preserved') + local meta={__index={other=7}};local custom=setmetatable({mapPalette=2},meta) + T.eq(Context.publish(custom),false,'authored lookup metatable is not replaced');T.eq(getmetatable(custom),meta,'authored metatable identity retained') + T.eq(P.daytimeFor(d.maps.ROOM,20,false),'DAY',version..' pinned room remains day') + T.eq(P.daytimeFor(d.maps.CAVE,12,false),'DARK',version..' neighbouring cave is dark before entry') + P.setDarkWorld(true) + T.eq(P.daytimeFor(d.maps.AUTO,12,false),'DAY',version..' outdoor neighbour cannot inherit current cave darkness') + T.eq(P.daytimeFor(d.maps.ROOM,20,false),'DAY',version..' pinned neighbour cannot inherit current cave darkness') + T.eq(P.daytimeFor(d.maps.CAVE,12,true),'NITE',version..' explicit Flash lights queried cave') + P.setDarkWorld(false) + P.setGen2MapPalette(4,d.maps.CAVE) + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' current-map query retains actual native presentation overrides') + T.eq(P.gen2RowFor(d.maps.ROOM),'DAY',version..' neighbour query still uses own header') + T.eq(P.daytimeFor(d.maps.AUTO,nil,false),'NITE',version..' absent hour reads RTC outside a pinned current map') + P.setGen2Tod('MORN');P.setGen2MapPalette(1,d.maps.ROOM) + T.eq(P.gen2RowFor(d.maps.AUTO),'NITE',version..' pinned room cannot pass an unused morning override to outdoor neighbours') + P.setGen2MapPalette(0,d.maps.AUTO) + T.eq(P.gen2RowFor({mapPalette=0}),'MORN',version..' outdoor neighbours still share current AUTO presentation') + P.setGen2Tod('NITE') + P.setGen2MapPalette(1) + for id,row in pairs({AUTO='NITE',ROOM='DAY',NIGHT='NITE',MORNING='MORN',CAVE='DARK',UNUSED='NITE',CENTER='DAY'})do + local def=d.maps[id];local map=Map.new(def,ts) + local colors,chosen=P.bgSet(d.gen2Palettes,def,P.gen2RowFor(def)) + T.eq(chosen,row,version..' native header row '..id) + local renderer=TR.new(map,d) + T.eq(renderer.trueColor,true,version..' native constructor bakes actual colours '..id) + samePixels(renderer,colors,version..' '..id) + if id=='CENTER'then T.eq(colors[1][1][1],d.field.gen2MapPalettes.sets.CENTER.DAY[1][1][1],version..' Center special rule reaches public query')end + end + local before=rows.NITE[7][2] + local set=P.bgSet(d.gen2Palettes,d.maps.AUTO,'NITE') + T.same(set[7][2],{211,12,13},version..' native roof writes only imported middle pair') + T.eq(rows.NITE[7][2],before,version..' public roof query does not modify shared tileset') + T.eq(set[7][1],rows.NITE[7][1],version..' roof preserves endpoint zero') + T.eq(set[7][4],rows.NITE[7][4],version..' roof preserves endpoint three') + d.field.gen2Roofs.colors[1].NITE[1]={231,32,33} + T.same(P.bgSet(d.gen2Palettes,d.maps.AUTO,'NITE')[7][2],{231,32,33},version..' metadata view sees live roof replacements') + TR.invalidate() + samePixels(TR.new(Map.new(d.maps.AUTO,ts),d),P.bgSet(d,d.maps.AUTO,'NITE'),version..' native invalidation observes changed roof') + d.field.gen2MapPalettes.rules[1].set='MISSING';d.maps.CENTER.landmark=99 + T.eq(P.bgSet(d,d.maps.CENTER,'DAY')[1],rows.DAY[1],version..' unavailable first rule does not fall through to later match') + d.field.gen2MapPalettes.rules[1].set='CENTER' + local game={data=d,save={flags={}}};debug.setupvalue(W.setDark,slot,game) + W.map=Map.new(d.maps.CAVE,ts) + P.setGen2MapPalette(4,d.maps.CAVE) + -- Keep the actual presentation setter while controlling only reload IO. + local reload=W.reloadMap;W.reloadMap=function()end + W:setDark(true) + T.eq(W.daytime,'DARK',version..' public world reflects actual native darkness') + T.eq(W.flashUsed,false,version..' public Flash view reads canonical save state') + require('src.script.Gen2Commands') + require('src.script.Commands').g2_blinding_flash({game=game,save=game.save,overworld=W}) + T.eq(game.save.flashLit,true,version..' original native Flash writer sets actual save bit') + T.eq(W.flashUsed,true,version..' public Flash view follows native script write') + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' native neighbour context follows script Flash write') + T.eq(W.daytime,'NITE',version..' world view follows actual lit-cave palette bake') + W.reloadMap=reload + local oldView=d.gen2Palettes;Data.publishGen2Palettes(d) + T.eq(P.bgSet(oldView,d.maps.ROOM,'DAY'),nil,version..' retired published view cannot mix old/new metadata') + local files={['mods/context/manifest.json']=Json.encode({id='context',name='Synthetic context',version='1.0.0',api=2,entry='main.lua',affects_link=false}), + ['mods/context/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + mod.exports.query=function(world,map) + local row=Palettes.daytimeFor(map.def,20,world.flashUsed) + local current=world.map + local same=current.id==map.id + local clock=current.def.palette=='PALETTE_AUTO'and map.def.palette=='PALETTE_AUTO' + if(same or clock)and Palettes.DAYTIME_ID[world.daytime]then row=world.daytime end + return Palettes.bgSet(world.game.data.gen2Palettes,map.def,row),row + end + ]]} + local r=T.sdk.loadMods({'mods/context'},{data=d,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,version..' real Loader imports public atlas context') + W.game=game;W.map=Map.new(d.maps.AUTO,ts);P.setGen2MapPalette(0);P.setGen2Tod('NITE');W:setDark(false) + local query=r.loader.exports.context.query + local colors,row=query(W,Map.new(d.maps.ROOM,ts)) + T.eq(row,'DAY',version..' actual loaded public consumer keeps neighbouring room pinned') + T.same(colors,P.bgSet(d,d.maps.ROOM,'DAY'),version..' loaded consumer uses same native roof selection') + colors,row=query(W,W.map);T.eq(row,W.daytime,version..' loaded consumer uses actual current bake') + W:syncGen2Tod('NITE') + T.eq(P.gen2RowFor(d.maps.CAVE),'NITE',version..' native clock sync rebinds canonical Flash after mod reload') + Loader.teardown();r.release() + T.eq(P.gen2RowFor(d.maps.CAVE),'DARK',version..' teardown retires Flash presentation state') +end +Assets.image,Assets.imageData=saved.image,saved.imageData;love.image=saved.loveImage;love.graphics.newImage=saved.newImage +os.date=saved.date +W.map=saved.map;W.game=saved.game;W.dark=saved.worldDark;debug.setupvalue(W.setDark,slot,oldGame) +P.setMode(saved.mode);P.setGen2MapPalette(saved.palette);P.setGen2Tod(saved.tod);P.setDarkWorld(saved.dark);P.setGen2FlashUsed(false);V.set(original) +T.finish('native Gen2 map context, roof/special rows, world views and actual CPU atlas bake') diff --git a/tests/engine/gen2_palette_draw.lua b/tests/engine/gen2_palette_draw.lua new file mode 100644 index 000000000..c60ec63a5 --- /dev/null +++ b/tests/engine/gen2_palette_draw.lua @@ -0,0 +1,109 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local P=require('src.render.PaletteFX') +local G=require('src.render.GbcPalette') +local LCD=require('src.render.GBCFX') +local V=require('src.core.GameVersion');local edition=V.get() +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local old=love.graphics +local A={{243,241,239},{219,17,31},{19,197,83},{23,29,157}} +local B={{233,229,227},{11,61,211},{173,37,139},{3,7,13}} +local function normalized(p) + local out={};for i=1,4 do local r=p[i];out['pal'..i-1]={r[1]/255,r[2]/255,r[3]/255}end;return out +end +local active, builds, failSend=nil,0,nil +local function fakeShader(code) + builds=builds+1 + return {source=code,values={},send=function(self,name,value) + if name==failSend then failSend=nil;error('uniform upload failed')end + self.values[name]={unpack(value)} + end} +end +love.graphics={newShader=fakeShader,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G,require('src.render.Gen2PaletteDraw'),'public spelling has typed native implementation') +T.neq(G,LCD,'palette drawing must not resolve to LCD screen effect') +T.eq(type(LCD.applyOptions),'function','native LCD options retained') +T.check(G.available(),'indexed shader is available') +local standard,keyed=G.shader(),G.keyedShader() +T.neq(standard,keyed,'keyed drawing has separate alpha policy') +T.eq(builds,2,'one lazy shader per variant') +T.check(standard.source:find('pixel.a',1,true)~=nil,'source alpha retained') +T.check(standard.source:find('* tint',1,true)~=nil,'draw colour retained') +T.check(keyed.source:find('shade < 0.5 ? 0.0',1,true)~=nil,'keyed source shade zero is transparent') +for _,variant in ipairs({'with','withRaw','keyedWith'})do + active={external=true};local external=active + local wanted=variant=='keyedWith'and keyed or standard + T.check(G[variant](A,function() + T.eq(active,wanted,variant..' shader active inside callback') + T.same(active.values,normalized(A),variant..' all four RGB rows normalized') + G.withRaw(B,function()T.same(active.values,normalized(B),variant..' nested different palette active')end) + T.eq(active,wanted,variant..' nested return restores shader') + T.same(active.values,normalized(A),variant..' nested return restores uniforms') + local ok,err=pcall(function()G.withRaw(B,function()error('authored callback failure',0)end)end) + T.check(not ok and err=='authored callback failure',variant..' callback failure propagated') + T.eq(active,wanted,variant..' nested failure restores shader') + T.same(active.values,normalized(A),variant..' nested failure restores outer palette') + end),variant..' returns applied flag') + T.eq(active,external,variant..' restores foreign shader') +end +G.useRaw(A);A[2][1]=111 +G.withRaw(B,function()end) +local saved=normalized(A);saved.pal1[1]=219/255 +T.same(active.values,saved,'restoration does not borrow caller palette rows') +A[2][1]=219 +standard:send('pal1',{0,0,0});G.useRaw(A) +T.same(active.values,normalized(A),'later public call repairs externally edited uniforms') +G.useRaw(A);failSend='pal2' +local ok,err=pcall(function()G.withRaw(B,function()error('body must not run')end)end) +T.check(not ok and tostring(err):find('uniform upload failed',1,true)~=nil,'upload failure is propagated') +T.eq(active,standard,'partial upload restores previous shader') +T.same(active.values,normalized(A),'partial upload restores all previous uniforms') +local nativeMode,nativeMap=P.mode,P.shadeMap() +for _,game in ipairs({'red','gold','silver','crystal'})do + V.set(game) + for _,mode in ipairs(P.MODES)do + P.setMode(mode);P.setShadeMap({[0]=3,[1]=1,[2]=2,[3]=0}) + local resolved=P.effectiveColors(A) + local display=mode=='classic'and'classic'or((mode=='og'or mode=='og_inv')and'dmg'or'gbc') + T.eq(G.mode,display,game..' '..mode..' public mode follows actual native resolution') + local trueColor=true + T.eq(not(trueColor and G.mode=='gbc'),display~='gbc',game..' '..mode..' unchanged evolution colour gate preserves true-colour art') + T.same(G.resolve(A),P.displayColors(A),game..' '..mode..' resolves mode without consuming the register') + G.with(A,function()T.same(active.values,normalized(resolved),game..' '..mode..' actual use shares resolved colours')end) + G.withRaw(A,function()T.same(active.values,normalized(A),game..' '..mode..' raw use bypasses display/register state')end) + T.same(G.color(A,2),resolved[2],game..' '..mode..' solid colour shares same native row') + end +end +P.setShadeMap(nativeMap);P.setMode(nativeMode);V.set(edition) +for _,bad in ipairs({false,{},{{1,2,3}},{{1,2,3},{1,2,3},{1,2,3},{0/0,2,3}},{{1,2,3},{1,2,3},{1,2,3},{256,2,3}}})do + active={foreign=true};local foreign=active;local called=false + T.eq(G.withRaw(bad,function()called=true;T.eq(active,foreign,'invalid palette retains previous shader in body')end),false,'invalid palette declines colourization') + T.check(called,'invalid palette still executes draw callback') + T.eq(active,foreign,'invalid palette restores previous shader') +end +G.clear();T.eq(active,nil,'clear restores ordinary drawing') +love.graphics={} +T.eq(G.available(),false,'headless renderer declines shader availability') +local called=false;T.eq(G.with(A,function()called=true end),false,'headless with declines colourization') +T.check(called,'headless callback still executes') +love.graphics={newShader=function()error('compiler unavailable')end,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G.available(),false,'compile failure is an unavailable feature') +love.graphics.newShader=fakeShader +T.check(G.available(),'replacement compiler can recover after a failed compile') +for _,api in ipairs({1,2})do + local files={['mods/draw/manifest.json']=Json.encode({id='draw',name='Native indexed drawing consumer',version='1.0.0',api=api,content_format=api==3 and 'unified'or'native',entry='main.lua',affects_link=false}), + ['mods/draw/main.lua']=[[ + local mod=...;local Gbc=require('src.render.GbcPalette') + mod.exports.draw=function(colors,body)return Gbc.with(colors,body)end + ]]} + local r=T.sdk.loadMods({'mods/draw'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,'API'..api..' unchanged indexed consumer loads') + local ran=false + T.check(r.loader.exports.draw.draw(A,function()ran=true end),'API'..api..' loaded consumer applies palette') + T.check(ran,'API'..api..' loaded draw callback executes') + Loader.teardown();r.release() +end +love.graphics=old +T.finish('native indexed palette drawing, nested uniform and shader restoration') diff --git a/tests/engine/gen2_palette_queries.lua b/tests/engine/gen2_palette_queries.lua new file mode 100644 index 000000000..3bc458519 --- /dev/null +++ b/tests/engine/gen2_palette_queries.lua @@ -0,0 +1,27 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local P=require('src.render.PaletteFX') +local Data=require('src.core.Data') +local original={tilesets=Data.tilesets,palettes=Data.gen2Palettes,mode=P.mode,dark=P.darkWorld()} +P.setMode('gbc');P.setDarkWorld(false) +local rows={};for i,tod in ipairs({'MORN','DAY','NITE','DARK'})do + rows[tod]={};for slot=1,8 do rows[tod][slot]={{i,slot,1},{i,slot,2},{i,slot,3},{i,slot,4}}end +end +Data.tilesets={JOHTO={palColorsByTod=rows,palMap={0,1,7}}};Data:publishGen2Palettes() +for i,tod in ipairs({'MORN','DAY','NITE','DARK'})do + T.eq(P.DAYTIMES[i],tod,'ROM row order '..tod) + T.eq(P.DAYTIME_ID[tod],i,'public daytime lookup '..tod) + -- Native external atlas call shape: preserve the effective world daytime, + -- then query the published data without changing the live native state. + local effective=tod + local daytime=P.daytimeFor({tileset='JOHTO'},12,false) + if P.DAYTIME_ID[effective]then daytime=effective end + local set,row=P.bgSet(Data.gen2Palettes,{tileset='JOHTO'},daytime) + T.eq(set,rows[tod],'native external atlas receives actual row '..tod) + T.eq(row,tod,'resolved row name '..tod) +end +T.eq(P.DAYTIME_ID.UNKNOWN,nil,'unknown row not accepted') +T.eq(P.bgSet(Data.gen2Palettes,{tileset='MISSING'},'DAY'),nil,'missing tileset stays unavailable') +P.setMode('og');T.eq(P.bgSet(Data.gen2Palettes,{tileset='JOHTO'},'DAY'),nil,'DMG color choice honored') +Data.tilesets,Data.gen2Palettes=original.tilesets,original.palettes;P.setMode(original.mode);P.setDarkWorld(original.dark) +T.finish('Gen2 public palette query rows') diff --git a/tests/engine/gen2_palette_register.lua b/tests/engine/gen2_palette_register.lua new file mode 100644 index 000000000..bee18ef2d --- /dev/null +++ b/tests/engine/gen2_palette_register.lua @@ -0,0 +1,97 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('src.core.ModCompat').install() +local G=require('src.render.GbcPalette') +local P=require('src.render.PaletteFX') +local V=require('src.core.GameVersion') +local A={{251,241,239},{211,37,53},{17,181,89},{29,31,157}} +local B={{233,227,223},{13,67,197},{163,41,131},{3,7,11}} +local R={{229,199,167},{179,127,79},{109,61,23},{47,19,5}} +local active,fail +local function compiler(code) + return {code=code,values={},send=function(self,name,...) + if fail==name then fail=nil;error('driver rejected uniform')end + local args={...};local out={} + for i,v in ipairs(args)do out[i]=type(v)=='table'and{unpack(v)}or v end + self.values[name]=out + end} +end +love.graphics={newShader=compiler,setShader=function(s)active=s end,getShader=function()return active end} +T.eq(G.BGP_IDENTITY,0xe4,'public identity byte') +T.same(G.bgpShades(),{0,1,2,3},'omitted byte is identity') +for _,edition in ipairs({'red','gold','silver','crystal'})do + V.set(edition);P.setMode('gbc');P.setShadeMap(nil) + for byte=0,255 do + local fields,expected={},{} + for i=0,3 do fields[i+1]=math.floor(byte/4^i)%4;expected[i+1]=A[fields[i+1]+1]end + local label=edition..' byte '..byte + T.same(G.bgpShades(byte),fields,label..' reads all two-bit fields') + T.same(G.remap(A,byte),expected,label..' explicit palette permutation') + local before=G.bgp + T.eq(G.setBgp(byte),before,label..' returns actual previous register') + T.eq(G.bgp,byte~=0xe4 and byte or nil,label..' public byte reflects native state') + T.same(P.effectiveColors(A),expected,label..' native renderer receives permutation') + T.eq(G.resolve(A),A,label..' inspecting colours does not consume BGP') + T.same(G.color(A,2),expected[2],label..' solid colours consume it exactly once') + G.use(A) + for i=1,4 do T.same(active.values['pal'..i-1][1],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255},label..' indexed draw row '..i)end + local native=compiler('native');P.sendColors(native,A) + for i=1,4 do T.same(native.values['c'..i-1][1],{expected[i][1]/255,expected[i][2]/255,expected[i][3]/255},label..' native uniform row '..i)end + end + P.setShadeMap({[0]=2,3,0,1});T.eq(G.bgp,0x4e,edition..' native register write visible publicly') + G.bgp=0xf9;T.eq(P.bgp(),0xf9,edition..' legacy property writes native register') + G.bgp=nil;T.eq(P.shadeMap(),nil,edition..' legacy nil clears native state') + G.setBgp(0);P.setShadeMap(nil);T.eq(G.bgp,nil,edition..' native frame reset clears public register') +end +T.eq(G.remap(A,nil),A,'nil remap retains identity') +T.eq(G.remap(A,0xe4),A,'identity remap retains table identity') +T.eq(G.remap(nil,0xf9),nil,'missing palette stays missing') +V.set('crystal');P.setShadeMap(nil) +local src,dst,count,ambiguous=G.remapTable({A},0xf9,{B}) +T.eq(count,8,'BG and OBJ rows both recognized') +T.eq(ambiguous,0,'disjoint palette mapping exact') +for i=1,4 do T.same(src[i],A[i],'BG source row');T.same(dst[i],A[({2,3,4,4})[i]],'BG byte applies own palette');T.same(dst[i+4],B[i],'OBJ row stays unchanged')end +T.eq(#src,64,'source bounded and padded');T.eq(#dst,64,'destination bounded and padded') +T.neq(src[9],src[1],'padding owns its row');T.same(src[9],src[1],'padding repeats first colour') +local _,collisionDst,_,collisions=G.remapTable({A},0xff,{A}) +T.eq(collisions,3,'overlapping BG/OBJ collision count is explicit') +T.same(collisionDst[1],A[4],'first BG interpretation wins') +local rampSrc,rampDst,rampCount,rampAmbiguous=G.remapTable({A},0xf9,{B},R,{true}) +T.eq(rampCount,12,'forced ramp included as already drawn source') +T.eq(rampAmbiguous,0,'disjoint forced ramp exact') +T.same(rampDst[1],R[2],'forced BG takes explicit byte') +T.same(rampDst[9],R[1],'ramped OBJ uses unpermuted forced ramp') +local _,unramped=G.remapTable({A},0xf9,{B},R) +T.same(unramped[9],B[1],'unramped OBJ retains original colour') +G.setBgp(0xff) +local u=G.remapUniforms({A},0xf9,{B}) +T.same(u.src[1],{A[1][1]/255,A[1][2]/255,A[1][3]/255},'explicit backward byte ignores standing forward register') +T.same(u.dst[1],{A[2][1]/255,A[2][2]/255,A[2][3]/255},'backward byte consumed once') +G.setBgp(nil) +local ok,n=G.useRemapUniforms(u);T.check(ok,'remap shader applies');T.eq(n,0,'successful apply reports ambiguity') +local sh=active;T.eq(sh,G.remapShader(),'actual remap shader active') +T.check(sh.code:find('vec4(rgb, pixel.a) * tint',1,true)~=nil,'backward shader retains alpha and draw tint') +T.same(sh.values.remapSrc,u.src,'all normalized source entries uploaded') +T.same(sh.values.remapDst,u.dst,'all normalized destination entries uploaded') +T.same(sh.values.remapCount,{8},'bounded live count uploaded') +local first=u.src[1][1];u.src[1][1]=0 +G.withRaw(A,function()G.useRemap({B},0xff)end) +T.eq(active,sh,'nested draw restores remap shader') +T.eq(sh.values.remapSrc[1][1],first,'nested draw restores detached original remap uniforms') +local did,why=pcall(function()G.withRaw(A,function()G.useRemap({B},0xff);error('draw failure',0)end)end) +T.check(not did and why=='draw failure','callback failure propagates') +T.eq(active,sh,'error restores remap shader');T.eq(sh.values.remapSrc[1][1],first,'error restores remap uniforms') +T.eq(G.useRemap({}),false,'empty palette leaves shader alone');T.eq(active,sh,'empty palette keeps prior shader') +local rows={};for p=1,20 do rows[p]={{p,1,1},{p,2,2},{p,3,3},{p,4,4}}end +local hugeSrc,hugeDst,hugeCount=G.remapTable(rows,0xff) +T.eq(hugeCount,64,'overfull palette tables bounded');T.eq(#hugeSrc,64,'source cannot overflow shader');T.eq(#hugeDst,64,'destination cannot overflow shader') +T.eq(G.remapUniforms({false,{},{{1,2,3}}},0xf9),nil,'malformed palette rows decline safely') +T.eq(G.useRemapUniforms({count=65,src={},dst={}}),false,'oversized borrowed uniforms rejected') +fail='remapDst';T.eq(G.useRemap({B},0xff),false,'driver upload failure declines effect') +T.eq(active,sh,'driver failure keeps previous shader');T.eq(sh.values.remapSrc[1][1],first,'partial driver failure restores previous uniforms') +T.eq(G.remapShader(),nil,'failed remap shader disabled for current compiler') +love.graphics.newShader=function(code)return compiler(code)end +T.check(G.useRemap({A},0xf9),'new compiler recovers from failed upload') +love.graphics={};T.eq(G.useRemap({A},0xf9),false,'headless remap declines effect') +T.finish('native palette byte register and composited BG/OBJ remapping') diff --git a/tests/engine/gen2_party_item_result.lua b/tests/engine/gen2_party_item_result.lua new file mode 100644 index 000000000..5250eed61 --- /dev/null +++ b/tests/engine/gen2_party_item_result.lua @@ -0,0 +1,140 @@ +-- Contract adapted from Gen1Recomp 535a5ec0 native PartyMenu/HpBar. +-- Existing project rights/licences remain applicable. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Party=require('src.ui.PartyMenu') +local Pokemon=require('src.pokemon.Pokemon') +local Font=require('src.render.Font') +local Chrome=require('src.ui.Gen2Chrome') +local Hud=require('src.render.HudTiles') +local Overlay=require('src.render.NativeScreenOverlay') +local UI=require('src.render.ScreenSpaceUI') +local Stack=require('src.core.StateStack') +local Sound=require('src.core.Sound') +local oldVersion=V.get();V.set('crystal') +local data=T.fixtures.fresh();Font.load(data) +data.audio=data.audio or{};data.audio.sfx=data.audio.sfx or{} +data.audio.sfx.Sfx_Potion='fixture-native-potion.wav' +local input={pressed={}} +function input:wasPressed(k)return self.pressed[k]==true end +local game={data=data,save={party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',20)}},input=input} +game.stack=setmetatable({},{__index=Stack});game.stack:init() +local menu=Party.new(game,{pickOnly=true,keepOpen=true});game.stack:push(menu) +T.eq(type(menu.showItemResult),'function','actual PartyMenu offers native result owner') +if type(menu.showItemResult)=='function'then + for _,maxHp in ipairs({19,47,48,49,96,301})do + local mon=menu.party[1];mon.stats.hp=maxHp;mon.hp=maxHp + local pp=mon.moves[1].pp;local done=0 + menu:showItemResult(1,{fromHp=0,toHp=maxHp,text='NATIVE RESULT',onDone=function()done=done+1 end}) + local result=menu.itemResult + T.eq(menu.heal,result,'one actual HP hold prevents a second animation/input clock') + T.eq(menu:shownHpFor(1,mon),0,'zero pre-revive HP is a real display value') + local ticks=0;local stride=maxHp>=48 and math.ceil(maxHp/48)or 1 + input.pressed={a=true} + while menu:itemResultClimbing()and ticks<400 do + local before=result.shown;menu:update(1/60);ticks=ticks+1 + T.eq(result.shown,math.min(maxHp,before+stride),'native HP step matches reference at this max HP') + T.eq(mon.hp,maxHp,'bar stepping never fabricates gameplay HP') + T.eq(mon.moves[1].pp,pp,'bar stepping cannot spend PP') + T.eq(done,0,'early A cannot acknowledge a climbing bar') + end + T.eq(ticks,math.ceil(maxHp/stride),'native fill duration follows HP step count') + T.eq(menu.itemResult.delay,50,'fill does not spend any native message hold') + T.eq(menu:wantsNativeOverlay(),true,'result text becomes visible after the bar lands') + for i=1,50 do menu:update(1/60)end + T.eq(done,0,'A during the 50-frame delay cannot skip the hold') + input.pressed={};menu:update(1/60) + T.eq(done,0,'elapsed delay alone does not confirm') + input.pressed={b=true};menu:update(1/60);input.pressed={} + T.eq(done,1,'native B acknowledges once after the full delay') + T.eq(menu.itemResult,nil,'acknowledgment retires native result owner') + T.eq(menu.heal,nil,'same acknowledgment retires shared HP hold') + T.eq(game.stack:top(),menu,'controller callback alone owns screen lifetime') + end + local done=0 + menu.party[1].stats.hp=19;menu.party[1].hp=19 -- this case requires one-HP native steps + menu:showItemResult(1,{fromHp=5,toHp=2,auto=true,onDone=function()done=done+1 end}) + for i=1,3 do menu:update(1/60)end + T.eq(done,0,'auto result still waits for all downward HP steps') + menu:update(1/60);T.eq(done,1,'auto result completes on the tick after fill') + menu:showItemResult(2,{text='STATUS RESULT',delay=0,holdSlot=1,holdHp=0,onDone=function()done=done+1 end}) + T.eq(menu:shownHpFor(1,menu.party[1]),0,'held other slot preserves explicit zero') + T.eq(menu:shownHpFor(2,menu.party[2]),menu.party[2].hp,'status result uses actual HP when no animation requested') + input.pressed={a=true};menu:update(1/60);input.pressed={} + T.eq(done,2,'zero delay respects immediate native confirmation') + local pages=0 + menu:showItemResult(1,{text='FIRST ROW\nSECOND ROW\nTHIRD ROW',delay=0,onDone=function()pages=pages+1 end}) + input.pressed={a=true};menu:update(1/60);input.pressed={} + T.eq(pages,0,'long native result cannot lose its final rows or release the enemy early') + T.eq(menu.itemResult.line,3,'native result acknowledgment advances to remaining text') + T.eq(menu.itemResult.delay,50,'remaining native page receives its own confirmation hold') + for i=1,50 do menu:update(1/60)end + input.pressed={b=true};menu:update(1/60);input.pressed={} + T.eq(pages,1,'last native page acknowledgment releases exactly one continuation') + local played={};local soundPlay=Sound.play + Sound.play=function(actual,name)T.eq(actual,data,'native cue uses actual imported data');played[#played+1]=name end + menu:showItemResult(1,{text='HP RESTORED',sfx='Sfx_Potion'}) + T.same(played,{'Sfx_Potion'},'native result cue uses existing Sound.play with no invented resolver') + menu:playSfx('missing');T.eq(#played,1,'missing optional native cue is not invented') + Sound.play=soundPlay + -- Native party rows must consume the real result's shown HP, not change the mon. + local hp=menu.party[1].hp + menu:showItemResult(1,{fromHp=0,toHp=hp,text='HP RESTORED'}) + local bars={};local drawBar=Hud.drawHPBar + Hud.drawHPBar=function(d,x,y,mon,...)bars[#bars+1]=mon.hp;return drawBar(d,x,y,mon,...)end + menu:draw();Hud.drawHPBar=drawBar + T.eq(bars[1],0,'actual native row draws zero displayed HP during revive') + T.eq(menu.party[1].hp,hp,'drawing cannot replace real HP') + -- A replaceable view may reset origin and omit feedback. The engine's + -- controller-owned pass must still paint it once, before modal children. + menu:showItemResult(1,{text='HP RESTORED\nNATIVE CONFIRM'}) + local print=Chrome.print;local box=Chrome.textbox;local panel=Chrome.withOverlayPanel + local trace={};Chrome.print=function(text)trace[#trace+1]=text end + Chrome.textbox=function()trace[#trace+1]='native-box'end + menu.draw=function()trace[#trace+1]='custom-view';love.graphics.origin()end + menu.uiSize=function()return 512,288 end + local dimensions={};Chrome.withOverlayPanel=function(w,h,draw)dimensions={w,h};draw()end + Overlay.screen(menu) + T.same(trace,{'custom-view','native-box','HP RESTORED','NATIVE CONFIRM'},'engine paints native confirmation after the replaceable party view') + T.same(dimensions,{512,288},'classic expanded view uses its actual declared surface') + trace={};game.stack:draw() + T.same(trace,{'custom-view','native-box','HP RESTORED','NATIVE CONFIRM'},'actual state stack paints feedback exactly once') + local front={draw=function()trace[#trace+1]='modal-child'end};game.stack:push(front) + trace={};game.stack:draw() + T.eq(trace[#trace],'modal-child','native modal children stay above controller feedback') + game.stack:pop() + local viewport={width=1280,height=960} + local renderer={WIDTH=512,HEIGHT=288,uiPresentation={x=0,y=0,scaleX=1,scaleY=1}} + function renderer:uiSize()return 512,288 end + trace={};UI.drawNativeStack(game.stack,game.stack,1,nil,renderer,viewport,false) + T.same(dimensions,{1280,960},'native full-resolution pass uses actual window dimensions') + T.same(trace,{'custom-view','native-box','HP RESTORED','NATIVE CONFIRM'},'native full-resolution pass paints feedback once') + menu.drawWidescreen=function()trace[#trace+1]='custom-window'end + trace={};UI.draw(menu,renderer,viewport,nil,false) + T.same(trace,{'custom-window','native-box','HP RESTORED','NATIVE CONFIRM'},'window-resolution owner cannot drop native confirmation') + trace={};local hidden={renderVisible=function()return false end} + UI.drawNativeStack(hidden,game.stack,1,nil,renderer,viewport,false) + T.eq(#trace,0,'hidden owner cannot resurrect feedback') + -- Guard both view leaks and native feedback errors, including leaked pushes. + local g=love.graphics;local canvas=g.newCanvas(9,11);local other=g.newCanvas(5,7) + g.setCanvas(canvas);g.setColor(.2,.3,.4,.5) + menu.draw=function()g.push('all');g.setCanvas(other);g.setColor(0,0,0,1)end + Overlay.screen(menu) + T.eq(g.getCanvas(),canvas,'view leaking a canvas cannot steal confirmation output') + T.same({g.getColor()},{.2,.3,.4,.5},'view tint/push cannot leak into caller') + menu.drawWidescreen=menu.draw + Overlay.window(menu,1280,960) + T.eq(g.getCanvas(),canvas,'window view leaking a canvas cannot steal native feedback') + T.same({g.getColor()},{.2,.3,.4,.5},'window view restores tint and leaked pushes') + Chrome.textbox=function()g.push('all');g.setCanvas(other);error('native result draw failure')end + T.raises(function()Overlay.screen(menu)end,'native result draw failure','native feedback errors remain observable') + T.raises(function()UI.draw(menu,renderer,viewport,nil,false)end,'native result draw failure','window feedback errors remain observable') + T.eq(g.getCanvas(),canvas,'failed feedback restores actual caller target') + T.same({g.getColor()},{.2,.3,.4,.5},'failed feedback restores tint and leaked pushes') + Chrome.print=print;Chrome.textbox=box;Chrome.withOverlayPanel=panel + g.setCanvas();g.setColor(1,1,1,1) +end +V.set(oldVersion) +T.finish('native Gen2 party item result, timing, input and render ownership') diff --git a/tests/engine/gen2_pc_mail.lua b/tests/engine/gen2_pc_mail.lua new file mode 100644 index 000000000..4eb34d3b2 --- /dev/null +++ b/tests/engine/gen2_pc_mail.lua @@ -0,0 +1,120 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local old=V.get() +require('src.core.ModCompat').install() +local Mail=require('src.core.gen2.Mail') +local Boxes=require('src.core.gen2.Boxes') +local Storage=require('src.pokemon.Boxes') +T.eq(require("src.ui.gen2.BoxMenu"),require("src.ui.Gen2BoxMenu"),"legacy Gen2 PC namespace resolves the real native factory") +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local data=T.fixtures.fresh() +data.items.ITEM_158={id='ITEM_158',key='FLOWER_MAIL',name='Translated stationery'} +data.items.ITEM_181={id='ITEM_181',key='SURF_MAIL',name='Another translation'} +local function mon(hp) + local m=Pokemon.new(data,T.fixtures.ids.species[1],5,function(lo)return lo end) + if hp~=nil then m.hp=hp end + return m +end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version);Storage.load(nil);Mail.load(data) + T.eq(Boxes.NUM_BOXES,14,'native GSC has fourteen boxes') + T.eq(Boxes.MONS_PER_BOX,20,'native GSC has twenty residents per box') + T.eq(Boxes.PARTY_SIZE,6,'native party limit') + T.eq(Boxes,require('src.pokemon.Gen2Boxes'),'legacy namespace reaches the real native operations') + T.check(Boxes~=Storage,'incompatible count contracts do not alias the same table') + T.check(Mail.isMail('ITEM_158'),'native opaque item ID is identified from its semantic key') + T.check(Mail.isMail('FLOWER_MAIL'),'symbolic stationery remains accepted') + T.eq(Mail.isMail('ITEM_001'),false,'ordinary native item is not inferred from display text') + T.eq(Mail.INDEX.ITEM_158,0,'native stationery has the same graphics index') + local a,b,c=mon(),mon(),mon() + local s={party={a,b,c},player={name='PLAYER',id=123}} + a.ot,a.otId='GIVER',456 + a.item='ITEM_158';Mail.set(s,1,Mail.entry(a.item,'FIRST','GIVER',456,a.species)) + b.item='ITEM_181';Mail.set(s,2,Mail.entry(b.item,'SECOND','OTHER',789,b.species)) + T.eq(Boxes.canDeposit(s,1),false,'mail-bearing party member cannot enter a box') + T.eq(#s.party,3,'refused deposit leaves native party unchanged') + local ok,removed=Boxes.deposit(s,3,1,data) + T.check(ok and removed==c,'native deposit moves the actual member') + T.eq(Boxes.count(s,1),1,'legacy count reports stored mons, not box count') + T.eq(Storage.count(),14,'shared box-count query remains unchanged') + T.eq(Boxes.box(s,1)[1],c,'legacy and native storage are the same save table') + T.eq(s.mail.party[1].message,'FIRST','unrelated deposit retains first letter') + T.eq(s.mail.party[2].message,'SECOND','unrelated deposit retains second letter') + T.eq(Boxes.deposit(s,1,1,data),false,'mail gate remains enforced with room in the box') + T.check(Mail.sendToPc(s,1),'detaching to mailbox succeeds') + T.eq(a.item,nil,'mailbox move clears held stationery') + T.eq(s.mail.party[1],nil,'mailbox move clears only the source letter') + T.eq(s.mail.box[1].message,'FIRST','mailbox retains the original message') + T.check(Boxes.deposit(s,1,1,data),'now ordinary native deposit succeeds') + T.eq(s.party[1],b,'party compacts after native deposit') + T.eq(s.mail.party[1].message,'SECOND','letter shifts with its original carrier') + T.eq(s.mail.party[2],nil,'old source slot is cleared') + T.eq(Boxes.canDeposit(s,1),false,'last healthy mail carrier cannot be deposited') + c.hp=0;c.status='PSN';c.statusTurns=4;c.moves[1].pp=0 + local ok,back=Boxes.withdraw(s,1,1,data) + T.check(ok and back==c,'withdraw returns the actual boxed mon') + T.eq(c.hp,c.stats.hp,'withdraw restores native max HP rather than legacy maxHp') + T.eq(c.status,nil,'withdraw clears status') + T.eq(c.statusTurns,nil,'withdraw clears status duration') + T.check(Mail.moveFromPcToParty(s,1,2),'mailbox letter can attach to a vacant party item') + T.eq(c.item,'ITEM_158','native item ID survives mailbox round trip') + T.eq(s.mail.party[2].message,'FIRST','mailbox message follows its selected carrier') + T.eq(#s.mail.box,0,'mailbox compacts after successful attachment') + local encoded=Save.encode(s);local round=assert(Save.decode(encoded)) + T.eq(round.mail.party[1].message,'SECOND','real save codec retains slot one mail') + T.eq(round.mail.party[2].authorId,456,'real save codec retains author identity') + T.eq(round.party[2].item,'ITEM_158','real save codec retains native stationery') + s.party[1],s.party[2]=s.party[2],s.party[1];Mail.swapSlots(s,1,2) + T.eq(s.mail.party[1].message,'FIRST','party reorder follows the mon') + T.eq(s.mail.party[2].message,'SECOND','party reorder does not exchange letter ownership') + T.eq(Mail.checkPokeMail(s,nil,'FIRST'),2,'cancel is the actual POKEMAIL_REFUSED value') + T.eq(Mail.checkPokeMail(s,1,'WRONG'),0,'wrong letter cannot deliver a mon') + T.eq(#s.party,2,'wrong letter leaves party unchanged') + T.eq(Mail.checkPokeMail(s,1,'FIRST'),1,'correct delivery consumes the chosen mon') + T.eq(s.party[1],b,'delivery preserves the other party member') + T.eq(s.mail.party[1].message,'SECOND','delivery shifts following mail') + T.eq(Mail.checkPokeMail(s,1,'SECOND'),4,'last healthy recipient cannot be given away') + T.eq(Mail.checkPokeMail(s,1,nil),0,'unresolved old-cache expected message never grants a delivery') + s.party[1].item=nil + T.eq(Mail.checkPokeMail(s,1,'SECOND'),3,'no mail has a distinct result') + T.check(Mail.give(s,'ITEM_181','GIFT'),'script gift writes real native mail') + T.eq(s.party[1].item,'ITEM_181','script gift writes native held item') + T.eq(s.mail.party[1].message,'GIFT','script gift writes the letter') + T.check(Mail.compose(s,1,string.rep('é',33),s.party[1],'ITEM_181'),'composition accepts native stationery') + T.eq(#Mail.characters(s.mail.party[1].message),32,'native character limit is UTF-8 aware') + T.eq(s.mail.party[1].author,'PLAYER','composition records the actual save player') + T.eq(s.mail.party[1].authorId,123,'composition records actual trainer ID') + local top,bottom=Mail.lines(s.mail.party[1]) + T.eq(#Mail.characters(top),16,'first mail line has sixteen characters') + T.eq(#Mail.characters(bottom),16,'second mail line has sixteen characters') + for i=1,10 do s.mail.box[i]=Mail.entry('ITEM_158','LETTER '..i) end + local letter=s.mail.party[1] + T.eq(Mail.sendToPc(s,1),false,'full mailbox refuses atomically') + T.eq(s.mail.party[1],letter,'failed send retains the same letter') + T.eq(s.party[1].item,'ITEM_181','failed send retains stationery') + T.eq(Mail.moveFromPcToParty(s,1,1),false,'occupied item refuses attachment') + T.eq(#s.mail.box,10,'failed attachment retains every mailbox letter') + local singleton={party={mon(),mon(0)}} + T.eq(Boxes.canDeposit(singleton,1),false,'fainted teammate does not satisfy last-healthy rule') + T.eq(Boxes.canDeposit(singleton,2),true,'fainted mon may be deposited while a healthy one remains') + singleton.party[2].moves[1].pp=0;singleton.party[2].status='PAR' + local target=singleton.party[2];Boxes.deposit(singleton,2,1,data) + T.eq(target.moves[1].pp,data.moves[target.moves[1].id].pp,'deposit restores native move PP') + T.eq(target.status,nil,'deposit clears boxed status') +end +V.set('crystal');Mail.load(data) +local Events=require('src.mods.Events');local Runtime=require('src.mods.Runtime') +local bus=Events.new();local previous=Runtime.events;Runtime.events=bus +local writes,reads=0,0 +bus:on('mail.written',function(e)writes=writes+1;T.eq(e.source,'compose','native write event identifies producer')end) +bus:on('mail.read',function(e)reads=reads+1;T.eq(e.author,'P','native read event carries author')end) +local s={party={mon()},player={name='P',id=7}} +Mail.compose(s,1,'ABC',s.party[1],'ITEM_158') +T.eq(writes,1,'actual native composition emits mail.written') +local entry=Mail.get(s,1);Mail.lines(entry);Mail.lines(entry) +T.eq(reads,1,'redrawing a letter does not emit mail.read again') +Mail.get(s,1);Mail.lines(entry);T.eq(reads,2,'opening the same letter again emits a new read') +Runtime.events=previous;V.set(old);Storage.load(nil);Mail.load(nil) +T.finish('native GSC PC transfers, slot-indexed mail, save codec and native events') diff --git a/tests/engine/gen2_pc_owners.lua b/tests/engine/gen2_pc_owners.lua new file mode 100644 index 000000000..42be48460 --- /dev/null +++ b/tests/engine/gen2_pc_owners.lua @@ -0,0 +1,119 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Actual native owners, registry construction and the shared save stores. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local old=Version.get() +require('src.core.ModCompat').install() +local Screens=require('src.ui.Screens');local Stack=require('src.core.StateStack') +local Bag=require('src.inventory.Bag');local Mail=require('src.pokemon.Gen2Mail') +local Storage=require('src.pokemon.Boxes');local Font=require('src.render.Font') +local function game(version) + local data=T.fixtures.fresh() + data.items.POTION={name='POTION',pocket='ITEM',canToss=true} + data.items.ANTIDOTE={name='ANTIDOTE',pocket='ITEM',canToss=true} + data.items.POKE_BALL={name='POKé BALL',pocket='BALL',canToss=true} + data.items.FLOWER_MAIL={name='FLOWER MAIL',pocket='ITEM'} + data.font.charmap={} + for n=32,126 do data.font.charmap[#data.font.charmap+1]={seq=string.char(n),code=n} end + Font.load(data);Mail.load(data);Storage.load(nil) + local g={data=data,input={key=nil},save={version=version,flags={},inventory={POTION=5,ANTIDOTE=2,POKE_BALL=3}, + pcItems={POTION=2},options={},player={name='QA'},party={},pokedex={seen={},owned={}}}} + function g.input:wasPressed(key)return self.key==key end + function g.input:isDown(key)return self.key==key end + g.stack=setmetatable({},{__index=Stack});g.stack:init();g.overworld={};g.stack:push(g.overworld) + return g +end +local function tick(g,key)g.input.key=key;g.stack:update(1/60);g.input.key=nil end +for _,version in ipairs({'gold','silver','crystal'}) do + Version.set(version);local g=game(version) + for _,name in ipairs({'PcMenu','CenterPcMenu','ItemPcMenu','MailboxMenu','DecorationMenu','PackMenu','BoxMenu','MailRead'}) do + local owner=require('src.ui.Gen2'..name) + T.eq(require('src.ui.gen2.'..name),owner,'public spelling retains '..name..' class identity') + local before=#g.stack.states + local instance=Screens.build(g,'Gen2'..name) + T.eq(getmetatable(instance),owner,'factory builds actual '..name..' state') + T.eq(instance.screenId,'Gen2'..name,'resolved owner ID is available before stack insertion') + T.eq(#g.stack.states,before,'embedded construction never pushes '..name) + end + local Item=require('src.ui.Gen2ItemPcMenu') + g.data.items.KEY={name='KEY',pocket='KEY_ITEM',keyItem=true,cantToss=true,registerable=true} + g.data.items.HM={name='HM01',pocket='TM_HM',machine={kind='HM',number=1,move='CUT'}} + g.data.items.TM={name='TM02',pocket='TM_HM',machine={kind='TM',number=2,move='MOVE_001'}} + local pack=require('src.ui.Gen2PackMenu').new(g) + T.eq(pack:moveOf('HM'),'CUT','pack reads imported native machine records') + T.eq(pack:moveOf('TM'),'MOVE_001','TM uses native move identity') + T.eq(table.concat(pack:submenuRows('KEY'),','),'use,sel,quit','native CANT_TOSS/CAN_SELECT bits govern key-item actions') + T.check(Item.new(g):cantToss('HM'),'PC cannot toss an imported HM') + T.check(Item.new(g):cantToss('KEY'),'PC cannot toss an imported key item') + + local pc=Screens.push(g,'Gen2ItemPcMenu') + T.eq(getmetatable(pc),Item,'ordinary PC push uses its native owner') + pc:enterDeposit() + T.eq(getmetatable(pc.pack),require('src.ui.Gen2PackMenu'),'deposit embeds an actual pack without a second stack state') + T.eq(g.stack:top(),pc,'embedded PACK leaves PC as stack owner') + pc:deposit('POTION','POTION',2) + T.eq(g.save.inventory.POTION,3,'deposit removes the real bag quantity') + T.eq(g.save.pcItems.POTION,4,'deposit grows the canonical PC store') + pc:withdraw({id='POTION',name='POTION'},1) + T.eq(g.save.inventory.POTION,4,'withdraw returns real items to the bag') + T.eq(g.save.pcItems.POTION,3,'withdraw debits the same PC store') + pc:leaveDeposit();pc:close();T.eq(g.stack:top(),g.overworld,'PC default close returns to the world') + local s=g.save;s.bagOrder={'POTION','POKE_BALL','ANTIDOTE'} + T.check(Bag.move(s,'POTION','ITEM',2,g.data),'SELECT reorders inside its pocket') + T.eq(table.concat(s.bagOrder,','),'ANTIDOTE,POKE_BALL,POTION','another pocket keeps its global acquisition position') + T.eq(Bag.move(s,'POKE_BALL','ITEM',1,g.data),false,'cross-pocket move is refused') + T.eq(Bag.move(s,'MISSING','ITEM',1,g.data),false,'unknown rows never create items') + T.eq(s.inventory.POTION,4,'reorder never changes quantities') + local Rating=require('src.core.Gen2PokedexRating') + s.pokedex={seen={A=true,B=true},owned={A=true}} + local seen,owned=Rating.dexCounts(s) + T.eq(seen,2,'Oak counts the native seen map');T.eq(owned,1,'Oak counts native owned rather than a second caught map') + T.eq(Rating.findOakRating(9).max,9,'rating covers its upper boundary') + T.eq(Rating.findOakRating(10).max,19,'rating advances past the boundary') + local letter=Mail.entry('FLOWER_MAIL','ONE\nTWO','SENDER',123) + s.mail={party={},box={letter}};s.party={{species=T.fixtures.ids.species[1],hp=10,moves={},stats={hp=10}}} + local mailbox=Screens.push(g,'Gen2MailboxMenu');mailbox:attachMail() + local party=g.stack:top();T.eq(party.pickOnly,true,'native mailbox requests actual party selection') + tick(g,'a') + T.eq(g.stack:top(),mailbox,'party selection pops exactly one state') + T.eq(s.party[1].item,'FLOWER_MAIL','attachment retains stationery ID') + T.eq(s.mail.party[1],letter,'attachment retains the exact letter') + T.eq(#s.mail.box,0,'attachment removes only the selected mailbox entry') + mailbox:close();T.eq(g.stack:top(),g.overworld,'mailbox close leaves the underlying world intact') + local pcmon=require('src.ui.Gen2PcMenu').new(g,{saveExists=false,writer=function()return false end}) + s.currentBox=1;pcmon:beginChangeBox(2);pcmon:writeChangeBox() + T.eq(pcmon.saved,false,'failed change-box persistence is reported') + T.eq(s.currentBox,1,'failed write restores the live current-box selection') + local Pokemon=require('src.pokemon.Pokemon') + local species=T.fixtures.ids.species[1] + local function mon()return Pokemon.new(g.data,species,5,function(lo)return lo end)end + local a,b,c=mon(),mon(),mon();s.party={a,b,c};s.mail={party={},box={}} + local box=Screens.build(g,'Gen2BoxMenu',{mode='deposit'}) + box:doDeposit() + T.eq(#s.party,2,'native deposit screen removes one party member') + T.eq(require('src.pokemon.Gen2Boxes').box(s,1)[1],a,'deposit screen stores the exact selected member') + local withdrawal=Screens.build(g,'Gen2BoxMenu',{mode='withdraw'}) + withdrawal:doWithdraw() + T.eq(s.party[3],a,'native withdrawal screen returns the actual stored member') + T.eq(require('src.pokemon.Gen2Boxes').count(s,1),0,'withdraw screen empties the source slot') + local move=Screens.build(g,'Gen2BoxMenu',{mode='move'}) + move.boxIndex=0;move.index=1;move:beginMove() + T.eq(move.phase,'insert','party move admits a valid destination chooser') + move:stepBox(1);move:insertMon() + T.eq(require('src.pokemon.Gen2Boxes').box(s,1)[1],b,'move screen inserts the selected live mon into its destination') + T.eq(#s.party,2,'cross-list move retains party cardinality') + T.eq(s.party[1],c,'cross-list move compacts the party in order') + local alternate={version=version,party={},inventory={},player={}} + local alternatePC=require('src.ui.Gen2PcMenu').new(g,{save=alternate,saveExists=false}) + alternatePC:beginChangeBox(2);alternatePC:writeChangeBox() + T.eq(alternatePC.saved,false,'alternate PC never falls back to the live profile writer') +end +Version.set('red');local g=game('red');local value={} +g.data.screens={Probe={new=function(_,a,b)T.eq(a,11,'build preserves first positional argument');T.eq(b,value,'build preserves trailing argument');return {}end}} +Screens.invalidate();local state=Screens.build(g,'Probe',11,value) +T.eq(g.stack:top(),g.overworld,'registry build leaves stack untouched') +T.eq(state.screenId,'Probe','registry build records resolved identity') +T.eq(Screens.push(g,'Probe',11,value),g.stack:top(),'push uses the same construction policy before insertion') +Version.set(old) +T.finish('native Gen2 PC owners') diff --git a/tests/engine/gen2_picbox_script_wait.lua b/tests/engine/gen2_picbox_script_wait.lua new file mode 100644 index 000000000..726598fbf --- /dev/null +++ b/tests/engine/gen2_picbox_script_wait.lua @@ -0,0 +1,60 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- A visible starter portrait must retain its script beyond watchdog expiry. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local World=require('src.world.OverworldController') +local Stack=require('src.core.StateStack') +local Runner=require('src.script.ScriptRunner') +local Commands=require('src.script.Commands') +require('src.script.Gen2Commands') +local PicBox=require('src.ui.PicBox') +local game={data={},save={flags={}},input={wasPressed=function()return false end}} +game.stack=setmetatable({}, {__index=Stack});game.stack:init() +local slot,previous +for i=1,100 do local name,value=debug.getupvalue(World.handleInput,i) + if not name then break end + if name=='Game'then slot=i;previous=value;debug.setupvalue(World.handleInput,i,game);break end +end +assert(slot,'native field game owner') +local world=setmetatable({map={id='ELMS_LAB'},scriptMoves={},player={}}, {__index=World}) +game.overworld=world +world.runner=Runner.new(game,world) +-- Use the real portrait and script owners; replace only rendering/field IO. +world.update=function(self)self:watchStuckScript();self.runner:update()end +local pic=setmetatable({game=game,overworld=world,passive=true},PicBox) +world.pokepicBox=pic +game.stack:push(pic) +local resumed=0 +local old=Commands.qa_portrait_followup +Commands.qa_portrait_followup=function()resumed=resumed+1 end +world.runner:run({{'g2_close_pokepic'},{'qa_portrait_followup'}}) +world.runner:update() +T.eq(world.runner:isRunning(),true,'actual closepokepic yields until user acknowledgment') +T.eq(pic.passive,false,'actual closepokepic arms the portrait') +for _=1,1800 do game.stack:update(1/60)end +T.eq(world.runner:isRunning(),true,'thirty seconds inspecting a visible portrait retains its real runner') +T.eq(world.stuckFrames,nil,'foreground wait does not accumulate stranded-script frames') +T.eq(resumed,0,'foreground wait does not skip remaining story rows') +game.input.wasPressed=function(_,button)return button=='a'end +game.stack:update(1/60) +game.input.wasPressed=function()return false end +world.runner:update() +T.eq(game.stack:top(),nil,'A removes the actual portrait') +T.eq(resumed,1,'A resumes remaining native story exactly once') +T.eq(world.runner:isRunning(),false,'completed portrait script releases its input gate') +-- The watchdog still detects a genuinely stranded native world script. +world.runner.co=coroutine.create(function()coroutine.yield()end) +coroutine.resume(world.runner.co) +world.runner.ctx={};world.runner.waitingFrames=nil;world.runner.waitingCheck=nil +game.stack.worldState=world +for _=1,720 do world:watchStuckScript()end +T.eq(world.runner:isRunning(),false,'persistent bare world still retires a truly stranded runner') +-- Legacy stacks with the world itself on top are not a foreground wait. +world.runner.co=coroutine.create(function()coroutine.yield()end) +coroutine.resume(world.runner.co) +game.stack.states[1]=world -- model the legacy stack without running map-entry IO +for _=1,720 do world:watchStuckScript()end +T.eq(world.runner:isRunning(),false,'legacy world-on-stack watchdog remains active') +Commands.qa_portrait_followup=old +debug.setupvalue(World.handleInput,slot,previous) +T.finish('native portrait foreground waits and stranded-script recovery') diff --git a/tests/engine/gen2_pokemon_palette_queries.lua b/tests/engine/gen2_pokemon_palette_queries.lua new file mode 100644 index 000000000..6ce9ae5ff --- /dev/null +++ b/tests/engine/gen2_pokemon_palette_queries.lua @@ -0,0 +1,71 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local version=V.get() +require('src.core.ModCompat').install() +local P=require('src.world.gen2.Palettes');local Data=require('src.core.Data') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local mode=P.mode +local function palette(seed)return {{255,255,255},{seed,2,3},{4,seed+5,6},{0,0,0}}end +local function fixture() + local d=T.fixtures.fresh();local normal,shiny,custom=palette(20),palette(60),palette(100) + d.tilesets={QUERY={palMap={0},palColorsByTod={DAY={normal}}}} + d.pokemon={SPECIES_133={id='SPECIES_133',index=133,dex=133,name='SPECIES_133',palette='QA_NORMAL',shinyPalette='QA_SHINY'}, + SPECIES_149={id='SPECIES_149',index=149,dex=149,name='DRAGONITE'}, + NO_COLORS={id='NO_COLORS'}} + d.palettes={palettes={QA_NORMAL=normal,QA_SHINY=shiny,QA_CUSTOM=custom,QA_MAPPED=palette(140), + MEWMON=palette(170),GRAYMON=palette(180)},pokemon={SPECIES_149='QA_MAPPED'}} + d.constants.speciesOrder={[133]='SPECIES_133',[149]='SPECIES_149'} + Data.publishGen2Palettes(d) + return d,normal,shiny,custom +end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);local d,normal,shiny,custom=fixture();local view=d.gen2Palettes + for _,colorMode in ipairs(P.MODES)do + P.setMode(colorMode) + T.same(P.monColors(view,'SPECIES_133'),normal,edition..' public normal RGB in '..colorMode) + T.same(P.monColors(view,'SPECIES_133',true),shiny,edition..' public shiny RGB in '..colorMode) + T.same(P.monColors(d,'SPECIES_133'),normal,edition..' full native dataset accepted in '..colorMode) + T.eq(P.monPal(d,'SPECIES_133',false,false),normal,edition..' native renderer keeps source table, without copied palettes') + T.eq(P.monPal(d,'SPECIES_133',false,true),shiny,edition..' native shiny renderer shares source selection') + end + P.setMode('gbc') + T.same(P.monColors(view,'SPECIES_149'),d.palettes.palettes.QA_MAPPED,edition..' native species-to-palette map retained') + T.eq(P.monColors(view,'ABSENT'),nil,edition..' unknown species does not acquire arbitrary fallback colours') + T.eq(P.monColors(view,'NO_COLORS'),nil,edition..' existing species without native palette stays unavailable') + local copy=P.monColors(view,'SPECIES_133');copy[2][1]=0;copy[1]=nil + T.eq(normal[2][1],20,edition..' external recolouring cannot mutate native palette') + T.same(P.monColors(view,'SPECIES_133'),normal,edition..' later read is detached from previous query') + d.pokemon.SPECIES_133.palette='QA_CUSTOM' + T.same(P.monColors(view,'SPECIES_133'),custom,edition..' live authored palette replacement reaches external view') + T.eq(P.monPal(d,'SPECIES_133',false,false),custom,edition..' same replacement reaches native renderer') + d.palettes.palettes.QA_SHINY=nil + T.same(P.monColors(view,'SPECIES_133',true),custom,edition..' unavailable shiny palette falls back to same native normal row') + T.eq(P.monPal(d,'SPECIES_133',true,true),d.palettes.palettes.GRAYMON,edition..' transformed native rendering keeps original precedence') + local bytes=Json.encode(d.palettes) + P.monColors(view,'SPECIES_133');T.eq(Json.encode(d.palettes),bytes,edition..' public read does not publish another palette catalogue') + Data.publishGen2Palettes(d) + T.eq(P.monColors(view,'SPECIES_133'),nil,edition..' retired view cannot revive a previous binding') + local legacy={pokemon={LOCAL={normal={{21,22,23},{31,32,33}},shiny={{41,42,43},{51,52,53}}}}} + T.same(P.monColors(legacy,'LOCAL'),{{255,255,255},{21,22,23},{31,32,33},{0,0,0}},edition..' reference pair input preserved') + T.same(P.monColors(legacy,'LOCAL',true),{{255,255,255},{41,42,43},{51,52,53},{0,0,0}},edition..' reference shiny pair preserved') + legacy.pokemon.LOCAL.shiny=nil + T.same(P.monColors(legacy,'LOCAL',true),P.monColors(legacy,'LOCAL'),edition..' reference absent shiny falls back to normal') + legacy.pokemon.LOCAL.normal[1][2]=0/0 + T.eq(P.monColors(legacy,'LOCAL'),nil,edition..' malformed colour is not handed to external shader') + for _,api in ipairs({1,2})do + local files={['mods/colors/manifest.json']=Json.encode({id='colors',name='Native Pokemon palette query',version='1.0.0',api=api,content_format=api==3 and 'unified' or 'native',entry='main.lua',affects_link=false}), + ['mods/colors/main.lua']=[[ + local mod=...;local Palettes=require('src.world.gen2.Palettes') + mod.exports.colors=function(data,species,shiny)return Palettes.monColors(data.gen2Palettes,species,shiny)end + ]]} + local fresh=fixture();local r=T.sdk.loadMods({'mods/colors'},{data=fresh,fs=T.sdk.memfs(files)}) + T.eq(#r.errors,0,edition..' API'..api..' unchanged public consumer loads') + T.same(r.loader.exports.colors.colors(fresh,'SPECIES_133'),fresh.palettes.palettes.QA_NORMAL,edition..' API'..api..' loaded public query reads actual native palette') + Loader.teardown();r.release() + end +end +for _,args in ipairs({{}, {false}, {{},false}, {{pokemon={BAD=false}},'BAD'}})do + T.eq(P.monColors(unpack(args)),nil,'missing or invalid colour query declines') +end +P.setMode(mode);V.set(version) +T.finish('native Gen2 Pokemon palette queries and shared native selection') diff --git a/tests/engine/gen2_pp_defaults.lua b/tests/engine/gen2_pp_defaults.lua new file mode 100644 index 000000000..d52e4be1a --- /dev/null +++ b/tests/engine/gen2_pp_defaults.lua @@ -0,0 +1,50 @@ +-- Copyright (c) 2026 Roxas2712. Contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Save=require('src.core.SaveData') +local Pokemon=require('src.pokemon.Pokemon') +local Battle=require('src.battle.BattleState') +local Defaults=require('src.battle.RulesetDefaults') +local old=V.get() +local function fixture(version,choice) + V.set(version) + local data=T.fixtures.fresh();data.constants.gen=V.generation() + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + if choice~=nil then save.options.ruleset=choice end + local game={data=data,save=save,input={wasPressed=function()return false end},stack={top=function()end}} + return game,Battle.newWild(game,'FIXMON_C',3) +end +T.eq(Save.defaultOptions().ruleset,false,'new settings retain automatic cartridge selection') +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + local game,b=fixture(version) + T.eq(Defaults.defaultFor(game),'gen2_finite_pp','native generation has a bounded PP compatibility default') + T.eq(b.ruleset.name,'gen2_finite_pp','actual fresh battle constructor chooses native finite PP') + local move=b.enemy.curMoves[1];move.pp=1 + b:executeAction(b.enemy,b.player,move) + T.eq(move.pp,0,'actual native enemy spends its final PP') + T.eq(b:enemyAction().id,'STRUGGLE','actual enemy selection falls back after PP exhaustion') + game,b=fixture(version,'gen1_faithful') + T.eq(b.ruleset.name,'gen1_faithful','an explicitly saved Gen1 rule remains selected') + move=b.enemy.curMoves[1];move.pp=1;b:executeAction(b.enemy,b.player,move) + T.eq(move.pp,1,'explicit unlimited enemy PP choice is honored') + game,b=fixture(version,'modern_clean') + T.eq(b.ruleset.name,'modern_clean','explicit alternative rule remains selected') + move=b.enemy.curMoves[1];move.pp=1;b:executeAction(b.enemy,b.player,move) + T.eq(move.pp,0,'explicit finite alternative still spends native PP') + local Options=require('src.ui.OptionsMenu');game,b=fixture(version) + local menu=Options.new(game);local row + for _,r in ipairs(menu.rows)do if r.id=='ruleset'then row=r end end + T.check(row~=nil,'actual options screen retains ruleset selection') + if row then + T.eq(row.value(game),'AUTO','automatic choice is displayed honestly') + row.step(game,1);T.check(game.save.options.ruleset~=false,'ordinary option input reaches explicit rules') + row.step(game,-1);T.eq(game.save.options.ruleset,false,'ordinary option input can return to automatic') + end +end +local game,b=fixture('red');T.eq(b.ruleset.name,'gen1_faithful','native Gen1 keeps its original default behavior') +local move=b.enemy.curMoves[1];move.pp=1;b:executeAction(b.enemy,b.player,move) +T.eq(move.pp,1,'native Gen1 retains unlimited enemy PP') +V.set(old) +T.finish('native upstream Gen2 finite enemy PP defaults and explicit choices') diff --git a/tests/engine/gen2_pp_selection.lua b/tests/engine/gen2_pp_selection.lua new file mode 100644 index 000000000..3b82a1789 --- /dev/null +++ b/tests/engine/gen2_pp_selection.lua @@ -0,0 +1,110 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Pokemon=require('src.pokemon.Pokemon') +local Effects=require('src.inventory.ItemEffects') +local Screens=require('src.ui.Screens') +local Chrome=require('src.ui.Gen2Chrome') +local Sound=require('src.core.Sound') +local Protocol=require('src.link.Protocol') +local oldVersion=V.get() +for _,version in ipairs({'gold','silver','crystal','prism','polishedcrystal'})do + V.set(version);Screens.invalidate() + local data=T.fixtures.fresh();data.constants.gen=2;require('src.render.Font').load(data) + local mon=Pokemon.new(data,'FIXMON_A',30) + local game={data=data,save={party={mon}},input={pressed={}},stack={states={}}} + function game.input:wasPressed(key)return self.pressed[key]==true end + function game.stack:top()return self.states[#self.states]end + function game.stack:push(state)self.states[#self.states+1]=state end + function game.stack:pop()return table.remove(self.states)end + mon.moves={{id='FIX_TACKLE',pp=9,ppUps=2},{id='FIX_SCRATCH',pp=3,ppUps=1}} + local picked,cancelled=0,0 + local menu=Screens.build(game,'Gen2MoveDeleter',{mon=mon,moves=data.moves, + onChoose=function(slot)picked=slot end,onCancel=function()cancelled=cancelled+1 end}) + T.eq(menu.mon,mon,version..' screen registry resolves the actual native move controller') + T.eq(menu.list,mon.moves,'native selector reads the actual party move list') + if menu.mon==mon and menu.list==mon.moves then + local function press(key)game.input.pressed={[key]=true};menu:update(1/60);game.input.pressed={}end + local hp,pp=mon.hp,mon.moves[1].pp + press('up');T.eq(menu.row,1,'native PP selector stops at the first row') + press('down');T.eq(menu.row,2,'ordinary native input selects the second actual move') + press('down');T.eq(menu.row,2,'native PP selector stops at the last row') + local trace={};local print,box,cursor=Chrome.print,Chrome.textbox,Chrome.cursor + Chrome.print=function(text,x,y)trace[#trace+1]={text,x,y}end + Chrome.textbox=function(...)trace.box={...}end + Chrome.cursor=function(...)trace.cursor={...}end + menu:draw() + T.same(trace.box,{0,1,18,9},'native move list uses cartridge SetUpMoveList geometry') + T.same(trace.cursor,{1,5},'native cursor tracks the actual selected second row') + T.same(trace[1],{data.moves.FIX_TACKLE.name,2,3},'first move uses native name placement') + T.same(trace[6],{data.moves.FIX_SCRATCH.name,2,5},'second move uses native name placement') + Chrome.print,Chrome.textbox,Chrome.cursor=print,box,cursor + T.eq(mon.hp,hp,'drawing a PP choice cannot heal') + T.eq(mon.moves[1].pp,pp,'browsing a PP choice cannot spend or restore PP') + data.audio=data.audio or{};data.audio.sfx=data.audio.sfx or{};data.audio.sfx.Sfx_ReadText2='fixture-native-select.wav' + local cue=0;local play=Sound.play + Sound.play=function(actual,name)T.eq(actual,data,'native selection uses actual imported audio');T.eq(name,'Sfx_ReadText2','native selection uses the real cue');cue=cue+1 end + press('a');press('a');T.eq(picked,2,'selection returns actual move slot once');T.eq(cue,1,'completed selection cannot replay its cue') + Sound.play=play + menu=Screens.build(game,'Gen2MoveDeleter',{mon=mon,onCancel=function()cancelled=cancelled+1 end}) + game.input.pressed={b=true};menu:update(1/60);menu:update(1/60);game.input.pressed={} + T.eq(cancelled,1,'native cancel continuation fires exactly once') + menu=Screens.build(game,'Gen2MoveDeleter',{mon=mon,layout='forget'}) + game.input.pressed={up=true};menu:update(1/60);game.input.pressed={} + T.eq(menu.row,2,'native forget layout alone wraps upward') + game.input.pressed={down=true};menu:update(1/60);game.input.pressed={} + T.eq(menu.row,1,'native forget layout alone wraps downward') + T.check(pcall(function()menu:draw()end),'native forget layout draws its actual four-slot window') + end + for _,base in ipairs({5,10,15,20,25,30,35,40})do + local def={pp=base};local bonus=math.min(math.floor(base/5),7) + for ups=0,3 do + local slot={pp=0,ppUps=ups};local expected=base+ups*bonus + T.eq(Pokemon.maxPP(def,slot),expected,'native maximum follows ComputeMaxPP, including capped high-base bonus') + T.eq(Pokemon.ppUps and Pokemon.ppUps(def,slot),ups,'native PP-Up reader uses the real counter') + local stored={pp=0,maxPp=expected} + T.eq(Pokemon.maxPP(def,stored),expected,'legacy stored maximum infers the existing native bonus') + T.eq(stored.ppUps,nil,'maximum read never migrates or mutates the saved counter') + end + end + data.moves.FIX_TACKLE.pp=40 + mon.moves={{id='FIX_TACKLE',pp=59,ppUps=3},{id='FIX_SCRATCH',pp=0}} + local result=Effects.use(data,game.save,'ETHER',mon,nil,1) + T.eq(result,'consumed','actual ETHER restores a partially depleted native move') + T.eq(mon.moves[1].pp,61,'actual ETHER caps restoration at61 instead of64') + T.eq(Effects.use(data,game.save,'ETHER',mon,nil,1),'failed','full native61PP refuses another ETHER') + mon.moves[1].pp=58 + T.eq(Effects.use(data,game.save,'MYSTERYBERRY',mon,nil,1),'consumed','actual MysteryBerry restores the chosen native move') + T.eq(mon.moves[1].pp,61,'MysteryBerry also clamps at the native61PP maximum') + T.eq(mon.moves[2].pp,0,'single-move PP item cannot restore another slot') + mon.moves[1].pp=0 + T.eq(Effects.use(data,game.save,'MAX_ETHER',mon,nil,1),'consumed','actual MAX ETHER restores the selected move') + T.eq(mon.moves[1].pp,61,'actual MAX ETHER uses the same native maximum') + mon.moves[1].pp=0 + Pokemon.restorePP(mon,data.moves) + T.eq(mon.moves[1].pp,61,'actual party healing uses the same capped maximum') + mon.moves[1]={id='FIX_TACKLE',pp=52,maxPp=54} + T.eq(Effects.use(data,game.save,'PP_UP',mon,nil,1),'consumed','actual PP UP accepts a valid legacy two-bonus slot') + T.eq(mon.moves[1].ppUps,3,'PP UP increments the real inferred native counter') + T.eq(mon.moves[1].maxPp,61,'PP UP writes the actual native maximum') + T.eq(mon.moves[1].pp,59,'PP UP adds seven to the actual remaining PP') + T.eq(Effects.use(data,game.save,'PP_UP',mon,nil,1),'failed','native three-bonus slot refuses a fourth PP UP') + data.moves.SKETCH={id='SKETCH',name='SKETCH',pp=1};mon.moves[1]={id='SKETCH',pp=1} + T.eq(Effects.use(data,game.save,'PP_UP',mon,nil,1),'failed','native SKETCH refuses PP UP without consuming it') + T.eq(mon.moves[1].ppUps,nil,'SKETCH refusal leaves its counter unchanged') + mon.moves[1]={id='FIX_TACKLE',pp=61,maxPp=61} + local packet=Protocol.packMon(mon);packet.moves[1].pp=64;packet.moves[1].ppUps=3 + local unpacked=Protocol.unpackMon(data,packet) + T.check(unpacked~=nil,'actual native link reconstruction returns a valid Pokemon') + T.eq(unpacked and unpacked.moves[1].pp,61,'actual link reconstruction clamps to native Gen2 maximum') + T.eq(mon.moves[1].pp,61,'detached link reconstruction cannot rewrite the original party') +end +for _,version in ipairs({'red','firered','emerald','platinum','heartgold','black'})do + V.set(version) + T.eq(Pokemon.maxPP({pp=40},{ppUps=3}),64,'other editions retain their existing PP rule') + T.eq(Pokemon.maxPP({pp=40},{ppUps=3},2),61,'explicit Gen2 target is independent of the current launcher game') +end +V.set(oldVersion);Screens.invalidate() +T.finish('native Gen2 PP selection, arithmetic, actual item effects, healing and link boundary') diff --git a/tests/engine/gen2_save_menu.lua b/tests/engine/gen2_save_menu.lua new file mode 100644 index 000000000..6d51cb02f --- /dev/null +++ b/tests/engine/gen2_save_menu.lua @@ -0,0 +1,112 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local UI=require('src.ui.Gen2SaveMenu');local Box=require('src.render.TextBox') +local Stack=require('src.core.StateStack');local Screens=require('src.ui.Screens') +local Save=require('src.core.SaveData') +local function game(version) + local g={data=T.fixtures.fresh(),save={version=version,player={name='QA'},flags={},options={textSpeed='fast'},party={},pokedex={owned={}},inventory={}},input={queue={}}} + g.input.wasPressed=function(self,k)return self.queue[k]==true end + g.input.isDown=function(self,k)return self.queue[k]==true end + g.data.font.charmap={} + for code=32,126 do g.data.font.charmap[#g.data.font.charmap+1]={seq=string.char(code),code=0x80+(code-32)} end + g.data.font.charmap[#g.data.font.charmap+1]={seq='…',code=0xf0} + require('src.render.Font').load(g.data) + g.stack=setmetatable({}, {__index=Stack});g.stack:init() + g.writeSave=function(self)self.writes=(self.writes or 0)+1;return self.result~=false end + return g +end +local function tick(g,key,n) + for _=1,n or 1 do g.input.queue=key and {[key]=true}or{};g.stack:update(1/60) end + g.input.queue={} +end +local function wait(g,pred,label) + for _=1,3000 do if pred() then return end;tick(g) end + error('save flow did not reach '..label) +end +local function ready(g,ui) + for _=1,3000 do + if ui:yesNoVisible() then return end + tick(g,ui.message.waiting and 'a' or nil) + end + error('save prompt never offered choices') +end +T.eq(require('src.ui.gen2.SaveMenu'),UI,'legacy class spelling resolves native save owner') +local pages=UI.pagesOf('A\nB\vC\fD\nE\nF') +T.eq(#pages,4,'paragraphs and scrolling retain independent visible pages') +T.eq(pages[2][1],'B','scroll retains previous visible lower line') +T.eq(pages[2][2],'C','scroll introduces next line') +T.eq(pages[2].scrolled,true,'scroll provenance is explicit') +T.eq(UI.pagesOf('')[1][1],'','empty prompt retains one visible page') +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition) + local g=game(edition);local parent={};g.stack:push(parent) + local done,n=nil,0 + local ui=Screens.push(g,'Gen2SaveMenu',{existed=false,onDone=function(saved)done=saved;n=n+1;g.stack:pop()end}) + T.eq(getmetatable(ui),UI,edition..' native screen registry fallback') + T.eq(getmetatable(ui.message),Box,'actual native text printer retained') + T.eq(g.stack:top(),ui,'embedded prompt cannot replace native menu') + ui:accept();T.eq(ui.phase,'confirm','cannot accept before prompt finishes') + ready(g,ui);tick(g,'b');T.eq(done,false,'B declines without writing') + T.eq(g.writes,nil,'cancel cannot reach save writer');T.eq(g.stack:top(),parent,'exact parent restored') + ui:finish(true);T.eq(n,1,'completion callback runs once') + -- Existing file: two separate confirmations and original text scrolling. + g=game(edition);n=0 + ui=Screens.push(g,'Gen2SaveMenu',{existed=true,onDone=function(saved)done=saved;n=n+1;g.stack:pop()end}) + ready(g,ui);tick(g,'a');T.eq(ui.phase,'overwrite','existing file requires independent overwrite answer') + T.eq(g.writes,nil,'first YES does not write existing slot') + ready(g,ui);T.eq(ui.message.lineIndex>=3,true,'overwrite scrolling finishes before YES/NO becomes active') + T.eq(ui.page,2,'native text progress reaches public scrolled page') + tick(g,'down');T.eq(ui.choice,2,'real menu input selects NO') + tick(g,'a');T.eq(done,false,'overwrite NO returns cancellation');T.eq(g.writes,nil,'overwrite refusal preserves slot') + -- No existing slot: fixed save delay and one authoritative write. + g=game(edition);ui=Screens.push(g,'Gen2SaveMenu',{existed=false,onDone=function(saved)done=saved;g.stack:pop()end}) + ready(g,ui);tick(g,'a');T.eq(ui.phase,'saving','YES enters timed native save phase') + T.eq(ui.message.textSpeed,3,'timed save message pins MID cadence') + tick(g,'b',2);T.eq(ui.message.charIndex,0,'held B cannot accelerate timed save text') + wait(g,function()return ui.message.done end,'saving text completion') + tick(g,'b',UI.SAVING_FRAMES-1);T.eq(g.writes,nil,'B cannot skip pre-write delay') + tick(g,'a');T.eq(g.writes,1,'sixteenth post-text frame writes exactly once') + ui:writeNow();T.eq(g.writes,1,'duplicate direct write does not rewrite save') + wait(g,function()return ui.finished end,'saved completion') + T.eq(done,true,'actual successful writer reported');T.eq(g.writes,1,'all later timed frames keep one write') + -- Failed and throwing writers cannot display a successful completion. + for _,writer in ipairs({function()return false end,function()error('QA rejected write')end})do + g=game(edition);ui=Screens.push(g,'Gen2SaveMenu',{existed=false,writer=writer,onDone=function(saved)done=saved;g.stack:pop()end}) + ready(g,ui);tick(g,'a');wait(g,function()return ui.finished end,'failed save completion') + T.eq(done,false,'write failure reaches caller as false') + T.eq(ui:promptText(),'Could not save.','failed writer never prints saved message') + end + -- START's ordinary native route uses this owner, rather than a substitute + -- class offered only to optional consumers. + g=game(edition);local start=Screens.push(g,'StartMenu');local row + for i,item in ipairs(start.items)do if item.label=='SAVE' then row=i end end + T.eq(type(row),'number','native START exposes SAVE') + if start.list then start.list.index=row else start.index=row end;tick(g,'a') + T.eq(getmetatable(g.stack:top()),UI,'actual START input enters native save screen') +end +V.set('red');local g=game('red');local start=Screens.push(g,'StartMenu');local row +for i,item in ipairs(start.items)do if item.label=='SAVE' then row=i end end +if start.list then start.list.index=row else start.index=row end;tick(g,'a');T.eq(getmetatable(g.stack:top()),Box,'Gen1 retains original save dialogue') +V.set(original) +-- This state must enter the real Game writer, retaining its capture and +-- metadata/resume work rather than calling a bare serializer in production. +V.set('crystal');g=game('crystal');g.writeSave=require('src.core.Game').writeSave +local captures,writes=0,0 +g.overworld={captureSave=function(_,save)captures=captures+1;save.qaCaptured=true end} +local originalSave=Save.save +Save.save=function(save)writes=writes+1;T.eq(save.qaCaptured,true,'native overworld captured before write');T.eq(type(save.meta),'table','native writer stamps metadata');return true end +local ui=Screens.push(g,'Gen2SaveMenu',{existed=false}) +ready(g,ui);tick(g,'a');wait(g,function()return ui.finished end,'native Game writer completion') +T.eq(captures,1,'actual native writer captures live state once');T.eq(writes,1,'actual native writer saves once') +local Runtime=require('src.mods.Runtime');local previousHooks=Runtime.hooks +local hooks=require('src.mods.Hooks').new();Runtime.hooks=hooks +hooks:wrap('save.write',function()return false end,0,'qa-save-veto') +ui=Screens.push(g,'Gen2SaveMenu',{existed=false}) +ready(g,ui);tick(g,'a');wait(g,function()return ui.finished end,'native save hook veto') +T.eq(ui.saved,false,'native save veto cannot report success') +T.eq(captures,1,'veto runs before overworld mutation');T.eq(writes,1,'veto never reaches serializer') +Runtime.hooks=previousHooks +Save.save=originalSave;V.set(original) +T.finish('native Gen2 save menu') diff --git a/tests/engine/gen2_start_menu.lua b/tests/engine/gen2_start_menu.lua new file mode 100644 index 000000000..ce554256e --- /dev/null +++ b/tests/engine/gen2_start_menu.lua @@ -0,0 +1,81 @@ +-- Native Gen2 START ownership, routing and input (ROM-free). +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local UI=require('src.ui.Gen2StartMenu');local Saving=require('src.ui.Gen2SaveMenu') +local Screens=require('src.ui.Screens');local Stack=require('src.core.StateStack') +local Generic=require('src.ui.Menu');local Font=require('src.render.Font') +local function game(version) + local g={data=T.fixtures.fresh(),save={version=version,player={name='QA'},flags={},options={},party={},pokedex={owned={}},inventory={}},input={queue={}}} + g.input.wasPressed=function(self,k)return self.queue[k]==true end + g.input.isDown=function(self,k)return self.queue[k]==true end + g.data.font.charmap={} + for code=32,126 do g.data.font.charmap[#g.data.font.charmap+1]={seq=string.char(code),code=0x80+(code-32)}end + Font.load(g.data) + g.stack=setmetatable({}, {__index=Stack});g.stack:init();g.world={};g.stack:push(g.world) + g.writeSave=function(self)self.writes=(self.writes or 0)+1;return true end + return g +end +local function tick(g,key) + g.input.queue=key and {[key]=true}or{};g.stack:update(1/60);g.input.queue={} +end +local function select(g,ui,value) + for _=1,#ui.items do + if ui.items[ui.list.index].value==value then tick(g,'a');return end + tick(g,'down') + end + error('native START missing row '..value) +end +local function ready(g,ui) + for _=1,3000 do + if ui:yesNoVisible()then return end + tick(g,ui.message.waiting and 'a' or nil) + end + error('save choices never ready') +end +T.eq(require('src.ui.gen2.StartMenu'),UI,'public spelling is the actual native START class') +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local g=game(version) + local ui=Screens.push(g,'StartMenu') + T.eq(ui.screenId,'Gen2StartMenu','ordinary field route identifies the native Gen2 owner') + T.eq(getmetatable(ui),UI,'ordinary START has the public class identity') + T.eq(ui.isOpaque,false,'START leaves live world behind it') + T.eq(ui.items[1].value,'pack','empty-party menu begins with native PACK') + T.eq(ui.items[1].label,'PACK','native GSC label') + tick(g,'up');T.eq(ui.list.index,#ui.items,'native cursor wraps upward') + tick(g,'down');T.eq(ui.list.index,1,'native cursor wraps downward') + select(g,ui,'save');local save=g.stack:top() + T.eq(getmetatable(save),Saving,'real input opens native save owner') + T.eq(g.stack.states[#g.stack.states-1],ui,'exact START instance remains below its save submenu') + ready(g,save);tick(g,'b') + T.eq(g.stack:top(),g.world,'save cancellation closes both owners and returns to world') + T.eq(g.writes,nil,'save cancellation never reaches writer') + ui=Screens.push(g,'StartMenu') + T.eq(ui.items[ui.list.index].value,'save','cursor survives a complete START/save round trip') + tick(g,'start');T.eq(g.stack:top(),g.world,'START closes without invoking highlighted action') + ui=Screens.push(g,'StartMenu');select(g,ui,'pack') + T.neq(g.stack:top(),ui,'pack is a real native submenu') + T.eq(g.stack.states[#g.stack.states-1],ui,'pack retains exact parent rather than reconstructing it') + tick(g,'b');T.eq(g.stack:top(),ui,'pack B restores original START owner') + tick(g,'b');T.eq(g.stack:top(),g.world,'START B restores world') + -- Engine flags are the cartridge stores; unlocks must not depend on an + -- optional presentation module inventing legacy convenience fields. + g.save.flags.ENGINE_POKEDEX=true;g.save.flags.ENGINE_POKEGEAR=true;g.save.party={{species='CYNDAQUIL',hp=10}} + ui=Screens.push(g,'StartMenu');local ids={} + for _,row in ipairs(ui.items)do ids[row.value]=true end + T.eq(ids.pokedex,true,'native dex bit unlocks Pokédex') + T.eq(ids.pokemon,true,'actual party unlocks Pokémon') + T.eq(ids.pokegear,true,'native gear bit unlocks Pokégear') + tick(g,'b') + local n=0;ui=UI.new(g,{onClose=function()n=n+1 end});ui:close();ui:close() + T.eq(n,1,'close callback is idempotent') +end +V.set('red');local g=game('red');local red=Screens.push(g,'StartMenu') +T.eq(getmetatable(red),Generic,'Gen1 keeps its generic START implementation') +T.eq(Screens.resolveId(g,'StartMenu'),'StartMenu','Gen1 screen routing remains unchanged') +V.set('crystal');g=game('crystal');g.data.screens={StartMenu={}} +T.eq(Screens.resolveId(g,'StartMenu'),'StartMenu','explicit registry owner still wins') +V.set(original) +T.finish('native Gen2 START owner') diff --git a/tests/engine/gen2_summary_contract.lua b/tests/engine/gen2_summary_contract.lua new file mode 100644 index 000000000..b4fe906f9 --- /dev/null +++ b/tests/engine/gen2_summary_contract.lua @@ -0,0 +1,129 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Screens=require('src.ui.Screens') +local Summary=require('src.ui.SummaryMenu') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local old=V.get() +local function fixture(version) + V.set(version);Screens.invalidate() + local data=T.fixtures.fresh();data.constants.gen=V.generation();require('src.render.Font').load(data) + -- Imported audio is present in real sessions. Keep the cue in this device-free fixture too. + data.audio=data.audio or{};data.audio.sfx=data.audio.sfx or{} + data.audio.sfx.Sfx_SwitchPokemon='fixture-native-switch.wav' + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30),Pokemon.new(data,'FIXMON_B',20),Pokemon.new(data,'FIXMON_C',15)} + for _,mon in ipairs(save.party)do mon.moves={{id='FIX_TACKLE',pp=17,ppUps=2},{id='FIX_SCRATCH',pp=13,ppUps=1}}end + local input={pressed={}} + function input:wasPressed(k)return self.pressed[k]==true end + local stack={states={},pops=0} + function stack:top()return self.states[#self.states]end + function stack:push(s)self.states[#self.states+1]=s end + function stack:pop()self.pops=self.pops+1;return table.remove(self.states)end + local parent={};stack:push(parent) + local game={data=data,save=save,input=input,stack=stack} + local function press(menu,key)input.pressed={[key]=true};menu:update(1/60);input.pressed={}end + return game,press,parent +end +for _,version in ipairs{'gold','silver','crystal','prism','polishedcrystal'}do + local game,press,parent=fixture(version);local party=game.save.party + local closed=0 + local ok,menu=pcall(Screens.push,game,'Gen2SummaryMenu',{party=party,index=2,page=2,onClose=function()closed=closed+1;game.stack:pop()end}) + T.check(ok,version..' actual screen resolver accepts native party/index constructor') + T.eq(ok and menu.mon,party[2],'summary keeps the actual requested Pokemon') + if ok and menu.mon==party[2]then + T.eq(menu.party,party,'public native party is the same real selection') + T.eq(menu.screenId,'Gen2SummaryMenu','native screen resolution retains its public identity') + T.eq(menu.index,2,'public index is actual selected slot') + T.eq(menu.page,2,'native requested page is honored') + T.eq(menu:speciesDef(),game.data.pokemon[party[2].species],'species reader returns the actual catalog row') + T.eq(menu:moveList(),party[2].moves,'move list is not a presentation copy') + T.eq(menu:moveDef('FIX_TACKLE'),game.data.moves.FIX_TACKLE,'native move catalog reader') + T.eq(menu:moveName(party[2].moves[1]),game.data.moves.FIX_TACKLE.name,'move name comes from native catalog') + local tag,draw,wide={},function()return 'consumer-draw'end,function()return 512,288 end + menu.presentationOwner=tag;menu.draw=draw;menu.uiSize=wide + local callback + menu.onMonChange=function(index,mon)callback={index,mon}end + local hp,pp,exp=party[2].hp,party[2].moves[1].pp,party[2].exp + press(menu,'down') + T.eq(menu.mon,party[3],'normal native input changes the actual Pokemon') + T.eq(menu.index,3,'public index advances with native selection') + T.eq(menu.presentationOwner,tag,'Pokemon navigation preserves consumer ownership') + T.eq(menu.draw,draw,'Pokemon navigation preserves installed draw method') + T.eq(menu:draw(),'consumer-draw','retained consumer draw method still executes on the actual instance') + T.eq(menu.uiSize,wide,'Pokemon navigation preserves installed surface owner') + T.eq(menu.onMonChange~=nil,true,'native change continuation survives navigation') + T.eq(callback[1],3,'native change continuation gets current slot') + T.eq(callback[2],party[3],'native change continuation gets actual Pokemon') + T.eq(menu.page,2,'Pokemon change preserves the status page') + press(menu,'down');T.eq(menu.mon,party[3],'native party navigation does not wrap past end') + press(menu,'up');T.eq(menu.mon,party[2],'up returns to actual prior Pokemon') + T.eq(party[2].hp,hp,'browsing cannot heal or damage a Pokemon') + T.eq(party[2].moves[1].pp,pp,'browsing cannot spend PP') + T.eq(party[2].exp,exp,'browsing cannot award EXP') + party[2].item='POTION';game.data.items.POTION={name='NATIVE POTION'} + T.eq(menu:itemName(),'NATIVE POTION','held-item reader uses native catalog') + party[2].ot='NATIVE OT';party[2].otId=9876 + T.eq(menu:otName(),'NATIVE OT','native OT is preserved') + T.eq(menu:otId(),9876,'native OT identifier is preserved') + local def=menu:speciesDef();def.types={'FIRE','FIRE'} + local a,b=menu:typeNames();T.eq(a,require('src.battle.TypeChart').displayName('FIRE',game.data),'type display follows native type registry');T.eq(b,nil,'duplicate second type is suppressed') + def.growthRate='QA_NATIVE_CURVE';game.data.growth_rates=game.data.growth_rates or{} + game.data.growth_rates.QA_NATIVE_CURVE={expForLevel=function(n)return n*1000 end} + T.eq(menu:growth(),game.data.growth_rates.QA_NATIVE_CURVE,'growth reader is the native registry row') + T.eq(menu:expToNext(),math.max(0,21000-party[2].exp),'next EXP reads actual native growth curve') + press(menu,'right');T.eq(menu.page,3,'right advances to blue page') + press(menu,'right');T.eq(menu.page,1,'right wraps after blue page') + press(menu,'left');T.eq(menu.page,3,'left wraps to blue page') + press(menu,'a');T.eq(closed,1,'A on final Gen2 page calls native close once') + T.eq(game.stack:top(),parent,'close callback owns exactly its screen pop') + menu:close();T.eq(closed,1,'repeat close does not call an already-released owner') + local single=Summary.new(game,{mon=party[2]}) + T.eq(single.mon,party[2],'native isolated-mon constructor selects the actual Pokemon') + T.eq(#single.party,1,'isolated summary cannot browse unrelated save Pokemon') + local manager=Screens.push(game,'Gen2SummaryMenu',{party=party,index=1,moveScreen=true}) + T.check(manager.moveDetail,'native MOVE request opens actual move-management mode') + local first,second=party[1].moves[1],party[1].moves[2] + press(manager,'a');T.eq(manager.swapFrom,1,'actual A picks up a move slot') + press(manager,'down');T.eq(manager.moveIndex,2,'actual down chooses the destination') + press(manager,'right');T.eq(manager.mon,party[1],'held move blocks party switching') + local swapped,swapError=pcall(press,manager,'a') + T.check(swapped,'native move swap with an imported sound cue must not call unavailable audio APIs: '..tostring(swapError)) + T.eq(party[1].moves[1],second,'native swap moves the whole destination slot') + T.eq(party[1].moves[2],first,'native swap retains original PP and PP-Up record identity') + T.eq(first.pp,17,'move rearrangement does not restore or spend PP') + T.eq(first.ppUps,2,'move rearrangement preserves PP Ups') + T.eq(manager.swapFrom,nil,'completed native swap releases its lift owner') + press(manager,'a');press(manager,'up');press(manager,'b') + T.eq(manager.moveIndex,2,'B while holding restores cursor to lifted row') + T.eq(manager.swapFrom,nil,'B cancels only the lifted move') + T.eq(game.stack:top(),manager,'cancelling a lift does not exit the manager') + T.eq(party[1].moves[1],second,'cancel leaves actual move order intact') + party[2].isEgg=true + press(manager,'right');T.eq(manager.mon,party[3],'move-screen native party navigation skips eggs') + press(manager,'right');T.eq(manager.mon,party[3],'move-screen party navigation does not wrap') + press(manager,'left');T.eq(manager.mon,party[1],'move-screen left skips egg in reverse') + local Font=require('src.render.Font');local nativeDraw=Font.draw;local unicodeFrame=false + Font.draw=function(text,...) + for _,edge in ipairs{'┌','─','┐','│','└'}do if tostring(text):find(edge,1,true)then unicodeFrame=true end end + return nativeDraw(text,...) + end + local painted,paintError=pcall(manager.drawMoveDetail,manager);Font.draw=nativeDraw + T.check(painted,'actual native move-screen drawer accepts live slots and descriptions: '..tostring(paintError)) + T.eq(unicodeFrame,false,'native border glyphs do not depend on Unicode text-font coverage') + press(manager,'b');T.eq(game.stack:top(),parent,'B exits native standalone move screen') + local status=Screens.push(game,'Gen2SummaryMenu',{party=party,index=1,page=2}) + press(status,'select');T.check(status.moveDetail,'SELECT enters move details from native green page') + press(status,'b');T.eq(status.moveDetail,false,'B returns from detail to the original status page') + T.eq(game.stack:top(),status,'detail return preserves status screen owner') + press(status,'b');T.eq(game.stack:top(),parent,'next B releases status screen') + end +end +for _,version in ipairs{'red','firered','emerald'}do + local game,press,parent=fixture(version) + local menu=Summary.new(game,game.save.party[1]);game.stack:push(menu) + press(menu,'a');T.eq(menu.page,2,version..' keeps shared two-page navigation') + press(menu,'a');T.eq(game.stack:top(),parent,version..' shared A closes second page') +end +V.set(old);Screens.invalidate() +T.finish('native Gen2 summary constructor, navigation and move-management ownership') diff --git a/tests/engine/native_save_summary.lua b/tests/engine/native_save_summary.lua new file mode 100644 index 000000000..c227ad34b --- /dev/null +++ b/tests/engine/native_save_summary.lua @@ -0,0 +1,38 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local previous=V.get() +require('src.core.ModCompat').install() +local Save=require('src.core.SaveData') +T.eq(Save,require('src.core.gen2.Save'),'legacy save spelling retains the native owner') +T.eq(Save.summary(nil),nil,'empty summary has no record') +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local owned={A=true,B=false,C=true} + local save={version=version,player={name='QA',map='NATIVE_ROOM'},flags={ZEPHYRBADGE=true,BOULDERBADGE=true}, + pokedex={owned=owned},inventory={},position={map='ROOM'},playTime=3661.75} + local value=Save.summary(save) + T.eq(value.name,'QA','summary reads the actual native trainer') + T.eq(value.caught,2,'summary counts only owned species') + T.eq(value.badges,2,'summary counts both native badge regions') + T.eq(value.hours,1,'scalar seconds become whole hours') + T.eq(value.minutes,1,'scalar seconds become minute remainder') + T.eq(value.map,'NATIVE_ROOM','summary uses the authoritative native player location before legacy position') + T.eq(save.playTime,3661.75,'summary does not rewrite the live clock') + T.eq(save.pokedex.owned,owned,'summary retains native map identity') + save.playTime={hours=2,minutes=3,seconds=4,frames=30} + value=Save.summary(save) + T.eq(value.hours,2,'legacy broken-down time remains readable') + T.eq(value.minutes,3,'legacy minutes retain their meaning') + T.eq(type(save.playTime),'table','legacy input is read without migration') + value.name='CHANGED';T.eq(save.player.name,'QA','summary results are detached') + save.playTime=0/0;T.eq(Save.summary(save).hours,0,'invalid play time cannot crash a menu') + save.player.map=nil;T.eq(Save.summary(save).map,'ROOM','legacy position remains readable without a native player location') + save.position=nil;save.spawn={map='START'} + T.eq(Save.summary(save).map,'START','native spawn records expose their map') + local native=Save.newGame({version=version,startMap='REAL_NATIVE_ROOM'}) + T.eq(Save.summary(native).map,'REAL_NATIVE_ROOM','a real native new-game save exposes its actual map without a facade-shaped position') +end +V.set(previous) +T.finish('native save summary for external menu renderers') diff --git a/tests/engine/palette_library.lua b/tests/engine/palette_library.lua new file mode 100644 index 000000000..3ee468219 --- /dev/null +++ b/tests/engine/palette_library.lua @@ -0,0 +1,50 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local library={{name='Authored',palettes={ + {'BLUE','00112233445566778899aabb'}, + {'GRAY','000000555555aaaaaaffffff'}, + {'MIX','ff000000ff000000ffffffff'}, + {'INVALID','not a ramp'}, +}}} +package.loaded['data.gb_palettes']=library +local P=require('src.render.Palette') +T.eq(P.packs(),library,'named ramps come from actual optional authored library') +T.eq(P.packId('Authored','BLUE'),'p:Authored/BLUE','named palette ID') +T.eq(P.monoId(128,160,192),'m:80a0c0','RGB palette ID') +T.same(P.ramp('m:80a0c0'),{{128,160,192},{92,115,138},{49,61,73},{15,19,23}},'mono ramp uses public four-step intensities') +T.same(P.unhex('00112233445566778899aabb'),{{0,17,34},{51,68,85},{102,119,136},{153,170,187}},'hex row order') +T.same(P.ramp('p:Authored/BLUE'),{{153,170,187},{102,119,136},{51,68,85},{0,17,34}},'named library order reversed into light-to-dark drawing ramp') +T.same(P.swatch('p:Authored/BLUE'),{{0,17,34},{51,68,85},{102,119,136},{153,170,187}},'swatch reverses drawing ramp') +T.eq(P.findPack('Authored','BLUE'),library[1].palettes[1],'lookup keeps library ownership') +T.eq(P.label('p:Authored/BLUE'),'BLUE','named display label') +T.eq(P.label('m:9bbc0f'),'GREEN','known mono colour label') +T.eq(P.label('m:123456'),'MONO','authored mono colour label') +T.eq(P.label('unknown'),'unknown','unknown ID label remains observable') +T.eq(P.label(''),nil,'empty label absent') +for _,id in ipairs({'m:bad','m:1234567','m:xxxxxx','p:missing/entry','p:Authored/INVALID',''})do + T.eq(P.ramp(id),nil,'invalid/missing palette not invented: '..id) +end +for _,value in ipairs({false,'','00112233445566778899aabx','00112233445566778899aab'})do + T.eq(P.unhex(value),nil,'invalid hex refused') +end +T.eq(P.categoryOf(P.unhex('000000555555aaaaaaffffff')),'grey','gray category') +T.eq(P.categoryOf(P.unhex('00112233445566778899aabb')),'single','single hue category') +T.eq(P.categoryOf(P.unhex('ff000000ff000000ffffffff')),'full','full hue category') +T.eq(P.categoryOf({}),nil,'incomplete ramp has no category') +T.eq(P.hueArc({350,10}),20,'hue arc crosses zero') +T.eq(P.hueArc({}),360,'empty hue-arc protocol') +local hue,chroma=P.hueChroma({255,0,0});T.eq(hue,0,'red hue');T.eq(chroma,255,'red chroma') +local categories=P.categories();T.eq(#categories,3,'three public categories') +T.eq(categories[1].key,'full','category order preserved');T.eq(#categories[1].palettes,1,'invalid ramp omitted') +local key,at=P.locate('p:Authored/BLUE');T.eq(key,'single','locate category');T.eq(at,1,'locate index') +T.eq(P.category('single').palettes[1].pack,'Authored','category entry keeps source pack') +T.eq(P.category('unknown'),nil,'unknown category absent') +T.eq(P.locate('m:80a0c0'),nil,'mono ramp is not a named-library row') +local old=P.ramp('p:Authored/BLUE');T.eq(P.ramp('p:Authored/BLUE'),old,'same ramp ID retains cached table') +library[1].palettes[1][2]='112233445566778899aabbcc';P.invalidate() +local updated=P.ramp('p:Authored/BLUE');T.neq(updated,old,'invalidate rebuilds ramps') +T.same(updated[1],{170,187,204},'updated authored library reaches drawing ramp') +T.eq(P.packs(),library,'invalidating derived indices preserves library owner') +T.finish('native public palette IDs, authored libraries and colour categories') diff --git a/tests/engine/party_live_data.lua b/tests/engine/party_live_data.lua new file mode 100644 index 000000000..8ebd270c4 --- /dev/null +++ b/tests/engine/party_live_data.lua @@ -0,0 +1,50 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local PartyMenu=require('src.ui.PartyMenu') +local Pokemon=require('src.pokemon.Pokemon') +local old=Version.get() +for _,version in ipairs{'red','gold','silver','crystal','prism','polishedcrystal'}do + Version.set(version) + local data=T.fixtures.fresh() + local party={Pokemon.new(data,'FIXMON_A',5),Pokemon.new(data,'FIXMON_B',8)} + local game={data=data,save={party=party},input={pressed={}}} + function game.input:wasPressed(k)return self.pressed[k]==true end + local stack={states={}} + function stack:top()return self.states[#self.states]end + function stack:push(s)self.states[#self.states+1]=s end + function stack:pop()return table.remove(self.states)end + game.stack=stack + local function press(menu,key)game.input.pressed={[key]=true};menu:update(1/60);game.input.pressed={}end + party[1].name='STALE SPECIES NAME';party[1].nickname='EARNED NAME' + local selected,cancelled + local menu=PartyMenu.new(game,{battle={},pickOnly=true,onSwitch=function(mon)selected=mon end}) + stack:push(menu) + T.eq(menu.party,party,version..' presentation reads the real save party') + if Version.isGen2()then + T.eq(party[1].name,data.pokemon[party[1].species].name,'native Gen2 boundary refreshes the catalog species name') + T.eq(party[1].nickname,'EARNED NAME','catalog refresh preserves earned nickname') + end + T.eq(#(menu.party or{}),2,'presentation cannot show an empty team while input selects a mon') + party[2].hp=1;T.eq(menu.party[2].hp,1,'live HP is not copied or cached') + press(menu,'down');press(menu,'a') + T.eq(selected,party[2],'ordinary native input selects exactly the displayed Pokemon') + T.eq(stack:top(),nil,'selection releases its menu owner') + menu=PartyMenu.new(game,{battle={},onCancel=function()cancelled=true end});stack:push(menu) + local hp,pp=party[1].hp,party[1].moves[1].pp + press(menu,'b');T.check(cancelled,'native cancel continuation fires') + T.eq(party[1].hp,hp,'cancel preserves HP');T.eq(party[1].moves[1].pp,pp,'cancel preserves PP') + local scoped={party[2]} + T.eq(PartyMenu.new(game,{battle={playerParty=scoped}}).party,scoped,'scoped battle copy beats save party') + local explicit={party[1]} + T.eq(PartyMenu.new(game,{battle={playerParty=scoped},party=explicit}).party,explicit,'explicit picker selection beats battle scope') + T.eq(PartyMenu.new(game,{party={}}).party[1],nil,'explicit empty party stays empty') + T.eq(game.save.party,party,'menu never replaces the save party') + T.eq(#party,2,'menu never shrinks or reorders the save party') + local ok,native=pcall(PartyMenu.new,game,{battle=true}) + T.check(ok,'native boolean battle option does not index a boolean') + T.eq(ok and native.party,party,'native boolean battle option reads the live party') + T.eq(#PartyMenu.new({}).party,0,'empty preview has a concrete empty party') +end +Version.set(old) +T.finish('native party presentation and input share the live party') diff --git a/tests/engine/screen_space_ui.lua b/tests/engine/screen_space_ui.lua new file mode 100644 index 000000000..c983542a4 --- /dev/null +++ b/tests/engine/screen_space_ui.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local UI=require('src.render.ScreenSpaceUI') +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local scene={states={}} +T.eq(UI.select(stack,scene,1),nil,'ordinary empty frame requests no window-resolution UI') +local trace={} +local parent={drawsWidescreen=function()return true end,drawWidescreen=function(_,w,h) + trace[#trace+1]='wide';T.eq(w,800,'window width reaches the advertised drawer');T.eq(h,720,'window height reaches the advertised drawer') + love.graphics.setColor(1,0,0,1) +end} +local overlay={draw=function()end} +scene.states={parent,overlay} +local owner,index=UI.select(stack,scene,1) +T.eq(owner,parent,'native overlay retains the visible wide parent') +T.eq(index,1,'wide owner position is preserved for overlay separation') +parent.drawsWidescreen=function()return false end +T.eq(UI.select(stack,scene,1),nil,'drawer alone does not opt in') +parent.drawsWidescreen=function()return true end +T.eq(UI.select(stack,scene,2),nil,'hidden below draw boundary is not resurrected') +local renderer={canvas={},WIDTH=160,HEIGHT=144,uiPresentation={x=0,y=0,scaleX=5,scaleY=5}} +function renderer:uiSize()return 160,144 end +function renderer:blitCanvas(canvas,sx,sy) + trace[#trace+1]='overlay';T.eq(canvas,self.canvas,'overlay reuses native canvas');T.eq(sx,5,'native overlay horizontal scale');T.eq(sy,5,'native overlay vertical scale') +end +local viewport={width=800,height=720,gameX=0,gameY=0,scale=5,dpiX=1,dpiY=1} +love.graphics.setColor(.2,.3,.4,1) +UI.draw(parent,renderer,viewport,nil,true) +T.same(trace,{'wide','overlay'},'native prompt is composited after its window-resolution parent') +local r,g,b=love.graphics.getColor();T.eq(r,.2,'drawer graphics state restored');T.eq(g,.3,'drawer cannot leak color');T.eq(b,.4,'overlay graphics state restored') +trace={};UI.draw(parent,renderer,viewport,nil,false) +T.same(trace,{'wide'},'frame without overlay does not duplicate the cartridge canvas') +parent.drawWidescreen=function()love.graphics.setColor(0,0,0,1);error('deliberate wide drawer failure')end +T.raises(function()UI.draw(parent,renderer,viewport,nil,false)end,'deliberate wide drawer failure','drawer errors remain observable') +T.eq(select(1,love.graphics.getColor()),.2,'failed drawer still restores graphics state') +local drew=0;UI.classic(function()drew=drew+1 end,renderer,800,720) +T.eq(drew,1,'classic fallback executes native draw exactly once') +-- Native Gen2 controllers can draw on the full-size output without a +-- low-resolution intermediate, while retaining their native stack lifetime. +trace={};local world={draw=function()trace[#trace+1]='world' end} +local hidden={draw=function()error('hidden native state drawn')end} +local output={width=800,height=720} +local native={draw=function(self) + T.eq(love.graphics.getCanvas(),output,'native drawer receives output canvas directly') + trace[#trace+1]='native';love.graphics.setColor(0,1,0,1) +end} +local front={draw=function() + local r=love.graphics.getColor();T.eq(r,1,'preceding native drawer cannot leak its graphics state') + trace[#trace+1]='front' +end} +scene.states={world,hidden,native,front} +local filtered={renderVisible=function(_,s)return s~=hidden end} +T.eq(UI.hasNativeStack(filtered,scene,1,world),true,'visible native stack requests output-resolution drawing') +T.eq(UI.hasNativeStack(filtered,{states={world,hidden}},1,world),false,'world and mirrored states alone request no UI pass') +love.graphics.setCanvas(output);love.graphics.setColor(.2,.3,.4,1) +UI.drawNativeStack(filtered,scene,1,world,renderer,viewport,false) +T.same(trace,{'native','front'},'world stays single-pass and native stack draw order is retained') +T.eq(love.graphics.getCanvas(),output,'native pass restores output target') +T.eq(select(1,love.graphics.getColor()),.2,'native pass restores caller color') +trace={};UI.drawNativeStack(filtered,scene,4,world,renderer,viewport,false) +T.same(trace,{'front'},'native overlays above wide owner exclude that owner') +native.draw=function()love.graphics.setCanvas({});error('native drawer failure')end +T.raises(function()UI.drawNativeStack(filtered,scene,3,world,renderer,viewport,false)end,'native drawer failure','native errors remain visible') +T.eq(love.graphics.getCanvas(),output,'failed native drawer restores output target') +love.graphics.setCanvas() +T.finish('screen-space UI owner, native overlay ordering and graphics isolation') diff --git a/tests/engine/wide_battle_layout.lua b/tests/engine/wide_battle_layout.lua index 5571af399..804a7f816 100644 --- a/tests/engine/wide_battle_layout.lua +++ b/tests/engine/wide_battle_layout.lua @@ -38,13 +38,44 @@ T.eq(WideBattle.navigate(1, 4, pressing("right")), 2, T.eq(WideBattle.navigate(1, 4, pressing("a")), nil, "navigate leaves A / B / SELECT to the battle engine") --- animation frames shift as one rigid group toward the side they play on -local px, py = WideBattle.animationOffset({ { x = 24 }, { x = 64 } }) -T.eq(px, 20, "a player-side frame lands on the player anchor") -T.eq(py, 8, "a player-side frame lands on the player baseline") -local ex, ey = WideBattle.animationOffset({ { x = 104 }, { x = 144 } }) -T.eq(ex, 136, "an enemy-side frame lands on the enemy anchor") -T.eq(ey, 0, "an enemy-side frame lands on the enemy baseline") +-- OAM x is the right edge of an 8px sprite. These groups are centered on +-- the authored player (26) and enemy (124) anchors, rather than merely +-- falling somewhere on their half of the 160px screen. +local px, py = WideBattle.animationOffset({ { x = 14 }, { x = 46 } }) +T.eq(px, 20, "the player anchor maps to its wide-layout offset") +T.eq(py, 8, "the player anchor maps to its wide-layout baseline") +local ex, ey = WideBattle.animationOffset({ { x = 112 }, { x = 144 } }) +T.eq(ex, 136, "the enemy anchor maps to its wide-layout offset") +T.eq(ey, 0, "the enemy anchor maps to its wide-layout baseline") + +local function offset(x, wantX, wantY, label) + local dx, dy = WideBattle.animationOffset({ { x = x } }) + T.eq(dx, wantX, label .. " horizontal offset") + T.eq(dy, wantY, label .. " vertical offset") +end +offset(30, 20, 8, "single sprite at the player anchor") +offset(128, 136, 0, "single sprite at the enemy anchor") +offset(79, 78, 4, "midpoint between anchors") +-- Wobble must continue across the anchor instead of sticking at it. +offset(29, 19, 8, "one pixel before the player anchor") +offset(31, 21, 8, "one pixel after the player anchor") +offset(127, 135, 0, "one pixel before the enemy anchor") +offset(129, 137, 0, "one pixel after the enemy anchor") +-- Bounded overshoot lets balls leave the picture without unbounded offsets. +offset(-1000, -3, 10, "far left overshoot") +offset(1000, 159, -2, "far right overshoot") +local rx, ry = WideBattle.animationOffset({ { x = 46 }, { x = 14 } }) +T.eq(rx, px, "sprite ordering does not change the rigid frame offset") +T.eq(ry, py, "sprite ordering does not change the baseline") +local emptyX, emptyY = WideBattle.animationOffset({}) +T.eq(emptyX, 0, "an empty frame has no horizontal offset") +T.eq(emptyY, 0, "an empty frame has no vertical offset") + +local mx, my = WideBattle.animationOffset({ { x = 79 } }) +T.eq(mx, 78, "a crossing frame interpolates halfway between picture anchors") +T.eq(my, 4, "the crossing baseline interpolates with the same fraction") +local ox = WideBattle.animationOffset({ { x = 20 } }) +T.check(ox < 20, "motion can overshoot the player anchor without the old 40px clamp") -- the surface: a request outside the bounds falls back to the GB screen, -- and the canvas is only reallocated when the size actually changes diff --git a/tests/gpu/gen2_display_modes.lua b/tests/gpu/gen2_display_modes.lua new file mode 100644 index 000000000..71826794c --- /dev/null +++ b/tests/gpu/gen2_display_modes.lua @@ -0,0 +1,157 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- ROM-free native GPU composition, including final CGB zones and window UI. +local M={} +function M.run() + require('src.core.ModCompat').install() + local P=require('src.render.PaletteFX');local G=require('src.render.GbcPalette') + local V=require('src.core.GameVersion');local Renderer=require('src.render.Renderer') + local Present=require('src.render.Gen2Presentation');local haveViewport,Viewport=pcall(require,'src.render.GameViewport') + if not haveViewport then Viewport={setTarget=function()love.graphics.setCanvas()end}end + local Runtime=require('src.mods.Runtime');local Hooks=require('src.mods.Hooks') + local g=love.graphics + local A={{255,255,255},{201,53,71},{43,157,89},{0,0,0}} + local R={{231,219,207},{181,151,111},{101,67,37},{23,17,11}} + local GRAY={{255,255,255},{170,170,170},{85,85,85},{0,0,0}} + local original={version=V.get(),mode=P.mode,ramp=P.customRamp(),shadeMap=P.shadeMap(),setTarget=Viewport.setTarget,setCanvas=g.setCanvas, + events=Runtime.events,hooks=Runtime.hooks,errors=Runtime.errors} + local w,h=g.getDimensions() + local output=g.newCanvas(w,h,{format='rgba8',dpiscale=1}) + local sheet=g.newCanvas(w,h*4,{format='rgba8',dpiscale=1}) + M.image=sheet + local data=love.image.newImageData(4,1,'rgba8') + for x=0,3 do local n=1-x/3;data:setPixel(x,0,n,n,n,1)end + local image=g.newImage(data);image:setFilter('nearest','nearest') + local checks=0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local function same(actual,wanted,label) + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255,label..' channel '..c..' got '..actual[c]..' expected '..wanted[c])end + end + local function color(c)return {c[1]/255,c[2]/255,c[3]/255,1}end + local function sample(pixels,x,y)return {pixels:getPixel(math.floor(x),math.floor(y))}end + local game={nativeGpuOwner=true} + local bus=Hooks.new();local zoneCalls,composeCalls=0,0 + local nativeViewport + if haveViewport then Viewport.setTarget=function()g.setCanvas(output)end + else local setCanvas=g.setCanvas;g.setCanvas=function(canvas,...)return setCanvas(canvas or output,...)end end -- bounded output IO only + g.push('all') + local ok,why=xpcall(function() + V.set('crystal');P.setShadeMap(nil);P.setDarkWorld(false) + local haveCorrection,Correction=pcall(require,'src.render.GbcColorCorrection');if haveCorrection then Correction.setMode('off')end + require('src.render.GBCFX').setLevel(0) + local havePresets,Presets=pcall(require,'src.render.ShaderPresets');if havePresets then Presets:off()end + Runtime.install(original.events,bus,{}) + Renderer:init() + local function scene(windowMenu) + g.setCanvas(output);g.origin();g.clear(0,0,0,1) + Renderer:beginFrame() + g.setColor(1,1,1,1) + G.with(A,function()g.draw(image,0,0,0,40,72)end) + local native=P.shader() + local cached=require('src.core.Performance').paletteZoneCache + if cached~=nil then check(cached,'native optimized upload enabled')end + P.sendColors(native,A);g.setShader(native);g.draw(image,0,72,0,40,72);g.setShader() + Renderer.gen2FrameManaged=true;Renderer.gen2FrameGame=game + Renderer.gen2FrameZones=Present.zones(game) + if windowMenu then Renderer.gen2WindowDraw=function(viewport) + local vector={draw=function() + g.origin();g.setColor(1,1,1,1);g.rectangle('fill',0,0,w,12) + end} + require('src.render.ScreenSpaceUI').drawNativeStack({}, + {states={vector}},1,nil,Renderer,viewport,false) + end end + nativeViewport=Renderer:endFrame(nil,nil) + g.setCanvas();g.setShader() + original.setCanvas() -- leave the bounded legacy output target before readback + return g.readbackTexture(output) + end + for i,choice in ipairs({{'gbc',A},{'dmg',GRAY},{'classic',P.CLASSIC},{'custom',R}})do + local mode,wanted=choice[1],choice[2] + P.setDisplay(mode,mode=='custom' and R or nil) + local pixels=scene(false) + for _,y in ipairs({36,108})do for x=0,3 do + same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+y*nativeViewport.scale),color(wanted[x+1]),'native/public composite '..mode..' shade '..x..' row '..y) + end end + g.setCanvas(sheet);g.origin();g.setShader();g.setColor(1,1,1,1);g.setBlendMode('replace','premultiplied');g.draw(output,0,(i-1)*h) + g.setCanvas();pixels:release() + end + P.setDisplay('classic',nil) + local pixels=scene(true) + same(sample(pixels,w-4,4),color(P.CLASSIC[1]),'native window-space menu included before Classic at top right') + same(sample(pixels,w-4,h-4),color(P.CLASSIC[4]),'native final image keeps bottom-right orientation') + pixels:release() + local detachZones=bus:wrap('render.zones',function(next,owner,zones) + zoneCalls=zoneCalls+1 + check(owner==game,'zone hook receives actual game owner') + check(zones[1].colors==P.CLASSIC,'zone hook observes final green ramp') + -- Override final Classic as the public seam permits. Resolve must not + -- turn this authored destination back into grayscale. + zones[1].colors=R + return next(owner,zones) + end,0,'gpu-palette-consumer') + pixels=scene(false) + check(zoneCalls==1,'zone hook called once for completed frame') + for x=0,3 do same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+72*nativeViewport.scale),color(R[x+1]),'authored final zone shade '..x)end + pixels:release();detachZones() + local detachCompose=bus:wrap('render.compose',function(_,renderer,ctx) + composeCalls=composeCalls+1 + check(renderer==Renderer and ctx.renderer==Renderer,'native renderer remains composition owner') + check(ctx.game==game and ctx.generation==2,'composition identifies actual game/generation') + check(ctx.worldCanvas==ctx.sceneCanvas and ctx.uiCanvas==ctx.sceneCanvas,'composition receives completed Gen2 scene') + check(ctx.zones[1].colors==P.CLASSIC,'composition receives resolved final palette') + check(g.getCanvas()~=ctx.sceneCanvas,'composition never reads its active render target') + -- Taking ownership bypasses the ordinary tint and post-process, so a + -- reference-style consumer can present the supplied zones itself. + renderer:blitZones(ctx.sceneCanvas,ctx.zones,ctx.ww,ctx.wh) + return true + end,0,'gpu-frame-consumer') + pixels=scene(false) + check(composeCalls==1,'compose invoked once after native scene') + for x=0,3 do same(sample(pixels,nativeViewport.gameX+(x*40+20)*nativeViewport.scale,nativeViewport.gameY+72*nativeViewport.scale),color(P.CLASSIC[x+1]),'composition-owned final shade '..x)end + pixels:release();detachCompose() + -- Empty/backwards/outside rectangles are valid no-op draws. A false + -- palette is a raw RGB opt-out, and scoped state must be restored. + local raw=g.newCanvas(4,1,{format='rgba8',dpiscale=1}) + g.setCanvas(raw);g.clear(0,0,0,1) + local external=G.shader();G.useRaw(A) + Present.blit(image,{{x=0,y=0,w=-1,h=144,colors=R},{x=200,w=5,colors=R},{x=0,y=0,w=160,h=144,colors=false}},4,1) + check(g.getShader()==external,'public final blit restores existing shader') + g.setCanvas();g.setShader();local plain=g.readbackTexture(raw) + for x=0,3 do same(sample(plain,x,0),color(GRAY[x+1]),'false-zone raw RGB '..x)end + g.setCanvas(raw);G.useRaw(A) + Present.blit(image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + check(g.getShader()==external,'authored final blit restores shader identity') + g.draw(image,0,0);g.setCanvas();g.setShader() + local restored=g.readbackTexture(raw) + for x=0,3 do same(sample(restored,x,0),color(A[x+1]),'authored final blit restores managed uniforms '..x)end + g.setCanvas(raw);G.useRaw(A) + Present.apply(image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + check(g.getCanvas()==raw and g.getShader()==external,'offscreen final pass restores target and shader') + g.draw(image,0,0);g.setCanvas();g.setShader() + local applied=g.readbackTexture(raw) + for x=0,3 do same(sample(applied,x,0),color(A[x+1]),'offscreen final pass restores managed uniforms '..x)end + local draw=g.draw + g.draw=function(source,...) + if source==image then error('authored final draw failure',0)end + return draw(source,...) + end + g.setCanvas(raw);G.useRaw(A) + local failed,reason=pcall(Present.apply,image,{{x=0,y=0,w=160,h=144,colors=R}},4,1) + g.draw=draw + check(not failed and tostring(reason):find('authored final draw failure',1,true),'final draw error propagates') + check(g.getCanvas()==raw and g.getShader()==external,'failed final pass restores target and shader') + g.draw(image,0,0);g.setCanvas();g.setShader() + local recovered=g.readbackTexture(raw) + for x=0,3 do same(sample(recovered,x,0),color(A[x+1]),'failed final pass restores managed uniforms '..x)end + recovered:release() + restored:release();applied:release() + raw:release();plain:release() + end,debug.traceback) + g.setCanvas();g.pop() + g.setCanvas=original.setCanvas;g.setCanvas();Viewport.setTarget=original.setTarget;Runtime.install(original.events,original.hooks,original.errors) + V.set(original.version);P.setDisplay(original.mode,original.ramp);P.setShadeMap(original.shadeMap) + output:release();image:release();data:release() + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='Native Gen2 GPU composition in GBC/DMG/Classic/custom, final Classic/zone hooks, completed-scene composition ownership, window-space menus, scoped raw blits'},sheet +end +return M diff --git a/tests/gpu/gen2_hp_pixels.lua b/tests/gpu/gen2_hp_pixels.lua new file mode 100644 index 000000000..c1cd2b901 --- /dev/null +++ b/tests/gpu/gen2_hp_pixels.lua @@ -0,0 +1,90 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Actual native HP queue and HUD tile rendering on each GPU backend. +-- The atlas below is generated test art, not cartridge or mod content. +local M={} +function M.run() + local Version=require('src.core.GameVersion') + local Data=require('src.core.Data') + local Hud=require('src.render.HudTiles') + local Palette=require('src.render.PaletteFX') + local Hp=require('src.battle.Gen2HpPresentation') + local Battle=require('src.battle.BattleState') + local g=love.graphics + local before={version=Version.get(),font=Data.font,mode=Palette.mode} + local checks,frames=0,0 + local function check(ok,why)checks=checks+1;assert(ok,why)end + local colors={GREENBAR={0,189,0},YELLOWBAR={239,181,0},REDBAR={223,37,19}} + local data={palettes={palettes={}}} + for name,color in pairs(colors)do + data.palettes.palettes[name]={{255,255,255},{170,170,170},color,{0,0,0}} + end + local atlas=love.image.newImageData(32*8,8,'rgba8') + for tile=0,31 do + local code=0x62+tile + for y=0,7 do for x=0,7 do + local value=1 + if code>=0x63 and code<=0x6b then + if y==1 or y==5 then value=0 + elseif y>=2 and y<=4 and x.99 and green<.01 and b<.01,'native fallback moved or shrank into provider coordinates') + r,green,b=pixels:getPixel(199,119) + check(r>.99 and green<.01 and b<.01,'native pixel extent lost its integer 5x scale') + r,green,b=pixels:getPixel(200,120) + check(math.abs(r-.1)<.01 and math.abs(green-.2)<.01 and math.abs(b-.3)<.01,'native fallback spills beyond its scaled pixel extent') + r,green,b=pixels:getPixel(0,0) + check(math.abs(r-.1)<.01 and math.abs(green-.2)<.01 and math.abs(b-.3)<.01,'fallback overwrote the live world surround') + pixels:release() + end + end,debug.traceback) + g.pop();Renderer.uiSize=oldSize;Renderer.uiPresentation=oldPresentation + V.set(version) + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='Real GPU native START/SAVE window fallback pixels with a larger provider UI grid'},canvas +end +return M diff --git a/tests/gpu/gen2_native_stack.lua b/tests/gpu/gen2_native_stack.lua new file mode 100644 index 000000000..755993335 --- /dev/null +++ b/tests/gpu/gen2_native_stack.lua @@ -0,0 +1,41 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Output pixels for the native Gen2 menu layer, independently of screen providers. +local M={} +function M.run() + local g=love.graphics;local Renderer=require('src.render.Renderer') + local oldSize,oldPresentation=Renderer.uiSize,Renderer.uiPresentation + local canvas=g.newCanvas(800,720,{format='rgba8',dpiscale=1});M.image=canvas + local checks=0;local function check(ok,why)checks=checks+1;assert(ok,why)end + g.push('all') + local ok,why=xpcall(function() + -- Bitmap coordinates keep integer cartridge scaling; a vector drawer that + -- resets origin() paints one actual output pixel, without enlarged texels. + Renderer.uiSize=function()return 160,144 end + Renderer.uiPresentation={x=0,y=0,scaleX=5,scaleY=5} + g.setCanvas(canvas);g.origin();g.setScissor();g.setShader();g.clear(0,0,0,1) + local native={draw=function() + check(g.getCanvas()==canvas,'native stack left the full-size output target') + g.setColor(1,1,1,1);g.rectangle('fill',2,4,1,1) + end} + local vector={draw=function() + g.origin();g.setColor(1,0,0,1);g.rectangle('fill',23,31,1,1) + end} + require('src.render.ScreenSpaceUI').drawNativeStack({}, + {states={native,vector}},1,nil,Renderer,{width=800,height=720},false) + g.setCanvas();local pixels=g.readbackTexture(canvas) + local r,green,b=pixels:getPixel(10,20) + check(r>.99 and green>.99 and b>.99,'native bitmap moved from cartridge pixel grid') + r,green,b=pixels:getPixel(14,24) + check(r>.99 and green>.99 and b>.99,'native bitmap scale changed') + r,green,b=pixels:getPixel(23,31) + check(r>.99 and green<.01 and b<.01,'vector drawer lost its output pixel coordinates') + r,green,b=pixels:getPixel(24,31) + check(r<.01 and green<.01 and b<.01,'one vector pixel enlarged through a low-resolution intermediate') + pixels:release() + end,debug.traceback) + g.pop();Renderer.uiSize=oldSize;Renderer.uiPresentation=oldPresentation + if not ok then error(why,0)end + return{passed=true,checks=checks,scope='Real GPU direct native/vector menu output pixels'},canvas +end +return M diff --git a/tests/gpu/gen2_palette_draw.lua b/tests/gpu/gen2_palette_draw.lua new file mode 100644 index 000000000..0c84afaf9 --- /dev/null +++ b/tests/gpu/gen2_palette_draw.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Call run() from an isolated LÖVE host, on each graphics backend. +local M = {} +function M.run() + require('src.core.ModCompat').install() + local G = require('src.render.GbcPalette') + local P = require('src.render.PaletteFX') + local V = require('src.core.GameVersion') + local beforeVersion, beforeMode, beforeMap = V.get(), P.mode, P.shadeMap() + V.set('crystal') + local checks = 0 + local function check(ok, message) + checks = checks + 1 + assert(ok, message) + end + local A = {{247,239,229},{213,19,43},{23,193,79},{17,31,151}} + local B = {{233,227,211},{7,59,199},{163,41,137},{3,11,29}} + local shades = love.image.newImageData(4,1,'rgba8') + for x=0,3 do local n=1-x/3;shades:setPixel(x,0,n,n,n,1-x/4) end + local image = love.graphics.newImage(shades);image:setFilter('nearest','nearest') + local canvas = love.graphics.newCanvas(4,12,{format='rgba8',dpiscale=1}) + local expected = {} + local function draw(row, palette, keyed, tint) + love.graphics.setColor(unpack(tint or {1,1,1,1})) + love.graphics.draw(image,0,row) + expected[row] = {palette=palette,keyed=keyed,tint=tint or {1,1,1,1}} + end + love.graphics.push('all') + local ok, why = pcall(function() + love.graphics.setCanvas(canvas);love.graphics.clear(0,0,0,0) + love.graphics.setBlendMode('replace','premultiplied') + check(G.with(A,function()draw(0,A,false,{0.5,0.75,1,0.8})end),'normal draw unavailable') + check(G.keyedWith(A,function()draw(1,A,true)end),'keyed draw unavailable') + G.withRaw(A,function() + draw(2,A) + G.withRaw(B,function()draw(3,B)end) + draw(4,A) + local success,err=pcall(function()G.withRaw(B,function()error('nested failure',0)end)end) + check(not success and err=='nested failure','nested callback error lost') + draw(5,A) + G.keyedWith(B,function()draw(6,B,true)end) + draw(7,A) + end) + P.setShadeMap({[0]=3,[1]=2,[2]=1,[3]=0}) + G.with(A,function()draw(8,{A[4],A[3],A[2],A[1]})end) + G.withRaw(A,function()draw(9,A)end) + local foreign=love.graphics.newShader([[ + vec4 effect(vec4 tint,Image image,vec2 uv,vec2 screen){return vec4(0.2,0.4,0.6,0.8);} + ]]) + love.graphics.setShader(foreign) + G.withRaw(B,function()draw(10,B)end) + check(love.graphics.getShader()==foreign,'foreign shader identity not restored') + love.graphics.setColor(1,1,1,1);love.graphics.draw(image,0,11) + love.graphics.setCanvas();love.graphics.setShader() + local pixels=canvas:newImageData() + for row,expect in pairs(expected)do + for x=0,3 do + local r,g,b,a=pixels:getPixel(x,row) + local rgb=expect.palette[x+1];local tint=expect.tint + local wanted={rgb[1]/255*tint[1],rgb[2]/255*tint[2],rgb[3]/255*tint[3], + expect.keyed and x==0 and 0 or (1-x/4)*tint[4]} + for c,value in ipairs({r,g,b,a})do + check(math.abs(value-wanted[c])<=2/255,'pixel mismatch row '..row..' shade '..x..' channel '..c) + end + end + end + for x=0,3 do + local pixel={pixels:getPixel(x,11)} + for c,n in ipairs({0.2,0.4,0.6,0.8})do check(math.abs(pixel[c]-n)<=2/255,'foreign shader pixel not restored')end + end + end) + love.graphics.pop() + P.setShadeMap(beforeMap);P.setMode(beforeMode);V.set(beforeVersion) + if not ok then error(why,0)end + return {passed=true,checks=checks,scope='GPU pixel equality for shade palette, tint, alpha, keying, nested success/error and foreign shader restoration'},canvas +end +return M diff --git a/tests/gpu/gen2_palette_register.lua b/tests/gpu/gen2_palette_register.lua new file mode 100644 index 000000000..72814580b --- /dev/null +++ b/tests/gpu/gen2_palette_register.lua @@ -0,0 +1,89 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- ROM-free pixel verification in a separate LÖVE host on each backend. +local M = {} +function M.run() + require('src.core.ModCompat').install() + local G = require('src.render.GbcPalette') + local P = require('src.render.PaletteFX') + local V = require('src.core.GameVersion') + local version, mode, map = V.get(), P.mode, P.shadeMap() + local g = love.graphics + local A={{251,241,239},{211,37,53},{17,181,89},{29,31,157}} + local B={{233,227,223},{13,67,197},{163,41,131},{3,7,11}} + local C={107,149,207} + local shades = love.image.newImageData(4,1,'rgba8') + local rgba = love.image.newImageData(9,1,'rgba8') + for i=0,3 do + local n=1-i/3;shades:setPixel(i,0,n,n,n,1-i/5) + for p,colors in ipairs({A,B}) do + local c=colors[i+1];rgba:setPixel((p-1)*4+i,0,c[1]/255,c[2]/255,c[3]/255,1-i/5) + end + end + rgba:setPixel(8,0,C[1]/255,C[2]/255,C[3]/255,0.4) + local indexed, colored = g.newImage(shades), g.newImage(rgba) + indexed:setFilter('nearest','nearest');colored:setFilter('nearest','nearest') + local canvas=g.newCanvas(17,257,{format='rgba8',dpiscale=1}) + local checks=0 + local function check(ok,why) checks=checks+1;assert(ok,why) end + g.push('all') + local ok,why=xpcall(function() + V.set('crystal');P.setMode('gbc') + g.setCanvas(canvas);g.clear(0,0,0,0);g.origin() + g.setBlendMode('replace','premultiplied');g.setColor(0.5,0.75,1,0.8) + local native = assert(P.shader(),'native palette shader unavailable') + for byte=0,255 do + G.setBgp(byte) + check(G.use(A),'forward shader unavailable') + g.draw(indexed,0,byte) + -- Native PaletteFX owns the same register and the real optimized + -- userdata upload path, rather than just a fake public shader object. + P.sendColors(native,A);g.setShader(native);g.draw(indexed,4,byte) + -- The explicit backward byte is independent of the standing register. + G.setBgp(0xff) + local applied,ambiguous=G.useRemap({A},byte,{B}) + check(applied and ambiguous==0,'backward shader unavailable or ambiguous') + g.draw(colored,8,byte) + end + G.setBgp(nil) + local display=P.displayColors + P.displayColors=function()return B end + local changed,changeError=pcall(function() + if P._beginColorBatch then check(P._beginColorBatch(native)==nil,'overridden display resolver entered a trusted native batch') end + P.sendColors(native,A);g.setShader(native);g.draw(indexed,0,256) + end) + P.displayColors=display + if not changed then error(changeError,0) end + g.setCanvas();g.setShader() + local pixels=g.readbackTexture and g.readbackTexture(canvas) or canvas:newImageData() + for byte=0,255 do + for x=0,16 do + local color,alpha + if x<8 then + local shade=x%4;local field=math.floor(byte/4^shade)%4 + color,alpha=A[field+1],1-shade/5 + elseif x<12 then + local shade=x-8;local field=math.floor(byte/4^shade)%4 + color,alpha=A[field+1],1-shade/5 + elseif x<16 then color,alpha=B[x-11],1-(x-12)/5 + else color,alpha=C,0.4 end + local wanted={color[1]/255*0.5,color[2]/255*0.75,color[3]/255,alpha*0.8} + -- PaletteFX's native final-zone shader deliberately ignores the + -- draw tint. The public indexed/remap shaders compose with it. + if x>=4 and x<8 then wanted={color[1]/255,color[2]/255,color[3]/255,alpha} end + local actual={pixels:getPixel(x,byte)} + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255, + 'register '..byte..' pixel '..x..' channel '..c..' got '..actual[c]..' expected '..wanted[c])end + end + end + for x=0,3 do + local actual={pixels:getPixel(x,256)} + local wanted={B[x+1][1]/255,B[x+1][2]/255,B[x+1][3]/255,1-x/5} + for c=1,4 do check(math.abs(actual[c]-wanted[c])<=2/255,'native upload ignored replaced display resolver')end + end + end,debug.traceback) + g.pop();P.setShadeMap(map);P.setMode(mode);V.set(version) + indexed:release();colored:release();shades:release();rgba:release() + if not ok then canvas:release();error(why,0) end + return {passed=true,checks=checks,scope='All 256 BGP bytes: native and public indexed draws, composited BG permutation, OBJ preservation, unrelated RGB preservation, alpha and tint'},canvas +end +return M