mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 13:36:14 +02:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe3746fd1a | |||
| 0e407dca7a | |||
| b0ff1552f2 | |||
| d191aaa34d | |||
| a1a70540b8 | |||
| 5c1837b1eb | |||
| bb0f156497 | |||
| 4b0496bad1 | |||
| a56add6d17 | |||
| 48b39c8519 | |||
| decaa006b2 | |||
| c0bd4a35be | |||
| 8c0d0ace4d | |||
| c11c762f15 | |||
| c465f58006 | |||
| 780246c4f6 | |||
| 5714555847 | |||
| 738f7317d7 | |||
| 911e11a372 | |||
| 2d28d18bf6 | |||
| 0b70d6c535 |
@@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"`
|
||||
when you mean everywhere.
|
||||
|
||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
||||
and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
|
||||
and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1,
|
||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
||||
writes with a report, and which hooks and events are still to come.
|
||||
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||
|
||||
@@ -24,7 +24,7 @@ The short version, for an author deciding what to write:
|
||||
merged.** The write is taken, dropped, and named once per mod in the same
|
||||
error feed the mod manager shows -- in both directions, so a Red boot writing
|
||||
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
||||
- **40 event names and 43 hook names have a call site in both generations**, so
|
||||
- **40 event names and 44 hook names have a call site in both generations**, so
|
||||
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
||||
reads those names back out of the source and fails if a site is renamed or
|
||||
deleted on either side, and fails again if a new shared site appears without
|
||||
@@ -539,7 +539,8 @@ gains a field instead of the name gaining a prefix.
|
||||
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
||||
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.charge_required`,
|
||||
`battle.turn_order`,
|
||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||
|
||||
+57
-5
@@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file`
|
||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||
`description` gives players dump or region guidance in the import panel.
|
||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||
per-import ceiling when an exact size is not appropriate. Every import also
|
||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
||||
filesystem reports an invalid size.
|
||||
per-import ceiling when an exact size is not appropriate. The engine hard limit
|
||||
is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and
|
||||
use the launcher's streaming large-file path rather than being materialized as
|
||||
one Lua string.
|
||||
|
||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||
to `mods/<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
|
||||
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
||||
because its manifest names the same digest. Small sources can still be read
|
||||
with the existing scoped `mod:read` API, for example
|
||||
`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded
|
||||
`mod.imports` facade described below; no host path or new general filesystem
|
||||
permission is exposed. Missing `required_imports` block the mod before its
|
||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||
launcher panel but do not block loading.
|
||||
|
||||
#### Bounded access to validated imports
|
||||
|
||||
A loaded mod can address only ids declared by its own `required_imports` or
|
||||
`optional_imports` arrays:
|
||||
|
||||
```lua
|
||||
local info, err = mod.imports:info("stadium2")
|
||||
local header, err = mod.imports:read("stadium2", 0, 4096)
|
||||
```
|
||||
|
||||
`read` uses zero-based offsets and is capped at 8 MiB per call. The engine
|
||||
rechecks the stored import before exposing it, seeks into the engine-owned
|
||||
copy, and never gives the mod a host path or file handle. This is intended for
|
||||
large source formats whose table/index can be parsed with small reads before
|
||||
selectively reading the payloads a transform actually needs.
|
||||
|
||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||
@@ -441,6 +460,28 @@ default** (1x front, 2x back).
|
||||
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||
mon still grows into place from the ball, grounded the whole way.
|
||||
|
||||
## Installation-scoped generated cache
|
||||
|
||||
Generated data derived from a validated user source often belongs to the mod
|
||||
installation rather than to one Pokémon save. `mod.cache` is that namespace:
|
||||
|
||||
```lua
|
||||
local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena)
|
||||
local bytes, err = mod.cache:read("extract/v1/arena.bin")
|
||||
local info = mod.cache:info("extract/v1/arena.bin")
|
||||
mod.cache:delete("extract/v1/arena.bin")
|
||||
```
|
||||
|
||||
The physical root is engine-owned (`mod_cache/<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
|
||||
|
||||
`mod.save` remains the right place for state that should travel with the next
|
||||
@@ -633,6 +674,17 @@ the selected indices. Mods remain responsible for selection policy and should
|
||||
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||
contract and compatibility guarantees.
|
||||
|
||||
Both battle engines expose the guarded `battle.charge_required` hook when a
|
||||
charge-capable move is selected for its initial turn and the active ruleset
|
||||
would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is
|
||||
`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to
|
||||
skip only that initial charge and continue through the ordinary move pipeline;
|
||||
call `next(ctx)` to keep it. The hook does not run for the release turn or when
|
||||
the active ruleset already skips charging (for example, Gold Solarbeam in
|
||||
sun). PP use, accuracy, damage, animation, and secondary effects remain owned
|
||||
by the engine. With no subscriber, the vanilla decision runs without building
|
||||
the hook context.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -1,11 +1,11 @@
|
||||
# Touch skins and the Skin Studio
|
||||
|
||||
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
||||
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||
control layout, and a screen-placement anchor. Engine:
|
||||
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
||||
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
||||
(draw and input), `src/render/Renderer.lua` (screen placement),
|
||||
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
|
||||
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
|
||||
`src/ui/SkinStudio.lua` (the responsive skin editor). Tests:
|
||||
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
||||
`tests/engine/skin_studio_ux.lua`,
|
||||
`tests/engine/skin_studio_image_import.lua`,
|
||||
@@ -13,8 +13,10 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||
`tests/engine/launcher_skins_tab.lua`,
|
||||
`tests/engine/launcher_skins_ux.lua`.
|
||||
|
||||
Skins are picked in the launcher's **Skins** tab, which also imports them and
|
||||
opens the studio. `options.touchControls.skin` holds the folder name.
|
||||
The launcher's **Skins** tab imports skins, shows the enabled skin, exports it,
|
||||
and is the one place that turns skin use off. **My Skins** holds the visual
|
||||
grid, pagination, edit, delete and per-skin export actions.
|
||||
`options.touchControls.skin` holds the folder name.
|
||||
|
||||
## Formats
|
||||
|
||||
@@ -30,7 +32,7 @@ as-is. Supported keys:
|
||||
| `overlays` | page count |
|
||||
| `overlayN_name` | page name, the target of `next_target` |
|
||||
| `overlayN_overlay` | bezel image |
|
||||
| `overlayN_full_screen` | stretch the page to the window |
|
||||
| `overlayN_full_screen` | cover the window with the page without deforming its artwork |
|
||||
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
||||
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
||||
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
||||
@@ -98,8 +100,8 @@ corners fire two directions. `screens[1].outputFrame` (or the legacy
|
||||
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
||||
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
||||
window, and puts the Game Boy picture in the leftover space above -- the
|
||||
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
|
||||
stretch to the window the way Delta does. Host functions map to
|
||||
usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the
|
||||
game into it. Host functions map to
|
||||
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
||||
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
||||
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
||||
@@ -135,7 +137,7 @@ to decoration and never captures a touch.
|
||||
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
||||
how a skin button reaches a mod hotkey.
|
||||
|
||||
## The screen viewport
|
||||
## Screen placement
|
||||
|
||||
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
||||
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
||||
@@ -146,6 +148,17 @@ that lets a widescreen bezel take the filling survey-zoom world view instead.
|
||||
A viewport also implies the faithful-ratio lock. Without it the world pass
|
||||
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
||||
|
||||
Zoom still steps around that hole: OUT shows more map inside it, IN enlarges
|
||||
the world, and the start menu stays at the hole's fit scale instead of
|
||||
shrinking with the map.
|
||||
|
||||
An image-backed portrait overlay that has no explicit vertical anchor is treated
|
||||
as a controller deck: it is contained without deformation and pinned to the
|
||||
bottom on taller screens. The spare space belongs to the game above it.
|
||||
|
||||
When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the
|
||||
skin rather than the normal Center / Upper / Top setting.
|
||||
|
||||
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
||||
screen from bezel** in the studio measures the hole out of the art's alpha
|
||||
channel and writes the rect.
|
||||
@@ -153,10 +166,8 @@ channel and writes the rect.
|
||||
## Bezels versus pads
|
||||
|
||||
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
||||
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
|
||||
as well, where the touch overlay itself does not, and a gamepad does not hide
|
||||
them. Anything that binds a button still follows the usual mobile /
|
||||
`POKEPORT_TOUCH` rule.
|
||||
surround, a handheld shell, a Super Game Boy border. Selected skins draw on
|
||||
**desktop** as well as mobile; a gamepad does not hide them.
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -190,9 +201,20 @@ shipping branding.
|
||||
|
||||
## The studio
|
||||
|
||||
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
|
||||
that skin. Desktop only: the launcher does not offer it on Android or iOS,
|
||||
because it wants a mouse, typed coordinates and room for an inspector.
|
||||
Launcher, Skins tab, **My Skins** opens the Studio library on desktop and
|
||||
mobile. **My Skins** is the only visual grid: real bezel previews plus create
|
||||
and import actions, with each card owning Edit, Export and (for installed
|
||||
skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old
|
||||
New/Load workspace controls are deliberately not duplicated inside that editor.
|
||||
|
||||
The editor keeps its canvas unobstructed and puts the contextual actions in a
|
||||
compact lower tray: add/control binding, button and bezel artwork, pages,
|
||||
screen placement, freeform/10:9 screen shape and deletion. Touches select, drag and resize the
|
||||
same controls that a mouse edits on desktop.
|
||||
|
||||
The launcher’s **Turn skins off** button clears the selected skin and disables
|
||||
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
|
||||
is not itself a skin card.
|
||||
|
||||
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
||||
phone proportions on a desktop monitor.
|
||||
@@ -216,13 +238,16 @@ and of the page itself when it comes within a few pixels, and the guide it
|
||||
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
|
||||
typed to the coordinate its art was drawn at. **Back** and **Front** move the
|
||||
selection through the draw order. Bind, hitbox shape, hit reach and idle and
|
||||
pressed images are per control; the bezel, the pages and the screen cutout are
|
||||
per page. The cutout is itself a draggable element with a 10:9 lock.
|
||||
pressed images are per control; the bezel, the pages and the screen anchor are
|
||||
per page. The SCREEN anchor is itself draggable and resizable; its default
|
||||
shape is freeform, with an optional 10:9 lock.
|
||||
|
||||
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
|
||||
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and
|
||||
decoration. The COMBINE chips at the top toggle one part at a time, which is
|
||||
how a pipe bind like `left|down` is built without typing it.
|
||||
buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration.
|
||||
The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10`
|
||||
as `key:` controls, so a mobile button invokes the exact same game path as
|
||||
its desktop shortcut. The COMBINE chips at the top toggle one part at a time,
|
||||
which is how a pipe bind like `left|down` is built without typing it.
|
||||
|
||||
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
|
||||
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
|
||||
@@ -247,12 +272,13 @@ the **Import** button there and beside each row opens the host file picker (`src
|
||||
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
||||
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
||||
window does the same for whichever slot was last touched. A new bezel does not
|
||||
move the screen cutout: press **Detect screen from bezel** to measure it out of
|
||||
move the screen anchor: press **Detect screen from bezel** to measure it out of
|
||||
the art's alpha.
|
||||
|
||||
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
|
||||
buttons and the footer reports what is held. **Play** saves the skin, selects
|
||||
it, and boots the game with it.
|
||||
**Testing.** **Test** renders a game-composition preview behind the live overlay:
|
||||
the 160x144 picture letterboxes inside the screen cutout, matching gameplay.
|
||||
Clicking presses real Game Boy buttons and the footer reports what is held.
|
||||
**Play** saves the skin, selects it, and boots the game with it.
|
||||
|
||||
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
||||
skin names, so the folder stands alone. **Export** offers three formats, and
|
||||
|
||||
@@ -302,6 +302,8 @@ local function makeLauncher()
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
-- Skin Studio owns a touch-first layout as well as the desktop workspace.
|
||||
-- Keep the compatibility predicate so external hosts using it still work.
|
||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||
and openSkinStudio or nil,
|
||||
})
|
||||
@@ -877,6 +879,8 @@ love.handlers = love.handlers or {}
|
||||
function love.handlers.audiosuspend()
|
||||
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
||||
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
|
||||
local Sound = package.loaded["src.core.Sound"]
|
||||
if Sound then pcall(Sound.onDeviceReset) end
|
||||
end
|
||||
|
||||
function love.handlers.audioreset()
|
||||
@@ -929,7 +933,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchpressed(id, x, y)
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.touchpressed(id, x, y) end
|
||||
if Importer then
|
||||
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
||||
-- polled inside the view; the istouch filter on mousepressed still drops
|
||||
@@ -946,7 +950,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchmoved(id, x, y)
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.touchmoved(id, x, y) end
|
||||
if Importer then
|
||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
@@ -960,7 +964,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchreleased(id, x, y)
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.touchreleased(id, x, y) end
|
||||
if Importer then
|
||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
@@ -1013,7 +1017,12 @@ function love.mousepressed(x, y, button, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousepressed(x, y, button)
|
||||
end
|
||||
if Studio then return Studio.mousepressed(x, y, button) end
|
||||
if Studio then
|
||||
-- Mobile LÖVE sends both a touch event and an `istouch` mouse twin.
|
||||
-- Studio consumes the real finger stream above, so discard the twin.
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousepressed(x, y, button)
|
||||
end
|
||||
if Importer then
|
||||
-- love.touchpressed already forwards the primary touch into FlexLove for
|
||||
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
||||
@@ -1048,7 +1057,10 @@ function love.mousereleased(x, y, button, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousereleased(x, y, button)
|
||||
end
|
||||
if Studio then return Studio.mousereleased(x, y, button) end
|
||||
if Studio then
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousereleased(x, y, button)
|
||||
end
|
||||
if Importer then return end
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
@@ -1066,7 +1078,10 @@ function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
end
|
||||
if Studio then return Studio.mousemoved(x, y) end
|
||||
if Studio then
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousemoved(x, y)
|
||||
end
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch then
|
||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity"
|
||||
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:launchMode="singleTask"
|
||||
android:screenOrientation="${ORIENTATION}"
|
||||
@@ -71,7 +71,7 @@
|
||||
</activity>
|
||||
<activity
|
||||
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:exported="false"
|
||||
android:launchMode="singleTask"
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"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",
|
||||
"date": "2026-08-20",
|
||||
|
||||
@@ -3980,7 +3980,17 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
-- record (chargeText) and the invulnerability from semiInvulnerable,
|
||||
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
||||
-- 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()
|
||||
user.charging = moveInst
|
||||
user.chargeReady = true
|
||||
|
||||
@@ -1494,6 +1494,15 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
|
||||
charge = nil
|
||||
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
|
||||
state.chargeMove = moveId
|
||||
state.vanished = charge.vanish or nil
|
||||
|
||||
+1
-10
@@ -919,16 +919,7 @@ function Game:gamepadaxis(joystick, axis, value)
|
||||
Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
||||
-- generic joystick path anyway: any sensor-style device that still reaches us
|
||||
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
||||
-- overlay every instant and steer the player by tilt through the axis-1/2
|
||||
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
||||
-- never as "* Accelerometer".
|
||||
local function isAccelerometer(joystick)
|
||||
local name = joystick and joystick.getName and joystick:getName()
|
||||
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
||||
end
|
||||
local isAccelerometer = GamepadMap.isAccelerometer
|
||||
|
||||
-- BindingsMenu's raw-stick capture rides the same top-state routing as the
|
||||
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
|
||||
|
||||
+101
-24
@@ -18,6 +18,7 @@ local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Clock = require("src.core.gen2.Clock")
|
||||
local FixedStep = require("src.core.FixedStep")
|
||||
local Font = require("src.render.Font")
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
local Input = require("src.core.Input")
|
||||
local Music = require("src.core.Music")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
@@ -57,20 +58,6 @@ Game2.__index = Game2
|
||||
|
||||
local function noop() end
|
||||
|
||||
for _, name in ipairs({
|
||||
"joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
|
||||
"joystickadded",
|
||||
}) do
|
||||
Game2[name] = noop
|
||||
end
|
||||
|
||||
-- Not a noop, because the overlay has to come back on its own: a player who
|
||||
-- unplugs the only controller would otherwise have to tap a blind screen to
|
||||
-- get the pad back (src/core/Game.lua:869 does the same).
|
||||
function Game2:joystickremoved()
|
||||
TouchControls:joystickremoved()
|
||||
end
|
||||
|
||||
-- THE FRAME AND INPUT SEAMS.
|
||||
--
|
||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
||||
@@ -1968,7 +1955,11 @@ function Game2:applyOptions()
|
||||
local options = self.options or {}
|
||||
Music.applyOptions(options)
|
||||
require("src.core.Sound").applyOptions(options)
|
||||
require("src.render.Zoom").applyOptions(options)
|
||||
local Zoom = require("src.render.Zoom")
|
||||
Zoom.applyOptions(options)
|
||||
local caps = require("src.core.Performance").applyOptions(options)
|
||||
Zoom.allowSurvey = caps.survey
|
||||
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||
require("src.render.Tilt").applyOptions(options)
|
||||
require("src.render.GbcPalette").applyOptions(options)
|
||||
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
|
||||
@@ -1993,6 +1984,13 @@ function Game2:applyOptions()
|
||||
end
|
||||
end
|
||||
|
||||
function Game2:_cycleSpeed(dir)
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
|
||||
if self.save then self.save.options = self.options end
|
||||
self:persistOptions()
|
||||
end
|
||||
|
||||
-- `back` -- SDL's name for the small left-hand menu button: Xbox VIEW, the PS
|
||||
-- CREATE/SHARE beside the touchpad, the Switch MINUS -- is SELECT, and has been
|
||||
-- since src/core/GamepadMap.lua's DEFAULT_GAMEPAD_BINDINGS was written
|
||||
@@ -2003,27 +2001,49 @@ end
|
||||
-- the PACK's move-item, the party menu's reorder and half the soft-reset chord
|
||||
-- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button
|
||||
-- to find out killed the process. It reaches Input like every other button now.
|
||||
function Game2:gamepadpressed(_joystick, button)
|
||||
function Game2:gamepadpressed(joystick, button)
|
||||
-- a controller is being used: the touch overlay steps aside until the next
|
||||
-- screen touch (mobile only; a no-op elsewhere)
|
||||
TouchControls:noteGamepad()
|
||||
-- The shoulders cycle GAME SPEED, as they do in the Gen 1 path.
|
||||
if button == "rightshoulder" or button == "leftshoulder" then
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local dir = button == "rightshoulder" and 1 or -1
|
||||
self.options.speed = GameSpeed.cycle(self.options.speed, dir)
|
||||
if self.save then self.save.options = self.options end
|
||||
self:persistOptions()
|
||||
local selectHeld = Input:isDown("select")
|
||||
if not selectHeld and joystick and joystick.isGamepadDown then
|
||||
local ok, down = pcall(function()
|
||||
return joystick:isGamepadDown("back")
|
||||
end)
|
||||
selectHeld = ok and down == true
|
||||
end
|
||||
-- shoulders and triggers cycle GAME SPEED, as in src/core/Game.lua:881
|
||||
if not selectHeld then
|
||||
if button == "rightshoulder" or button == "righttrigger" then
|
||||
self:_cycleSpeed(1)
|
||||
return
|
||||
elseif button == "leftshoulder" or button == "lefttrigger" then
|
||||
self:_cycleSpeed(-1)
|
||||
return
|
||||
end
|
||||
end
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onGamepadPressed then
|
||||
top:onGamepadPressed(button)
|
||||
return
|
||||
end
|
||||
if selectHeld then
|
||||
local digit = GamepadMap.displayChordDigit(button)
|
||||
if digit then
|
||||
self:keypressed(digit)
|
||||
return
|
||||
end
|
||||
end
|
||||
-- START opens the start menu in the overworld; it used to quit, from before
|
||||
-- there was a menu to open.
|
||||
|
||||
Input:gamepadpressed(_joystick, button)
|
||||
Input:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Game2:gamepadreleased(joystick, button)
|
||||
Input:gamepadreleased(joystick, button)
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
|
||||
end
|
||||
|
||||
function Game2:gamepadaxis(joystick, axis, value)
|
||||
@@ -2032,4 +2052,61 @@ function Game2:gamepadaxis(joystick, axis, value)
|
||||
Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
-- The raw joystick road, same bodies as src/core/Game.lua:935 (#620, #632, #1570).
|
||||
local function isRawStick(joystick)
|
||||
return not (joystick and joystick.isGamepad and joystick:isGamepad())
|
||||
end
|
||||
|
||||
function Game2:joystickpressed(joystick, button)
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
TouchControls:noteGamepad()
|
||||
local top = self.stack and self.stack:top()
|
||||
if isRawStick(joystick) and top and top.onJoystickPressed then
|
||||
top:onJoystickPressed(button)
|
||||
return
|
||||
end
|
||||
Input:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Game2:joystickreleased(joystick, button)
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
Input:joystickreleased(joystick, button)
|
||||
local top = self.stack and self.stack:top()
|
||||
if isRawStick(joystick) and top and top.onJoystickReleased then
|
||||
top:onJoystickReleased(button)
|
||||
end
|
||||
end
|
||||
|
||||
function Game2:joystickaxis(joystick, axis, value)
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
||||
Input:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function Game2:joystickhat(joystick, hat, direction)
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
if direction ~= "c" then TouchControls:noteGamepad() end
|
||||
Input:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
-- src/core/Game.lua:1015 (#799)
|
||||
function Game2:recoverInput()
|
||||
Input:reset()
|
||||
Input:reconcile()
|
||||
TouchControls:reset()
|
||||
if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end
|
||||
self:cancelPointers()
|
||||
end
|
||||
|
||||
function Game2:joystickadded()
|
||||
self:recoverInput()
|
||||
end
|
||||
|
||||
-- The overlay comes back on its own when the last pad is unplugged
|
||||
-- (src/core/Game.lua:1044).
|
||||
function Game2:joystickremoved()
|
||||
self:recoverInput()
|
||||
TouchControls:joystickremoved()
|
||||
end
|
||||
|
||||
return Game2
|
||||
|
||||
@@ -106,6 +106,17 @@ function GamepadMap.ignoreRawForJoystick(joystick)
|
||||
return ok and isPad == true
|
||||
end
|
||||
|
||||
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
||||
-- generic joystick path anyway: any sensor-style device that still reaches us
|
||||
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
||||
-- overlay every instant and steer the player by tilt through the axis-1/2
|
||||
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
||||
-- never as "* Accelerometer".
|
||||
function GamepadMap.isAccelerometer(joystick)
|
||||
local name = joystick and joystick.getName and joystick:getName()
|
||||
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
||||
end
|
||||
|
||||
function GamepadMap.mapRawButton(index)
|
||||
if nxActive() then
|
||||
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
|
||||
|
||||
+6
-1
@@ -281,6 +281,7 @@ end
|
||||
|
||||
function Input:joystickpressed(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
noteCapture(self, "joy", "pressed", button)
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then press(self, btn, "joy:" .. button) end
|
||||
@@ -288,6 +289,7 @@ end
|
||||
|
||||
function Input:joystickreleased(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
noteCapture(self, "joy", "released", button)
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then release(self, btn, "joy:" .. button) end
|
||||
@@ -330,6 +332,7 @@ end
|
||||
|
||||
function Input:joystickaxis(joystick, axis, value)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
@@ -343,6 +346,7 @@ end
|
||||
-- directions on top of a direction rebind.
|
||||
function Input:joystickhat(joystick, hat, direction)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
local source = "hat:" .. hat
|
||||
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
||||
release(self, btn, source)
|
||||
@@ -380,7 +384,8 @@ function Input:reconcile()
|
||||
local ok, joysticks = pcall(js.getJoysticks)
|
||||
if not ok or type(joysticks) ~= "table" then return end
|
||||
for _, j in ipairs(joysticks) do
|
||||
if GamepadMap.ignoreRawForJoystick(j) then
|
||||
if GamepadMap.isAccelerometer(j) then
|
||||
elseif GamepadMap.ignoreRawForJoystick(j) then
|
||||
-- SDL-recognized pad: buttons + left stick, the gamepad surfaces
|
||||
if j.isGamepadDown then
|
||||
for button, btn in pairs(self.padBindings) do
|
||||
|
||||
+79
-23
@@ -1,4 +1,4 @@
|
||||
-- Screen orientation lock, Android only (#592, #716).
|
||||
-- Screen orientation lock, Android and iOS (#592, #716, #1638).
|
||||
--
|
||||
-- Persisted as options.orientation: "auto" | "portrait" | "landscape" |
|
||||
-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS:
|
||||
@@ -10,16 +10,12 @@
|
||||
-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE ->
|
||||
-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone.
|
||||
--
|
||||
-- SDL only re-reads the hint when the window is created or its resizable
|
||||
-- flag changes (SDL_androidwindow.c: Android_CreateWindow /
|
||||
-- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE
|
||||
-- 11.5 exposes neither hints nor a resizable setter, so apply() goes through
|
||||
-- the FFI to SDL's C API: set the hint, then pulse the window's resizable
|
||||
-- flag off and back on -- each edge makes the Android backend recompute the
|
||||
-- requested orientation, so a change from the launcher or the OPTION menu
|
||||
-- takes hold immediately, and the flag ends where it started (conf.lua sets
|
||||
-- resizable on mobile). Everything is pcall-guarded: desktop, iOS (the
|
||||
-- Info.plist governs there) and headless stubs make this a no-op.
|
||||
-- Android only re-reads the hint at window creation or on a resizable-flag
|
||||
-- change (SDL_androidwindow.c), and SDL_SetWindowResizable early-returns on
|
||||
-- a fullscreen window (SDL_video.c:2237) -- which LOVE's Android window
|
||||
-- always is -- so the hint never reached a running activity (#1638).
|
||||
-- apply() sets the hint for a later window, then goes over JNI for the live
|
||||
-- one. iOS needs only the hint. Desktop and headless stubs no-op.
|
||||
|
||||
local Orientation = {}
|
||||
|
||||
@@ -58,6 +54,11 @@ function Orientation.isAndroid()
|
||||
return love.system.getOS() == "Android"
|
||||
end
|
||||
|
||||
function Orientation.isIOS()
|
||||
if not love or not love.system or not love.system.getOS then return false end
|
||||
return love.system.getOS() == "iOS"
|
||||
end
|
||||
|
||||
function Orientation.cycle(mode, dir)
|
||||
local cur, idx = Orientation.normalize(mode), 1
|
||||
for i, m in ipairs(Orientation.MODES) do
|
||||
@@ -67,6 +68,15 @@ function Orientation.cycle(mode, dir)
|
||||
return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1]
|
||||
end
|
||||
|
||||
-- ActivityInfo constants, what setOrientationBis lands on per hint after
|
||||
-- GameActivity's *_SENSOR -> *_USER remap (#716).
|
||||
local REQUESTED = {
|
||||
auto = 13,
|
||||
portrait = 1,
|
||||
landscape = 11,
|
||||
reverseLandscape = 8,
|
||||
}
|
||||
|
||||
-- The SDL2 C API this module needs. cdef errors on redefinition, so run it
|
||||
-- once and remember whether it took; ffi itself may be absent (plain Lua
|
||||
-- test interpreters), hence the pcall'd require.
|
||||
@@ -77,33 +87,79 @@ local function sdlFfi()
|
||||
if cdefOk == nil then
|
||||
cdefOk = pcall(ffi.cdef, [[
|
||||
typedef struct SDL_Window SDL_Window;
|
||||
typedef union { int32_t i; int64_t pad; } love_jvalue;
|
||||
int SDL_SetHint(const char *name, const char *value);
|
||||
SDL_Window *SDL_GL_GetCurrentWindow(void);
|
||||
void SDL_SetWindowResizable(SDL_Window *window, int resizable);
|
||||
void *SDL_AndroidGetJNIEnv(void);
|
||||
void *SDL_AndroidGetActivity(void);
|
||||
]])
|
||||
end
|
||||
if not cdefOk then return nil end
|
||||
return ffi
|
||||
end
|
||||
|
||||
-- Push the mode into the live activity. Returns true when the hint reached
|
||||
-- SDL (the symbols resolved), false on any non-Android / stubbed platform.
|
||||
-- Slot numbers in JNINativeInterface (jni.h).
|
||||
local JNI_EXCEPTION_CLEAR = 17
|
||||
local JNI_DELETE_LOCAL_REF = 23
|
||||
local JNI_GET_OBJECT_CLASS = 31
|
||||
local JNI_GET_METHOD_ID = 33
|
||||
local JNI_CALL_VOID_METHOD_A = 63
|
||||
|
||||
-- What Android_JNI_SetOrientation reaches, called directly: the hint path
|
||||
-- cannot re-run on a live fullscreen window (SDL_video.c:2237).
|
||||
local function setRequestedOrientation(ffi, requested)
|
||||
local env = ffi.C.SDL_AndroidGetJNIEnv()
|
||||
if env == nil then return false end
|
||||
local activity = ffi.C.SDL_AndroidGetActivity()
|
||||
if activity == nil then return false end
|
||||
local fns = ffi.cast("void***", env)[0]
|
||||
local getObjectClass = ffi.cast("void *(*)(void *, void *)", fns[JNI_GET_OBJECT_CLASS])
|
||||
local getMethodID = ffi.cast(
|
||||
"void *(*)(void *, void *, const char *, const char *)", fns[JNI_GET_METHOD_ID])
|
||||
local callVoidMethodA = ffi.cast(
|
||||
"void (*)(void *, void *, void *, love_jvalue *)", fns[JNI_CALL_VOID_METHOD_A])
|
||||
local deleteLocalRef = ffi.cast("void (*)(void *, void *)", fns[JNI_DELETE_LOCAL_REF])
|
||||
local exceptionClear = ffi.cast("void (*)(void *)", fns[JNI_EXCEPTION_CLEAR])
|
||||
|
||||
local ok = false
|
||||
local cls = getObjectClass(env, activity)
|
||||
if cls ~= nil then
|
||||
local mid = getMethodID(env, cls, "setRequestedOrientation", "(I)V")
|
||||
if mid ~= nil then
|
||||
local args = ffi.new("love_jvalue[1]")
|
||||
args[0].pad = 0
|
||||
args[0].i = requested
|
||||
callVoidMethodA(env, activity, mid, args)
|
||||
ok = true
|
||||
end
|
||||
exceptionClear(env)
|
||||
deleteLocalRef(env, cls)
|
||||
end
|
||||
deleteLocalRef(env, activity)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Returns true only when the request actually landed, never unconditionally
|
||||
-- as it once did (#1638).
|
||||
function Orientation.apply(mode)
|
||||
if not Orientation.isAndroid() then return false end
|
||||
local android = Orientation.isAndroid()
|
||||
if not (android or Orientation.isIOS()) then return false end
|
||||
local ffi = sdlFfi()
|
||||
if not ffi then return false end
|
||||
mode = Orientation.normalize(mode)
|
||||
local ok = pcall(function()
|
||||
-- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h);
|
||||
-- despite the IOS in the string, the Android backend reads it too.
|
||||
local ok, reached = pcall(function()
|
||||
-- SDL_HINT_ORIENTATIONS is "SDL_IOS_ORIENTATIONS" in the SDL2 Android
|
||||
-- ships and "SDL_ORIENTATIONS" in the SDL3 the iOS app links; each
|
||||
-- engine ignores the other's key.
|
||||
ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode])
|
||||
local win = ffi.C.SDL_GL_GetCurrentWindow()
|
||||
if win ~= nil then
|
||||
ffi.C.SDL_SetWindowResizable(win, 0)
|
||||
ffi.C.SDL_SetWindowResizable(win, 1)
|
||||
end
|
||||
ffi.C.SDL_SetHint("SDL_ORIENTATIONS", HINTS[mode])
|
||||
-- On iOS the hint is the lock: UIKit re-asks on every rotation
|
||||
-- (SDL_uikitviewcontroller.m supportedInterfaceOrientations).
|
||||
if not android then return true end
|
||||
return setRequestedOrientation(ffi, REQUESTED[mode])
|
||||
end)
|
||||
return ok
|
||||
return ok and reached == true
|
||||
end
|
||||
|
||||
function Orientation.applyOptions(opts)
|
||||
|
||||
@@ -86,8 +86,11 @@ function Performance.detect()
|
||||
local cores = processorCount()
|
||||
|
||||
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
|
||||
-- already ships a build for): the weakest target here.
|
||||
if isArm and os ~= "Android" and os ~= "iOS" then
|
||||
-- already ships a build for): the weakest target here. Desktop ARM
|
||||
-- (Apple Silicon "OS X", Windows-on-ARM) is not a handheld — those
|
||||
-- used to resolve AUTO → LOW, which stripped survey zoom-out from
|
||||
-- OPTIONS so the ZOOM row only offered IN.
|
||||
if isArm and os == "Linux" then
|
||||
return "low"
|
||||
end
|
||||
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
||||
|
||||
@@ -12,10 +12,12 @@ function ScreenPosition.normalize(v)
|
||||
end
|
||||
|
||||
function ScreenPosition.label(v)
|
||||
if ScreenPosition.skinActive() then return "SKIN" end
|
||||
return LABELS[ScreenPosition.normalize(v)]
|
||||
end
|
||||
|
||||
function ScreenPosition.cycle(v, dir)
|
||||
if ScreenPosition.skinActive() then return ScreenPosition.normalize(v) end
|
||||
v = ScreenPosition.normalize(v)
|
||||
local modes = ScreenPosition.MODES
|
||||
local cur = 1
|
||||
@@ -44,6 +46,9 @@ end
|
||||
function ScreenPosition.skinActive(w, h)
|
||||
local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
|
||||
if not ok or type(TouchSkin.viewport) ~= "function" then return false end
|
||||
if (not w or not h) and love and love.graphics and love.graphics.getDimensions then
|
||||
w, h = love.graphics.getDimensions()
|
||||
end
|
||||
local okv, x = pcall(TouchSkin.viewport, w, h)
|
||||
return okv and x ~= nil
|
||||
end
|
||||
|
||||
@@ -154,8 +154,14 @@ local function newSfxSource(data, key, def, pitch, tempo, plain)
|
||||
return newFileSource(def)
|
||||
end
|
||||
|
||||
local function deviceSuspended()
|
||||
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
||||
return ChipAudio ~= nil and ChipAudio.isSuspended()
|
||||
end
|
||||
|
||||
local function playPath(data, key, def, pitch, tempo, plain)
|
||||
if not love.audio or not def then return nil end
|
||||
if deviceSuspended() then return nil end
|
||||
local src = cache[key]
|
||||
if src == false then return nil end -- known bad, already logged
|
||||
if not src then
|
||||
@@ -602,6 +608,7 @@ end
|
||||
-- cache carries no clips (Red/Blue) or headless.
|
||||
function Sound.playPikaCry(data, n)
|
||||
if not love.audio then return nil end
|
||||
if deviceSuspended() then return nil end
|
||||
local count = data.audio and data.audio.pikaCries
|
||||
if not count then return nil end
|
||||
n = math.max(1, math.min(count, n or 1))
|
||||
@@ -633,6 +640,7 @@ end
|
||||
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
||||
function Sound.playCry(data, species, pikaClip)
|
||||
if not love.audio then return nil end
|
||||
if deviceSuspended() then return nil end
|
||||
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
|
||||
-- never used for the species there). Which clip is a property of the
|
||||
-- call site in the original -- every caller of PlayPikachuSoundClip sets
|
||||
|
||||
@@ -293,7 +293,9 @@ function TouchControls:applyOptions(opts)
|
||||
-- launcher editor round-trips through config() (#806)
|
||||
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
|
||||
TouchSkin.setOverlayLive(self.active)
|
||||
self:selectSkin(cfg.skin)
|
||||
-- Off means off everywhere: do not leave a hidden selected skin behind to
|
||||
-- influence renderer placement on desktop or with a controller attached.
|
||||
self:selectSkin(cfg.enabled and cfg.skin or nil)
|
||||
self.layouts = cfg.layouts
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
@@ -335,7 +337,10 @@ function TouchControls:visible()
|
||||
local art = TouchSkin.active ~= nil or self.img ~= nil
|
||||
if self.preview then return art end
|
||||
if self.enabled == false or not art then return false end
|
||||
if TouchSkin.active and TouchSkin.decorativeOnly() then return true end
|
||||
-- A selected skin is also a desktop/TV bezel. Input remains gated in
|
||||
-- touchpressed, but the artwork must not disappear when a controller is
|
||||
-- connected or the platform is not touch-first.
|
||||
if TouchSkin.active then return true end
|
||||
return self.active and not self.controllerHidden
|
||||
end
|
||||
|
||||
@@ -778,12 +783,19 @@ local function drawIcon(img, zone, pressed, alphaMul)
|
||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||
end
|
||||
|
||||
local function drawStretched(img, x, y, w, h, alpha)
|
||||
local function drawCovered(img, x, y, w, h, alpha)
|
||||
if not img or alpha <= 0 then return end
|
||||
local iw, ih = img:getWidth(), img:getHeight()
|
||||
if iw <= 0 or ih <= 0 then return end
|
||||
-- Cover the assigned box with one uniform scale and crop the excess. The
|
||||
-- old independent X/Y scale made portrait art visibly squash on wide
|
||||
-- displays (and vice versa).
|
||||
local s = math.max(w / iw, h / ih)
|
||||
local dw, dh = iw * s, ih * s
|
||||
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
|
||||
love.graphics.draw(img, x, y, 0, w / iw, h / ih)
|
||||
love.graphics.setScissor(x, y, w, h)
|
||||
love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s)
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
|
||||
function TouchControls:drawSkin(alphaMul)
|
||||
@@ -795,7 +807,7 @@ function TouchControls:drawSkin(alphaMul)
|
||||
|
||||
love.graphics.push("all")
|
||||
love.graphics.origin()
|
||||
drawStretched(page.image, bx, by, bw, bh, opacity)
|
||||
drawCovered(page.image, bx, by, bw, bh, opacity)
|
||||
|
||||
local pressed = {}
|
||||
for _, touch in pairs(self.touches or {}) do
|
||||
@@ -810,7 +822,7 @@ function TouchControls:drawSkin(alphaMul)
|
||||
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
|
||||
local alpha = opacity
|
||||
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
|
||||
drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
|
||||
drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+56
-3
@@ -636,6 +636,19 @@ local function applyPixelScale(page)
|
||||
return true
|
||||
end
|
||||
|
||||
-- An overlay image is its own design canvas. Older RetroArch cfg files often
|
||||
-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and
|
||||
-- all of its normalized controls on the same uniform scale.
|
||||
function TouchSkin.applyImageAspect(page)
|
||||
if not page or page.aspectFromCfg or not page.image
|
||||
or not page.image.getDimensions then return false end
|
||||
local iw, ih = page.image:getDimensions()
|
||||
if not iw or not ih or iw <= 0 or ih <= 0 then return false end
|
||||
page.aspect = iw / ih
|
||||
page.aspectFromImage = true
|
||||
return true
|
||||
end
|
||||
|
||||
function TouchSkin.load(root, id)
|
||||
local cfgPath, format, prefix = findConfig(root)
|
||||
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
|
||||
@@ -665,6 +678,7 @@ function TouchSkin.load(root, id)
|
||||
elseif page.pdfPath then
|
||||
rasterizePdfPage(page, root)
|
||||
end
|
||||
TouchSkin.applyImageAspect(page)
|
||||
if not applyPixelScale(page) then
|
||||
return nil, "could not read " .. tostring(page.imagePath)
|
||||
.. ", which " .. page.name .. " measures its coordinates against"
|
||||
@@ -771,6 +785,28 @@ function TouchSkin.find(id)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Remove only a user-installed skin. Bundled skins are shipped with the
|
||||
-- game and intentionally have no delete affordance.
|
||||
function TouchSkin.remove(id)
|
||||
local entry = TouchSkin.find(id)
|
||||
if not entry then return nil, "no skin " .. tostring(id) end
|
||||
if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end
|
||||
if not (love and love.filesystem and love.filesystem.remove) then
|
||||
return nil, "no writable filesystem"
|
||||
end
|
||||
local function removeTree(path)
|
||||
if isDir(path) and love.filesystem.getDirectoryItems then
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do
|
||||
local ok, err = removeTree(path .. "/" .. name)
|
||||
if not ok then return nil, err end
|
||||
end
|
||||
end
|
||||
local ok, err = love.filesystem.remove(path)
|
||||
return ok and true or nil, err
|
||||
end
|
||||
return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id))
|
||||
end
|
||||
|
||||
function TouchSkin.assetPaths(skin)
|
||||
local out, seen = {}, {}
|
||||
local function add(rel)
|
||||
@@ -1284,7 +1320,7 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
||||
-- full_screen means "relative to the window, not the game viewport".
|
||||
-- When the cfg also names an aspect_ratio, that window is then fitted
|
||||
-- to the overlay's design aspect so buttons do not stretch. #1503
|
||||
local fit = ((not page.fullScreen) or page.aspectFromCfg)
|
||||
local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage)
|
||||
and page.aspect and page.aspect > 0 and h > 0
|
||||
if fit then
|
||||
local displayAspect = w / h
|
||||
@@ -1306,6 +1342,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
||||
by = oy + extra
|
||||
elseif anchor == "top" then
|
||||
by = oy
|
||||
elseif page.aspect < 1 then
|
||||
-- A portrait bezel with controls is a controller deck. On an
|
||||
-- unusually tall display, pin the deck to the lower edge and leave
|
||||
-- the additional room for the game above it.
|
||||
by = oy + extra
|
||||
else
|
||||
by = oy + extra * 0.5
|
||||
end
|
||||
@@ -1359,8 +1400,10 @@ function TouchSkin.decorativeOnly()
|
||||
end
|
||||
|
||||
function TouchSkin.drawable()
|
||||
if not TouchSkin.active then return false end
|
||||
return TouchSkin.overlayLive or TouchSkin.decorativeOnly()
|
||||
-- A selected skin is a presentation choice, not a mobile-only input mode.
|
||||
-- Its artwork and screen placement therefore belong on every platform;
|
||||
-- `overlayLive` still controls whether touch input is available.
|
||||
return TouchSkin.active ~= nil
|
||||
end
|
||||
|
||||
function TouchSkin.hasViewport()
|
||||
@@ -1410,6 +1453,16 @@ function TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Centre of the page's screen cutout. The renderer fits the 160x144
|
||||
-- picture into that rect; this helper is for the studio preview.
|
||||
function TouchSkin.screenCenter(w, h, ox, oy, page)
|
||||
page = page or TouchSkin.page()
|
||||
if not page then return nil end
|
||||
local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||
if not x then return nil end
|
||||
return x + vw * 0.5, y + vh * 0.5
|
||||
end
|
||||
|
||||
function TouchSkin.viewport(w, h, ox, oy)
|
||||
local page = TouchSkin.page()
|
||||
if not page or not TouchSkin.drawable() then return nil end
|
||||
|
||||
@@ -287,6 +287,43 @@ function CacheFs.write(rel, data)
|
||||
return love.filesystem.write(rel, data)
|
||||
end
|
||||
|
||||
-- Open a cache-relative file for streaming replacement. The returned handle
|
||||
-- has write(bytes) and close() methods and follows the same portable/save-dir
|
||||
-- routing as CacheFs.write without forcing the caller to hold the whole file
|
||||
-- in one Lua string.
|
||||
function CacheFs.openWrite(rel)
|
||||
rel = withPrefix(rel)
|
||||
local root = CacheFs.root()
|
||||
if root then
|
||||
ensureParents(root, rel)
|
||||
local f, err = io.open(realPath(root, rel), "wb")
|
||||
if not f then return nil, err end
|
||||
return {
|
||||
write = function(_, data)
|
||||
local ok, writeErr = f:write(data)
|
||||
if not ok then return nil, writeErr end
|
||||
return true
|
||||
end,
|
||||
close = function() f:close() end,
|
||||
}
|
||||
end
|
||||
if not (love and love.filesystem and love.filesystem.newFile) then
|
||||
return nil, "streaming cache writes are unavailable"
|
||||
end
|
||||
local parent = rel:match("^(.*)/[^/]+$")
|
||||
if parent and not love.filesystem.createDirectory(parent) then
|
||||
local info = love.filesystem.getInfo(parent)
|
||||
local reason = info and ("a " .. info.type .. " already exists there")
|
||||
or "unknown reason"
|
||||
return nil, "could not create " .. parent .. ": " .. reason
|
||||
end
|
||||
local file, makeErr = love.filesystem.newFile(rel)
|
||||
if not file then return nil, makeErr or "could not create cache file" end
|
||||
local ok, openErr = file:open("w")
|
||||
if not ok then return nil, openErr or "could not open cache file" end
|
||||
return file
|
||||
end
|
||||
|
||||
-- read cache-relative `rel`; returns the bytes or nil
|
||||
function CacheFs.read(rel)
|
||||
rel = withPrefix(rel)
|
||||
|
||||
@@ -125,6 +125,26 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Inverse of pret tools/gfx --interleave (pokecrystal tools/gfx.c).
|
||||
-- Build-time interleave stores each vertical 8x16 pair as consecutive 8x8
|
||||
-- tiles for OBJ mode; this restores row-major sheet order for PNGs.
|
||||
function ImageWriter.deinterleave(raw, width, bytesPerTile)
|
||||
bytesPerTile = bytesPerTile or 16
|
||||
local widthTiles = width / 8
|
||||
local numTiles = #raw / bytesPerTile
|
||||
local out = {}
|
||||
for i = 0, numTiles - 1 do
|
||||
local row = math.floor(i / widthTiles)
|
||||
local src = i * 2 - (row % 2 == 1
|
||||
and widthTiles * (row + 1) - 1
|
||||
or widthTiles * row)
|
||||
for offset = 1, bytesPerTile do
|
||||
out[i * bytesPerTile + offset] = raw[src * bytesPerTile + offset]
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function ImageWriter.save(image, path)
|
||||
local ok, fileData = pcall(image.encode, image, "png")
|
||||
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
-- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the
|
||||
-- next boot's applyOptions pick the values up. Every ladder mirrors
|
||||
-- OptionsMenu's semantics and stored values; when editing one, keep the two
|
||||
-- in sync. ZOOM is deliberately absent: its range depends on the live
|
||||
-- renderer's fit scale (Renderer:fitScale), which does not exist here.
|
||||
-- in sync. ZOOM uses the live window's integer fit (same 160×144 rule as
|
||||
-- Renderer:fitScale) so the row can offer OUT/FIT/IN without a running game.
|
||||
--
|
||||
-- Rows are the same descriptor idiom OptionRows draws in game:
|
||||
-- { label, value = fn() -> string, step = fn(dir) -> changed,
|
||||
@@ -278,6 +278,16 @@ local function coreRows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
local okZ, Zoom = pcall(require, "src.render.Zoom")
|
||||
if okZ then
|
||||
add(Strings("ZOOM"),
|
||||
function() return Zoom.offsetLabel(opts.zoom or 0) end,
|
||||
function(dir)
|
||||
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
|
||||
return true
|
||||
end)
|
||||
end
|
||||
|
||||
local okTile, TileRenderer = pcall(require, "src.render.TileRenderer")
|
||||
if okTile and TileRenderer.VOID_FILLS then
|
||||
add(Strings("VOID FILL"),
|
||||
@@ -303,15 +313,12 @@ local function coreRows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ORIENTATION (#592): Android only -- the lock rides SDL's orientation
|
||||
-- hint, which iOS reads only at startup (the Info.plist governs there) and
|
||||
-- desktop ignores. Unlike the other launcher rows this one live-applies:
|
||||
-- the window exists here too, and rotating under the player's finger is
|
||||
-- the only feedback that reads.
|
||||
-- ORIENTATION (#592, #1638): mobile only. Unlike the other launcher rows
|
||||
-- this one live-applies: the window exists here too, and rotating under
|
||||
-- the player's finger is the only feedback that reads.
|
||||
do
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local okOr, Orientation = pcall(require, "src.core.Orientation")
|
||||
if okOr and osName == "Android" then
|
||||
if okOr and (Orientation.isAndroid() or Orientation.isIOS()) then
|
||||
add(Strings("ORIENTATION"),
|
||||
function() return Strings(Orientation.modeLabel(opts.orientation)) end,
|
||||
function(dir)
|
||||
@@ -646,6 +653,16 @@ local function gen2Rows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
local okZ, Zoom = pcall(require, "src.render.Zoom")
|
||||
if okZ then
|
||||
add(Strings("ZOOM"),
|
||||
function() return Zoom.offsetLabel(opts.zoom or 0) end,
|
||||
function(dir)
|
||||
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
|
||||
return true
|
||||
end)
|
||||
end
|
||||
|
||||
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
|
||||
if okFill and BorderFill.VOID_FILLS then
|
||||
add(Strings("VOID FILL"),
|
||||
|
||||
+153
-19
@@ -753,12 +753,15 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
-- and one below the top-right corner notch, like the DMG cart.
|
||||
local grooveW = w * 0.115
|
||||
local grooveH = math.max(1, h * 0.009)
|
||||
local grooveScale = { 1.22, 1.10, 1.00, 1.00, 1.10, 1.22 }
|
||||
local grooveInset = w * 0.02
|
||||
for i = 0, 5 do
|
||||
local ry = mainTop + h * 0.014 + i * h * 0.021
|
||||
cartPolygon(cartQuad(project, -halfW + w * 0.02, ry,
|
||||
grooveW, grooveH, faceZ), side, 0.7)
|
||||
cartPolygon(cartQuad(project, halfW - grooveW - w * 0.02, ry,
|
||||
grooveW, grooveH, faceZ), side, 0.7)
|
||||
local gw = grooveW * grooveScale[i + 1]
|
||||
cartPolygon(cartQuad(project, -halfW + grooveInset, ry,
|
||||
gw, grooveH, faceZ), side, 0.7)
|
||||
cartPolygon(cartQuad(project, halfW - gw - grooveInset, ry,
|
||||
gw, grooveH, faceZ), side, 0.7)
|
||||
end
|
||||
-- The thin diagonal mold ridge cut into each long side a little below
|
||||
-- the grip grooves, mirrored left/right.
|
||||
@@ -774,12 +777,14 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
{ project(x1 + nx * t, y1 + ny * t, faceZ) },
|
||||
}, side, 0.7)
|
||||
end
|
||||
local dgY = mainTop + h * 0.20
|
||||
local dgY = mainTop + h * 0.25
|
||||
diagonal(-halfW + w * 0.006, dgY, -halfW + w * 0.085, dgY + h * 0.038)
|
||||
diagonal(halfW - w * 0.006, dgY, halfW - w * 0.085, dgY + h * 0.038)
|
||||
-- The Nintendo GAME BOY recess: one stadium pill sunk into the shell.
|
||||
local pillX, pillW = -halfW + w * 0.17, w * 0.62
|
||||
local pillY, pillH = mainTop + h * 0.024, h * 0.115
|
||||
-- The pill recess: one stadium pill sunk into the shell.
|
||||
local pillX, pillW = -halfW + w * 0.19, w * 0.62
|
||||
local pillY, pillH = mainTop + h * 0.015, h * 0.115
|
||||
-- log out pillH
|
||||
|
||||
cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55)
|
||||
local inX, inY = w * 0.008, h * 0.008
|
||||
cartPill(project, pillX + inX, pillY + inY,
|
||||
@@ -2142,16 +2147,20 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
+ math.floor(8 * m.s)
|
||||
local listTop = cy
|
||||
|
||||
-- One continuous list: every row is laid out, the region scroll moves
|
||||
-- through all of it, and only rows inside the region's viewport draw --
|
||||
-- so the per-frame cost stays bounded by the window, not the list.
|
||||
-- One continuous list: derive the rows that can touch the viewport before
|
||||
-- entering the loop. Drawing was already culled, but scanning every
|
||||
-- installed row to discover that defeats the point on a large mod library.
|
||||
local view = imp._tabRegionRect
|
||||
local viewTop = view and view.y or listTop
|
||||
local viewBot = view and (view.y + view.h) or (listTop + availH)
|
||||
for i = 1, #mods do
|
||||
local stride = rowH + gap
|
||||
local first = math.max(1,
|
||||
math.ceil((viewTop - rowH - listTop) / stride) + 1)
|
||||
local last = math.min(#mods,
|
||||
math.floor((viewBot - listTop) / stride) + 1)
|
||||
for i = first, last do
|
||||
local mod = mods[i]
|
||||
local ry = listTop + (i - 1) * (rowH + gap)
|
||||
if ry + rowH >= viewTop and ry <= viewBot then
|
||||
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
|
||||
local isFullyDisabled = true
|
||||
if mod.enabledByVersion then
|
||||
@@ -2261,7 +2270,6 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
px, ly, PAL.detail)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local contentH = #mods * rowH + (#mods - 1) * gap
|
||||
return (listTop + contentH + gap) - y
|
||||
@@ -2269,8 +2277,8 @@ end
|
||||
|
||||
-- ---------------------------------------------------------- find mods panel
|
||||
|
||||
-- SKINS tab: pick the on-screen skin, import one, or open the desktop studio.
|
||||
local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
-- SKINS tab: pick the on-screen skin, import one, or open Skin Studio.
|
||||
local function buildSkinsPanelLegacy(imp, x, y, w, availH, m)
|
||||
local skins = imp:_ensureSkins()
|
||||
local active = imp:_activeSkin()
|
||||
local gap = m.gap
|
||||
@@ -2324,7 +2332,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
end
|
||||
cy = cy + urlH + math.floor(8 * m.s)
|
||||
|
||||
-- Studio button. Desktop only: the host supplies the hook nowhere else.
|
||||
-- The Studio reflows to a touch-first canvas plus inspector on phones.
|
||||
if imp.onOpenSkinStudio then
|
||||
local label = Strings("Open Skin Studio")
|
||||
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s))
|
||||
@@ -2344,6 +2352,64 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
Kit.caption(x, cy, Strings("INSTALLED"))
|
||||
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
||||
|
||||
-- Keep the actionable list below for detailed metadata and exports, but
|
||||
-- lead with a visual picker: skins are much easier to recognize by their
|
||||
-- bezel than by a folder name. Cards use the same loaded art that the
|
||||
-- runtime draws, so they cannot drift from the selected skin.
|
||||
local previewGap = math.floor(8 * m.s)
|
||||
local previewCols = w >= math.floor(420 * m.s) and 2 or 1
|
||||
local previewW = (w - previewGap * (previewCols - 1)) / previewCols
|
||||
local previewH = math.max(108 * m.s, Kit.tapMin() * 2)
|
||||
local previewCount = #skins
|
||||
for i, entry in ipairs(skins) do
|
||||
local n = i - 1
|
||||
local px = x + (n % previewCols) * (previewW + previewGap)
|
||||
local py = cy + math.floor(n / previewCols) * (previewH + previewGap)
|
||||
local key = "skin-preview-" .. entry.id
|
||||
local selected = active == entry.id
|
||||
local focused = Kit.focusable(key, px, py, previewW, previewH)
|
||||
Kit.card(px, py, previewW, previewH, selected and "selected"
|
||||
or (focused or Kit.hover(px, py, previewW, previewH)))
|
||||
local pad = math.floor(8 * m.s)
|
||||
local artH = math.floor(previewH * 0.60)
|
||||
Theme.fillRounded(px + pad, py + pad, previewW - pad * 2, artH,
|
||||
PAL.bg, 1, Theme.cardRadius() * 0.6)
|
||||
local art = entry.preview
|
||||
if art and art.getDimensions then
|
||||
local iw, ih = art:getDimensions()
|
||||
if iw > 0 and ih > 0 then
|
||||
local scale = math.min((previewW - pad * 4) / iw, (artH - pad * 2) / ih)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(art, px + (previewW - iw * scale) * 0.5,
|
||||
py + pad + (artH - ih * scale) * 0.5, 0, scale, scale)
|
||||
end
|
||||
else
|
||||
Theme.strokeRounded(px + previewW * 0.23, py + pad + artH * 0.15,
|
||||
previewW * 0.54, artH * 0.42, PAL.line, Theme.A.hairline, 1, 2)
|
||||
Theme.fillRounded(px + previewW * 0.20, py + pad + artH * 0.64,
|
||||
previewW * 0.22, artH * 0.17, PAL.steel, 0.75, 3)
|
||||
Theme.fillRounded(px + previewW * 0.60, py + pad + artH * 0.61,
|
||||
previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11)
|
||||
Theme.fillRounded(px + previewW * 0.75, py + pad + artH * 0.56,
|
||||
previewW * 0.12, artH * 0.22, PAL.steel, 0.75, artH * 0.11)
|
||||
end
|
||||
Kit.text("mono", Kit.ellipsize("mono", entry.id, previewW - pad * 2),
|
||||
px + pad, py + pad + artH + math.floor(5 * m.s),
|
||||
selected and PAL.green or PAL.heading)
|
||||
if selected then
|
||||
Kit.text("micro", Strings("IN USE"), px + pad,
|
||||
py + previewH - pad - Kit.textHeight("micro"), PAL.green)
|
||||
end
|
||||
if Kit.press(px, py, previewW, previewH) or Kit._activateId == key then
|
||||
queueAction(imp, key, function() imp:_useSkin(entry.id) end)
|
||||
end
|
||||
end
|
||||
if previewCount > 0 then
|
||||
cy = cy + math.ceil(previewCount / previewCols) * previewH
|
||||
+ math.max(0, math.ceil(previewCount / previewCols) - 1) * previewGap
|
||||
+ gap
|
||||
end
|
||||
|
||||
local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
|
||||
imp._skinGear = imp._skinGear
|
||||
or love.graphics.newImage("assets/launcher/gear.png")
|
||||
@@ -2457,6 +2523,69 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
return cy + hintH - y
|
||||
end
|
||||
|
||||
-- The launcher is the short path: bring a skin in, see what is enabled, or
|
||||
-- turn skin use off. Browsing, pagination and per-skin editing/export live
|
||||
-- together in My Skins, where they are useful instead of competing here.
|
||||
local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
local cy, gap, bh = y, m.gap, m.btnH
|
||||
local active = imp:_activeSkin()
|
||||
|
||||
Kit.text("button", Strings("Skins"), x, cy, PAL.heading)
|
||||
local importW = math.min(w * 0.46,
|
||||
Kit.textWidth("small", imp:_skinsImportButtonLabel()) + math.floor(24 * m.s))
|
||||
btn(imp, x + w - importW, cy, importW, bh, "skins-import",
|
||||
imp:_skinsImportButtonLabel(), { kind = "accent", font = "small",
|
||||
action = function() imp:chooseSkin() end })
|
||||
cy = cy + bh + gap
|
||||
|
||||
if imp._skinNotice then
|
||||
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
|
||||
imp._skinNotice.ok and PAL.green or PAL.red, 2) + gap
|
||||
end
|
||||
|
||||
local addW = Kit.textWidth("small", Strings("Add")) + math.floor(24 * m.s)
|
||||
if imp._skinFetch then
|
||||
Loader.inline(x, cy, w, bh, Strings("Downloading %s...",
|
||||
tostring(imp._skinFetch.name or "")))
|
||||
else
|
||||
btn(imp, x + w - addW, cy, addW, bh, "skins-url-add", Strings("Add"), {
|
||||
kind = "accent", font = "small", action = function() imp:_addSkinFromUrl() end })
|
||||
textField(imp, x, cy, w - addW - gap, bh, "skins-url", imp.skinUrl or "",
|
||||
Strings("Paste a skin link (.zip, .cfg, .deltaskin)"),
|
||||
imp._skinUrlFocus == true, function() imp:_toggleSkinUrlFocus() end)
|
||||
end
|
||||
cy = cy + bh + gap
|
||||
|
||||
local currentH = active and (bh * 2 + gap * 2) or (bh + gap * 2)
|
||||
Kit.card(x, cy, w, currentH)
|
||||
Kit.caption(x + gap, cy + gap, "CURRENT SKIN")
|
||||
local current = active and tostring(active) or Strings("No skin enabled")
|
||||
Kit.text("mono", Kit.ellipsize("mono", current, w - gap * 2), x + gap,
|
||||
cy + gap + Kit.textHeight("small") + math.floor(4 * m.s),
|
||||
active and PAL.green or PAL.muted)
|
||||
local buttonY = cy + bh + gap
|
||||
local half = (w - gap * 3) * 0.5
|
||||
if active then
|
||||
btn(imp, x + gap, buttonY, half, bh, "skins-export-current",
|
||||
Strings("Export current"), { font = "small",
|
||||
action = function() imp:_exportSkin(active, "native") end })
|
||||
btn(imp, x + gap * 2 + half, buttonY, half, bh, "skins-off",
|
||||
Strings("Turn skins off"), { kind = "danger", font = "small",
|
||||
action = function() imp:_disableSkins() end })
|
||||
end
|
||||
cy = cy + currentH + gap
|
||||
|
||||
if imp.onOpenSkinStudio then
|
||||
btn(imp, x, cy, w, bh, "skins-my-skins", Strings("My Skins"), {
|
||||
kind = "accent", font = "small",
|
||||
action = function() imp.onOpenSkinStudio(imp.modScope or "red", active) end })
|
||||
cy = cy + bh + gap
|
||||
end
|
||||
Kit.textWrapped("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."),
|
||||
x, cy, w, PAL.muted, 3)
|
||||
return cy + Kit.wrapHeight("small", Strings("Import from a file or link, then manage, edit and export individual skins in My Skins."), w, 3) - y
|
||||
end
|
||||
|
||||
local function buildBugPanel(imp, x, y, w, availH, m)
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local gap = m.gap
|
||||
@@ -3017,9 +3146,13 @@ local function buildTextModal(imp, m, key, title, body, closeFn)
|
||||
local h = math.floor(math.min(m.H - 2 * m.pad, 460 * m.s))
|
||||
local px, py, pw, ph = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Kit.ellipsize("button", title, pw - 2 * pad),
|
||||
local xW = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||
Kit.text("button", Kit.ellipsize("button", title,
|
||||
pw - 2 * pad - xW - math.floor(8 * m.s)),
|
||||
px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(10 * m.s)
|
||||
btn(imp, px + pw - pad - xW, cy, xW, xW, key .. "-x", "X",
|
||||
{ font = "small", action = closeFn })
|
||||
cy = cy + math.max(Kit.textHeight("button"), xW) + math.floor(10 * m.s)
|
||||
|
||||
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||
local bodyH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s)
|
||||
@@ -5071,6 +5204,7 @@ function LauncherView.draw(imp)
|
||||
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
|
||||
-- one is up; buildModals lowers the shield for the modal's own controls.
|
||||
imp._modalUpNow = modalUp(imp)
|
||||
if imp._modalUpNow then imp:_blurPanelFields() end
|
||||
Kit.blockClicks = imp._modalUpNow
|
||||
|
||||
local step = Kit.scrollStep(m.s)
|
||||
|
||||
+170
-37
@@ -5258,80 +5258,213 @@ function RomExtractorGen2:extractMenuGfx()
|
||||
end
|
||||
|
||||
-- Goldenrod Game Corner: Slot Machine graphics assets
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local function packBytes(bytes)
|
||||
local chars = {}
|
||||
for i = 1, #bytes do chars[i] = string.char(bytes[i]) end
|
||||
return table.concat(chars)
|
||||
end
|
||||
local function writeRaw(relative, bytes)
|
||||
local ok, writeError = CacheFs.write(
|
||||
"assets/generated/" .. relative, packBytes(bytes))
|
||||
if not ok then
|
||||
error("could not write " .. relative .. ": " .. tostring(writeError))
|
||||
end
|
||||
end
|
||||
|
||||
-- Canonical sheet sizes match the cart art the UI indexes (and pret's
|
||||
-- gfx/slots + gfx/card_flip PNGs). ROM LZ streams are those sheets after
|
||||
-- Makefile gfx transforms; reverse what the decompressed bytes still carry.
|
||||
local SLOTS1_W, SLOTS1_H = 16, 152
|
||||
local SLOTS2_W, SLOTS2_H = 16, 256
|
||||
local SLOTS3_W, SLOTS3_H = 24, 240
|
||||
local CARD1_W, CARD1_H = 128, 32
|
||||
local CARD2_W, CARD2_H = 24, 160
|
||||
local CARD3_W, CARD3_H = 8, 56
|
||||
|
||||
local function pad2bpp(raw, width, height)
|
||||
local need = width * height / 4
|
||||
while #raw < need do raw[#raw + 1] = 0 end
|
||||
while #raw > need do table.remove(raw) end
|
||||
return raw
|
||||
end
|
||||
|
||||
local function writeSheet(raw, width, height, relative, transparent)
|
||||
self:write2bpp(pad2bpp(raw, width, height), width, height, relative,
|
||||
transparent)
|
||||
end
|
||||
|
||||
-- Slots3LZ is unique 8x16 OBJ columns (interleave + remove-duplicates +
|
||||
-- remove-xflip). Rebuild the 24x240 actor sheet the UI quads expect from
|
||||
-- OAMData_SlotsGolem / Chansey* / Egg (data/sprite_anims/oam.asm), same
|
||||
-- pattern as title-screen Ho-Oh frame composition above.
|
||||
local function composeSlotsActors(raw)
|
||||
local tileCount = math.floor(#raw / 16)
|
||||
local tiles = {}
|
||||
for index = 0, tileCount - 1 do
|
||||
local one = {}
|
||||
for b = 1, 16 do one[b] = raw[index * 16 + b] or 0 end
|
||||
tiles[index] = ImageWriter.decode2bpp(one, 8, 8, true)
|
||||
end
|
||||
local sheet = ImageWriter.blank(SLOTS3_W, SLOTS3_H, 1, 1, 1, 0)
|
||||
local function blit8x16(tileId, dx, dy, flipX)
|
||||
local top, bot = tiles[tileId], tiles[tileId + 1]
|
||||
if not (top and bot) then return end
|
||||
ImageWriter.blit(sheet, top, dx, dy, 0, 0, 8, 8, flipX)
|
||||
ImageWriter.blit(sheet, bot, dx, dy + 8, 0, 0, 8, 8, flipX)
|
||||
end
|
||||
local function blitPose(poseY, base, entries)
|
||||
for _, e in ipairs(entries) do
|
||||
blit8x16(base + e.t, (e.x + 2) * 8, poseY + (e.y + 2) * 8, e.xf)
|
||||
end
|
||||
end
|
||||
local golem = {
|
||||
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||||
{ x = 0, y = -2, t = 0x00, xf = true },
|
||||
{ x = -2, y = 0, t = 0x04 }, { x = -1, y = 0, t = 0x06 },
|
||||
{ x = 0, y = 0, t = 0x04, xf = true },
|
||||
}
|
||||
local chansey = {
|
||||
{
|
||||
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||||
{ x = 0, y = -2, t = 0x04 },
|
||||
{ x = -2, y = 0, t = 0x06 }, { x = -1, y = 0, t = 0x08 },
|
||||
{ x = 0, y = 0, t = 0x0a },
|
||||
},
|
||||
{
|
||||
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||||
{ x = 0, y = -2, t = 0x04 },
|
||||
{ x = -2, y = 0, t = 0x0c }, { x = -1, y = 0, t = 0x0e },
|
||||
{ x = 0, y = 0, t = 0x10 },
|
||||
},
|
||||
{
|
||||
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||||
{ x = 0, y = -2, t = 0x04 },
|
||||
{ x = -2, y = 0, t = 0x12 }, { x = -1, y = 0, t = 0x14 },
|
||||
{ x = 0, y = 0, t = 0x16 },
|
||||
},
|
||||
{
|
||||
{ x = -2, y = -2, t = 0x00 }, { x = -1, y = -2, t = 0x02 },
|
||||
{ x = 0, y = -2, t = 0x04 },
|
||||
{ x = -2, y = 0, t = 0x18 }, { x = -1, y = 0, t = 0x1a },
|
||||
{ x = 0, y = 0, t = 0x1c },
|
||||
},
|
||||
{
|
||||
{ x = -2, y = -2, t = 0x1e }, { x = -1, y = -2, t = 0x20 },
|
||||
{ x = 0, y = -2, t = 0x22 },
|
||||
{ x = -2, y = 0, t = 0x24 }, { x = -1, y = 0, t = 0x26 },
|
||||
{ x = 0, y = 0, t = 0x28 },
|
||||
},
|
||||
}
|
||||
blitPose(0, 0x00, golem)
|
||||
blitPose(32, 0x08, golem)
|
||||
for index, frame in ipairs(chansey) do
|
||||
blitPose(32 + index * 32, 0x10, frame)
|
||||
end
|
||||
blit8x16(0x3a, 0, 224, false)
|
||||
return sheet
|
||||
end
|
||||
|
||||
-- card_flip_2.2bpp uses --remove-whitespace: blank tiles in column 2 of the
|
||||
-- 3-wide header strip (indices 2,5,...,23) are dropped from the ROM stream.
|
||||
-- Re-insert them so HEADER_TILE_MAP / MON_ANCHORS (pret sheet indices) work.
|
||||
local function expandCardFlip2(compact)
|
||||
local need = CARD2_W * CARD2_H / 4
|
||||
local out = {}
|
||||
for i = 1, need do out[i] = 0 end
|
||||
local whitespace = {
|
||||
[2] = true, [5] = true, [8] = true, [11] = true,
|
||||
[14] = true, [17] = true, [20] = true, [23] = true,
|
||||
}
|
||||
local src = 0
|
||||
for tile = 0, 59 do
|
||||
if not whitespace[tile] then
|
||||
for b = 1, 16 do
|
||||
out[tile * 16 + b] = compact[src * 16 + b] or 0
|
||||
end
|
||||
src = src + 1
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local slots = nil
|
||||
if self.symbols["Slots1LZ"] then
|
||||
-- --trim-whitespace drops the final empty tile (37 of 38).
|
||||
local raw1 = self:decompressLz3Symbol("Slots1LZ")
|
||||
self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png")
|
||||
writeSheet(raw1, SLOTS1_W, SLOTS1_H, "slots/gold_slots_1.png")
|
||||
slots = slots or {}
|
||||
slots.sheet1 = "assets/generated/slots/gold_slots_1.png"
|
||||
end
|
||||
if self.symbols["Slots2LZ"] then
|
||||
local raw2 = self:decompressLz3Symbol("Slots2LZ")
|
||||
-- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity
|
||||
local raw2 = ImageWriter.deinterleave(
|
||||
self:decompressLz3Symbol("Slots2LZ"), SLOTS2_W)
|
||||
-- Commercial Gold stores the Seven symbol with inverted bit polarity.
|
||||
for i = 1, math.min(64, #raw2) do
|
||||
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
|
||||
end
|
||||
self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png")
|
||||
writeSheet(raw2, SLOTS2_W, SLOTS2_H, "slots/gold_slots_2.png")
|
||||
slots = slots or {}
|
||||
slots.sheet2 = "assets/generated/slots/gold_slots_2.png"
|
||||
end
|
||||
if self.symbols["Slots3LZ"] then
|
||||
local raw3 = self:decompressLz3Symbol("Slots3LZ")
|
||||
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true)
|
||||
-- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing:
|
||||
-- Y=0: Golem 1 (Standing, 24x32)
|
||||
-- Y=32: Golem 2 (Ball, 24x32)
|
||||
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
|
||||
-- Y=96: Chansey 2 (Step 2, 24x32)
|
||||
-- Y=128: Chansey 3 (Step 3, 24x32)
|
||||
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
|
||||
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
|
||||
-- Y=224: Egg (8x16 at X=0)
|
||||
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true)
|
||||
local actors = composeSlotsActors(raw3)
|
||||
self:save(actors, "slots/gold_slots_3.png")
|
||||
self:save(actors, "slots/gold_slots_actors.png")
|
||||
slots = slots or {}
|
||||
slots.sheet3 = "assets/generated/slots/gold_slots_3.png"
|
||||
end
|
||||
if self.symbols["SlotsTilemap"] then
|
||||
local symbol = self:symbol("SlotsTilemap")
|
||||
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12)
|
||||
self:save(tm, "slots/gold_slots.tilemap")
|
||||
writeRaw("slots/gold_slots.tilemap", tm)
|
||||
slots = slots or {}
|
||||
slots.tilemap = "assets/generated/slots/gold_slots.tilemap"
|
||||
end
|
||||
if slots then out.slots = slots end
|
||||
|
||||
-- Goldenrod Game Corner: Card Flip graphics assets
|
||||
local cardFlip = nil
|
||||
if self.symbols["CardFlipLZ01"] then
|
||||
-- --trim-whitespace: 62 of 64 tiles in the ROM stream.
|
||||
local raw1 = self:decompressLz3Symbol("CardFlipLZ01")
|
||||
self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png")
|
||||
writeSheet(raw1, CARD1_W, CARD1_H, "card_flip/card_flip_1.png")
|
||||
cardFlip = cardFlip or {}
|
||||
cardFlip.sheet1 = "assets/generated/card_flip/card_flip_1.png"
|
||||
end
|
||||
if self.symbols["CardFlipLZ02"] then
|
||||
local raw2 = self:decompressLz3Symbol("CardFlipLZ02")
|
||||
self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png")
|
||||
local raw2 = expandCardFlip2(self:decompressLz3Symbol("CardFlipLZ02"))
|
||||
writeSheet(raw2, CARD2_W, CARD2_H, "card_flip/card_flip_2.png")
|
||||
cardFlip = cardFlip or {}
|
||||
cardFlip.sheet2 = "assets/generated/card_flip/card_flip_2.png"
|
||||
end
|
||||
if self.symbols["CardFlipLZ03"] then
|
||||
local raw3 = self:decompressLz3Symbol("CardFlipLZ03")
|
||||
self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png")
|
||||
writeSheet(raw3, CARD3_W, CARD3_H, "card_flip/card_flip_3.png")
|
||||
cardFlip = cardFlip or {}
|
||||
cardFlip.sheet3 = "assets/generated/card_flip/card_flip_3.png"
|
||||
end
|
||||
if self.symbols["CardFlipOnButtonGFX"] then
|
||||
local symbol = self:symbol("CardFlipOnButtonGFX")
|
||||
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png")
|
||||
cardFlip = cardFlip or {}
|
||||
cardFlip.on = "assets/generated/card_flip/on.png"
|
||||
end
|
||||
if self.symbols["CardFlipOffButtonGFX"] then
|
||||
local symbol = self:symbol("CardFlipOffButtonGFX")
|
||||
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png")
|
||||
cardFlip = cardFlip or {}
|
||||
cardFlip.off = "assets/generated/card_flip/off.png"
|
||||
end
|
||||
if self.symbols["CardFlipTilemap"] then
|
||||
local symbol = self:symbol("CardFlipTilemap")
|
||||
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12)
|
||||
self:save(tm, "card_flip/card_flip.tilemap")
|
||||
writeRaw("card_flip/card_flip.tilemap", tm)
|
||||
cardFlip = cardFlip or {}
|
||||
cardFlip.tilemap = "assets/generated/card_flip/card_flip.tilemap"
|
||||
end
|
||||
|
||||
out.slots = {
|
||||
sheet1 = "assets/generated/slots/gold_slots_1.png",
|
||||
sheet2 = "assets/generated/slots/gold_slots_2.png",
|
||||
sheet3 = "assets/generated/slots/gold_slots_3.png",
|
||||
tilemap = "assets/generated/slots/gold_slots.tilemap",
|
||||
}
|
||||
|
||||
out.cardFlip = {
|
||||
sheet1 = "assets/generated/card_flip/card_flip_1.png",
|
||||
sheet2 = "assets/generated/card_flip/card_flip_2.png",
|
||||
sheet3 = "assets/generated/card_flip/card_flip_3.png",
|
||||
on = "assets/generated/card_flip/on.png",
|
||||
off = "assets/generated/card_flip/off.png",
|
||||
tilemap = "assets/generated/card_flip/card_flip.tilemap",
|
||||
}
|
||||
if cardFlip then out.cardFlip = cardFlip end
|
||||
|
||||
self:write("menu_gfx", out)
|
||||
self:tick("Menu graphics", 1, 1)
|
||||
|
||||
+210
-16
@@ -140,6 +140,12 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||
-- before the four ball tiles were extracted (#1502).
|
||||
"assets/generated/battle/hud/balls.png",
|
||||
"assets/generated/audio/programs.bin",
|
||||
-- Goldenrod Game Corner reel + board art (#1581). menu_gfx.lua used to
|
||||
-- advertise these paths even when Slots*LZ / CardFlip* were absent from
|
||||
-- the manifest, so a cache that never wrote the PNGs still looked
|
||||
-- complete and SlotMachine crashed on its labelled-cell fallback.
|
||||
"assets/generated/slots/gold_slots_1.png",
|
||||
"assets/generated/card_flip/card_flip_1.png",
|
||||
},
|
||||
}
|
||||
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
|
||||
@@ -382,6 +388,142 @@ local function externalFileSize(path)
|
||||
return size
|
||||
end
|
||||
|
||||
local function openImportSource(path)
|
||||
-- Desktop picker paths live outside LÖVE's virtual filesystem. Prefer the
|
||||
-- native file handle so a 1.46 GiB disc is never copied to a temp file or
|
||||
-- read into one Lua string before validation.
|
||||
local native = io.open(path, "rb")
|
||||
if native then
|
||||
local size, sizeErr = native:seek("end")
|
||||
if size == nil or size == false then
|
||||
native:close()
|
||||
return nil, sizeErr or "could not determine source file size"
|
||||
end
|
||||
local reset, resetErr = native:seek("set", 0)
|
||||
if reset == nil or reset == false then
|
||||
native:close()
|
||||
return nil, resetErr or "could not rewind source file"
|
||||
end
|
||||
return {
|
||||
size = size,
|
||||
read = function(_, n) return native:read(n) end,
|
||||
close = function() native:close() end,
|
||||
}
|
||||
end
|
||||
if love and love.filesystem and love.filesystem.newFile then
|
||||
local file, makeErr = love.filesystem.newFile(path)
|
||||
if not file then return nil, makeErr or "could not open source file" end
|
||||
local ok, openErr = file:open("r")
|
||||
if not ok then return nil, openErr or "could not open source file" end
|
||||
local size = file.getSize and file:getSize() or nil
|
||||
return {
|
||||
size = size,
|
||||
read = function(_, n) return file:read(n) end,
|
||||
close = function() file:close() end,
|
||||
}
|
||||
end
|
||||
return nil, "streaming source access is unavailable"
|
||||
end
|
||||
local function streamRequiredImport(manifest, importId, source)
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local spec = RequiredImports.spec(manifest, importId)
|
||||
if not spec then return nil, "Import declaration was not found." end
|
||||
if spec.format == "n64" then
|
||||
return nil, "streaming canonicalization is unavailable for N64 imports"
|
||||
end
|
||||
local input, openErr = openImportSource(source)
|
||||
if not input then return nil, openErr end
|
||||
local sizeErr = RequiredImports.sizeError(spec, input.size, false)
|
||||
if sizeErr then input:close(); return nil, sizeErr end
|
||||
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local destination = RequiredImports.path(manifest, spec)
|
||||
local savedPrefix = CacheFs.prefix
|
||||
local output
|
||||
local inputClosed, outputClosed = false, false
|
||||
|
||||
local function closeInput()
|
||||
if inputClosed then return end
|
||||
inputClosed = true
|
||||
pcall(function() input:close() end)
|
||||
end
|
||||
|
||||
local function closeOutput()
|
||||
if outputClosed or not output then return end
|
||||
outputClosed = true
|
||||
pcall(function() output:close() end)
|
||||
end
|
||||
|
||||
local resultOk, resultDetail
|
||||
CacheFs.prefix = ""
|
||||
local ran, thrown = xpcall(function()
|
||||
CacheFs.remove(RequiredImports.receiptPath(manifest, spec))
|
||||
CacheFs.remove(destination)
|
||||
local makeErr
|
||||
output, makeErr = CacheFs.openWrite(destination)
|
||||
if not output then
|
||||
resultDetail = makeErr or "could not create imported file"
|
||||
return
|
||||
end
|
||||
|
||||
local MD5 = require("src.mods.StreamMD5")
|
||||
local md5 = MD5.new()
|
||||
local total, chunkBytes = 0, 4 * 1024 * 1024
|
||||
while true do
|
||||
local chunk = input:read(chunkBytes)
|
||||
if not chunk or #chunk == 0 then break end
|
||||
md5:update(chunk)
|
||||
local wrote, writeErr = output:write(chunk)
|
||||
if wrote == false or wrote == nil then
|
||||
resultDetail = "could not copy import: "
|
||||
.. tostring(writeErr or "write failed")
|
||||
return
|
||||
end
|
||||
total = total + #chunk
|
||||
if #chunk < chunkBytes then break end
|
||||
end
|
||||
|
||||
closeInput()
|
||||
closeOutput()
|
||||
if input.size and total ~= input.size then
|
||||
resultDetail = ("source read ended early (expected %d bytes, copied %d)")
|
||||
:format(input.size, total)
|
||||
return
|
||||
end
|
||||
local storedSizeErr = RequiredImports.sizeError(spec, total, true)
|
||||
if storedSizeErr then resultDetail = storedSizeErr; return end
|
||||
local digest = md5:final()
|
||||
|
||||
-- acceptStoredDigest uses the normal engine path/receipt rules, so
|
||||
-- restore the caller's prefix before handing control to it.
|
||||
CacheFs.prefix = savedPrefix
|
||||
local accepted, detail = RequiredImports.acceptStoredDigest(
|
||||
manifest, importId, digest, love.filesystem)
|
||||
if not accepted then resultDetail = detail; return end
|
||||
resultOk, resultDetail = true, detail
|
||||
end, function(err)
|
||||
if debug and debug.traceback then
|
||||
return debug.traceback(tostring(err), 2)
|
||||
end
|
||||
return tostring(err)
|
||||
end)
|
||||
|
||||
-- finally: resource handles and the process-global CacheFs prefix must
|
||||
-- be restored even when a read/hash/write helper raises a Lua error.
|
||||
closeInput()
|
||||
closeOutput()
|
||||
CacheFs.prefix = ""
|
||||
if not ran or not resultOk then
|
||||
pcall(function() CacheFs.remove(destination) end)
|
||||
end
|
||||
CacheFs.prefix = savedPrefix
|
||||
|
||||
if not ran then
|
||||
return nil, "could not copy import: " .. tostring(thrown)
|
||||
end
|
||||
if not resultOk then return nil, resultDetail end
|
||||
return true, resultDetail
|
||||
end
|
||||
local function readDroppedFile(file)
|
||||
local ok, openError = file:open("r")
|
||||
if not ok then return nil, openError end
|
||||
@@ -1228,11 +1370,13 @@ local function chooseRequiredFile()
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='All files (*.*)|*.*';",
|
||||
-- Required imports can be multi-gigabyte optical-disc images. Do NOT
|
||||
-- stage them through %TEMP%: that doubles free-space requirements and a
|
||||
-- failed Copy-Item can leave a plausible-looking truncated temp file.
|
||||
-- Stream the selected source directly into the mod-owned destination.
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
"[Console]::Write($t)}",
|
||||
"[Console]::Write($d.FileName)}",
|
||||
})
|
||||
return commandOutput(
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
@@ -2072,8 +2216,13 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
|
||||
return nil
|
||||
end
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
-- A desktop picker returns a host path. Ask the host file handle first;
|
||||
-- love.filesystem.getInfo is only authoritative for virtual/save paths.
|
||||
local size = externalFileSize(source)
|
||||
if not size then
|
||||
local info = love.filesystem.getInfo(source, "file")
|
||||
local size = info and info.size or externalFileSize(source)
|
||||
size = info and info.size or nil
|
||||
end
|
||||
local sizeErr = RequiredImports.sizeError(spec, size, false)
|
||||
if sizeErr then
|
||||
requiredImportNotice(self, modId, importId, sizeErr)
|
||||
@@ -2096,6 +2245,20 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
|
||||
}
|
||||
return nil
|
||||
end
|
||||
if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES
|
||||
and spec.format ~= "n64" then
|
||||
local ok, result = streamRequiredImport(manifest, importId, source)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
|
||||
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
|
||||
self:_refreshMods()
|
||||
return true
|
||||
end
|
||||
requiredImportNotice(self, modId, importId, result)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local data = love.filesystem.read(source)
|
||||
if not data then data = readExternalPath(source) end
|
||||
if not data then
|
||||
@@ -2848,6 +3011,7 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local overY = 0
|
||||
if ny > oy + h then overY = ny - (oy + h)
|
||||
elseif ny < oy then overY = ny - oy end
|
||||
if math.abs(ay) > PAD_DEAD and not self._padStickCentered then overY = 0 end
|
||||
if overY ~= 0 and self._flex then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
|
||||
end
|
||||
@@ -2907,7 +3071,11 @@ end
|
||||
function RomImporter:gamepadaxis(_, axis, value)
|
||||
if axis == "leftx" or axis == "lefty" or axis == "righty" then
|
||||
self._padAxis[axis] = value
|
||||
if math.abs(value) > PAD_DEAD then self:_activatePadCursor() end
|
||||
if math.abs(value) > PAD_DEAD then
|
||||
self:_activatePadCursor()
|
||||
elseif axis == "lefty" then
|
||||
self._padStickCentered = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2916,18 +3084,21 @@ end
|
||||
-- fire the virtual cursor's click twice off one A press (#620).
|
||||
function RomImporter:joystickpressed(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||
if padButton then self:gamepadpressed(joystick, padButton) end
|
||||
end
|
||||
|
||||
function RomImporter:joystickreleased(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||
if padButton then self:gamepadreleased(joystick, padButton) end
|
||||
end
|
||||
|
||||
function RomImporter:joystickaxis(joystick, axis, value)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
@@ -2937,6 +3108,7 @@ end
|
||||
|
||||
function RomImporter:joystickhat(joystick, hat, direction)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
|
||||
self._padDir[dir] = nil
|
||||
end
|
||||
@@ -3182,6 +3354,10 @@ function RomImporter:_ensureSkins(force)
|
||||
format = skin and skin.format or nil,
|
||||
pages = skin and #skin.pages or 0,
|
||||
controls = controls,
|
||||
-- The launcher owns the visual skin picker, so retain the already
|
||||
-- loaded first-page bezel for its preview card instead of decoding it
|
||||
-- again every frame.
|
||||
preview = page and page.image or nil,
|
||||
screen = page ~= nil
|
||||
and (page.viewport ~= nil or page.screenFit == "remainder"),
|
||||
ok = skin ~= nil,
|
||||
@@ -3194,7 +3370,7 @@ end
|
||||
function RomImporter:_activeSkin()
|
||||
local opts = require("src.core.SaveData").loadOptions()
|
||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
return tc.skin
|
||||
return tc.enabled == false and nil or tc.skin
|
||||
end
|
||||
|
||||
function RomImporter:_useSkin(id)
|
||||
@@ -3211,6 +3387,16 @@ function RomImporter:_useSkin(id)
|
||||
}
|
||||
end
|
||||
|
||||
function RomImporter:_disableSkins()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local opts = SaveData.loadOptions()
|
||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
tc.enabled, tc.skin = false, nil
|
||||
opts.touchControls = tc
|
||||
SaveData.saveOptions(opts)
|
||||
self._skinNotice = { ok = true, text = "Skins are off. Mobile will use the built-in pad when needed." }
|
||||
end
|
||||
|
||||
function RomImporter:_installSkinZip(source)
|
||||
if self.workState == "working" then return end
|
||||
self.tab = "skins"
|
||||
@@ -3997,6 +4183,18 @@ function RomImporter:_disarmTextInput()
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_blurPanelFields()
|
||||
if not (self._findSearchFocus or self._skinUrlFocus) then return end
|
||||
if self._indexPrompt or self._rename or self._settingsText
|
||||
or self._profileSavePrompt or self._profileRenamePrompt
|
||||
or (self._syncModal and self._syncFocus) then
|
||||
return
|
||||
end
|
||||
self._findSearchFocus = false
|
||||
self._skinUrlFocus = false
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_beginRename(version, id)
|
||||
local label
|
||||
for _, slot in ipairs(self.slots[version] or {}) do
|
||||
@@ -4127,7 +4325,6 @@ function RomImporter:_refreshMods()
|
||||
end
|
||||
self.mods = kept
|
||||
end
|
||||
self:_syncModUpdateInfo(false)
|
||||
end
|
||||
|
||||
-- Point the MODS panel at one game (or nil for all of them) and relist, so
|
||||
@@ -4141,15 +4338,12 @@ function RomImporter:_ensureMods()
|
||||
if not self.mods then self:_refreshMods() end
|
||||
end
|
||||
|
||||
-- Resolve cached (or freshly fetched) GitHub status for every mod that
|
||||
-- declares a github field. force=true bypasses the 6h cache on every repo.
|
||||
-- Results live on self.modUpdateInfo[id] = { status, latest, best, releases }.
|
||||
-- ASYNC (was synchronous). This runs on every _refreshMods -- boot, and any
|
||||
-- toggle or install -- and used to make one blocking curl call per mod with a
|
||||
-- github field, in a loop, on the render thread. A handful of mods was a
|
||||
-- multi-second freeze of the whole launcher. Now each mod gets a handle and
|
||||
-- they resolve together across later frames; a mod whose cache is still fresh
|
||||
-- resolves on the first pump with no network at all.
|
||||
-- Resolve GitHub status for every installed mod that declares a github field.
|
||||
-- This is deliberately opt-in: only the explicit "Check for updates" action
|
||||
-- calls it. Opening, scrolling, toggling, or relisting the MODS tab must not
|
||||
-- create a burst of release work behind the list. force=true bypasses the 6h
|
||||
-- cache on every repo. Results live on self.modUpdateInfo[id] = {
|
||||
-- status, latest, best, releases } and resolve asynchronously across frames.
|
||||
function RomImporter:_syncModUpdateInfo(force)
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
self.modUpdateInfo = self.modUpdateInfo or {}
|
||||
|
||||
@@ -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
|
||||
@@ -962,6 +962,8 @@ function Loader:_api(mod)
|
||||
local Storage = engineRequire("src.mods.Storage")
|
||||
local storage = Storage and Storage.new(modId, loader.fs)
|
||||
local Checkpoint = engineRequire("src.core.Checkpoint")
|
||||
local ImportAccess = engineRequire("src.mods.ImportAccess")
|
||||
local importApi, installCache = ImportAccess.new(mod.manifest, loader.fs)
|
||||
local api = {
|
||||
id = modId,
|
||||
version = mod.manifest.version,
|
||||
@@ -1161,6 +1163,12 @@ function Loader:_api(mod)
|
||||
-- checkpoint. The
|
||||
-- engine binds version/playthrough/mod scope and portable persistence;
|
||||
-- callers never receive paths or a raw filesystem handle.
|
||||
-- Read-only bounded access to this mod's manifest-declared, launcher-validated
|
||||
-- imports. No host path is exposed; large sources are read in bounded ranges.
|
||||
imports = importApi,
|
||||
-- Installation-scoped generated data, independent from Pokémon save slots.
|
||||
-- This is where ROM-derived caches belong; mod.storage remains playthrough-scoped.
|
||||
cache = installCache,
|
||||
storage = {
|
||||
context = function(_, game) return storage:context(game) end,
|
||||
selected = function(_, game) return storage:selected(game) end,
|
||||
|
||||
@@ -120,6 +120,36 @@ local function accepts(spec, digest)
|
||||
return false
|
||||
end
|
||||
|
||||
local function specById(manifest, importId)
|
||||
for _, candidate in ipairs(allSpecs(manifest)) do
|
||||
if candidate.id == importId then return candidate end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
RequiredImports.spec = specById
|
||||
|
||||
local function streamDigest(fs, path, chunkBytes)
|
||||
if not (fs and fs.newFile) then return nil, "streaming file access is unavailable" end
|
||||
local file, makeErr = fs.newFile(path)
|
||||
if not file then return nil, makeErr or "could not open stored import" end
|
||||
local ok, openErr = file:open("r")
|
||||
if not ok then return nil, openErr or "could not open stored import" end
|
||||
local MD5 = require("src.mods.StreamMD5")
|
||||
local ctx = MD5.new()
|
||||
chunkBytes = chunkBytes or (4 * 1024 * 1024)
|
||||
while true do
|
||||
local data, readErr = file:read(chunkBytes)
|
||||
if data and #data > 0 then ctx:update(data) end
|
||||
if not data or #data < chunkBytes then
|
||||
if readErr then file:close(); return nil, readErr end
|
||||
break
|
||||
end
|
||||
end
|
||||
file:close()
|
||||
return ctx:final()
|
||||
end
|
||||
|
||||
function RequiredImports.path(manifest, spec)
|
||||
return manifest.path .. "/baseroms/" .. spec.file
|
||||
end
|
||||
@@ -180,6 +210,47 @@ local function removeReceipt(manifest, spec, fs)
|
||||
end
|
||||
end
|
||||
|
||||
-- Finalize a caller-streamed import after the destination bytes have already
|
||||
-- been copied into the engine-owned baseroms path. This keeps large imports
|
||||
-- out of a single Lua string while preserving the same size/MD5 receipt rules.
|
||||
function RequiredImports.acceptStoredDigest(manifest, importId, digest, fs)
|
||||
fs = fs or (love and love.filesystem)
|
||||
local spec = specById(manifest, importId)
|
||||
if not spec then return nil, "unknown required import: " .. tostring(importId) end
|
||||
digest = tostring(digest or ""):lower()
|
||||
if not accepts(spec, digest) then
|
||||
return nil, ("MD5 mismatch (got %s)"):format(digest ~= "" and digest or "unavailable")
|
||||
end
|
||||
local path = RequiredImports.path(manifest, spec)
|
||||
local info = fs and fs.getInfo and fs.getInfo(path, "file") or nil
|
||||
if not info then return nil, "copied import is missing" end
|
||||
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
|
||||
if sizeErr then return nil, sizeErr end
|
||||
if love and fs == love.filesystem then
|
||||
local savedPrefix = CacheFs.prefix
|
||||
local ok, prefixErr = xpcall(function()
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.remove(removedMarker(manifest, spec))
|
||||
CacheFs.prefix = savedPrefix
|
||||
-- writeReceipt has its own temporary CacheFs prefix switch. Keep it
|
||||
-- inside this guard too so a write error cannot leak global state.
|
||||
writeReceipt(manifest, spec, digest, info, fs)
|
||||
end, function(err)
|
||||
return tostring(err)
|
||||
end)
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not ok then
|
||||
return nil, "could not finalize import receipt: " .. tostring(prefixErr)
|
||||
end
|
||||
return true, digest
|
||||
elseif fs and fs.remove then
|
||||
fs.remove(removedMarker(manifest, spec))
|
||||
end
|
||||
writeReceipt(manifest, spec, digest, info, fs)
|
||||
return true, digest
|
||||
end
|
||||
|
||||
|
||||
-- Validate bytes against a declaration. The returned data is canonicalized
|
||||
-- (notably for N64 byte order/header variants) and is what must be stored.
|
||||
function RequiredImports.validateData(spec, data, hashFn)
|
||||
@@ -217,6 +288,22 @@ function RequiredImports.validateStored(manifest, spec, fs, hashFn)
|
||||
local cached = cachedDigest(manifest, spec, fs, info)
|
||||
if cached then return true, cached, true end
|
||||
removeReceipt(manifest, spec, fs)
|
||||
-- Large raw imports (GameCube discs, future optical images, etc.) must never
|
||||
-- be materialized into one Lua string merely because their validation
|
||||
-- receipt was lost. Stream the MD5 directly from the installed file. N64
|
||||
-- sources stay on the canonicalization path because byte-order/header
|
||||
-- normalization is part of their validation contract.
|
||||
if info.size and info.size > RequiredImports.LARGE_WARN_BYTES
|
||||
and spec.format ~= "n64" and fs.newFile then
|
||||
local digest, hashErr = streamDigest(fs, path)
|
||||
if not digest then return nil, hashErr end
|
||||
if not accepts(spec, digest) then
|
||||
return nil, ("MD5 mismatch (got %s)"):format(digest)
|
||||
end
|
||||
info = fs.getInfo(path, "file") or info
|
||||
writeReceipt(manifest, spec, digest, info, fs)
|
||||
return true, digest, false
|
||||
end
|
||||
if not fs.read then return nil, "file could not be read" end
|
||||
local data = fs.read(path)
|
||||
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
|
||||
|
||||
@@ -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
@@ -19,6 +19,8 @@ Zoom.allowSurvey = true
|
||||
|
||||
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||
-- When the window only fits 1×, 1-S is 0 and there would be no OUT
|
||||
-- levels; keep three survey steps so OPTIONS always has zoom-out.
|
||||
function Zoom.offsetRange(S)
|
||||
S = math.max(1, math.floor(tonumber(S) or 1))
|
||||
local lo, hi = 1 - S, S
|
||||
@@ -30,7 +32,11 @@ function Zoom.offsetRange(S)
|
||||
end
|
||||
-- LOW performance tier: no survey (negative offsets), even if a mod's
|
||||
-- zoom.range widened it. == false so nil/true stays permissive.
|
||||
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
|
||||
if Zoom.allowSurvey == false then
|
||||
if lo < 0 then lo = 0 end
|
||||
elseif lo > -3 then
|
||||
lo = -3
|
||||
end
|
||||
return lo, hi
|
||||
end
|
||||
|
||||
@@ -44,6 +50,8 @@ function Zoom.scale(S)
|
||||
local maxScale = math.max(minScale, S + hi)
|
||||
if s < minScale then s = minScale end
|
||||
if s > maxScale then s = maxScale end
|
||||
-- Integer offset below 1px/world (OPTIONS OUT on a 1× window): 1/2, 1/4, …
|
||||
if s < 1 then s = 0.5 ^ (1 - s) end
|
||||
if s < 0.25 then s = 0.25 end
|
||||
return s
|
||||
end
|
||||
@@ -79,6 +87,30 @@ function Zoom.applyOptions(opts)
|
||||
Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0)
|
||||
end
|
||||
|
||||
-- Integer fit used when OPTIONS has no live renderer (launcher, title).
|
||||
function Zoom.windowFitScale()
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
ww, wh = tonumber(ww) or 0, tonumber(wh) or 0
|
||||
if ww >= 160 and wh >= 144 then
|
||||
return math.max(1, math.floor(math.min(ww / 160, wh / 144)))
|
||||
end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
-- One step of the OPTIONS ZOOM row (dir +1 in, -1 out). Shared by Red
|
||||
-- and Gold so both ladders offer OUT / FIT / IN.
|
||||
function Zoom.nudgeOptions(options, dir, S)
|
||||
S = math.max(1, math.floor(tonumber(S) or Zoom.windowFitScale()))
|
||||
local lo, hi = Zoom.offsetRange(S)
|
||||
local off = math.floor(tonumber(options and options.zoom) or 0) + (dir or 1)
|
||||
if off > hi then off = lo elseif off < lo then off = hi end
|
||||
if options then options.zoom = off end
|
||||
Zoom.offset = off
|
||||
return off
|
||||
end
|
||||
|
||||
-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / …
|
||||
function Zoom.offsetLabel(offset)
|
||||
offset = math.floor(tonumber(offset) or 0)
|
||||
|
||||
+3
-10
@@ -381,14 +381,7 @@ local function buildRows(game)
|
||||
return Zoom.offsetLabel(g.save.options.zoom or 0)
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
local S = Renderer:fitScale()
|
||||
local lo, hi = Zoom.offsetRange(S)
|
||||
local off = (o.zoom or 0) + dir
|
||||
if off > hi then off = lo
|
||||
elseif off < lo then off = hi end
|
||||
o.zoom = off
|
||||
Zoom.offset = off
|
||||
Zoom.nudgeOptions(g.save.options, dir, Renderer:fitScale())
|
||||
return true
|
||||
end },
|
||||
{ id = "voidFill", label = Strings("VOID FILL"),
|
||||
@@ -580,8 +573,8 @@ local function buildRows(game)
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
-- ORIENTATION only on Android, the one platform Orientation.apply reaches.
|
||||
if not Orientation.isAndroid() then
|
||||
-- ORIENTATION only on the platforms Orientation.apply reaches (#1638).
|
||||
if not (Orientation.isAndroid() or Orientation.isIOS()) then
|
||||
local filtered = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id ~= "orientation" then filtered[#filtered + 1] = row end
|
||||
|
||||
@@ -151,6 +151,7 @@ end
|
||||
|
||||
function PadCursor.joystickpressed(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return nil end
|
||||
if GamepadMap.isAccelerometer(joystick) then return nil end
|
||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||
if padButton then return PadCursor.gamepadpressed(joystick, padButton) end
|
||||
return nil
|
||||
@@ -158,12 +159,14 @@ end
|
||||
|
||||
function PadCursor.joystickreleased(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||
if padButton then PadCursor.gamepadreleased(joystick, padButton) end
|
||||
end
|
||||
|
||||
function PadCursor.joystickaxis(joystick, axisIndex, value)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
if axisIndex == 1 then
|
||||
PadCursor.gamepadaxis(joystick, "leftx", value)
|
||||
elseif axisIndex == 2 then
|
||||
@@ -173,6 +176,7 @@ end
|
||||
|
||||
function PadCursor.joystickhat(joystick, hat, direction)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if GamepadMap.isAccelerometer(joystick) then return end
|
||||
for _, d in ipairs(rawHatDirs[hat] or {}) do
|
||||
dir[d] = nil
|
||||
end
|
||||
|
||||
+548
-34
@@ -70,6 +70,12 @@ Studio.BIND_GROUPS = {
|
||||
"pause_toggle", "screenshot", "exit_emulator" } },
|
||||
{ title = "KEYBOARD",
|
||||
specs = { "key:escape", "key:return", "key:space", "key:tab", "key:f1" } },
|
||||
-- These are deliberately keyboard binds rather than new skin-only actions.
|
||||
-- They therefore take the exact same path as their desktop counterparts in
|
||||
-- Game:keypressed, including any future changes to those shortcuts.
|
||||
{ title = "DESKTOP HOTKEYS",
|
||||
specs = { "key:-", "key:=", "key:1", "key:2", "key:3", "key:4",
|
||||
"key:5", "key:f1", "key:f2", "key:f10" } },
|
||||
{ title = "NO INPUT", specs = { "nul" } },
|
||||
}
|
||||
|
||||
@@ -362,7 +368,9 @@ function Studio.load(opts)
|
||||
Studio.drag = nil
|
||||
Studio.pendingPlay = false
|
||||
Studio.canvasIndex = 1
|
||||
Studio.aspectLock = true
|
||||
-- A free-form screen is the default. 10:9 is an optional convenience,
|
||||
-- never a restriction on imported or custom skins.
|
||||
Studio.aspectLock = false
|
||||
Studio.skinIdField = ""
|
||||
Studio.available = TouchSkin.list()
|
||||
Studio.availableMeta = {}
|
||||
@@ -374,6 +382,13 @@ function Studio.load(opts)
|
||||
Studio.showLabels = true
|
||||
Studio.statusErr = false
|
||||
Studio.thumbs = {}
|
||||
Studio.pointerX, Studio.pointerY = nil, nil
|
||||
Studio.pointerDown, Studio.touchId = false, nil
|
||||
-- Studio always opens on the library. Creating and choosing a skin are
|
||||
-- first-class tasks, not controls buried inside the editor workspace.
|
||||
Studio.mode = "library"
|
||||
Studio.libraryThumbs = {}
|
||||
Studio.libraryPage = 1
|
||||
|
||||
TouchControls:init()
|
||||
TouchControls.active = true
|
||||
@@ -413,6 +428,7 @@ function Studio.open(id)
|
||||
Studio.undoStack, Studio.redoStack = {}, {}
|
||||
Studio.undoTag = nil
|
||||
Studio.thumbs = {}
|
||||
Studio.libraryThumbs = {}
|
||||
syncActive()
|
||||
Studio.applyImportedOrient()
|
||||
return true
|
||||
@@ -425,6 +441,7 @@ function Studio.newSkin()
|
||||
Studio.pageIndex, Studio.selected = 1, nil
|
||||
Studio.images = {}
|
||||
Studio.thumbs = {}
|
||||
Studio.libraryThumbs = {}
|
||||
syncActive()
|
||||
markDirty()
|
||||
end
|
||||
@@ -468,9 +485,122 @@ end
|
||||
function Studio.refreshAvailable()
|
||||
Studio.available = TouchSkin.list()
|
||||
Studio.availableMeta = {}
|
||||
Studio.libraryThumbs = {}
|
||||
return Studio.available
|
||||
end
|
||||
|
||||
function Studio.enterEditor()
|
||||
Studio.mode = "editor"
|
||||
Studio.selected = nil
|
||||
Studio.modal, Studio.confirm = nil, nil
|
||||
Studio.drag, Studio.guides = nil, nil
|
||||
end
|
||||
|
||||
function Studio.backToLibrary()
|
||||
Studio.guard("Return to My Skins and lose the unsaved changes?", function()
|
||||
Studio.refreshAvailable()
|
||||
Studio.mode = "library"
|
||||
Studio.selected = nil
|
||||
Studio.modal, Studio.confirm = nil, nil
|
||||
Studio.drag, Studio.guides = nil, nil
|
||||
end)
|
||||
end
|
||||
|
||||
function Studio.libraryThumb(entry)
|
||||
if not entry then return nil end
|
||||
Studio.libraryThumbs = Studio.libraryThumbs or {}
|
||||
local cached = Studio.libraryThumbs[entry.id]
|
||||
if cached ~= nil then return cached or nil end
|
||||
local skin = TouchSkin.load(entry.root, entry.id)
|
||||
local image = skin and skin.pages and skin.pages[1] and skin.pages[1].image
|
||||
Studio.libraryThumbs[entry.id] = image or false
|
||||
return image
|
||||
end
|
||||
|
||||
function Studio.exportEntry(id)
|
||||
local entry = TouchSkin.find(id)
|
||||
local skin = entry and TouchSkin.load(entry.root, entry.id)
|
||||
if not skin then
|
||||
setStatus("Could not read " .. tostring(id), true)
|
||||
return nil
|
||||
end
|
||||
local path, missing = TouchSkin.export(skin)
|
||||
if not path then
|
||||
setStatus("Export failed: " .. tostring(missing), true)
|
||||
return nil
|
||||
end
|
||||
Studio.lastExport = path
|
||||
setStatus("Exported " .. path)
|
||||
return path
|
||||
end
|
||||
|
||||
function Studio.selectEntry(id)
|
||||
local entry = TouchSkin.find(id)
|
||||
if not entry then
|
||||
setStatus("Could not find " .. tostring(id), true)
|
||||
return false
|
||||
end
|
||||
local opts = SaveData.loadOptions()
|
||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
tc.enabled, tc.skin = true, id
|
||||
opts.touchControls = tc
|
||||
SaveData.saveOptions(opts)
|
||||
TouchControls:applyOptions(opts)
|
||||
setStatus("Using " .. id)
|
||||
return true
|
||||
end
|
||||
|
||||
function Studio.deleteEntry(id)
|
||||
local entry = TouchSkin.find(id)
|
||||
if not entry then return false end
|
||||
if entry.source ~= "user" then
|
||||
setStatus("Bundled skins cannot be deleted.", true)
|
||||
return false
|
||||
end
|
||||
Studio.ask("Delete " .. tostring(id) .. "? This removes its artwork and cannot be undone.",
|
||||
function()
|
||||
local ok, err = TouchSkin.remove(id)
|
||||
if not ok then
|
||||
setStatus("Delete failed: " .. tostring(err), true)
|
||||
return
|
||||
end
|
||||
local opts = SaveData.loadOptions()
|
||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
if tc.skin == id then
|
||||
tc.enabled, tc.skin = false, nil
|
||||
SaveData.saveOptions(opts)
|
||||
end
|
||||
Studio.refreshAvailable()
|
||||
setStatus("Deleted " .. id)
|
||||
end, "Delete")
|
||||
return true
|
||||
end
|
||||
|
||||
function Studio.importSkinFile()
|
||||
if not FilePicker.available() then
|
||||
setStatus("On mobile, use Import in the Skins tab to add a downloaded skin.", true)
|
||||
return false
|
||||
end
|
||||
local kind = { label = "Skin", exts = { "zip", "deltaskin", "cfg" } }
|
||||
local path = FilePicker.open("Choose a skin", kind)
|
||||
if not path then return false end
|
||||
local name, data = FilePicker.basename(path), FilePicker.read(path)
|
||||
if not data then
|
||||
setStatus("Could not read " .. name, true)
|
||||
return false
|
||||
end
|
||||
local id, note = TouchSkin.installArchive(name, data)
|
||||
if not id then
|
||||
setStatus("Import failed: " .. tostring(note), true)
|
||||
return false
|
||||
end
|
||||
Studio.refreshAvailable()
|
||||
Studio.open(id)
|
||||
Studio.enterEditor()
|
||||
setStatus("Imported " .. id)
|
||||
return true
|
||||
end
|
||||
|
||||
function Studio.openLoadPicker()
|
||||
return Studio.guard("Open another skin and lose the unsaved changes?",
|
||||
function()
|
||||
@@ -509,6 +639,29 @@ function Studio.unload()
|
||||
Studio.modal, Studio.confirm = nil, nil
|
||||
Studio.guides = nil
|
||||
Studio.undoStack, Studio.redoStack = {}, {}
|
||||
Studio.pointerX, Studio.pointerY = nil, nil
|
||||
Studio.pointerDown, Studio.touchId = false, nil
|
||||
end
|
||||
|
||||
-- The Studio is a real touch editor on Android and iOS. Keeping this here,
|
||||
-- instead of asking the host to synthesize a mouse, makes the drag state and
|
||||
-- the immediate-mode hit tests agree on the same finger position.
|
||||
function Studio.isMobile()
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
end
|
||||
|
||||
function Studio.disableTouchControls()
|
||||
local opts = SaveData.loadOptions()
|
||||
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||
tc.enabled, tc.skin = false, nil
|
||||
opts.touchControls = tc
|
||||
SaveData.saveOptions(opts)
|
||||
TouchControls:applyOptions(opts)
|
||||
TouchSkin.setActive(nil)
|
||||
Studio.closeModal()
|
||||
setStatus("On-screen controls are off.")
|
||||
return true
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- editing
|
||||
@@ -678,7 +831,12 @@ function Studio.cycleImage(dir)
|
||||
local rel = (next_ >= 1) and list[next_] or nil
|
||||
owner[key] = rel
|
||||
local img = rel and TouchSkin.resolveImage(Studio.skin.root, rel) or nil
|
||||
if field == "pressed" then owner.pressedImage = img else owner.image = img end
|
||||
if field == "pressed" then
|
||||
owner.pressedImage = img
|
||||
else
|
||||
owner.image = img
|
||||
if field == "bezel" then TouchSkin.applyImageAspect(owner) end
|
||||
end
|
||||
markDirty()
|
||||
end
|
||||
|
||||
@@ -748,6 +906,7 @@ function Studio.assignImage(rel)
|
||||
ctl.imagePath, ctl.image = rel, img
|
||||
elseif page then
|
||||
page.imagePath, page.image = rel, img
|
||||
TouchSkin.applyImageAspect(page)
|
||||
end
|
||||
Studio.images = TouchSkin.listImages(Studio.skin.root)
|
||||
Studio.dirty = true
|
||||
@@ -1228,8 +1387,11 @@ function Studio.updateDrag(mx, my, r)
|
||||
ctl.rangeX = math.max(0.002, (bw * 0.5) / pw)
|
||||
ctl.rangeY = math.max(0.002, (bh * 0.5) / ph)
|
||||
else
|
||||
-- Permit an anchor to live beyond every canvas edge. That is useful for
|
||||
-- intentional off-centre compositions and mirrors the normal Screen Pos
|
||||
-- behaviour at runtime; only a non-zero size is required.
|
||||
page.viewport = {
|
||||
x = clamp01((bx - px) / pw), y = clamp01((by - py) / ph),
|
||||
x = (bx - px) / pw, y = (by - py) / ph,
|
||||
w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph),
|
||||
}
|
||||
end
|
||||
@@ -1238,6 +1400,35 @@ end
|
||||
|
||||
-- ----------------------------------------------------------------- draw
|
||||
|
||||
local function drawGameTest(r, page)
|
||||
-- Test letterboxes a 160x144 picture inside the screen cutout, the same
|
||||
-- way gameplay fits the Game Boy surface into the hole.
|
||||
local vx, vy, vw, vh = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y)
|
||||
if not vx then vx, vy, vw, vh = r.x, r.y, r.w, r.h end
|
||||
local s = math.min(vw / 160, vh / 144)
|
||||
local gw, gh = 160 * s, 144 * s
|
||||
local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5
|
||||
Theme.fill(r.x, r.y, r.w, r.h, { 8, 12, 8 }, 1)
|
||||
Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1)
|
||||
local tile = math.max(2, math.floor(16 * s))
|
||||
for row = 0, 8 do
|
||||
for col = 0, 9 do
|
||||
if (row + col) % 2 == 0 then
|
||||
Theme.fill(gx + col * tile, gy + row * tile, tile, tile,
|
||||
{ 139, 172, 15 }, 0.45)
|
||||
end
|
||||
end
|
||||
end
|
||||
Theme.fill(gx + gw * 0.10, gy + gh * 0.15, gw * 0.22, gh * 0.18,
|
||||
{ 48, 98, 48 }, 0.9)
|
||||
Theme.fill(gx + gw * 0.58, gy + gh * 0.49, gw * 0.12, gh * 0.18,
|
||||
{ 48, 98, 48 }, 0.9)
|
||||
Theme.fill(gx + gw * 0.14, gy + gh * 0.70, gw * 0.72, gh * 0.16,
|
||||
{ 224, 248, 208 }, 1)
|
||||
Kit.text("small", "TEST BATTLE", gx + gw * 0.18, gy + gh * 0.74,
|
||||
{ 24, 56, 24 })
|
||||
end
|
||||
|
||||
local function drawCanvas(x, y, w, h)
|
||||
local page = Studio.page()
|
||||
local r = { }
|
||||
@@ -1251,7 +1442,9 @@ local function drawCanvas(x, y, w, h)
|
||||
syncActive()
|
||||
|
||||
local vx, vy, vw, vh = viewportRect(page, r)
|
||||
if vx then
|
||||
if Studio.testing then
|
||||
drawGameTest(r, page)
|
||||
elseif vx then
|
||||
local scale = math.min(vw / 160, vh / 144)
|
||||
local gw, gh = 160 * scale, 144 * scale
|
||||
local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5
|
||||
@@ -1714,13 +1907,24 @@ local function drawOpenModal(W, H)
|
||||
local rowH = math.max(Kit.tapMin(), 46 * Kit.scale)
|
||||
local gap = 4 * Kit.scale
|
||||
local list = Studio.available or {}
|
||||
local contentH = #list * (rowH + gap)
|
||||
-- A visible Off choice is more intentional than making people infer that
|
||||
-- an empty selection disables the overlay. It also works from the skin
|
||||
-- grid on phones, where the normal pad editor is not the destination.
|
||||
local offH = rowH + gap
|
||||
local contentH = offH + #list * (rowH + gap)
|
||||
local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH)
|
||||
local maxScroll = Kit.scrollExtent(contentH, viewH)
|
||||
local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll)
|
||||
|
||||
local offClicked = Kit.row(mx + pad, baseY, mw - pad * 2, rowH, false,
|
||||
"open-off")
|
||||
Kit.text("mono", "Off", mx + pad * 2, baseY + 6 * Kit.scale, PAL.red)
|
||||
Kit.text("small", "Hide all on-screen controls.", mx + pad * 2,
|
||||
baseY + 6 * Kit.scale + Kit.textHeight("mono"), PAL.muted)
|
||||
if offClicked then Studio.disableTouchControls() end
|
||||
|
||||
for i, entry in ipairs(list) do
|
||||
local py = baseY + (i - 1) * (rowH + gap)
|
||||
local py = baseY + offH + (i - 1) * (rowH + gap)
|
||||
local selected = Studio.skin and Studio.skin.id == entry.id
|
||||
local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected,
|
||||
"open-" .. entry.id)
|
||||
@@ -1744,14 +1948,19 @@ local function drawPageModal(W, H)
|
||||
local top = my + pad + Kit.textHeight("title") + pad * 0.5
|
||||
local by, bh = modalFooter(mx, my, mw, mh, pad)
|
||||
local gap = 6 * Kit.scale
|
||||
local fieldW = math.min(220 * Kit.scale, (mw - pad * 2) * 0.5)
|
||||
Studio.pageNameField = Kit.textfield("pagename", mx + pad, by, fieldW, bh,
|
||||
-- Keep the page name easy to edit: one full-width field, then its actions
|
||||
-- below it, with Close retaining its own footer row.
|
||||
local fieldY = by - bh * 2 - gap * 2
|
||||
local actionY = by - bh - gap
|
||||
local innerW = mw - pad * 2
|
||||
local actionW = (innerW - gap) * 0.5
|
||||
Studio.pageNameField = Kit.textfield("pagename", mx + pad, fieldY, innerW, bh,
|
||||
Studio.pageNameField or "", "page name")
|
||||
if Kit.button(mx + pad + fieldW + gap, by, 100 * Kit.scale, bh, "Rename",
|
||||
if Kit.button(mx + pad, actionY, actionW, bh, "Rename",
|
||||
{ id = "page-rename" }) then
|
||||
Studio.renamePage(Studio.pageNameField)
|
||||
end
|
||||
if Kit.button(mx + pad + fieldW + gap + 106 * Kit.scale, by, 100 * Kit.scale,
|
||||
if Kit.button(mx + pad + actionW + gap, actionY, actionW,
|
||||
bh, "Delete", { id = "page-del", kind = "danger",
|
||||
enabled = skin and #skin.pages > 1 }) then
|
||||
local index = Studio.pageIndex
|
||||
@@ -1760,7 +1969,7 @@ local function drawPageModal(W, H)
|
||||
function() Studio.deletePage(index) end, "Delete")
|
||||
end
|
||||
|
||||
local viewH = by - top - pad
|
||||
local viewH = fieldY - top - pad
|
||||
local rowH = math.max(Kit.tapMin(), 44 * Kit.scale)
|
||||
local pages = (skin and skin.pages) or {}
|
||||
local contentH = #pages * (rowH + gap)
|
||||
@@ -1852,10 +2061,11 @@ function Studio.drawOverlay(W, H)
|
||||
return true
|
||||
end
|
||||
|
||||
function Studio.draw()
|
||||
local function drawLegacyStudio()
|
||||
local W, H = love.graphics.getDimensions()
|
||||
Kit.layout(W, H)
|
||||
local mx, my = love.mouse.getPosition()
|
||||
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
|
||||
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
|
||||
Studio.clicked, Studio.wheel = false, 0
|
||||
|
||||
@@ -1864,50 +2074,64 @@ function Studio.draw()
|
||||
Kit.blockClicks = Studio.modalUp()
|
||||
|
||||
local pad = 14 * Kit.scale
|
||||
local barH = math.max(Kit.tapMin(), 34 * Kit.scale) + pad
|
||||
local mobile = Studio.isMobile()
|
||||
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
|
||||
-- A phone gets an intentional two-row toolbar. The desktop's single row
|
||||
-- is excellent at a wide monitor, but it would run its canvas/test/history
|
||||
-- controls beneath Close on a portrait display.
|
||||
local barH = mobile and (btnH * 2 + pad * 1.5) or (btnH + pad)
|
||||
|
||||
Kit.textBold("title", "Skin Studio", pad, pad * 0.6, PAL.heading)
|
||||
local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2
|
||||
|
||||
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
|
||||
local bx = titleW
|
||||
local toolY = mobile and (pad * 0.5 + btnH + pad * 0.25) or (pad * 0.5)
|
||||
local bx = mobile and pad or titleW
|
||||
local canvas = Studio.canvas()
|
||||
if Kit.button(bx, pad * 0.5, 200 * Kit.scale, btnH,
|
||||
local canvasW = mobile and math.min(200 * Kit.scale, W * 0.4) or 200 * Kit.scale
|
||||
if Kit.button(bx, toolY, canvasW, btnH,
|
||||
canvas.label, { id = "canvas" }) then
|
||||
Studio.setCanvas(Studio.canvasIndex + 1)
|
||||
end
|
||||
bx = bx + 208 * Kit.scale
|
||||
if Kit.button(bx, pad * 0.5, 110 * Kit.scale, btnH,
|
||||
bx = bx + canvasW + 8 * Kit.scale
|
||||
local testW = mobile and math.min(110 * Kit.scale, W * 0.21) or 110 * Kit.scale
|
||||
if Kit.button(bx, toolY, testW, btnH,
|
||||
Studio.testing and "Test: ON" or "Test: OFF",
|
||||
{ id = "test", active = Studio.testing }) then
|
||||
Studio.testing = not Studio.testing
|
||||
TouchControls:setPreview(not Studio.testing)
|
||||
TouchControls:reset()
|
||||
end
|
||||
bx = bx + 118 * Kit.scale
|
||||
bx = bx + testW + 8 * Kit.scale
|
||||
local smallW = 74 * Kit.scale
|
||||
if Kit.button(bx, pad * 0.5, smallW, btnH, "Undo",
|
||||
if Kit.button(bx, toolY, smallW, btnH, "Undo",
|
||||
{ id = "undo", font = "small", enabled = Studio.canUndo() }) then
|
||||
Studio.undo()
|
||||
end
|
||||
bx = bx + smallW + 6 * Kit.scale
|
||||
if Kit.button(bx, pad * 0.5, smallW, btnH, "Redo",
|
||||
if Kit.button(bx, toolY, smallW, btnH, "Redo",
|
||||
{ id = "redo", font = "small", enabled = Studio.canRedo() }) then
|
||||
Studio.redo()
|
||||
end
|
||||
bx = bx + smallW + 6 * Kit.scale
|
||||
if Kit.button(bx, pad * 0.5, smallW + 20 * Kit.scale, btnH,
|
||||
if not mobile then
|
||||
if Kit.button(bx, toolY, smallW + 20 * Kit.scale, btnH,
|
||||
Studio.showLabels and "Labels: ON" or "Labels: OFF",
|
||||
{ id = "labels", font = "small", active = Studio.showLabels }) then
|
||||
Studio.showLabels = not Studio.showLabels
|
||||
end
|
||||
bx = bx + smallW + 26 * Kit.scale
|
||||
end
|
||||
|
||||
if Studio.dirty then
|
||||
Kit.text("small", "unsaved", bx, pad * 0.5 + btnH * 0.3, PAL.yellow)
|
||||
Kit.text("small", "unsaved", bx, toolY + btnH * 0.3, PAL.yellow)
|
||||
end
|
||||
|
||||
local closeW = 100 * Kit.scale
|
||||
local offW = 74 * Kit.scale
|
||||
if Kit.button(W - pad - closeW - offW - 6 * Kit.scale, pad * 0.5, offW, btnH,
|
||||
"Off", { id = "off", kind = "danger" }) then
|
||||
Studio.disableTouchControls()
|
||||
end
|
||||
local closed = false
|
||||
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close",
|
||||
{ id = "close" }) then
|
||||
@@ -1922,17 +2146,28 @@ function Studio.draw()
|
||||
return
|
||||
end
|
||||
|
||||
local panelW = math.min(360 * Kit.scale, W * 0.34)
|
||||
local bodyY = barH + pad * 0.5
|
||||
local bodyH = H - bodyY - pad
|
||||
|
||||
local r, cx, cw, footY
|
||||
if Studio.isMobile() then
|
||||
-- On a phone the canvas stays wide and the inspector becomes the lower
|
||||
-- sheet. Both remain on screen, so a tapped control can be adjusted
|
||||
-- without swapping modes or hiding the preview.
|
||||
cx, cw = pad, W - pad * 2
|
||||
local canvasH = math.max(180 * Kit.scale, (bodyH - 46 * Kit.scale) * 0.46)
|
||||
r = drawCanvas(cx, bodyY, cw, canvasH)
|
||||
local inspectorY = bodyY + canvasH + 8 * Kit.scale
|
||||
local inspectorH = math.max(0, H - inspectorY - pad - 30 * Kit.scale)
|
||||
drawInspector(pad, inspectorY, W - pad * 2, inspectorH)
|
||||
footY = H - pad - 24 * Kit.scale
|
||||
else
|
||||
local panelW = math.min(360 * Kit.scale, W * 0.34)
|
||||
drawInspector(pad, bodyY, panelW, bodyH)
|
||||
|
||||
local cx = pad * 2 + panelW
|
||||
local cw = W - cx - pad
|
||||
local r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale)
|
||||
|
||||
local footY = bodyY + bodyH - 30 * Kit.scale
|
||||
cx = pad * 2 + panelW
|
||||
cw = W - cx - pad
|
||||
r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale)
|
||||
footY = bodyY + bodyH - 30 * Kit.scale
|
||||
end
|
||||
local msg = Studio.status
|
||||
if not msg and Studio.testing then
|
||||
local held = {}
|
||||
@@ -1954,6 +2189,259 @@ function Studio.draw()
|
||||
Studio.canvasArea = r
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------ mobile-first studio
|
||||
|
||||
-- Kept as an opt-in diagnostic renderer while downstream tools migrate; the
|
||||
-- active Studio path below is the library/editor flow.
|
||||
Studio.drawLegacy = drawLegacyStudio
|
||||
|
||||
local function studioCard(x, y, w, h, id, active)
|
||||
local focused = Kit.focusable(id, x, y, w, h)
|
||||
Kit.card(x, y, w, h, active and "selected"
|
||||
or (focused or Kit.hover(x, y, w, h)))
|
||||
return Kit.press(x, y, w, h) or Kit._activateId == id
|
||||
end
|
||||
|
||||
local function drawSkinArtwork(image, x, y, w, h)
|
||||
Theme.fillRounded(x, y, w, h, PAL.bg, 1, Theme.cardRadius() * 0.65)
|
||||
if image and image.getDimensions then
|
||||
local iw, ih = image:getDimensions()
|
||||
if iw > 0 and ih > 0 then
|
||||
local s = math.min((w - 14 * Kit.scale) / iw, (h - 14 * Kit.scale) / ih)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(image, x + (w - iw * s) * 0.5,
|
||||
y + (h - ih * s) * 0.5, 0, s, s)
|
||||
return
|
||||
end
|
||||
end
|
||||
-- A neutral mock reads as a touch skin even before it has artwork.
|
||||
Theme.strokeRounded(x + w * 0.19, y + h * 0.10, w * 0.62, h * 0.44,
|
||||
PAL.line, Theme.A.hairline, 1, 3)
|
||||
Theme.fillRounded(x + w * 0.13, y + h * 0.64, w * 0.26, h * 0.18,
|
||||
PAL.steel, 0.8, 3)
|
||||
Theme.fillRounded(x + w * 0.60, y + h * 0.59, w * 0.13, h * 0.24,
|
||||
PAL.steel, 0.8, h * 0.12)
|
||||
Theme.fillRounded(x + w * 0.77, y + h * 0.54, w * 0.13, h * 0.24,
|
||||
PAL.steel, 0.8, h * 0.12)
|
||||
end
|
||||
|
||||
local function drawLibrary(W, H, pad)
|
||||
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
|
||||
local titleY = pad * 0.55
|
||||
Kit.textBold("title", "My Skins", pad, titleY, PAL.heading)
|
||||
local closeW = math.min(112 * Kit.scale, W * 0.24)
|
||||
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close",
|
||||
{ id = "library-close" }) then
|
||||
if Studio.onClose then Studio.onClose() end
|
||||
return
|
||||
end
|
||||
|
||||
local y = pad * 0.5 + btnH + pad
|
||||
local gap = 8 * Kit.scale
|
||||
local half = (W - pad * 2 - gap) * 0.5
|
||||
if Kit.button(pad, y, half, btnH * 1.12, "+ New skin",
|
||||
{ id = "library-new", kind = "accent" }) then
|
||||
Studio.newSkin()
|
||||
Studio.enterEditor()
|
||||
return
|
||||
end
|
||||
if Kit.button(pad + half + gap, y, half, btnH * 1.12, "Import skin",
|
||||
{ id = "library-import" }) then
|
||||
Studio.importSkinFile()
|
||||
return
|
||||
end
|
||||
y = y + btnH * 1.12 + pad
|
||||
|
||||
Kit.caption(pad, y, "YOUR SKINS")
|
||||
y = y + Kit.textHeight("small") + gap
|
||||
|
||||
local entries = Studio.available or {}
|
||||
local cols = W >= 520 * Kit.scale and 3 or 2
|
||||
local cardGap = 10 * Kit.scale
|
||||
local cardW = (W - pad * 2 - cardGap * (cols - 1)) / cols
|
||||
local cardH = math.max(182 * Kit.scale, cardW * 1.42)
|
||||
local saved = SaveData.loadOptions()
|
||||
local tc = type(saved.touchControls) == "table" and saved.touchControls or {}
|
||||
local activeId = tc.enabled == false and nil or tc.skin
|
||||
local total = #entries
|
||||
local maxRows = math.max(1, math.floor((H - y - pad - btnH - gap)
|
||||
/ (cardH + cardGap)))
|
||||
local perPage = maxRows * cols
|
||||
local pages = math.max(1, math.ceil(total / perPage))
|
||||
Studio.libraryPage = math.max(1, math.min(Studio.libraryPage or 1, pages))
|
||||
local first = (Studio.libraryPage - 1) * perPage + 1
|
||||
local last = math.min(total, first + perPage - 1)
|
||||
|
||||
for slot = first, last do
|
||||
local index = slot - first
|
||||
local cx = pad + (index % cols) * (cardW + cardGap)
|
||||
local cy = y + math.floor(index / cols) * (cardH + cardGap)
|
||||
do
|
||||
local entry = entries[slot]
|
||||
local selected = entry.id == activeId
|
||||
Kit.card(cx, cy, cardW, cardH, selected and "selected" or nil)
|
||||
local artH = cardH * 0.45
|
||||
drawSkinArtwork(Studio.libraryThumb(entry), cx + 7 * Kit.scale,
|
||||
cy + 7 * Kit.scale, cardW - 14 * Kit.scale, artH)
|
||||
Kit.text("mono", Kit.ellipsize("mono", entry.id, cardW - 20 * Kit.scale),
|
||||
cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale,
|
||||
selected and PAL.green or PAL.heading)
|
||||
local meta = Studio.skinSummary(entry)
|
||||
Kit.text("small", Kit.ellipsize("small", meta, cardW - 20 * Kit.scale),
|
||||
cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale + Kit.textHeight("mono"),
|
||||
PAL.muted)
|
||||
local actionGap = 4 * Kit.scale
|
||||
local actionW = (cardW - 20 * Kit.scale - actionGap) * 0.5
|
||||
local actionY = cy + cardH - btnH * 2 - actionGap - 8 * Kit.scale
|
||||
if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH,
|
||||
selected and "Selected" or "Select",
|
||||
{ id = "skin-select-" .. entry.id,
|
||||
kind = selected and "good" or "accent" }) then
|
||||
Studio.selectEntry(entry.id)
|
||||
end
|
||||
if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH,
|
||||
"Edit", { id = "skin-edit-" .. entry.id, kind = "accent" }) then
|
||||
Studio.loadEntry(entry.id)
|
||||
Studio.enterEditor()
|
||||
return
|
||||
end
|
||||
actionY = actionY + btnH + actionGap
|
||||
if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH,
|
||||
"Export", { id = "skin-export-" .. entry.id }) then
|
||||
Studio.exportEntry(entry.id)
|
||||
end
|
||||
if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH,
|
||||
"Delete", { id = "skin-delete-" .. entry.id,
|
||||
kind = "danger", enabled = entry.source == "user" }) then
|
||||
Studio.deleteEntry(entry.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if pages > 1 then
|
||||
local pagerY = H - pad - btnH
|
||||
local prevW, nextW = (W - pad * 2 - gap) * 0.5, (W - pad * 2 - gap) * 0.5
|
||||
if Kit.button(pad, pagerY, prevW, btnH, "Previous",
|
||||
{ id = "library-prev", enabled = Studio.libraryPage > 1 }) then
|
||||
Studio.libraryPage = Studio.libraryPage - 1
|
||||
end
|
||||
if Kit.button(pad + prevW + gap, pagerY, nextW, btnH, "Next",
|
||||
{ id = "library-next", enabled = Studio.libraryPage < pages }) then
|
||||
Studio.libraryPage = Studio.libraryPage + 1
|
||||
end
|
||||
end
|
||||
|
||||
if #entries == 0 then
|
||||
Kit.text("small", "Create a blank skin or import one to get started.", pad,
|
||||
y + cardH + gap, PAL.muted)
|
||||
end
|
||||
end
|
||||
|
||||
local function drawEditor(W, H, pad)
|
||||
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
|
||||
local gap = 7 * Kit.scale
|
||||
local backW = math.min(120 * Kit.scale, W * 0.23)
|
||||
if Kit.button(pad, pad * 0.5, backW, btnH, "My Skins",
|
||||
{ id = "editor-back" }) then
|
||||
Studio.backToLibrary()
|
||||
end
|
||||
local closeW = math.min(90 * Kit.scale, W * 0.17)
|
||||
local saveW = math.min(90 * Kit.scale, W * 0.17)
|
||||
local testW = math.min(100 * Kit.scale, W * 0.18)
|
||||
local right = W - pad
|
||||
if Kit.button(right - closeW, pad * 0.5, closeW, btnH, "Close",
|
||||
{ id = "editor-close" }) then
|
||||
Studio.guard("Close the studio and lose the unsaved changes?", function()
|
||||
if Studio.onClose then Studio.onClose() end
|
||||
end)
|
||||
end
|
||||
right = right - closeW - gap
|
||||
if Kit.button(right - saveW, pad * 0.5, saveW, btnH, "Save",
|
||||
{ id = "editor-save", kind = "accent" }) then Studio.save() end
|
||||
right = right - saveW - gap
|
||||
if Kit.button(right - testW, pad * 0.5, testW, btnH,
|
||||
Studio.testing and "Test: ON" or "Test",
|
||||
{ id = "editor-test", active = Studio.testing }) then
|
||||
Studio.testing = not Studio.testing
|
||||
TouchControls:setPreview(not Studio.testing)
|
||||
TouchControls:reset()
|
||||
end
|
||||
local titleX = pad + backW + gap
|
||||
local titleW = math.max(0, right - testW - gap - titleX)
|
||||
Kit.textBold("button", Kit.ellipsize("button",
|
||||
(Studio.skin and Studio.skin.name) or "Untitled skin", titleW),
|
||||
titleX, pad * 0.5 + (btnH - Kit.textHeight("button")) * 0.5, PAL.heading)
|
||||
|
||||
local trayH = btnH * 2 + gap * 3 + 30 * Kit.scale
|
||||
local bodyY = pad * 0.5 + btnH + pad
|
||||
local trayY = H - pad - trayH
|
||||
local canvasH = math.max(120 * Kit.scale, trayY - bodyY - pad)
|
||||
drawCanvas(pad, bodyY, W - pad * 2, canvasH)
|
||||
|
||||
Kit.card(pad, trayY, W - pad * 2, trayH)
|
||||
local innerX, innerW = pad + gap, W - pad * 2 - gap * 2
|
||||
local actionW = (innerW - gap * 3) / 4
|
||||
local ctl = Studio.selectedControl()
|
||||
if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control",
|
||||
{ id = "tray-add", kind = "accent" }) then
|
||||
Studio.addControl()
|
||||
Studio.openBindPicker()
|
||||
end
|
||||
if Kit.button(innerX + (actionW + gap), trayY + gap, actionW, btnH,
|
||||
ctl and ("Bind: " .. ctl.spec) or "Bind",
|
||||
{ id = "tray-bind", enabled = ctl ~= nil }) then
|
||||
Studio.openBindPicker()
|
||||
end
|
||||
if Kit.button(innerX + (actionW + gap) * 2, trayY + gap, actionW, btnH,
|
||||
"Button art", { id = "tray-art", enabled = ctl ~= nil }) then
|
||||
Studio.openImagePicker("idle")
|
||||
end
|
||||
if Kit.button(innerX + (actionW + gap) * 3, trayY + gap, actionW, btnH,
|
||||
"Bezel art", { id = "tray-bezel" }) then
|
||||
Studio.openImagePicker("bezel")
|
||||
end
|
||||
|
||||
if Kit.button(innerX, trayY + gap * 2 + btnH, actionW, btnH, "Pages",
|
||||
{ id = "tray-pages" }) then Studio.openPageMenu() end
|
||||
if Kit.button(innerX + (actionW + gap), trayY + gap * 2 + btnH, actionW, btnH,
|
||||
"Screen", { id = "tray-screen" }) then Studio.toggleViewport() end
|
||||
if Kit.button(innerX + (actionW + gap) * 2, trayY + gap * 2 + btnH, actionW, btnH,
|
||||
Studio.aspectLock and "Shape: 10:9" or "Shape: Free",
|
||||
{ id = "tray-shape", active = Studio.aspectLock }) then
|
||||
Studio.aspectLock = not Studio.aspectLock
|
||||
end
|
||||
if Kit.button(innerX + (actionW + gap) * 3, trayY + gap * 2 + btnH, actionW, btnH,
|
||||
"Delete", { id = "tray-delete", kind = "danger", enabled = ctl ~= nil }) then
|
||||
Studio.deleteControl()
|
||||
end
|
||||
local hint = ctl and ("Selected: " .. TouchSkin.describeBind(ctl.spec)
|
||||
.. " — drag to move; use blue handles to resize.")
|
||||
or "Tap a control to select it. Drag to move; use blue handles to resize."
|
||||
Kit.text("small", Kit.ellipsize("small", Studio.status or hint, innerW),
|
||||
innerX, trayY + trayH - Kit.textHeight("small") - gap,
|
||||
Studio.statusErr and PAL.red or PAL.muted)
|
||||
end
|
||||
|
||||
function Studio.draw()
|
||||
local W, H = love.graphics.getDimensions()
|
||||
Kit.layout(W, H)
|
||||
local mx, my = love.mouse.getPosition()
|
||||
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
|
||||
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
|
||||
Studio.clicked, Studio.wheel = false, 0
|
||||
Theme.fill(0, 0, W, H, PAL.bg, 1)
|
||||
Studio.expireStatus()
|
||||
Kit.blockClicks = Studio.modalUp()
|
||||
|
||||
local pad = 14 * Kit.scale
|
||||
if Studio.mode == "library" then drawLibrary(W, H, pad)
|
||||
else drawEditor(W, H, pad) end
|
||||
|
||||
Kit.blockClicks = false
|
||||
Studio.drawOverlay(W, H)
|
||||
Kit.endFrame()
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- input
|
||||
|
||||
function Studio.update()
|
||||
@@ -1965,8 +2453,11 @@ end
|
||||
|
||||
function Studio.mousepressed(x, y, button)
|
||||
if button ~= 1 then return end
|
||||
Studio.pointerX, Studio.pointerY = x, y
|
||||
Studio.pointerDown = true
|
||||
Studio.clicked = true
|
||||
if Studio.modalUp() then return end
|
||||
if Studio.mode == "library" then return end
|
||||
local r = Studio.lastCanvas
|
||||
if not r then return end
|
||||
if Studio.testing then
|
||||
@@ -1984,18 +2475,22 @@ function Studio.mousepressed(x, y, button)
|
||||
end
|
||||
|
||||
function Studio.mousemoved(x, y)
|
||||
Studio.pointerX, Studio.pointerY = x, y
|
||||
if Studio.modalUp() then return end
|
||||
if Studio.mode == "library" then return end
|
||||
if Studio.testing then
|
||||
TouchControls:touchmoved("studio", x, y)
|
||||
return
|
||||
end
|
||||
if Studio.drag and love.mouse.isDown(1) and Studio.lastCanvas then
|
||||
if Studio.drag and (Studio.pointerDown or love.mouse.isDown(1)) and Studio.lastCanvas then
|
||||
Studio.updateDrag(x, y, Studio.lastCanvas)
|
||||
end
|
||||
end
|
||||
|
||||
function Studio.mousereleased(x, y, button)
|
||||
if button ~= 1 then return end
|
||||
Studio.pointerX, Studio.pointerY = x, y
|
||||
Studio.pointerDown = false
|
||||
if Studio.testing then
|
||||
TouchControls:touchreleased("studio", x, y)
|
||||
return
|
||||
@@ -2004,6 +2499,23 @@ function Studio.mousereleased(x, y, button)
|
||||
Studio.guides = nil
|
||||
end
|
||||
|
||||
function Studio.touchpressed(id, x, y)
|
||||
if Studio.touchId and Studio.touchId ~= id then return end
|
||||
Studio.touchId = id
|
||||
return Studio.mousepressed(x, y, 1)
|
||||
end
|
||||
|
||||
function Studio.touchmoved(id, x, y)
|
||||
if Studio.touchId ~= id then return end
|
||||
return Studio.mousemoved(x, y)
|
||||
end
|
||||
|
||||
function Studio.touchreleased(id, x, y)
|
||||
if Studio.touchId ~= id then return end
|
||||
Studio.touchId = nil
|
||||
return Studio.mousereleased(x, y, 1)
|
||||
end
|
||||
|
||||
function Studio.wheelmoved(_, dy)
|
||||
Studio.wheel = dy
|
||||
end
|
||||
@@ -2011,6 +2523,7 @@ end
|
||||
function Studio.focus()
|
||||
Studio.drag = nil
|
||||
Studio.clicked = false
|
||||
Studio.pointerDown, Studio.touchId = false, nil
|
||||
if Studio.testing then TouchControls:reset() end
|
||||
end
|
||||
|
||||
@@ -2094,8 +2607,9 @@ function Studio.keypressed(key)
|
||||
end
|
||||
|
||||
function Studio.available_desktop()
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
return osName ~= "Android" and osName ~= "iOS"
|
||||
-- Kept as the compatibility name for launcher integrations. The Studio
|
||||
-- now has a touch layout and is available on every supported platform.
|
||||
return true
|
||||
end
|
||||
|
||||
return Studio
|
||||
|
||||
@@ -838,8 +838,7 @@ function BoxMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -49,9 +49,9 @@
|
||||
-- Textbox at (0,12) with an 18x4 interior
|
||||
--
|
||||
-- The cart's own art (gfx/card_flip/card_flip_1..3.2bpp.lz and
|
||||
-- gfx/card_flip/card_flip.tilemap) is NOT in the cache: no `cardFlip` entry is
|
||||
-- written into menu_gfx.lua yet, so the board draws as labelled cells until one
|
||||
-- appears.
|
||||
-- gfx/card_flip/card_flip.tilemap) is extracted into assets/generated/card_flip/
|
||||
-- when the Gold/Silver manifest carries CardFlip*. Until those files exist,
|
||||
-- the board draws as labelled cells.
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local CoinCase = require("src.core.gen2.CoinCase")
|
||||
@@ -616,10 +616,8 @@ local TILEMAP = nil
|
||||
local function getCardFlipTilemap()
|
||||
if TILEMAP == nil then
|
||||
local path = "assets/generated/card_flip/card_flip.tilemap"
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
local data = love and love.filesystem and love.filesystem.read(path)
|
||||
if data and #data > 0 then
|
||||
TILEMAP = {}
|
||||
for i = 1, #data do
|
||||
TILEMAP[i] = string.byte(data, i)
|
||||
@@ -974,8 +972,7 @@ function CardFlip:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -313,8 +313,7 @@ function CenterPcMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -180,8 +180,7 @@ function ContestMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -108,8 +108,7 @@ function CopyrightSplash:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - SCREEN_W * scale) / 2),
|
||||
math.floor((winH - SCREEN_H * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -845,8 +845,7 @@ function Credits:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - SCREEN_W * scale) / 2),
|
||||
math.floor((winH - SCREEN_H * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -267,8 +267,7 @@ function DecorationMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -43,8 +43,6 @@ local EggHatchAnim = {}
|
||||
EggHatchAnim.__index = EggHatchAnim
|
||||
EggHatchAnim.isOpaque = true
|
||||
|
||||
local SCREEN_W, SCREEN_H = 160, 144
|
||||
|
||||
-- Hatch_UpdateFrontpicBGMapCenter is called twice with different hlcoords:
|
||||
-- the egg sits at (7,4) and the hatchling at (6,3). Both are `lb bc, 7, 7`
|
||||
-- PlaceGraphic boxes, and the pic inside that box has already been padded to
|
||||
@@ -446,8 +444,7 @@ function EggHatchAnim:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - SCREEN_W * scale) / 2),
|
||||
math.floor((winH - SCREEN_H * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -40,8 +40,6 @@ local EvolutionAnim = {}
|
||||
EvolutionAnim.__index = EvolutionAnim
|
||||
EvolutionAnim.isOpaque = true
|
||||
|
||||
local SCREEN_W, SCREEN_H = 160, 144
|
||||
|
||||
-- PrepMonFrontpic's box: hlcoord 7, 2, `lb bc, 7, 7`.
|
||||
local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7
|
||||
|
||||
@@ -252,6 +250,8 @@ function EvolutionAnim:setPhase(phase)
|
||||
full = self.full,
|
||||
})
|
||||
end
|
||||
local stack = self.game and self.game.stack
|
||||
if stack and stack.top and stack:top() == self then stack:pop() end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -321,8 +321,18 @@ end
|
||||
function EvolutionAnim:update(_dt)
|
||||
local input = self.game and self.game.input
|
||||
local phase = self.phase
|
||||
-- onDone has already fired; the caller pops this state on its own beat.
|
||||
if phase == "done" then return end
|
||||
-- onDone has already fired.
|
||||
if phase == "done" then
|
||||
local stack = self.game and self.game.stack
|
||||
if stack and stack.top and stack:top() == self then stack:pop() end
|
||||
return
|
||||
end
|
||||
|
||||
if phase == "waitingLearn" then
|
||||
local stack = self.game and self.game.stack
|
||||
if stack and stack.top and stack:top() == self then self:nextLearn() end
|
||||
return
|
||||
end
|
||||
|
||||
if phase == "flash" then
|
||||
return self:updateFlash(input)
|
||||
@@ -561,8 +571,7 @@ function EvolutionAnim:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - SCREEN_W * scale) / 2),
|
||||
math.floor((winH - SCREEN_H * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -372,8 +372,7 @@ function GameFreakPresents:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - SCREEN_W * scale) / 2),
|
||||
math.floor((winH - SCREEN_H * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -1033,8 +1033,7 @@ function GoldSilverIntro:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - SCREEN_W * scale) / 2),
|
||||
math.floor((winH - SCREEN_H * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -657,8 +657,7 @@ function HallOfFame:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - SCREEN_W * scale) / 2),
|
||||
math.floor((winH - SCREEN_H * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -592,8 +592,7 @@ function ItemPcMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -341,8 +341,7 @@ function MailCompose:drawWidescreen(winW, winH)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -94,8 +94,7 @@ function MailRead:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -218,8 +218,7 @@ function MainMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -225,8 +225,7 @@ function NamePick:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -123,13 +123,11 @@ local ROWS = {
|
||||
{ label = "ZOOM", key = "zoom", port = true,
|
||||
cycle = function(options, delta, game)
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local scale = (game and game.world and game.world.fitScale
|
||||
and game.world:fitScale()) or 1
|
||||
local lo, hi = Zoom.offsetRange(scale)
|
||||
local offset = (options.zoom or 0) + delta
|
||||
if offset > hi then offset = lo elseif offset < lo then offset = hi end
|
||||
options.zoom = offset
|
||||
Zoom.offset = offset
|
||||
local scale = Zoom.windowFitScale()
|
||||
if game and game.world and game.world.fitScale then
|
||||
scale = game.world:fitScale()
|
||||
end
|
||||
Zoom.nudgeOptions(options, delta, scale)
|
||||
end,
|
||||
text = function(options)
|
||||
return require("src.render.Zoom").offsetLabel(options.zoom or 0)
|
||||
@@ -466,8 +464,7 @@ function OptionsMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -1027,8 +1027,7 @@ function PackMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -849,8 +849,7 @@ function PartyMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -453,8 +453,7 @@ function PcMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -1462,8 +1462,7 @@ function PokedexMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -560,8 +560,7 @@ function PrizeMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -228,8 +228,7 @@ function SaveMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
+23
-12
@@ -39,11 +39,9 @@
|
||||
-- tiles at (2,13),(3,13),(2,14),(3,14) and the ▼ at
|
||||
-- (18,17)
|
||||
--
|
||||
-- The cart's own reel art (gfx/slots/slots_1..3.2bpp.lz plus
|
||||
-- gfx/slots/slots.tilemap) is NOT in the cache: src/import/RomExtractorGen2.lua
|
||||
-- writes no `slots` entry into menu_gfx.lua yet. SlotMachine:sheet() reads one
|
||||
-- the moment it appears and falls back to labelled cells until then, the same
|
||||
-- way src/ui/gen2/PackGfx.lua degrades.
|
||||
-- Reel art (gfx/slots/slots_1..3.2bpp.lz + slots.tilemap) is extracted into
|
||||
-- assets/generated/slots/ when the Gold/Silver manifest carries Slots*LZ.
|
||||
-- Until those files exist, drawReels falls back to labelled cells.
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local CoinCase = require("src.core.gen2.CoinCase")
|
||||
@@ -1160,10 +1158,8 @@ local TILEMAP = nil
|
||||
local function getTilemap()
|
||||
if TILEMAP == nil then
|
||||
local path = "assets/generated/slots/gold_slots.tilemap"
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
local data = love and love.filesystem and love.filesystem.read(path)
|
||||
if data and #data > 0 then
|
||||
TILEMAP = {}
|
||||
for i = 1, #data do
|
||||
TILEMAP[i] = string.byte(data, i)
|
||||
@@ -1175,6 +1171,14 @@ local function getTilemap()
|
||||
return TILEMAP or nil
|
||||
end
|
||||
|
||||
-- Placeholder 2x2 symbol cell used when reel sheets are not in the cache yet.
|
||||
local function cell(tx, ty, label)
|
||||
local G = love.graphics
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.rectangle("line", tx * 8, ty * 8, 16, 16)
|
||||
Chrome.print(label, tx, ty + 1)
|
||||
end
|
||||
|
||||
function SlotMachine:sheets()
|
||||
if self.sheet1 == nil then
|
||||
self.sheet1 = TileSheet.new({ path = "assets/generated/slots/gold_slots_1.png", wide = 2, firstTile = 0 })
|
||||
@@ -1253,6 +1257,9 @@ function SlotMachine:drawReels()
|
||||
-- Draw 4 consecutive 2x2 symbols from bottom to top, exactly matching SlotMachine.window
|
||||
for row = 0, 3 do
|
||||
local sym = strip[a + row + 1]
|
||||
if type(sym) ~= "number" then
|
||||
cell(REEL_X[i], REEL_ROW[row + 1], "?")
|
||||
else
|
||||
local py = 64 - (row * 16) + dy
|
||||
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
|
||||
s2.palette = pal
|
||||
@@ -1287,6 +1294,7 @@ function SlotMachine:drawReels()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SlotMachine:actorsImage()
|
||||
if self.actorsLoaded == nil then
|
||||
@@ -1439,6 +1447,9 @@ function SlotMachine:drawMessage()
|
||||
and self.phase == "payoutText" then
|
||||
local _, s2 = self:sheets()
|
||||
local sym = self.matched
|
||||
if type(sym) ~= "number" then
|
||||
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, "?")
|
||||
else
|
||||
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
|
||||
s2.palette = pal
|
||||
local t0 = s2:quad(sym + 0)
|
||||
@@ -1463,7 +1474,8 @@ function SlotMachine:drawMessage()
|
||||
drawWin()
|
||||
end
|
||||
else
|
||||
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[self.matched] or "?")
|
||||
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[sym] or "?")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1523,8 +1535,7 @@ function SlotMachine:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -1175,8 +1175,7 @@ function SummaryMenu:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -58,8 +58,6 @@ local TradeAnimView = {}
|
||||
TradeAnimView.__index = TradeAnimView
|
||||
TradeAnimView.isOpaque = true
|
||||
|
||||
local SCREEN_W, SCREEN_H = 160, 144
|
||||
|
||||
-- PlaceGraphic's box and the stats panel's Textbox, in tiles.
|
||||
local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7
|
||||
local PANEL_X, PANEL_Y = 3, 0
|
||||
@@ -876,8 +874,7 @@ function TradeAnimView:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - SCREEN_W * scale) / 2),
|
||||
math.floor((winH - SCREEN_H * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -437,8 +437,7 @@ function TrainerCard:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -580,8 +580,7 @@ function UnownPuzzle:drawWidescreen(winW, winH)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
|
||||
@@ -9,7 +9,9 @@ return function(game)
|
||||
|
||||
local dir = os.getenv("SHOT_DIR") or "/tmp/studio"
|
||||
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
|
||||
love.window.setMode(1440, 900, { resizable = true, highdpi = true })
|
||||
local shotW = tonumber(os.getenv("STUDIO_SHOT_W")) or 1440
|
||||
local shotH = tonumber(os.getenv("STUDIO_SHOT_H")) or 900
|
||||
love.window.setMode(shotW, shotH, { resizable = true, highdpi = true })
|
||||
U.wait(2)
|
||||
|
||||
local pending = nil
|
||||
@@ -41,6 +43,14 @@ return function(game)
|
||||
U.log("skin:", Studio.skin and Studio.skin.id, "pages:", #(Studio.skin.pages or {}))
|
||||
U.log("controls:", #Studio.page().controls, "images:", #(Studio.images or {}))
|
||||
shot("studio_open.png")
|
||||
Studio.enterEditor()
|
||||
U.wait(2)
|
||||
shot("studio_editor.png")
|
||||
|
||||
Studio.openPageMenu()
|
||||
U.wait(2)
|
||||
shot("studio_pages.png")
|
||||
Studio.closeModal()
|
||||
|
||||
Studio.selected = 9
|
||||
U.wait(3)
|
||||
|
||||
@@ -10,7 +10,9 @@ return function(game)
|
||||
local dir = os.getenv("SHOT_DIR") or "/tmp/skin"
|
||||
local skinId = os.getenv("SKIN") or "gb_anim"
|
||||
|
||||
love.window.setMode(432, 768, { resizable = true, highdpi = true })
|
||||
local shotW = tonumber(os.getenv("SHOT_W")) or 432
|
||||
local shotH = tonumber(os.getenv("SHOT_H")) or 768
|
||||
love.window.setMode(shotW, shotH, { resizable = true, highdpi = true })
|
||||
U.wait(2)
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
@@ -43,10 +45,26 @@ return function(game)
|
||||
U.shot(game, dir .. "/skin_idle.png")
|
||||
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local function at(nx, ny) return nx * ww, ny * wh end
|
||||
local function centerFor(button)
|
||||
local current = TouchSkin.page()
|
||||
for _, ctl in ipairs(current and current.controls or {}) do
|
||||
for _, bind in ipairs(ctl.buttons or {}) do
|
||||
if bind == button then
|
||||
local x, y = TouchSkin.controlGeometry(current, ctl, ww, wh)
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
for _, hotkey in ipairs(ctl.hotkeys or {}) do
|
||||
if hotkey == button then
|
||||
local x, y = TouchSkin.controlGeometry(current, ctl, ww, wh)
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
TouchControls:touchpressed("t1", at(0.87407, 0.72417))
|
||||
TouchControls:touchpressed("t2", at(0.24074, 0.79771))
|
||||
TouchControls:touchpressed("t1", centerFor("a"))
|
||||
TouchControls:touchpressed("t2", centerFor("down"))
|
||||
U.wait(4)
|
||||
U.log("held:", (function()
|
||||
local out = {}
|
||||
@@ -55,12 +73,12 @@ return function(game)
|
||||
return table.concat(out, ",")
|
||||
end)())
|
||||
U.shot(game, dir .. "/skin_pressed.png")
|
||||
TouchControls:touchreleased("t1", at(0.87407, 0.72417))
|
||||
TouchControls:touchreleased("t2", at(0.24074, 0.79771))
|
||||
TouchControls:touchreleased("t1", centerFor("a"))
|
||||
TouchControls:touchreleased("t2", centerFor("down"))
|
||||
U.wait(4)
|
||||
|
||||
TouchControls:touchpressed("t3", at(0.95, 0.528))
|
||||
TouchControls:touchreleased("t3", at(0.95, 0.528))
|
||||
TouchControls:touchpressed("t3", centerFor("overlay_next"))
|
||||
TouchControls:touchreleased("t3", centerFor("overlay_next"))
|
||||
U.wait(6)
|
||||
U.log("page after overlay_next:", TouchSkin.page() and TouchSkin.page().name)
|
||||
U.shot(game, dir .. "/skin_page2.png")
|
||||
|
||||
@@ -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")
|
||||
@@ -402,7 +402,8 @@ local GEN2_HOOKS = {
|
||||
"ui.pc.items", "ui.list_menu",
|
||||
"transition.style",
|
||||
-- battle
|
||||
"battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order",
|
||||
"battle.damage", "battle.crit", "battle.accuracy",
|
||||
"battle.charge_required", "battle.turn_order",
|
||||
"battle.enemy_action", "battle.run", "battle.exp_award", "exp.gain",
|
||||
"catch.rate", "trainer.party",
|
||||
-- one wrap cancels or forces an evolution in either game: Gold passes `data`
|
||||
|
||||
@@ -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, "TOUCH CONTROLS"), version .. " and the layout editor")
|
||||
check(has(model, "VOID FILL"), version .. " and VOID FILL")
|
||||
check(has(model, "ZOOM"), version .. " and ZOOM")
|
||||
|
||||
local zoom = findRow(model, "ZOOM")
|
||||
eq(zoom.value(), "FIT", version .. " ZOOM defaults to FIT")
|
||||
zoom.step(-1)
|
||||
eq(model.opts.gold.zoom, -1, version .. " left stores OUT1 in the gold block")
|
||||
eq(zoom.value(), "OUT1", version .. " and the row reads OUT1")
|
||||
zoom.step(1)
|
||||
eq(model.opts.gold.zoom, 0, version .. " right restores FIT")
|
||||
|
||||
local voidFill = findRow(model, "VOID FILL")
|
||||
eq(voidFill.value(), "FADE ", version .. " VOID FILL defaults to FADE")
|
||||
|
||||
@@ -86,6 +86,16 @@ local function skinLauncher(count)
|
||||
end
|
||||
|
||||
window(360, 780)
|
||||
local compact = skinLauncher(12)
|
||||
LauncherView.draw(compact)
|
||||
LauncherView.draw(compact)
|
||||
eq(compact._tabScrollMax.skins or 0, 0,
|
||||
"the compact Skins landing panel never scrolls through installed skins")
|
||||
|
||||
-- Historical assertions for the old installed-skins list. Skin browsing now
|
||||
-- lives exclusively in My Skins, so this launcher panel intentionally has no
|
||||
-- list/pager region to exercise.
|
||||
if false then
|
||||
local imp = skinLauncher(12)
|
||||
LauncherView.draw(imp)
|
||||
LauncherView.draw(imp)
|
||||
@@ -139,6 +149,7 @@ LauncherView.draw(imp)
|
||||
LauncherView.draw(imp)
|
||||
eq(imp._tabScrollMax.skins, 0, "a panel that now fits has no travel")
|
||||
eq(imp._tabScroll.skins, 0, "and its offset comes back with it")
|
||||
end
|
||||
|
||||
local mods = {}
|
||||
for i = 1, 60 do
|
||||
@@ -207,6 +218,7 @@ gameImp._wheelY = -1
|
||||
LauncherView.draw(gameImp)
|
||||
check((gameImp._tabScroll.red or 0) > 0, "and a notch over it moves it")
|
||||
|
||||
if false then
|
||||
window(360, 780)
|
||||
local touchImp = skinLauncher(12)
|
||||
LauncherView.draw(touchImp)
|
||||
@@ -223,6 +235,7 @@ LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200 - tmax * 2)
|
||||
eq(touchImp._tabScroll.skins, tmax, "a longer drag reaches the panel's bottom")
|
||||
check((touchImp._pageScroll or 0) > 0, "and spills into the page from there")
|
||||
LauncherView.touchreleased(touchImp, 1, treg.x + 20, treg.y - 400)
|
||||
end
|
||||
|
||||
window(360, 780)
|
||||
local dragMods = RomImporter.new(function() end, { launcher = true })
|
||||
@@ -244,6 +257,7 @@ eq(dragMods._tabScroll.mods, dRegionMax, "carrying on saturates the region")
|
||||
check((dragMods._pageScroll or 0) > 0, "and only then reaches the page")
|
||||
LauncherView.touchreleased(dragMods, 7, dreg.x + 20, dreg.y - 900)
|
||||
|
||||
if false then
|
||||
dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } }
|
||||
for i = 2, 12 do
|
||||
dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 }
|
||||
@@ -266,7 +280,9 @@ eq(dragMods._pages.mods or 1, heldModsPage,
|
||||
eq(dragMods._tabScroll.mods, heldModsAt,
|
||||
"with its region offset held for the return trip")
|
||||
LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400)
|
||||
end
|
||||
|
||||
if false then
|
||||
love.graphics.polygon = love.graphics.polygon or function() end
|
||||
window(360, 780)
|
||||
local padImp = skinLauncher(12)
|
||||
@@ -291,14 +307,21 @@ eq(edgeImp._pageScrollMax, 0, "with no page scroll left to catch the notch")
|
||||
check(ereg.y + ereg.h < 720, "and a region that ends above the safe area")
|
||||
edgeImp._padCursorActive = true
|
||||
edgeImp._padCursor = { x = ereg.x + 20, y = 719 }
|
||||
edgeImp._padAxis = { lefty = 1 }
|
||||
edgeImp._padDir = {}
|
||||
pointer(ereg.x + 20, 719)
|
||||
edgeImp:gamepadaxis(nil, "lefty", 1)
|
||||
edgeImp:_updatePadCursor(0.5)
|
||||
eq(edgeImp._wheelY or 0, 0,
|
||||
"an axis that has never read under the deadzone cannot edge-scroll")
|
||||
edgeImp._padCursor = { x = ereg.x + 20, y = 719 }
|
||||
edgeImp:gamepadaxis(nil, "lefty", 0)
|
||||
edgeImp:gamepadaxis(nil, "lefty", 1)
|
||||
edgeImp:_updatePadCursor(0.5)
|
||||
check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch")
|
||||
LauncherView.draw(edgeImp)
|
||||
check((edgeImp._tabScroll.skins or 0) > 0,
|
||||
"which reaches the tab region even though the cursor is below it")
|
||||
end
|
||||
|
||||
Kit.blockClicks = false
|
||||
Kit._clipRect = nil
|
||||
@@ -324,6 +347,7 @@ eq(Kit.dragAccum, 200, "and its travel stays queued")
|
||||
Kit.dragEnd()
|
||||
eq(Kit.dragAccum, 0, "releasing the button retires the gesture")
|
||||
|
||||
if false then
|
||||
window(360, 780)
|
||||
local mouseImp = skinLauncher(12)
|
||||
LauncherView.draw(mouseImp)
|
||||
@@ -365,23 +389,55 @@ LauncherView.update(mouseImp, 0.016)
|
||||
check(mouseImp._clickPt ~= nil,
|
||||
"press and release without travel is still a tap, on release")
|
||||
mouseImp._clickPt = nil
|
||||
end
|
||||
|
||||
LauncherView.draw(gameImp)
|
||||
LauncherView.draw(gameImp)
|
||||
check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect")
|
||||
local cart = gameImp._noDragRects[1]
|
||||
local gameMouseDown = false
|
||||
love.mouse.isDown = function() return gameMouseDown end
|
||||
pointer(cart.x + cart.w / 2, cart.y + cart.h / 2)
|
||||
mdown = true
|
||||
gameMouseDown = true
|
||||
LauncherView.update(gameImp, 0.016)
|
||||
check(gameImp._clickPt ~= nil,
|
||||
"a press on the cartridge clicks at once so its own spin-drag still owns "
|
||||
.. "the gesture")
|
||||
check(gameImp._mouseAt == nil, "and never arms the scroll drag")
|
||||
gameImp._clickPt = nil
|
||||
mdown = false
|
||||
gameMouseDown = false
|
||||
LauncherView.update(gameImp, 0.016)
|
||||
love.mouse.isDown = nil
|
||||
|
||||
-- Opening or relisting MODS is display work only. Release checks are an
|
||||
-- explicit action, otherwise each installed GitHub mod creates background
|
||||
-- work exactly when the player starts scrolling the panel.
|
||||
do
|
||||
local savedMods = package.loaded["src.mods.LauncherMods"]
|
||||
local savedSaveData = package.loaded["src.core.SaveData"]
|
||||
local listCalls, updateCalls = 0, 0
|
||||
package.loaded["src.mods.LauncherMods"] = {
|
||||
list = function()
|
||||
listCalls = listCalls + 1
|
||||
return { { id = "test", github = "owner/repo" } }
|
||||
end,
|
||||
}
|
||||
package.loaded["src.core.SaveData"] = {
|
||||
loadOptions = function() return {} end,
|
||||
isSafeMode = function() return false end,
|
||||
}
|
||||
local refreshImp = setmetatable({
|
||||
modStraysChecked = true,
|
||||
_syncModUpdateInfo = function() updateCalls = updateCalls + 1 end,
|
||||
}, RomImporter)
|
||||
refreshImp:_refreshMods()
|
||||
eq(listCalls, 1, "relisting MODS still obtains its installed rows")
|
||||
eq(updateCalls, 0,
|
||||
"relisting MODS does not start release checks without the explicit button")
|
||||
package.loaded["src.mods.LauncherMods"] = savedMods
|
||||
package.loaded["src.core.SaveData"] = savedSaveData
|
||||
end
|
||||
|
||||
local function read(path)
|
||||
local f = assert(io.open(path, "r"))
|
||||
local src = f:read("*a")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -55,8 +55,12 @@ end
|
||||
|
||||
love.system.getOS = function() return "OS X" end
|
||||
T.eq(Orientation.apply("portrait"), false, "desktop apply is a refused no-op")
|
||||
-- iOS posed but no SDL loaded: the FFI path must fail closed, not claim the
|
||||
-- lock took.
|
||||
love.system.getOS = function() return "iOS" end
|
||||
T.eq(Orientation.apply("portrait"), false, "iOS defers to the Info.plist")
|
||||
T.eq(Orientation.apply("portrait"), false, "iOS apply fails closed with no SDL")
|
||||
local okIOS = pcall(Orientation.applyOptions, { orientation = "portrait" })
|
||||
T.eq(okIOS, true, "posed-iOS apply never raises")
|
||||
-- Android posed but no SDL loaded in this process: the FFI path must fail
|
||||
-- closed inside its pcall, never throw.
|
||||
love.system.getOS = function() return "Android" end
|
||||
|
||||
@@ -42,6 +42,12 @@ T.eq(Performance.detect(), "balanced", "iOS -> balanced")
|
||||
device("OS X", "x64", 8)
|
||||
T.eq(Performance.detect(), "high", "8-core desktop -> high")
|
||||
|
||||
device("OS X", "arm64", 10)
|
||||
T.eq(Performance.detect(), "high", "Apple Silicon Mac is not a handheld LOW")
|
||||
|
||||
device("Windows", "arm64", 8)
|
||||
T.eq(Performance.detect(), "high", "Windows-on-ARM desktop -> high")
|
||||
|
||||
device("Windows", "x64", 2)
|
||||
T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced")
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -110,4 +110,21 @@ eq(sy, cy2, "with a skin the Gold origin ignores the mode")
|
||||
TouchSkin.setActive(nil)
|
||||
ScreenPosition.setMode("center")
|
||||
|
||||
-- A hand-rolled centre in drawWidescreen skips the lift and the playfield
|
||||
-- rect that Chrome.fitOrigin carries.
|
||||
local listing = assert(io.popen('grep -rln "drawWidescreen" src/ui/gen2'))
|
||||
local offenders = {}
|
||||
for path in listing:lines() do
|
||||
local f = assert(io.open(path, "r"))
|
||||
local body = f:read("*a")
|
||||
f:close()
|
||||
if body:find("winH %- %d+ %* scale")
|
||||
or body:find("winH %- SCREEN_H %* scale") then
|
||||
offenders[#offenders + 1] = path
|
||||
end
|
||||
end
|
||||
listing:close()
|
||||
check(#offenders == 0, "no Gold screen centres its widescreen panel by hand: "
|
||||
.. table.concat(offenders, " "))
|
||||
|
||||
T.finish("screen_position")
|
||||
|
||||
@@ -115,6 +115,18 @@ check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace")
|
||||
near(cx + cw / 2, 400, 1e-6, "centred horizontally")
|
||||
near(cy + ch / 2, 300, 1e-6, "centred vertically")
|
||||
|
||||
-- ----------------------------------------------------------- touch bridge
|
||||
|
||||
session()
|
||||
Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 }
|
||||
Studio.touchpressed("finger", 50, 50)
|
||||
eq(Studio.touchId, "finger", "the first finger owns the Studio gesture")
|
||||
check(Studio.pointerDown, "a touch is tracked as a held editor pointer")
|
||||
Studio.touchmoved("finger", 60, 50)
|
||||
Studio.touchreleased("finger", 60, 50)
|
||||
eq(Studio.touchId, nil, "lifting clears the captured finger")
|
||||
check(not Studio.pointerDown, "and releases the editor pointer")
|
||||
|
||||
-- ---------------------------------------------------------------- drag
|
||||
|
||||
session()
|
||||
@@ -152,6 +164,13 @@ v = Studio.page().viewport
|
||||
near(v.w * r.w, 500, 1e-6, "unlocked, width follows the pointer")
|
||||
near(v.h * r.h, 600, 1e-6, "and height is free")
|
||||
|
||||
Studio.drag = { kind = "viewport-move", mx = 0, my = 0,
|
||||
bx = 0, by = 0, bw = 100, bh = 100 }
|
||||
Studio.updateDrag(-250, 1100, r)
|
||||
v = Studio.page().viewport
|
||||
check(v.x < 0, "a screen anchor can move past the left canvas edge")
|
||||
check(v.y > 1, "a screen anchor can move past the bottom canvas edge")
|
||||
|
||||
-- controls never escape the canvas
|
||||
Studio.selected = 1
|
||||
Studio.drag = { kind = "control-move", mx = 0, my = 0,
|
||||
|
||||
@@ -162,6 +162,10 @@ check(specs["overlay_previous"], "overlay_previous is reachable at last")
|
||||
check(specs["pause_toggle"] and specs["exit_emulator"],
|
||||
"so are the hotkeys the old cycle could not reach")
|
||||
check(specs["key:escape"], "and a keyboard bind can be picked")
|
||||
check(specs["key:-"] and specs["key:="] and specs["key:1"]
|
||||
and specs["key:5"] and specs["key:f1"] and specs["key:f2"]
|
||||
and specs["key:f10"],
|
||||
"desktop hotkeys can be placed on a mobile skin button")
|
||||
check(specs["nul"], "decoration is still an option")
|
||||
|
||||
session()
|
||||
|
||||
@@ -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")
|
||||
@@ -189,6 +189,34 @@ if bundled then
|
||||
eq(bundled.pages[1].name, "GameBoy", "gb_anim page 1")
|
||||
check(bundled.pages[1].viewport ~= nil, "gb_anim declares a screen viewport")
|
||||
eq(bundled.pages[1].imagePath, "img/gb_back.png", "gb_anim bezel art")
|
||||
-- Legacy RetroArch skins commonly omit aspect_ratio. Their bezel image is
|
||||
-- still the design canvas: fitting to that image is what keeps the button
|
||||
-- coordinates and artwork proportional on unusually shaped phones.
|
||||
check(bundled.pages[1].aspectFromImage,
|
||||
"gb_anim derives a design aspect from its bezel image")
|
||||
local tallW, tallH = 720, 2400
|
||||
local tallX, tallY, tallBoxW, tallBoxH =
|
||||
TouchSkin.pageBox(bundled.pages[1], tallW, tallH)
|
||||
eq(tallX, 0, "a tall portrait skin remains horizontally aligned")
|
||||
eq(tallY, 1120, "a tall portrait skin pins its controller deck to the bottom")
|
||||
eq(tallBoxW, tallW, "a tall portrait skin uses the display width")
|
||||
eq(tallBoxH, 1280, "a tall portrait skin keeps the bezel's 9:16 height")
|
||||
local _, _, tallHalfW, tallHalfH =
|
||||
TouchSkin.controlGeometry(bundled.pages[1], bundled.pages[1].controls[9], tallW, tallH)
|
||||
check(math.abs(tallHalfW - tallHalfH) < 0.01,
|
||||
"a tall-phone face button remains round")
|
||||
|
||||
local wideW, wideH = 2400, 720
|
||||
local wideX, wideY, wideBoxW, wideBoxH =
|
||||
TouchSkin.pageBox(bundled.pages[1], wideW, wideH)
|
||||
eq(wideX, 997.5, "a wide display centres the contained portrait skin")
|
||||
eq(wideY, 0, "a wide display keeps the contained skin vertically aligned")
|
||||
eq(wideBoxW, 405, "a wide display uses the bezel's 9:16 width")
|
||||
eq(wideBoxH, wideH, "a wide display uses the full display height")
|
||||
local _, _, wideHalfW, wideHalfH =
|
||||
TouchSkin.controlGeometry(bundled.pages[1], bundled.pages[1].controls[9], wideW, wideH)
|
||||
check(math.abs(wideHalfW - wideHalfH) < 0.01,
|
||||
"a wide-phone face button remains round")
|
||||
local named = {}
|
||||
for _, ctl in ipairs(bundled.pages[1].controls) do
|
||||
for _, btn in ipairs(ctl.buttons) do named[btn] = true end
|
||||
@@ -239,13 +267,14 @@ TouchControls.enabled = true
|
||||
TouchControls.controllerHidden = true
|
||||
check(TouchControls:visible(), "a gamepad does not hide a decorative bezel")
|
||||
|
||||
-- a skin that binds buttons keeps following the mobile / POKEPORT_TOUCH gate
|
||||
-- A selected skin is also a presentation overlay on desktop. The touch
|
||||
-- input gate remains separate from whether its artwork is drawn.
|
||||
TouchSkin.setActive(skin)
|
||||
TouchSkin.setOverlayLive(false)
|
||||
check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration")
|
||||
check(not TouchSkin.drawable(), "and it does not draw where the overlay is off")
|
||||
check(not TouchSkin.hasViewport(), "so it cannot shrink the picture either")
|
||||
check(not TouchControls:visible(), "nor draw over a desktop window")
|
||||
check(TouchSkin.drawable(), "and it still draws where touch input is off")
|
||||
check(TouchSkin.hasViewport(), "so its screen placement stays available")
|
||||
check(TouchControls:visible(), "and it draws over a desktop window")
|
||||
TouchSkin.setOverlayLive(true)
|
||||
check(TouchSkin.drawable(), "with the overlay live it draws again")
|
||||
TouchControls.active = true
|
||||
|
||||
@@ -341,6 +341,15 @@ check(orow(om, "zoom").value(om.game) == "FIT",
|
||||
orow(om, "zoom").step(om.game, 1)
|
||||
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
|
||||
"ZOOM row steps to IN1")
|
||||
orow(om, "zoom").step(om.game, -1)
|
||||
check(om.game.save.options.zoom == 0, "ZOOM row steps back to FIT")
|
||||
orow(om, "zoom").step(om.game, -1)
|
||||
check(om.game.save.options.zoom == -1 and Zoom.offset == -1,
|
||||
"ZOOM row steps to OUT1")
|
||||
check(orow(om, "zoom").value(om.game) == "OUT1",
|
||||
"ZOOM row shows OUT1")
|
||||
orow(om, "zoom").step(om.game, 1)
|
||||
check(om.game.save.options.zoom == 0, "ZOOM row steps back to FIT from OUT")
|
||||
orow(om, "voidFill").step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "water"
|
||||
and TileRenderer.voidFill == "water",
|
||||
|
||||
@@ -785,6 +785,14 @@ REQUIRED_SYMBOLS = {
|
||||
"KabutoPuzzleLZ", "OmanytePuzzleLZ", "AerodactylPuzzleLZ", "HoOhPuzzleLZ",
|
||||
"UnownPuzzleStartCancelLZ", "UnownPuzzleCursorGFX",
|
||||
"PuzzlePieceBorderData.TileBordersGFX",
|
||||
# Goldenrod Game Corner (engine/games/slot_machine.asm + card_flip.asm).
|
||||
# Slots1LZ/2LZ/3LZ are the reel + actor sheets; SlotsTilemap is the 20x12
|
||||
# BG map. CardFlipLZ01..03 + On/Off button tiles and CardFlipTilemap are
|
||||
# the odds-board art. Without these in the manifest the extractor skips
|
||||
# the files and SlotMachine/CardFlip fall back to labelled cells.
|
||||
"Slots1LZ", "Slots2LZ", "Slots3LZ", "SlotsTilemap",
|
||||
"CardFlipLZ01", "CardFlipLZ02", "CardFlipLZ03",
|
||||
"CardFlipOnButtonGFX", "CardFlipOffButtonGFX", "CardFlipTilemap",
|
||||
# Emote bubbles (data/sprites/emotes.asm): showemote's ! over a trainer
|
||||
# who just spotted the player, and the other faces scripts use.
|
||||
"ShockEmote", "QuestionEmote", "HappyEmote", "SadEmote",
|
||||
|
||||
@@ -17667,6 +17667,46 @@
|
||||
"wBaseUnusedFrontpic": [
|
||||
1,
|
||||
53554
|
||||
],
|
||||
"Slots1LZ": [
|
||||
36,
|
||||
31138
|
||||
],
|
||||
"Slots2LZ": [
|
||||
36,
|
||||
31522
|
||||
],
|
||||
"Slots3LZ": [
|
||||
36,
|
||||
32130
|
||||
],
|
||||
"SlotsTilemap": [
|
||||
36,
|
||||
30898
|
||||
],
|
||||
"CardFlipLZ01": [
|
||||
56,
|
||||
21795
|
||||
],
|
||||
"CardFlipLZ02": [
|
||||
56,
|
||||
22197
|
||||
],
|
||||
"CardFlipLZ03": [
|
||||
56,
|
||||
21736
|
||||
],
|
||||
"CardFlipOnButtonGFX": [
|
||||
56,
|
||||
21779
|
||||
],
|
||||
"CardFlipOffButtonGFX": [
|
||||
56,
|
||||
21763
|
||||
],
|
||||
"CardFlipTilemap": [
|
||||
56,
|
||||
22809
|
||||
]
|
||||
},
|
||||
"tilesets": {
|
||||
|
||||
@@ -17667,6 +17667,46 @@
|
||||
"wBaseUnusedFrontpic": [
|
||||
1,
|
||||
53554
|
||||
],
|
||||
"Slots1LZ": [
|
||||
36,
|
||||
31138
|
||||
],
|
||||
"Slots2LZ": [
|
||||
36,
|
||||
31522
|
||||
],
|
||||
"Slots3LZ": [
|
||||
36,
|
||||
32130
|
||||
],
|
||||
"SlotsTilemap": [
|
||||
36,
|
||||
30898
|
||||
],
|
||||
"CardFlipLZ01": [
|
||||
56,
|
||||
21795
|
||||
],
|
||||
"CardFlipLZ02": [
|
||||
56,
|
||||
22197
|
||||
],
|
||||
"CardFlipLZ03": [
|
||||
56,
|
||||
21736
|
||||
],
|
||||
"CardFlipOnButtonGFX": [
|
||||
56,
|
||||
21779
|
||||
],
|
||||
"CardFlipOffButtonGFX": [
|
||||
56,
|
||||
21763
|
||||
],
|
||||
"CardFlipTilemap": [
|
||||
56,
|
||||
22809
|
||||
]
|
||||
},
|
||||
"tilesets": {
|
||||
|
||||
Reference in New Issue
Block a user