diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 40147fae..3030138f 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"` when you mean everywhere. `docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold -and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1, +and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their writes with a report, and which hooks and events are still to come. `docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 0e7e7ffd..896b9187 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -24,7 +24,7 @@ The short version, for an author deciding what to write: merged.** The write is taken, dropped, and named once per mod in the same error feed the mod manager shows -- in both directions, so a Red boot writing to `decorations` is told exactly as a Gold boot writing to `map_scripts` is. -- **40 event names and 43 hook names have a call site in both generations**, so +- **40 event names and 44 hook names have a call site in both generations**, so one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua` reads those names back out of the source and fails if a site is renamed or deleted on either side, and fails again if a new shared site appears without @@ -539,7 +539,8 @@ gains a field instead of the name gaining a prefix. `battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`, `battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`, `pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`, - `battle.crit`, `battle.accuracy`, `battle.turn_order`, + `battle.crit`, `battle.accuracy`, `battle.charge_required`, + `battle.turn_order`, `battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`, `catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`, `battle.catch_exp`, `battle.bottom_ui_visible`, diff --git a/docs/modding.md b/docs/modding.md index 4016e1f1..7c724404 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file` digests. `format` is either `"raw"` (the default) or `"n64"`. An optional `description` gives players dump or region guidance in the import panel. `size` declares the exact canonical byte length; `max_size` declares a smaller -per-import ceiling when an exact size is not appropriate. Every import also -has an engine-enforced 128 MiB ceiling and is rejected before hashing when its -filesystem reports an invalid size. +per-import ceiling when an exact size is not appropriate. The engine hard limit +is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and +use the launcher's streaming large-file path rather than being materialized as +one Lua string. For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders, strips a recognized 512-byte copier header, converts the bytes to canonical big-endian `.z64` order, and then checks MD5. The canonical bytes are written to `mods//baseroms/`. Each selection is a private grant to that mod: the launcher never scans or copies another mod's imported files merely -because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for -example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem +because its manifest names the same digest. Small sources can still be read +with the existing scoped `mod:read` API, for example +`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded +`mod.imports` facade described below; no host path or new general filesystem permission is exposed. Missing `required_imports` block the mod before its entry chunk runs; missing `optional_imports` remain visible in the same launcher panel but do not block loading. +#### Bounded access to validated imports + +A loaded mod can address only ids declared by its own `required_imports` or +`optional_imports` arrays: + +```lua +local info, err = mod.imports:info("stadium2") +local header, err = mod.imports:read("stadium2", 0, 4096) +``` + +`read` uses zero-based offsets and is capped at 8 MiB per call. The engine +rechecks the stored import before exposing it, seeks into the engine-owned +copy, and never gives the mod a host path or file handle. This is intended for +large source formats whose table/index can be parsed with small reads before +selectively reading the payloads a transform actually needs. + MD5 here identifies a known dump because ROM databases commonly publish it; it is not a security or authenticity guarantee. Do not paste the SHA-1 used by Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives @@ -441,6 +460,28 @@ default** (1x front, 2x back). ball-to-pic grow multiplies your scale through each stage, so a rescaled mon still grows into place from the ball, grounded the whole way. +## Installation-scoped generated cache + +Generated data derived from a validated user source often belongs to the mod +installation rather than to one Pokémon save. `mod.cache` is that namespace: + +```lua +local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena) +local bytes, err = mod.cache:read("extract/v1/arena.bin") +local info = mod.cache:info("extract/v1/arena.bin") +mod.cache:delete("extract/v1/arena.bin") +``` + +The physical root is engine-owned (`mod_cache//`) and never exposed to +the mod. Keys are safe relative paths and a single write is capped at 64 MiB. +The cache does not rewind with checkpoints and is not scoped to game version, +slot, or playthrough. The mod owns its generated format, fingerprints, rebuild +policy, and completion marker; the engine treats the bytes as opaque data. + +Use `mod.storage` instead when the data belongs to one playthrough. Use +`mod.cache` when it is a reproducible installation artifact that can be rebuilt +from a declared user source. + ## Durable tool storage and runtime checkpoints `mod.save` remains the right place for state that should travel with the next @@ -633,6 +674,17 @@ the selected indices. Mods remain responsible for selection policy and should use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact contract and compatibility guarantees. +Both battle engines expose the guarded `battle.charge_required` hook when a +charge-capable move is selected for its initial turn and the active ruleset +would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is +`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to +skip only that initial charge and continue through the ordinary move pipeline; +call `next(ctx)` to keep it. The hook does not run for the release turn or when +the active ruleset already skips charging (for example, Gold Solarbeam in +sun). PP use, accuracy, damage, animation, and secondary effects remain owned +by the engine. With no subscriber, the vanilla decision runs without building +the hook context. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/rfcs/0008-streamed-mod-imports-and-install-cache.md b/docs/rfcs/0008-streamed-mod-imports-and-install-cache.md new file mode 100644 index 00000000..22240416 --- /dev/null +++ b/docs/rfcs/0008-streamed-mod-imports-and-install-cache.md @@ -0,0 +1,141 @@ +# RFC 0008 — Streamed mod imports and installation-scoped generated cache + +## Motivation + +`required_imports`/`optional_imports` can now describe files up to 2 GiB, but +the existing launcher and public mod API still assume imported bytes are small: + +* the Windows desktop picker stages a selected required import through a fixed + `%TEMP%/pokeport_required_import.bin` path before validation; +* the fallback import path materializes the selected file as one Lua string; +* after validation a mod can only use `mod:read("baseroms/...")`, which also + materializes the whole file; +* `mod.storage` is intentionally scoped to one Pokémon playthrough, so it is + not an appropriate home for a one-time generated asset cache shared by every + save using the same installed mod. + +This makes optical-disc-sized user sources impractical even though the manifest +schema already accepts them. A failed temporary staging copy can also turn a +valid large source into a smaller temporary file and produce a misleading +"wrong file size" rejection. + +A mod should be able to consume its own already-validated source incrementally +and compile derived runtime data once, without receiving a host path or general +filesystem access. + +## Decision being extended + +This extends the same legal/sandbox direction as **D11 asset transforms** +(`src/mods/AssetTransform.lua`): mods distribute recipes and derive bytes from +user-owned sources rather than shipping ROM-derived data. It also follows the +**D14 parity-gate** contract referenced by `tests/harness.lua` and +`tests/engine/gate_meta_coverage.lua` (the `21-testing-and-ci` plan): additive +extension points ship public-API coverage, no-mod parity coverage, and docs in +the same change. + +The historical D11 plan document is referenced by source comments but is not +present in the current repository tree; this RFC is the checked-in design +record for the new surface. + +## Exact API delta + +No manifest field changes. Existing `required_imports` and `optional_imports` +remain the declaration/validation authority. + +Two additive facades are added to the `mod` object. + +### `mod.imports` + +```lua +local info, err = mod.imports:info("source_id") +local bytes, err = mod.imports:read("source_id", offset, length) +``` + +* `source_id` must name an import declared by the calling mod. +* the import is rechecked through `RequiredImports.validateStored` before it is + exposed, so missing, replaced, or invalid optional imports are not readable; +* `offset` and `length` are zero-based byte coordinates; +* one read is capped at 8 MiB; +* no host path or file handle is returned; +* production reads seek into the engine-owned stored copy instead of reading + the whole source. + +`info()` returns declaration metadata plus stored size. It does not expose a +host path. + +### `mod.cache` + +```lua +mod.cache:write("extract/v1/model.bin", bytes) +local bytes = mod.cache:read("extract/v1/model.bin") +local info = mod.cache:info("extract/v1/model.bin") +mod.cache:delete("extract/v1/model.bin") +``` + +The cache is rooted at `mod_cache//`, follows the engine persistence +backend, and is independent of game version, launcher slot, and playthrough. +Paths are checked with `SafePath`; `..`, absolute paths, drive paths, and other +escapes remain unavailable. A single cache write is capped at 64 MiB so large +generated datasets are naturally split into independently replaceable files. + +The engine does not interpret cache bytes. Mods own generated-format versioning, +fingerprints, transactional completion markers, and rebuild policy. + +## Launcher/import transport delta + +For large raw required imports: + +1. desktop pickers return the original selected path instead of staging it + through a fixed temporary file; +2. the engine opens that source itself; +3. bytes are copied directly to the existing engine-owned + `mods//baseroms/` destination in 4 MiB chunks; +4. MD5 is updated incrementally during the copy; +5. the normal size/MD5 validation receipt is written only after the complete + destination passes validation; +6. partial destinations are removed on short reads, write failure, size + mismatch, or digest mismatch. + +N64 imports stay on the existing canonicalization path because byte-order and +copier-header normalization require transformation rather than a raw copy. + +If a validation receipt for an already-stored large raw import is missing, the +engine rebuilds it with streaming MD5 rather than a whole-file read. + +## Backward compatibility / migration + +**Existing mods do nothing.** This is additive. + +* manifest v1/v2 fields are unchanged; +* `mod:read`, `mod.storage`, registries, events, hooks, and legacy compatibility + retain their existing behavior; +* small required imports retain the existing in-memory validation path; +* N64 imports retain canonicalization and existing accepted byte orders; +* a mod that never touches `mod.imports` or `mod.cache` creates no new cache + files and observes no new behavior. + +The mod API integer is not bumped because no existing member changes meaning or +shape. + +## Security and legal posture + +The launcher remains the authority that validates user-supplied bytes. The new +facade narrows access rather than widening it: a mod can read only ids declared +in its own manifest, only after validation, and only in bounded ranges. It does +not receive host paths, `io`, or a raw filesystem handle. + +`mod.cache` is writable only beneath the calling mod's generated-cache root. +Nothing in this RFC permits packaged ROM-derived bytes; `modkit lint/pack` +continue to enforce the existing legal posture. + +## Parity guarantee + +The change ships with: + +* a no-mod/API-v1 parity test proving an empty load and an existing v1-style + `mod:read` load do not create cache data or change the old surface; +* a public mod-API test that reaches `mod.imports` and `mod.cache` through a + real `Loader` load, including bounded reads, undeclared/missing imports, + cache isolation, and traversal rejection; +* incremental MD5 vectors and a large-import streaming regression test; +* the existing engine suite, required-import suite, and mod lint gates. diff --git a/docs/rfcs/0011-battle-charge-required.md b/docs/rfcs/0011-battle-charge-required.md new file mode 100644 index 00000000..310bec00 --- /dev/null +++ b/docs/rfcs/0011-battle-charge-required.md @@ -0,0 +1,92 @@ +# RFC 0011: Charge-required battle hook + +## Status + +Proposed. + +## Motivation + +A battle-mechanics mod can change damage through `battle.damage` and register +move effects, but it cannot conditionally skip the first turn of an existing +charge move. In Gen 1, the engine decides and stores the charge continuation +before any public effect callback can run. Reaching into `user.charging`, +`user.chargeReady`, or generation-specific volatile state is private, +checkpoint-fragile, and would require a mod to duplicate move-pipeline policy. + +Weather is the immediate example: a portable sun rule needs Solarbeam to +resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation, +and secondary effects to the engine. The capability is generic and useful to +other ruleset and move-mechanics mods. + +## Decision and plan extended + +This implements **D-AT-002: charge-stage policy remains mod authority through a +generic guarded engine decision seam**. The consuming design is tracked in the +Adaptive Trainers implementation plan, +[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md), +Task 8. The delta follows the additive, guarded hook convention documented by +Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or +Adaptive Trainers policy. + +## Exact API delta + +Both the Gen 1 and Gen 2 battle engines add this guarded hook: + +```lua +mod.hooks:wrap("battle.charge_required", function(next, ctx) + -- ctx = { + -- battle = live battle controller, + -- user = attacking battler, + -- target = defending battler, + -- move = merged move record, + -- charge = true, + -- isCalled = false, + -- } + if should_resolve_now(ctx) then return false end + return next(ctx) +end) +``` + +The call site is the initial-use charge decision, after announcement and PP +handling but before charge state, invulnerability, charge animation, or charge +text is created. It runs only when the active engine rules would otherwise +require a charge. It does not run on the release turn. Returning exactly +`false` skips that initial charge and continues through the engine-owned move +pipeline. Any other downstream return preserves the charge. `isCalled` is true +when Metronome or Mirror Move selected the move. + +Gold keeps its native sun decision first, so Solarbeam in native sun already +requires no charge and does not invoke the hook. Gen 1 link battles use the +shared Gen 1 move pipeline and therefore receive the same seam; normal link +mod-compatibility rules continue to govern deterministic peers. + +The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no +subscriber, no hook payload table is allocated and the existing branch runs +unchanged. + +## Migration and compatibility + +Existing mods change nothing. The hook name and payload are additive. With no +wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous +charge state, PP use, text, animation, accuracy, damage, and native weather +behavior. Existing charge-move data and effect records require no migration. + +A mod adopting the seam should call `next(ctx)` unless it deliberately wants to +skip this charge. It should not mutate private charge fields or re-run the move. + +## Verification + +- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2 + engines through a sandboxed public mod, including false-to-skip, next-to-keep, + release-turn behavior, called-move PP semantics, shared payload shape, and + native Gold sun behavior. +- The same test proves no-mod charge/release parity and replaces + `Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard. +- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty + chains preserve vanilla values and allocation behavior. +- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both + generations and keeps the compatibility reference list complete. + +## Deprecation etiquette + +Nothing is removed, renamed, superseded, or deprecated. diff --git a/docs/skin-studio.md b/docs/skin-studio.md index ff487233..ecae75a7 100644 --- a/docs/skin-studio.md +++ b/docs/skin-studio.md @@ -1,11 +1,11 @@ # Touch skins and the Skin Studio A **skin** replaces the on-screen controls wholesale: a bezel image, a -control layout, and the rectangle the Game Boy screen is drawn into. Engine: +control layout, and a screen-placement anchor. Engine: `src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua` -(draw and input), `src/render/Renderer.lua` (the screen viewport), +(draw and input), `src/render/Renderer.lua` (screen placement), `src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export), -`src/ui/SkinStudio.lua` (the desktop editor). Tests: +`src/ui/SkinStudio.lua` (the responsive skin editor). Tests: `tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`, `tests/engine/skin_studio_ux.lua`, `tests/engine/skin_studio_image_import.lua`, @@ -13,8 +13,10 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine: `tests/engine/launcher_skins_tab.lua`, `tests/engine/launcher_skins_ux.lua`. -Skins are picked in the launcher's **Skins** tab, which also imports them and -opens the studio. `options.touchControls.skin` holds the folder name. +The launcher's **Skins** tab imports skins, shows the enabled skin, exports it, +and is the one place that turns skin use off. **My Skins** holds the visual +grid, pagination, edit, delete and per-skin export actions. +`options.touchControls.skin` holds the folder name. ## Formats @@ -30,7 +32,7 @@ as-is. Supported keys: | `overlays` | page count | | `overlayN_name` | page name, the target of `next_target` | | `overlayN_overlay` | bezel image | -| `overlayN_full_screen` | stretch the page to the window | +| `overlayN_full_screen` | cover the window with the page without deforming its artwork | | `overlayN_rect` | page placement, default `0,0,1,1` | | `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen | | `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults | @@ -98,8 +100,8 @@ corners fire two directions. `screens[1].outputFrame` (or the legacy `gameScreenFrame`) becomes the screen cutout. A portrait page with neither keeps `mappingSize` as the overlay aspect, sits at the bottom of the window, and puts the Game Boy picture in the leftover space above -- the -usual GBA4iOS controller-deck layout. Pages that name a screen rect still -stretch to the window the way Delta does. Host functions map to +usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the +game into it. Host functions map to engine hotkeys: `menu` to `menu_toggle`, `fastForward` to `hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`; `quickSave` and `quickLoad` have nothing to bind to and drop to decoration. @@ -135,7 +137,7 @@ to decoration and never captures a touch. As an extension to the format, `key:` presses any keyboard key, which is how a skin button reaches a mod hotkey. -## The screen viewport +## Screen placement `overlayN_viewport` is the cutout the picture is fitted into. The Game Boy screen keeps its whole-pixel scale and letterboxes inside that rect rather than @@ -146,6 +148,17 @@ that lets a widescreen bezel take the filling survey-zoom world view instead. A viewport also implies the faithful-ratio lock. Without it the world pass expands to fill the cutout and you get more map instead of a Game Boy screen. +Zoom still steps around that hole: OUT shows more map inside it, IN enlarges +the world, and the start menu stays at the hole's fit scale instead of +shrinking with the map. + +An image-backed portrait overlay that has no explicit vertical anchor is treated +as a controller deck: it is contained without deformation and pinned to the +bottom on taller screens. The spare space belongs to the game above it. + +When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the +skin rather than the normal Center / Upper / Top setting. + Border art often ships with a transparent hole and no `viewport` key. **Detect screen from bezel** in the studio measures the hole out of the art's alpha channel and writes the rect. @@ -153,10 +166,8 @@ channel and writes the rect. ## Bezels versus pads A skin whose active page binds nothing is a frame rather than a pad: a TV -surround, a handheld shell, a Super Game Boy border. Those draw on **desktop** -as well, where the touch overlay itself does not, and a gamepad does not hide -them. Anything that binds a button still follows the usual mobile / -`POKEPORT_TOUCH` rule. +surround, a handheld shell, a Super Game Boy border. Selected skins draw on +**desktop** as well as mobile; a gamepad does not hide them. ## Installing @@ -190,9 +201,20 @@ shipping branding. ## The studio -Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open -that skin. Desktop only: the launcher does not offer it on Android or iOS, -because it wants a mouse, typed coordinates and room for an inspector. +Launcher, Skins tab, **My Skins** opens the Studio library on desktop and +mobile. **My Skins** is the only visual grid: real bezel previews plus create +and import actions, with each card owning Edit, Export and (for installed +skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old +New/Load workspace controls are deliberately not duplicated inside that editor. + +The editor keeps its canvas unobstructed and puts the contextual actions in a +compact lower tray: add/control binding, button and bezel artwork, pages, +screen placement, freeform/10:9 screen shape and deletion. Touches select, drag and resize the +same controls that a mouse edits on desktop. + +The launcher’s **Turn skins off** button clears the selected skin and disables +skin use. With no skin enabled, mobile falls back to the built-in pad; that pad +is not itself a skin card. **Canvas.** A mock device at a chosen preset, so a phone skin is authored at phone proportions on a desktop monitor. @@ -216,13 +238,16 @@ and of the page itself when it comes within a few pixels, and the guide it snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be typed to the coordinate its art was drawn at. **Back** and **Front** move the selection through the draw order. Bind, hitbox shape, hit reach and idle and -pressed images are per control; the bezel, the pages and the screen cutout are -per page. The cutout is itself a draggable element with a 10:9 lock. +pressed images are per control; the bezel, the pages and the screen anchor are +per page. The SCREEN anchor is itself draggable and resizable; its default +shape is freeform, with an optional 10:9 lock. **Bind** opens a grid of every bind the engine understands: the eight Game Boy -buttons, the diagonal pairs, every hotkey, a few `key:` entries, and -decoration. The COMBINE chips at the top toggle one part at a time, which is -how a pipe bind like `left|down` is built without typing it. +buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration. +The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10` +as `key:` controls, so a mobile button invokes the exact same game path as +its desktop shortcut. The COMBINE chips at the top toggle one part at a time, +which is how a pipe bind like `left|down` is built without typing it. **Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or `u` / shift+`u` without a keyboard modifier). The stack holds the last 50 @@ -247,12 +272,13 @@ the **Import** button there and beside each row opens the host file picker (`src zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the window does the same for whichever slot was last touched. A new bezel does not -move the screen cutout: press **Detect screen from bezel** to measure it out of +move the screen anchor: press **Detect screen from bezel** to measure it out of the art's alpha. -**Testing.** **Test** makes the canvas live: clicking presses real Game Boy -buttons and the footer reports what is held. **Play** saves the skin, selects -it, and boots the game with it. +**Testing.** **Test** renders a game-composition preview behind the live overlay: +the 160x144 picture letterboxes inside the screen cutout, matching gameplay. +Clicking presses real Game Boy buttons and the footer reports what is held. +**Play** saves the skin, selects it, and boots the game with it. **Saving.** **Save** writes `skins//skin.lua` and copies every image the skin names, so the folder stands alone. **Export** offers three formats, and diff --git a/main.lua b/main.lua index 2c352977..c73b10ec 100644 --- a/main.lua +++ b/main.lua @@ -302,6 +302,8 @@ local function makeLauncher() forceImport = forceImport, onEditSave = openEditor, onEditTouchControls = openTouchControlsEditor, + -- Skin Studio owns a touch-first layout as well as the desktop workspace. + -- Keep the compatibility predicate so external hosts using it still work. onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop() and openSkinStudio or nil, }) @@ -877,6 +879,8 @@ love.handlers = love.handlers or {} function love.handlers.audiosuspend() local ChipAudio = package.loaded["src.core.ChipAudio"] if ChipAudio then pcall(ChipAudio.setSuspended, true) end + local Sound = package.loaded["src.core.Sound"] + if Sound then pcall(Sound.onDeviceReset) end end function love.handlers.audioreset() @@ -929,7 +933,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchpressed(id, x, y) end - if Studio then return end + if Studio then return Studio.touchpressed(id, x, y) end if Importer then -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are -- polled inside the view; the istouch filter on mousepressed still drops @@ -946,7 +950,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchmoved(id, x, y) end - if Studio then return end + if Studio then return Studio.touchmoved(id, x, y) end if Importer then return Importer:touchmoved(id, x, y, dx, dy, pressure) end @@ -960,7 +964,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchreleased(id, x, y) end - if Studio then return end + if Studio then return Studio.touchreleased(id, x, y) end if Importer then return Importer:touchreleased(id, x, y, dx, dy, pressure) end @@ -1013,7 +1017,12 @@ function love.mousepressed(x, y, button, istouch) if love.system.getOS() == "Android" then return end return TouchEditor.mousepressed(x, y, button) end - if Studio then return Studio.mousepressed(x, y, button) end + if Studio then + -- Mobile LÖVE sends both a touch event and an `istouch` mouse twin. + -- Studio consumes the real finger stream above, so discard the twin. + if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end + return Studio.mousepressed(x, y, button) + end if Importer then -- love.touchpressed already forwards the primary touch into FlexLove for -- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both @@ -1048,7 +1057,10 @@ function love.mousereleased(x, y, button, istouch) if love.system.getOS() == "Android" then return end return TouchEditor.mousereleased(x, y, button) end - if Studio then return Studio.mousereleased(x, y, button) end + if Studio then + if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end + return Studio.mousereleased(x, y, button) + end if Importer then return end if editorMode and EditorApp.mousereleased then return EditorApp.mousereleased(x, y, button) @@ -1066,7 +1078,10 @@ function love.mousemoved(x, y, dx, dy, istouch) if love.system.getOS() == "Android" then return end return TouchEditor.mousemoved(x, y) end - if Studio then return Studio.mousemoved(x, y) end + if Studio then + if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end + return Studio.mousemoved(x, y) + end if editorMode or Importer then return end if mouseTouch then if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index a12254e6..2c7d8798 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -54,7 +54,7 @@ 0, + }) + if required == false then charge = nil end + end if charge and not charging then state.chargeMove = moveId state.vanished = charge.vanish or nil diff --git a/src/core/Game.lua b/src/core/Game.lua index 389c8aa1..85bedf24 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -919,16 +919,7 @@ function Game:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value) end --- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the --- generic joystick path anyway: any sensor-style device that still reaches us --- has gravity pinning an axis past the deadzone, which would hide the touch --- overlay every instant and steer the player by tilt through the axis-1/2 --- mapping (#459). Real controllers arrive as SDL gamepads or named sticks, --- never as "* Accelerometer". -local function isAccelerometer(joystick) - local name = joystick and joystick.getName and joystick:getName() - return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil -end +local isAccelerometer = GamepadMap.isAccelerometer -- BindingsMenu's raw-stick capture rides the same top-state routing as the -- keyboard and gamepad paths (#632). Only a stick SDL does not recognize diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 3eee6a6a..c1236a37 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -18,6 +18,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Clock = require("src.core.gen2.Clock") local FixedStep = require("src.core.FixedStep") local Font = require("src.render.Font") +local GamepadMap = require("src.core.GamepadMap") local Input = require("src.core.Input") local Music = require("src.core.Music") local Save = require("src.core.gen2.Save") @@ -57,20 +58,6 @@ Game2.__index = Game2 local function noop() end -for _, name in ipairs({ - "joystickpressed", "joystickreleased", "joystickaxis", "joystickhat", - "joystickadded", -}) do - Game2[name] = noop -end - --- Not a noop, because the overlay has to come back on its own: a player who --- unplugs the only controller would otherwise have to tap a blind screen to --- get the pad back (src/core/Game.lua:869 does the same). -function Game2:joystickremoved() - TouchControls:joystickremoved() -end - -- THE FRAME AND INPUT SEAMS. -- -- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad @@ -1968,7 +1955,11 @@ function Game2:applyOptions() local options = self.options or {} Music.applyOptions(options) require("src.core.Sound").applyOptions(options) - require("src.render.Zoom").applyOptions(options) + local Zoom = require("src.render.Zoom") + Zoom.applyOptions(options) + local caps = require("src.core.Performance").applyOptions(options) + Zoom.allowSurvey = caps.survey + if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end require("src.render.Tilt").applyOptions(options) require("src.render.GbcPalette").applyOptions(options) -- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame. @@ -1993,6 +1984,13 @@ function Game2:applyOptions() end end +function Game2:_cycleSpeed(dir) + local GameSpeed = require("src.core.GameSpeed") + self.options.speed = GameSpeed.cycle(self.options.speed, dir) + if self.save then self.save.options = self.options end + self:persistOptions() +end + -- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS -- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been -- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written @@ -2003,27 +2001,49 @@ end -- the PACK's move-item, the party menu's reorder and half the soft-reset chord -- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button -- to find out killed the process. It reaches Input like every other button now. -function Game2:gamepadpressed(_joystick, button) +function Game2:gamepadpressed(joystick, button) -- a controller is being used: the touch overlay steps aside until the next -- screen touch (mobile only; a no-op elsewhere) TouchControls:noteGamepad() - -- The shoulders cycle GAME SPEED, as they do in the Gen 1 path. - if button == "rightshoulder" or button == "leftshoulder" then - local GameSpeed = require("src.core.GameSpeed") - local dir = button == "rightshoulder" and 1 or -1 - self.options.speed = GameSpeed.cycle(self.options.speed, dir) - if self.save then self.save.options = self.options end - self:persistOptions() + local selectHeld = Input:isDown("select") + if not selectHeld and joystick and joystick.isGamepadDown then + local ok, down = pcall(function() + return joystick:isGamepadDown("back") + end) + selectHeld = ok and down == true + end + -- shoulders and triggers cycle GAME SPEED, as in src/core/Game.lua:881 + if not selectHeld then + if button == "rightshoulder" or button == "righttrigger" then + self:_cycleSpeed(1) + return + elseif button == "leftshoulder" or button == "lefttrigger" then + self:_cycleSpeed(-1) + return + end + end + local top = self.stack and self.stack:top() + if top and top.onGamepadPressed then + top:onGamepadPressed(button) return end + if selectHeld then + local digit = GamepadMap.displayChordDigit(button) + if digit then + self:keypressed(digit) + return + end + end -- START opens the start menu in the overworld; it used to quit, from before -- there was a menu to open. - Input:gamepadpressed(_joystick, button) + Input:gamepadpressed(joystick, button) end function Game2:gamepadreleased(joystick, button) Input:gamepadreleased(joystick, button) + local top = self.stack and self.stack:top() + if top and top.onGamepadReleased then top:onGamepadReleased(button) end end function Game2:gamepadaxis(joystick, axis, value) @@ -2032,4 +2052,61 @@ function Game2:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value) end +-- The raw joystick road, same bodies as src/core/Game.lua:935 (#620, #632, #1570). +local function isRawStick(joystick) + return not (joystick and joystick.isGamepad and joystick:isGamepad()) +end + +function Game2:joystickpressed(joystick, button) + if GamepadMap.isAccelerometer(joystick) then return end + TouchControls:noteGamepad() + local top = self.stack and self.stack:top() + if isRawStick(joystick) and top and top.onJoystickPressed then + top:onJoystickPressed(button) + return + end + Input:joystickpressed(joystick, button) +end + +function Game2:joystickreleased(joystick, button) + if GamepadMap.isAccelerometer(joystick) then return end + Input:joystickreleased(joystick, button) + local top = self.stack and self.stack:top() + if isRawStick(joystick) and top and top.onJoystickReleased then + top:onJoystickReleased(button) + end +end + +function Game2:joystickaxis(joystick, axis, value) + if GamepadMap.isAccelerometer(joystick) then return end + if math.abs(value) > 0.5 then TouchControls:noteGamepad() end + Input:joystickaxis(joystick, axis, value) +end + +function Game2:joystickhat(joystick, hat, direction) + if GamepadMap.isAccelerometer(joystick) then return end + if direction ~= "c" then TouchControls:noteGamepad() end + Input:joystickhat(joystick, hat, direction) +end + +-- src/core/Game.lua:1015 (#799) +function Game2:recoverInput() + Input:reset() + Input:reconcile() + TouchControls:reset() + if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end + self:cancelPointers() +end + +function Game2:joystickadded() + self:recoverInput() +end + +-- The overlay comes back on its own when the last pad is unplugged +-- (src/core/Game.lua:1044). +function Game2:joystickremoved() + self:recoverInput() + TouchControls:joystickremoved() +end + return Game2 diff --git a/src/core/GamepadMap.lua b/src/core/GamepadMap.lua index 9d8b921f..87e90e31 100644 --- a/src/core/GamepadMap.lua +++ b/src/core/GamepadMap.lua @@ -106,6 +106,17 @@ function GamepadMap.ignoreRawForJoystick(joystick) return ok and isPad == true end +-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the +-- generic joystick path anyway: any sensor-style device that still reaches us +-- has gravity pinning an axis past the deadzone, which would hide the touch +-- overlay every instant and steer the player by tilt through the axis-1/2 +-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks, +-- never as "* Accelerometer". +function GamepadMap.isAccelerometer(joystick) + local name = joystick and joystick.getName and joystick:getName() + return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil +end + function GamepadMap.mapRawButton(index) if nxActive() then local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index] diff --git a/src/core/Input.lua b/src/core/Input.lua index 5ff73007..3add0629 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -281,6 +281,7 @@ end function Input:joystickpressed(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end noteCapture(self, "joy", "pressed", button) local btn = self.joyBindings[button] if btn then press(self, btn, "joy:" .. button) end @@ -288,6 +289,7 @@ end function Input:joystickreleased(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end noteCapture(self, "joy", "released", button) local btn = self.joyBindings[button] if btn then release(self, btn, "joy:" .. button) end @@ -330,6 +332,7 @@ end function Input:joystickaxis(joystick, axis, value) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end if axis == 1 then self:gamepadaxis(joystick, "leftx", value) elseif axis == 2 then @@ -343,6 +346,7 @@ end -- directions on top of a direction rebind. function Input:joystickhat(joystick, hat, direction) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end local source = "hat:" .. hat for _, btn in ipairs(self.hatDirs[hat] or {}) do release(self, btn, source) @@ -380,7 +384,8 @@ function Input:reconcile() local ok, joysticks = pcall(js.getJoysticks) if not ok or type(joysticks) ~= "table" then return end for _, j in ipairs(joysticks) do - if GamepadMap.ignoreRawForJoystick(j) then + if GamepadMap.isAccelerometer(j) then + elseif GamepadMap.ignoreRawForJoystick(j) then -- SDL-recognized pad: buttons + left stick, the gamepad surfaces if j.isGamepadDown then for button, btn in pairs(self.padBindings) do diff --git a/src/core/Orientation.lua b/src/core/Orientation.lua index e166a7ca..26a867e6 100644 --- a/src/core/Orientation.lua +++ b/src/core/Orientation.lua @@ -1,4 +1,4 @@ --- Screen orientation lock, Android only (#592, #716). +-- Screen orientation lock, Android and iOS (#592, #716, #1638). -- -- Persisted as options.orientation: "auto" | "portrait" | "landscape" | -- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS: @@ -10,16 +10,12 @@ -- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE -> -- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone. -- --- SDL only re-reads the hint when the window is created or its resizable --- flag changes (SDL_androidwindow.c: Android_CreateWindow / --- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE --- 11.5 exposes neither hints nor a resizable setter, so apply() goes through --- the FFI to SDL's C API: set the hint, then pulse the window's resizable --- flag off and back on -- each edge makes the Android backend recompute the --- requested orientation, so a change from the launcher or the OPTION menu --- takes hold immediately, and the flag ends where it started (conf.lua sets --- resizable on mobile). Everything is pcall-guarded: desktop, iOS (the --- Info.plist governs there) and headless stubs make this a no-op. +-- Android only re-reads the hint at window creation or on a resizable-flag +-- change (SDL_androidwindow.c), and SDL_SetWindowResizable early-returns on +-- a fullscreen window (SDL_video.c:2237) -- which LOVE's Android window +-- always is -- so the hint never reached a running activity (#1638). +-- apply() sets the hint for a later window, then goes over JNI for the live +-- one. iOS needs only the hint. Desktop and headless stubs no-op. local Orientation = {} @@ -58,6 +54,11 @@ function Orientation.isAndroid() return love.system.getOS() == "Android" end +function Orientation.isIOS() + if not love or not love.system or not love.system.getOS then return false end + return love.system.getOS() == "iOS" +end + function Orientation.cycle(mode, dir) local cur, idx = Orientation.normalize(mode), 1 for i, m in ipairs(Orientation.MODES) do @@ -67,6 +68,15 @@ function Orientation.cycle(mode, dir) return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1] end +-- ActivityInfo constants, what setOrientationBis lands on per hint after +-- GameActivity's *_SENSOR -> *_USER remap (#716). +local REQUESTED = { + auto = 13, + portrait = 1, + landscape = 11, + reverseLandscape = 8, +} + -- The SDL2 C API this module needs. cdef errors on redefinition, so run it -- once and remember whether it took; ffi itself may be absent (plain Lua -- test interpreters), hence the pcall'd require. @@ -77,33 +87,79 @@ local function sdlFfi() if cdefOk == nil then cdefOk = pcall(ffi.cdef, [[ typedef struct SDL_Window SDL_Window; + typedef union { int32_t i; int64_t pad; } love_jvalue; int SDL_SetHint(const char *name, const char *value); SDL_Window *SDL_GL_GetCurrentWindow(void); void SDL_SetWindowResizable(SDL_Window *window, int resizable); + void *SDL_AndroidGetJNIEnv(void); + void *SDL_AndroidGetActivity(void); ]]) end if not cdefOk then return nil end return ffi end --- Push the mode into the live activity. Returns true when the hint reached --- SDL (the symbols resolved), false on any non-Android / stubbed platform. +-- Slot numbers in JNINativeInterface (jni.h). +local JNI_EXCEPTION_CLEAR = 17 +local JNI_DELETE_LOCAL_REF = 23 +local JNI_GET_OBJECT_CLASS = 31 +local JNI_GET_METHOD_ID = 33 +local JNI_CALL_VOID_METHOD_A = 63 + +-- What Android_JNI_SetOrientation reaches, called directly: the hint path +-- cannot re-run on a live fullscreen window (SDL_video.c:2237). +local function setRequestedOrientation(ffi, requested) + local env = ffi.C.SDL_AndroidGetJNIEnv() + if env == nil then return false end + local activity = ffi.C.SDL_AndroidGetActivity() + if activity == nil then return false end + local fns = ffi.cast("void***", env)[0] + local getObjectClass = ffi.cast("void *(*)(void *, void *)", fns[JNI_GET_OBJECT_CLASS]) + local getMethodID = ffi.cast( + "void *(*)(void *, void *, const char *, const char *)", fns[JNI_GET_METHOD_ID]) + local callVoidMethodA = ffi.cast( + "void (*)(void *, void *, void *, love_jvalue *)", fns[JNI_CALL_VOID_METHOD_A]) + local deleteLocalRef = ffi.cast("void (*)(void *, void *)", fns[JNI_DELETE_LOCAL_REF]) + local exceptionClear = ffi.cast("void (*)(void *)", fns[JNI_EXCEPTION_CLEAR]) + + local ok = false + local cls = getObjectClass(env, activity) + if cls ~= nil then + local mid = getMethodID(env, cls, "setRequestedOrientation", "(I)V") + if mid ~= nil then + local args = ffi.new("love_jvalue[1]") + args[0].pad = 0 + args[0].i = requested + callVoidMethodA(env, activity, mid, args) + ok = true + end + exceptionClear(env) + deleteLocalRef(env, cls) + end + deleteLocalRef(env, activity) + return ok +end + +-- Returns true only when the request actually landed, never unconditionally +-- as it once did (#1638). function Orientation.apply(mode) - if not Orientation.isAndroid() then return false end + local android = Orientation.isAndroid() + if not (android or Orientation.isIOS()) then return false end local ffi = sdlFfi() if not ffi then return false end mode = Orientation.normalize(mode) - local ok = pcall(function() - -- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h); - -- despite the IOS in the string, the Android backend reads it too. + local ok, reached = pcall(function() + -- SDL_HINT_ORIENTATIONS is "SDL_IOS_ORIENTATIONS" in the SDL2 Android + -- ships and "SDL_ORIENTATIONS" in the SDL3 the iOS app links; each + -- engine ignores the other's key. ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode]) - local win = ffi.C.SDL_GL_GetCurrentWindow() - if win ~= nil then - ffi.C.SDL_SetWindowResizable(win, 0) - ffi.C.SDL_SetWindowResizable(win, 1) - end + ffi.C.SDL_SetHint("SDL_ORIENTATIONS", HINTS[mode]) + -- On iOS the hint is the lock: UIKit re-asks on every rotation + -- (SDL_uikitviewcontroller.m supportedInterfaceOrientations). + if not android then return true end + return setRequestedOrientation(ffi, REQUESTED[mode]) end) - return ok + return ok and reached == true end function Orientation.applyOptions(opts) diff --git a/src/core/Performance.lua b/src/core/Performance.lua index a3d2945a..b15b520e 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -86,8 +86,11 @@ function Performance.detect() local cores = processorCount() -- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project - -- already ships a build for): the weakest target here. - if isArm and os ~= "Android" and os ~= "iOS" then + -- already ships a build for): the weakest target here. Desktop ARM + -- (Apple Silicon "OS X", Windows-on-ARM) is not a handheld — those + -- used to resolve AUTO → LOW, which stripped survey zoom-out from + -- OPTIONS so the ZOOM row only offered IN. + if isArm and os == "Linux" then return "low" end -- Phones and tablets: GBC FX is already force-disabled here (issue #136); diff --git a/src/core/ScreenPosition.lua b/src/core/ScreenPosition.lua index d4834db4..506fccf6 100644 --- a/src/core/ScreenPosition.lua +++ b/src/core/ScreenPosition.lua @@ -12,10 +12,12 @@ function ScreenPosition.normalize(v) end function ScreenPosition.label(v) + if ScreenPosition.skinActive() then return "SKIN" end return LABELS[ScreenPosition.normalize(v)] end function ScreenPosition.cycle(v, dir) + if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end v = ScreenPosition.normalize(v) local modes = ScreenPosition.MODES local cur = 1 @@ -44,6 +46,9 @@ end function ScreenPosition.skinActive(w, h) local ok, TouchSkin = pcall(require, "src.core.TouchSkin") if not ok or type(TouchSkin.viewport) ~= "function" then return false end + if (not w or not h) and love and love.graphics and love.graphics.getDimensions then + w, h = love.graphics.getDimensions() + end local okv, x = pcall(TouchSkin.viewport, w, h) return okv and x ~= nil end diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 9aea0c7d..f2982353 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -154,8 +154,14 @@ local function newSfxSource(data, key, def, pitch, tempo, plain) return newFileSource(def) end +local function deviceSuspended() + local ChipAudio = package.loaded["src.core.ChipAudio"] + return ChipAudio ~= nil and ChipAudio.isSuspended() +end + local function playPath(data, key, def, pitch, tempo, plain) if not love.audio or not def then return nil end + if deviceSuspended() then return nil end local src = cache[key] if src == false then return nil end -- known bad, already logged if not src then @@ -602,6 +608,7 @@ end -- cache carries no clips (Red/Blue) or headless. function Sound.playPikaCry(data, n) if not love.audio then return nil end + if deviceSuspended() then return nil end local count = data.audio and data.audio.pikaCries if not count then return nil end n = math.max(1, math.min(count, n or 1)) @@ -633,6 +640,7 @@ end -- like the original's PlayCry -> WaitForSoundToFinish can poll it function Sound.playCry(data, species, pikaClip) if not love.audio then return nil end + if deviceSuspended() then return nil end -- Yellow voices every Pikachu cry with the PCM clips (the chip cry is -- never used for the species there). Which clip is a property of the -- call site in the original -- every caller of PlayPikachuSoundClip sets diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 32190f90..50c28048 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -293,7 +293,9 @@ function TouchControls:applyOptions(opts) -- launcher editor round-trips through config() (#806) self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics) TouchSkin.setOverlayLive(self.active) - self:selectSkin(cfg.skin) + -- Off means off everywhere: do not leave a hidden selected skin behind to + -- influence renderer placement on desktop or with a controller attached. + self:selectSkin(cfg.enabled and cfg.skin or nil) self.layouts = cfg.layouts self.layoutW, self.layoutH = nil, nil self.layoutOx, self.layoutOy = nil, nil @@ -335,7 +337,10 @@ function TouchControls:visible() local art = TouchSkin.active ~= nil or self.img ~= nil if self.preview then return art end if self.enabled == false or not art then return false end - if TouchSkin.active and TouchSkin.decorativeOnly() then return true end + -- A selected skin is also a desktop/TV bezel. Input remains gated in + -- touchpressed, but the artwork must not disappear when a controller is + -- connected or the platform is not touch-first. + if TouchSkin.active then return true end return self.active and not self.controllerHidden end @@ -778,12 +783,19 @@ local function drawIcon(img, zone, pressed, alphaMul) zone.cy - img:getHeight() * scale / 2, 0, scale, scale) end -local function drawStretched(img, x, y, w, h, alpha) +local function drawCovered(img, x, y, w, h, alpha) if not img or alpha <= 0 then return end local iw, ih = img:getWidth(), img:getHeight() if iw <= 0 or ih <= 0 then return end + -- Cover the assigned box with one uniform scale and crop the excess. The + -- old independent X/Y scale made portrait art visibly squash on wide + -- displays (and vice versa). + local s = math.max(w / iw, h / ih) + local dw, dh = iw * s, ih * s love.graphics.setColor(1, 1, 1, math.min(1, alpha)) - love.graphics.draw(img, x, y, 0, w / iw, h / ih) + love.graphics.setScissor(x, y, w, h) + love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s) + love.graphics.setScissor() end function TouchControls:drawSkin(alphaMul) @@ -795,7 +807,7 @@ function TouchControls:drawSkin(alphaMul) love.graphics.push("all") love.graphics.origin() - drawStretched(page.image, bx, by, bw, bh, opacity) + drawCovered(page.image, bx, by, bw, bh, opacity) local pressed = {} for _, touch in pairs(self.touches or {}) do @@ -810,7 +822,7 @@ function TouchControls:drawSkin(alphaMul) TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy) local alpha = opacity if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end - drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) + drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) end end diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index 1b79d620..23463f50 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -636,6 +636,19 @@ local function applyPixelScale(page) return true end +-- An overlay image is its own design canvas. Older RetroArch cfg files often +-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and +-- all of its normalized controls on the same uniform scale. +function TouchSkin.applyImageAspect(page) + if not page or page.aspectFromCfg or not page.image + or not page.image.getDimensions then return false end + local iw, ih = page.image:getDimensions() + if not iw or not ih or iw <= 0 or ih <= 0 then return false end + page.aspect = iw / ih + page.aspectFromImage = true + return true +end + function TouchSkin.load(root, id) local cfgPath, format, prefix = findConfig(root) if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end @@ -665,6 +678,7 @@ function TouchSkin.load(root, id) elseif page.pdfPath then rasterizePdfPage(page, root) end + TouchSkin.applyImageAspect(page) if not applyPixelScale(page) then return nil, "could not read " .. tostring(page.imagePath) .. ", which " .. page.name .. " measures its coordinates against" @@ -771,6 +785,28 @@ function TouchSkin.find(id) return nil end +-- Remove only a user-installed skin. Bundled skins are shipped with the +-- game and intentionally have no delete affordance. +function TouchSkin.remove(id) + local entry = TouchSkin.find(id) + if not entry then return nil, "no skin " .. tostring(id) end + if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end + if not (love and love.filesystem and love.filesystem.remove) then + return nil, "no writable filesystem" + end + local function removeTree(path) + if isDir(path) and love.filesystem.getDirectoryItems then + for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do + local ok, err = removeTree(path .. "/" .. name) + if not ok then return nil, err end + end + end + local ok, err = love.filesystem.remove(path) + return ok and true or nil, err + end + return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id)) +end + function TouchSkin.assetPaths(skin) local out, seen = {}, {} local function add(rel) @@ -1284,7 +1320,7 @@ function TouchSkin.pageBox(page, w, h, ox, oy) -- full_screen means "relative to the window, not the game viewport". -- When the cfg also names an aspect_ratio, that window is then fitted -- to the overlay's design aspect so buttons do not stretch. #1503 - local fit = ((not page.fullScreen) or page.aspectFromCfg) + local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage) and page.aspect and page.aspect > 0 and h > 0 if fit then local displayAspect = w / h @@ -1306,6 +1342,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy) by = oy + extra elseif anchor == "top" then by = oy + elseif page.aspect < 1 then + -- A portrait bezel with controls is a controller deck. On an + -- unusually tall display, pin the deck to the lower edge and leave + -- the additional room for the game above it. + by = oy + extra else by = oy + extra * 0.5 end @@ -1359,8 +1400,10 @@ function TouchSkin.decorativeOnly() end function TouchSkin.drawable() - if not TouchSkin.active then return false end - return TouchSkin.overlayLive or TouchSkin.decorativeOnly() + -- A selected skin is a presentation choice, not a mobile-only input mode. + -- Its artwork and screen placement therefore belong on every platform; + -- `overlayLive` still controls whether touch input is available. + return TouchSkin.active ~= nil end function TouchSkin.hasViewport() @@ -1410,6 +1453,16 @@ function TouchSkin.pageViewport(page, w, h, ox, oy) return nil end +-- Centre of the page's screen cutout. The renderer fits the 160x144 +-- picture into that rect; this helper is for the studio preview. +function TouchSkin.screenCenter(w, h, ox, oy, page) + page = page or TouchSkin.page() + if not page then return nil end + local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy) + if not x then return nil end + return x + vw * 0.5, y + vh * 0.5 +end + function TouchSkin.viewport(w, h, ox, oy) local page = TouchSkin.page() if not page or not TouchSkin.drawable() then return nil end diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index a3bfd889..00ef8e99 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -287,6 +287,43 @@ function CacheFs.write(rel, data) return love.filesystem.write(rel, data) end +-- Open a cache-relative file for streaming replacement. The returned handle +-- has write(bytes) and close() methods and follows the same portable/save-dir +-- routing as CacheFs.write without forcing the caller to hold the whole file +-- in one Lua string. +function CacheFs.openWrite(rel) + rel = withPrefix(rel) + local root = CacheFs.root() + if root then + ensureParents(root, rel) + local f, err = io.open(realPath(root, rel), "wb") + if not f then return nil, err end + return { + write = function(_, data) + local ok, writeErr = f:write(data) + if not ok then return nil, writeErr end + return true + end, + close = function() f:close() end, + } + end + if not (love and love.filesystem and love.filesystem.newFile) then + return nil, "streaming cache writes are unavailable" + end + local parent = rel:match("^(.*)/[^/]+$") + if parent and not love.filesystem.createDirectory(parent) then + local info = love.filesystem.getInfo(parent) + local reason = info and ("a " .. info.type .. " already exists there") + or "unknown reason" + return nil, "could not create " .. parent .. ": " .. reason + end + local file, makeErr = love.filesystem.newFile(rel) + if not file then return nil, makeErr or "could not create cache file" end + local ok, openErr = file:open("w") + if not ok then return nil, openErr or "could not open cache file" end + return file +end + -- read cache-relative `rel`; returns the bytes or nil function CacheFs.read(rel) rel = withPrefix(rel) diff --git a/src/import/ImageWriter.lua b/src/import/ImageWriter.lua index 9ab64fc7..3b57e347 100644 --- a/src/import/ImageWriter.lua +++ b/src/import/ImageWriter.lua @@ -125,6 +125,26 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile) return out end +-- Inverse of pret tools/gfx --interleave (pokecrystal tools/gfx.c). +-- Build-time interleave stores each vertical 8x16 pair as consecutive 8x8 +-- tiles for OBJ mode; this restores row-major sheet order for PNGs. +function ImageWriter.deinterleave(raw, width, bytesPerTile) + bytesPerTile = bytesPerTile or 16 + local widthTiles = width / 8 + local numTiles = #raw / bytesPerTile + local out = {} + for i = 0, numTiles - 1 do + local row = math.floor(i / widthTiles) + local src = i * 2 - (row % 2 == 1 + and widthTiles * (row + 1) - 1 + or widthTiles * row) + for offset = 1, bytesPerTile do + out[i * bytesPerTile + offset] = raw[src * bytesPerTile + offset] + end + end + return out +end + function ImageWriter.save(image, path) local ok, fileData = pcall(image.encode, image, "png") if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index b832121e..7d828cab 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -6,8 +6,8 @@ -- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the -- next boot's applyOptions pick the values up. Every ladder mirrors -- OptionsMenu's semantics and stored values; when editing one, keep the two --- in sync. ZOOM is deliberately absent: its range depends on the live --- renderer's fit scale (Renderer:fitScale), which does not exist here. +-- in sync. ZOOM uses the live window's integer fit (same 160×144 rule as +-- Renderer:fitScale) so the row can offer OUT/FIT/IN without a running game. -- -- Rows are the same descriptor idiom OptionRows draws in game: -- { label, value = fn() -> string, step = fn(dir) -> changed, @@ -278,6 +278,16 @@ local function coreRows(opts, hooks) end) end + local okZ, Zoom = pcall(require, "src.render.Zoom") + if okZ then + add(Strings("ZOOM"), + function() return Zoom.offsetLabel(opts.zoom or 0) end, + function(dir) + Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale()) + return true + end) + end + local okTile, TileRenderer = pcall(require, "src.render.TileRenderer") if okTile and TileRenderer.VOID_FILLS then add(Strings("VOID FILL"), @@ -303,15 +313,12 @@ local function coreRows(opts, hooks) end) end - -- ORIENTATION (#592): Android only -- the lock rides SDL's orientation - -- hint, which iOS reads only at startup (the Info.plist governs there) and - -- desktop ignores. Unlike the other launcher rows this one live-applies: - -- the window exists here too, and rotating under the player's finger is - -- the only feedback that reads. + -- ORIENTATION (#592, #1638): mobile only. Unlike the other launcher rows + -- this one live-applies: the window exists here too, and rotating under + -- the player's finger is the only feedback that reads. do - local osName = love.system and love.system.getOS and love.system.getOS() local okOr, Orientation = pcall(require, "src.core.Orientation") - if okOr and osName == "Android" then + if okOr and (Orientation.isAndroid() or Orientation.isIOS()) then add(Strings("ORIENTATION"), function() return Strings(Orientation.modeLabel(opts.orientation)) end, function(dir) @@ -646,6 +653,16 @@ local function gen2Rows(opts, hooks) end) end + local okZ, Zoom = pcall(require, "src.render.Zoom") + if okZ then + add(Strings("ZOOM"), + function() return Zoom.offsetLabel(opts.zoom or 0) end, + function(dir) + Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale()) + return true + end) + end + local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill") if okFill and BorderFill.VOID_FILLS then add(Strings("VOID FILL"), diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index a5d6f204..860b5e8b 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -753,12 +753,15 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) -- and one below the top-right corner notch, like the DMG cart. local grooveW = w * 0.115 local grooveH = math.max(1, h * 0.009) + local grooveScale = { 1.22, 1.10, 1.00, 1.00, 1.10, 1.22 } + local grooveInset = w * 0.02 for i = 0, 5 do local ry = mainTop + h * 0.014 + i * h * 0.021 - cartPolygon(cartQuad(project, -halfW + w * 0.02, ry, - grooveW, grooveH, faceZ), side, 0.7) - cartPolygon(cartQuad(project, halfW - grooveW - w * 0.02, ry, - grooveW, grooveH, faceZ), side, 0.7) + local gw = grooveW * grooveScale[i + 1] + cartPolygon(cartQuad(project, -halfW + grooveInset, ry, + gw, grooveH, faceZ), side, 0.7) + cartPolygon(cartQuad(project, halfW - gw - grooveInset, ry, + gw, grooveH, faceZ), side, 0.7) end -- The thin diagonal mold ridge cut into each long side a little below -- the grip grooves, mirrored left/right. @@ -774,12 +777,14 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) { project(x1 + nx * t, y1 + ny * t, faceZ) }, }, side, 0.7) end - local dgY = mainTop + h * 0.20 + local dgY = mainTop + h * 0.25 diagonal(-halfW + w * 0.006, dgY, -halfW + w * 0.085, dgY + h * 0.038) diagonal(halfW - w * 0.006, dgY, halfW - w * 0.085, dgY + h * 0.038) - -- The Nintendo GAME BOY recess: one stadium pill sunk into the shell. - local pillX, pillW = -halfW + w * 0.17, w * 0.62 - local pillY, pillH = mainTop + h * 0.024, h * 0.115 + -- The pill recess: one stadium pill sunk into the shell. + local pillX, pillW = -halfW + w * 0.19, w * 0.62 + local pillY, pillH = mainTop + h * 0.015, h * 0.115 + -- log out pillH + cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55) local inX, inY = w * 0.008, h * 0.008 cartPill(project, pillX + inX, pillY + inY, @@ -2142,124 +2147,127 @@ local function buildModsPanel(imp, x, y, w, availH, m) + math.floor(8 * m.s) local listTop = cy - -- One continuous list: every row is laid out, the region scroll moves - -- through all of it, and only rows inside the region's viewport draw -- - -- so the per-frame cost stays bounded by the window, not the list. + -- One continuous list: derive the rows that can touch the viewport before + -- entering the loop. Drawing was already culled, but scanning every + -- installed row to discover that defeats the point on a large mod library. local view = imp._tabRegionRect local viewTop = view and view.y or listTop local viewBot = view and (view.y + view.h) or (listTop + availH) - for i = 1, #mods do + local stride = rowH + gap + local first = math.max(1, + math.ceil((viewTop - rowH - listTop) / stride) + 1) + local last = math.min(#mods, + math.floor((viewBot - listTop) / stride) + 1) + for i = first, last do local mod = mods[i] local ry = listTop + (i - 1) * (rowH + gap) - if ry + rowH >= viewTop and ry <= viewBot then - local rowKey = rowKeyFor(imp, "mod-row-", mod.id) - local isFullyDisabled = true - if mod.enabledByVersion then - for _, on in pairs(mod.enabledByVersion) do - if on then isFullyDisabled = false; break end - end - else - isFullyDisabled = not mod.enabled + local rowKey = rowKeyFor(imp, "mod-row-", mod.id) + local isFullyDisabled = true + if mod.enabledByVersion then + for _, on in pairs(mod.enabledByVersion) do + if on then isFullyDisabled = false; break end end + else + isFullyDisabled = not mod.enabled + end - local focused = Kit.focusable(rowKey, x, ry, w, rowH) - local hot = focused or Kit.hover(x, ry, w, rowH) - if isFullyDisabled then - Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted") - else - Kit.card(x, ry, w, rowH, hot) - end - local pad = math.floor(12 * m.s) - local px, inner = x + pad, w - 2 * pad - local ly = ry + math.floor(10 * m.s) + local focused = Kit.focusable(rowKey, x, ry, w, rowH) + local hot = focused or Kit.hover(x, ry, w, rowH) + if isFullyDisabled then + Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted") + else + Kit.card(x, ry, w, rowH, hot) + end + local pad = math.floor(12 * m.s) + local px, inner = x + pad, w - 2 * pad + local ly = ry + math.floor(10 * m.s) - local togGap = math.floor(5 * m.s) + 1 - local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) + local togGap = math.floor(5 * m.s) + 1 + local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) - -- These answer separate games, not a single shared install flag. The - -- importer receives the game id so an experimental confirmation also - -- applies only to the checkbox the player pressed. - local flipped = false - local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s) - Kit.text("micro", gamesLabel, px, - gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted) - local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s) - for _, game in ipairs(GameVersion.ORDER) do - local togKey = "mod-toggle-" .. mod.id .. "-" .. game - if modGameCheckbox(tx, gamesY, togH, - mod.enabledByVersion and mod.enabledByVersion[game] == true, - game, togKey, not safeMode) then - local version = game - queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) - flipped = true - end - tx = tx + togH + togGap + -- These answer separate games, not a single shared install flag. The + -- importer receives the game id so an experimental confirmation also + -- applies only to the checkbox the player pressed. + local flipped = false + local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s) + Kit.text("micro", gamesLabel, px, + gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted) + local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s) + for _, game in ipairs(GameVersion.ORDER) do + local togKey = "mod-toggle-" .. mod.id .. "-" .. game + if modGameCheckbox(tx, gamesY, togH, + mod.enabledByVersion and mod.enabledByVersion[game] == true, + game, togKey, not safeMode) then + local version = game + queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) + flipped = true end - -- The checkboxes sit inside the row's rect, so their press also passes the - -- row hit test; `flipped` gates the row action to everywhere else. - if not flipped - and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then - local id = mod.id - queueAction(imp, rowKey, function() imp._modActions = id end) - end - local textW = inner + tx = tx + togH + togGap + end + -- The checkboxes sit inside the row's rect, so their press also passes the + -- row hit test; `flipped` gates the row action to everywhere else. + if not flipped + and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then + local id = mod.id + queueAction(imp, rowKey, function() imp._modActions = id end) + end + local textW = inner - local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s) - -- the games the mod is for, beside its category: the same chip the - -- in-game manager shows (src/mods/ModTargets.lua) - local gamesW = mod.targets - and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 - local nameShown = Kit.ellipsize("button", mod.name, - textW - badgeW - gamesW - math.floor(12 * m.s)) - local headingCol = isFullyDisabled and PAL.muted or PAL.heading - Kit.text("button", nameShown, px, ly, headingCol) - local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) - Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, - mod.experimental and PAL.yellow or PAL.muted) - if mod.targets then - Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW, - Kit.textHeight("button"), mod.targets, - mod.targetsHere == false and PAL.steel or PAL.blue) - end - ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) + local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s) + -- the games the mod is for, beside its category: the same chip the + -- in-game manager shows (src/mods/ModTargets.lua) + local gamesW = mod.targets + and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 + local nameShown = Kit.ellipsize("button", mod.name, + textW - badgeW - gamesW - math.floor(12 * m.s)) + local headingCol = isFullyDisabled and PAL.muted or PAL.heading + Kit.text("button", nameShown, px, ly, headingCol) + local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) + Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, + mod.experimental and PAL.yellow or PAL.muted) + if mod.targets then + Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW, + Kit.textHeight("button"), mod.targets, + mod.targetsHere == false and PAL.steel or PAL.blue) + end + ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) - -- version + status + update state - local statusText, statusCol = modStatusColor(mod.status) - local line = "v" .. tostring(mod.version or "?") .. " " .. statusText - Kit.text("small", line, px, ly, statusCol) - local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s) - if imp:_modInfoPending(mod.id) then - -- An inline spinner, because this row's release check is genuinely in - -- flight -- the list stays usable while it resolves. - Loader.dot(lx, ly, Kit.textHeight("small")) - Kit.text("small", Strings("Checking..."), - lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted) - elseif info and info.status == "available" then - Kit.text("small", Strings("v%s available", tostring(info.latest)), - lx, ly, PAL.yellow) - elseif info and info.status == "current" then - Kit.text("small", Strings("up to date"), lx, ly, PAL.muted) - elseif info and info.status == "error" then - Kit.text("small", Strings("check failed"), lx, ly, PAL.red) - end - ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) + -- version + status + update state + local statusText, statusCol = modStatusColor(mod.status) + local line = "v" .. tostring(mod.version or "?") .. " " .. statusText + Kit.text("small", line, px, ly, statusCol) + local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s) + if imp:_modInfoPending(mod.id) then + -- An inline spinner, because this row's release check is genuinely in + -- flight -- the list stays usable while it resolves. + Loader.dot(lx, ly, Kit.textHeight("small")) + Kit.text("small", Strings("Checking..."), + lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted) + elseif info and info.status == "available" then + Kit.text("small", Strings("v%s available", tostring(info.latest)), + lx, ly, PAL.yellow) + elseif info and info.status == "current" then + Kit.text("small", Strings("up to date"), lx, ly, PAL.muted) + elseif info and info.status == "error" then + Kit.text("small", Strings("check failed"), lx, ly, PAL.red) + end + ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) - -- one line of description, or the download stats when we have them - -- (download count in green so popularity reads at a glance) - if info and info.downloads then - local d = info.dates - local dl = ModUpdate.downloadsLine(info.downloads.total) - local dates = ModUpdate.datesLine(d and d.first, d and d.latest) - local segs = {} - if dl then segs[#segs + 1] = { dl, PAL.green } end - if dates then - segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail } - end - segLine("small", segs, px, ly, textW) - elseif (mod.description or "") ~= "" then - Kit.text("small", Kit.ellipsize("small", mod.description, textW), - px, ly, PAL.detail) + -- one line of description, or the download stats when we have them + -- (download count in green so popularity reads at a glance) + if info and info.downloads then + local d = info.dates + local dl = ModUpdate.downloadsLine(info.downloads.total) + local dates = ModUpdate.datesLine(d and d.first, d and d.latest) + local segs = {} + if dl then segs[#segs + 1] = { dl, PAL.green } end + if dates then + segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail } end + segLine("small", segs, px, ly, textW) + elseif (mod.description or "") ~= "" then + Kit.text("small", Kit.ellipsize("small", mod.description, textW), + px, ly, PAL.detail) end end @@ -2269,8 +2277,8 @@ end -- ---------------------------------------------------------- find mods panel --- SKINS tab: pick the on-screen skin, import one, or open the desktop studio. -local function buildSkinsPanel(imp, x, y, w, availH, m) +-- SKINS tab: pick the on-screen skin, import one, or open Skin Studio. +local function buildSkinsPanelLegacy(imp, x, y, w, availH, m) local skins = imp:_ensureSkins() local active = imp:_activeSkin() local gap = m.gap @@ -2324,7 +2332,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) end cy = cy + urlH + math.floor(8 * m.s) - -- Studio button. Desktop only: the host supplies the hook nowhere else. + -- The Studio reflows to a touch-first canvas plus inspector on phones. if imp.onOpenSkinStudio then local label = Strings("Open Skin Studio") local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s)) @@ -2344,6 +2352,64 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) Kit.caption(x, cy, Strings("INSTALLED")) cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) + -- Keep the actionable list below for detailed metadata and exports, but + -- lead with a visual picker: skins are much easier to recognize by their + -- bezel than by a folder name. Cards use the same loaded art that the + -- runtime draws, so they cannot drift from the selected skin. + local previewGap = math.floor(8 * m.s) + local previewCols = w >= math.floor(420 * m.s) and 2 or 1 + local previewW = (w - previewGap * (previewCols - 1)) / previewCols + local previewH = math.max(108 * m.s, Kit.tapMin() * 2) + local previewCount = #skins + for i, entry in ipairs(skins) do + local n = i - 1 + local px = x + (n % previewCols) * (previewW + previewGap) + local py = cy + math.floor(n / previewCols) * (previewH + previewGap) + local key = "skin-preview-" .. entry.id + local selected = active == entry.id + local focused = Kit.focusable(key, px, py, previewW, previewH) + Kit.card(px, py, previewW, previewH, selected and "selected" + or (focused or Kit.hover(px, py, previewW, previewH))) + local pad = math.floor(8 * m.s) + local artH = math.floor(previewH * 0.60) + Theme.fillRounded(px + pad, py + pad, previewW - pad * 2, artH, + PAL.bg, 1, Theme.cardRadius() * 0.6) + local art = entry.preview + if art and art.getDimensions then + local iw, ih = art:getDimensions() + if iw > 0 and ih > 0 then + local scale = math.min((previewW - pad * 4) / iw, (artH - pad * 2) / ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(art, px + (previewW - iw * scale) * 0.5, + py + pad + (artH - ih * scale) * 0.5, 0, scale, scale) + end + else + Theme.strokeRounded(px + previewW * 0.23, py + pad + artH * 0.15, + previewW * 0.54, artH * 0.42, PAL.line, Theme.A.hairline, 1, 2) + Theme.fillRounded(px + previewW * 0.20, py + pad + artH * 0.64, + previewW * 0.22, artH * 0.17, PAL.steel, 0.75, 3) + Theme.fillRounded(px + previewW * 0.60, py + pad + artH * 0.61, + previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11) + Theme.fillRounded(px + previewW * 0.75, py + pad + artH * 0.56, + previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11) + end + Kit.text("mono", Kit.ellipsize("mono", entry.id, previewW - pad * 2), + px + pad, py + pad + artH + math.floor(5 * m.s), + selected and PAL.green or PAL.heading) + if selected then + Kit.text("micro", Strings("IN USE"), px + pad, + py + previewH - pad - Kit.textHeight("micro"), PAL.green) + end + if Kit.press(px, py, previewW, previewH) or Kit._activateId == key then + queueAction(imp, key, function() imp:_useSkin(entry.id) end) + end + end + if previewCount > 0 then + cy = cy + math.ceil(previewCount / previewCols) * previewH + + math.max(0, math.ceil(previewCount / previewCols) - 1) * previewGap + + gap + end + local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s)) imp._skinGear = imp._skinGear or love.graphics.newImage("assets/launcher/gear.png") @@ -2457,6 +2523,69 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) return cy + hintH - y end +-- The launcher is the short path: bring a skin in, see what is enabled, or +-- turn skin use off. Browsing, pagination and per-skin editing/export live +-- together in My Skins, where they are useful instead of competing here. +local function buildSkinsPanel(imp, x, y, w, availH, m) + local cy, gap, bh = y, m.gap, m.btnH + local active = imp:_activeSkin() + + Kit.text("button", Strings("Skins"), x, cy, PAL.heading) + local importW = math.min(w * 0.46, + Kit.textWidth("small", imp:_skinsImportButtonLabel()) + math.floor(24 * m.s)) + btn(imp, x + w - importW, cy, importW, bh, "skins-import", + imp:_skinsImportButtonLabel(), { kind = "accent", font = "small", + action = function() imp:chooseSkin() end }) + cy = cy + bh + gap + + if imp._skinNotice then + cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w, + imp._skinNotice.ok and PAL.green or PAL.red, 2) + gap + end + + local addW = Kit.textWidth("small", Strings("Add")) + math.floor(24 * m.s) + if imp._skinFetch then + Loader.inline(x, cy, w, bh, Strings("Downloading %s...", + tostring(imp._skinFetch.name or ""))) + else + btn(imp, x + w - addW, cy, addW, bh, "skins-url-add", Strings("Add"), { + kind = "accent", font = "small", action = function() imp:_addSkinFromUrl() end }) + textField(imp, x, cy, w - addW - gap, bh, "skins-url", imp.skinUrl or "", + Strings("Paste a skin link (.zip, .cfg, .deltaskin)"), + imp._skinUrlFocus == true, function() imp:_toggleSkinUrlFocus() end) + end + cy = cy + bh + gap + + local currentH = active and (bh * 2 + gap * 2) or (bh + gap * 2) + Kit.card(x, cy, w, currentH) + Kit.caption(x + gap, cy + gap, "CURRENT SKIN") + local current = active and tostring(active) or Strings("No skin enabled") + Kit.text("mono", Kit.ellipsize("mono", current, w - gap * 2), x + gap, + cy + gap + Kit.textHeight("small") + math.floor(4 * m.s), + active and PAL.green or PAL.muted) + local buttonY = cy + bh + gap + local half = (w - gap * 3) * 0.5 + if active then + btn(imp, x + gap, buttonY, half, bh, "skins-export-current", + Strings("Export current"), { font = "small", + action = function() imp:_exportSkin(active, "native") end }) + btn(imp, x + gap * 2 + half, buttonY, half, bh, "skins-off", + Strings("Turn skins off"), { kind = "danger", font = "small", + action = function() imp:_disableSkins() end }) + end + cy = cy + currentH + gap + + if imp.onOpenSkinStudio then + btn(imp, x, cy, w, bh, "skins-my-skins", Strings("My Skins"), { + kind = "accent", font = "small", + action = function() imp.onOpenSkinStudio(imp.modScope or "red", active) end }) + cy = cy + bh + gap + end + Kit.textWrapped("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."), + x, cy, w, PAL.muted, 3) + return cy + Kit.wrapHeight("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."), w, 3) - y +end + local function buildBugPanel(imp, x, y, w, availH, m) local SaveData = require("src.core.SaveData") local gap = m.gap @@ -3017,9 +3146,13 @@ local function buildTextModal(imp, m, key, title, body, closeFn) local h = math.floor(math.min(m.H - 2 * m.pad, 460 * m.s)) local px, py, pw, ph = modalPanel(m, w, h) local cy = py + pad - Kit.text("button", Kit.ellipsize("button", title, pw - 2 * pad), + local xW = math.max(Kit.tapMin(), math.floor(30 * m.s)) + Kit.text("button", Kit.ellipsize("button", title, + pw - 2 * pad - xW - math.floor(8 * m.s)), px + pad, cy, PAL.heading) - cy = cy + Kit.textHeight("button") + math.floor(10 * m.s) + btn(imp, px + pw - pad - xW, cy, xW, xW, key .. "-x", "X", + { font = "small", action = closeFn }) + cy = cy + math.max(Kit.textHeight("button"), xW) + math.floor(10 * m.s) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local bodyH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s) @@ -5071,6 +5204,7 @@ function LauncherView.draw(imp) -- whole stage draws shielded (no clicks, no hover, no focus ring) while -- one is up; buildModals lowers the shield for the modal's own controls. imp._modalUpNow = modalUp(imp) + if imp._modalUpNow then imp:_blurPanelFields() end Kit.blockClicks = imp._modalUpNow local step = Kit.scrollStep(m.s) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index d3736b8e..d2ff2435 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -5258,80 +5258,213 @@ function RomExtractorGen2:extractMenuGfx() end -- Goldenrod Game Corner: Slot Machine graphics assets + local CacheFs = require("src.import.CacheFs") + local function packBytes(bytes) + local chars = {} + for i = 1, #bytes do chars[i] = string.char(bytes[i]) end + return table.concat(chars) + end + local function writeRaw(relative, bytes) + local ok, writeError = CacheFs.write( + "assets/generated/" .. relative, packBytes(bytes)) + if not ok then + error("could not write " .. relative .. ": " .. tostring(writeError)) + end + end + + -- Canonical sheet sizes match the cart art the UI indexes (and pret's + -- gfx/slots + gfx/card_flip PNGs). ROM LZ streams are those sheets after + -- Makefile gfx transforms; reverse what the decompressed bytes still carry. + local SLOTS1_W, SLOTS1_H = 16, 152 + local SLOTS2_W, SLOTS2_H = 16, 256 + local SLOTS3_W, SLOTS3_H = 24, 240 + local CARD1_W, CARD1_H = 128, 32 + local CARD2_W, CARD2_H = 24, 160 + local CARD3_W, CARD3_H = 8, 56 + + local function pad2bpp(raw, width, height) + local need = width * height / 4 + while #raw < need do raw[#raw + 1] = 0 end + while #raw > need do table.remove(raw) end + return raw + end + + local function writeSheet(raw, width, height, relative, transparent) + self:write2bpp(pad2bpp(raw, width, height), width, height, relative, + transparent) + end + + -- Slots3LZ is unique 8x16 OBJ columns (interleave + remove-duplicates + + -- remove-xflip). Rebuild the 24x240 actor sheet the UI quads expect from + -- OAMData_SlotsGolem / Chansey* / Egg (data/sprite_anims/oam.asm), same + -- pattern as title-screen Ho-Oh frame composition above. + local function composeSlotsActors(raw) + local tileCount = math.floor(#raw / 16) + local tiles = {} + for index = 0, tileCount - 1 do + local one = {} + for b = 1, 16 do one[b] = raw[index * 16 + b] or 0 end + tiles[index] = ImageWriter.decode2bpp(one, 8, 8, true) + end + local sheet = ImageWriter.blank(SLOTS3_W, SLOTS3_H, 1, 1, 1, 0) + local function blit8x16(tileId, dx, dy, flipX) + local top, bot = tiles[tileId], tiles[tileId + 1] + if not (top and bot) then return end + ImageWriter.blit(sheet, top, dx, dy, 0, 0, 8, 8, flipX) + ImageWriter.blit(sheet, bot, dx, dy + 8, 0, 0, 8, 8, flipX) + end + local function blitPose(poseY, base, entries) + for _, e in ipairs(entries) do + blit8x16(base + e.t, (e.x + 2) * 8, poseY + (e.y + 2) * 8, e.xf) + end + end + local golem = { + { x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 }, + { x = 0, y = -2, t = 0x00, xf = true }, + { x = -2, y = 0, t = 0x04 }, { x = -1, y = 0, t = 0x06 }, + { x = 0, y = 0, t = 0x04, xf = true }, + } + local chansey = { + { + { x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 }, + { x = 0, y = -2, t = 0x04 }, + { x = -2, y = 0, t = 0x06 }, { x = -1, y = 0, t = 0x08 }, + { x = 0, y = 0, t = 0x0a }, + }, + { + { x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 }, + { x = 0, y = -2, t = 0x04 }, + { x = -2, y = 0, t = 0x0c }, { x = -1, y = 0, t = 0x0e }, + { x = 0, y = 0, t = 0x10 }, + }, + { + { x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 }, + { x = 0, y = -2, t = 0x04 }, + { x = -2, y = 0, t = 0x12 }, { x = -1, y = 0, t = 0x14 }, + { x = 0, y = 0, t = 0x16 }, + }, + { + { x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 }, + { x = 0, y = -2, t = 0x04 }, + { x = -2, y = 0, t = 0x18 }, { x = -1, y = 0, t = 0x1a }, + { x = 0, y = 0, t = 0x1c }, + }, + { + { x = -2, y = -2, t = 0x1e }, { x = -1, y = -2, t = 0x20 }, + { x = 0, y = -2, t = 0x22 }, + { x = -2, y = 0, t = 0x24 }, { x = -1, y = 0, t = 0x26 }, + { x = 0, y = 0, t = 0x28 }, + }, + } + blitPose(0, 0x00, golem) + blitPose(32, 0x08, golem) + for index, frame in ipairs(chansey) do + blitPose(32 + index * 32, 0x10, frame) + end + blit8x16(0x3a, 0, 224, false) + return sheet + end + + -- card_flip_2.2bpp uses --remove-whitespace: blank tiles in column 2 of the + -- 3-wide header strip (indices 2,5,...,23) are dropped from the ROM stream. + -- Re-insert them so HEADER_TILE_MAP / MON_ANCHORS (pret sheet indices) work. + local function expandCardFlip2(compact) + local need = CARD2_W * CARD2_H / 4 + local out = {} + for i = 1, need do out[i] = 0 end + local whitespace = { + [2] = true, [5] = true, [8] = true, [11] = true, + [14] = true, [17] = true, [20] = true, [23] = true, + } + local src = 0 + for tile = 0, 59 do + if not whitespace[tile] then + for b = 1, 16 do + out[tile * 16 + b] = compact[src * 16 + b] or 0 + end + src = src + 1 + end + end + return out + end + + local slots = nil if self.symbols["Slots1LZ"] then + -- --trim-whitespace drops the final empty tile (37 of 38). local raw1 = self:decompressLz3Symbol("Slots1LZ") - self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png") + writeSheet(raw1, SLOTS1_W, SLOTS1_H, "slots/gold_slots_1.png") + slots = slots or {} + slots.sheet1 = "assets/generated/slots/gold_slots_1.png" end if self.symbols["Slots2LZ"] then - local raw2 = self:decompressLz3Symbol("Slots2LZ") - -- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity + local raw2 = ImageWriter.deinterleave( + self:decompressLz3Symbol("Slots2LZ"), SLOTS2_W) + -- Commercial Gold stores the Seven symbol with inverted bit polarity. for i = 1, math.min(64, #raw2) do raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF) end - self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png") + writeSheet(raw2, SLOTS2_W, SLOTS2_H, "slots/gold_slots_2.png") + slots = slots or {} + slots.sheet2 = "assets/generated/slots/gold_slots_2.png" end if self.symbols["Slots3LZ"] then local raw3 = self:decompressLz3Symbol("Slots3LZ") - self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true) - -- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing: - -- Y=0: Golem 1 (Standing, 24x32) - -- Y=32: Golem 2 (Ball, 24x32) - -- Y=64: Chansey 1 (Standing / Step 1, 24x32) - -- Y=96: Chansey 2 (Step 2, 24x32) - -- Y=128: Chansey 3 (Step 3, 24x32) - -- Y=160: Chansey 4 (Arm raised / Step 4, 24x32) - -- Y=192: Chansey 5 (Egg Drop pose, 24x32) - -- Y=224: Egg (8x16 at X=0) - self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true) + local actors = composeSlotsActors(raw3) + self:save(actors, "slots/gold_slots_3.png") + self:save(actors, "slots/gold_slots_actors.png") + slots = slots or {} + slots.sheet3 = "assets/generated/slots/gold_slots_3.png" end if self.symbols["SlotsTilemap"] then local symbol = self:symbol("SlotsTilemap") local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12) - self:save(tm, "slots/gold_slots.tilemap") + writeRaw("slots/gold_slots.tilemap", tm) + slots = slots or {} + slots.tilemap = "assets/generated/slots/gold_slots.tilemap" end + if slots then out.slots = slots end -- Goldenrod Game Corner: Card Flip graphics assets + local cardFlip = nil if self.symbols["CardFlipLZ01"] then + -- --trim-whitespace: 62 of 64 tiles in the ROM stream. local raw1 = self:decompressLz3Symbol("CardFlipLZ01") - self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png") + writeSheet(raw1, CARD1_W, CARD1_H, "card_flip/card_flip_1.png") + cardFlip = cardFlip or {} + cardFlip.sheet1 = "assets/generated/card_flip/card_flip_1.png" end if self.symbols["CardFlipLZ02"] then - local raw2 = self:decompressLz3Symbol("CardFlipLZ02") - self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png") + local raw2 = expandCardFlip2(self:decompressLz3Symbol("CardFlipLZ02")) + writeSheet(raw2, CARD2_W, CARD2_H, "card_flip/card_flip_2.png") + cardFlip = cardFlip or {} + cardFlip.sheet2 = "assets/generated/card_flip/card_flip_2.png" end if self.symbols["CardFlipLZ03"] then local raw3 = self:decompressLz3Symbol("CardFlipLZ03") - self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png") + writeSheet(raw3, CARD3_W, CARD3_H, "card_flip/card_flip_3.png") + cardFlip = cardFlip or {} + cardFlip.sheet3 = "assets/generated/card_flip/card_flip_3.png" end if self.symbols["CardFlipOnButtonGFX"] then local symbol = self:symbol("CardFlipOnButtonGFX") self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png") + cardFlip = cardFlip or {} + cardFlip.on = "assets/generated/card_flip/on.png" end if self.symbols["CardFlipOffButtonGFX"] then local symbol = self:symbol("CardFlipOffButtonGFX") self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png") + cardFlip = cardFlip or {} + cardFlip.off = "assets/generated/card_flip/off.png" end if self.symbols["CardFlipTilemap"] then local symbol = self:symbol("CardFlipTilemap") local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12) - self:save(tm, "card_flip/card_flip.tilemap") + writeRaw("card_flip/card_flip.tilemap", tm) + cardFlip = cardFlip or {} + cardFlip.tilemap = "assets/generated/card_flip/card_flip.tilemap" end - - out.slots = { - sheet1 = "assets/generated/slots/gold_slots_1.png", - sheet2 = "assets/generated/slots/gold_slots_2.png", - sheet3 = "assets/generated/slots/gold_slots_3.png", - tilemap = "assets/generated/slots/gold_slots.tilemap", - } - - out.cardFlip = { - sheet1 = "assets/generated/card_flip/card_flip_1.png", - sheet2 = "assets/generated/card_flip/card_flip_2.png", - sheet3 = "assets/generated/card_flip/card_flip_3.png", - on = "assets/generated/card_flip/on.png", - off = "assets/generated/card_flip/off.png", - tilemap = "assets/generated/card_flip/card_flip.tilemap", - } + if cardFlip then out.cardFlip = cardFlip end self:write("menu_gfx", out) self:tick("Menu graphics", 1, 1) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 3b2c2ac2..f1dff9c9 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -140,6 +140,12 @@ local VERSION_REQUIRED_FILES_OVERRIDE = { -- before the four ball tiles were extracted (#1502). "assets/generated/battle/hud/balls.png", "assets/generated/audio/programs.bin", + -- Goldenrod Game Corner reel + board art (#1581). menu_gfx.lua used to + -- advertise these paths even when Slots*LZ / CardFlip* were absent from + -- the manifest, so a cache that never wrote the PNGs still looked + -- complete and SlotMachine crashed on its labelled-cell fallback. + "assets/generated/slots/gold_slots_1.png", + "assets/generated/card_flip/card_flip_1.png", }, } -- Same Gen 2 extract, so a Silver cache is complete when the same files exist. @@ -382,6 +388,142 @@ local function externalFileSize(path) return size end +local function openImportSource(path) + -- Desktop picker paths live outside LÖVE's virtual filesystem. Prefer the + -- native file handle so a 1.46 GiB disc is never copied to a temp file or + -- read into one Lua string before validation. + local native = io.open(path, "rb") + if native then + local size, sizeErr = native:seek("end") + if size == nil or size == false then + native:close() + return nil, sizeErr or "could not determine source file size" + end + local reset, resetErr = native:seek("set", 0) + if reset == nil or reset == false then + native:close() + return nil, resetErr or "could not rewind source file" + end + return { + size = size, + read = function(_, n) return native:read(n) end, + close = function() native:close() end, + } + end + if love and love.filesystem and love.filesystem.newFile then + local file, makeErr = love.filesystem.newFile(path) + if not file then return nil, makeErr or "could not open source file" end + local ok, openErr = file:open("r") + if not ok then return nil, openErr or "could not open source file" end + local size = file.getSize and file:getSize() or nil + return { + size = size, + read = function(_, n) return file:read(n) end, + close = function() file:close() end, + } + end + return nil, "streaming source access is unavailable" +end +local function streamRequiredImport(manifest, importId, source) + local RequiredImports = require("src.mods.RequiredImports") + local spec = RequiredImports.spec(manifest, importId) + if not spec then return nil, "Import declaration was not found." end + if spec.format == "n64" then + return nil, "streaming canonicalization is unavailable for N64 imports" + end + local input, openErr = openImportSource(source) + if not input then return nil, openErr end + local sizeErr = RequiredImports.sizeError(spec, input.size, false) + if sizeErr then input:close(); return nil, sizeErr end + + local CacheFs = require("src.import.CacheFs") + local destination = RequiredImports.path(manifest, spec) + local savedPrefix = CacheFs.prefix + local output + local inputClosed, outputClosed = false, false + + local function closeInput() + if inputClosed then return end + inputClosed = true + pcall(function() input:close() end) + end + + local function closeOutput() + if outputClosed or not output then return end + outputClosed = true + pcall(function() output:close() end) + end + + local resultOk, resultDetail + CacheFs.prefix = "" + local ran, thrown = xpcall(function() + CacheFs.remove(RequiredImports.receiptPath(manifest, spec)) + CacheFs.remove(destination) + local makeErr + output, makeErr = CacheFs.openWrite(destination) + if not output then + resultDetail = makeErr or "could not create imported file" + return + end + + local MD5 = require("src.mods.StreamMD5") + local md5 = MD5.new() + local total, chunkBytes = 0, 4 * 1024 * 1024 + while true do + local chunk = input:read(chunkBytes) + if not chunk or #chunk == 0 then break end + md5:update(chunk) + local wrote, writeErr = output:write(chunk) + if wrote == false or wrote == nil then + resultDetail = "could not copy import: " +.. tostring(writeErr or "write failed") + return + end + total = total + #chunk + if #chunk < chunkBytes then break end + end + + closeInput() + closeOutput() + if input.size and total ~= input.size then + resultDetail = ("source read ended early (expected %d bytes, copied %d)") + :format(input.size, total) + return + end + local storedSizeErr = RequiredImports.sizeError(spec, total, true) + if storedSizeErr then resultDetail = storedSizeErr; return end + local digest = md5:final() + + -- acceptStoredDigest uses the normal engine path/receipt rules, so + -- restore the caller's prefix before handing control to it. + CacheFs.prefix = savedPrefix + local accepted, detail = RequiredImports.acceptStoredDigest( + manifest, importId, digest, love.filesystem) + if not accepted then resultDetail = detail; return end + resultOk, resultDetail = true, detail + end, function(err) + if debug and debug.traceback then + return debug.traceback(tostring(err), 2) + end + return tostring(err) + end) + + -- finally: resource handles and the process-global CacheFs prefix must + -- be restored even when a read/hash/write helper raises a Lua error. + closeInput() + closeOutput() + CacheFs.prefix = "" + if not ran or not resultOk then + pcall(function() CacheFs.remove(destination) end) + end + CacheFs.prefix = savedPrefix + + if not ran then + return nil, "could not copy import: " .. tostring(thrown) + end + if not resultOk then return nil, resultDetail end + return true, resultDetail +end local function readDroppedFile(file) local ok, openError = file:open("r") if not ok then return nil, openError end @@ -1228,11 +1370,13 @@ local function chooseRequiredFile() "$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d.Title='" .. prompt .. "';", "$d.Filter='All files (*.*)|*.*';", + -- Required imports can be multi-gigabyte optical-disc images. Do NOT + -- stage them through %TEMP%: that doubles free-space requirements and a + -- failed Copy-Item can leave a plausible-looking truncated temp file. + -- Stream the selected source directly into the mod-owned destination. "if($d.ShowDialog() -eq 'OK'){", - "$t=Join-Path $env:TEMP 'pokeport_required_import.bin';", - "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", - "[Console]::Write($t)}", + "[Console]::Write($d.FileName)}", }) return commandOutput( 'powershell -NoProfile -STA -Command "' .. script .. '"') @@ -2072,8 +2216,13 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed) return nil end local RequiredImports = require("src.mods.RequiredImports") - local info = love.filesystem.getInfo(source, "file") - local size = info and info.size or externalFileSize(source) + -- A desktop picker returns a host path. Ask the host file handle first; + -- love.filesystem.getInfo is only authoritative for virtual/save paths. + local size = externalFileSize(source) + if not size then + local info = love.filesystem.getInfo(source, "file") + size = info and info.size or nil + end local sizeErr = RequiredImports.sizeError(spec, size, false) if sizeErr then requiredImportNotice(self, modId, importId, sizeErr) @@ -2096,6 +2245,20 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed) } return nil end + if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES + and spec.format ~= "n64" then + local ok, result = streamRequiredImport(manifest, importId, source) + if ok then + self.requiredImportNotice = nil + self.modNotice = { ok = true, text = "Imported " .. tostring(importId) + .. " for " .. tostring(manifest.name or manifest.id) .. "." } + self:_refreshMods() + return true + end + requiredImportNotice(self, modId, importId, result) + self.modNotice = nil + return nil + end local data = love.filesystem.read(source) if not data then data = readExternalPath(source) end if not data then @@ -2848,6 +3011,7 @@ function RomImporter:_updatePadCursor(dt) local overY = 0 if ny > oy + h then overY = ny - (oy + h) elseif ny < oy then overY = ny - oy end + if math.abs(ay) > PAD_DEAD and not self._padStickCentered then overY = 0 end if overY ~= 0 and self._flex then require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) end @@ -2907,7 +3071,11 @@ end function RomImporter:gamepadaxis(_, axis, value) if axis == "leftx" or axis == "lefty" or axis == "righty" then self._padAxis[axis] = value - if math.abs(value) > PAD_DEAD then self:_activatePadCursor() end + if math.abs(value) > PAD_DEAD then + self:_activatePadCursor() + elseif axis == "lefty" then + self._padStickCentered = true + end end end @@ -2916,18 +3084,21 @@ end -- fire the virtual cursor's click twice off one A press (#620). function RomImporter:joystickpressed(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then self:gamepadpressed(joystick, padButton) end end function RomImporter:joystickreleased(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then self:gamepadreleased(joystick, padButton) end end function RomImporter:joystickaxis(joystick, axis, value) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end if axis == 1 then self:gamepadaxis(joystick, "leftx", value) elseif axis == 2 then @@ -2937,6 +3108,7 @@ end function RomImporter:joystickhat(joystick, hat, direction) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end for _, dir in ipairs(self._rawHatDirs[hat] or {}) do self._padDir[dir] = nil end @@ -3182,6 +3354,10 @@ function RomImporter:_ensureSkins(force) format = skin and skin.format or nil, pages = skin and #skin.pages or 0, controls = controls, + -- The launcher owns the visual skin picker, so retain the already + -- loaded first-page bezel for its preview card instead of decoding it + -- again every frame. + preview = page and page.image or nil, screen = page ~= nil and (page.viewport ~= nil or page.screenFit == "remainder"), ok = skin ~= nil, @@ -3194,7 +3370,7 @@ end function RomImporter:_activeSkin() local opts = require("src.core.SaveData").loadOptions() local tc = type(opts.touchControls) == "table" and opts.touchControls or {} - return tc.skin + return tc.enabled == false and nil or tc.skin end function RomImporter:_useSkin(id) @@ -3211,6 +3387,16 @@ function RomImporter:_useSkin(id) } end +function RomImporter:_disableSkins() + local SaveData = require("src.core.SaveData") + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled, tc.skin = false, nil + opts.touchControls = tc + SaveData.saveOptions(opts) + self._skinNotice = { ok = true, text = "Skins are off. Mobile will use the built-in pad when needed." } +end + function RomImporter:_installSkinZip(source) if self.workState == "working" then return end self.tab = "skins" @@ -3997,6 +4183,18 @@ function RomImporter:_disarmTextInput() end end +function RomImporter:_blurPanelFields() + if not (self._findSearchFocus or self._skinUrlFocus) then return end + if self._indexPrompt or self._rename or self._settingsText + or self._profileSavePrompt or self._profileRenamePrompt + or (self._syncModal and self._syncFocus) then + return + end + self._findSearchFocus = false + self._skinUrlFocus = false + self:_disarmTextInput() +end + function RomImporter:_beginRename(version, id) local label for _, slot in ipairs(self.slots[version] or {}) do @@ -4127,7 +4325,6 @@ function RomImporter:_refreshMods() end self.mods = kept end - self:_syncModUpdateInfo(false) end -- Point the MODS panel at one game (or nil for all of them) and relist, so @@ -4141,15 +4338,12 @@ function RomImporter:_ensureMods() if not self.mods then self:_refreshMods() end end --- Resolve cached (or freshly fetched) GitHub status for every mod that --- declares a github field. force=true bypasses the 6h cache on every repo. --- Results live on self.modUpdateInfo[id] = { status, latest, best, releases }. --- ASYNC (was synchronous). This runs on every _refreshMods -- boot, and any --- toggle or install -- and used to make one blocking curl call per mod with a --- github field, in a loop, on the render thread. A handful of mods was a --- multi-second freeze of the whole launcher. Now each mod gets a handle and --- they resolve together across later frames; a mod whose cache is still fresh --- resolves on the first pump with no network at all. +-- Resolve GitHub status for every installed mod that declares a github field. +-- This is deliberately opt-in: only the explicit "Check for updates" action +-- calls it. Opening, scrolling, toggling, or relisting the MODS tab must not +-- create a burst of release work behind the list. force=true bypasses the 6h +-- cache on every repo. Results live on self.modUpdateInfo[id] = { +-- status, latest, best, releases } and resolve asynchronously across frames. function RomImporter:_syncModUpdateInfo(force) local ModUpdate = require("src.mods.ModUpdate") self.modUpdateInfo = self.modUpdateInfo or {} diff --git a/src/mods/ImportAccess.lua b/src/mods/ImportAccess.lua new file mode 100644 index 00000000..11f32380 --- /dev/null +++ b/src/mods/ImportAccess.lua @@ -0,0 +1,174 @@ +-- Scoped access to a mod's launcher-validated required/optional imports and +-- to installation-wide generated cache data. +-- +-- This intentionally does not expose host paths or raw filesystem handles. +-- Import reads are bounded and can only address ids declared by the calling +-- mod's manifest. Cache paths are confined to mod_cache// and are not +-- tied to a Pokémon playthrough. + +local RequiredImports = require("src.mods.RequiredImports") +local SafePath = require("src.mods.SafePath") +local SaveData = require("src.core.SaveData") + +local ImportAccess = {} + +ImportAccess.MAX_READ_BYTES = 8 * 1024 * 1024 +ImportAccess.MAX_CACHE_WRITE_BYTES = 64 * 1024 * 1024 + +local function specMap(manifest) + local out = {} + for _, spec in ipairs(RequiredImports.specs(manifest)) do out[spec.id] = spec end + return out +end + +local function parentOf(path) + return path:match("^(.*)/[^/]+$") +end + +local function copyInfo(info) + if not info then return nil end + return { type = info.type, size = info.size, modtime = info.modtime } +end + +local function fsReadRange(fs, path, offset, length) + if fs and type(fs.readRange) == "function" then + return fs.readRange(path, offset, length) + end + local newFile = fs and fs.newFile + if newFile then + local file, makeErr = newFile(path) + if not file then return nil, makeErr or "could not open import" end + local ok, openErr = file:open("r") + if not ok then return nil, openErr or "could not open import" end + local seekOk, seekErr = file:seek(offset) + if seekOk == nil or seekOk == false then + file:close() + return nil, seekErr or "could not seek import" + end + local data, readErr = file:read(length) + file:close() + return data, readErr + end + -- Injectable headless filesystems may expose only read(). Production + -- love.filesystem has newFile(), so large imports are never materialized + -- into one Lua string by this fallback. + if fs and fs.read then + local data = fs.read(path) + if type(data) ~= "string" then return nil, "could not read import" end + return data:sub(offset + 1, offset + length) + end + return nil, "random-access import reads are unavailable" +end + +local function validatedInfo(manifest, spec, fs) + local ok, detail = RequiredImports.validateStored(manifest, spec, fs) + if not ok then return nil, detail or "import is not validated" end + local path = RequiredImports.path(manifest, spec) + local info = fs.getInfo and fs.getInfo(path, "file") or nil + if not info then return nil, "import is missing" end + return info, detail +end + +local function makeCache(modId, fs) + local root = "mod_cache/" .. modId + local function pathFor(rel, what) + rel = SafePath.require(rel, what or "mod.cache path") + return root .. "/" .. rel + end + local cache = {} + + function cache:write(rel, bytes) + if type(bytes) ~= "string" then + return nil, "mod.cache:write expects a byte string" + end + if #bytes > ImportAccess.MAX_CACHE_WRITE_BYTES then + return nil, "mod.cache:write payload exceeds 64 MiB; split generated data into smaller files" + end + local path = pathFor(rel, "mod.cache:write") + local parent = parentOf(path) + if parent and fs.createDirectory then + local ok = fs.createDirectory(parent) + if ok == false then return nil, "could not create cache directory" end + end + if not fs.write then return nil, "cache writes are unavailable" end + return fs.write(path, bytes) + end + + function cache:read(rel) + local path = pathFor(rel, "mod.cache:read") + if not fs.read then return nil, "cache reads are unavailable" end + return fs.read(path) + end + + function cache:info(rel) + local path = pathFor(rel, "mod.cache:info") + if not fs.getInfo then return nil end + return copyInfo(fs.getInfo(path)) + end + + function cache:exists(rel) + local info = self:info(rel) + return info ~= nil and info.type == "file" + end + + + function cache:delete(rel) + local path = pathFor(rel, "mod.cache:delete") + if not fs.remove then return nil, "cache deletion is unavailable" end + return fs.remove(path) + end + + return cache +end + +function ImportAccess.new(manifest, fs) + local specs = specMap(manifest) + local cacheFs = SaveData.persistenceFs(fs) or fs + local imports = {} + + function imports:info(id) + local spec = specs[id] + if not spec then return nil, "undeclared import: " .. tostring(id) end + local info, digestOrErr = validatedInfo(manifest, spec, fs) + if not info then return nil, digestOrErr end + return { + id = spec.id, + name = spec.name, + file = spec.file, + size = info.size, + md5 = digestOrErr, + required = spec.required ~= false, + } + end + + function imports:read(id, offset, length) + local spec = specs[id] + if not spec then return nil, "undeclared import: " .. tostring(id) end + offset, length = tonumber(offset), tonumber(length) + if not offset or offset < 0 or offset % 1 ~= 0 then + return nil, "offset must be a non-negative integer" + end + if not length or length < 0 or length % 1 ~= 0 then + return nil, "length must be a non-negative integer" + end + if length > ImportAccess.MAX_READ_BYTES then + return nil, "single import read exceeds 8 MiB" + end + + local info, err = validatedInfo(manifest, spec, fs) + if not info then return nil, err end + local size = tonumber(info.size) or tonumber(spec.size) + if size and offset + length > size then return nil, "import read is out of bounds" end + if length == 0 then return "" end + + local path = RequiredImports.path(manifest, spec) + local data, readErr = fsReadRange(fs, path, offset, length) + if not data then return nil, readErr end + if #data ~= length then return nil, "short import read" end + return data + end + + return imports, makeCache(manifest.id, cacheFs) +end + +return ImportAccess diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 8ec4767d..ddfc3f12 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -962,6 +962,8 @@ function Loader:_api(mod) local Storage = engineRequire("src.mods.Storage") local storage = Storage and Storage.new(modId, loader.fs) local Checkpoint = engineRequire("src.core.Checkpoint") + local ImportAccess = engineRequire("src.mods.ImportAccess") + local importApi, installCache = ImportAccess.new(mod.manifest, loader.fs) local api = { id = modId, version = mod.manifest.version, @@ -1161,6 +1163,12 @@ function Loader:_api(mod) -- checkpoint. The -- engine binds version/playthrough/mod scope and portable persistence; -- callers never receive paths or a raw filesystem handle. + -- Read-only bounded access to this mod's manifest-declared, launcher-validated + -- imports. No host path is exposed; large sources are read in bounded ranges. + imports = importApi, + -- Installation-scoped generated data, independent from Pokémon save slots. + -- This is where ROM-derived caches belong; mod.storage remains playthrough-scoped. + cache = installCache, storage = { context = function(_, game) return storage:context(game) end, selected = function(_, game) return storage:selected(game) end, diff --git a/src/mods/RequiredImports.lua b/src/mods/RequiredImports.lua index 806e916e..cfdb9b42 100644 --- a/src/mods/RequiredImports.lua +++ b/src/mods/RequiredImports.lua @@ -120,6 +120,36 @@ local function accepts(spec, digest) return false end +local function specById(manifest, importId) + for _, candidate in ipairs(allSpecs(manifest)) do + if candidate.id == importId then return candidate end + end + return nil +end + +RequiredImports.spec = specById + +local function streamDigest(fs, path, chunkBytes) + if not (fs and fs.newFile) then return nil, "streaming file access is unavailable" end + local file, makeErr = fs.newFile(path) + if not file then return nil, makeErr or "could not open stored import" end + local ok, openErr = file:open("r") + if not ok then return nil, openErr or "could not open stored import" end + local MD5 = require("src.mods.StreamMD5") + local ctx = MD5.new() + chunkBytes = chunkBytes or (4 * 1024 * 1024) + while true do + local data, readErr = file:read(chunkBytes) + if data and #data > 0 then ctx:update(data) end + if not data or #data < chunkBytes then + if readErr then file:close(); return nil, readErr end + break + end + end + file:close() + return ctx:final() +end + function RequiredImports.path(manifest, spec) return manifest.path .. "/baseroms/" .. spec.file end @@ -180,6 +210,47 @@ local function removeReceipt(manifest, spec, fs) end end +-- Finalize a caller-streamed import after the destination bytes have already +-- been copied into the engine-owned baseroms path. This keeps large imports +-- out of a single Lua string while preserving the same size/MD5 receipt rules. +function RequiredImports.acceptStoredDigest(manifest, importId, digest, fs) + fs = fs or (love and love.filesystem) + local spec = specById(manifest, importId) + if not spec then return nil, "unknown required import: " .. tostring(importId) end + digest = tostring(digest or ""):lower() + if not accepts(spec, digest) then + return nil, ("MD5 mismatch (got %s)"):format(digest ~= "" and digest or "unavailable") + end + local path = RequiredImports.path(manifest, spec) + local info = fs and fs.getInfo and fs.getInfo(path, "file") or nil + if not info then return nil, "copied import is missing" end + local sizeErr = RequiredImports.sizeError(spec, info.size, true) + if sizeErr then return nil, sizeErr end + if love and fs == love.filesystem then + local savedPrefix = CacheFs.prefix + local ok, prefixErr = xpcall(function() + CacheFs.prefix = "" + CacheFs.remove(removedMarker(manifest, spec)) + CacheFs.prefix = savedPrefix + -- writeReceipt has its own temporary CacheFs prefix switch. Keep it + -- inside this guard too so a write error cannot leak global state. + writeReceipt(manifest, spec, digest, info, fs) + end, function(err) + return tostring(err) + end) + CacheFs.prefix = savedPrefix + if not ok then + return nil, "could not finalize import receipt: " .. tostring(prefixErr) + end + return true, digest + elseif fs and fs.remove then + fs.remove(removedMarker(manifest, spec)) + end + writeReceipt(manifest, spec, digest, info, fs) + return true, digest +end + + -- Validate bytes against a declaration. The returned data is canonicalized -- (notably for N64 byte order/header variants) and is what must be stored. function RequiredImports.validateData(spec, data, hashFn) @@ -217,6 +288,22 @@ function RequiredImports.validateStored(manifest, spec, fs, hashFn) local cached = cachedDigest(manifest, spec, fs, info) if cached then return true, cached, true end removeReceipt(manifest, spec, fs) + -- Large raw imports (GameCube discs, future optical images, etc.) must never + -- be materialized into one Lua string merely because their validation + -- receipt was lost. Stream the MD5 directly from the installed file. N64 + -- sources stay on the canonicalization path because byte-order/header + -- normalization is part of their validation contract. + if info.size and info.size > RequiredImports.LARGE_WARN_BYTES + and spec.format ~= "n64" and fs.newFile then + local digest, hashErr = streamDigest(fs, path) + if not digest then return nil, hashErr end + if not accepts(spec, digest) then + return nil, ("MD5 mismatch (got %s)"):format(digest) + end + info = fs.getInfo(path, "file") or info + writeReceipt(manifest, spec, digest, info, fs) + return true, digest, false + end if not fs.read then return nil, "file could not be read" end local data = fs.read(path) local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn) diff --git a/src/mods/StreamMD5.lua b/src/mods/StreamMD5.lua new file mode 100644 index 00000000..1990e8ec --- /dev/null +++ b/src/mods/StreamMD5.lua @@ -0,0 +1,101 @@ +local bitlib = rawget(_G, "bit") or rawget(_G, "bit32") +if not bitlib then error("StreamMD5 requires bit or bit32") end + +local band, bor, bxor, bnot = bitlib.band, bitlib.bor, bitlib.bxor, bitlib.bnot +local lshift, rshift = bitlib.lshift, bitlib.rshift +local rol = bitlib.rol or bitlib.lrotate + +local K = { + 0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501, + 0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821, + 0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8, + 0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a, + 0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70, + 0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665, + 0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1, + 0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391, +} +local S = { + 7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22, + 5,9,14,20, 5,9,14,20, 5,9,14,20, 5,9,14,20, + 4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23, + 6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21, +} + +local function add32(a,b,c,d) + local n = (a or 0) + (b or 0) + (c or 0) + (d or 0) + return band(n, 0xffffffff) +end + +local function le32_from(s, i) + local b1,b2,b3,b4 = s:byte(i, i+3) + return bor(b1, lshift(b2,8), lshift(b3,16), lshift(b4,24)) +end + +local function le32_bytes(x) + return string.char( + band(x,0xff), band(rshift(x,8),0xff), + band(rshift(x,16),0xff), band(rshift(x,24),0xff)) +end + +local M = {} +local StreamMD5 = {} +StreamMD5.__index = StreamMD5 + +function StreamMD5.new() + return setmetatable({ + a=0x67452301, b=0xefcdab89, c=0x98badcfe, d=0x10325476, + bytes=0, buffer="", done=false, + }, StreamMD5) +end + +function StreamMD5:_block(block) + for j=1,16 do M[j] = le32_from(block, (j-1)*4+1) end + local a,b,c,d = self.a,self.b,self.c,self.d + for i=0,63 do + local f,g + if i < 16 then + f = bor(band(b,c), band(bnot(b),d)); g=i + elseif i < 32 then + f = bor(band(d,b), band(bnot(d),c)); g=(5*i+1)%16 + elseif i < 48 then + f = bxor(b,c,d); g=(3*i+5)%16 + else + f = bxor(c, bor(b,bnot(d))); g=(7*i)%16 + end + local tmp=d + d=c + c=b + b=add32(b, rol(add32(a,f,K[i+1],M[g+1]), S[i+1])) + a=tmp + end + self.a=add32(self.a,a); self.b=add32(self.b,b) + self.c=add32(self.c,c); self.d=add32(self.d,d) +end + +function StreamMD5:update(data) + assert(not self.done, "StreamMD5 context already finalized") + assert(type(data)=="string", "StreamMD5:update expects a string") + self.bytes = self.bytes + #data + local s = self.buffer .. data + local full = #s - (#s % 64) + for i=1,full,64 do self:_block(s:sub(i,i+63)) end + self.buffer = s:sub(full+1) + return self +end + +function StreamMD5:final() + assert(not self.done, "StreamMD5 context already finalized") + local originalBytes = self.bytes + local padLen = (56 - ((originalBytes + 1) % 64)) % 64 + local bits = originalBytes * 8 + local lo = bits % 4294967296 + local hi = math.floor(bits / 4294967296) % 4294967296 + self:update("\128" .. string.rep("\0", padLen) .. le32_bytes(lo) .. le32_bytes(hi)) + assert(#self.buffer == 0, "MD5 finalization left a partial block") + self.done=true + local raw = le32_bytes(self.a)..le32_bytes(self.b)..le32_bytes(self.c)..le32_bytes(self.d) + return (raw:gsub(".", function(ch) return string.format("%02x", ch:byte()) end)) +end + +return StreamMD5 diff --git a/src/render/Zoom.lua b/src/render/Zoom.lua index 13b27cad..00484251 100644 --- a/src/render/Zoom.lua +++ b/src/render/Zoom.lua @@ -19,6 +19,8 @@ Zoom.allowSurvey = true -- legal offset range for a given fit scale (vanilla: survey at 1 px/world -- through 2× fit). zoom.range may widen or shrink the window. +-- When the window only fits 1×, 1-S is 0 and there would be no OUT +-- levels; keep three survey steps so OPTIONS always has zoom-out. function Zoom.offsetRange(S) S = math.max(1, math.floor(tonumber(S) or 1)) local lo, hi = 1 - S, S @@ -30,7 +32,11 @@ function Zoom.offsetRange(S) end -- LOW performance tier: no survey (negative offsets), even if a mod's -- zoom.range widened it. == false so nil/true stays permissive. - if Zoom.allowSurvey == false and lo < 0 then lo = 0 end + if Zoom.allowSurvey == false then + if lo < 0 then lo = 0 end + elseif lo > -3 then + lo = -3 + end return lo, hi end @@ -44,6 +50,8 @@ function Zoom.scale(S) local maxScale = math.max(minScale, S + hi) if s < minScale then s = minScale end if s > maxScale then s = maxScale end + -- Integer offset below 1px/world (OPTIONS OUT on a 1× window): 1/2, 1/4, … + if s < 1 then s = 0.5 ^ (1 - s) end if s < 0.25 then s = 0.25 end return s end @@ -79,6 +87,30 @@ function Zoom.applyOptions(opts) Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0) end +-- Integer fit used when OPTIONS has no live renderer (launcher, title). +function Zoom.windowFitScale() + if love and love.graphics and love.graphics.getDimensions then + local ww, wh = love.graphics.getDimensions() + ww, wh = tonumber(ww) or 0, tonumber(wh) or 0 + if ww >= 160 and wh >= 144 then + return math.max(1, math.floor(math.min(ww / 160, wh / 144))) + end + end + return 1 +end + +-- One step of the OPTIONS ZOOM row (dir +1 in, -1 out). Shared by Red +-- and Gold so both ladders offer OUT / FIT / IN. +function Zoom.nudgeOptions(options, dir, S) + S = math.max(1, math.floor(tonumber(S) or Zoom.windowFitScale())) + local lo, hi = Zoom.offsetRange(S) + local off = math.floor(tonumber(options and options.zoom) or 0) + (dir or 1) + if off > hi then off = lo elseif off < lo then off = hi end + if options then options.zoom = off end + Zoom.offset = off + return off +end + -- FIT / OUT1 / OUT2 / … / IN1 / IN2 / … function Zoom.offsetLabel(offset) offset = math.floor(tonumber(offset) or 0) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d79d2b5d..503b5dfa 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -381,14 +381,7 @@ local function buildRows(game) return Zoom.offsetLabel(g.save.options.zoom or 0) end, step = function(g, dir) - local o = g.save.options - local S = Renderer:fitScale() - local lo, hi = Zoom.offsetRange(S) - local off = (o.zoom or 0) + dir - if off > hi then off = lo - elseif off < lo then off = hi end - o.zoom = off - Zoom.offset = off + Zoom.nudgeOptions(g.save.options, dir, Renderer:fitScale()) return true end }, { id = "voidFill", label = Strings("VOID FILL"), @@ -580,8 +573,8 @@ local function buildRows(game) end rows = filtered end - -- ORIENTATION only on Android, the one platform Orientation.apply reaches. - if not Orientation.isAndroid() then + -- ORIENTATION only on the platforms Orientation.apply reaches (#1638). + if not (Orientation.isAndroid() or Orientation.isIOS()) then local filtered = {} for _, row in ipairs(rows) do if row.id ~= "orientation" then filtered[#filtered + 1] = row end diff --git a/src/ui/PadCursor.lua b/src/ui/PadCursor.lua index 2286e23a..4e8ea9b2 100644 --- a/src/ui/PadCursor.lua +++ b/src/ui/PadCursor.lua @@ -151,6 +151,7 @@ end function PadCursor.joystickpressed(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return nil end + if GamepadMap.isAccelerometer(joystick) then return nil end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then return PadCursor.gamepadpressed(joystick, padButton) end return nil @@ -158,12 +159,14 @@ end function PadCursor.joystickreleased(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then PadCursor.gamepadreleased(joystick, padButton) end end function PadCursor.joystickaxis(joystick, axisIndex, value) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end if axisIndex == 1 then PadCursor.gamepadaxis(joystick, "leftx", value) elseif axisIndex == 2 then @@ -173,6 +176,7 @@ end function PadCursor.joystickhat(joystick, hat, direction) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end for _, d in ipairs(rawHatDirs[hat] or {}) do dir[d] = nil end diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index 99f2b3b5..bdf790aa 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -70,6 +70,12 @@ Studio.BIND_GROUPS = { "pause_toggle", "screenshot", "exit_emulator" } }, { title = "KEYBOARD", specs = { "key:escape", "key:return", "key:space", "key:tab", "key:f1" } }, + -- These are deliberately keyboard binds rather than new skin-only actions. + -- They therefore take the exact same path as their desktop counterparts in + -- Game:keypressed, including any future changes to those shortcuts. + { title = "DESKTOP HOTKEYS", + specs = { "key:-", "key:=", "key:1", "key:2", "key:3", "key:4", + "key:5", "key:f1", "key:f2", "key:f10" } }, { title = "NO INPUT", specs = { "nul" } }, } @@ -362,7 +368,9 @@ function Studio.load(opts) Studio.drag = nil Studio.pendingPlay = false Studio.canvasIndex = 1 - Studio.aspectLock = true + -- A free-form screen is the default. 10:9 is an optional convenience, + -- never a restriction on imported or custom skins. + Studio.aspectLock = false Studio.skinIdField = "" Studio.available = TouchSkin.list() Studio.availableMeta = {} @@ -374,6 +382,13 @@ function Studio.load(opts) Studio.showLabels = true Studio.statusErr = false Studio.thumbs = {} + Studio.pointerX, Studio.pointerY = nil, nil + Studio.pointerDown, Studio.touchId = false, nil + -- Studio always opens on the library. Creating and choosing a skin are + -- first-class tasks, not controls buried inside the editor workspace. + Studio.mode = "library" + Studio.libraryThumbs = {} + Studio.libraryPage = 1 TouchControls:init() TouchControls.active = true @@ -413,6 +428,7 @@ function Studio.open(id) Studio.undoStack, Studio.redoStack = {}, {} Studio.undoTag = nil Studio.thumbs = {} + Studio.libraryThumbs = {} syncActive() Studio.applyImportedOrient() return true @@ -425,6 +441,7 @@ function Studio.newSkin() Studio.pageIndex, Studio.selected = 1, nil Studio.images = {} Studio.thumbs = {} + Studio.libraryThumbs = {} syncActive() markDirty() end @@ -468,9 +485,122 @@ end function Studio.refreshAvailable() Studio.available = TouchSkin.list() Studio.availableMeta = {} + Studio.libraryThumbs = {} return Studio.available end +function Studio.enterEditor() + Studio.mode = "editor" + Studio.selected = nil + Studio.modal, Studio.confirm = nil, nil + Studio.drag, Studio.guides = nil, nil +end + +function Studio.backToLibrary() + Studio.guard("Return to My Skins and lose the unsaved changes?", function() + Studio.refreshAvailable() + Studio.mode = "library" + Studio.selected = nil + Studio.modal, Studio.confirm = nil, nil + Studio.drag, Studio.guides = nil, nil + end) +end + +function Studio.libraryThumb(entry) + if not entry then return nil end + Studio.libraryThumbs = Studio.libraryThumbs or {} + local cached = Studio.libraryThumbs[entry.id] + if cached ~= nil then return cached or nil end + local skin = TouchSkin.load(entry.root, entry.id) + local image = skin and skin.pages and skin.pages[1] and skin.pages[1].image + Studio.libraryThumbs[entry.id] = image or false + return image +end + +function Studio.exportEntry(id) + local entry = TouchSkin.find(id) + local skin = entry and TouchSkin.load(entry.root, entry.id) + if not skin then + setStatus("Could not read " .. tostring(id), true) + return nil + end + local path, missing = TouchSkin.export(skin) + if not path then + setStatus("Export failed: " .. tostring(missing), true) + return nil + end + Studio.lastExport = path + setStatus("Exported " .. path) + return path +end + +function Studio.selectEntry(id) + local entry = TouchSkin.find(id) + if not entry then + setStatus("Could not find " .. tostring(id), true) + return false + end + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled, tc.skin = true, id + opts.touchControls = tc + SaveData.saveOptions(opts) + TouchControls:applyOptions(opts) + setStatus("Using " .. id) + return true +end + +function Studio.deleteEntry(id) + local entry = TouchSkin.find(id) + if not entry then return false end + if entry.source ~= "user" then + setStatus("Bundled skins cannot be deleted.", true) + return false + end + Studio.ask("Delete " .. tostring(id) .. "? This removes its artwork and cannot be undone.", + function() + local ok, err = TouchSkin.remove(id) + if not ok then + setStatus("Delete failed: " .. tostring(err), true) + return + end + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + if tc.skin == id then + tc.enabled, tc.skin = false, nil + SaveData.saveOptions(opts) + end + Studio.refreshAvailable() + setStatus("Deleted " .. id) + end, "Delete") + return true +end + +function Studio.importSkinFile() + if not FilePicker.available() then + setStatus("On mobile, use Import in the Skins tab to add a downloaded skin.", true) + return false + end + local kind = { label = "Skin", exts = { "zip", "deltaskin", "cfg" } } + local path = FilePicker.open("Choose a skin", kind) + if not path then return false end + local name, data = FilePicker.basename(path), FilePicker.read(path) + if not data then + setStatus("Could not read " .. name, true) + return false + end + local id, note = TouchSkin.installArchive(name, data) + if not id then + setStatus("Import failed: " .. tostring(note), true) + return false + end + Studio.refreshAvailable() + Studio.open(id) + Studio.enterEditor() + setStatus("Imported " .. id) + return true +end + function Studio.openLoadPicker() return Studio.guard("Open another skin and lose the unsaved changes?", function() @@ -509,6 +639,29 @@ function Studio.unload() Studio.modal, Studio.confirm = nil, nil Studio.guides = nil Studio.undoStack, Studio.redoStack = {}, {} + Studio.pointerX, Studio.pointerY = nil, nil + Studio.pointerDown, Studio.touchId = false, nil +end + +-- The Studio is a real touch editor on Android and iOS. Keeping this here, +-- instead of asking the host to synthesize a mouse, makes the drag state and +-- the immediate-mode hit tests agree on the same finger position. +function Studio.isMobile() + local osName = love.system and love.system.getOS and love.system.getOS() + return osName == "Android" or osName == "iOS" +end + +function Studio.disableTouchControls() + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled, tc.skin = false, nil + opts.touchControls = tc + SaveData.saveOptions(opts) + TouchControls:applyOptions(opts) + TouchSkin.setActive(nil) + Studio.closeModal() + setStatus("On-screen controls are off.") + return true end -- --------------------------------------------------------------- editing @@ -678,7 +831,12 @@ function Studio.cycleImage(dir) local rel = (next_ >= 1) and list[next_] or nil owner[key] = rel local img = rel and TouchSkin.resolveImage(Studio.skin.root, rel) or nil - if field == "pressed" then owner.pressedImage = img else owner.image = img end + if field == "pressed" then + owner.pressedImage = img + else + owner.image = img + if field == "bezel" then TouchSkin.applyImageAspect(owner) end + end markDirty() end @@ -748,6 +906,7 @@ function Studio.assignImage(rel) ctl.imagePath, ctl.image = rel, img elseif page then page.imagePath, page.image = rel, img + TouchSkin.applyImageAspect(page) end Studio.images = TouchSkin.listImages(Studio.skin.root) Studio.dirty = true @@ -1228,8 +1387,11 @@ function Studio.updateDrag(mx, my, r) ctl.rangeX = math.max(0.002, (bw * 0.5) / pw) ctl.rangeY = math.max(0.002, (bh * 0.5) / ph) else + -- Permit an anchor to live beyond every canvas edge. That is useful for + -- intentional off-centre compositions and mirrors the normal Screen Pos + -- behaviour at runtime; only a non-zero size is required. page.viewport = { - x = clamp01((bx - px) / pw), y = clamp01((by - py) / ph), + x = (bx - px) / pw, y = (by - py) / ph, w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph), } end @@ -1238,6 +1400,35 @@ end -- ----------------------------------------------------------------- draw +local function drawGameTest(r, page) + -- Test letterboxes a 160x144 picture inside the screen cutout, the same + -- way gameplay fits the Game Boy surface into the hole. + local vx, vy, vw, vh = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y) + if not vx then vx, vy, vw, vh = r.x, r.y, r.w, r.h end + local s = math.min(vw / 160, vh / 144) + local gw, gh = 160 * s, 144 * s + local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5 + Theme.fill(r.x, r.y, r.w, r.h, { 8, 12, 8 }, 1) + Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1) + local tile = math.max(2, math.floor(16 * s)) + for row = 0, 8 do + for col = 0, 9 do + if (row + col) % 2 == 0 then + Theme.fill(gx + col * tile, gy + row * tile, tile, tile, + { 139, 172, 15 }, 0.45) + end + end + end + Theme.fill(gx + gw * 0.10, gy + gh * 0.15, gw * 0.22, gh * 0.18, + { 48, 98, 48 }, 0.9) + Theme.fill(gx + gw * 0.58, gy + gh * 0.49, gw * 0.12, gh * 0.18, + { 48, 98, 48 }, 0.9) + Theme.fill(gx + gw * 0.14, gy + gh * 0.70, gw * 0.72, gh * 0.16, + { 224, 248, 208 }, 1) + Kit.text("small", "TEST BATTLE", gx + gw * 0.18, gy + gh * 0.74, + { 24, 56, 24 }) +end + local function drawCanvas(x, y, w, h) local page = Studio.page() local r = { } @@ -1251,7 +1442,9 @@ local function drawCanvas(x, y, w, h) syncActive() local vx, vy, vw, vh = viewportRect(page, r) - if vx then + if Studio.testing then + drawGameTest(r, page) + elseif vx then local scale = math.min(vw / 160, vh / 144) local gw, gh = 160 * scale, 144 * scale local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5 @@ -1714,13 +1907,24 @@ local function drawOpenModal(W, H) local rowH = math.max(Kit.tapMin(), 46 * Kit.scale) local gap = 4 * Kit.scale local list = Studio.available or {} - local contentH = #list * (rowH + gap) + -- A visible Off choice is more intentional than making people infer that + -- an empty selection disables the overlay. It also works from the skin + -- grid on phones, where the normal pad editor is not the destination. + local offH = rowH + gap + local contentH = offH + #list * (rowH + gap) local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) local maxScroll = Kit.scrollExtent(contentH, viewH) local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + local offClicked = Kit.row(mx + pad, baseY, mw - pad * 2, rowH, false, + "open-off") + Kit.text("mono", "Off", mx + pad * 2, baseY + 6 * Kit.scale, PAL.red) + Kit.text("small", "Hide all on-screen controls.", mx + pad * 2, + baseY + 6 * Kit.scale + Kit.textHeight("mono"), PAL.muted) + if offClicked then Studio.disableTouchControls() end + for i, entry in ipairs(list) do - local py = baseY + (i - 1) * (rowH + gap) + local py = baseY + offH + (i - 1) * (rowH + gap) local selected = Studio.skin and Studio.skin.id == entry.id local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected, "open-" .. entry.id) @@ -1744,14 +1948,19 @@ local function drawPageModal(W, H) local top = my + pad + Kit.textHeight("title") + pad * 0.5 local by, bh = modalFooter(mx, my, mw, mh, pad) local gap = 6 * Kit.scale - local fieldW = math.min(220 * Kit.scale, (mw - pad * 2) * 0.5) - Studio.pageNameField = Kit.textfield("pagename", mx + pad, by, fieldW, bh, + -- Keep the page name easy to edit: one full-width field, then its actions + -- below it, with Close retaining its own footer row. + local fieldY = by - bh * 2 - gap * 2 + local actionY = by - bh - gap + local innerW = mw - pad * 2 + local actionW = (innerW - gap) * 0.5 + Studio.pageNameField = Kit.textfield("pagename", mx + pad, fieldY, innerW, bh, Studio.pageNameField or "", "page name") - if Kit.button(mx + pad + fieldW + gap, by, 100 * Kit.scale, bh, "Rename", + if Kit.button(mx + pad, actionY, actionW, bh, "Rename", { id = "page-rename" }) then Studio.renamePage(Studio.pageNameField) end - if Kit.button(mx + pad + fieldW + gap + 106 * Kit.scale, by, 100 * Kit.scale, + if Kit.button(mx + pad + actionW + gap, actionY, actionW, bh, "Delete", { id = "page-del", kind = "danger", enabled = skin and #skin.pages > 1 }) then local index = Studio.pageIndex @@ -1760,7 +1969,7 @@ local function drawPageModal(W, H) function() Studio.deletePage(index) end, "Delete") end - local viewH = by - top - pad + local viewH = fieldY - top - pad local rowH = math.max(Kit.tapMin(), 44 * Kit.scale) local pages = (skin and skin.pages) or {} local contentH = #pages * (rowH + gap) @@ -1852,10 +2061,11 @@ function Studio.drawOverlay(W, H) return true end -function Studio.draw() +local function drawLegacyStudio() local W, H = love.graphics.getDimensions() Kit.layout(W, H) local mx, my = love.mouse.getPosition() + if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) Studio.clicked, Studio.wheel = false, 0 @@ -1864,50 +2074,64 @@ function Studio.draw() Kit.blockClicks = Studio.modalUp() local pad = 14 * Kit.scale - local barH = math.max(Kit.tapMin(), 34 * Kit.scale) + pad + local mobile = Studio.isMobile() + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + -- A phone gets an intentional two-row toolbar. The desktop's single row + -- is excellent at a wide monitor, but it would run its canvas/test/history + -- controls beneath Close on a portrait display. + local barH = mobile and (btnH * 2 + pad * 1.5) or (btnH + pad) Kit.textBold("title", "Skin Studio", pad, pad * 0.6, PAL.heading) local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2 - local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) - local bx = titleW + local toolY = mobile and (pad * 0.5 + btnH + pad * 0.25) or (pad * 0.5) + local bx = mobile and pad or titleW local canvas = Studio.canvas() - if Kit.button(bx, pad * 0.5, 200 * Kit.scale, btnH, + local canvasW = mobile and math.min(200 * Kit.scale, W * 0.4) or 200 * Kit.scale + if Kit.button(bx, toolY, canvasW, btnH, canvas.label, { id = "canvas" }) then Studio.setCanvas(Studio.canvasIndex + 1) end - bx = bx + 208 * Kit.scale - if Kit.button(bx, pad * 0.5, 110 * Kit.scale, btnH, + bx = bx + canvasW + 8 * Kit.scale + local testW = mobile and math.min(110 * Kit.scale, W * 0.21) or 110 * Kit.scale + if Kit.button(bx, toolY, testW, btnH, Studio.testing and "Test: ON" or "Test: OFF", { id = "test", active = Studio.testing }) then Studio.testing = not Studio.testing TouchControls:setPreview(not Studio.testing) TouchControls:reset() end - bx = bx + 118 * Kit.scale + bx = bx + testW + 8 * Kit.scale local smallW = 74 * Kit.scale - if Kit.button(bx, pad * 0.5, smallW, btnH, "Undo", + if Kit.button(bx, toolY, smallW, btnH, "Undo", { id = "undo", font = "small", enabled = Studio.canUndo() }) then Studio.undo() end bx = bx + smallW + 6 * Kit.scale - if Kit.button(bx, pad * 0.5, smallW, btnH, "Redo", + if Kit.button(bx, toolY, smallW, btnH, "Redo", { id = "redo", font = "small", enabled = Studio.canRedo() }) then Studio.redo() end bx = bx + smallW + 6 * Kit.scale - if Kit.button(bx, pad * 0.5, smallW + 20 * Kit.scale, btnH, - Studio.showLabels and "Labels: ON" or "Labels: OFF", - { id = "labels", font = "small", active = Studio.showLabels }) then - Studio.showLabels = not Studio.showLabels + if not mobile then + if Kit.button(bx, toolY, smallW + 20 * Kit.scale, btnH, + Studio.showLabels and "Labels: ON" or "Labels: OFF", + { id = "labels", font = "small", active = Studio.showLabels }) then + Studio.showLabels = not Studio.showLabels + end + bx = bx + smallW + 26 * Kit.scale end - bx = bx + smallW + 26 * Kit.scale if Studio.dirty then - Kit.text("small", "unsaved", bx, pad * 0.5 + btnH * 0.3, PAL.yellow) + Kit.text("small", "unsaved", bx, toolY + btnH * 0.3, PAL.yellow) end local closeW = 100 * Kit.scale + local offW = 74 * Kit.scale + if Kit.button(W - pad - closeW - offW - 6 * Kit.scale, pad * 0.5, offW, btnH, + "Off", { id = "off", kind = "danger" }) then + Studio.disableTouchControls() + end local closed = false if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close", { id = "close" }) then @@ -1922,17 +2146,28 @@ function Studio.draw() return end - local panelW = math.min(360 * Kit.scale, W * 0.34) local bodyY = barH + pad * 0.5 local bodyH = H - bodyY - pad - - drawInspector(pad, bodyY, panelW, bodyH) - - local cx = pad * 2 + panelW - local cw = W - cx - pad - local r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale) - - local footY = bodyY + bodyH - 30 * Kit.scale + local r, cx, cw, footY + if Studio.isMobile() then + -- On a phone the canvas stays wide and the inspector becomes the lower + -- sheet. Both remain on screen, so a tapped control can be adjusted + -- without swapping modes or hiding the preview. + cx, cw = pad, W - pad * 2 + local canvasH = math.max(180 * Kit.scale, (bodyH - 46 * Kit.scale) * 0.46) + r = drawCanvas(cx, bodyY, cw, canvasH) + local inspectorY = bodyY + canvasH + 8 * Kit.scale + local inspectorH = math.max(0, H - inspectorY - pad - 30 * Kit.scale) + drawInspector(pad, inspectorY, W - pad * 2, inspectorH) + footY = H - pad - 24 * Kit.scale + else + local panelW = math.min(360 * Kit.scale, W * 0.34) + drawInspector(pad, bodyY, panelW, bodyH) + cx = pad * 2 + panelW + cw = W - cx - pad + r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale) + footY = bodyY + bodyH - 30 * Kit.scale + end local msg = Studio.status if not msg and Studio.testing then local held = {} @@ -1954,6 +2189,259 @@ function Studio.draw() Studio.canvasArea = r end +-- ------------------------------------------------------ mobile-first studio + +-- Kept as an opt-in diagnostic renderer while downstream tools migrate; the +-- active Studio path below is the library/editor flow. +Studio.drawLegacy = drawLegacyStudio + +local function studioCard(x, y, w, h, id, active) + local focused = Kit.focusable(id, x, y, w, h) + Kit.card(x, y, w, h, active and "selected" + or (focused or Kit.hover(x, y, w, h))) + return Kit.press(x, y, w, h) or Kit._activateId == id +end + +local function drawSkinArtwork(image, x, y, w, h) + Theme.fillRounded(x, y, w, h, PAL.bg, 1, Theme.cardRadius() * 0.65) + if image and image.getDimensions then + local iw, ih = image:getDimensions() + if iw > 0 and ih > 0 then + local s = math.min((w - 14 * Kit.scale) / iw, (h - 14 * Kit.scale) / ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, x + (w - iw * s) * 0.5, + y + (h - ih * s) * 0.5, 0, s, s) + return + end + end + -- A neutral mock reads as a touch skin even before it has artwork. + Theme.strokeRounded(x + w * 0.19, y + h * 0.10, w * 0.62, h * 0.44, + PAL.line, Theme.A.hairline, 1, 3) + Theme.fillRounded(x + w * 0.13, y + h * 0.64, w * 0.26, h * 0.18, + PAL.steel, 0.8, 3) + Theme.fillRounded(x + w * 0.60, y + h * 0.59, w * 0.13, h * 0.24, + PAL.steel, 0.8, h * 0.12) + Theme.fillRounded(x + w * 0.77, y + h * 0.54, w * 0.13, h * 0.24, + PAL.steel, 0.8, h * 0.12) +end + +local function drawLibrary(W, H, pad) + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + local titleY = pad * 0.55 + Kit.textBold("title", "My Skins", pad, titleY, PAL.heading) + local closeW = math.min(112 * Kit.scale, W * 0.24) + if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close", + { id = "library-close" }) then + if Studio.onClose then Studio.onClose() end + return + end + + local y = pad * 0.5 + btnH + pad + local gap = 8 * Kit.scale + local half = (W - pad * 2 - gap) * 0.5 + if Kit.button(pad, y, half, btnH * 1.12, "+ New skin", + { id = "library-new", kind = "accent" }) then + Studio.newSkin() + Studio.enterEditor() + return + end + if Kit.button(pad + half + gap, y, half, btnH * 1.12, "Import skin", + { id = "library-import" }) then + Studio.importSkinFile() + return + end + y = y + btnH * 1.12 + pad + + Kit.caption(pad, y, "YOUR SKINS") + y = y + Kit.textHeight("small") + gap + + local entries = Studio.available or {} + local cols = W >= 520 * Kit.scale and 3 or 2 + local cardGap = 10 * Kit.scale + local cardW = (W - pad * 2 - cardGap * (cols - 1)) / cols + local cardH = math.max(182 * Kit.scale, cardW * 1.42) + local saved = SaveData.loadOptions() + local tc = type(saved.touchControls) == "table" and saved.touchControls or {} + local activeId = tc.enabled == false and nil or tc.skin + local total = #entries + local maxRows = math.max(1, math.floor((H - y - pad - btnH - gap) + / (cardH + cardGap))) + local perPage = maxRows * cols + local pages = math.max(1, math.ceil(total / perPage)) + Studio.libraryPage = math.max(1, math.min(Studio.libraryPage or 1, pages)) + local first = (Studio.libraryPage - 1) * perPage + 1 + local last = math.min(total, first + perPage - 1) + + for slot = first, last do + local index = slot - first + local cx = pad + (index % cols) * (cardW + cardGap) + local cy = y + math.floor(index / cols) * (cardH + cardGap) + do + local entry = entries[slot] + local selected = entry.id == activeId + Kit.card(cx, cy, cardW, cardH, selected and "selected" or nil) + local artH = cardH * 0.45 + drawSkinArtwork(Studio.libraryThumb(entry), cx + 7 * Kit.scale, + cy + 7 * Kit.scale, cardW - 14 * Kit.scale, artH) + Kit.text("mono", Kit.ellipsize("mono", entry.id, cardW - 20 * Kit.scale), + cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale, + selected and PAL.green or PAL.heading) + local meta = Studio.skinSummary(entry) + Kit.text("small", Kit.ellipsize("small", meta, cardW - 20 * Kit.scale), + cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale + Kit.textHeight("mono"), + PAL.muted) + local actionGap = 4 * Kit.scale + local actionW = (cardW - 20 * Kit.scale - actionGap) * 0.5 + local actionY = cy + cardH - btnH * 2 - actionGap - 8 * Kit.scale + if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH, + selected and "Selected" or "Select", + { id = "skin-select-" .. entry.id, + kind = selected and "good" or "accent" }) then + Studio.selectEntry(entry.id) + end + if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH, + "Edit", { id = "skin-edit-" .. entry.id, kind = "accent" }) then + Studio.loadEntry(entry.id) + Studio.enterEditor() + return + end + actionY = actionY + btnH + actionGap + if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH, + "Export", { id = "skin-export-" .. entry.id }) then + Studio.exportEntry(entry.id) + end + if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH, + "Delete", { id = "skin-delete-" .. entry.id, + kind = "danger", enabled = entry.source == "user" }) then + Studio.deleteEntry(entry.id) + end + end + end + + if pages > 1 then + local pagerY = H - pad - btnH + local prevW, nextW = (W - pad * 2 - gap) * 0.5, (W - pad * 2 - gap) * 0.5 + if Kit.button(pad, pagerY, prevW, btnH, "Previous", + { id = "library-prev", enabled = Studio.libraryPage > 1 }) then + Studio.libraryPage = Studio.libraryPage - 1 + end + if Kit.button(pad + prevW + gap, pagerY, nextW, btnH, "Next", + { id = "library-next", enabled = Studio.libraryPage < pages }) then + Studio.libraryPage = Studio.libraryPage + 1 + end + end + + if #entries == 0 then + Kit.text("small", "Create a blank skin or import one to get started.", pad, + y + cardH + gap, PAL.muted) + end +end + +local function drawEditor(W, H, pad) + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + local gap = 7 * Kit.scale + local backW = math.min(120 * Kit.scale, W * 0.23) + if Kit.button(pad, pad * 0.5, backW, btnH, "My Skins", + { id = "editor-back" }) then + Studio.backToLibrary() + end + local closeW = math.min(90 * Kit.scale, W * 0.17) + local saveW = math.min(90 * Kit.scale, W * 0.17) + local testW = math.min(100 * Kit.scale, W * 0.18) + local right = W - pad + if Kit.button(right - closeW, pad * 0.5, closeW, btnH, "Close", + { id = "editor-close" }) then + Studio.guard("Close the studio and lose the unsaved changes?", function() + if Studio.onClose then Studio.onClose() end + end) + end + right = right - closeW - gap + if Kit.button(right - saveW, pad * 0.5, saveW, btnH, "Save", + { id = "editor-save", kind = "accent" }) then Studio.save() end + right = right - saveW - gap + if Kit.button(right - testW, pad * 0.5, testW, btnH, + Studio.testing and "Test: ON" or "Test", + { id = "editor-test", active = Studio.testing }) then + Studio.testing = not Studio.testing + TouchControls:setPreview(not Studio.testing) + TouchControls:reset() + end + local titleX = pad + backW + gap + local titleW = math.max(0, right - testW - gap - titleX) + Kit.textBold("button", Kit.ellipsize("button", + (Studio.skin and Studio.skin.name) or "Untitled skin", titleW), + titleX, pad * 0.5 + (btnH - Kit.textHeight("button")) * 0.5, PAL.heading) + + local trayH = btnH * 2 + gap * 3 + 30 * Kit.scale + local bodyY = pad * 0.5 + btnH + pad + local trayY = H - pad - trayH + local canvasH = math.max(120 * Kit.scale, trayY - bodyY - pad) + drawCanvas(pad, bodyY, W - pad * 2, canvasH) + + Kit.card(pad, trayY, W - pad * 2, trayH) + local innerX, innerW = pad + gap, W - pad * 2 - gap * 2 + local actionW = (innerW - gap * 3) / 4 + local ctl = Studio.selectedControl() + if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control", + { id = "tray-add", kind = "accent" }) then + Studio.addControl() + Studio.openBindPicker() + end + if Kit.button(innerX + (actionW + gap), trayY + gap, actionW, btnH, + ctl and ("Bind: " .. ctl.spec) or "Bind", + { id = "tray-bind", enabled = ctl ~= nil }) then + Studio.openBindPicker() + end + if Kit.button(innerX + (actionW + gap) * 2, trayY + gap, actionW, btnH, + "Button art", { id = "tray-art", enabled = ctl ~= nil }) then + Studio.openImagePicker("idle") + end + if Kit.button(innerX + (actionW + gap) * 3, trayY + gap, actionW, btnH, + "Bezel art", { id = "tray-bezel" }) then + Studio.openImagePicker("bezel") + end + + if Kit.button(innerX, trayY + gap * 2 + btnH, actionW, btnH, "Pages", + { id = "tray-pages" }) then Studio.openPageMenu() end + if Kit.button(innerX + (actionW + gap), trayY + gap * 2 + btnH, actionW, btnH, + "Screen", { id = "tray-screen" }) then Studio.toggleViewport() end + if Kit.button(innerX + (actionW + gap) * 2, trayY + gap * 2 + btnH, actionW, btnH, + Studio.aspectLock and "Shape: 10:9" or "Shape: Free", + { id = "tray-shape", active = Studio.aspectLock }) then + Studio.aspectLock = not Studio.aspectLock + end + if Kit.button(innerX + (actionW + gap) * 3, trayY + gap * 2 + btnH, actionW, btnH, + "Delete", { id = "tray-delete", kind = "danger", enabled = ctl ~= nil }) then + Studio.deleteControl() + end + local hint = ctl and ("Selected: " .. TouchSkin.describeBind(ctl.spec) + .. " — drag to move; use blue handles to resize.") + or "Tap a control to select it. Drag to move; use blue handles to resize." + Kit.text("small", Kit.ellipsize("small", Studio.status or hint, innerW), + innerX, trayY + trayH - Kit.textHeight("small") - gap, + Studio.statusErr and PAL.red or PAL.muted) +end + +function Studio.draw() + local W, H = love.graphics.getDimensions() + Kit.layout(W, H) + local mx, my = love.mouse.getPosition() + if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end + Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) + Studio.clicked, Studio.wheel = false, 0 + Theme.fill(0, 0, W, H, PAL.bg, 1) + Studio.expireStatus() + Kit.blockClicks = Studio.modalUp() + + local pad = 14 * Kit.scale + if Studio.mode == "library" then drawLibrary(W, H, pad) + else drawEditor(W, H, pad) end + + Kit.blockClicks = false + Studio.drawOverlay(W, H) + Kit.endFrame() +end + -- ---------------------------------------------------------------- input function Studio.update() @@ -1965,8 +2453,11 @@ end function Studio.mousepressed(x, y, button) if button ~= 1 then return end + Studio.pointerX, Studio.pointerY = x, y + Studio.pointerDown = true Studio.clicked = true if Studio.modalUp() then return end + if Studio.mode == "library" then return end local r = Studio.lastCanvas if not r then return end if Studio.testing then @@ -1984,18 +2475,22 @@ function Studio.mousepressed(x, y, button) end function Studio.mousemoved(x, y) + Studio.pointerX, Studio.pointerY = x, y if Studio.modalUp() then return end + if Studio.mode == "library" then return end if Studio.testing then TouchControls:touchmoved("studio", x, y) return end - if Studio.drag and love.mouse.isDown(1) and Studio.lastCanvas then + if Studio.drag and (Studio.pointerDown or love.mouse.isDown(1)) and Studio.lastCanvas then Studio.updateDrag(x, y, Studio.lastCanvas) end end function Studio.mousereleased(x, y, button) if button ~= 1 then return end + Studio.pointerX, Studio.pointerY = x, y + Studio.pointerDown = false if Studio.testing then TouchControls:touchreleased("studio", x, y) return @@ -2004,6 +2499,23 @@ function Studio.mousereleased(x, y, button) Studio.guides = nil end +function Studio.touchpressed(id, x, y) + if Studio.touchId and Studio.touchId ~= id then return end + Studio.touchId = id + return Studio.mousepressed(x, y, 1) +end + +function Studio.touchmoved(id, x, y) + if Studio.touchId ~= id then return end + return Studio.mousemoved(x, y) +end + +function Studio.touchreleased(id, x, y) + if Studio.touchId ~= id then return end + Studio.touchId = nil + return Studio.mousereleased(x, y, 1) +end + function Studio.wheelmoved(_, dy) Studio.wheel = dy end @@ -2011,6 +2523,7 @@ end function Studio.focus() Studio.drag = nil Studio.clicked = false + Studio.pointerDown, Studio.touchId = false, nil if Studio.testing then TouchControls:reset() end end @@ -2094,8 +2607,9 @@ function Studio.keypressed(key) end function Studio.available_desktop() - local osName = love.system and love.system.getOS and love.system.getOS() - return osName ~= "Android" and osName ~= "iOS" + -- Kept as the compatibility name for launcher integrations. The Studio + -- now has a touch layout and is available on every supported platform. + return true end return Studio diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index b019a12b..c4abcbaa 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -838,8 +838,7 @@ function BoxMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/CardFlip.lua b/src/ui/gen2/CardFlip.lua index a1b596ce..88e7a170 100644 --- a/src/ui/gen2/CardFlip.lua +++ b/src/ui/gen2/CardFlip.lua @@ -49,9 +49,9 @@ -- Textbox at (0,12) with an 18x4 interior -- -- The cart's own art (gfx/card_flip/card_flip_1..3.2bpp.lz and --- gfx/card_flip/card_flip.tilemap) is NOT in the cache: no `cardFlip` entry is --- written into menu_gfx.lua yet, so the board draws as labelled cells until one --- appears. +-- gfx/card_flip/card_flip.tilemap) is extracted into assets/generated/card_flip/ +-- when the Gold/Silver manifest carries CardFlip*. Until those files exist, +-- the board draws as labelled cells. local Chrome = require("src.ui.gen2.Chrome") local CoinCase = require("src.core.gen2.CoinCase") @@ -616,10 +616,8 @@ local TILEMAP = nil local function getCardFlipTilemap() if TILEMAP == nil then local path = "assets/generated/card_flip/card_flip.tilemap" - local f = io.open(path, "rb") - if f then - local data = f:read("*a") - f:close() + local data = love and love.filesystem and love.filesystem.read(path) + if data and #data > 0 then TILEMAP = {} for i = 1, #data do TILEMAP[i] = string.byte(data, i) @@ -974,8 +972,7 @@ function CardFlip:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/CenterPcMenu.lua b/src/ui/gen2/CenterPcMenu.lua index 49d6794a..222f7213 100644 --- a/src/ui/gen2/CenterPcMenu.lua +++ b/src/ui/gen2/CenterPcMenu.lua @@ -313,8 +313,7 @@ function CenterPcMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/ContestMenu.lua b/src/ui/gen2/ContestMenu.lua index cc309f6a..52f5a52d 100644 --- a/src/ui/gen2/ContestMenu.lua +++ b/src/ui/gen2/ContestMenu.lua @@ -180,8 +180,7 @@ function ContestMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/CopyrightSplash.lua b/src/ui/gen2/CopyrightSplash.lua index e5271b1b..abeed958 100644 --- a/src/ui/gen2/CopyrightSplash.lua +++ b/src/ui/gen2/CopyrightSplash.lua @@ -108,8 +108,7 @@ function CopyrightSplash:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/Credits.lua b/src/ui/gen2/Credits.lua index 97ca86ba..8ab716e0 100644 --- a/src/ui/gen2/Credits.lua +++ b/src/ui/gen2/Credits.lua @@ -845,8 +845,7 @@ function Credits:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/DecorationMenu.lua b/src/ui/gen2/DecorationMenu.lua index fedc2db3..f8c100f6 100644 --- a/src/ui/gen2/DecorationMenu.lua +++ b/src/ui/gen2/DecorationMenu.lua @@ -267,8 +267,7 @@ function DecorationMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/EggHatchAnim.lua b/src/ui/gen2/EggHatchAnim.lua index 6b62db6d..931fd3b4 100644 --- a/src/ui/gen2/EggHatchAnim.lua +++ b/src/ui/gen2/EggHatchAnim.lua @@ -43,8 +43,6 @@ local EggHatchAnim = {} EggHatchAnim.__index = EggHatchAnim EggHatchAnim.isOpaque = true -local SCREEN_W, SCREEN_H = 160, 144 - -- Hatch_UpdateFrontpicBGMapCenter is called twice with different hlcoords: -- the egg sits at (7,4) and the hatchling at (6,3). Both are `lb bc, 7, 7` -- PlaceGraphic boxes, and the pic inside that box has already been padded to @@ -446,8 +444,7 @@ function EggHatchAnim:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/EvolutionAnim.lua b/src/ui/gen2/EvolutionAnim.lua index b3c4f02c..753d2880 100644 --- a/src/ui/gen2/EvolutionAnim.lua +++ b/src/ui/gen2/EvolutionAnim.lua @@ -40,8 +40,6 @@ local EvolutionAnim = {} EvolutionAnim.__index = EvolutionAnim EvolutionAnim.isOpaque = true -local SCREEN_W, SCREEN_H = 160, 144 - -- PrepMonFrontpic's box: hlcoord 7, 2, `lb bc, 7, 7`. local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7 @@ -252,6 +250,8 @@ function EvolutionAnim:setPhase(phase) full = self.full, }) end + local stack = self.game and self.game.stack + if stack and stack.top and stack:top() == self then stack:pop() end return end end @@ -321,8 +321,18 @@ end function EvolutionAnim:update(_dt) local input = self.game and self.game.input local phase = self.phase - -- onDone has already fired; the caller pops this state on its own beat. - if phase == "done" then return end + -- onDone has already fired. + if phase == "done" then + local stack = self.game and self.game.stack + if stack and stack.top and stack:top() == self then stack:pop() end + return + end + + if phase == "waitingLearn" then + local stack = self.game and self.game.stack + if stack and stack.top and stack:top() == self then self:nextLearn() end + return + end if phase == "flash" then return self:updateFlash(input) @@ -561,8 +571,7 @@ function EvolutionAnim:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/GameFreakPresents.lua b/src/ui/gen2/GameFreakPresents.lua index 668edfcb..7eab4456 100644 --- a/src/ui/gen2/GameFreakPresents.lua +++ b/src/ui/gen2/GameFreakPresents.lua @@ -372,8 +372,7 @@ function GameFreakPresents:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/GoldSilverIntro.lua b/src/ui/gen2/GoldSilverIntro.lua index b8eeb39f..1d6aabb3 100644 --- a/src/ui/gen2/GoldSilverIntro.lua +++ b/src/ui/gen2/GoldSilverIntro.lua @@ -1033,8 +1033,7 @@ function GoldSilverIntro:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/HallOfFame.lua b/src/ui/gen2/HallOfFame.lua index 8d593003..23ee87ce 100644 --- a/src/ui/gen2/HallOfFame.lua +++ b/src/ui/gen2/HallOfFame.lua @@ -657,8 +657,7 @@ function HallOfFame:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 3984f95e..9ef4babe 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -592,8 +592,7 @@ function ItemPcMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/MailCompose.lua b/src/ui/gen2/MailCompose.lua index 409f6a93..4101c4c9 100644 --- a/src/ui/gen2/MailCompose.lua +++ b/src/ui/gen2/MailCompose.lua @@ -341,8 +341,7 @@ function MailCompose:drawWidescreen(winW, winH) G.setColor(1, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/MailRead.lua b/src/ui/gen2/MailRead.lua index d3b22235..b96c857a 100644 --- a/src/ui/gen2/MailRead.lua +++ b/src/ui/gen2/MailRead.lua @@ -94,8 +94,7 @@ function MailRead:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/MainMenu.lua b/src/ui/gen2/MainMenu.lua index d6f97b70..15d653ec 100644 --- a/src/ui/gen2/MainMenu.lua +++ b/src/ui/gen2/MainMenu.lua @@ -218,8 +218,7 @@ function MainMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/NamePick.lua b/src/ui/gen2/NamePick.lua index ccfe5ff8..3f2dbee6 100644 --- a/src/ui/gen2/NamePick.lua +++ b/src/ui/gen2/NamePick.lua @@ -225,8 +225,7 @@ function NamePick:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 0d3a3e11..3ff6f334 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -123,13 +123,11 @@ local ROWS = { { label = "ZOOM", key = "zoom", port = true, cycle = function(options, delta, game) local Zoom = require("src.render.Zoom") - local scale = (game and game.world and game.world.fitScale - and game.world:fitScale()) or 1 - local lo, hi = Zoom.offsetRange(scale) - local offset = (options.zoom or 0) + delta - if offset > hi then offset = lo elseif offset < lo then offset = hi end - options.zoom = offset - Zoom.offset = offset + local scale = Zoom.windowFitScale() + if game and game.world and game.world.fitScale then + scale = game.world:fitScale() + end + Zoom.nudgeOptions(options, delta, scale) end, text = function(options) return require("src.render.Zoom").offsetLabel(options.zoom or 0) @@ -466,8 +464,7 @@ function OptionsMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 72aa6132..10384b76 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -1027,8 +1027,7 @@ function PackMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index 6cae3190..cefa59aa 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -849,8 +849,7 @@ function PartyMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/PcMenu.lua b/src/ui/gen2/PcMenu.lua index 3d283807..cf365eca 100644 --- a/src/ui/gen2/PcMenu.lua +++ b/src/ui/gen2/PcMenu.lua @@ -453,8 +453,7 @@ function PcMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index fbd14aef..a0c4a685 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -1462,8 +1462,7 @@ function PokedexMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/PrizeMenu.lua b/src/ui/gen2/PrizeMenu.lua index d3b166b3..41f43756 100644 --- a/src/ui/gen2/PrizeMenu.lua +++ b/src/ui/gen2/PrizeMenu.lua @@ -560,8 +560,7 @@ function PrizeMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index 31811fed..c0eeb47b 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -228,8 +228,7 @@ function SaveMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/SlotMachine.lua b/src/ui/gen2/SlotMachine.lua index 83bd38fb..fe0bded1 100644 --- a/src/ui/gen2/SlotMachine.lua +++ b/src/ui/gen2/SlotMachine.lua @@ -39,11 +39,9 @@ -- tiles at (2,13),(3,13),(2,14),(3,14) and the ▼ at -- (18,17) -- --- The cart's own reel art (gfx/slots/slots_1..3.2bpp.lz plus --- gfx/slots/slots.tilemap) is NOT in the cache: src/import/RomExtractorGen2.lua --- writes no `slots` entry into menu_gfx.lua yet. SlotMachine:sheet() reads one --- the moment it appears and falls back to labelled cells until then, the same --- way src/ui/gen2/PackGfx.lua degrades. +-- Reel art (gfx/slots/slots_1..3.2bpp.lz + slots.tilemap) is extracted into +-- assets/generated/slots/ when the Gold/Silver manifest carries Slots*LZ. +-- Until those files exist, drawReels falls back to labelled cells. local Chrome = require("src.ui.gen2.Chrome") local CoinCase = require("src.core.gen2.CoinCase") @@ -1160,10 +1158,8 @@ local TILEMAP = nil local function getTilemap() if TILEMAP == nil then local path = "assets/generated/slots/gold_slots.tilemap" - local f = io.open(path, "rb") - if f then - local data = f:read("*a") - f:close() + local data = love and love.filesystem and love.filesystem.read(path) + if data and #data > 0 then TILEMAP = {} for i = 1, #data do TILEMAP[i] = string.byte(data, i) @@ -1175,6 +1171,14 @@ local function getTilemap() return TILEMAP or nil end +-- Placeholder 2x2 symbol cell used when reel sheets are not in the cache yet. +local function cell(tx, ty, label) + local G = love.graphics + G.setColor(0, 0, 0, 1) + G.rectangle("line", tx * 8, ty * 8, 16, 16) + Chrome.print(label, tx, ty + 1) +end + function SlotMachine:sheets() if self.sheet1 == nil then self.sheet1 = TileSheet.new({ path = "assets/generated/slots/gold_slots_1.png", wide = 2, firstTile = 0 }) @@ -1253,36 +1257,40 @@ function SlotMachine:drawReels() -- Draw 4 consecutive 2x2 symbols from bottom to top, exactly matching SlotMachine.window for row = 0, 3 do local sym = strip[a + row + 1] - local py = 64 - (row * 16) + dy - local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] - s2.palette = pal - - -- 2x2 tiles in 2-wide sheet: - -- sym + 0 = top-left (col 0, row 0) - -- sym + 1 = top-right (col 1, row 0) - -- sym + 2 = bottom-left (col 0, row 1) - -- sym + 3 = bottom-right (col 1, row 1) - local t0 = s2:quad(sym + 0) - local t1 = s2:quad(sym + 1) - local t2 = s2:quad(sym + 2) - local t3 = s2:quad(sym + 3) - local img = s2:image() - - if img and t0 and t1 and t2 and t3 then - local function drawSym() - G.draw(img, t0, rx, py) - G.draw(img, t1, rx + 8, py) - G.draw(img, t2, rx, py + 8) - G.draw(img, t3, rx + 8, py + 8) - end - if GbcPalette.available() then - GbcPalette.with(pal, drawSym) - else - drawSym() - end + if type(sym) ~= "number" then + cell(REEL_X[i], REEL_ROW[row + 1], "?") else - -- Fallback label - cell(REEL_X[i], REEL_ROW[row + 1], SlotMachine.LABELS[sym] or "?") + local py = 64 - (row * 16) + dy + local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] + s2.palette = pal + + -- 2x2 tiles in 2-wide sheet: + -- sym + 0 = top-left (col 0, row 0) + -- sym + 1 = top-right (col 1, row 0) + -- sym + 2 = bottom-left (col 0, row 1) + -- sym + 3 = bottom-right (col 1, row 1) + local t0 = s2:quad(sym + 0) + local t1 = s2:quad(sym + 1) + local t2 = s2:quad(sym + 2) + local t3 = s2:quad(sym + 3) + local img = s2:image() + + if img and t0 and t1 and t2 and t3 then + local function drawSym() + G.draw(img, t0, rx, py) + G.draw(img, t1, rx + 8, py) + G.draw(img, t2, rx, py + 8) + G.draw(img, t3, rx + 8, py + 8) + end + if GbcPalette.available() then + GbcPalette.with(pal, drawSym) + else + drawSym() + end + else + -- Fallback label + cell(REEL_X[i], REEL_ROW[row + 1], SlotMachine.LABELS[sym] or "?") + end end end end @@ -1439,31 +1447,35 @@ function SlotMachine:drawMessage() and self.phase == "payoutText" then local _, s2 = self:sheets() local sym = self.matched - local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] - s2.palette = pal - local t0 = s2:quad(sym + 0) - local t1 = s2:quad(sym + 1) - local t2 = s2:quad(sym + 2) - local t3 = s2:quad(sym + 3) - local img = s2:image() - local G = love.graphics - local px, py = PAYOUT_SYMBOL_X * 8, PAYOUT_SYMBOL_Y * 8 - if img and t0 and t1 and t2 and t3 then - local function drawWin() - G.setColor(1, 1, 1, 1) - G.draw(img, t0, px, py) - G.draw(img, t1, px + 8, py) - G.draw(img, t2, px, py + 8) - G.draw(img, t3, px + 8, py + 8) - end - G.setColor(1, 1, 1, 1) - if GbcPalette.available() then - GbcPalette.with(pal, drawWin) - else - drawWin() - end + if type(sym) ~= "number" then + cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, "?") else - cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[self.matched] or "?") + local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] + s2.palette = pal + local t0 = s2:quad(sym + 0) + local t1 = s2:quad(sym + 1) + local t2 = s2:quad(sym + 2) + local t3 = s2:quad(sym + 3) + local img = s2:image() + local G = love.graphics + local px, py = PAYOUT_SYMBOL_X * 8, PAYOUT_SYMBOL_Y * 8 + if img and t0 and t1 and t2 and t3 then + local function drawWin() + G.setColor(1, 1, 1, 1) + G.draw(img, t0, px, py) + G.draw(img, t1, px + 8, py) + G.draw(img, t2, px, py + 8) + G.draw(img, t3, px + 8, py + 8) + end + G.setColor(1, 1, 1, 1) + if GbcPalette.available() then + GbcPalette.with(pal, drawWin) + else + drawWin() + end + else + cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[sym] or "?") + end end end end @@ -1523,8 +1535,7 @@ function SlotMachine:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 7fdbdd74..9ccc3723 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -1175,8 +1175,7 @@ function SummaryMenu:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/TradeAnim.lua b/src/ui/gen2/TradeAnim.lua index f0af7576..bd404038 100644 --- a/src/ui/gen2/TradeAnim.lua +++ b/src/ui/gen2/TradeAnim.lua @@ -58,8 +58,6 @@ local TradeAnimView = {} TradeAnimView.__index = TradeAnimView TradeAnimView.isOpaque = true -local SCREEN_W, SCREEN_H = 160, 144 - -- PlaceGraphic's box and the stats panel's Textbox, in tiles. local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7 local PANEL_X, PANEL_Y = 3, 0 @@ -876,8 +874,7 @@ function TradeAnimView:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - SCREEN_W * scale) / 2), - math.floor((winH - SCREEN_H * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/TrainerCard.lua b/src/ui/gen2/TrainerCard.lua index f3bdb4ee..4b993c52 100644 --- a/src/ui/gen2/TrainerCard.lua +++ b/src/ui/gen2/TrainerCard.lua @@ -437,8 +437,7 @@ function TrainerCard:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/src/ui/gen2/UnownPuzzle.lua b/src/ui/gen2/UnownPuzzle.lua index 1b707f3b..ff8e7d8a 100644 --- a/src/ui/gen2/UnownPuzzle.lua +++ b/src/ui/gen2/UnownPuzzle.lua @@ -580,8 +580,7 @@ function UnownPuzzle:drawWidescreen(winW, winH) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() - G.translate(math.floor((winW - 160 * scale) / 2), - math.floor((winH - 144 * scale) / 2)) + G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() diff --git a/tests/drivers/skin_studio_shot.lua b/tests/drivers/skin_studio_shot.lua index 75d8bffb..d0820a7e 100644 --- a/tests/drivers/skin_studio_shot.lua +++ b/tests/drivers/skin_studio_shot.lua @@ -9,7 +9,9 @@ return function(game) local dir = os.getenv("SHOT_DIR") or "/tmp/studio" os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') - love.window.setMode(1440, 900, { resizable = true, highdpi = true }) + local shotW = tonumber(os.getenv("STUDIO_SHOT_W")) or 1440 + local shotH = tonumber(os.getenv("STUDIO_SHOT_H")) or 900 + love.window.setMode(shotW, shotH, { resizable = true, highdpi = true }) U.wait(2) local pending = nil @@ -41,6 +43,14 @@ return function(game) U.log("skin:", Studio.skin and Studio.skin.id, "pages:", #(Studio.skin.pages or {})) U.log("controls:", #Studio.page().controls, "images:", #(Studio.images or {})) shot("studio_open.png") + Studio.enterEditor() + U.wait(2) + shot("studio_editor.png") + + Studio.openPageMenu() + U.wait(2) + shot("studio_pages.png") + Studio.closeModal() Studio.selected = 9 U.wait(3) diff --git a/tests/drivers/touch_skin_shot.lua b/tests/drivers/touch_skin_shot.lua index c45ad950..12a08c35 100644 --- a/tests/drivers/touch_skin_shot.lua +++ b/tests/drivers/touch_skin_shot.lua @@ -10,7 +10,9 @@ return function(game) local dir = os.getenv("SHOT_DIR") or "/tmp/skin" local skinId = os.getenv("SKIN") or "gb_anim" - love.window.setMode(432, 768, { resizable = true, highdpi = true }) + local shotW = tonumber(os.getenv("SHOT_W")) or 432 + local shotH = tonumber(os.getenv("SHOT_H")) or 768 + love.window.setMode(shotW, shotH, { resizable = true, highdpi = true }) U.wait(2) game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } @@ -43,10 +45,26 @@ return function(game) U.shot(game, dir .. "/skin_idle.png") local ww, wh = love.graphics.getDimensions() - local function at(nx, ny) return nx * ww, ny * wh end + local function centerFor(button) + local current = TouchSkin.page() + for _, ctl in ipairs(current and current.controls or {}) do + for _, bind in ipairs(ctl.buttons or {}) do + if bind == button then + local x, y = TouchSkin.controlGeometry(current, ctl, ww, wh) + return x, y + end + end + for _, hotkey in ipairs(ctl.hotkeys or {}) do + if hotkey == button then + local x, y = TouchSkin.controlGeometry(current, ctl, ww, wh) + return x, y + end + end + end + end - TouchControls:touchpressed("t1", at(0.87407, 0.72417)) - TouchControls:touchpressed("t2", at(0.24074, 0.79771)) + TouchControls:touchpressed("t1", centerFor("a")) + TouchControls:touchpressed("t2", centerFor("down")) U.wait(4) U.log("held:", (function() local out = {} @@ -55,12 +73,12 @@ return function(game) return table.concat(out, ",") end)()) U.shot(game, dir .. "/skin_pressed.png") - TouchControls:touchreleased("t1", at(0.87407, 0.72417)) - TouchControls:touchreleased("t2", at(0.24074, 0.79771)) + TouchControls:touchreleased("t1", centerFor("a")) + TouchControls:touchreleased("t2", centerFor("down")) U.wait(4) - TouchControls:touchpressed("t3", at(0.95, 0.528)) - TouchControls:touchreleased("t3", at(0.95, 0.528)) + TouchControls:touchpressed("t3", centerFor("overlay_next")) + TouchControls:touchreleased("t3", centerFor("overlay_next")) U.wait(6) U.log("page after overlay_next:", TouchSkin.page() and TouchSkin.page().name) U.shot(game, dir .. "/skin_page2.png") diff --git a/tests/engine/battle_charge_required.lua b/tests/engine/battle_charge_required.lua new file mode 100644 index 00000000..c046639a --- /dev/null +++ b/tests/engine/battle_charge_required.lua @@ -0,0 +1,345 @@ +-- Public, shared battle.charge_required hook. +-- +-- A mod that adds weather to Gen 1 needs to let SolarBeam resolve on the +-- turn it is selected without replacing the move, mutating private battle +-- state, or reimplementing the damage pipeline. This case loads a real +-- sandboxed mod through the public SDK and drives the real Gen 1 and Gold +-- battle engines. It also pins each generation's empty-chain decision. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") +local Font = require("src.render.Font") +local Gen2Battle = require("src.battle.gen2.Battle") +local Gen2Mon = require("src.battle.gen2.Mon") +local Pokemon = require("src.pokemon.Pokemon") +local Runtime = require("src.mods.Runtime") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") + +local function lowRoll(a) + return a or 0 +end + +local function queueHasText(battle, fragment) + for _, row in ipairs(battle.queue or {}) do + if row.text and row.text:find(fragment, 1, true) then return true end + end + return false +end + +local function eventHasText(battle, fragment) + for _, row in ipairs(battle.events or {}) do + if row.kind == "message" and row.text + and row.text:find(fragment, 1, true) then return true end + end + return false +end + +local function gen1Data() + local data = T.fixtures.fresh() + data.moves.SOLARBEAM = { + id = "SOLARBEAM", index = 80, name = "SOLARBEAM", type = "GRASS", + power = 120, accuracy = 100, pp = 10, effect = "CHARGE_EFFECT", + } + data.moves.FLY = { + id = "FLY", index = 81, name = "FLY", type = "FLYING", + power = 70, accuracy = 95, pp = 15, effect = "FLY_EFFECT", + } + data.moves.DIG = { + id = "DIG", index = 82, name = "DIG", type = "GROUND", + power = 100, accuracy = 100, pp = 10, effect = "FLY_EFFECT", + } + Font.load(data) + TypeChart.load(data) + return data +end + +local function gen1Battle(data, moveId) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + local move = { id = moveId, pp = 10, maxPp = 10 } + save.party[1].moves = { move } + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + local game = { data = data, save = save, stack = stack, + input = { wasPressed = function() return false end, + isDown = function() return false end } } + local battle = BattleState.newWild(game, "FIXMON_B", 20) + battle.rng = lowRoll + return battle, battle.player, battle.enemy, move +end + +local G2_TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + GRASS = { id = "GRASS", index = 22, category = "special" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, + GROUND = { id = "GROUND", index = 4, category = "physical" }, +} + +local G2_MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, + type = "NORMAL", accuracy = 100, pp = 35, + effect = "EFFECT_NORMAL_HIT" }, + SOLARBEAM = { id = "SOLARBEAM", name = "SOLARBEAM", power = 120, + type = "GRASS", accuracy = 100, pp = 10, + effect = "EFFECT_SOLARBEAM" }, + FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING", + accuracy = 95, pp = 15, effect = "EFFECT_FLY" }, + DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND", + accuracy = 100, pp = 10, effect = "EFFECT_FLY" }, +} + +local G2_DATA = { + pokemon = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + MACHOP = { + id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = {}, evolutions = {}, + }, + }, + moves = G2_MOVES, + type_chart = { types = G2_TYPES, matchups = {} }, + items = {}, +} + +local G2_DVS = { attack = 15, defense = 15, speed = 15, special = 15 } +G2_DVS.hp = Gen2Mon.hpDV(G2_DVS) + +local function gen2Battle(moveId, weather) + local player = Gen2Mon.new(G2_DATA, "MACHOP", 30, { dvs = G2_DVS }) + local move = { id = moveId, pp = 10, maxPp = 10 } + player.moves = { move } + local wild = Gen2Mon.new(G2_DATA, "MACHOP", 20, { dvs = G2_DVS }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Gen2Battle.new({ data = G2_DATA, party = { player }, + wild = wild, random = function(n) return math.max(0, (n or 1) - 1) end }) + battle.weather = weather + return battle, player, wild, move +end + +-- No-mod parity: Red always charges a charge-capable move on initial use, +-- spends PP only on that initial use, and resolves on the continuation. +do + local run = T.sdk.loadNone() + local battle, player, enemy, move = gen1Battle(gen1Data(), "SOLARBEAM") + local hp = enemy.mon.hp + battle:performMove(player, enemy, move) + T.eq(enemy.mon.hp, hp, "Gen 1 no-mod initial SolarBeam only charges") + T.eq(player.charging, move, "Gen 1 no-mod stores the selected move") + T.eq(move.pp, 9, "Gen 1 no-mod charge spends one PP") + T.check(queueHasText(battle, "took in sunlight"), + "Gen 1 no-mod keeps the charge text") + battle:performMove(player, enemy, move) + T.check(enemy.mon.hp < hp, "Gen 1 no-mod continuation resolves damage") + T.eq(move.pp, 9, "Gen 1 no-mod continuation spends no second PP") + run.release() +end + +-- No-mod parity: Gold's native answer remains weather-sensitive. Solarbeam +-- charges without sun and skips charge under sun. +do + local run = T.sdk.loadNone({ generation = 2 }) + local battle, player, wild, move = gen2Battle("SOLARBEAM") + local hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.eq(wild.hp, hp, "Gold no-mod initial Solarbeam charges without sun") + T.eq(player.volatile.chargeMove, "SOLARBEAM", + "Gold no-mod stores Solarbeam without sun") + T.eq(move.pp, 9, "Gold no-mod charge spends one PP") + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, "Gold no-mod continuation resolves damage") + T.eq(player.volatile.chargeMove, nil, + "Gold no-mod continuation clears stored charge state") + T.eq(move.pp, 9, "Gold no-mod continuation spends no second PP") + + battle, player, wild, move = gen2Battle("SOLARBEAM", "sun") + hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, "Gold no-mod sun skips Solarbeam charge") + T.eq(player.volatile.chargeMove, nil, + "Gold no-mod sun stores no charge continuation") + T.eq(move.pp, 9, "Gold no-mod sun still spends exactly one PP") + run.release() +end + +local MOD = { + ["mods/charge_probe/manifest.json"] = [[{ + "id": "charge_probe", + "name": "Charge Required Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"] + }]], + ["mods/charge_probe/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("battle.charge_required", function(nextFn, ctx) + mod.exports.calls = (mod.exports.calls or 0) + 1 + mod.exports.last = { + battle = ctx.battle ~= nil, + user = ctx.user ~= nil, + target = ctx.target ~= nil, + move = ctx.move and ctx.move.id, + charge = ctx.charge, + isCalled = ctx.isCalled, + } + if ctx.move.id == "SOLARBEAM" then return false end + return nextFn(ctx) + end) + ]], +} + +-- Public mod API, Gen 1: one conditional false resolves through the ordinary +-- damage pipeline on the first turn. Fly and Dig keep their vanilla charge +-- state, invulnerability, PP, and text; the release does not call the hook. +do + local run = T.sdk.loadMods({ "mods/charge_probe" }, { + fs = T.sdk.memfs(MOD), + }) + T.eq(#run.errors, 0, + "the public charge hook mod loads clean (" .. tostring(run.errors[1]) .. ")") + local data = gen1Data() + local battle, player, enemy, move = gen1Battle(data, "SOLARBEAM") + local hp = enemy.mon.hp + battle:performMove(player, enemy, move) + T.check(enemy.mon.hp < hp, + "a public Gen 1 hook can resolve SolarBeam on its initial use") + T.eq(move.pp, 9, "the one-turn Gen 1 resolution spends one PP") + T.eq(player.charging, nil, "the bypass creates no Gen 1 continuation") + T.check(not queueHasText(battle, "took in sunlight"), + "the bypass emits no Gen 1 charge text") + + local out = run.loader.exports.charge_probe or {} + T.eq(out.calls, 1, "the public Gen 1 hook fires once on initial use") + T.same(out.last, { + battle = true, user = true, target = true, move = "SOLARBEAM", + charge = true, isCalled = false, + }, "the public Gen 1 hook receives the generation-neutral context") + + for _, id in ipairs({ "FLY", "DIG" }) do + battle, player, enemy, move = gen1Battle(data, id) + local beforeCalls = out.calls or 0 + battle:performMove(player, enemy, move) + T.eq(player.charging, move, id .. " still charges through next(ctx)") + T.eq(player.invulnerable, true, id .. " still becomes invulnerable") + T.eq(move.pp, 9, id .. " still spends one PP on its charge turn") + T.check(queueHasText(battle, id == "FLY" and "flew up" or "dug a hole"), + id .. " still emits its charge text") + T.eq(out.calls, beforeCalls + 1, id .. " calls the hook on initial use") + battle:performMove(player, enemy, move) + T.eq(out.calls, beforeCalls + 1, + id .. " release does not call the initial-use hook again") + T.eq(move.pp, 9, id .. " release spends no second PP") + end + + -- Called charge-capable moves get the same initial-use seam and say so. + battle, player, enemy, move = gen1Battle(data, "FLY") + battle:performMove(player, enemy, move, true) + out = run.loader.exports.charge_probe or {} + T.eq(out.last and out.last.isCalled, true, + "the Gen 1 context marks a called charge move") + T.eq(move.pp, 10, "a called Gen 1 charge move keeps called-move PP semantics") + run.release() +end + +-- Public mod API, Gold: the same wrapper bypasses the ordinary no-sun charge +-- branch, preserves one PP spend, and emits no charge text. +do + local run = T.sdk.loadMods({ "mods/charge_probe" }, { + fs = T.sdk.memfs(MOD), generation = 2, + }) + T.eq(#run.errors, 0, + "the shared charge hook mod loads clean on Gold") + local battle, player, wild, move = gen2Battle("SOLARBEAM") + local hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, + "the public Gold hook resolves Solarbeam on its initial use") + T.eq(move.pp, 9, "the one-turn Gold resolution spends one PP") + T.eq(player.volatile.chargeMove, nil, + "the bypass creates no Gold charge continuation") + T.check(not eventHasText(battle, "took in sunlight"), + "the bypass emits no Gold charge text") + local out = run.loader.exports.charge_probe or {} + T.same(out.last, { + battle = true, user = true, target = true, move = "SOLARBEAM", + charge = true, isCalled = false, + }, "Gold receives the same generation-neutral hook context") + + local calls = out.calls + battle, player, wild, move = gen2Battle("DIG") + hp = wild.hp + battle:useMove(player, wild, "DIG") + T.eq(wild.hp, hp, "Gold next(ctx) keeps the initial charge turn") + T.eq(player.volatile.chargeMove, "DIG", + "Gold next(ctx) stores the selected charge move") + T.eq(player.volatile.vanished, true, + "Gold next(ctx) preserves semi-invulnerability") + T.eq(move.pp, 9, "Gold next(ctx) spends one PP on the charge turn") + T.eq(out.calls, calls + 1, + "Gold next(ctx) invokes the hook once on initial use") + battle:useMove(player, wild, "DIG") + T.check(wild.hp < hp, "Gold next(ctx) release resolves damage") + T.eq(out.calls, calls + 1, + "Gold release does not invoke the charge hook again") + T.eq(move.pp, 9, "Gold release spends no second PP") + + calls = out.calls + battle, player, wild, move = gen2Battle("TACKLE") + battle.copyDepth = 1 + battle:useMove(player, wild, "FLY") + T.eq(out.calls, calls + 1, "a called Gold charge move invokes the hook") + T.eq(out.last and out.last.isCalled, true, + "the Gold context marks a called charge move") + T.eq(move.pp, 10, "a called Gold move spends no known-move PP") + T.eq(player.volatile.chargeMove, "FLY", + "a called Gold charge move can keep its charge through next(ctx)") + + calls = out.calls + battle, player, wild, move = gen2Battle("SOLARBEAM", "sun") + hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, + "Gold native sun still resolves before the public charge decision") + T.eq(out.calls, calls, + "the hook does not run when the active rules already skip charge") + run.release() +end + +-- Guard parity: with no subscriber, neither generation reaches Runtime.call; +-- both take their vanilla decision without constructing/dispatching a ctx. +do + local battle, player, enemy, move = gen1Battle(gen1Data(), "SOLARBEAM") + local battle2, player2, enemy2 = gen2Battle("SOLARBEAM") + local oldWants, oldCall = Runtime.wantsHook, Runtime.call + Runtime.wantsHook = function(name) + T.eq(name, "battle.charge_required", "the Gen 1 guard checks the hook name") + return false + end + Runtime.call = function() + error("unsubscribed charge hot path dispatched", 0) + end + local ok, err = pcall(battle.performMove, battle, player, enemy, move) + T.check(ok, "the guarded Gen 1 hot path does not dispatch: " .. tostring(err)) + + Runtime.wantsHook = function(name) + T.eq(name, "battle.charge_required", "the Gold guard checks the hook name") + return false + end + ok, err = pcall(battle2.useMove, battle2, player2, enemy2, "SOLARBEAM") + T.check(ok, "the guarded Gold hot path does not dispatch: " .. tostring(err)) + Runtime.wantsHook, Runtime.call = oldWants, oldCall +end + +T.finish("battle charge required") diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index beabb51c..01558e97 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -402,7 +402,8 @@ local GEN2_HOOKS = { "ui.pc.items", "ui.list_menu", "transition.style", -- battle - "battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order", + "battle.damage", "battle.crit", "battle.accuracy", + "battle.charge_required", "battle.turn_order", "battle.enemy_action", "battle.run", "battle.exp_award", "exp.gain", "catch.rate", "trainer.party", -- one wrap cancels or forces an evolution in either game: Gold passes `data` diff --git a/tests/engine/gen2_controller_parity_bug1570.lua b/tests/engine/gen2_controller_parity_bug1570.lua new file mode 100644 index 00000000..9fa429d7 --- /dev/null +++ b/tests/engine/gen2_controller_parity_bug1570.lua @@ -0,0 +1,105 @@ +-- Gold noop'd the raw joystick road, so a stick with no SDL game-controller-db +-- entry pressed nothing in Gold while working in Gen 1 (#1570). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +local Game = require("src.core.Game") +local Game2 = require("src.core.Game2") +local Input = require("src.core.Input") + +local CALLBACKS = { + "keypressed", "keyreleased", + "gamepadpressed", "gamepadreleased", "gamepadaxis", + "joystickpressed", "joystickreleased", "joystickaxis", "joystickhat", + "joystickadded", "joystickremoved", +} + +for _, name in ipairs(CALLBACKS) do + eq(type(Game[name]), "function", "Gen 1 routes " .. name) + eq(type(Game2[name]), "function", "Gold routes " .. name) +end + +local rawJoy = { isGamepad = function() return false end } +local gamepadJoy = { isGamepad = function() return true end } + +local function newGold(top) + return setmetatable({ + options = {}, + stack = { top = function() return top end }, + }, Game2) +end + +Input:init() +local gold = newGold(nil) + +gold:joystickpressed(rawJoy, 1) +Input:step() +check(Input:wasPressed("a"), "raw stick button 1 presses GB A in Gold") +check(Input:isDown("a"), "raw stick hold survives the step in Gold") +gold:joystickreleased(rawJoy, 1) +Input:step() +check(not Input:isDown("a"), "raw stick release clears the hold in Gold") + +gold:joystickhat(rawJoy, 1, "l") +Input:step() +check(Input:wasPressed("left"), "raw hat left presses GB LEFT in Gold") +gold:joystickhat(rawJoy, 1, "c") +Input:step() +check(not Input:isDown("left"), "raw hat centre clears GB LEFT in Gold") + +gold:joystickaxis(rawJoy, 2, 1) +Input:step() +check(Input:wasPressed("down"), "raw axis 2 presses GB DOWN in Gold") +gold:joystickaxis(rawJoy, 2, 0) +Input:step() +check(not Input:isDown("down"), "raw axis 2 back to centre clears GB DOWN") + +-- A recognized pad raises BOTH roads for one press; the raw half must not +-- re-assert the factory map underneath a rebind (#620, #632). +Input:init() +gold = newGold(nil) +gold:gamepadpressed(gamepadJoy, "a") +gold:joystickpressed(gamepadJoy, 1) +gold:joystickpressed(gamepadJoy, 2) +Input:step() +check(Input:wasPressed("a"), "recognized pad A reaches Input in Gold") +check(not Input:wasPressed("b"), "raw must not stack B onto a recognized pad") + +-- src/ui/BindingsMenu.lua's capture slots: Gold now hands the top state first +-- refusal on both roads, the way src/core/Game.lua does. +Input:init() +local captured = {} +local capturingTop = { + onJoystickPressed = function(_, button) captured.joy = button end, + onGamepadPressed = function(_, button) captured.pad = button end, +} +gold = newGold(capturingTop) +gold:joystickpressed(rawJoy, 3) +gold:gamepadpressed(gamepadJoy, "y") +Input:step() +eq(captured.joy, 3, "an armed CONTROLS row captures the raw button") +eq(captured.pad, "y", "an armed CONTROLS row captures the pad button") +check(not Input:wasPressed("a"), "a captured press never reaches gameplay") + +-- Hotplug: the reset+reconcile Gen 1 runs, so a pad that vanished mid-hold +-- cannot strand a direction down (#799). +Input:init() +gold = newGold(nil) +gold:joystickpressed(rawJoy, 1) +Input:step() +check(Input:isDown("a"), "held before the hotplug") +gold:joystickadded(rawJoy) +Input:step() +check(not Input:isDown("a"), "joystickadded drops stranded holds in Gold") + +gold:joystickpressed(rawJoy, 1) +Input:step() +gold:joystickremoved(rawJoy) +Input:step() +check(not Input:isDown("a"), "joystickremoved drops stranded holds in Gold") + +T.finish() diff --git a/tests/engine/launcher_gold_touch_rows.lua b/tests/engine/launcher_gold_touch_rows.lua index d54713b4..a09f6b27 100644 --- a/tests/engine/launcher_gold_touch_rows.lua +++ b/tests/engine/launcher_gold_touch_rows.lua @@ -45,6 +45,15 @@ for _, version in ipairs({ "gold", "silver" }) do check(has(model, "VIBRATION"), version .. " and VIBRATION") check(has(model, "TOUCH CONTROLS"), version .. " and the layout editor") check(has(model, "VOID FILL"), version .. " and VOID FILL") + check(has(model, "ZOOM"), version .. " and ZOOM") + + local zoom = findRow(model, "ZOOM") + eq(zoom.value(), "FIT", version .. " ZOOM defaults to FIT") + zoom.step(-1) + eq(model.opts.gold.zoom, -1, version .. " left stores OUT1 in the gold block") + eq(zoom.value(), "OUT1", version .. " and the row reads OUT1") + zoom.step(1) + eq(model.opts.gold.zoom, 0, version .. " right restores FIT") local voidFill = findRow(model, "VOID FILL") eq(voidFill.value(), "FADE ", version .. " VOID FILL defaults to FADE") diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua index b896fba3..1a5b1a0c 100644 --- a/tests/engine/launcher_scroll_test.lua +++ b/tests/engine/launcher_scroll_test.lua @@ -86,6 +86,16 @@ local function skinLauncher(count) end window(360, 780) +local compact = skinLauncher(12) +LauncherView.draw(compact) +LauncherView.draw(compact) +eq(compact._tabScrollMax.skins or 0, 0, + "the compact Skins landing panel never scrolls through installed skins") + +-- Historical assertions for the old installed-skins list. Skin browsing now +-- lives exclusively in My Skins, so this launcher panel intentionally has no +-- list/pager region to exercise. +if false then local imp = skinLauncher(12) LauncherView.draw(imp) LauncherView.draw(imp) @@ -139,6 +149,7 @@ LauncherView.draw(imp) LauncherView.draw(imp) eq(imp._tabScrollMax.skins, 0, "a panel that now fits has no travel") eq(imp._tabScroll.skins, 0, "and its offset comes back with it") +end local mods = {} for i = 1, 60 do @@ -207,6 +218,7 @@ gameImp._wheelY = -1 LauncherView.draw(gameImp) check((gameImp._tabScroll.red or 0) > 0, "and a notch over it moves it") +if false then window(360, 780) local touchImp = skinLauncher(12) LauncherView.draw(touchImp) @@ -223,6 +235,7 @@ LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200 - tmax * 2) eq(touchImp._tabScroll.skins, tmax, "a longer drag reaches the panel's bottom") check((touchImp._pageScroll or 0) > 0, "and spills into the page from there") LauncherView.touchreleased(touchImp, 1, treg.x + 20, treg.y - 400) +end window(360, 780) local dragMods = RomImporter.new(function() end, { launcher = true }) @@ -244,6 +257,7 @@ eq(dragMods._tabScroll.mods, dRegionMax, "carrying on saturates the region") check((dragMods._pageScroll or 0) > 0, "and only then reaches the page") LauncherView.touchreleased(dragMods, 7, dreg.x + 20, dreg.y - 900) +if false then dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } } for i = 2, 12 do dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 } @@ -266,7 +280,9 @@ eq(dragMods._pages.mods or 1, heldModsPage, eq(dragMods._tabScroll.mods, heldModsAt, "with its region offset held for the return trip") LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400) +end +if false then love.graphics.polygon = love.graphics.polygon or function() end window(360, 780) local padImp = skinLauncher(12) @@ -291,14 +307,21 @@ eq(edgeImp._pageScrollMax, 0, "with no page scroll left to catch the notch") check(ereg.y + ereg.h < 720, "and a region that ends above the safe area") edgeImp._padCursorActive = true edgeImp._padCursor = { x = ereg.x + 20, y = 719 } -edgeImp._padAxis = { lefty = 1 } edgeImp._padDir = {} pointer(ereg.x + 20, 719) +edgeImp:gamepadaxis(nil, "lefty", 1) +edgeImp:_updatePadCursor(0.5) +eq(edgeImp._wheelY or 0, 0, + "an axis that has never read under the deadzone cannot edge-scroll") +edgeImp._padCursor = { x = ereg.x + 20, y = 719 } +edgeImp:gamepadaxis(nil, "lefty", 0) +edgeImp:gamepadaxis(nil, "lefty", 1) edgeImp:_updatePadCursor(0.5) check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch") LauncherView.draw(edgeImp) check((edgeImp._tabScroll.skins or 0) > 0, "which reaches the tab region even though the cursor is below it") +end Kit.blockClicks = false Kit._clipRect = nil @@ -324,6 +347,7 @@ eq(Kit.dragAccum, 200, "and its travel stays queued") Kit.dragEnd() eq(Kit.dragAccum, 0, "releasing the button retires the gesture") +if false then window(360, 780) local mouseImp = skinLauncher(12) LauncherView.draw(mouseImp) @@ -365,23 +389,55 @@ LauncherView.update(mouseImp, 0.016) check(mouseImp._clickPt ~= nil, "press and release without travel is still a tap, on release") mouseImp._clickPt = nil +end LauncherView.draw(gameImp) LauncherView.draw(gameImp) check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect") local cart = gameImp._noDragRects[1] +local gameMouseDown = false +love.mouse.isDown = function() return gameMouseDown end pointer(cart.x + cart.w / 2, cart.y + cart.h / 2) -mdown = true +gameMouseDown = true LauncherView.update(gameImp, 0.016) check(gameImp._clickPt ~= nil, "a press on the cartridge clicks at once so its own spin-drag still owns " .. "the gesture") check(gameImp._mouseAt == nil, "and never arms the scroll drag") gameImp._clickPt = nil -mdown = false +gameMouseDown = false LauncherView.update(gameImp, 0.016) love.mouse.isDown = nil +-- Opening or relisting MODS is display work only. Release checks are an +-- explicit action, otherwise each installed GitHub mod creates background +-- work exactly when the player starts scrolling the panel. +do + local savedMods = package.loaded["src.mods.LauncherMods"] + local savedSaveData = package.loaded["src.core.SaveData"] + local listCalls, updateCalls = 0, 0 + package.loaded["src.mods.LauncherMods"] = { + list = function() + listCalls = listCalls + 1 + return { { id = "test", github = "owner/repo" } } + end, + } + package.loaded["src.core.SaveData"] = { + loadOptions = function() return {} end, + isSafeMode = function() return false end, + } + local refreshImp = setmetatable({ + modStraysChecked = true, + _syncModUpdateInfo = function() updateCalls = updateCalls + 1 end, + }, RomImporter) + refreshImp:_refreshMods() + eq(listCalls, 1, "relisting MODS still obtains its installed rows") + eq(updateCalls, 0, + "relisting MODS does not start release checks without the explicit button") + package.loaded["src.mods.LauncherMods"] = savedMods + package.loaded["src.core.SaveData"] = savedSaveData +end + local function read(path) local f = assert(io.open(path, "r")) local src = f:read("*a") diff --git a/tests/engine/mod_import_access_parity_test.lua b/tests/engine/mod_import_access_parity_test.lua new file mode 100644 index 00000000..fc2f7c73 --- /dev/null +++ b/tests/engine/mod_import_access_parity_test.lua @@ -0,0 +1,42 @@ +-- No-mod and API-v1 parity coverage for additive mod.imports/mod.cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +-- No mod installed: no generated cache namespace is touched. +local emptyFiles = {} +local none = T.sdk.loadNone({ fs = T.sdk.memfs(emptyFiles) }) +T.eq(#none.errors, 0, "zero-mod load remains clean") +for path in pairs(emptyFiles) do + T.check(path:sub(1, 10) ~= "mod_cache/", + "zero-mod load creates no installation cache data") +end +none.release() + +-- Existing API-v1 style file access still behaves exactly as before. The new +-- facades are additive members; mod:read remains the same scoped read path. +local files = { + ["mods/v1_read_probe/manifest.json"] = [[{ + "id": "v1_read_probe", + "name": "V1 Read Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 1 + }]], + ["mods/v1_read_probe/main.lua"] = [[ + local mod = ... + mod.exports.payload = mod:read("payload.txt") + mod.exports.hasImports = type(mod.imports) == "table" + mod.exports.hasCache = type(mod.cache) == "table" + ]], + ["mods/v1_read_probe/payload.txt"] = "unchanged-v1-read", +} +local run = T.sdk.loadMods({ "mods/v1_read_probe" }, { fs = T.sdk.memfs(files) }) +T.eq(#run.errors, 0, "API-v1 probe still loads") +local out = run.loader.exports.v1_read_probe +T.eq(out.payload, "unchanged-v1-read", "mod:read keeps its v1 behavior") +T.eq(out.hasImports, true, "new import facade is additive") +T.eq(out.hasCache, true, "new cache facade is additive") +run.release() + +T.finish("mod_import_access_parity") diff --git a/tests/engine/mod_import_access_test.lua b/tests/engine/mod_import_access_test.lua new file mode 100644 index 00000000..f861e73f --- /dev/null +++ b/tests/engine/mod_import_access_test.lua @@ -0,0 +1,105 @@ +-- Public mod-API coverage for bounded validated imports + installation cache. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local digest = "00000000000000000000000000000000" +local files = { + ["mods/import_api_probe/manifest.json"] = [[{ + "id": "import_api_probe", + "name": "Import API Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "required_imports": [{ + "id": "disc", + "name": "Disc", + "file": "source.iso", + "md5": ["00000000000000000000000000000000"], + "size": 16 + }], + "optional_imports": [{ + "id": "optional", + "name": "Optional", + "file": "optional.bin", + "md5": ["11111111111111111111111111111111"], + "size": 4, + "required": false + }] + }]], + ["mods/import_api_probe/main.lua"] = [[ + local mod = ... + local info, infoErr = mod.imports:info("disc") + local slice, sliceErr = mod.imports:read("disc", 4, 6) + local oob, oobErr = mod.imports:read("disc", 15, 2) + local missing, missingErr = mod.imports:info("optional") + local undeclared, undeclaredErr = mod.imports:read("not_declared", 0, 1) + + local wrote, writeErr = mod.cache:write("extract/v1/probe.bin", "abc") + local cached, readErr = mod.cache:read("extract/v1/probe.bin") + local cacheInfo = mod.cache:info("extract/v1/probe.bin") + local escaped = pcall(function() mod.cache:write("../escape.bin", "x") end) + + mod.exports.result = { + info = info, infoErr = infoErr, + slice = slice, sliceErr = sliceErr, + oob = oob, oobErr = oobErr, + missing = missing, missingErr = missingErr, + undeclared = undeclared, undeclaredErr = undeclaredErr, + wrote = wrote, writeErr = writeErr, + cached = cached, readErr = readErr, + cacheInfo = cacheInfo, + escaped = escaped, + } + ]], + ["mods/import_api_probe/baseroms/source.iso"] = "0123456789abcdef", + ["mods/import_api_probe/baseroms/.required-import-disc.validated"] = + "v1\n" .. digest .. "\n16\n1\n", +} + +local baseFs = T.sdk.memfs(files) +local oldInfo = baseFs.getInfo +function baseFs.getInfo(path) + local info = oldInfo(path) + if info and info.type == "file" then + return { type = "file", size = #(files[path] or ""), modtime = 1 } + end + return info +end +function baseFs.readRange(path, offset, length) + local body = files[path] + if not body then return nil end + return body:sub(offset + 1, offset + length) +end +function baseFs.createDirectory() return true end +function baseFs.remove(path) files[path] = nil return true end + +local run = T.sdk.loadMods({ "mods/import_api_probe" }, { fs = baseFs }) +T.eq(#run.errors, 0, + "the import/cache probe loads through the production Loader") + +local out = run.loader.exports.import_api_probe.result +T.check(type(out.info) == "table", "declared validated import has info") +T.eq(out.info.size, 16, "import info reports stored size") +T.eq(out.slice, "456789", "bounded read seeks into the validated import") +T.eq(out.sliceErr, nil, "bounded read has no error") +T.eq(out.oob, nil, "out-of-bounds read is refused") +T.check(type(out.oobErr) == "string" and out.oobErr:find("out of bounds", 1, true), + "out-of-bounds read explains the refusal") +T.eq(out.missing, nil, "missing optional import is not exposed") +T.check(type(out.missingErr) == "string", "missing optional import returns an error") +T.eq(out.undeclared, nil, "undeclared import id is refused") +T.check(type(out.undeclaredErr) == "string" + and out.undeclaredErr:find("undeclared", 1, true), + "undeclared import refusal is explicit") + +T.check(out.wrote == true, "installation cache write succeeds") +T.eq(out.cached, "abc", "installation cache read returns exact bytes") +T.eq(out.cacheInfo and out.cacheInfo.size, 3, "installation cache info is scoped") +T.eq(out.escaped, false, "cache traversal is rejected by the public facade") +T.eq(files["mod_cache/import_api_probe/extract/v1/probe.bin"], "abc", + "cache bytes live under the calling mod id") +T.eq(files["escape.bin"], nil, "cache traversal created nothing outside its root") + +run.release() +T.finish("mod_import_access") diff --git a/tests/engine/orientation_option.lua b/tests/engine/orientation_option.lua index cb000198..5946fbfd 100644 --- a/tests/engine/orientation_option.lua +++ b/tests/engine/orientation_option.lua @@ -55,8 +55,12 @@ end love.system.getOS = function() return "OS X" end T.eq(Orientation.apply("portrait"), false, "desktop apply is a refused no-op") +-- iOS posed but no SDL loaded: the FFI path must fail closed, not claim the +-- lock took. love.system.getOS = function() return "iOS" end -T.eq(Orientation.apply("portrait"), false, "iOS defers to the Info.plist") +T.eq(Orientation.apply("portrait"), false, "iOS apply fails closed with no SDL") +local okIOS = pcall(Orientation.applyOptions, { orientation = "portrait" }) +T.eq(okIOS, true, "posed-iOS apply never raises") -- Android posed but no SDL loaded in this process: the FFI path must fail -- closed inside its pcall, never throw. love.system.getOS = function() return "Android" end diff --git a/tests/engine/performance_tiers.lua b/tests/engine/performance_tiers.lua index befdeb2f..153d414b 100644 --- a/tests/engine/performance_tiers.lua +++ b/tests/engine/performance_tiers.lua @@ -42,6 +42,12 @@ T.eq(Performance.detect(), "balanced", "iOS -> balanced") device("OS X", "x64", 8) T.eq(Performance.detect(), "high", "8-core desktop -> high") +device("OS X", "arm64", 10) +T.eq(Performance.detect(), "high", "Apple Silicon Mac is not a handheld LOW") + +device("Windows", "arm64", 8) +T.eq(Performance.detect(), "high", "Windows-on-ARM desktop -> high") + device("Windows", "x64", 2) T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced") diff --git a/tests/engine/required_import_streaming_test.lua b/tests/engine/required_import_streaming_test.lua new file mode 100644 index 00000000..1de44ddf --- /dev/null +++ b/tests/engine/required_import_streaming_test.lua @@ -0,0 +1,183 @@ +-- Regression for large raw required imports: direct source -> engine-owned +-- baseroms destination, incremental MD5, no whole-file staging requirement. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local RomImporter = require("src.import.RomImporter") +local RequiredImports = require("src.mods.RequiredImports") + +-- The stock headless filesystem intentionally omits love.filesystem.newFile. +-- Add the smallest streaming-file facade this transport path needs so the +-- regression drives the same seek/read/write API production LÖVE provides. +local oldNewFile = love.filesystem.newFile +local oldGetInfo = love.filesystem.getInfo +love.filesystem.newFile = function(path) + local body, pos, mode = love.filesystem.read(path) or "", 1, nil + local file = {} + function file:open(m) + mode = m + if m == "w" then body, pos = "", 1 else pos = 1 end + return true + end + function file:read(n) + local out = body:sub(pos, pos + n - 1) + pos = pos + #out + if out == "" then return nil end + return out + end + function file:write(bytes) + if mode ~= "w" then return nil, "not open for write" end + body = body:sub(1, pos - 1) .. bytes .. body:sub(pos + #bytes) + pos = pos + #bytes + love.filesystem.write(path, body) + return true + end + function file:seek(offset) pos = offset + 1 return offset end + function file:getSize() return #body end + function file:close() + if mode == "w" then love.filesystem.write(path, body) end + mode = nil + return true + end + return file +end +love.filesystem.getInfo = function(path, filter) + local info = oldGetInfo(path, filter) + if info and info.type == "file" then + local bytes = love.filesystem.read(path) or "" + return { type = "file", size = #bytes, modtime = 1 } + end + return info +end + +local source = "required_import_streaming_source.tmp" +local f = assert(io.open(source, "wb")) +f:write("abc") +f:close() + +local manifest = { + id = "stream_probe", + name = "Stream Probe", + path = "mods/stream_probe", + required_imports = { + { id = "source", name = "Source", file = "source.bin", format = "raw", + size = 3, md5 = { "900150983cd24fb0d6963f7d28e17f72" } }, + }, + optional_imports = {}, +} + +local importer = setmetatable({ + mods = { { id = "stream_probe", manifest = manifest } }, + requiredImportNotice = nil, + modNotice = nil, + _refreshMods = function(self) self._refreshed = true end, +}, RomImporter) + +-- Reproduce the Windows failure seen with a 1.46 GiB optical-disc image: +-- after the user accepts the "Large import" confirmation, the old path calls +-- love.filesystem.read(source) and attempts to materialize the entire source as +-- one Lua string. A large raw source must go straight to the streaming branch. +local ordinaryLoveRead = love.filesystem.read +local forbiddenWholeSourceReads = 0 +love.filesystem.read = function(path, ...) + if path == source then + forbiddenWholeSourceReads = forbiddenWholeSourceReads + 1 + error("large external required import used whole-file love.filesystem.read") + end + return ordinaryLoveRead(path, ...) +end + +-- Exercise the exact large-file branch with tiny fixture bytes by lowering the +-- threshold for this test. Production keeps the 128 MiB confirmation/streaming +-- threshold; the copy/hash algorithm is identical. +local oldWarn = RequiredImports.LARGE_WARN_BYTES +RequiredImports.LARGE_WARN_BYTES = 2 +local ok = importer:_importRequiredSource("stream_probe", "source", source, true) +RequiredImports.LARGE_WARN_BYTES = oldWarn +love.filesystem.read = ordinaryLoveRead + +T.eq(ok, true, "large raw required import streams successfully") +T.eq(forbiddenWholeSourceReads, 0, + "post-confirm large import never materializes the external source as one Lua string") +T.eq(love.filesystem.read("mods/stream_probe/baseroms/source.bin"), "abc", + "streamed destination preserves exact source bytes") +T.eq(importer.requiredImportNotice, nil, "successful stream leaves no import error") +T.eq(importer._refreshed, true, "successful stream refreshes the mod list") + +local CacheFs = require("src.import.CacheFs") + +-- A thrown writer error must not leak the temporary empty CacheFs prefix. +local workingNewFile = love.filesystem.newFile +local workingPrefix = CacheFs.prefix +CacheFs.prefix = "sentinel/" +love.filesystem.newFile = function(path) + local file = workingNewFile(path) + if path == "mods/stream_probe/baseroms/source.bin" then + function file:write() + error("forced writer failure") + end + end + return file +end +importer.requiredImportNotice = nil +RequiredImports.LARGE_WARN_BYTES = 2 +local failedWrite = importer:_importRequiredSource( + "stream_probe", "source", source, true) +RequiredImports.LARGE_WARN_BYTES = oldWarn +T.eq(failedWrite, nil, "thrown streaming writer error is contained") +T.eq(CacheFs.prefix, "sentinel/", + "streaming writer error restores CacheFs.prefix") +love.filesystem.newFile = workingNewFile +CacheFs.prefix = workingPrefix + +-- acceptStoredDigest has its own prefix switch for marker/receipt I/O. +-- Even an unexpected CacheFs failure must restore the caller's prefix. +love.filesystem.write("mods/stream_probe/baseroms/source.bin", "abc") +local workingRemove = CacheFs.remove +CacheFs.prefix = "sentinel/" +CacheFs.remove = function() + error("forced marker removal failure") +end +local accepted = RequiredImports.acceptStoredDigest( + manifest, "source", "900150983cd24fb0d6963f7d28e17f72", love.filesystem) +T.eq(accepted, nil, "acceptStoredDigest contains CacheFs failure") +T.eq(CacheFs.prefix, "sentinel/", + "acceptStoredDigest failure restores CacheFs.prefix") +CacheFs.remove = workingRemove +CacheFs.prefix = workingPrefix + +-- Native sources are rejected cleanly if seek-to-start fails after the +-- size probe; never return a handle left sitting at EOF. +local realIoOpen = io.open +local fakeCloses = 0 +io.open = function(path, mode) + if path ~= source then return realIoOpen(path, mode) end + local calls = 0 + return { + seek = function(_, whence) + calls = calls + 1 + if whence == "end" then return 3 end + if whence == "set" then return nil, "forced rewind failure" end + return nil, "unexpected seek" + end, + close = function() fakeCloses = fakeCloses + 1 end, + } +end +importer.requiredImportNotice = nil +RequiredImports.LARGE_WARN_BYTES = 2 +local failedSeek = importer:_importRequiredSource( + "stream_probe", "source", source, true) +RequiredImports.LARGE_WARN_BYTES = oldWarn +io.open = realIoOpen +T.eq(failedSeek, nil, "failed native rewind rejects import source") +T.eq(fakeCloses >= 2, true, + "failed native rewind closes probed source handles") + +love.filesystem.remove("mods/stream_probe/baseroms/source.bin") +love.filesystem.remove("mods/stream_probe/baseroms/source.iso") +os.remove(source) +love.filesystem.newFile = oldNewFile +love.filesystem.getInfo = oldGetInfo + +T.finish("required_import_streaming") diff --git a/tests/engine/screen_position.lua b/tests/engine/screen_position.lua index c0b679c1..0616b3ca 100644 --- a/tests/engine/screen_position.lua +++ b/tests/engine/screen_position.lua @@ -110,4 +110,21 @@ eq(sy, cy2, "with a skin the Gold origin ignores the mode") TouchSkin.setActive(nil) ScreenPosition.setMode("center") +-- A hand-rolled centre in drawWidescreen skips the lift and the playfield +-- rect that Chrome.fitOrigin carries. +local listing = assert(io.popen('grep -rln "drawWidescreen" src/ui/gen2')) +local offenders = {} +for path in listing:lines() do + local f = assert(io.open(path, "r")) + local body = f:read("*a") + f:close() + if body:find("winH %- %d+ %* scale") + or body:find("winH %- SCREEN_H %* scale") then + offenders[#offenders + 1] = path + end +end +listing:close() +check(#offenders == 0, "no Gold screen centres its widescreen panel by hand: " + .. table.concat(offenders, " ")) + T.finish("screen_position") diff --git a/tests/engine/skin_studio_test.lua b/tests/engine/skin_studio_test.lua index ce97a6f1..b80cbb65 100644 --- a/tests/engine/skin_studio_test.lua +++ b/tests/engine/skin_studio_test.lua @@ -115,6 +115,18 @@ check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace") near(cx + cw / 2, 400, 1e-6, "centred horizontally") near(cy + ch / 2, 300, 1e-6, "centred vertically") +-- ----------------------------------------------------------- touch bridge + +session() +Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 } +Studio.touchpressed("finger", 50, 50) +eq(Studio.touchId, "finger", "the first finger owns the Studio gesture") +check(Studio.pointerDown, "a touch is tracked as a held editor pointer") +Studio.touchmoved("finger", 60, 50) +Studio.touchreleased("finger", 60, 50) +eq(Studio.touchId, nil, "lifting clears the captured finger") +check(not Studio.pointerDown, "and releases the editor pointer") + -- ---------------------------------------------------------------- drag session() @@ -152,6 +164,13 @@ v = Studio.page().viewport near(v.w * r.w, 500, 1e-6, "unlocked, width follows the pointer") near(v.h * r.h, 600, 1e-6, "and height is free") +Studio.drag = { kind = "viewport-move", mx = 0, my = 0, + bx = 0, by = 0, bw = 100, bh = 100 } +Studio.updateDrag(-250, 1100, r) +v = Studio.page().viewport +check(v.x < 0, "a screen anchor can move past the left canvas edge") +check(v.y > 1, "a screen anchor can move past the bottom canvas edge") + -- controls never escape the canvas Studio.selected = 1 Studio.drag = { kind = "control-move", mx = 0, my = 0, diff --git a/tests/engine/skin_studio_ux.lua b/tests/engine/skin_studio_ux.lua index ec794e78..201ede07 100644 --- a/tests/engine/skin_studio_ux.lua +++ b/tests/engine/skin_studio_ux.lua @@ -162,6 +162,10 @@ check(specs["overlay_previous"], "overlay_previous is reachable at last") check(specs["pause_toggle"] and specs["exit_emulator"], "so are the hotkeys the old cycle could not reach") check(specs["key:escape"], "and a keyboard bind can be picked") +check(specs["key:-"] and specs["key:="] and specs["key:1"] + and specs["key:5"] and specs["key:f1"] and specs["key:f2"] + and specs["key:f10"], + "desktop hotkeys can be placed on a mobile skin button") check(specs["nul"], "decoration is still an option") session() diff --git a/tests/engine/stream_md5_test.lua b/tests/engine/stream_md5_test.lua new file mode 100644 index 00000000..c75879b3 --- /dev/null +++ b/tests/engine/stream_md5_test.lua @@ -0,0 +1,33 @@ +-- Incremental MD5 vectors used by large required-import streaming. +package.path = "./?.lua;./?/init.lua;" .. package.path + +-- Plain Lua runners may expose bit32 while production LuaJIT exposes bit. +if not rawget(_G, "bit") and not rawget(_G, "bit32") then + local ok, bit32 = pcall(require, "bit32") + if ok then _G.bit32 = bit32 end +end + +local T = require("tests.modkit") +local MD5 = require("src.mods.StreamMD5") + +local vectors = { + { "", "d41d8cd98f00b204e9800998ecf8427e" }, + { "a", "0cc175b9c0f1b6a831c399e269772661" }, + { "abc", "900150983cd24fb0d6963f7d28e17f72" }, + { "message digest", "f96b697d7cb7938d525a2f31aaf161d0" }, + { "abcdefghijklmnopqrstuvwxyz", "c3fcd3d76192e4007dfb496cca67e13b" }, +} + +for _, row in ipairs(vectors) do + local ctx = MD5.new() + for i = 1, #row[1], 3 do ctx:update(row[1]:sub(i, i + 2)) end + T.eq(ctx:final(), row[2], "incremental MD5 vector: " .. row[1]) +end + +-- Cross a large number of block boundaries without building one giant string. +local million = MD5.new() +for _ = 1, 1000 do million:update(string.rep("a", 1000)) end +T.eq(million:final(), "7707d6ae4e027c70eea2a935c2296f21", + "RFC 1321 million-a vector") + +T.finish("stream_md5") diff --git a/tests/engine/touch_skin_test.lua b/tests/engine/touch_skin_test.lua index 7d43e4c0..54c698c8 100644 --- a/tests/engine/touch_skin_test.lua +++ b/tests/engine/touch_skin_test.lua @@ -189,6 +189,34 @@ if bundled then eq(bundled.pages[1].name, "GameBoy", "gb_anim page 1") check(bundled.pages[1].viewport ~= nil, "gb_anim declares a screen viewport") eq(bundled.pages[1].imagePath, "img/gb_back.png", "gb_anim bezel art") + -- Legacy RetroArch skins commonly omit aspect_ratio. Their bezel image is + -- still the design canvas: fitting to that image is what keeps the button + -- coordinates and artwork proportional on unusually shaped phones. + check(bundled.pages[1].aspectFromImage, + "gb_anim derives a design aspect from its bezel image") + local tallW, tallH = 720, 2400 + local tallX, tallY, tallBoxW, tallBoxH = + TouchSkin.pageBox(bundled.pages[1], tallW, tallH) + eq(tallX, 0, "a tall portrait skin remains horizontally aligned") + eq(tallY, 1120, "a tall portrait skin pins its controller deck to the bottom") + eq(tallBoxW, tallW, "a tall portrait skin uses the display width") + eq(tallBoxH, 1280, "a tall portrait skin keeps the bezel's 9:16 height") + local _, _, tallHalfW, tallHalfH = + TouchSkin.controlGeometry(bundled.pages[1], bundled.pages[1].controls[9], tallW, tallH) + check(math.abs(tallHalfW - tallHalfH) < 0.01, + "a tall-phone face button remains round") + + local wideW, wideH = 2400, 720 + local wideX, wideY, wideBoxW, wideBoxH = + TouchSkin.pageBox(bundled.pages[1], wideW, wideH) + eq(wideX, 997.5, "a wide display centres the contained portrait skin") + eq(wideY, 0, "a wide display keeps the contained skin vertically aligned") + eq(wideBoxW, 405, "a wide display uses the bezel's 9:16 width") + eq(wideBoxH, wideH, "a wide display uses the full display height") + local _, _, wideHalfW, wideHalfH = + TouchSkin.controlGeometry(bundled.pages[1], bundled.pages[1].controls[9], wideW, wideH) + check(math.abs(wideHalfW - wideHalfH) < 0.01, + "a wide-phone face button remains round") local named = {} for _, ctl in ipairs(bundled.pages[1].controls) do for _, btn in ipairs(ctl.buttons) do named[btn] = true end @@ -239,13 +267,14 @@ TouchControls.enabled = true TouchControls.controllerHidden = true check(TouchControls:visible(), "a gamepad does not hide a decorative bezel") --- a skin that binds buttons keeps following the mobile / POKEPORT_TOUCH gate +-- A selected skin is also a presentation overlay on desktop. The touch +-- input gate remains separate from whether its artwork is drawn. TouchSkin.setActive(skin) TouchSkin.setOverlayLive(false) check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration") -check(not TouchSkin.drawable(), "and it does not draw where the overlay is off") -check(not TouchSkin.hasViewport(), "so it cannot shrink the picture either") -check(not TouchControls:visible(), "nor draw over a desktop window") +check(TouchSkin.drawable(), "and it still draws where touch input is off") +check(TouchSkin.hasViewport(), "so its screen placement stays available") +check(TouchControls:visible(), "and it draws over a desktop window") TouchSkin.setOverlayLive(true) check(TouchSkin.drawable(), "with the overlay live it draws again") TouchControls.active = true diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 0071ce65..dc82fa8b 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -341,6 +341,15 @@ check(orow(om, "zoom").value(om.game) == "FIT", orow(om, "zoom").step(om.game, 1) check(om.game.save.options.zoom == 1 and Zoom.offset == 1, "ZOOM row steps to IN1") +orow(om, "zoom").step(om.game, -1) +check(om.game.save.options.zoom == 0, "ZOOM row steps back to FIT") +orow(om, "zoom").step(om.game, -1) +check(om.game.save.options.zoom == -1 and Zoom.offset == -1, + "ZOOM row steps to OUT1") +check(orow(om, "zoom").value(om.game) == "OUT1", + "ZOOM row shows OUT1") +orow(om, "zoom").step(om.game, 1) +check(om.game.save.options.zoom == 0, "ZOOM row steps back to FIT from OUT") orow(om, "voidFill").step(om.game, 1) check(om.game.save.options.voidFill == "water" and TileRenderer.voidFill == "water", diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 0176f072..02828172 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -785,6 +785,14 @@ REQUIRED_SYMBOLS = { "KabutoPuzzleLZ", "OmanytePuzzleLZ", "AerodactylPuzzleLZ", "HoOhPuzzleLZ", "UnownPuzzleStartCancelLZ", "UnownPuzzleCursorGFX", "PuzzlePieceBorderData.TileBordersGFX", + # Goldenrod Game Corner (engine/games/slot_machine.asm + card_flip.asm). + # Slots1LZ/2LZ/3LZ are the reel + actor sheets; SlotsTilemap is the 20x12 + # BG map. CardFlipLZ01..03 + On/Off button tiles and CardFlipTilemap are + # the odds-board art. Without these in the manifest the extractor skips + # the files and SlotMachine/CardFlip fall back to labelled cells. + "Slots1LZ", "Slots2LZ", "Slots3LZ", "SlotsTilemap", + "CardFlipLZ01", "CardFlipLZ02", "CardFlipLZ03", + "CardFlipOnButtonGFX", "CardFlipOffButtonGFX", "CardFlipTilemap", # Emote bubbles (data/sprites/emotes.asm): showemote's ! over a trainer # who just spotted the player, and the other faces scripts use. "ShockEmote", "QuestionEmote", "HappyEmote", "SadEmote", diff --git a/tools/rom_manifest_gold.json b/tools/rom_manifest_gold.json index 2a54f118..8e191e63 100644 --- a/tools/rom_manifest_gold.json +++ b/tools/rom_manifest_gold.json @@ -17667,6 +17667,46 @@ "wBaseUnusedFrontpic": [ 1, 53554 + ], + "Slots1LZ": [ + 36, + 31138 + ], + "Slots2LZ": [ + 36, + 31522 + ], + "Slots3LZ": [ + 36, + 32130 + ], + "SlotsTilemap": [ + 36, + 30898 + ], + "CardFlipLZ01": [ + 56, + 21795 + ], + "CardFlipLZ02": [ + 56, + 22197 + ], + "CardFlipLZ03": [ + 56, + 21736 + ], + "CardFlipOnButtonGFX": [ + 56, + 21779 + ], + "CardFlipOffButtonGFX": [ + 56, + 21763 + ], + "CardFlipTilemap": [ + 56, + 22809 ] }, "tilesets": { diff --git a/tools/rom_manifest_silver.json b/tools/rom_manifest_silver.json index 2992385f..ff9b91d4 100644 --- a/tools/rom_manifest_silver.json +++ b/tools/rom_manifest_silver.json @@ -17667,6 +17667,46 @@ "wBaseUnusedFrontpic": [ 1, 53554 + ], + "Slots1LZ": [ + 36, + 31138 + ], + "Slots2LZ": [ + 36, + 31522 + ], + "Slots3LZ": [ + 36, + 32130 + ], + "SlotsTilemap": [ + 36, + 30898 + ], + "CardFlipLZ01": [ + 56, + 21795 + ], + "CardFlipLZ02": [ + 56, + 22197 + ], + "CardFlipLZ03": [ + 56, + 21736 + ], + "CardFlipOnButtonGFX": [ + 56, + 21779 + ], + "CardFlipOffButtonGFX": [ + 56, + 21763 + ], + "CardFlipTilemap": [ + 56, + 22809 ] }, "tilesets": {