Compare commits

...

21 Commits

Author SHA1 Message Date
bryanthaboi fe3746fd1a Merge pull request #1650 from bryanthaboi/dev
big freaking junk off
2026-08-21 14:12:38 -04:00
bryanthaboi 0e407dca7a Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-21 14:03:27 -04:00
bryanthaboi b0ff1552f2 skins fixerino 2026-08-21 14:03:25 -04:00
bryanthaboi d191aaa34d Merge pull request #1616 from HighDrexler/fix/large-required-imports-v2
Fix/large required imports v2
2026-08-21 09:59:17 -04:00
bryanthaboi a1a70540b8 Merge pull request #1645 from MaxTomahawk/adaptive-trainers/charge-required-hook
feat(mod-api): expose charge decision hook
2026-08-21 09:19:17 -04:00
bryanthaboi 5c1837b1eb maybe smoother mobile scroll on installed mods 2026-08-21 09:17:06 -04:00
MaxTomahawk bb0f156497 test(mod-api): cover Gold charge continuations 2026-08-21 14:52:11 +02:00
MaxTomahawk 4b0496bad1 feat(mod-api): expose charge decision hook 2026-08-21 14:40:51 +02:00
bryanthaboi a56add6d17 screen position fixes for gen 2 2026-08-21 08:30:44 -04:00
bryanthaboi 48b39c8519 CLOSES #1570 2026-08-21 08:23:21 -04:00
HighDrexler decaa006b2 test: lock large imports to streaming path after confirmation 2026-08-21 08:01:00 -04:00
HighDrexler c0bd4a35be test(mods): fix streamed import rewind assertion 2026-08-21 07:33:24 -04:00
bryanthaboi 8c0d0ace4d CLOSES #1367, CLOSES #1585, CLOSES #1611, CLOSES #1612, CLOSES #1636, CLOSES #1638, CLOSES #1641 2026-08-21 07:14:47 -04:00
bryanthaboi c11c762f15 Merge pull request #1634 from 1Jamie/game-corner-extraction-fix 2026-08-21 05:20:28 -04:00
HighDrexler c465f58006 fix(mods): harden streamed import cleanup 2026-08-20 23:46:02 -04:00
1jamie 780246c4f6 feat: add deinterleave function and enhance graphics extraction for Slots and Card Flip
Implemented a new deinterleave function in ImageWriter to restore row-major order for PNGs. Updated RomExtractorGen2 to utilize this function for extracting Slot Machine graphics, and added new functions for padding and writing graphics sheets. Updated the ROM manifests to include necessary symbols for Slots and Card Flip assets.
2026-08-20 20:40:37 -05:00
1jamie 5714555847 feat: enhance Goldenrod Game Corner graphics extraction
Added functions to write raw graphics assets for the Slot Machine and Card Flip games, ensuring that the necessary files are generated when the corresponding symbols are present in the ROM. Updated the manifest to include required symbols for these assets, preventing fallback to labelled cells when graphics are missing.
2026-08-20 20:23:39 -05:00
github-actions 738f7317d7 chore(ios): update app-repo.json [skip ci] 2026-08-20 20:51:52 -04:00
HighDrexler 911e11a372 test(mods): document and cover streamed import access 2026-08-20 17:14:32 -04:00
github-actions[bot] 2d28d18bf6 fix(mods): support large required imports end-to-end 2026-08-20 21:10:01 +00:00
HighDrexler 0b70d6c535 chore: stage prepared large-import PR patch 2026-08-20 17:09:40 -04:00
85 changed files with 3591 additions and 501 deletions
+1 -1
View File
@@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"`
when you mean everywhere. when you mean everywhere.
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold `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 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. 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 `docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
+3 -2
View File
@@ -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 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 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. 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` 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 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 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.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`, `battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`, `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`, `battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`, `catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
`battle.catch_exp`, `battle.bottom_ui_visible`, `battle.catch_exp`, `battle.bottom_ui_visible`,
+57 -5
View File
@@ -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 digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
`description` gives players dump or region guidance in the import panel. `description` gives players dump or region guidance in the import panel.
`size` declares the exact canonical byte length; `max_size` declares a smaller `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 per-import ceiling when an exact size is not appropriate. The engine hard limit
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and
filesystem reports an invalid size. 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, For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
strips a recognized 512-byte copier header, converts the bytes to canonical 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 big-endian `.z64` order, and then checks MD5. The canonical bytes are written
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
mod: the launcher never scans or copies another mod's imported files merely 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 because its manifest names the same digest. Small sources can still be read
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem 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 permission is exposed. Missing `required_imports` block the mod before its
entry chunk runs; missing `optional_imports` remain visible in the same entry chunk runs; missing `optional_imports` remain visible in the same
launcher panel but do not block loading. 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; 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 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 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 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. 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/<mod-id>/`) 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 ## Durable tool storage and runtime checkpoints
`mod.save` remains the right place for state that should travel with the next `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 use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
contract and compatibility guarantees. 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 ## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload Boot with developer mode on to unlock the in-game console and hot-reload
@@ -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/<mod-id>/`, 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/<id>/baseroms/<file>` 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.
+92
View File
@@ -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.
+51 -25
View File
@@ -1,11 +1,11 @@
# Touch skins and the Skin Studio # Touch skins and the Skin Studio
A **skin** replaces the on-screen controls wholesale: a bezel image, a 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` `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/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/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
`tests/engine/skin_studio_ux.lua`, `tests/engine/skin_studio_ux.lua`,
`tests/engine/skin_studio_image_import.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_tab.lua`,
`tests/engine/launcher_skins_ux.lua`. `tests/engine/launcher_skins_ux.lua`.
Skins are picked in the launcher's **Skins** tab, which also imports them and The launcher's **Skins** tab imports skins, shows the enabled skin, exports it,
opens the studio. `options.touchControls.skin` holds the folder name. 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 ## Formats
@@ -30,7 +32,7 @@ as-is. Supported keys:
| `overlays` | page count | | `overlays` | page count |
| `overlayN_name` | page name, the target of `next_target` | | `overlayN_name` | page name, the target of `next_target` |
| `overlayN_overlay` | bezel image | | `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_rect` | page placement, default `0,0,1,1` |
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen | | `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults | | `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 `gameScreenFrame`) becomes the screen cutout. A portrait page with neither
keeps `mappingSize` as the overlay aspect, sits at the bottom of the 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 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 usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the
stretch to the window the way Delta does. Host functions map to game into it. Host functions map to
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`; `hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration. `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:<name>` presses any keyboard key, which is As an extension to the format, `key:<name>` presses any keyboard key, which is
how a skin button reaches a mod hotkey. 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 `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 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 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. 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 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 screen from bezel** in the studio measures the hole out of the art's alpha
channel and writes the rect. channel and writes the rect.
@@ -153,10 +166,8 @@ channel and writes the rect.
## Bezels versus pads ## Bezels versus pads
A skin whose active page binds nothing is a frame rather than a pad: a TV 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** surround, a handheld shell, a Super Game Boy border. Selected skins draw on
as well, where the touch overlay itself does not, and a gamepad does not hide **desktop** as well as mobile; a gamepad does not hide them.
them. Anything that binds a button still follows the usual mobile /
`POKEPORT_TOUCH` rule.
## Installing ## Installing
@@ -190,9 +201,20 @@ shipping branding.
## The studio ## The studio
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open Launcher, Skins tab, **My Skins** opens the Studio library on desktop and
that skin. Desktop only: the launcher does not offer it on Android or iOS, mobile. **My Skins** is the only visual grid: real bezel previews plus create
because it wants a mouse, typed coordinates and room for an inspector. 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 launchers **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 **Canvas.** A mock device at a chosen preset, so a phone skin is authored at
phone proportions on a desktop monitor. 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 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 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 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 pressed images are per control; the bezel, the pages and the screen anchor are
per page. The cutout is itself a draggable element with a 10:9 lock. 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 **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 buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration.
decoration. The COMBINE chips at the top toggle one part at a time, which is The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10`
how a pipe bind like `left|down` is built without typing it. 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 **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 `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 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 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 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. the art's alpha.
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy **Testing.** **Test** renders a game-composition preview behind the live overlay:
buttons and the footer reports what is held. **Play** saves the skin, selects the 160x144 picture letterboxes inside the screen cutout, matching gameplay.
it, and boots the game with it. 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/<name>/skin.lua` and copies every image the **Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
skin names, so the folder stands alone. **Export** offers three formats, and skin names, so the folder stands alone. **Export** offers three formats, and
+21 -6
View File
@@ -302,6 +302,8 @@ local function makeLauncher()
forceImport = forceImport, forceImport = forceImport,
onEditSave = openEditor, onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor, 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() onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil, and openSkinStudio or nil,
}) })
@@ -877,6 +879,8 @@ love.handlers = love.handlers or {}
function love.handlers.audiosuspend() function love.handlers.audiosuspend()
local ChipAudio = package.loaded["src.core.ChipAudio"] local ChipAudio = package.loaded["src.core.ChipAudio"]
if ChipAudio then pcall(ChipAudio.setSuspended, true) end if ChipAudio then pcall(ChipAudio.setSuspended, true) end
local Sound = package.loaded["src.core.Sound"]
if Sound then pcall(Sound.onDeviceReset) end
end end
function love.handlers.audioreset() 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 if love.system.getOS() == "iOS" then return end
return TouchEditor.touchpressed(id, x, y) return TouchEditor.touchpressed(id, x, y)
end end
if Studio then return end if Studio then return Studio.touchpressed(id, x, y) end
if Importer then if Importer then
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
-- polled inside the view; the istouch filter on mousepressed still drops -- 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 if love.system.getOS() == "iOS" then return end
return TouchEditor.touchmoved(id, x, y) return TouchEditor.touchmoved(id, x, y)
end end
if Studio then return end if Studio then return Studio.touchmoved(id, x, y) end
if Importer then if Importer then
return Importer:touchmoved(id, x, y, dx, dy, pressure) return Importer:touchmoved(id, x, y, dx, dy, pressure)
end end
@@ -960,7 +964,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end if love.system.getOS() == "iOS" then return end
return TouchEditor.touchreleased(id, x, y) return TouchEditor.touchreleased(id, x, y)
end end
if Studio then return end if Studio then return Studio.touchreleased(id, x, y) end
if Importer then if Importer then
return Importer:touchreleased(id, x, y, dx, dy, pressure) return Importer:touchreleased(id, x, y, dx, dy, pressure)
end end
@@ -1013,7 +1017,12 @@ function love.mousepressed(x, y, button, istouch)
if love.system.getOS() == "Android" then return end if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button) return TouchEditor.mousepressed(x, y, button)
end 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 if Importer then
-- love.touchpressed already forwards the primary touch into FlexLove for -- love.touchpressed already forwards the primary touch into FlexLove for
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both -- 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 if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button) return TouchEditor.mousereleased(x, y, button)
end 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 Importer then return end
if editorMode and EditorApp.mousereleased then if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button) 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 if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y) return TouchEditor.mousemoved(x, y)
end 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 editorMode or Importer then return end
if mouseTouch then if mouseTouch then
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
@@ -54,7 +54,7 @@
<activity <activity
android:name="org.love2d.android.GameActivity" android:name="org.love2d.android.GameActivity"
android:exported="true" android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
android:label="${NAME}" android:label="${NAME}"
android:launchMode="singleTask" android:launchMode="singleTask"
android:screenOrientation="${ORIENTATION}" android:screenOrientation="${ORIENTATION}"
@@ -71,7 +71,7 @@
</activity> </activity>
<activity <activity
android:name="org.love2d.android.GameActivity$SecondaryActivity" android:name="org.love2d.android.GameActivity$SecondaryActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
android:excludeFromRecents="true" android:excludeFromRecents="true"
android:exported="false" android:exported="false"
android:launchMode="singleTask" android:launchMode="singleTask"
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.2.15",
"date": "2026-08-21",
"size": 13753007,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.15/gen1recomp++-0.2.15-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1589 Player sprite still blinking on the Town Map\n- #1595 Pokedex completion certificate is not displaying correctly\n- #1597 Link cable looks broken\n- #1613 Visual differences in Pokemon trades\n- #1619 Save Menu cutting off and Put into another place\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
},
{ {
"version": "0.2.14", "version": "0.2.14",
"date": "2026-08-20", "date": "2026-08-20",
+11 -1
View File
@@ -3980,7 +3980,17 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- record (chargeText) and the invulnerability from semiInvulnerable, -- record (chargeText) and the invulnerability from semiInvulnerable,
-- falling back to the id tables (Fly AND Dig go semi-invulnerable: -- falling back to the id tables (Fly AND Dig go semi-invulnerable:
-- ChargeEffect sets INVULNERABLE for both) -- ChargeEffect sets INVULNERABLE for both)
if record and record.charge and not releasing then local chargeRequired = record and record.charge ~= nil and not releasing
if chargeRequired and Runtime.wantsHook("battle.charge_required") then
local required = Runtime.call("battle.charge_required", function(c)
return c.charge
end, {
battle = self, user = user, target = target, move = move,
charge = true, isCalled = isCalled or false,
})
chargeRequired = required ~= false
end
if chargeRequired then
self:cancelMoveAnim() self:cancelMoveAnim()
user.charging = moveInst user.charging = moveInst
user.chargeReady = true user.chargeReady = true
+9
View File
@@ -1494,6 +1494,15 @@ function Battle:useMove(attacker, defender, moveId)
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
charge = nil charge = nil
end end
if charge and not charging and Runtime.wantsHook("battle.charge_required") then
local required = Runtime.call("battle.charge_required", function(c)
return c.charge
end, {
battle = self, user = attacker, target = defender, move = def,
charge = true, isCalled = (self.copyDepth or 0) > 0,
})
if required == false then charge = nil end
end
if charge and not charging then if charge and not charging then
state.chargeMove = moveId state.chargeMove = moveId
state.vanished = charge.vanish or nil state.vanished = charge.vanish or nil
+1 -10
View File
@@ -919,16 +919,7 @@ function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value)
end end
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the local isAccelerometer = GamepadMap.isAccelerometer
-- 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
-- BindingsMenu's raw-stick capture rides the same top-state routing as the -- 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 -- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
+101 -24
View File
@@ -18,6 +18,7 @@ local Chrome = require("src.ui.gen2.Chrome")
local Clock = require("src.core.gen2.Clock") local Clock = require("src.core.gen2.Clock")
local FixedStep = require("src.core.FixedStep") local FixedStep = require("src.core.FixedStep")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local GamepadMap = require("src.core.GamepadMap")
local Input = require("src.core.Input") local Input = require("src.core.Input")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Save = require("src.core.gen2.Save") local Save = require("src.core.gen2.Save")
@@ -57,20 +58,6 @@ Game2.__index = Game2
local function noop() end 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. -- THE FRAME AND INPUT SEAMS.
-- --
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad -- 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 {} local options = self.options or {}
Music.applyOptions(options) Music.applyOptions(options)
require("src.core.Sound").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.Tilt").applyOptions(options)
require("src.render.GbcPalette").applyOptions(options) require("src.render.GbcPalette").applyOptions(options)
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame. -- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
@@ -1993,6 +1984,13 @@ function Game2:applyOptions()
end end
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 -- `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 -- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been
-- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written -- 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 -- 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 -- (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. -- 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 -- a controller is being used: the touch overlay steps aside until the next
-- screen touch (mobile only; a no-op elsewhere) -- screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad() TouchControls:noteGamepad()
-- The shoulders cycle GAME SPEED, as they do in the Gen 1 path. local selectHeld = Input:isDown("select")
if button == "rightshoulder" or button == "leftshoulder" then if not selectHeld and joystick and joystick.isGamepadDown then
local GameSpeed = require("src.core.GameSpeed") local ok, down = pcall(function()
local dir = button == "rightshoulder" and 1 or -1 return joystick:isGamepadDown("back")
self.options.speed = GameSpeed.cycle(self.options.speed, dir) end)
if self.save then self.save.options = self.options end selectHeld = ok and down == true
self:persistOptions() 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 return
end 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 -- START opens the start menu in the overworld; it used to quit, from before
-- there was a menu to open. -- there was a menu to open.
Input:gamepadpressed(_joystick, button) Input:gamepadpressed(joystick, button)
end end
function Game2:gamepadreleased(joystick, button) function Game2:gamepadreleased(joystick, button)
Input: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 end
function Game2:gamepadaxis(joystick, axis, value) function Game2:gamepadaxis(joystick, axis, value)
@@ -2032,4 +2052,61 @@ function Game2:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value)
end 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 return Game2
+11
View File
@@ -106,6 +106,17 @@ function GamepadMap.ignoreRawForJoystick(joystick)
return ok and isPad == true return ok and isPad == true
end 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) function GamepadMap.mapRawButton(index)
if nxActive() then if nxActive() then
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index] local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
+6 -1
View File
@@ -281,6 +281,7 @@ end
function Input:joystickpressed(joystick, button) function Input:joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
noteCapture(self, "joy", "pressed", button) noteCapture(self, "joy", "pressed", button)
local btn = self.joyBindings[button] local btn = self.joyBindings[button]
if btn then press(self, btn, "joy:" .. button) end if btn then press(self, btn, "joy:" .. button) end
@@ -288,6 +289,7 @@ end
function Input:joystickreleased(joystick, button) function Input:joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
noteCapture(self, "joy", "released", button) noteCapture(self, "joy", "released", button)
local btn = self.joyBindings[button] local btn = self.joyBindings[button]
if btn then release(self, btn, "joy:" .. button) end if btn then release(self, btn, "joy:" .. button) end
@@ -330,6 +332,7 @@ end
function Input:joystickaxis(joystick, axis, value) function Input:joystickaxis(joystick, axis, value)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
if axis == 1 then if axis == 1 then
self:gamepadaxis(joystick, "leftx", value) self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then elseif axis == 2 then
@@ -343,6 +346,7 @@ end
-- directions on top of a direction rebind. -- directions on top of a direction rebind.
function Input:joystickhat(joystick, hat, direction) function Input:joystickhat(joystick, hat, direction)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
local source = "hat:" .. hat local source = "hat:" .. hat
for _, btn in ipairs(self.hatDirs[hat] or {}) do for _, btn in ipairs(self.hatDirs[hat] or {}) do
release(self, btn, source) release(self, btn, source)
@@ -380,7 +384,8 @@ function Input:reconcile()
local ok, joysticks = pcall(js.getJoysticks) local ok, joysticks = pcall(js.getJoysticks)
if not ok or type(joysticks) ~= "table" then return end if not ok or type(joysticks) ~= "table" then return end
for _, j in ipairs(joysticks) do 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 -- SDL-recognized pad: buttons + left stick, the gamepad surfaces
if j.isGamepadDown then if j.isGamepadDown then
for button, btn in pairs(self.padBindings) do for button, btn in pairs(self.padBindings) do
+79 -23
View File
@@ -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" | -- Persisted as options.orientation: "auto" | "portrait" | "landscape" |
-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS: -- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS:
@@ -10,16 +10,12 @@
-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE -> -- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE ->
-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone. -- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone.
-- --
-- SDL only re-reads the hint when the window is created or its resizable -- Android only re-reads the hint at window creation or on a resizable-flag
-- flag changes (SDL_androidwindow.c: Android_CreateWindow / -- change (SDL_androidwindow.c), and SDL_SetWindowResizable early-returns on
-- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE -- a fullscreen window (SDL_video.c:2237) -- which LOVE's Android window
-- 11.5 exposes neither hints nor a resizable setter, so apply() goes through -- always is -- so the hint never reached a running activity (#1638).
-- the FFI to SDL's C API: set the hint, then pulse the window's resizable -- apply() sets the hint for a later window, then goes over JNI for the live
-- flag off and back on -- each edge makes the Android backend recompute the -- one. iOS needs only the hint. Desktop and headless stubs no-op.
-- 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.
local Orientation = {} local Orientation = {}
@@ -58,6 +54,11 @@ function Orientation.isAndroid()
return love.system.getOS() == "Android" return love.system.getOS() == "Android"
end 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) function Orientation.cycle(mode, dir)
local cur, idx = Orientation.normalize(mode), 1 local cur, idx = Orientation.normalize(mode), 1
for i, m in ipairs(Orientation.MODES) do 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] return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1]
end 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 -- 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 -- once and remember whether it took; ffi itself may be absent (plain Lua
-- test interpreters), hence the pcall'd require. -- test interpreters), hence the pcall'd require.
@@ -77,33 +87,79 @@ local function sdlFfi()
if cdefOk == nil then if cdefOk == nil then
cdefOk = pcall(ffi.cdef, [[ cdefOk = pcall(ffi.cdef, [[
typedef struct SDL_Window SDL_Window; 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); int SDL_SetHint(const char *name, const char *value);
SDL_Window *SDL_GL_GetCurrentWindow(void); SDL_Window *SDL_GL_GetCurrentWindow(void);
void SDL_SetWindowResizable(SDL_Window *window, int resizable); void SDL_SetWindowResizable(SDL_Window *window, int resizable);
void *SDL_AndroidGetJNIEnv(void);
void *SDL_AndroidGetActivity(void);
]]) ]])
end end
if not cdefOk then return nil end if not cdefOk then return nil end
return ffi return ffi
end end
-- Push the mode into the live activity. Returns true when the hint reached -- Slot numbers in JNINativeInterface (jni.h).
-- SDL (the symbols resolved), false on any non-Android / stubbed platform. 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) 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() local ffi = sdlFfi()
if not ffi then return false end if not ffi then return false end
mode = Orientation.normalize(mode) mode = Orientation.normalize(mode)
local ok = pcall(function() local ok, reached = pcall(function()
-- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h); -- SDL_HINT_ORIENTATIONS is "SDL_IOS_ORIENTATIONS" in the SDL2 Android
-- despite the IOS in the string, the Android backend reads it too. -- 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]) ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode])
local win = ffi.C.SDL_GL_GetCurrentWindow() ffi.C.SDL_SetHint("SDL_ORIENTATIONS", HINTS[mode])
if win ~= nil then -- On iOS the hint is the lock: UIKit re-asks on every rotation
ffi.C.SDL_SetWindowResizable(win, 0) -- (SDL_uikitviewcontroller.m supportedInterfaceOrientations).
ffi.C.SDL_SetWindowResizable(win, 1) if not android then return true end
end return setRequestedOrientation(ffi, REQUESTED[mode])
end) end)
return ok return ok and reached == true
end end
function Orientation.applyOptions(opts) function Orientation.applyOptions(opts)
+5 -2
View File
@@ -86,8 +86,11 @@ function Performance.detect()
local cores = processorCount() local cores = processorCount()
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project -- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
-- already ships a build for): the weakest target here. -- already ships a build for): the weakest target here. Desktop ARM
if isArm and os ~= "Android" and os ~= "iOS" then -- (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" return "low"
end end
-- Phones and tablets: GBC FX is already force-disabled here (issue #136); -- Phones and tablets: GBC FX is already force-disabled here (issue #136);
+5
View File
@@ -12,10 +12,12 @@ function ScreenPosition.normalize(v)
end end
function ScreenPosition.label(v) function ScreenPosition.label(v)
if ScreenPosition.skinActive() then return "SKIN" end
return LABELS[ScreenPosition.normalize(v)] return LABELS[ScreenPosition.normalize(v)]
end end
function ScreenPosition.cycle(v, dir) function ScreenPosition.cycle(v, dir)
if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end
v = ScreenPosition.normalize(v) v = ScreenPosition.normalize(v)
local modes = ScreenPosition.MODES local modes = ScreenPosition.MODES
local cur = 1 local cur = 1
@@ -44,6 +46,9 @@ end
function ScreenPosition.skinActive(w, h) function ScreenPosition.skinActive(w, h)
local ok, TouchSkin = pcall(require, "src.core.TouchSkin") local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
if not ok or type(TouchSkin.viewport) ~= "function" then return false end 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) local okv, x = pcall(TouchSkin.viewport, w, h)
return okv and x ~= nil return okv and x ~= nil
end end
+8
View File
@@ -154,8 +154,14 @@ local function newSfxSource(data, key, def, pitch, tempo, plain)
return newFileSource(def) return newFileSource(def)
end 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) local function playPath(data, key, def, pitch, tempo, plain)
if not love.audio or not def then return nil end if not love.audio or not def then return nil end
if deviceSuspended() then return nil end
local src = cache[key] local src = cache[key]
if src == false then return nil end -- known bad, already logged if src == false then return nil end -- known bad, already logged
if not src then if not src then
@@ -602,6 +608,7 @@ end
-- cache carries no clips (Red/Blue) or headless. -- cache carries no clips (Red/Blue) or headless.
function Sound.playPikaCry(data, n) function Sound.playPikaCry(data, n)
if not love.audio then return nil end if not love.audio then return nil end
if deviceSuspended() then return nil end
local count = data.audio and data.audio.pikaCries local count = data.audio and data.audio.pikaCries
if not count then return nil end if not count then return nil end
n = math.max(1, math.min(count, n or 1)) n = math.max(1, math.min(count, n or 1))
@@ -633,6 +640,7 @@ end
-- like the original's PlayCry -> WaitForSoundToFinish can poll it -- like the original's PlayCry -> WaitForSoundToFinish can poll it
function Sound.playCry(data, species, pikaClip) function Sound.playCry(data, species, pikaClip)
if not love.audio then return nil end 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 -- 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 -- never used for the species there). Which clip is a property of the
-- call site in the original -- every caller of PlayPikachuSoundClip sets -- call site in the original -- every caller of PlayPikachuSoundClip sets
+18 -6
View File
@@ -293,7 +293,9 @@ function TouchControls:applyOptions(opts)
-- launcher editor round-trips through config() (#806) -- launcher editor round-trips through config() (#806)
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics) self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
TouchSkin.setOverlayLive(self.active) 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.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil self.layoutOx, self.layoutOy = nil, nil
@@ -335,7 +337,10 @@ function TouchControls:visible()
local art = TouchSkin.active ~= nil or self.img ~= nil local art = TouchSkin.active ~= nil or self.img ~= nil
if self.preview then return art end if self.preview then return art end
if self.enabled == false or not art then return false 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 return self.active and not self.controllerHidden
end end
@@ -778,12 +783,19 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale) zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end 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 if not img or alpha <= 0 then return end
local iw, ih = img:getWidth(), img:getHeight() local iw, ih = img:getWidth(), img:getHeight()
if iw <= 0 or ih <= 0 then return end 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.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 end
function TouchControls:drawSkin(alphaMul) function TouchControls:drawSkin(alphaMul)
@@ -795,7 +807,7 @@ function TouchControls:drawSkin(alphaMul)
love.graphics.push("all") love.graphics.push("all")
love.graphics.origin() love.graphics.origin()
drawStretched(page.image, bx, by, bw, bh, opacity) drawCovered(page.image, bx, by, bw, bh, opacity)
local pressed = {} local pressed = {}
for _, touch in pairs(self.touches or {}) do for _, touch in pairs(self.touches or {}) do
@@ -810,7 +822,7 @@ function TouchControls:drawSkin(alphaMul)
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy) TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
local alpha = opacity local alpha = opacity
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end 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
end end
+56 -3
View File
@@ -636,6 +636,19 @@ local function applyPixelScale(page)
return true return true
end 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) function TouchSkin.load(root, id)
local cfgPath, format, prefix = findConfig(root) local cfgPath, format, prefix = findConfig(root)
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end 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 elseif page.pdfPath then
rasterizePdfPage(page, root) rasterizePdfPage(page, root)
end end
TouchSkin.applyImageAspect(page)
if not applyPixelScale(page) then if not applyPixelScale(page) then
return nil, "could not read " .. tostring(page.imagePath) return nil, "could not read " .. tostring(page.imagePath)
.. ", which " .. page.name .. " measures its coordinates against" .. ", which " .. page.name .. " measures its coordinates against"
@@ -771,6 +785,28 @@ function TouchSkin.find(id)
return nil return nil
end 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) function TouchSkin.assetPaths(skin)
local out, seen = {}, {} local out, seen = {}, {}
local function add(rel) 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". -- full_screen means "relative to the window, not the game viewport".
-- When the cfg also names an aspect_ratio, that window is then fitted -- 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 -- 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 and page.aspect and page.aspect > 0 and h > 0
if fit then if fit then
local displayAspect = w / h local displayAspect = w / h
@@ -1306,6 +1342,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
by = oy + extra by = oy + extra
elseif anchor == "top" then elseif anchor == "top" then
by = oy 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 else
by = oy + extra * 0.5 by = oy + extra * 0.5
end end
@@ -1359,8 +1400,10 @@ function TouchSkin.decorativeOnly()
end end
function TouchSkin.drawable() function TouchSkin.drawable()
if not TouchSkin.active then return false end -- A selected skin is a presentation choice, not a mobile-only input mode.
return TouchSkin.overlayLive or TouchSkin.decorativeOnly() -- Its artwork and screen placement therefore belong on every platform;
-- `overlayLive` still controls whether touch input is available.
return TouchSkin.active ~= nil
end end
function TouchSkin.hasViewport() function TouchSkin.hasViewport()
@@ -1410,6 +1453,16 @@ function TouchSkin.pageViewport(page, w, h, ox, oy)
return nil return nil
end 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) function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page() local page = TouchSkin.page()
if not page or not TouchSkin.drawable() then return nil end if not page or not TouchSkin.drawable() then return nil end
+37
View File
@@ -287,6 +287,43 @@ function CacheFs.write(rel, data)
return love.filesystem.write(rel, data) return love.filesystem.write(rel, data)
end 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 -- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel) function CacheFs.read(rel)
rel = withPrefix(rel) rel = withPrefix(rel)
+20
View File
@@ -125,6 +125,26 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
return out return out
end 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) function ImageWriter.save(image, path)
local ok, fileData = pcall(image.encode, image, "png") local ok, fileData = pcall(image.encode, image, "png")
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
+26 -9
View File
@@ -6,8 +6,8 @@
-- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the -- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the
-- next boot's applyOptions pick the values up. Every ladder mirrors -- next boot's applyOptions pick the values up. Every ladder mirrors
-- OptionsMenu's semantics and stored values; when editing one, keep the two -- OptionsMenu's semantics and stored values; when editing one, keep the two
-- in sync. ZOOM is deliberately absent: its range depends on the live -- in sync. ZOOM uses the live window's integer fit (same 160×144 rule as
-- renderer's fit scale (Renderer:fitScale), which does not exist here. -- Renderer:fitScale) so the row can offer OUT/FIT/IN without a running game.
-- --
-- Rows are the same descriptor idiom OptionRows draws in game: -- Rows are the same descriptor idiom OptionRows draws in game:
-- { label, value = fn() -> string, step = fn(dir) -> changed, -- { label, value = fn() -> string, step = fn(dir) -> changed,
@@ -278,6 +278,16 @@ local function coreRows(opts, hooks)
end) end)
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") local okTile, TileRenderer = pcall(require, "src.render.TileRenderer")
if okTile and TileRenderer.VOID_FILLS then if okTile and TileRenderer.VOID_FILLS then
add(Strings("VOID FILL"), add(Strings("VOID FILL"),
@@ -303,15 +313,12 @@ local function coreRows(opts, hooks)
end) end)
end end
-- ORIENTATION (#592): Android only -- the lock rides SDL's orientation -- ORIENTATION (#592, #1638): mobile only. Unlike the other launcher rows
-- hint, which iOS reads only at startup (the Info.plist governs there) and -- this one live-applies: the window exists here too, and rotating under
-- desktop ignores. Unlike the other launcher rows this one live-applies: -- the player's finger is the only feedback that reads.
-- the window exists here too, and rotating under the player's finger is
-- the only feedback that reads.
do do
local osName = love.system and love.system.getOS and love.system.getOS()
local okOr, Orientation = pcall(require, "src.core.Orientation") 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"), add(Strings("ORIENTATION"),
function() return Strings(Orientation.modeLabel(opts.orientation)) end, function() return Strings(Orientation.modeLabel(opts.orientation)) end,
function(dir) function(dir)
@@ -646,6 +653,16 @@ local function gen2Rows(opts, hooks)
end) end)
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") local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
if okFill and BorderFill.VOID_FILLS then if okFill and BorderFill.VOID_FILLS then
add(Strings("VOID FILL"), add(Strings("VOID FILL"),
+251 -117
View File
@@ -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. -- and one below the top-right corner notch, like the DMG cart.
local grooveW = w * 0.115 local grooveW = w * 0.115
local grooveH = math.max(1, h * 0.009) 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 for i = 0, 5 do
local ry = mainTop + h * 0.014 + i * h * 0.021 local ry = mainTop + h * 0.014 + i * h * 0.021
cartPolygon(cartQuad(project, -halfW + w * 0.02, ry, local gw = grooveW * grooveScale[i + 1]
grooveW, grooveH, faceZ), side, 0.7) cartPolygon(cartQuad(project, -halfW + grooveInset, ry,
cartPolygon(cartQuad(project, halfW - grooveW - w * 0.02, ry, gw, grooveH, faceZ), side, 0.7)
grooveW, grooveH, faceZ), side, 0.7) cartPolygon(cartQuad(project, halfW - gw - grooveInset, ry,
gw, grooveH, faceZ), side, 0.7)
end end
-- The thin diagonal mold ridge cut into each long side a little below -- The thin diagonal mold ridge cut into each long side a little below
-- the grip grooves, mirrored left/right. -- 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) }, { project(x1 + nx * t, y1 + ny * t, faceZ) },
}, side, 0.7) }, side, 0.7)
end 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)
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. -- The pill recess: one stadium pill sunk into the shell.
local pillX, pillW = -halfW + w * 0.17, w * 0.62 local pillX, pillW = -halfW + w * 0.19, w * 0.62
local pillY, pillH = mainTop + h * 0.024, h * 0.115 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) cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55)
local inX, inY = w * 0.008, h * 0.008 local inX, inY = w * 0.008, h * 0.008
cartPill(project, pillX + inX, pillY + inY, cartPill(project, pillX + inX, pillY + inY,
@@ -2142,124 +2147,127 @@ local function buildModsPanel(imp, x, y, w, availH, m)
+ math.floor(8 * m.s) + math.floor(8 * m.s)
local listTop = cy local listTop = cy
-- One continuous list: every row is laid out, the region scroll moves -- One continuous list: derive the rows that can touch the viewport before
-- through all of it, and only rows inside the region's viewport draw -- -- entering the loop. Drawing was already culled, but scanning every
-- so the per-frame cost stays bounded by the window, not the list. -- installed row to discover that defeats the point on a large mod library.
local view = imp._tabRegionRect local view = imp._tabRegionRect
local viewTop = view and view.y or listTop local viewTop = view and view.y or listTop
local viewBot = view and (view.y + view.h) or (listTop + availH) 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 mod = mods[i]
local ry = listTop + (i - 1) * (rowH + gap) local ry = listTop + (i - 1) * (rowH + gap)
if ry + rowH >= viewTop and ry <= viewBot then local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
local rowKey = rowKeyFor(imp, "mod-row-", mod.id) local isFullyDisabled = true
local isFullyDisabled = true if mod.enabledByVersion then
if mod.enabledByVersion then for _, on in pairs(mod.enabledByVersion) do
for _, on in pairs(mod.enabledByVersion) do if on then isFullyDisabled = false; break end
if on then isFullyDisabled = false; break end
end
else
isFullyDisabled = not mod.enabled
end end
else
isFullyDisabled = not mod.enabled
end
local focused = Kit.focusable(rowKey, x, ry, w, rowH) local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH) local hot = focused or Kit.hover(x, ry, w, rowH)
if isFullyDisabled then if isFullyDisabled then
Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted") Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
else else
Kit.card(x, ry, w, rowH, hot) Kit.card(x, ry, w, rowH, hot)
end end
local pad = math.floor(12 * m.s) local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s) local ly = ry + math.floor(10 * m.s)
local togGap = math.floor(5 * m.s) + 1 local togGap = math.floor(5 * m.s) + 1
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
-- These answer separate games, not a single shared install flag. The -- These answer separate games, not a single shared install flag. The
-- importer receives the game id so an experimental confirmation also -- importer receives the game id so an experimental confirmation also
-- applies only to the checkbox the player pressed. -- applies only to the checkbox the player pressed.
local flipped = false local flipped = false
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s) local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
Kit.text("micro", gamesLabel, px, Kit.text("micro", gamesLabel, px,
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted) gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s) local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
for _, game in ipairs(GameVersion.ORDER) do for _, game in ipairs(GameVersion.ORDER) do
local togKey = "mod-toggle-" .. mod.id .. "-" .. game local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH, if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true, mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey, not safeMode) then game, togKey, not safeMode) then
local version = game local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true flipped = true
end
tx = tx + togH + togGap
end end
-- The checkboxes sit inside the row's rect, so their press also passes the tx = tx + togH + togGap
-- row hit test; `flipped` gates the row action to everywhere else. end
if not flipped -- The checkboxes sit inside the row's rect, so their press also passes the
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then -- row hit test; `flipped` gates the row action to everywhere else.
local id = mod.id if not flipped
queueAction(imp, rowKey, function() imp._modActions = id end) and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
end local id = mod.id
local textW = inner queueAction(imp, rowKey, function() imp._modActions = id end)
end
local textW = inner
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * 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 -- the games the mod is for, beside its category: the same chip the
-- in-game manager shows (src/mods/ModTargets.lua) -- in-game manager shows (src/mods/ModTargets.lua)
local gamesW = mod.targets local gamesW = mod.targets
and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0
local nameShown = Kit.ellipsize("button", mod.name, local nameShown = Kit.ellipsize("button", mod.name,
textW - badgeW - gamesW - math.floor(12 * m.s)) textW - badgeW - gamesW - math.floor(12 * m.s))
local headingCol = isFullyDisabled and PAL.muted or PAL.heading local headingCol = isFullyDisabled and PAL.muted or PAL.heading
Kit.text("button", nameShown, px, ly, headingCol) Kit.text("button", nameShown, px, ly, headingCol)
local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s)
Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge,
mod.experimental and PAL.yellow or PAL.muted) mod.experimental and PAL.yellow or PAL.muted)
if mod.targets then if mod.targets then
Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW, Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW,
Kit.textHeight("button"), mod.targets, Kit.textHeight("button"), mod.targets,
mod.targetsHere == false and PAL.steel or PAL.blue) mod.targetsHere == false and PAL.steel or PAL.blue)
end end
ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) ly = ly + Kit.textHeight("button") + math.floor(4 * m.s)
-- version + status + update state -- version + status + update state
local statusText, statusCol = modStatusColor(mod.status) local statusText, statusCol = modStatusColor(mod.status)
local line = "v" .. tostring(mod.version or "?") .. " " .. statusText local line = "v" .. tostring(mod.version or "?") .. " " .. statusText
Kit.text("small", line, px, ly, statusCol) Kit.text("small", line, px, ly, statusCol)
local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s) local lx = px + Kit.textWidth("small", line) + math.floor(12 * m.s)
if imp:_modInfoPending(mod.id) then if imp:_modInfoPending(mod.id) then
-- An inline spinner, because this row's release check is genuinely in -- An inline spinner, because this row's release check is genuinely in
-- flight -- the list stays usable while it resolves. -- flight -- the list stays usable while it resolves.
Loader.dot(lx, ly, Kit.textHeight("small")) Loader.dot(lx, ly, Kit.textHeight("small"))
Kit.text("small", Strings("Checking..."), Kit.text("small", Strings("Checking..."),
lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted) lx + Kit.textHeight("small") + math.floor(6 * m.s), ly, PAL.muted)
elseif info and info.status == "available" then elseif info and info.status == "available" then
Kit.text("small", Strings("v%s available", tostring(info.latest)), Kit.text("small", Strings("v%s available", tostring(info.latest)),
lx, ly, PAL.yellow) lx, ly, PAL.yellow)
elseif info and info.status == "current" then elseif info and info.status == "current" then
Kit.text("small", Strings("up to date"), lx, ly, PAL.muted) Kit.text("small", Strings("up to date"), lx, ly, PAL.muted)
elseif info and info.status == "error" then elseif info and info.status == "error" then
Kit.text("small", Strings("check failed"), lx, ly, PAL.red) Kit.text("small", Strings("check failed"), lx, ly, PAL.red)
end end
ly = ly + Kit.textHeight("small") + math.floor(2 * m.s) ly = ly + Kit.textHeight("small") + math.floor(2 * m.s)
-- one line of description, or the download stats when we have them -- one line of description, or the download stats when we have them
-- (download count in green so popularity reads at a glance) -- (download count in green so popularity reads at a glance)
if info and info.downloads then if info and info.downloads then
local d = info.dates local d = info.dates
local dl = ModUpdate.downloadsLine(info.downloads.total) local dl = ModUpdate.downloadsLine(info.downloads.total)
local dates = ModUpdate.datesLine(d and d.first, d and d.latest) local dates = ModUpdate.datesLine(d and d.first, d and d.latest)
local segs = {} local segs = {}
if dl then segs[#segs + 1] = { dl, PAL.green } end if dl then segs[#segs + 1] = { dl, PAL.green } end
if dates then if dates then
segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail } 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
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
end end
@@ -2269,8 +2277,8 @@ end
-- ---------------------------------------------------------- find mods panel -- ---------------------------------------------------------- find mods panel
-- SKINS tab: pick the on-screen skin, import one, or open the desktop studio. -- SKINS tab: pick the on-screen skin, import one, or open Skin Studio.
local function buildSkinsPanel(imp, x, y, w, availH, m) local function buildSkinsPanelLegacy(imp, x, y, w, availH, m)
local skins = imp:_ensureSkins() local skins = imp:_ensureSkins()
local active = imp:_activeSkin() local active = imp:_activeSkin()
local gap = m.gap local gap = m.gap
@@ -2324,7 +2332,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
end end
cy = cy + urlH + math.floor(8 * m.s) 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 if imp.onOpenSkinStudio then
local label = Strings("Open Skin Studio") local label = Strings("Open Skin Studio")
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s)) 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")) Kit.caption(x, cy, Strings("INSTALLED"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) 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)) local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
imp._skinGear = imp._skinGear imp._skinGear = imp._skinGear
or love.graphics.newImage("assets/launcher/gear.png") 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 return cy + hintH - y
end 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 function buildBugPanel(imp, x, y, w, availH, m)
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local gap = m.gap 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 h = math.floor(math.min(m.H - 2 * m.pad, 460 * m.s))
local px, py, pw, ph = modalPanel(m, w, h) local px, py, pw, ph = modalPanel(m, w, h)
local cy = py + pad 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) 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 pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local bodyH = (py + ph - pad) - cy - m.btnH - math.floor(10 * 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 -- 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. -- one is up; buildModals lowers the shield for the modal's own controls.
imp._modalUpNow = modalUp(imp) imp._modalUpNow = modalUp(imp)
if imp._modalUpNow then imp:_blurPanelFields() end
Kit.blockClicks = imp._modalUpNow Kit.blockClicks = imp._modalUpNow
local step = Kit.scrollStep(m.s) local step = Kit.scrollStep(m.s)
+170 -37
View File
@@ -5258,80 +5258,213 @@ function RomExtractorGen2:extractMenuGfx()
end end
-- Goldenrod Game Corner: Slot Machine graphics assets -- 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 if self.symbols["Slots1LZ"] then
-- --trim-whitespace drops the final empty tile (37 of 38).
local raw1 = self:decompressLz3Symbol("Slots1LZ") 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 end
if self.symbols["Slots2LZ"] then if self.symbols["Slots2LZ"] then
local raw2 = self:decompressLz3Symbol("Slots2LZ") local raw2 = ImageWriter.deinterleave(
-- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity self:decompressLz3Symbol("Slots2LZ"), SLOTS2_W)
-- Commercial Gold stores the Seven symbol with inverted bit polarity.
for i = 1, math.min(64, #raw2) do for i = 1, math.min(64, #raw2) do
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF) raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
end 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 end
if self.symbols["Slots3LZ"] then if self.symbols["Slots3LZ"] then
local raw3 = self:decompressLz3Symbol("Slots3LZ") local raw3 = self:decompressLz3Symbol("Slots3LZ")
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true) local actors = composeSlotsActors(raw3)
-- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing: self:save(actors, "slots/gold_slots_3.png")
-- Y=0: Golem 1 (Standing, 24x32) self:save(actors, "slots/gold_slots_actors.png")
-- Y=32: Golem 2 (Ball, 24x32) slots = slots or {}
-- Y=64: Chansey 1 (Standing / Step 1, 24x32) slots.sheet3 = "assets/generated/slots/gold_slots_3.png"
-- 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)
end end
if self.symbols["SlotsTilemap"] then if self.symbols["SlotsTilemap"] then
local symbol = self:symbol("SlotsTilemap") local symbol = self:symbol("SlotsTilemap")
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12) 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 end
if slots then out.slots = slots end
-- Goldenrod Game Corner: Card Flip graphics assets -- Goldenrod Game Corner: Card Flip graphics assets
local cardFlip = nil
if self.symbols["CardFlipLZ01"] then if self.symbols["CardFlipLZ01"] then
-- --trim-whitespace: 62 of 64 tiles in the ROM stream.
local raw1 = self:decompressLz3Symbol("CardFlipLZ01") 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 end
if self.symbols["CardFlipLZ02"] then if self.symbols["CardFlipLZ02"] then
local raw2 = self:decompressLz3Symbol("CardFlipLZ02") local raw2 = expandCardFlip2(self:decompressLz3Symbol("CardFlipLZ02"))
self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png") 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 end
if self.symbols["CardFlipLZ03"] then if self.symbols["CardFlipLZ03"] then
local raw3 = self:decompressLz3Symbol("CardFlipLZ03") 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 end
if self.symbols["CardFlipOnButtonGFX"] then if self.symbols["CardFlipOnButtonGFX"] then
local symbol = self:symbol("CardFlipOnButtonGFX") local symbol = self:symbol("CardFlipOnButtonGFX")
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png") 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 end
if self.symbols["CardFlipOffButtonGFX"] then if self.symbols["CardFlipOffButtonGFX"] then
local symbol = self:symbol("CardFlipOffButtonGFX") local symbol = self:symbol("CardFlipOffButtonGFX")
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png") 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 end
if self.symbols["CardFlipTilemap"] then if self.symbols["CardFlipTilemap"] then
local symbol = self:symbol("CardFlipTilemap") local symbol = self:symbol("CardFlipTilemap")
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12) 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 end
if cardFlip then out.cardFlip = cardFlip 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",
}
self:write("menu_gfx", out) self:write("menu_gfx", out)
self:tick("Menu graphics", 1, 1) self:tick("Menu graphics", 1, 1)
+211 -17
View File
@@ -140,6 +140,12 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
-- before the four ball tiles were extracted (#1502). -- before the four ball tiles were extracted (#1502).
"assets/generated/battle/hud/balls.png", "assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin", "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. -- 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 return size
end 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 function readDroppedFile(file)
local ok, openError = file:open("r") local ok, openError = file:open("r")
if not ok then return nil, openError end if not ok then return nil, openError end
@@ -1228,11 +1370,13 @@ local function chooseRequiredFile()
"$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';", "$d.Title='" .. prompt .. "';",
"$d.Filter='All files (*.*)|*.*';", "$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'){", "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]::OutputEncoding=[Text.Encoding]::UTF8;",
"[Console]::Write($t)}", "[Console]::Write($d.FileName)}",
}) })
return commandOutput( return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"') 'powershell -NoProfile -STA -Command "' .. script .. '"')
@@ -2072,8 +2216,13 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
return nil return nil
end end
local RequiredImports = require("src.mods.RequiredImports") local RequiredImports = require("src.mods.RequiredImports")
local info = love.filesystem.getInfo(source, "file") -- A desktop picker returns a host path. Ask the host file handle first;
local size = info and info.size or externalFileSize(source) -- 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) local sizeErr = RequiredImports.sizeError(spec, size, false)
if sizeErr then if sizeErr then
requiredImportNotice(self, modId, importId, sizeErr) requiredImportNotice(self, modId, importId, sizeErr)
@@ -2096,6 +2245,20 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
} }
return nil return nil
end 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) local data = love.filesystem.read(source)
if not data then data = readExternalPath(source) end if not data then data = readExternalPath(source) end
if not data then if not data then
@@ -2848,6 +3011,7 @@ function RomImporter:_updatePadCursor(dt)
local overY = 0 local overY = 0
if ny > oy + h then overY = ny - (oy + h) if ny > oy + h then overY = ny - (oy + h)
elseif ny < oy then overY = ny - oy end 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 if overY ~= 0 and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
end end
@@ -2907,7 +3071,11 @@ end
function RomImporter:gamepadaxis(_, axis, value) function RomImporter:gamepadaxis(_, axis, value)
if axis == "leftx" or axis == "lefty" or axis == "righty" then if axis == "leftx" or axis == "lefty" or axis == "righty" then
self._padAxis[axis] = value 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
end end
@@ -2916,18 +3084,21 @@ end
-- fire the virtual cursor's click twice off one A press (#620). -- fire the virtual cursor's click twice off one A press (#620).
function RomImporter:joystickpressed(joystick, button) function RomImporter:joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button) local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then self:gamepadpressed(joystick, padButton) end if padButton then self:gamepadpressed(joystick, padButton) end
end end
function RomImporter:joystickreleased(joystick, button) function RomImporter:joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button) local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then self:gamepadreleased(joystick, padButton) end if padButton then self:gamepadreleased(joystick, padButton) end
end end
function RomImporter:joystickaxis(joystick, axis, value) function RomImporter:joystickaxis(joystick, axis, value)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
if axis == 1 then if axis == 1 then
self:gamepadaxis(joystick, "leftx", value) self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then elseif axis == 2 then
@@ -2937,6 +3108,7 @@ end
function RomImporter:joystickhat(joystick, hat, direction) function RomImporter:joystickhat(joystick, hat, direction)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
self._padDir[dir] = nil self._padDir[dir] = nil
end end
@@ -3182,6 +3354,10 @@ function RomImporter:_ensureSkins(force)
format = skin and skin.format or nil, format = skin and skin.format or nil,
pages = skin and #skin.pages or 0, pages = skin and #skin.pages or 0,
controls = controls, 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 screen = page ~= nil
and (page.viewport ~= nil or page.screenFit == "remainder"), and (page.viewport ~= nil or page.screenFit == "remainder"),
ok = skin ~= nil, ok = skin ~= nil,
@@ -3194,7 +3370,7 @@ end
function RomImporter:_activeSkin() function RomImporter:_activeSkin()
local opts = require("src.core.SaveData").loadOptions() local opts = require("src.core.SaveData").loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {} local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
return tc.skin return tc.enabled == false and nil or tc.skin
end end
function RomImporter:_useSkin(id) function RomImporter:_useSkin(id)
@@ -3211,6 +3387,16 @@ function RomImporter:_useSkin(id)
} }
end 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) function RomImporter:_installSkinZip(source)
if self.workState == "working" then return end if self.workState == "working" then return end
self.tab = "skins" self.tab = "skins"
@@ -3997,6 +4183,18 @@ function RomImporter:_disarmTextInput()
end end
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) function RomImporter:_beginRename(version, id)
local label local label
for _, slot in ipairs(self.slots[version] or {}) do for _, slot in ipairs(self.slots[version] or {}) do
@@ -4127,7 +4325,6 @@ function RomImporter:_refreshMods()
end end
self.mods = kept self.mods = kept
end end
self:_syncModUpdateInfo(false)
end end
-- Point the MODS panel at one game (or nil for all of them) and relist, so -- 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 if not self.mods then self:_refreshMods() end
end end
-- Resolve cached (or freshly fetched) GitHub status for every mod that -- Resolve GitHub status for every installed mod that declares a github field.
-- declares a github field. force=true bypasses the 6h cache on every repo. -- This is deliberately opt-in: only the explicit "Check for updates" action
-- Results live on self.modUpdateInfo[id] = { status, latest, best, releases }. -- calls it. Opening, scrolling, toggling, or relisting the MODS tab must not
-- ASYNC (was synchronous). This runs on every _refreshMods -- boot, and any -- create a burst of release work behind the list. force=true bypasses the 6h
-- toggle or install -- and used to make one blocking curl call per mod with a -- cache on every repo. Results live on self.modUpdateInfo[id] = {
-- github field, in a loop, on the render thread. A handful of mods was a -- status, latest, best, releases } and resolve asynchronously across frames.
-- 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.
function RomImporter:_syncModUpdateInfo(force) function RomImporter:_syncModUpdateInfo(force)
local ModUpdate = require("src.mods.ModUpdate") local ModUpdate = require("src.mods.ModUpdate")
self.modUpdateInfo = self.modUpdateInfo or {} self.modUpdateInfo = self.modUpdateInfo or {}
+174
View File
@@ -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/<mod-id>/ 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
+8
View File
@@ -962,6 +962,8 @@ function Loader:_api(mod)
local Storage = engineRequire("src.mods.Storage") local Storage = engineRequire("src.mods.Storage")
local storage = Storage and Storage.new(modId, loader.fs) local storage = Storage and Storage.new(modId, loader.fs)
local Checkpoint = engineRequire("src.core.Checkpoint") local Checkpoint = engineRequire("src.core.Checkpoint")
local ImportAccess = engineRequire("src.mods.ImportAccess")
local importApi, installCache = ImportAccess.new(mod.manifest, loader.fs)
local api = { local api = {
id = modId, id = modId,
version = mod.manifest.version, version = mod.manifest.version,
@@ -1161,6 +1163,12 @@ function Loader:_api(mod)
-- checkpoint. The -- checkpoint. The
-- engine binds version/playthrough/mod scope and portable persistence; -- engine binds version/playthrough/mod scope and portable persistence;
-- callers never receive paths or a raw filesystem handle. -- 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 = { storage = {
context = function(_, game) return storage:context(game) end, context = function(_, game) return storage:context(game) end,
selected = function(_, game) return storage:selected(game) end, selected = function(_, game) return storage:selected(game) end,
+87
View File
@@ -120,6 +120,36 @@ local function accepts(spec, digest)
return false return false
end 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) function RequiredImports.path(manifest, spec)
return manifest.path .. "/baseroms/" .. spec.file return manifest.path .. "/baseroms/" .. spec.file
end end
@@ -180,6 +210,47 @@ local function removeReceipt(manifest, spec, fs)
end end
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 -- Validate bytes against a declaration. The returned data is canonicalized
-- (notably for N64 byte order/header variants) and is what must be stored. -- (notably for N64 byte order/header variants) and is what must be stored.
function RequiredImports.validateData(spec, data, hashFn) function RequiredImports.validateData(spec, data, hashFn)
@@ -217,6 +288,22 @@ function RequiredImports.validateStored(manifest, spec, fs, hashFn)
local cached = cachedDigest(manifest, spec, fs, info) local cached = cachedDigest(manifest, spec, fs, info)
if cached then return true, cached, true end if cached then return true, cached, true end
removeReceipt(manifest, spec, fs) 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 if not fs.read then return nil, "file could not be read" end
local data = fs.read(path) local data = fs.read(path)
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn) local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
+101
View File
@@ -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
+33 -1
View File
@@ -19,6 +19,8 @@ Zoom.allowSurvey = true
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world -- 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. -- 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) function Zoom.offsetRange(S)
S = math.max(1, math.floor(tonumber(S) or 1)) S = math.max(1, math.floor(tonumber(S) or 1))
local lo, hi = 1 - S, S local lo, hi = 1 - S, S
@@ -30,7 +32,11 @@ function Zoom.offsetRange(S)
end end
-- LOW performance tier: no survey (negative offsets), even if a mod's -- LOW performance tier: no survey (negative offsets), even if a mod's
-- zoom.range widened it. == false so nil/true stays permissive. -- 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 return lo, hi
end end
@@ -44,6 +50,8 @@ function Zoom.scale(S)
local maxScale = math.max(minScale, S + hi) local maxScale = math.max(minScale, S + hi)
if s < minScale then s = minScale end if s < minScale then s = minScale end
if s > maxScale then s = maxScale 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 if s < 0.25 then s = 0.25 end
return s return s
end end
@@ -79,6 +87,30 @@ function Zoom.applyOptions(opts)
Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0) Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0)
end 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 / … -- FIT / OUT1 / OUT2 / … / IN1 / IN2 / …
function Zoom.offsetLabel(offset) function Zoom.offsetLabel(offset)
offset = math.floor(tonumber(offset) or 0) offset = math.floor(tonumber(offset) or 0)
+3 -10
View File
@@ -381,14 +381,7 @@ local function buildRows(game)
return Zoom.offsetLabel(g.save.options.zoom or 0) return Zoom.offsetLabel(g.save.options.zoom or 0)
end, end,
step = function(g, dir) step = function(g, dir)
local o = g.save.options Zoom.nudgeOptions(g.save.options, dir, Renderer:fitScale())
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
return true return true
end }, end },
{ id = "voidFill", label = Strings("VOID FILL"), { id = "voidFill", label = Strings("VOID FILL"),
@@ -580,8 +573,8 @@ local function buildRows(game)
end end
rows = filtered rows = filtered
end end
-- ORIENTATION only on Android, the one platform Orientation.apply reaches. -- ORIENTATION only on the platforms Orientation.apply reaches (#1638).
if not Orientation.isAndroid() then if not (Orientation.isAndroid() or Orientation.isIOS()) then
local filtered = {} local filtered = {}
for _, row in ipairs(rows) do for _, row in ipairs(rows) do
if row.id ~= "orientation" then filtered[#filtered + 1] = row end if row.id ~= "orientation" then filtered[#filtered + 1] = row end
+4
View File
@@ -151,6 +151,7 @@ end
function PadCursor.joystickpressed(joystick, button) function PadCursor.joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return nil end if GamepadMap.ignoreRawForJoystick(joystick) then return nil end
if GamepadMap.isAccelerometer(joystick) then return nil end
local padButton = GamepadMap.mapRawToGamepadButton(button) local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then return PadCursor.gamepadpressed(joystick, padButton) end if padButton then return PadCursor.gamepadpressed(joystick, padButton) end
return nil return nil
@@ -158,12 +159,14 @@ end
function PadCursor.joystickreleased(joystick, button) function PadCursor.joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button) local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then PadCursor.gamepadreleased(joystick, padButton) end if padButton then PadCursor.gamepadreleased(joystick, padButton) end
end end
function PadCursor.joystickaxis(joystick, axisIndex, value) function PadCursor.joystickaxis(joystick, axisIndex, value)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
if axisIndex == 1 then if axisIndex == 1 then
PadCursor.gamepadaxis(joystick, "leftx", value) PadCursor.gamepadaxis(joystick, "leftx", value)
elseif axisIndex == 2 then elseif axisIndex == 2 then
@@ -173,6 +176,7 @@ end
function PadCursor.joystickhat(joystick, hat, direction) function PadCursor.joystickhat(joystick, hat, direction)
if GamepadMap.ignoreRawForJoystick(joystick) then return end if GamepadMap.ignoreRawForJoystick(joystick) then return end
if GamepadMap.isAccelerometer(joystick) then return end
for _, d in ipairs(rawHatDirs[hat] or {}) do for _, d in ipairs(rawHatDirs[hat] or {}) do
dir[d] = nil dir[d] = nil
end end
+553 -39
View File
@@ -70,6 +70,12 @@ Studio.BIND_GROUPS = {
"pause_toggle", "screenshot", "exit_emulator" } }, "pause_toggle", "screenshot", "exit_emulator" } },
{ title = "KEYBOARD", { title = "KEYBOARD",
specs = { "key:escape", "key:return", "key:space", "key:tab", "key:f1" } }, 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" } }, { title = "NO INPUT", specs = { "nul" } },
} }
@@ -362,7 +368,9 @@ function Studio.load(opts)
Studio.drag = nil Studio.drag = nil
Studio.pendingPlay = false Studio.pendingPlay = false
Studio.canvasIndex = 1 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.skinIdField = ""
Studio.available = TouchSkin.list() Studio.available = TouchSkin.list()
Studio.availableMeta = {} Studio.availableMeta = {}
@@ -374,6 +382,13 @@ function Studio.load(opts)
Studio.showLabels = true Studio.showLabels = true
Studio.statusErr = false Studio.statusErr = false
Studio.thumbs = {} 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:init()
TouchControls.active = true TouchControls.active = true
@@ -413,6 +428,7 @@ function Studio.open(id)
Studio.undoStack, Studio.redoStack = {}, {} Studio.undoStack, Studio.redoStack = {}, {}
Studio.undoTag = nil Studio.undoTag = nil
Studio.thumbs = {} Studio.thumbs = {}
Studio.libraryThumbs = {}
syncActive() syncActive()
Studio.applyImportedOrient() Studio.applyImportedOrient()
return true return true
@@ -425,6 +441,7 @@ function Studio.newSkin()
Studio.pageIndex, Studio.selected = 1, nil Studio.pageIndex, Studio.selected = 1, nil
Studio.images = {} Studio.images = {}
Studio.thumbs = {} Studio.thumbs = {}
Studio.libraryThumbs = {}
syncActive() syncActive()
markDirty() markDirty()
end end
@@ -468,9 +485,122 @@ end
function Studio.refreshAvailable() function Studio.refreshAvailable()
Studio.available = TouchSkin.list() Studio.available = TouchSkin.list()
Studio.availableMeta = {} Studio.availableMeta = {}
Studio.libraryThumbs = {}
return Studio.available return Studio.available
end 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() function Studio.openLoadPicker()
return Studio.guard("Open another skin and lose the unsaved changes?", return Studio.guard("Open another skin and lose the unsaved changes?",
function() function()
@@ -509,6 +639,29 @@ function Studio.unload()
Studio.modal, Studio.confirm = nil, nil Studio.modal, Studio.confirm = nil, nil
Studio.guides = nil Studio.guides = nil
Studio.undoStack, Studio.redoStack = {}, {} 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 end
-- --------------------------------------------------------------- editing -- --------------------------------------------------------------- editing
@@ -678,7 +831,12 @@ function Studio.cycleImage(dir)
local rel = (next_ >= 1) and list[next_] or nil local rel = (next_ >= 1) and list[next_] or nil
owner[key] = rel owner[key] = rel
local img = rel and TouchSkin.resolveImage(Studio.skin.root, rel) or nil 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() markDirty()
end end
@@ -748,6 +906,7 @@ function Studio.assignImage(rel)
ctl.imagePath, ctl.image = rel, img ctl.imagePath, ctl.image = rel, img
elseif page then elseif page then
page.imagePath, page.image = rel, img page.imagePath, page.image = rel, img
TouchSkin.applyImageAspect(page)
end end
Studio.images = TouchSkin.listImages(Studio.skin.root) Studio.images = TouchSkin.listImages(Studio.skin.root)
Studio.dirty = true Studio.dirty = true
@@ -1228,8 +1387,11 @@ function Studio.updateDrag(mx, my, r)
ctl.rangeX = math.max(0.002, (bw * 0.5) / pw) ctl.rangeX = math.max(0.002, (bw * 0.5) / pw)
ctl.rangeY = math.max(0.002, (bh * 0.5) / ph) ctl.rangeY = math.max(0.002, (bh * 0.5) / ph)
else 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 = { 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), w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph),
} }
end end
@@ -1238,6 +1400,35 @@ end
-- ----------------------------------------------------------------- draw -- ----------------------------------------------------------------- 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 function drawCanvas(x, y, w, h)
local page = Studio.page() local page = Studio.page()
local r = { } local r = { }
@@ -1251,7 +1442,9 @@ local function drawCanvas(x, y, w, h)
syncActive() syncActive()
local vx, vy, vw, vh = viewportRect(page, r) 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 scale = math.min(vw / 160, vh / 144)
local gw, gh = 160 * scale, 144 * scale local gw, gh = 160 * scale, 144 * scale
local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5 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 rowH = math.max(Kit.tapMin(), 46 * Kit.scale)
local gap = 4 * Kit.scale local gap = 4 * Kit.scale
local list = Studio.available or {} 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 at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH)
local maxScroll = Kit.scrollExtent(contentH, viewH) local maxScroll = Kit.scrollExtent(contentH, viewH)
local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) 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 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 selected = Studio.skin and Studio.skin.id == entry.id
local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected, local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected,
"open-" .. entry.id) "open-" .. entry.id)
@@ -1744,14 +1948,19 @@ local function drawPageModal(W, H)
local top = my + pad + Kit.textHeight("title") + pad * 0.5 local top = my + pad + Kit.textHeight("title") + pad * 0.5
local by, bh = modalFooter(mx, my, mw, mh, pad) local by, bh = modalFooter(mx, my, mw, mh, pad)
local gap = 6 * Kit.scale local gap = 6 * Kit.scale
local fieldW = math.min(220 * Kit.scale, (mw - pad * 2) * 0.5) -- Keep the page name easy to edit: one full-width field, then its actions
Studio.pageNameField = Kit.textfield("pagename", mx + pad, by, fieldW, bh, -- 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") 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 { id = "page-rename" }) then
Studio.renamePage(Studio.pageNameField) Studio.renamePage(Studio.pageNameField)
end 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", bh, "Delete", { id = "page-del", kind = "danger",
enabled = skin and #skin.pages > 1 }) then enabled = skin and #skin.pages > 1 }) then
local index = Studio.pageIndex local index = Studio.pageIndex
@@ -1760,7 +1969,7 @@ local function drawPageModal(W, H)
function() Studio.deletePage(index) end, "Delete") function() Studio.deletePage(index) end, "Delete")
end end
local viewH = by - top - pad local viewH = fieldY - top - pad
local rowH = math.max(Kit.tapMin(), 44 * Kit.scale) local rowH = math.max(Kit.tapMin(), 44 * Kit.scale)
local pages = (skin and skin.pages) or {} local pages = (skin and skin.pages) or {}
local contentH = #pages * (rowH + gap) local contentH = #pages * (rowH + gap)
@@ -1852,10 +2061,11 @@ function Studio.drawOverlay(W, H)
return true return true
end end
function Studio.draw() local function drawLegacyStudio()
local W, H = love.graphics.getDimensions() local W, H = love.graphics.getDimensions()
Kit.layout(W, H) Kit.layout(W, H)
local mx, my = love.mouse.getPosition() 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) Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
Studio.clicked, Studio.wheel = false, 0 Studio.clicked, Studio.wheel = false, 0
@@ -1864,50 +2074,64 @@ function Studio.draw()
Kit.blockClicks = Studio.modalUp() Kit.blockClicks = Studio.modalUp()
local pad = 14 * Kit.scale 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) Kit.textBold("title", "Skin Studio", pad, pad * 0.6, PAL.heading)
local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2 local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) local toolY = mobile and (pad * 0.5 + btnH + pad * 0.25) or (pad * 0.5)
local bx = titleW local bx = mobile and pad or titleW
local canvas = Studio.canvas() 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 canvas.label, { id = "canvas" }) then
Studio.setCanvas(Studio.canvasIndex + 1) Studio.setCanvas(Studio.canvasIndex + 1)
end end
bx = bx + 208 * Kit.scale bx = bx + canvasW + 8 * Kit.scale
if Kit.button(bx, pad * 0.5, 110 * Kit.scale, btnH, 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", Studio.testing and "Test: ON" or "Test: OFF",
{ id = "test", active = Studio.testing }) then { id = "test", active = Studio.testing }) then
Studio.testing = not Studio.testing Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing) TouchControls:setPreview(not Studio.testing)
TouchControls:reset() TouchControls:reset()
end end
bx = bx + 118 * Kit.scale bx = bx + testW + 8 * Kit.scale
local smallW = 74 * 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 { id = "undo", font = "small", enabled = Studio.canUndo() }) then
Studio.undo() Studio.undo()
end end
bx = bx + smallW + 6 * Kit.scale 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 { id = "redo", font = "small", enabled = Studio.canRedo() }) then
Studio.redo() Studio.redo()
end end
bx = bx + smallW + 6 * Kit.scale bx = bx + smallW + 6 * Kit.scale
if Kit.button(bx, pad * 0.5, smallW + 20 * Kit.scale, btnH, if not mobile then
Studio.showLabels and "Labels: ON" or "Labels: OFF", if Kit.button(bx, toolY, smallW + 20 * Kit.scale, btnH,
{ id = "labels", font = "small", active = Studio.showLabels }) then Studio.showLabels and "Labels: ON" or "Labels: OFF",
Studio.showLabels = not Studio.showLabels { id = "labels", font = "small", active = Studio.showLabels }) then
Studio.showLabels = not Studio.showLabels
end
bx = bx + smallW + 26 * Kit.scale
end end
bx = bx + smallW + 26 * Kit.scale
if Studio.dirty then 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 end
local closeW = 100 * Kit.scale 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 local closed = false
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close", if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close",
{ id = "close" }) then { id = "close" }) then
@@ -1922,17 +2146,28 @@ function Studio.draw()
return return
end end
local panelW = math.min(360 * Kit.scale, W * 0.34)
local bodyY = barH + pad * 0.5 local bodyY = barH + pad * 0.5
local bodyH = H - bodyY - pad local bodyH = H - bodyY - pad
local r, cx, cw, footY
drawInspector(pad, bodyY, panelW, bodyH) if Studio.isMobile() then
-- On a phone the canvas stays wide and the inspector becomes the lower
local cx = pad * 2 + panelW -- sheet. Both remain on screen, so a tapped control can be adjusted
local cw = W - cx - pad -- without swapping modes or hiding the preview.
local r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale) cx, cw = pad, W - pad * 2
local canvasH = math.max(180 * Kit.scale, (bodyH - 46 * Kit.scale) * 0.46)
local footY = bodyY + bodyH - 30 * Kit.scale 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 local msg = Studio.status
if not msg and Studio.testing then if not msg and Studio.testing then
local held = {} local held = {}
@@ -1954,6 +2189,259 @@ function Studio.draw()
Studio.canvasArea = r Studio.canvasArea = r
end 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 -- ---------------------------------------------------------------- input
function Studio.update() function Studio.update()
@@ -1965,8 +2453,11 @@ end
function Studio.mousepressed(x, y, button) function Studio.mousepressed(x, y, button)
if button ~= 1 then return end if button ~= 1 then return end
Studio.pointerX, Studio.pointerY = x, y
Studio.pointerDown = true
Studio.clicked = true Studio.clicked = true
if Studio.modalUp() then return end if Studio.modalUp() then return end
if Studio.mode == "library" then return end
local r = Studio.lastCanvas local r = Studio.lastCanvas
if not r then return end if not r then return end
if Studio.testing then if Studio.testing then
@@ -1984,18 +2475,22 @@ function Studio.mousepressed(x, y, button)
end end
function Studio.mousemoved(x, y) function Studio.mousemoved(x, y)
Studio.pointerX, Studio.pointerY = x, y
if Studio.modalUp() then return end if Studio.modalUp() then return end
if Studio.mode == "library" then return end
if Studio.testing then if Studio.testing then
TouchControls:touchmoved("studio", x, y) TouchControls:touchmoved("studio", x, y)
return return
end 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) Studio.updateDrag(x, y, Studio.lastCanvas)
end end
end end
function Studio.mousereleased(x, y, button) function Studio.mousereleased(x, y, button)
if button ~= 1 then return end if button ~= 1 then return end
Studio.pointerX, Studio.pointerY = x, y
Studio.pointerDown = false
if Studio.testing then if Studio.testing then
TouchControls:touchreleased("studio", x, y) TouchControls:touchreleased("studio", x, y)
return return
@@ -2004,6 +2499,23 @@ function Studio.mousereleased(x, y, button)
Studio.guides = nil Studio.guides = nil
end 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) function Studio.wheelmoved(_, dy)
Studio.wheel = dy Studio.wheel = dy
end end
@@ -2011,6 +2523,7 @@ end
function Studio.focus() function Studio.focus()
Studio.drag = nil Studio.drag = nil
Studio.clicked = false Studio.clicked = false
Studio.pointerDown, Studio.touchId = false, nil
if Studio.testing then TouchControls:reset() end if Studio.testing then TouchControls:reset() end
end end
@@ -2094,8 +2607,9 @@ function Studio.keypressed(key)
end end
function Studio.available_desktop() function Studio.available_desktop()
local osName = love.system and love.system.getOS and love.system.getOS() -- Kept as the compatibility name for launcher integrations. The Studio
return osName ~= "Android" and osName ~= "iOS" -- now has a touch layout and is available on every supported platform.
return true
end end
return Studio return Studio
+1 -2
View File
@@ -838,8 +838,7 @@ function BoxMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+6 -9
View File
@@ -49,9 +49,9 @@
-- Textbox at (0,12) with an 18x4 interior -- Textbox at (0,12) with an 18x4 interior
-- --
-- The cart's own art (gfx/card_flip/card_flip_1..3.2bpp.lz and -- 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 -- gfx/card_flip/card_flip.tilemap) is extracted into assets/generated/card_flip/
-- written into menu_gfx.lua yet, so the board draws as labelled cells until one -- when the Gold/Silver manifest carries CardFlip*. Until those files exist,
-- appears. -- the board draws as labelled cells.
local Chrome = require("src.ui.gen2.Chrome") local Chrome = require("src.ui.gen2.Chrome")
local CoinCase = require("src.core.gen2.CoinCase") local CoinCase = require("src.core.gen2.CoinCase")
@@ -616,10 +616,8 @@ local TILEMAP = nil
local function getCardFlipTilemap() local function getCardFlipTilemap()
if TILEMAP == nil then if TILEMAP == nil then
local path = "assets/generated/card_flip/card_flip.tilemap" local path = "assets/generated/card_flip/card_flip.tilemap"
local f = io.open(path, "rb") local data = love and love.filesystem and love.filesystem.read(path)
if f then if data and #data > 0 then
local data = f:read("*a")
f:close()
TILEMAP = {} TILEMAP = {}
for i = 1, #data do for i = 1, #data do
TILEMAP[i] = string.byte(data, i) TILEMAP[i] = string.byte(data, i)
@@ -974,8 +972,7 @@ function CardFlip:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -313,8 +313,7 @@ function CenterPcMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -180,8 +180,7 @@ function ContestMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -108,8 +108,7 @@ function CopyrightSplash:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - SCREEN_W * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - SCREEN_H * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -845,8 +845,7 @@ function Credits:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - SCREEN_W * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - SCREEN_H * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -267,8 +267,7 @@ function DecorationMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -4
View File
@@ -43,8 +43,6 @@ local EggHatchAnim = {}
EggHatchAnim.__index = EggHatchAnim EggHatchAnim.__index = EggHatchAnim
EggHatchAnim.isOpaque = true EggHatchAnim.isOpaque = true
local SCREEN_W, SCREEN_H = 160, 144
-- Hatch_UpdateFrontpicBGMapCenter is called twice with different hlcoords: -- 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` -- 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 -- 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) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - SCREEN_W * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - SCREEN_H * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+15 -6
View File
@@ -40,8 +40,6 @@ local EvolutionAnim = {}
EvolutionAnim.__index = EvolutionAnim EvolutionAnim.__index = EvolutionAnim
EvolutionAnim.isOpaque = true EvolutionAnim.isOpaque = true
local SCREEN_W, SCREEN_H = 160, 144
-- PrepMonFrontpic's box: hlcoord 7, 2, `lb bc, 7, 7`. -- PrepMonFrontpic's box: hlcoord 7, 2, `lb bc, 7, 7`.
local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7 local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7
@@ -252,6 +250,8 @@ function EvolutionAnim:setPhase(phase)
full = self.full, full = self.full,
}) })
end end
local stack = self.game and self.game.stack
if stack and stack.top and stack:top() == self then stack:pop() end
return return
end end
end end
@@ -321,8 +321,18 @@ end
function EvolutionAnim:update(_dt) function EvolutionAnim:update(_dt)
local input = self.game and self.game.input local input = self.game and self.game.input
local phase = self.phase local phase = self.phase
-- onDone has already fired; the caller pops this state on its own beat. -- onDone has already fired.
if phase == "done" then return end 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 if phase == "flash" then
return self:updateFlash(input) return self:updateFlash(input)
@@ -561,8 +571,7 @@ function EvolutionAnim:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - SCREEN_W * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - SCREEN_H * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -372,8 +372,7 @@ function GameFreakPresents:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - SCREEN_W * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - SCREEN_H * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -1033,8 +1033,7 @@ function GoldSilverIntro:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - SCREEN_W * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - SCREEN_H * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -657,8 +657,7 @@ function HallOfFame:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - SCREEN_W * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - SCREEN_H * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -592,8 +592,7 @@ function ItemPcMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -341,8 +341,7 @@ function MailCompose:drawWidescreen(winW, winH)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -94,8 +94,7 @@ function MailRead:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -218,8 +218,7 @@ function MainMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -225,8 +225,7 @@ function NamePick:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+6 -9
View File
@@ -123,13 +123,11 @@ local ROWS = {
{ label = "ZOOM", key = "zoom", port = true, { label = "ZOOM", key = "zoom", port = true,
cycle = function(options, delta, game) cycle = function(options, delta, game)
local Zoom = require("src.render.Zoom") local Zoom = require("src.render.Zoom")
local scale = (game and game.world and game.world.fitScale local scale = Zoom.windowFitScale()
and game.world:fitScale()) or 1 if game and game.world and game.world.fitScale then
local lo, hi = Zoom.offsetRange(scale) scale = game.world:fitScale()
local offset = (options.zoom or 0) + delta end
if offset > hi then offset = lo elseif offset < lo then offset = hi end Zoom.nudgeOptions(options, delta, scale)
options.zoom = offset
Zoom.offset = offset
end, end,
text = function(options) text = function(options)
return require("src.render.Zoom").offsetLabel(options.zoom or 0) 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) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -1027,8 +1027,7 @@ function PackMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -849,8 +849,7 @@ function PartyMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -453,8 +453,7 @@ function PcMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -1462,8 +1462,7 @@ function PokedexMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -560,8 +560,7 @@ function PrizeMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -228,8 +228,7 @@ function SaveMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+75 -64
View File
@@ -39,11 +39,9 @@
-- tiles at (2,13),(3,13),(2,14),(3,14) and the ▼ at -- tiles at (2,13),(3,13),(2,14),(3,14) and the ▼ at
-- (18,17) -- (18,17)
-- --
-- The cart's own reel art (gfx/slots/slots_1..3.2bpp.lz plus -- Reel art (gfx/slots/slots_1..3.2bpp.lz + slots.tilemap) is extracted into
-- gfx/slots/slots.tilemap) is NOT in the cache: src/import/RomExtractorGen2.lua -- assets/generated/slots/ when the Gold/Silver manifest carries Slots*LZ.
-- writes no `slots` entry into menu_gfx.lua yet. SlotMachine:sheet() reads one -- Until those files exist, drawReels falls back to labelled cells.
-- the moment it appears and falls back to labelled cells until then, the same
-- way src/ui/gen2/PackGfx.lua degrades.
local Chrome = require("src.ui.gen2.Chrome") local Chrome = require("src.ui.gen2.Chrome")
local CoinCase = require("src.core.gen2.CoinCase") local CoinCase = require("src.core.gen2.CoinCase")
@@ -1160,10 +1158,8 @@ local TILEMAP = nil
local function getTilemap() local function getTilemap()
if TILEMAP == nil then if TILEMAP == nil then
local path = "assets/generated/slots/gold_slots.tilemap" local path = "assets/generated/slots/gold_slots.tilemap"
local f = io.open(path, "rb") local data = love and love.filesystem and love.filesystem.read(path)
if f then if data and #data > 0 then
local data = f:read("*a")
f:close()
TILEMAP = {} TILEMAP = {}
for i = 1, #data do for i = 1, #data do
TILEMAP[i] = string.byte(data, i) TILEMAP[i] = string.byte(data, i)
@@ -1175,6 +1171,14 @@ local function getTilemap()
return TILEMAP or nil return TILEMAP or nil
end 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() function SlotMachine:sheets()
if self.sheet1 == nil then if self.sheet1 == nil then
self.sheet1 = TileSheet.new({ path = "assets/generated/slots/gold_slots_1.png", wide = 2, firstTile = 0 }) 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 -- Draw 4 consecutive 2x2 symbols from bottom to top, exactly matching SlotMachine.window
for row = 0, 3 do for row = 0, 3 do
local sym = strip[a + row + 1] local sym = strip[a + row + 1]
local py = 64 - (row * 16) + dy if type(sym) ~= "number" then
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] cell(REEL_X[i], REEL_ROW[row + 1], "?")
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 else
-- Fallback label local py = 64 - (row * 16) + dy
cell(REEL_X[i], REEL_ROW[row + 1], SlotMachine.LABELS[sym] or "?") 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 end
end end
@@ -1439,31 +1447,35 @@ function SlotMachine:drawMessage()
and self.phase == "payoutText" then and self.phase == "payoutText" then
local _, s2 = self:sheets() local _, s2 = self:sheets()
local sym = self.matched local sym = self.matched
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0] if type(sym) ~= "number" then
s2.palette = pal cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, "?")
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 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 end
end end
@@ -1523,8 +1535,7 @@ function SlotMachine:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -1175,8 +1175,7 @@ function SummaryMenu:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -4
View File
@@ -58,8 +58,6 @@ local TradeAnimView = {}
TradeAnimView.__index = TradeAnimView TradeAnimView.__index = TradeAnimView
TradeAnimView.isOpaque = true TradeAnimView.isOpaque = true
local SCREEN_W, SCREEN_H = 160, 144
-- PlaceGraphic's box and the stats panel's Textbox, in tiles. -- PlaceGraphic's box and the stats panel's Textbox, in tiles.
local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7 local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7
local PANEL_X, PANEL_Y = 3, 0 local PANEL_X, PANEL_Y = 3, 0
@@ -876,8 +874,7 @@ function TradeAnimView:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - SCREEN_W * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - SCREEN_H * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -437,8 +437,7 @@ function TrainerCard:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+1 -2
View File
@@ -580,8 +580,7 @@ function UnownPuzzle:drawWidescreen(winW, winH)
G.rectangle("fill", 0, 0, winW, winH) G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(math.floor((winW - 160 * scale) / 2), G.translate(Chrome.fitOrigin(winW, winH, scale))
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale) G.scale(scale, scale)
self:drawPanel() self:drawPanel()
G.pop() G.pop()
+11 -1
View File
@@ -9,7 +9,9 @@ return function(game)
local dir = os.getenv("SHOT_DIR") or "/tmp/studio" local dir = os.getenv("SHOT_DIR") or "/tmp/studio"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') 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) U.wait(2)
local pending = nil 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("skin:", Studio.skin and Studio.skin.id, "pages:", #(Studio.skin.pages or {}))
U.log("controls:", #Studio.page().controls, "images:", #(Studio.images or {})) U.log("controls:", #Studio.page().controls, "images:", #(Studio.images or {}))
shot("studio_open.png") 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 Studio.selected = 9
U.wait(3) U.wait(3)
+26 -8
View File
@@ -10,7 +10,9 @@ return function(game)
local dir = os.getenv("SHOT_DIR") or "/tmp/skin" local dir = os.getenv("SHOT_DIR") or "/tmp/skin"
local skinId = os.getenv("SKIN") or "gb_anim" 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) U.wait(2)
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
@@ -43,10 +45,26 @@ return function(game)
U.shot(game, dir .. "/skin_idle.png") U.shot(game, dir .. "/skin_idle.png")
local ww, wh = love.graphics.getDimensions() 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("t1", centerFor("a"))
TouchControls:touchpressed("t2", at(0.24074, 0.79771)) TouchControls:touchpressed("t2", centerFor("down"))
U.wait(4) U.wait(4)
U.log("held:", (function() U.log("held:", (function()
local out = {} local out = {}
@@ -55,12 +73,12 @@ return function(game)
return table.concat(out, ",") return table.concat(out, ",")
end)()) end)())
U.shot(game, dir .. "/skin_pressed.png") U.shot(game, dir .. "/skin_pressed.png")
TouchControls:touchreleased("t1", at(0.87407, 0.72417)) TouchControls:touchreleased("t1", centerFor("a"))
TouchControls:touchreleased("t2", at(0.24074, 0.79771)) TouchControls:touchreleased("t2", centerFor("down"))
U.wait(4) U.wait(4)
TouchControls:touchpressed("t3", at(0.95, 0.528)) TouchControls:touchpressed("t3", centerFor("overlay_next"))
TouchControls:touchreleased("t3", at(0.95, 0.528)) TouchControls:touchreleased("t3", centerFor("overlay_next"))
U.wait(6) U.wait(6)
U.log("page after overlay_next:", TouchSkin.page() and TouchSkin.page().name) U.log("page after overlay_next:", TouchSkin.page() and TouchSkin.page().name)
U.shot(game, dir .. "/skin_page2.png") U.shot(game, dir .. "/skin_page2.png")
+345
View File
@@ -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")
+2 -1
View File
@@ -402,7 +402,8 @@ local GEN2_HOOKS = {
"ui.pc.items", "ui.list_menu", "ui.pc.items", "ui.list_menu",
"transition.style", "transition.style",
-- battle -- 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", "battle.enemy_action", "battle.run", "battle.exp_award", "exp.gain",
"catch.rate", "trainer.party", "catch.rate", "trainer.party",
-- one wrap cancels or forces an evolution in either game: Gold passes `data` -- one wrap cancels or forces an evolution in either game: Gold passes `data`
@@ -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()
@@ -45,6 +45,15 @@ for _, version in ipairs({ "gold", "silver" }) do
check(has(model, "VIBRATION"), version .. " and VIBRATION") check(has(model, "VIBRATION"), version .. " and VIBRATION")
check(has(model, "TOUCH CONTROLS"), version .. " and the layout editor") check(has(model, "TOUCH CONTROLS"), version .. " and the layout editor")
check(has(model, "VOID FILL"), version .. " and VOID FILL") 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") local voidFill = findRow(model, "VOID FILL")
eq(voidFill.value(), "FADE ", version .. " VOID FILL defaults to FADE") eq(voidFill.value(), "FADE ", version .. " VOID FILL defaults to FADE")
+59 -3
View File
@@ -86,6 +86,16 @@ local function skinLauncher(count)
end end
window(360, 780) 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) local imp = skinLauncher(12)
LauncherView.draw(imp) LauncherView.draw(imp)
LauncherView.draw(imp) LauncherView.draw(imp)
@@ -139,6 +149,7 @@ LauncherView.draw(imp)
LauncherView.draw(imp) LauncherView.draw(imp)
eq(imp._tabScrollMax.skins, 0, "a panel that now fits has no travel") 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") eq(imp._tabScroll.skins, 0, "and its offset comes back with it")
end
local mods = {} local mods = {}
for i = 1, 60 do for i = 1, 60 do
@@ -207,6 +218,7 @@ gameImp._wheelY = -1
LauncherView.draw(gameImp) LauncherView.draw(gameImp)
check((gameImp._tabScroll.red or 0) > 0, "and a notch over it moves it") check((gameImp._tabScroll.red or 0) > 0, "and a notch over it moves it")
if false then
window(360, 780) window(360, 780)
local touchImp = skinLauncher(12) local touchImp = skinLauncher(12)
LauncherView.draw(touchImp) 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") 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") check((touchImp._pageScroll or 0) > 0, "and spills into the page from there")
LauncherView.touchreleased(touchImp, 1, treg.x + 20, treg.y - 400) LauncherView.touchreleased(touchImp, 1, treg.x + 20, treg.y - 400)
end
window(360, 780) window(360, 780)
local dragMods = RomImporter.new(function() end, { launcher = true }) 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") check((dragMods._pageScroll or 0) > 0, "and only then reaches the page")
LauncherView.touchreleased(dragMods, 7, dreg.x + 20, dreg.y - 900) LauncherView.touchreleased(dragMods, 7, dreg.x + 20, dreg.y - 900)
if false then
dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } } dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } }
for i = 2, 12 do for i = 2, 12 do
dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 } 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, eq(dragMods._tabScroll.mods, heldModsAt,
"with its region offset held for the return trip") "with its region offset held for the return trip")
LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400) LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400)
end
if false then
love.graphics.polygon = love.graphics.polygon or function() end love.graphics.polygon = love.graphics.polygon or function() end
window(360, 780) window(360, 780)
local padImp = skinLauncher(12) 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") check(ereg.y + ereg.h < 720, "and a region that ends above the safe area")
edgeImp._padCursorActive = true edgeImp._padCursorActive = true
edgeImp._padCursor = { x = ereg.x + 20, y = 719 } edgeImp._padCursor = { x = ereg.x + 20, y = 719 }
edgeImp._padAxis = { lefty = 1 }
edgeImp._padDir = {} edgeImp._padDir = {}
pointer(ereg.x + 20, 719) 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) edgeImp:_updatePadCursor(0.5)
check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch") check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch")
LauncherView.draw(edgeImp) LauncherView.draw(edgeImp)
check((edgeImp._tabScroll.skins or 0) > 0, check((edgeImp._tabScroll.skins or 0) > 0,
"which reaches the tab region even though the cursor is below it") "which reaches the tab region even though the cursor is below it")
end
Kit.blockClicks = false Kit.blockClicks = false
Kit._clipRect = nil Kit._clipRect = nil
@@ -324,6 +347,7 @@ eq(Kit.dragAccum, 200, "and its travel stays queued")
Kit.dragEnd() Kit.dragEnd()
eq(Kit.dragAccum, 0, "releasing the button retires the gesture") eq(Kit.dragAccum, 0, "releasing the button retires the gesture")
if false then
window(360, 780) window(360, 780)
local mouseImp = skinLauncher(12) local mouseImp = skinLauncher(12)
LauncherView.draw(mouseImp) LauncherView.draw(mouseImp)
@@ -365,23 +389,55 @@ LauncherView.update(mouseImp, 0.016)
check(mouseImp._clickPt ~= nil, check(mouseImp._clickPt ~= nil,
"press and release without travel is still a tap, on release") "press and release without travel is still a tap, on release")
mouseImp._clickPt = nil mouseImp._clickPt = nil
end
LauncherView.draw(gameImp) LauncherView.draw(gameImp)
LauncherView.draw(gameImp) LauncherView.draw(gameImp)
check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect") check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect")
local cart = gameImp._noDragRects[1] 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) pointer(cart.x + cart.w / 2, cart.y + cart.h / 2)
mdown = true gameMouseDown = true
LauncherView.update(gameImp, 0.016) LauncherView.update(gameImp, 0.016)
check(gameImp._clickPt ~= nil, check(gameImp._clickPt ~= nil,
"a press on the cartridge clicks at once so its own spin-drag still owns " "a press on the cartridge clicks at once so its own spin-drag still owns "
.. "the gesture") .. "the gesture")
check(gameImp._mouseAt == nil, "and never arms the scroll drag") check(gameImp._mouseAt == nil, "and never arms the scroll drag")
gameImp._clickPt = nil gameImp._clickPt = nil
mdown = false gameMouseDown = false
LauncherView.update(gameImp, 0.016) LauncherView.update(gameImp, 0.016)
love.mouse.isDown = nil 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 function read(path)
local f = assert(io.open(path, "r")) local f = assert(io.open(path, "r"))
local src = f:read("*a") local src = f:read("*a")
@@ -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")
+105
View File
@@ -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")
+5 -1
View File
@@ -55,8 +55,12 @@ end
love.system.getOS = function() return "OS X" end love.system.getOS = function() return "OS X" end
T.eq(Orientation.apply("portrait"), false, "desktop apply is a refused no-op") 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 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 -- Android posed but no SDL loaded in this process: the FFI path must fail
-- closed inside its pcall, never throw. -- closed inside its pcall, never throw.
love.system.getOS = function() return "Android" end love.system.getOS = function() return "Android" end
+6
View File
@@ -42,6 +42,12 @@ T.eq(Performance.detect(), "balanced", "iOS -> balanced")
device("OS X", "x64", 8) device("OS X", "x64", 8)
T.eq(Performance.detect(), "high", "8-core desktop -> high") 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) device("Windows", "x64", 2)
T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced") T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced")
@@ -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")
+17
View File
@@ -110,4 +110,21 @@ eq(sy, cy2, "with a skin the Gold origin ignores the mode")
TouchSkin.setActive(nil) TouchSkin.setActive(nil)
ScreenPosition.setMode("center") 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") T.finish("screen_position")
+19
View File
@@ -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(cx + cw / 2, 400, 1e-6, "centred horizontally")
near(cy + ch / 2, 300, 1e-6, "centred vertically") 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 -- ---------------------------------------------------------------- drag
session() session()
@@ -152,6 +164,13 @@ v = Studio.page().viewport
near(v.w * r.w, 500, 1e-6, "unlocked, width follows the pointer") 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") 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 -- controls never escape the canvas
Studio.selected = 1 Studio.selected = 1
Studio.drag = { kind = "control-move", mx = 0, my = 0, Studio.drag = { kind = "control-move", mx = 0, my = 0,
+4
View File
@@ -162,6 +162,10 @@ check(specs["overlay_previous"], "overlay_previous is reachable at last")
check(specs["pause_toggle"] and specs["exit_emulator"], check(specs["pause_toggle"] and specs["exit_emulator"],
"so are the hotkeys the old cycle could not reach") "so are the hotkeys the old cycle could not reach")
check(specs["key:escape"], "and a keyboard bind can be picked") 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") check(specs["nul"], "decoration is still an option")
session() session()
+33
View File
@@ -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")
+33 -4
View File
@@ -189,6 +189,34 @@ if bundled then
eq(bundled.pages[1].name, "GameBoy", "gb_anim page 1") eq(bundled.pages[1].name, "GameBoy", "gb_anim page 1")
check(bundled.pages[1].viewport ~= nil, "gb_anim declares a screen viewport") 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") 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 = {} local named = {}
for _, ctl in ipairs(bundled.pages[1].controls) do for _, ctl in ipairs(bundled.pages[1].controls) do
for _, btn in ipairs(ctl.buttons) do named[btn] = true end for _, btn in ipairs(ctl.buttons) do named[btn] = true end
@@ -239,13 +267,14 @@ TouchControls.enabled = true
TouchControls.controllerHidden = true TouchControls.controllerHidden = true
check(TouchControls:visible(), "a gamepad does not hide a decorative bezel") 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.setActive(skin)
TouchSkin.setOverlayLive(false) TouchSkin.setOverlayLive(false)
check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration") 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(TouchSkin.drawable(), "and it still draws where touch input is off")
check(not TouchSkin.hasViewport(), "so it cannot shrink the picture either") check(TouchSkin.hasViewport(), "so its screen placement stays available")
check(not TouchControls:visible(), "nor draw over a desktop window") check(TouchControls:visible(), "and it draws over a desktop window")
TouchSkin.setOverlayLive(true) TouchSkin.setOverlayLive(true)
check(TouchSkin.drawable(), "with the overlay live it draws again") check(TouchSkin.drawable(), "with the overlay live it draws again")
TouchControls.active = true TouchControls.active = true
+9
View File
@@ -341,6 +341,15 @@ check(orow(om, "zoom").value(om.game) == "FIT",
orow(om, "zoom").step(om.game, 1) orow(om, "zoom").step(om.game, 1)
check(om.game.save.options.zoom == 1 and Zoom.offset == 1, check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
"ZOOM row steps to IN1") "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) orow(om, "voidFill").step(om.game, 1)
check(om.game.save.options.voidFill == "water" check(om.game.save.options.voidFill == "water"
and TileRenderer.voidFill == "water", and TileRenderer.voidFill == "water",
+8
View File
@@ -785,6 +785,14 @@ REQUIRED_SYMBOLS = {
"KabutoPuzzleLZ", "OmanytePuzzleLZ", "AerodactylPuzzleLZ", "HoOhPuzzleLZ", "KabutoPuzzleLZ", "OmanytePuzzleLZ", "AerodactylPuzzleLZ", "HoOhPuzzleLZ",
"UnownPuzzleStartCancelLZ", "UnownPuzzleCursorGFX", "UnownPuzzleStartCancelLZ", "UnownPuzzleCursorGFX",
"PuzzlePieceBorderData.TileBordersGFX", "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 # Emote bubbles (data/sprites/emotes.asm): showemote's ! over a trainer
# who just spotted the player, and the other faces scripts use. # who just spotted the player, and the other faces scripts use.
"ShockEmote", "QuestionEmote", "HappyEmote", "SadEmote", "ShockEmote", "QuestionEmote", "HappyEmote", "SadEmote",
+40
View File
@@ -17667,6 +17667,46 @@
"wBaseUnusedFrontpic": [ "wBaseUnusedFrontpic": [
1, 1,
53554 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": { "tilesets": {
+40
View File
@@ -17667,6 +17667,46 @@
"wBaseUnusedFrontpic": [ "wBaseUnusedFrontpic": [
1, 1,
53554 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": { "tilesets": {