mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49d094b14d | |||
| cd7985e4ac | |||
| 61edf3470f | |||
| cd02cd6f33 | |||
| 127e3da909 | |||
| 8fa702e715 | |||
| 3aaaf9936e | |||
| af33c6e810 | |||
| e8eccfd4df | |||
| 241c3345bd | |||
| 20e0692486 | |||
| 0136429d3e | |||
| 01aab1d763 | |||
| 3d2d53362d | |||
| 03838ee1d4 | |||
| 2fabc03841 | |||
| 41f02ecfbc | |||
| cef286b170 | |||
| ab94b96a79 | |||
| cc43bd77d2 | |||
| d4dc72d0f4 | |||
| 6d841526b1 | |||
| 8e48bb4e32 | |||
| 2c8c800584 | |||
| 4db97164bb | |||
| 882763cfe1 | |||
| 798f3c25c0 | |||
| 02024fef58 | |||
| a4c51eccea | |||
| a9767e5df1 | |||
| e59175fc89 | |||
| 4e20f4585f | |||
| 1c37867e52 | |||
| 67491e2dac | |||
| 2d85c9595d | |||
| b8138ef850 | |||
| e3fc8380cf | |||
| b9afaaae60 | |||
| 4d03a38067 | |||
| ab5fed60fe | |||
| e3a215e081 | |||
| 0f484682d8 | |||
| 60cf07fb0a | |||
| 35ed326dda | |||
| f4a1dd9e16 | |||
| 439e97aee6 | |||
| 9de4db8531 | |||
| 683fee8028 | |||
| a83d18fc51 | |||
| 9696c8d3e2 | |||
| 18b2bcd0a7 | |||
| 7def560726 | |||
| 6cbd0de77d | |||
| afc5a52978 | |||
| 12a04f4188 | |||
| 1963a5453e | |||
| 59a383736c | |||
| d377518632 | |||
| f74e21782b | |||
| 7b060c19f4 | |||
| 604b9338f9 | |||
| 92f73d8bed | |||
| e3fcdd0776 |
@@ -68,3 +68,10 @@ mobile/ios/bundle_id.local
|
||||
/ports/uwp/build/
|
||||
/ports/uwp/third_party/*/source/
|
||||
/ports/uwp/third_party/angle/depot_tools/
|
||||
|
||||
# Native TLS dialer build output (dotnet publish)
|
||||
/native/tls_dial/bin/
|
||||
/native/tls_dial/obj/
|
||||
/dist/native/
|
||||
/dist/win/
|
||||
/.bazinga/
|
||||
|
||||
@@ -203,7 +203,8 @@ return {
|
||||
rows[#rows + 1] = { "set_field", "pikachuInBall", true }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
|
||||
TEXT_OAKSLAB_RIVAL = {
|
||||
|
||||
+24
-7
@@ -206,6 +206,13 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
|
||||
local function escortToLab(oak)
|
||||
-- PalletMovementScript_OakMoveLeft
|
||||
-- (engine/overworld/auto_movement.asm) starts MUSIC_MUSEUM_GUY
|
||||
-- when the escort begins in Yellow. Until then, Pallet Town plays
|
||||
-- after the battle; Red/Blue leave MUSIC_MEET_PROF_OAK playing.
|
||||
if yellow then
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
end
|
||||
local numSteps = x - 10
|
||||
if oak and numSteps > 0 then
|
||||
ow:scriptMove(oak, "left", numSteps, function()
|
||||
@@ -249,14 +256,24 @@ M.PALLET_TOWN = {
|
||||
function()
|
||||
-- Oak turns toward the horizontally adjacent grass (left exit
|
||||
-- looks right, right exit looks left -- the
|
||||
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch)
|
||||
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch).
|
||||
-- In pokeyellow, PalletTownOakGreetsPlayerScript turns Oak and
|
||||
-- PalletTownPikachuBattleScript arms the battle on the next
|
||||
-- overworld iteration. OverworldLoopLessDelay
|
||||
-- (home/overworld.asm) burns two DelayFrame calls at the top
|
||||
-- of each iteration and calls RunMapScript before checking
|
||||
-- wCurOpponent, so those two DelayFrame calls are what keep
|
||||
-- Oak's turn on screen before the battle check fires.
|
||||
if oak then oak.facing = x == 10 and "right" or "left" end
|
||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||
battle:makeOldManDemo("PROF.OAK")
|
||||
battle.onFinish = function()
|
||||
afterPikaBattle()
|
||||
end
|
||||
game.stack:push(battle)
|
||||
hold(2, nil, function()
|
||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||
battle:makeOldManDemo("PROF.OAK")
|
||||
battle.onFinish = function()
|
||||
afterPikaBattle()
|
||||
end
|
||||
-- Use the standard wild-battle entry transition.
|
||||
Commands.pushBattle(ctx, battle)
|
||||
end)
|
||||
end))
|
||||
end
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ M.CERULEAN_MELANIES_HOUSE = {
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
||||
end
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
-- pet flavor: the text with the species' cry over it
|
||||
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
|
||||
@@ -105,7 +106,8 @@ M.VERMILION_CITY = {
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
||||
end
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ local function oldMan2Talk(game, ow, npc, done)
|
||||
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
|
||||
return
|
||||
end
|
||||
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done })
|
||||
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# RFC 0008 — Runtime mod option schema export
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `src/mods/Loader.lua`. Tests:
|
||||
`tests/mod_loader_tests.lua`. This RFC defines an optional filesystem
|
||||
contract; it does not require a native launcher or any other consumer.
|
||||
|
||||
## Motivation
|
||||
|
||||
A native launcher may want to present settings for installed mods before it
|
||||
starts the game. Running every mod's entry chunk in that launcher just to
|
||||
discover its settings would duplicate engine behavior and give the launcher
|
||||
an unnecessary code-execution surface. The engine already has the authoritative
|
||||
runtime schemas after mod loading, so it can publish a data-only snapshot for
|
||||
platform shells that want one.
|
||||
|
||||
## The exact contract
|
||||
|
||||
After the mod loader has finished running entry chunks, it may write
|
||||
`mod_option_schemas.json` beside `options.lua` in the same filesystem. The
|
||||
document is a snapshot of the current boot; it is not a second settings store
|
||||
and does not change how option values are read or written.
|
||||
|
||||
Version 1 has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"mods": {
|
||||
"example": [
|
||||
{"key":"enabled","type":"toggle","label":"Enabled","default":true},
|
||||
{"key":"mode","type":"choice","label":"Mode","default":"safe",
|
||||
"choices":[["Safe","safe"],["Fast","fast"]]},
|
||||
{"key":"rate","type":"number","label":"Rate","default":5,
|
||||
"min":0,"max":10,"step":1},
|
||||
{"key":"name","type":"text","label":"Name","default":"","maxLen":12}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`mods` is keyed by mod id. Its rows come from the runtime
|
||||
`mod.options:define` schema, or from the legacy manifest `options_schema` file
|
||||
when the runtime schema is absent. The supported row types are `toggle`,
|
||||
`choice`, `number`, and `text`. Their optional fields retain the meanings
|
||||
established by the existing in-game option UI: choices are `[label, value]`
|
||||
pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may
|
||||
provide `maxLen`.
|
||||
|
||||
Only mods that are enabled and successfully loaded in the current boot are
|
||||
included. A disabled or failed mod must not contribute rows. If an older
|
||||
snapshot exists and the current boot has no schema-bearing mods, the producer
|
||||
overwrites it with `{"schema_version":1,"mods":{}}`; this prevents stale
|
||||
settings rows from surviving a disable or load failure. A fresh mod-free boot
|
||||
does not create the file, and a filesystem without write support is tolerated.
|
||||
|
||||
The producer writes the snapshot after entry chunks and the final load set
|
||||
have been established. Consumers must treat the file as untrusted input and
|
||||
must not execute anything from it.
|
||||
|
||||
## Compatibility and versioning
|
||||
|
||||
The contract is optional on both sides. A native consumer may be absent, and
|
||||
the engine continues normally if the file cannot be written. A native
|
||||
consumer is not required to render, validate, or persist every supported row;
|
||||
it may ignore an unknown row type or optional field.
|
||||
|
||||
For compatibility with files produced by the original unversioned prototype,
|
||||
a missing `schema_version` means version 1. Consumers must ignore documents
|
||||
with a newer version rather than guessing at their shape. Producers must bump
|
||||
the version whenever they change the document shape or the meaning of an
|
||||
existing field. Version 1 is therefore the legacy unversioned format as well
|
||||
as the explicitly versioned format shown above.
|
||||
|
||||
## Migration note
|
||||
|
||||
Nothing. Existing mods, option values, and the in-game options UI are
|
||||
unchanged. Platforms that do not consume `mod_option_schemas.json` have no
|
||||
new integration requirement.
|
||||
|
||||
## Parity tests
|
||||
|
||||
`tests/mod_loader_tests.lua` verifies the explicit version, runtime and legacy
|
||||
row round-tripping, enabled/disabled filtering, failed-mod filtering,
|
||||
stale-snapshot clearing, and tolerance of a read-only filesystem.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is deprecated. The unversioned file form remains readable as legacy
|
||||
version 1; new producers write the explicit `schema_version` field.
|
||||
+71
-6
@@ -68,6 +68,14 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||
read-only snapshots; mods choose which layers to render.
|
||||
|
||||
## Party ordering
|
||||
|
||||
Companion UIs and alternate party screens can call
|
||||
`mod.world:canReorderParty()` before offering a reorder action, then
|
||||
`mod.world:reorderParty(fromSlot, toSlot)` with one-based party slots. The
|
||||
operation is accepted only during idle overworld play; menus, movement,
|
||||
scripts, battles, and transitions leave the party untouched.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -231,7 +239,18 @@ local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
|
||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private.
|
||||
private. A title-selected context may additionally contain `normalSavedAt`, the
|
||||
validated matching ordinary-save chronology only; it never exposes normal-save
|
||||
progress or a slot/path handle.
|
||||
|
||||
At the title screen only, `mod.storage:selected(game)` returns a bound storage
|
||||
facade for the launcher-selected existing playthrough, or `nil, code, message`.
|
||||
Resolving this facade is read-only: it never allocates an identity, adopts a
|
||||
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
|
||||
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same
|
||||
data-only and transaction contract as `mod.storage`, but remain restricted to
|
||||
the calling mod's selected existing namespace. It is intended for title tools
|
||||
that need to browse or manage durable history before the first normal SAVE.
|
||||
|
||||
Values must be tables containing serializable data only. Keys are conservative
|
||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||
@@ -250,13 +269,21 @@ if capability.canCapture then
|
||||
end
|
||||
|
||||
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
||||
|
||||
-- After the tool has durably committed its first checkpoint, make a
|
||||
-- never-saved playthrough reachable through ordinary title boot exactly once.
|
||||
local anchored, anchorCode, anchorMessage =
|
||||
mod.checkpoints:ensureNormalSave(game, checkpoint)
|
||||
```
|
||||
|
||||
Checkpoint format 1 supports settled overworld control and proven battle
|
||||
player-decision safe points. Battle checkpoints are limited to ordinary
|
||||
single-player wild/trainer origins with no suspended script; link, Safari,
|
||||
ghost, demo, scripted, animation, message, queue, and forced-action phases fail
|
||||
closed. New checkpoints preserve gameplay RNG, while legacy overworld records
|
||||
player-decision safe points. Ordinary single-player wild/trainer encounters are
|
||||
supported. Scripted story battles are also supported when the engine can detach
|
||||
their current built-in battle command and data-only row continuation, rebind any
|
||||
NPC by stable id, and resume the story through a fresh runner. The suspended Lua
|
||||
coroutine is never serialized. Link, Safari, ghost, demo, opaque callback,
|
||||
non-data-only script, animation, message, queue, concurrent-script, and
|
||||
forced-action phases fail closed. New checkpoints preserve gameplay RNG, while legacy overworld records
|
||||
without RNG remain loadable. Capture excludes global options and runtime
|
||||
objects. Restore validates format, game/playthrough identity, content,
|
||||
coordinates, battle relationships, continuation, and RNG before mutation;
|
||||
@@ -293,7 +320,25 @@ private state. A mod that deliberately stores progress-coupled truth in
|
||||
cannot distinguish it safely from independent history, configuration, or cache
|
||||
data.
|
||||
|
||||
See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes.
|
||||
`mod.checkpoints:resume(game, checkpoint)` is the title-session counterpart to
|
||||
live `restore`. It validates the same data-only checkpoint against the
|
||||
engine-selected existing playthrough, reconstructs only after all validation
|
||||
passes, preserves current options, and verifies by recapture. A title session
|
||||
has no live gameplay rollback state: if reconstruction or verification fails,
|
||||
the engine rebuilds a usable title session and returns `false, code, message`.
|
||||
It never rewrites a normal Pokémon save. It is unavailable outside title and does
|
||||
not broaden capture or arbitrary-frame support.
|
||||
|
||||
`mod.checkpoints:ensureNormalSave(game, checkpoint)` is a separate live-runtime
|
||||
operation for durable checkpoint tools. It creates ordinary progress only when
|
||||
none exists, only after validating that the supplied checkpoint is the exact
|
||||
current safe runtime, and through the normal atomic save lifecycle. Once an
|
||||
ordinary save exists it returns `true, "already_exists"` without writing, so
|
||||
subsequent checkpoints and the player's later SAVE commands remain independent.
|
||||
Call it only after the tool's own checkpoint/index commit; treat an anchoring
|
||||
failure as a failed first checkpoint rather than claiming restart safety.
|
||||
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
|
||||
codes.
|
||||
|
||||
## Developer console
|
||||
|
||||
@@ -449,3 +494,23 @@ platform-bridge mod bundled only with that build's launcher, for example).
|
||||
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
|
||||
already falls straight through to the vanilla function when no mod has
|
||||
wrapped the name, at negligible cost.
|
||||
|
||||
## Shared date and time presentation
|
||||
|
||||
The global Options menu owns `DATE FORMAT` (`DEVICE`, `DD-MM-YYYY`,
|
||||
`MM-DD-YYYY`, `YYYY-MM-DD`) and `TIME FORMAT` (`DEVICE`, `24 HOUR`, `12 HOUR`).
|
||||
These preferences live in `options.lua`, so checkpoint restore never rewinds
|
||||
them. `DEVICE` uses the process time locale when the platform provides one;
|
||||
the portable fallback is `DD-MM-YYYY` plus 24-hour time.
|
||||
|
||||
Mods format captured timestamps through the read-only public facade:
|
||||
|
||||
```lua
|
||||
local date = mod.datetime:date(game, createdAt)
|
||||
local time = mod.datetime:time(game, createdAt)
|
||||
local both = mod.datetime:dateTime(game, createdAt)
|
||||
```
|
||||
|
||||
The live `game` supplies only the current option context. Formatting never
|
||||
mutates the save, options, or timestamp, and invalid timestamps return
|
||||
`"----"`.
|
||||
|
||||
@@ -15,9 +15,12 @@ references, completion is currently an `onFinish` closure, and scripted battles
|
||||
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
|
||||
a record that is neither data-only nor process-independent.
|
||||
|
||||
The engine can instead expose a narrow semantic safe point. This gives all mods
|
||||
the strongest persistent battle checkpoint the current architecture can prove,
|
||||
without claiming mid-animation or suspended-script support.
|
||||
The engine can instead expose a narrow semantic safe point. Ordinary encounters
|
||||
use fixed engine-owned completion descriptors. A scripted story encounter may
|
||||
also participate when its active command row and remaining row-list are
|
||||
detached data, its NPC can be rebound by stable object id, and its completion is
|
||||
one of the engine-declared semantic forms. The suspended coroutine itself is
|
||||
never captured.
|
||||
|
||||
## API delta
|
||||
|
||||
@@ -26,7 +29,7 @@ second format-1 runtime kind.
|
||||
|
||||
### Capability
|
||||
|
||||
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
|
||||
`mod.checkpoints:inspect(game)` returns this only when a supported single-player
|
||||
wild or trainer battle is settled at the player command menu:
|
||||
|
||||
```lua
|
||||
@@ -35,13 +38,15 @@ wild or trainer battle is settled at the player command menu:
|
||||
|
||||
The action/message queue, waits, UI, animations, HP/status presentation, and
|
||||
faint processing must be settled. The player must actually control the menu.
|
||||
The underlying overworld must have no running/queued script or scripted move,
|
||||
and the battle must carry an engine-owned semantic continuation descriptor.
|
||||
The battle must carry an engine-owned semantic continuation descriptor. An
|
||||
ordinary encounter requires an idle overworld. A scripted story encounter may
|
||||
have exactly its originating foreground runner suspended at the battle command;
|
||||
queued/parallel scripts and scripted movement remain unsafe.
|
||||
|
||||
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
|
||||
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
|
||||
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
|
||||
closure continuations remain rejected.
|
||||
ghost, old-man/demo, fishing, opaque callback continuations, non-data-only
|
||||
scripts, and unsupported concurrent script work remain rejected.
|
||||
|
||||
### Capture
|
||||
|
||||
@@ -107,10 +112,20 @@ trainer class/party, and optional header event; a win reapplies the same defeate
|
||||
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
|
||||
NPC freeze state are normalized instead of reviving the old closure.
|
||||
|
||||
`Commands.start_battle` is deliberately unsupported: its completion closure
|
||||
mutates script context and resumes a coroutine whose program counter and Lua
|
||||
stack cannot be serialized. Existing script rejection remains the correct safe
|
||||
contract until a separate semantic ScriptRunner checkpoint RFC exists.
|
||||
For `start_battle`, `rival_battle`, and `static_battle`, the runner records the
|
||||
detached row list and current command program counter plus stable source/NPC
|
||||
identity where present. On restore, battle completion starts a fresh runner at
|
||||
that command with a one-use semantic battle result. Replaying the current
|
||||
command (rather than skipping to the next row) preserves wrapper behavior such
|
||||
as rival-party consequences, static-object removal, `lastCheck`, and deferred
|
||||
`afterBattle` evolution ordering. The reconstructed overworld starts with
|
||||
normalized input/NPC freeze state, so an engine-marked `release_npc` callback
|
||||
needs no closure revival.
|
||||
|
||||
Arbitrary `onDone` callbacks, function-bearing rows, unknown commands, missing
|
||||
NPC identities, old-man/demo flows, and concurrent scripts fail closed. This is
|
||||
not a general ScriptRunner snapshot: no coroutine, Lua stack, local variable,
|
||||
function, or runtime object enters the checkpoint.
|
||||
|
||||
## Migration note
|
||||
|
||||
@@ -138,3 +153,6 @@ No-mod behavior is unchanged when checkpoints are unused.
|
||||
- exactly one post-verification `checkpoint.restored` event and none on failure;
|
||||
- legacy overworld checkpoint compatibility;
|
||||
- complete ROM-free engine and public mod-API suites.
|
||||
- scripted trainer and static/wild story continuation, including wrapper-row
|
||||
replay, cold reconstruction, malformed row rejection, and opaque callback
|
||||
refusal.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# RFC 0006 — Selected title playthrough storage and checkpoint resume
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `SaveData.lua`, `Storage.lua`, `Checkpoint.lua`, and
|
||||
`Loader.lua`. Tests: `title_playthrough_context.lua`, existing storage,
|
||||
checkpoint, title, save-slot, and no-mod parity suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
A tool checkpoint may be the first durable record of a new playthrough. The
|
||||
engine intentionally keeps normal Pokémon SAVE independent: before the first
|
||||
normal write, identity is retained by the engine-owned selected-slot mapping,
|
||||
while title starts with a fresh New Game skeleton. A durable checkpoint tool may
|
||||
explicitly create one ordinary progress anchor after its first checkpoint has
|
||||
committed; later tool writes must remain independent. Calling ordinary active
|
||||
`mod.storage` there would allocate/adopt an identity, and live
|
||||
`mod.checkpoints:restore` correctly refuses title because it has no gameplay
|
||||
rollback state. Generic public capabilities are required; a tool must not use
|
||||
private storage paths, slot ids, or simulate the player's SAVE menu flow.
|
||||
|
||||
## Additive public API
|
||||
|
||||
### `mod.storage:selected(game)`
|
||||
|
||||
Available only while the engine is in a title session. Returns an opaque bound
|
||||
facade or `nil, code, message`:
|
||||
|
||||
```lua
|
||||
local selected = mod.storage:selected(game)
|
||||
local context = selected:context()
|
||||
local history = selected:read("history/index")
|
||||
```
|
||||
|
||||
The facade exposes `context()`, `read(key)`, `write(key, value)`,
|
||||
`list(prefix)`, and `delete(key)`. It is bound internally to the launcher-
|
||||
selected existing game-version/playthrough and the calling mod id. It neither
|
||||
accepts an arbitrary playthrough id nor reveals a slot id, filesystem path, or
|
||||
another mod namespace. Resolution is read-only; no selected mapping means
|
||||
`no_selected_playthrough`, and opening a title browser never mints an identity.
|
||||
Its detached context can include only `normalSavedAt` from a matching ordinary
|
||||
save, so title tools can apply their own resume policy without receiving the
|
||||
canonical normal-save record.
|
||||
|
||||
### `mod.checkpoints:ensureNormalSave(game, checkpoint)`
|
||||
|
||||
Available only at a live checkpoint-safe boundary. After a tool has durably
|
||||
committed the supplied current checkpoint, it may request an ordinary progress
|
||||
anchor for a playthrough that has never had one. The engine validates the
|
||||
checkpoint, proves it exactly matches a fresh capture of the live runtime, and
|
||||
uses the normal atomic save path including `save.write` lifecycle/veto hooks.
|
||||
|
||||
The operation is idempotent. It returns `true, "already_exists"` without writing
|
||||
when matching normal progress already exists, so later checkpoints never move
|
||||
the vanilla CONTINUE target. A stale/non-current checkpoint, unsafe runtime,
|
||||
write veto/failure, or failed readback returns a structured failure. A tool
|
||||
should call it only after its own checkpoint and index are durable and must not
|
||||
report that first checkpoint as successful if the required anchor fails.
|
||||
|
||||
### `mod.checkpoints:resume(game, checkpoint)`
|
||||
|
||||
Available only from title. It validates format, data-only structure, selected
|
||||
game/playthrough identity, canonical save/content, overworld/battle runtime, and
|
||||
RNG exactly as `restore` does. It then reconstructs semantic overworld or a
|
||||
supported battle continuation, preserves current options, and differentially
|
||||
recaptures before committing. On success it emits `checkpoint.restored` once.
|
||||
|
||||
Title has no live runtime rollback. A reconstruction or verification failure
|
||||
therefore rebuilds a clean title session from the pre-operation title save and
|
||||
RNG; it emits no success event and never rewrites normal progress. Validation
|
||||
failure leaves the existing title session untouched. Stable errors include
|
||||
`not_at_title`, `no_selected_playthrough`, normal checkpoint validation codes,
|
||||
`resume_failed`, and `title_recovery_failed`.
|
||||
|
||||
## Isolation and migration
|
||||
|
||||
Explicit NEW GAME retains its existing fresh-identity rule. It does not reuse a
|
||||
previous selected mapping and cannot see old tool history. Existing mods change
|
||||
nothing: no identity, storage, title reconstruction, or event is created unless
|
||||
the new methods are called. `mod.storage` remains independent durable data and
|
||||
does not rewind with a checkpoint; canonical `game.save` / `mod.save` does.
|
||||
|
||||
## Verification
|
||||
|
||||
The public SDK test starts a fresh playthrough, stores tool history, creates and
|
||||
readback-verifies exactly one normal anchor, proves subsequent calls do not
|
||||
rewrite it, simulates title/restart, reads the selected binding without
|
||||
allocating title identity, resumes an overworld checkpoint, preserves options,
|
||||
differentially recaptures, and confirms a later explicit NEW GAME receives
|
||||
another identity. A separate two-process disk test proves cold-start routing and
|
||||
reconstruction. Existing no-mod, storage, checkpoint, battle, and title suites
|
||||
prove additive parity.
|
||||
@@ -23,6 +23,7 @@
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <unordered_map>
|
||||
|
||||
#include <SDL.h>
|
||||
@@ -324,6 +325,182 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
||||
* own class, never FindClass -- and the same tolerance for an old APK: a
|
||||
* missing method answers like a platform without TLS instead of aborting.
|
||||
*/
|
||||
static jclass tlsActivityClass(JNIEnv *env)
|
||||
{
|
||||
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||
if (activityObj == nullptr)
|
||||
return nullptr;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
return activity;
|
||||
}
|
||||
|
||||
int tlsOpen(const char *host, int port)
|
||||
{
|
||||
if (host == nullptr)
|
||||
return -1;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsOpen", "(Ljava/lang/String;I)I");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jstring jhost = env->NewStringUTF(host);
|
||||
jint result = env->CallStaticIntMethod(activity, method, jhost, (jint) port);
|
||||
env->DeleteLocalRef(jhost);
|
||||
env->DeleteLocalRef(activity);
|
||||
return (int) result;
|
||||
}
|
||||
|
||||
int tlsStatus(int handle)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsStatus", "(I)I");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jint result = env->CallStaticIntMethod(activity, method, (jint) handle);
|
||||
env->DeleteLocalRef(activity);
|
||||
return (int) result;
|
||||
}
|
||||
|
||||
int tlsSend(int handle, const char *data, int length)
|
||||
{
|
||||
if (data == nullptr || length <= 0)
|
||||
return 0;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsSend", "(I[B)I");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jbyteArray payload = env->NewByteArray((jsize) length);
|
||||
if (payload == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
env->SetByteArrayRegion(payload, 0, (jsize) length, (const jbyte*) data);
|
||||
|
||||
jint result = env->CallStaticIntMethod(activity, method, (jint) handle, payload);
|
||||
env->DeleteLocalRef(payload);
|
||||
env->DeleteLocalRef(activity);
|
||||
return (int) result;
|
||||
}
|
||||
|
||||
int tlsReceive(int handle, char *buf, int max)
|
||||
{
|
||||
if (buf == nullptr || max <= 0)
|
||||
return 0;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsReceive", "(II)[B");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle, (jint) max);
|
||||
env->DeleteLocalRef(activity);
|
||||
if (result == nullptr)
|
||||
return 0;
|
||||
|
||||
jbyteArray bytes = (jbyteArray) result;
|
||||
jsize length = env->GetArrayLength(bytes);
|
||||
if (length > max)
|
||||
length = max;
|
||||
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) buf);
|
||||
env->DeleteLocalRef(result);
|
||||
return (int) length;
|
||||
}
|
||||
|
||||
bool tlsError(int handle, char *buf, int max)
|
||||
{
|
||||
if (buf == nullptr || max <= 0)
|
||||
return false;
|
||||
buf[0] = '\0';
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return false;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsError", "(I)Ljava/lang/String;");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle);
|
||||
env->DeleteLocalRef(activity);
|
||||
if (result == nullptr)
|
||||
return false;
|
||||
|
||||
jstring text = (jstring) result;
|
||||
const char *utf = env->GetStringUTFChars(text, nullptr);
|
||||
if (utf != nullptr)
|
||||
{
|
||||
strncpy(buf, utf, (size_t) max - 1);
|
||||
buf[max - 1] = '\0';
|
||||
env->ReleaseStringUTFChars(text, utf);
|
||||
}
|
||||
env->DeleteLocalRef(result);
|
||||
return buf[0] != '\0';
|
||||
}
|
||||
|
||||
void tlsClose(int handle)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsClose", "(I)V");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return;
|
||||
}
|
||||
|
||||
env->CallStaticVoidMethod(activity, method, (jint) handle);
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
|
||||
@@ -98,6 +98,27 @@ bool restartApp();
|
||||
**/
|
||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
|
||||
|
||||
/**
|
||||
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
||||
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
||||
* unreachable -- and an Archipelago room hosted on archipelago.gg accepts a
|
||||
* plain connection only to drop it. The platform has both a TLS stack and the
|
||||
* system trust store, so this borrows them rather than vendoring mbedTLS.
|
||||
*
|
||||
* tlsOpen returns a handle immediately and connects on its own thread: poll
|
||||
* tlsStatus for 0 connecting / 1 open / 2 closed, and -1 for a handle that
|
||||
* does not exist. Bytes given to tlsSend before the handshake finishes are
|
||||
* queued rather than refused. tlsReceive fills buf and returns how much it
|
||||
* took, 0 when nothing is waiting. A closed connection keeps both its reason
|
||||
* (tlsError) and whatever arrived before it closed until tlsClose.
|
||||
**/
|
||||
int tlsOpen(const char *host, int port);
|
||||
int tlsStatus(int handle);
|
||||
int tlsSend(int handle, const char *data, int length);
|
||||
int tlsReceive(int handle, char *buf, int max);
|
||||
bool tlsError(int handle, char *buf, int max);
|
||||
void tlsClose(int handle);
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
|
||||
@@ -244,6 +244,72 @@ bool System::httpDownload(const char *url, const char *destPath,
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsOpen(const char *host, int port) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsOpen(host, port);
|
||||
#else
|
||||
LOVE_UNUSED(host);
|
||||
LOVE_UNUSED(port);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsStatus(int handle) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsStatus(handle);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsSend(int handle, const char *data, int length) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsSend(handle, data, length);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
LOVE_UNUSED(data);
|
||||
LOVE_UNUSED(length);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsReceive(int handle, char *buf, int max) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsReceive(handle, buf, max);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
LOVE_UNUSED(buf);
|
||||
LOVE_UNUSED(max);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::tlsError(int handle, char *buf, int max) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsError(handle, buf, max);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
LOVE_UNUSED(buf);
|
||||
LOVE_UNUSED(max);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void System::tlsClose(int handle) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
love::android::tlsClose(handle);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::hasBackgroundMusic() const
|
||||
{
|
||||
#if defined(LOVE_ANDROID)
|
||||
|
||||
@@ -149,6 +149,20 @@ public:
|
||||
virtual bool httpDownload(const char *url, const char *destPath,
|
||||
const char *userAgent = nullptr, const char *accept = nullptr) const;
|
||||
|
||||
/**
|
||||
* TLS client sockets (Android only; every call fails elsewhere, where
|
||||
* LuaSec or another provider is the answer). Non-blocking by contract:
|
||||
* tlsOpen returns a handle and connects on its own thread, tlsStatus
|
||||
* reports 0 connecting / 1 open / 2 closed / -1 unknown, and bytes sent
|
||||
* before the handshake completes are queued rather than refused.
|
||||
**/
|
||||
virtual int tlsOpen(const char *host, int port) const;
|
||||
virtual int tlsStatus(int handle) const;
|
||||
virtual int tlsSend(int handle, const char *data, int length) const;
|
||||
virtual int tlsReceive(int handle, char *buf, int max) const;
|
||||
virtual bool tlsError(int handle, char *buf, int max) const;
|
||||
virtual void tlsClose(int handle) const;
|
||||
|
||||
/**
|
||||
* Gets if the user is playing music on background.
|
||||
* Throws an exception on unsupported platforms.
|
||||
|
||||
@@ -139,6 +139,79 @@ int w_hasBackgroundMusic(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* TLS sockets. Deliberately a handle-and-poll API rather than an object:
|
||||
* the caller is a per-frame pump that must never block, and everything with
|
||||
* a thread behind it lives on the Java side.
|
||||
*/
|
||||
int w_tlsOpen(lua_State *L)
|
||||
{
|
||||
const char *host = luaL_checkstring(L, 1);
|
||||
int port = (int) luaL_checknumber(L, 2);
|
||||
lua_pushnumber(L, instance()->tlsOpen(host, port));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsStatus(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
lua_pushnumber(L, instance()->tlsStatus(handle));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsSend(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
size_t length = 0;
|
||||
const char *data = luaL_checklstring(L, 2, &length);
|
||||
lua_pushnumber(L, instance()->tlsSend(handle, data, (int) length));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsReceive(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
int max = (int) luaL_optnumber(L, 2, 8192);
|
||||
if (max <= 0)
|
||||
{
|
||||
lua_pushliteral(L, "");
|
||||
return 1;
|
||||
}
|
||||
// A frame's worth of a busy room, on the C stack rather than the heap:
|
||||
// this runs every frame and an allocation per poll is not worth it.
|
||||
if (max > 65536)
|
||||
max = 65536;
|
||||
char buf[65536];
|
||||
int got = instance()->tlsReceive(handle, buf, max);
|
||||
if (got < 0)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushlstring(L, buf, (size_t) got);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsError(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
char buf[512];
|
||||
if (!instance()->tlsError(handle, buf, (int) sizeof(buf)))
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushstring(L, buf);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsClose(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
instance()->tlsClose(handle);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const luaL_Reg functions[] =
|
||||
{
|
||||
{ "getOS", w_getOS },
|
||||
@@ -153,6 +226,12 @@ static const luaL_Reg functions[] =
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
{ "tlsOpen", w_tlsOpen },
|
||||
{ "tlsStatus", w_tlsStatus },
|
||||
{ "tlsSend", w_tlsSend },
|
||||
{ "tlsReceive", w_tlsReceive },
|
||||
{ "tlsError", w_tlsError },
|
||||
{ "tlsClose", w_tlsClose },
|
||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||
{ 0, 0 }
|
||||
};
|
||||
|
||||
@@ -606,6 +606,45 @@ public class GameActivity extends SDLActivity {
|
||||
* The body lands in a .part file and is renamed only once complete, so a
|
||||
* dropped connection can never leave a half file the caller trusts.
|
||||
*/
|
||||
/**
|
||||
* TLS client sockets, exposed as love.system.tls* and used by the
|
||||
* Archipelago mod for wss:// rooms. LuaSocket speaks TCP only, so without
|
||||
* these a hosted room -- every one of which is TLS-only -- is unreachable
|
||||
* from the game. The work is in TlsSocket; these are the static entry
|
||||
* points, because the JNI side resolves methods on the activity's own
|
||||
* class (see love/src/common/android.cpp) and cannot see other classes
|
||||
* from a worker thread.
|
||||
*/
|
||||
@Keep
|
||||
public static int tlsOpen(String host, int port) {
|
||||
return TlsSocket.open(host, port);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static int tlsStatus(int handle) {
|
||||
return TlsSocket.status(handle);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static int tlsSend(int handle, byte[] data) {
|
||||
return TlsSocket.send(handle, data);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static byte[] tlsReceive(int handle, int max) {
|
||||
return TlsSocket.receive(handle, max);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static String tlsError(int handle) {
|
||||
return TlsSocket.error(handle);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static void tlsClose(int handle) {
|
||||
TlsSocket.close(handle);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean httpDownload(String url, String destPath, String userAgent, String accept) {
|
||||
if (url == null || destPath == null) return false;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package org.love2d.android;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SNIHostName;
|
||||
import javax.net.ssl.SNIServerName;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
|
||||
/**
|
||||
* A TLS client socket that Lua can drive without ever blocking a frame.
|
||||
*
|
||||
* WHY THIS EXISTS. LuaSocket, which is what LOVE ships, speaks TCP and nothing
|
||||
* else, so wss:// was simply unreachable from the game -- and every room hosted
|
||||
* on archipelago.gg is TLS-only, accepting a plain connection just long enough
|
||||
* to drop it. The alternative was vendoring mbedTLS into the NDK build and
|
||||
* carrying a CA bundle in the APK; the platform already has both a TLS stack
|
||||
* and the system trust store, so this asks Android instead.
|
||||
*
|
||||
* THE CONTRACT. Callers get an int handle and poll it. open() returns
|
||||
* immediately and the connect and handshake happen on their own thread, so a
|
||||
* slow or unreachable host costs nothing on the game thread -- which matters
|
||||
* more here than it did for httpDownload, since that runs on a worker and this
|
||||
* is serviced from the frame loop. Bytes handed to send() before the handshake
|
||||
* finishes are queued, not refused, so a caller can write its request the
|
||||
* moment it has a handle and never think about readiness again.
|
||||
*
|
||||
* Reads are drained by a thread into a chunk queue and handed over a copy at a
|
||||
* time; a caller that stops polling stops the connection rather than growing
|
||||
* the heap without limit.
|
||||
*/
|
||||
final class TlsSocket {
|
||||
static final int STATUS_CONNECTING = 0;
|
||||
static final int STATUS_OPEN = 1;
|
||||
static final int STATUS_CLOSED = 2;
|
||||
|
||||
private static final int CONNECT_TIMEOUT_MS = 15000;
|
||||
private static final int READ_CHUNK = 16384;
|
||||
/** Roughly a second of a very chatty room; past this the reader is gone. */
|
||||
private static final int MAX_BUFFERED = 4 * 1024 * 1024;
|
||||
|
||||
private static final ConcurrentHashMap<Integer, TlsSocket> LIVE =
|
||||
new ConcurrentHashMap<Integer, TlsSocket>();
|
||||
private static final AtomicInteger NEXT_HANDLE = new AtomicInteger(1);
|
||||
|
||||
private final String host;
|
||||
private final int port;
|
||||
private final int handle;
|
||||
|
||||
private volatile int status = STATUS_CONNECTING;
|
||||
private volatile String error = null;
|
||||
private volatile boolean closing = false;
|
||||
private volatile SSLSocket socket = null;
|
||||
|
||||
private final Object inLock = new Object();
|
||||
private final ArrayDeque<byte[]> inChunks = new ArrayDeque<byte[]>();
|
||||
private int inHeadOffset = 0;
|
||||
private int inAvailable = 0;
|
||||
|
||||
private final Object outLock = new Object();
|
||||
private final ArrayDeque<byte[]> outChunks = new ArrayDeque<byte[]>();
|
||||
|
||||
private TlsSocket(String host, int port, int handle) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.handle = handle;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- API
|
||||
|
||||
static int open(String host, int port) {
|
||||
if (host == null || host.length() == 0 || port <= 0 || port > 65535) return -1;
|
||||
final int handle = NEXT_HANDLE.getAndIncrement();
|
||||
final TlsSocket self = new TlsSocket(host, port, handle);
|
||||
LIVE.put(Integer.valueOf(handle), self);
|
||||
Thread dialer = new Thread(new Runnable() {
|
||||
@Override public void run() { self.dial(); }
|
||||
}, "tls-dial-" + handle);
|
||||
dialer.setDaemon(true);
|
||||
dialer.start();
|
||||
return handle;
|
||||
}
|
||||
|
||||
static int status(int handle) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
return self == null ? -1 : self.status;
|
||||
}
|
||||
|
||||
static String error(int handle) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
return self == null ? null : self.error;
|
||||
}
|
||||
|
||||
static int send(int handle, byte[] data) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
if (self == null || data == null) return -1;
|
||||
if (self.status == STATUS_CLOSED) return -1;
|
||||
if (data.length == 0) return 0;
|
||||
synchronized (self.outLock) {
|
||||
self.outChunks.add(data);
|
||||
self.outLock.notifyAll();
|
||||
}
|
||||
return data.length;
|
||||
}
|
||||
|
||||
static byte[] receive(int handle, int max) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
if (self == null || max <= 0) return null;
|
||||
return self.take(max);
|
||||
}
|
||||
|
||||
static void close(int handle) {
|
||||
TlsSocket self = LIVE.remove(Integer.valueOf(handle));
|
||||
if (self != null) self.shutdown(null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
private void dial() {
|
||||
Socket plain = null;
|
||||
try {
|
||||
plain = new Socket();
|
||||
plain.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS);
|
||||
plain.setTcpNoDelay(true);
|
||||
|
||||
SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
|
||||
SSLSocket ssl = (SSLSocket) factory.createSocket(plain, host, port, true);
|
||||
|
||||
// Wrapping an already-connected socket skips the SNI and hostname
|
||||
// checking that createSocket(host, port) would have done for us, and
|
||||
// a shared address like archipelago.gg answers with the wrong
|
||||
// certificate without the name in the hello. Both are set through
|
||||
// SSLParameters where the platform has it, with the verifier below
|
||||
// as the floor for anything older.
|
||||
boolean verifiedByPlatform = false;
|
||||
try {
|
||||
SSLParameters params = ssl.getSSLParameters();
|
||||
params.setEndpointIdentificationAlgorithm("HTTPS");
|
||||
List<SNIServerName> names = new ArrayList<SNIServerName>(1);
|
||||
names.add(new SNIHostName(host));
|
||||
params.setServerNames(names);
|
||||
ssl.setSSLParameters(params);
|
||||
verifiedByPlatform = true;
|
||||
} catch (Throwable ignored) {
|
||||
// Older platform: handled after the handshake instead.
|
||||
}
|
||||
|
||||
enableModernProtocols(ssl);
|
||||
ssl.startHandshake();
|
||||
|
||||
if (!verifiedByPlatform
|
||||
&& !HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
.verify(host, ssl.getSession())) {
|
||||
throw new java.io.IOException(
|
||||
"certificate does not match " + host);
|
||||
}
|
||||
|
||||
socket = ssl;
|
||||
if (closing) { shutdown(null); return; }
|
||||
status = STATUS_OPEN;
|
||||
|
||||
Thread writer = new Thread(new Runnable() {
|
||||
@Override public void run() { pumpOut(); }
|
||||
}, "tls-write-" + handle);
|
||||
writer.setDaemon(true);
|
||||
writer.start();
|
||||
|
||||
pumpIn();
|
||||
} catch (Throwable t) {
|
||||
shutdown(describe(t));
|
||||
if (plain != null) {
|
||||
try { plain.close(); } catch (Throwable ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* minSdk is 16, where TLS 1.1/1.2 exist but are off by default. Every
|
||||
* modern server refuses everything older, so switch on whatever the
|
||||
* platform has rather than leaving an old device negotiating TLS 1.0.
|
||||
*/
|
||||
private static void enableModernProtocols(SSLSocket ssl) {
|
||||
try {
|
||||
List<String> wanted = new ArrayList<String>(3);
|
||||
for (String supported : ssl.getSupportedProtocols()) {
|
||||
if (supported.startsWith("TLSv1.1")
|
||||
|| supported.startsWith("TLSv1.2")
|
||||
|| supported.startsWith("TLSv1.3")) {
|
||||
wanted.add(supported);
|
||||
}
|
||||
}
|
||||
if (!wanted.isEmpty()) {
|
||||
ssl.setEnabledProtocols(wanted.toArray(new String[wanted.size()]));
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void pumpIn() {
|
||||
try {
|
||||
InputStream in = socket.getInputStream();
|
||||
byte[] buf = new byte[READ_CHUNK];
|
||||
while (!closing) {
|
||||
int n = in.read(buf);
|
||||
if (n < 0) break;
|
||||
if (n == 0) continue;
|
||||
byte[] chunk = new byte[n];
|
||||
System.arraycopy(buf, 0, chunk, 0, n);
|
||||
synchronized (inLock) {
|
||||
if (inAvailable + n > MAX_BUFFERED) {
|
||||
throw new java.io.IOException("read buffer overflow");
|
||||
}
|
||||
inChunks.add(chunk);
|
||||
inAvailable += n;
|
||||
}
|
||||
}
|
||||
shutdown(null);
|
||||
} catch (Throwable t) {
|
||||
shutdown(describe(t));
|
||||
}
|
||||
}
|
||||
|
||||
private void pumpOut() {
|
||||
try {
|
||||
OutputStream out = socket.getOutputStream();
|
||||
while (true) {
|
||||
byte[] chunk;
|
||||
synchronized (outLock) {
|
||||
while (outChunks.isEmpty() && !closing && status != STATUS_CLOSED) {
|
||||
outLock.wait();
|
||||
}
|
||||
if (closing || status == STATUS_CLOSED) return;
|
||||
chunk = outChunks.poll();
|
||||
}
|
||||
if (chunk != null) {
|
||||
out.write(chunk);
|
||||
out.flush();
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
shutdown(describe(t));
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] take(int max) {
|
||||
synchronized (inLock) {
|
||||
if (inAvailable <= 0) return null;
|
||||
int want = Math.min(max, inAvailable);
|
||||
byte[] out = new byte[want];
|
||||
int filled = 0;
|
||||
while (filled < want) {
|
||||
byte[] head = inChunks.peek();
|
||||
if (head == null) break;
|
||||
int have = head.length - inHeadOffset;
|
||||
int take = Math.min(have, want - filled);
|
||||
System.arraycopy(head, inHeadOffset, out, filled, take);
|
||||
filled += take;
|
||||
inHeadOffset += take;
|
||||
if (inHeadOffset >= head.length) {
|
||||
inChunks.poll();
|
||||
inHeadOffset = 0;
|
||||
}
|
||||
}
|
||||
inAvailable -= filled;
|
||||
if (filled == want) return out;
|
||||
byte[] short_ = new byte[filled];
|
||||
System.arraycopy(out, 0, short_, 0, filled);
|
||||
return short_;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The handle stays registered until the caller closes it, so why the
|
||||
* connection ended and whatever arrived before it did are both still
|
||||
* readable. Dropping it here instead would turn a server that states its
|
||||
* refusal and hangs up into an unknown handle, which is the one failure a
|
||||
* player most needs the reason for.
|
||||
*/
|
||||
private void shutdown(String why) {
|
||||
if (why != null && error == null) error = why;
|
||||
closing = true;
|
||||
status = STATUS_CLOSED;
|
||||
synchronized (outLock) { outLock.notifyAll(); }
|
||||
SSLSocket s = socket;
|
||||
socket = null;
|
||||
if (s != null) {
|
||||
try { s.close(); } catch (Throwable ignored) {}
|
||||
}
|
||||
if (why != null) Log.d("TlsSocket", host + ":" + port + " -- " + why);
|
||||
}
|
||||
|
||||
private static String describe(Throwable t) {
|
||||
String msg = t.getMessage();
|
||||
String name = t.getClass().getSimpleName();
|
||||
if (msg == null || msg.length() == 0) return name;
|
||||
return name + ": " + msg;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,104 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.78",
|
||||
"date": "2026-08-11",
|
||||
"size": 10944298,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.78/gen1recomp-0.1.78-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.77",
|
||||
"date": "2026-08-10",
|
||||
"size": 9611135,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.77/gen1recomp-0.1.77-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #960 Missing sounds for logging off & walking into NPCs\n- #961 Sound effects for entering/leaving a building & using stairs playing too late\n- #968 Caterpie not evolving?\n- #995 Nurse doesn't bow after healing\n- #1006 Minor bug: Prof Oak's aid can't do math\n- #1009 Pikachu follows player right away\n- #1013 Unable to nickname Pikachu in Yellow\n- #1021 Pikachu not moving in Prof. Oak's lab\n- #1031 Not evolving\n- #1044 Saving sound effect not playing when changing box at PC\n- #1045 Menuing sound effects missing in fights\n- #1049 No option to rename Lapras in Silph Co.\n- #1050 Pokemon Fan Club Chairman missing dialogue option.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @castdrian\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.76",
|
||||
"date": "2026-08-10",
|
||||
"size": 9610081,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.76/gen1recomp-0.1.76-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #617 Old Man in Viridian\n- #889 Pokemon yellow can't export save file right\n- #915 wrong sprite used in opening\n- #916 Trainer sprite at end of fly animation\n- #931 Mod update checks fail\n- #932 Bugs reset settings\n- #933 Add TitleState override for player\n- #945 Cannot edit Trainer Class's Battle Theme\n- #969 Linux x86_64 AppImage Shows up as \"LOVE\" instead of \"gen1recomp\"\n- #1016 Mod API: support variable-size overworld sprites\n- #1039 Encounter rate grace period not working\n- #1040 Player sprite walks right through rival after defeating him at the end of the game\n\n## Contributors\n\n- @ArmstrongThomas\n- @AverageConsumer\n- @Bortlesboat\n- @bryanthaboi\n- @crusty\n- @dlloa\n- @jherediagu\n- @KikiManjaro\n- @martin2844\n- @MaxTomahawk\n- @ShaneMcGovernIE\n- @steve1337\n- @swuff-star\n- @thibautbus\n- @Yukitty\n- hernan"
|
||||
},
|
||||
{
|
||||
"version": "0.1.75",
|
||||
"date": "2026-08-06",
|
||||
"size": 9574086,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.75/gen1recomp-0.1.75-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #848 Roms not showing up\n- #876 Remote mod download is unavailable on this platform\n- #899 Importing Pokémon red leads to the contents extracted without a folder called 'red'\n- #902 Audio is wrong!\n\n## Contributors\n\n- @andrewqsantos\n- @bryanthaboi\n- @caorthann-celt\n- @johnjohto\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.74",
|
||||
"date": "2026-08-06",
|
||||
"size": 9588946,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.74/gen1recomp-0.1.74-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #883 Evolution Stones consumed if evolution is cancelled\n- #887 MacOS Closing game just keeps reopening\n- #894 Max Repel usable in battle\n\n## Contributors\n\n- @bryanthaboi\n- @MarceloMachadoxD\n- @ratherDashing"
|
||||
},
|
||||
{
|
||||
"version": "0.1.73",
|
||||
"date": "2026-08-06",
|
||||
"size": 9585729,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.73/gen1recomp-0.1.73-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #878 Mod API: allow active screen states to be hidden from the main render\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.72",
|
||||
"date": "2026-08-05",
|
||||
"size": 9586678,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.72/gen1recomp-0.1.72-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #604 Android, retroid pocket 2+ Rom won't import\n- #666 Pikachu emotions are not working on Android\n- #716 Lock Auto Rotate Mobile\n- #727 [Bug] [Windows] Gen1 Recomp \"still in use\" after closing\n- #763 Some EVENTS are turned off\n- #781 Mouse cursor broken on Linux with multi-monitor X11 setup\n- #784 Leech Seed effect\n- #799 Held direction randomly stops player movement (requires re-input)\n- #801 Cannot update mods from the launcher (MacOS)\n- #810 Launcher menu cuts off in vertical mode iOS\n- #828 Closing the app causes settings in launcher to reset\n- #834 Mod import failing\n- #838 Exporting save file Pokemon Yellow\n- #839 AYN Thor Misplaced Data files\n- #849 Public folder support on iOS\n- #852 Cannot switch between saves states on smaller 4:3 screen or in vertical mode\n- #857 Mt. Moon Fossils Reappeared and Won’t Disappear.\n- #863 [Yellow] When you use stairs, Pikachu shouldn't be next to you in the new area\n- #864 Faithful Ratio\n- #867 Missing Dialogue after defeating Marowak in Pokemon Tower\n- #869 Giovanni moves up to the player too early\n- #870 Start Menu on Classic Color\n- #872 Missing text when finding an item with full inventory\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.71",
|
||||
"date": "2026-08-05",
|
||||
"size": 9569738,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.71/gen1recomp-0.1.71-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #806 Haptic Feedback for On-screen controls (mobile)\n- #809 NPC Stuck in Rock when battle triggered and pushing rock towards it\n- #853 When selecting one of the two pokemon after defeating the Karate Master it should show the Pokedex entry\n- #854 Textbox disappears when the yes/no dialogue appears\n- #860 Disabled moves can still be used in the turn they were disabled\n- #862 Casino Poster Rocket Grunt walks into the poster and doesn't \"Dang!\"\n- #865 [Yellow] James doesn't move in multiple encounters\n- #866 [Yellow] Dialogue in wrong \"order\" after multiple encounters (J+J, Giovanni, probably more)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.70",
|
||||
"date": "2026-08-05",
|
||||
"size": 9562731,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #590 Launcher/Save Editor/Dex\n- #788 Fly to the Pokemon Centers in the Routes is not available in original\n- #795 Fly logic is drastically different from the originals.\n- #796 Using Rare Candy from the menu closes it out.\n- #797 Gym Leaders giving items when your bag is full / Bypassing bag limit.\n- #805 Escape Rope Moltres Tower\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #833 Cancelling nickname entry results in \"A\" as the nickname\n- #835 Restarting the launcher forgets the last rom used\n- #837 Wrong sound effect for Pikachu when entering battle\n- #844 Blizzard sound effect.\n- #845 Moderate issue: Fuchsia City binoculars.\n- #846 Surfing speed after using the bicycle.\n- #847 Minor issues related to the endgame.\n\n## Contributors\n\n- @bryanthaboi\n- @dburton95\n- @johnjohto\n- @KikiManjaro"
|
||||
},
|
||||
{
|
||||
"version": "0.1.69",
|
||||
"date": "2026-08-04",
|
||||
"size": 9552851,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.69/gen1recomp-0.1.69-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #768 Menu behaviour for Pokemon and HM moves\n- #785 Back to launcher\n- #792 HM moves in the wrong position in the menu.\n- #807 Expose gameplay pointer events and source-safe mod input injection\n- #811 Untranslatables\n- #814 [minor thing] bold arrow on move swap (select)\n\n## Contributors\n\n- @bryanthaboi\n- @johnjohto"
|
||||
},
|
||||
{
|
||||
"version": "0.1.68",
|
||||
"date": "2026-08-04",
|
||||
"size": 9768011,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.68/gen1recomp-0.1.68-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @caorthann-celt"
|
||||
},
|
||||
{
|
||||
"version": "0.1.67",
|
||||
"date": "2026-08-04",
|
||||
"size": 9767619,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.67/gen1recomp-0.1.67-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #373 Lines at Title Screen\n- #644 Substitute failing causes visual issues\n- #673 iOS: thin vertical seam below the START menu panel (v0.1.56, iPhone 15 Pro Max)\n- #703 Credits playing too fast (song ends after it should)\n- #726 Surfing Pikachu Minigame Broken\n- #737 Battle menu move cursor fails to reset to the first slot after switching Pokémon\n- #750 NPC (possibly player) trades still graphically broken\n- #752 Launcher exports save to AppData while in portable mode\n- #764 Trainer Fanfare doesn't play\n- #765 Text Advance broken in certain aspects\n- #768 Menu behaviour for Pokemon and HM moves\n- #773 Battle screen colours messed up when BG = World, battle in un-flashed Rock Tunnel\n- #774 bug(build): Desktop build can reject a valid game archive under pipefail\n- #775 TM42 Dream Eater dialog.\n- #777 Battle Screen is Very dark\n- #780 Do not delete save\n- #782 Giovanni battle at Silph Co plays wrong song\n\n## Contributors\n\n- @bryanthaboi\n- @luisgonzaleznf\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.66",
|
||||
"date": "2026-08-04",
|
||||
"size": 9749980,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.66/gen1recomp-0.1.66-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #691 Save file transfer\n- #743 Cannot Scroll Main Menu/Mod Menu\n- #779 \"Enemy \" untranslateable\n\n## Contributors\n\n- @bryanthaboi\n- @jherediagu\n- @ShaneMcGovernIE\n- @vegerot"
|
||||
},
|
||||
{
|
||||
"version": "0.1.65",
|
||||
"date": "2026-08-03",
|
||||
"size": 9376618,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.65/gen1recomp-0.1.65-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #592 Screen Orientation\n- #702 FLY overworld animation incorrect/incomplete\n- #748 Launcher Menu has overlap\n- #754 Can push boulders with STRENGTH through walls\n- #758 PC to Mac Online Multiplayer Disconnects Shortly After Starting Match\n\n## Contributors\n\n- @andrewqsantos\n- @Bortlesboat\n- @bryanthaboi\n- @castdrian\n- @johnjohto\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.64",
|
||||
"date": "2026-08-03",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!--
|
||||
Desktop TLS dialer for mods that need outbound WSS/TLS (e.g. multiworld
|
||||
clients). Native AOT so the game gets a plain C ABI DLL with no .NET
|
||||
runtime to install. Windows uses Schannel via SslStream; Linux and macOS
|
||||
use their platform backends the same way.
|
||||
|
||||
Publish examples:
|
||||
dotnet publish -c Release -r win-x64 -o ../../dist/native/win-x64
|
||||
dotnet publish -c Release -r linux-x64 -o ../../dist/native/linux-x64
|
||||
dotnet publish -c Release -r osx-x64 -o ../../dist/native/osx-x64
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<PublishAot>true</PublishAot>
|
||||
<NativeLib>Shared</NativeLib>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<StripSymbols>true</StripSymbols>
|
||||
<AssemblyName>gen1tls</AssemblyName>
|
||||
<RootNamespace>Gen1Tls</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
# gen1tls — desktop TLS dialer
|
||||
|
||||
Native AOT library that gives mods a non-blocking TLS client with the same
|
||||
handle/poll contract as the Android `TlsSocket` / `love.system.tls*` bridge.
|
||||
|
||||
- **Windows:** Schannel via `SslStream` (system trust store, SNI)
|
||||
- **Linux / macOS:** same project, publish with `-r linux-x64` / `osx-x64` /
|
||||
`osx-arm64` when those builds are wired up
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
dotnet publish native/tls_dial/Gen1Tls.csproj -c Release -r win-x64 -o dist/native/win-x64
|
||||
```
|
||||
|
||||
The Windows game zip script already does this:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/build_windows.ps1 -Version 0.1.77
|
||||
```
|
||||
|
||||
Ship `gen1tls.dll` (or `libgen1tls.so` / `libgen1tls.dylib`) **next to** the
|
||||
fused executable. Mods load it through LuaJIT FFI; no .NET runtime is
|
||||
required on the player's machine.
|
||||
|
||||
## Android
|
||||
|
||||
On Android, the matching API is exposed as `love.system.tlsOpen` /
|
||||
`tlsStatus` / `tlsSend` / `tlsReceive` / `tlsError` / `tlsClose`, backed by
|
||||
`org.love2d.android.TlsSocket`.
|
||||
@@ -0,0 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Authentication;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace Gen1Tls;
|
||||
|
||||
/// <summary>
|
||||
/// Non-blocking TLS client with the same handle/poll contract as the Android
|
||||
/// TlsSocket bridge. Connect and handshake run on a background thread; send
|
||||
/// queues until the stream is ready; receive drains a chunk queue. Certificate
|
||||
/// validation and SNI are SslStream's defaults (platform trust store).
|
||||
/// </summary>
|
||||
public static class TlsDialer
|
||||
{
|
||||
public const int StatusConnecting = 0;
|
||||
public const int StatusOpen = 1;
|
||||
public const int StatusClosed = 2;
|
||||
|
||||
const int ConnectTimeoutMs = 15000;
|
||||
const int ReadChunk = 16384;
|
||||
const int MaxBuffered = 4 * 1024 * 1024;
|
||||
|
||||
static readonly ConcurrentDictionary<int, Conn> Live = new();
|
||||
static int NextHandle = 1;
|
||||
|
||||
sealed class Conn
|
||||
{
|
||||
public required string Host;
|
||||
public required int Port;
|
||||
public int Status = StatusConnecting;
|
||||
public string? Error;
|
||||
public bool Closing;
|
||||
public SslStream? Stream;
|
||||
public TcpClient? Client;
|
||||
|
||||
public readonly object InLock = new();
|
||||
public readonly Queue<byte[]> InChunks = new();
|
||||
public int InHeadOffset;
|
||||
public int InAvailable;
|
||||
|
||||
public readonly object OutLock = new();
|
||||
public readonly Queue<byte[]> OutChunks = new();
|
||||
public readonly ManualResetEventSlim OutPulse = new(false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- C ABI
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_open")]
|
||||
public static int Open(nint hostPtr, int port)
|
||||
{
|
||||
if (hostPtr == 0 || port <= 0 || port > 65535) return -1;
|
||||
string? host = Marshal.PtrToStringUTF8(hostPtr);
|
||||
if (string.IsNullOrEmpty(host)) return -1;
|
||||
|
||||
int handle = Interlocked.Increment(ref NextHandle);
|
||||
var conn = new Conn { Host = host, Port = port };
|
||||
if (!Live.TryAdd(handle, conn)) return -1;
|
||||
|
||||
var dialer = new Thread(() => Dial(conn))
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "gen1tls-dial-" + handle,
|
||||
};
|
||||
dialer.Start();
|
||||
return handle;
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_status")]
|
||||
public static int Status(int handle)
|
||||
=> Live.TryGetValue(handle, out var conn) ? conn.Status : -1;
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_send")]
|
||||
public static int Send(int handle, nint dataPtr, int length)
|
||||
{
|
||||
if (!Live.TryGetValue(handle, out var conn) || dataPtr == 0) return -1;
|
||||
if (conn.Status == StatusClosed) return -1;
|
||||
if (length <= 0) return 0;
|
||||
|
||||
var copy = new byte[length];
|
||||
Marshal.Copy(dataPtr, copy, 0, length);
|
||||
lock (conn.OutLock)
|
||||
{
|
||||
conn.OutChunks.Enqueue(copy);
|
||||
conn.OutPulse.Set();
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_receive")]
|
||||
public static int Receive(int handle, nint bufPtr, int max)
|
||||
{
|
||||
if (!Live.TryGetValue(handle, out var conn) || bufPtr == 0 || max <= 0)
|
||||
return 0;
|
||||
return Take(conn, bufPtr, max);
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_error")]
|
||||
public static int Error(int handle, nint bufPtr, int max)
|
||||
{
|
||||
if (!Live.TryGetValue(handle, out var conn)) return 0;
|
||||
string? err = conn.Error;
|
||||
if (string.IsNullOrEmpty(err) || bufPtr == 0 || max <= 1) return 0;
|
||||
|
||||
byte[] utf8 = Encoding.UTF8.GetBytes(err);
|
||||
int n = Math.Min(utf8.Length, max - 1);
|
||||
Marshal.Copy(utf8, 0, bufPtr, n);
|
||||
Marshal.WriteByte(bufPtr, n, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly(EntryPoint = "gen1tls_close")]
|
||||
public static void Close(int handle)
|
||||
{
|
||||
if (Live.TryRemove(handle, out var conn))
|
||||
Shutdown(conn, null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
static void Dial(Conn conn)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = new TcpClient();
|
||||
var connect = client.ConnectAsync(conn.Host, conn.Port);
|
||||
if (!connect.Wait(ConnectTimeoutMs))
|
||||
throw new TimeoutException($"connect to {conn.Host}:{conn.Port} timed out");
|
||||
connect.GetAwaiter().GetResult();
|
||||
client.NoDelay = true;
|
||||
conn.Client = client;
|
||||
|
||||
var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
|
||||
// AuthenticateAsClient sets SNI from targetHost and validates against
|
||||
// the platform trust store -- the whole reason this dialer exists.
|
||||
var auth = ssl.AuthenticateAsClientAsync(conn.Host);
|
||||
if (!auth.Wait(ConnectTimeoutMs))
|
||||
throw new TimeoutException($"TLS handshake with {conn.Host} timed out");
|
||||
auth.GetAwaiter().GetResult();
|
||||
|
||||
conn.Stream = ssl;
|
||||
if (conn.Closing) { Shutdown(conn, null); return; }
|
||||
conn.Status = StatusOpen;
|
||||
|
||||
var writer = new Thread(() => PumpOut(conn))
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "gen1tls-write",
|
||||
};
|
||||
writer.Start();
|
||||
PumpIn(conn);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Shutdown(conn, Describe(ex));
|
||||
}
|
||||
}
|
||||
|
||||
static void PumpIn(Conn conn)
|
||||
{
|
||||
var stream = conn.Stream;
|
||||
if (stream == null) return;
|
||||
var buf = new byte[ReadChunk];
|
||||
try
|
||||
{
|
||||
while (!conn.Closing)
|
||||
{
|
||||
int n = stream.Read(buf, 0, buf.Length);
|
||||
if (n <= 0) break;
|
||||
var chunk = new byte[n];
|
||||
Buffer.BlockCopy(buf, 0, chunk, 0, n);
|
||||
lock (conn.InLock)
|
||||
{
|
||||
// A stalled Lua pump must not grow forever; drop the
|
||||
// connection rather than the room's backlog.
|
||||
if (conn.InAvailable + n > MaxBuffered)
|
||||
throw new InvalidOperationException("TLS receive buffer overflow");
|
||||
conn.InChunks.Enqueue(chunk);
|
||||
conn.InAvailable += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!conn.Closing) Shutdown(conn, Describe(ex));
|
||||
return;
|
||||
}
|
||||
Shutdown(conn, null);
|
||||
}
|
||||
|
||||
static void PumpOut(Conn conn)
|
||||
{
|
||||
var stream = conn.Stream;
|
||||
if (stream == null) return;
|
||||
try
|
||||
{
|
||||
while (!conn.Closing)
|
||||
{
|
||||
byte[]? chunk = null;
|
||||
lock (conn.OutLock)
|
||||
{
|
||||
if (conn.OutChunks.Count == 0)
|
||||
{
|
||||
conn.OutPulse.Reset();
|
||||
// fall through to wait outside the lock
|
||||
}
|
||||
else
|
||||
{
|
||||
chunk = conn.OutChunks.Dequeue();
|
||||
}
|
||||
}
|
||||
if (chunk == null)
|
||||
{
|
||||
conn.OutPulse.Wait(250);
|
||||
continue;
|
||||
}
|
||||
stream.Write(chunk, 0, chunk.Length);
|
||||
stream.Flush();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!conn.Closing) Shutdown(conn, Describe(ex));
|
||||
}
|
||||
}
|
||||
|
||||
static int Take(Conn conn, nint bufPtr, int max)
|
||||
{
|
||||
lock (conn.InLock)
|
||||
{
|
||||
if (conn.InAvailable == 0 || conn.InChunks.Count == 0) return 0;
|
||||
int copied = 0;
|
||||
while (copied < max && conn.InChunks.Count > 0)
|
||||
{
|
||||
byte[] head = conn.InChunks.Peek();
|
||||
int avail = head.Length - conn.InHeadOffset;
|
||||
int n = Math.Min(avail, max - copied);
|
||||
Marshal.Copy(head, conn.InHeadOffset, bufPtr + copied, n);
|
||||
copied += n;
|
||||
conn.InHeadOffset += n;
|
||||
conn.InAvailable -= n;
|
||||
if (conn.InHeadOffset >= head.Length)
|
||||
{
|
||||
conn.InChunks.Dequeue();
|
||||
conn.InHeadOffset = 0;
|
||||
}
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
}
|
||||
|
||||
static void Shutdown(Conn conn, string? why)
|
||||
{
|
||||
if (conn.Closing && why == null && conn.Status == StatusClosed) return;
|
||||
conn.Closing = true;
|
||||
if (why != null) conn.Error = why;
|
||||
conn.Status = StatusClosed;
|
||||
conn.OutPulse.Set();
|
||||
try { conn.Stream?.Dispose(); } catch { /* ignore */ }
|
||||
try { conn.Client?.Dispose(); } catch { /* ignore */ }
|
||||
conn.Stream = null;
|
||||
conn.Client = null;
|
||||
}
|
||||
|
||||
static string Describe(Exception ex)
|
||||
{
|
||||
for (Exception? e = ex; e != null; e = e.InnerException)
|
||||
{
|
||||
if (e is AuthenticationException) return e.Message;
|
||||
if (e is SocketException se) return se.Message;
|
||||
if (e is TimeoutException) return e.Message;
|
||||
if (e is IOException) return e.Message;
|
||||
}
|
||||
return ex.GetBaseException().Message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# Build a Windows win64 Gen1Recomp zip with native TLS (gen1tls.dll).
|
||||
#
|
||||
# Usage:
|
||||
# powershell -ExecutionPolicy Bypass -File scripts\build_windows.ps1 [-Version 0.1.77]
|
||||
#
|
||||
# Produces:
|
||||
# dist\win\gen1recomp-<version>-windows.zip
|
||||
# gen1recomp.exe fused LÖVE + game.love
|
||||
# gen1tls.dll Native AOT TLS dialer (Schannel via SslStream)
|
||||
#
|
||||
# Requires: .NET 8 SDK (for Native AOT), Git Bash, and tools/winbuild on PATH
|
||||
# for pack_love.sh under Git Bash on Windows.
|
||||
|
||||
param(
|
||||
[string]$Version = "0.1.77"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
if (-not (Test-Path (Join-Path $Root "main.lua"))) {
|
||||
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
}
|
||||
|
||||
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
|
||||
throw "Version must be X.Y.Z (got '$Version')"
|
||||
}
|
||||
|
||||
$Cache = Join-Path $Root ".bazinga\cache"
|
||||
$Work = Join-Path $Root ".bazinga\work"
|
||||
$OutDir = Join-Path $Work "gen1recomp-win64"
|
||||
$DistDir = Join-Path $Root "dist\win"
|
||||
$NativeOut = Join-Path $Root "dist\native\win-x64"
|
||||
$LoveZip = Join-Path $Cache "love-11.5-win64.zip"
|
||||
$LoveUrl = "https://github.com/love2d/love/releases/download/11.5/love-11.5-win64.zip"
|
||||
$Bash = "C:\Program Files\Git\bin\bash.exe"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Cache, $Work, $DistDir, $NativeOut | Out-Null
|
||||
|
||||
# Put zip/python shims first so pack_love.sh works in Git Bash on Windows.
|
||||
$env:PATH = "$(Join-Path $Root 'tools\winbuild');$env:PATH"
|
||||
|
||||
Write-Host "==> building gen1tls.dll (Native AOT)"
|
||||
dotnet publish (Join-Path $Root "native\tls_dial\Gen1Tls.csproj") `
|
||||
-c Release -r win-x64 -o $NativeOut
|
||||
if ($LASTEXITCODE -ne 0) { throw "gen1tls publish failed" }
|
||||
$TlsDll = Join-Path $NativeOut "gen1tls.dll"
|
||||
if (-not (Test-Path $TlsDll)) { throw "gen1tls.dll missing after publish" }
|
||||
|
||||
if (-not (Test-Path $LoveZip) -or (Get-Item $LoveZip).Length -lt 1MB) {
|
||||
Write-Host "==> downloading love-11.5-win64.zip"
|
||||
Invoke-WebRequest -Uri $LoveUrl -OutFile $LoveZip -UseBasicParsing
|
||||
}
|
||||
|
||||
Write-Host "==> packing game.love (engine $Version)"
|
||||
if (-not (Test-Path $Bash)) { throw "Git Bash not found at $Bash" }
|
||||
$RootPosix = ($Root -replace '\\', '/') -replace '^([A-Za-z]):', '/$1'
|
||||
& $Bash -lc "cd '$RootPosix' && PATH=`"`$PWD/tools/winbuild:`$PATH`" scripts/pack_love.sh --output .bazinga/work/game.love --listing .bazinga/work/love-listing.txt --version $Version"
|
||||
if ($LASTEXITCODE -ne 0) { throw "pack_love failed" }
|
||||
|
||||
Write-Host "==> fusing gen1recomp.exe"
|
||||
$Extract = Join-Path $Work "love-win64"
|
||||
Remove-Item -Recurse -Force $Extract, $OutDir -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Extract, $OutDir | Out-Null
|
||||
Expand-Archive -Path $LoveZip -DestinationPath $Extract -Force
|
||||
$LoveDir = Get-ChildItem $Extract -Directory | Select-Object -First 1
|
||||
Copy-Item (Join-Path $LoveDir.FullName "*.dll") $OutDir
|
||||
Copy-Item (Join-Path $LoveDir.FullName "license.txt") $OutDir -ErrorAction SilentlyContinue
|
||||
Copy-Item $TlsDll $OutDir
|
||||
|
||||
$out = [IO.File]::Create((Join-Path $OutDir "gen1recomp.exe"))
|
||||
try {
|
||||
$a = [IO.File]::OpenRead((Join-Path $LoveDir.FullName "love.exe"))
|
||||
try { $a.CopyTo($out) } finally { $a.Dispose() }
|
||||
$b = [IO.File]::OpenRead((Join-Path $Work "game.love"))
|
||||
try { $b.CopyTo($out) } finally { $b.Dispose() }
|
||||
} finally { $out.Dispose() }
|
||||
|
||||
@"
|
||||
Gen1Recomp Windows (engine $Version) — native TLS
|
||||
|
||||
gen1tls.dll sits next to gen1recomp.exe and exposes a non-blocking TLS
|
||||
client (Windows Schannel via .NET SslStream, Native AOT). Mods can load it
|
||||
through LuaJIT FFI, or call love.system.tls* on Android.
|
||||
|
||||
Keep gen1tls.dll beside the executable when you redistribute the zip.
|
||||
"@ | Set-Content (Join-Path $OutDir "README-TLS.txt") -Encoding UTF8
|
||||
|
||||
$ZipOut = Join-Path $DistDir "gen1recomp-$Version-windows.zip"
|
||||
Remove-Item $ZipOut -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $OutDir -DestinationPath $ZipOut -Force
|
||||
|
||||
Write-Host "==> built $ZipOut"
|
||||
Get-Item $ZipOut | ForEach-Object { " {0:N1} MB" -f ($_.Length / 1MB) }
|
||||
Get-ChildItem $OutDir | ForEach-Object { " $($_.Name)" }
|
||||
@@ -77,6 +77,8 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_
|
||||
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||
run_tier "T4 title checkpoint cold restart" \
|
||||
bash tests/integration/title_checkpoint_cold_start.sh
|
||||
|
||||
# The modded-link desync suite (symmetric mod, handshake fail-closed,
|
||||
# extra-bag round trip) is ROM-free and runs inside the T4 tier above, as
|
||||
|
||||
@@ -145,6 +145,17 @@ function BattleState:statusHUDVisible()
|
||||
self) ~= false
|
||||
end
|
||||
|
||||
function BattleState:caughtMarkerVisible()
|
||||
local dex = self.game and self.game.save and self.game.save.pokedex
|
||||
if not self.enemy or (self.kind ~= "wild" and self.kind ~= "safari")
|
||||
or not (dex and dex.owned and dex.owned[self.enemy.mon.species]) then
|
||||
return false
|
||||
end
|
||||
if not Runtime.wantsHook("battle.caught_marker_visible") then return false end
|
||||
return Runtime.call("battle.caught_marker_visible",
|
||||
function() return false end, self) == true
|
||||
end
|
||||
|
||||
function BattleState:moveGridNavigation()
|
||||
if self:wideLayout() then return true end
|
||||
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
||||
@@ -1871,9 +1882,18 @@ function BattleState:update(dt)
|
||||
-- its own has no slide to wait for.
|
||||
if (self.introSlide or 0) > 0 then return end
|
||||
if not self:updateQueue() then
|
||||
if self.afterQueue == "menu" then
|
||||
local destination = self.afterQueue
|
||||
-- These fields are queue/presentation cursors, not durable battle
|
||||
-- state. Once the queue has drained, keeping their terminal values
|
||||
-- makes the real command menu look busy to BattleSafety even though
|
||||
-- every message, wait and intro animation has settled.
|
||||
self.afterQueue = nil
|
||||
self.nextInsert = nil
|
||||
self.waitFrames = nil
|
||||
if destination == "menu" then
|
||||
self.introSlide = nil
|
||||
self.phase = "menu"
|
||||
elseif self.afterQueue == "finish" then
|
||||
elseif destination == "finish" then
|
||||
self:finish()
|
||||
end
|
||||
end
|
||||
@@ -2194,8 +2214,13 @@ function BattleState:openOldManBag()
|
||||
self.afterQueue = "menu"
|
||||
self:ui(function()
|
||||
local list
|
||||
-- The canned bag (POKE_BALL, not read from the player's real
|
||||
-- inventory) differs by version: pokered's OldManItemList has 50
|
||||
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
||||
-- the Viridian tutorial and Oak's catch, has one.
|
||||
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
||||
list = ListMenu.new(game, "ITEMS", {
|
||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = "x50" },
|
||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
||||
}, {
|
||||
script = function(l)
|
||||
l.scriptTimer = (l.scriptTimer or 0) + 1
|
||||
@@ -4779,7 +4804,7 @@ end
|
||||
-- Party pokeball row (SetupPokeballs tiles: ball / status ball /
|
||||
-- fainted ball / empty), 6 slots stepping dx from (x,y).
|
||||
local ballQuads
|
||||
function BattleState:drawBallRow(party, x, y, dx)
|
||||
local function balls()
|
||||
if ballQuads == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, "assets/generated/battle/balls.png")
|
||||
if ok then
|
||||
@@ -4791,11 +4816,23 @@ function BattleState:drawBallRow(party, x, y, dx)
|
||||
ballQuads = false
|
||||
end
|
||||
end
|
||||
if not ballQuads then return end
|
||||
return ballQuads or nil
|
||||
end
|
||||
|
||||
function BattleState:drawCaughtBall(x, y)
|
||||
local quads = balls()
|
||||
if not quads then return end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(quads.img, quads[0], x, y)
|
||||
end
|
||||
|
||||
function BattleState:drawBallRow(party, x, y, dx)
|
||||
local quads = balls()
|
||||
if not quads then return end
|
||||
for i = 1, 6 do
|
||||
local mon = party[i]
|
||||
local tile = not mon and 3 or mon.hp <= 0 and 2 or mon.status and 1 or 0
|
||||
love.graphics.draw(ballQuads.img, ballQuads[tile], x + (i - 1) * dx, y)
|
||||
love.graphics.draw(quads.img, quads[tile], x + (i - 1) * dx, y)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5511,7 +5548,12 @@ function BattleState:drawHUDs(slide)
|
||||
love.graphics.translate(hudShake, 0)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
|
||||
local enemyNameX = nameX(1, self.enemy.name)
|
||||
local enemyNameWidth = Font.draw(self.enemy.name, enemyNameX, 0)
|
||||
if self:caughtMarkerVisible() then
|
||||
self:drawCaughtBall(enemyNameX + enemyNameWidth, 0)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
if self.enemy.shownStatus then
|
||||
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
|
||||
else
|
||||
|
||||
@@ -1246,14 +1246,14 @@ function Battle:dealDamage(attacker, defender, damage, opts)
|
||||
return damage
|
||||
end
|
||||
|
||||
function Battle:heal(mon, amount)
|
||||
function Battle:heal(mon, amount, opts)
|
||||
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1
|
||||
local before = mon.hp or 0
|
||||
mon.hp = math.min(maxHp, before + math.max(0, math.floor(amount or 0)))
|
||||
local healed = mon.hp - before
|
||||
if healed > 0 then
|
||||
self:emit({ kind = "heal", side = self:sideOf(mon), amount = healed,
|
||||
hp = mon.hp })
|
||||
hp = mon.hp, anim = opts and opts.anim })
|
||||
end
|
||||
return healed
|
||||
end
|
||||
@@ -2600,6 +2600,35 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
|
||||
self.forcedSwitch = true
|
||||
end
|
||||
|
||||
-- BattleCommand_Teleport (engine/battle/move_effects/teleport.asm). Fails
|
||||
-- outright for BATTLETYPE_FORCESHINY/TRAP, for a trapped user, and in any
|
||||
-- TRAINER battle; in a WILD battle the level ladder is identical to
|
||||
-- EFFECT_FORCE_SWITCH's. Without an entry here TELEPORT fell through to
|
||||
-- the (0-power) damage path and never ended the battle.
|
||||
Battle.MOVE_EFFECTS.EFFECT_TELEPORT = function(self, attacker, defender)
|
||||
if self.battleType == Battle.BATTLETYPE_FORCESHINY
|
||||
or self.battleType == Battle.BATTLETYPE_TRAP
|
||||
or self:volatile(defender).trapsTarget then
|
||||
return fail(self)
|
||||
end
|
||||
if not self.wild then return fail(self) end
|
||||
|
||||
local userLevel = attacker.level or 1
|
||||
local targetLevel = defender.level or 1
|
||||
local succeeds = userLevel >= targetLevel
|
||||
if not succeeds then
|
||||
local roll = self:rollBelow(math.min(256, userLevel + targetLevel + 1))
|
||||
succeeds = roll >= math.floor(targetLevel / 4)
|
||||
end
|
||||
if not succeeds then return fail(self) end
|
||||
|
||||
self.over = true
|
||||
self.outcome = "fled"
|
||||
self.forcedSwitch = true
|
||||
self:emit({ kind = "run", side = self:sideOf(attacker),
|
||||
text = self:monName(attacker) .. " fled from battle!" })
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------- the move effects
|
||||
--
|
||||
-- The three tables above as records, in the shape src/mods/Schemas.lua's
|
||||
@@ -4442,7 +4471,8 @@ function Battle:tickHeldItem(mon)
|
||||
end
|
||||
|
||||
if effect == "HELD_BERRY" and (mon.hp or 0) * 2 <= maxHp then
|
||||
self:heal(mon, parameter > 0 and parameter or 10)
|
||||
-- pokegold engine/battle/core.asm:4074 ItemRecoveryAnim
|
||||
self:heal(mon, parameter > 0 and parameter or 10, { anim = "RECOVER" })
|
||||
mon.item = nil
|
||||
self:emit({ kind = "message",
|
||||
text = name .. " ate the " .. (def.name or "BERRY") .. "!" })
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
local BattleCheckpoint = {}
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local BUILTIN_RULESETS = {
|
||||
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
||||
modern_clean = require("src.battle.rulesets.modern_clean"),
|
||||
@@ -158,7 +159,9 @@ function BattleCheckpoint.validate(game, checkpoint)
|
||||
end
|
||||
local expectedOrigin = model.kind == "wild" and "wild_encounter"
|
||||
or model.kind == "trainer" and "trainer_encounter" or nil
|
||||
if not expectedOrigin or model.origin.kind ~= expectedOrigin
|
||||
local scripted = model.origin.kind == "script_battle"
|
||||
if not expectedOrigin
|
||||
or (model.origin.kind ~= expectedOrigin and not scripted)
|
||||
or model.origin.map ~= checkpoint.runtime.overworld.map then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Battle continuation data is unsupported or inconsistent."
|
||||
@@ -167,7 +170,27 @@ function BattleCheckpoint.validate(game, checkpoint)
|
||||
or type(rulesets(game)[model.rulesetId]) ~= "table" then
|
||||
return nil, "invalid_content", "Battle ruleset is unavailable."
|
||||
end
|
||||
if model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
|
||||
if scripted then
|
||||
local origin = model.origin
|
||||
local row = type(origin.script) == "table" and origin.script[origin.pc]
|
||||
local allowed = { start_battle = true, static_battle = true, rival_battle = true }
|
||||
if type(origin.pc) ~= "number" or origin.pc % 1 ~= 0
|
||||
or type(row) ~= "table" or row[1] ~= origin.command
|
||||
or not allowed[origin.command] or origin.battleKind ~= model.kind
|
||||
or (model.kind == "trainer" and (origin.trainerClass ~= model.oppClass
|
||||
or origin.partyIndex ~= (model.partyIndex or 1)))
|
||||
or (model.kind == "wild" and (origin.wildSpecies ~= model.enemyMon.species
|
||||
or origin.wildLevel ~= model.enemyMon.level))
|
||||
or (origin.npcId ~= nil and type(origin.npcId) ~= "string") then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Script battle continuation data is incomplete or inconsistent."
|
||||
end
|
||||
local problems = ScriptRunner.validate(origin.script)
|
||||
if #problems > 0 then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Script battle continuation commands are unavailable."
|
||||
end
|
||||
elseif model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
|
||||
or model.origin.trainerClass ~= model.oppClass
|
||||
or model.origin.partyIndex ~= (model.partyIndex or 1)) then
|
||||
return nil, "battle_origin_unsupported",
|
||||
|
||||
+144
-12
@@ -57,13 +57,24 @@ local function inspectBattle(ow, battle)
|
||||
"This battle kind does not have a checkpoint contract.")
|
||||
end
|
||||
local origin = battle.checkpointOrigin
|
||||
local expectedOrigin = battle.kind == "wild" and "wild_encounter"
|
||||
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
|
||||
or "trainer_encounter"
|
||||
if type(origin) ~= "table" or origin.kind ~= expectedOrigin then
|
||||
local scriptedOrigin = type(origin) == "table"
|
||||
and origin.kind == "script_battle"
|
||||
if type(origin) ~= "table"
|
||||
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
|
||||
return refusal("battle", "battle_origin_unsupported",
|
||||
"The battle completion path cannot be reconstructed safely.")
|
||||
end
|
||||
if scriptsBusy(ow) then
|
||||
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
|
||||
or (ow.runner
|
||||
and ow.runner.isCheckpointBattle
|
||||
and ow.runner:isCheckpointBattle(battle)))
|
||||
local otherScriptWork = nonempty(ow.parallelRunners)
|
||||
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
|
||||
or nonempty(ow.scriptMoves)
|
||||
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
|
||||
or (not scriptedOrigin and scriptsBusy(ow)) then
|
||||
return refusal("battle", "script_busy",
|
||||
"A suspended or queued script cannot be checkpointed.")
|
||||
end
|
||||
@@ -246,7 +257,7 @@ end
|
||||
|
||||
local FACINGS = { up = true, down = true, left = true, right = true }
|
||||
|
||||
local function validate(game, checkpoint)
|
||||
local function validate(game, checkpoint, expectedIdentity)
|
||||
if type(checkpoint) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
|
||||
end
|
||||
@@ -264,13 +275,16 @@ local function validate(game, checkpoint)
|
||||
end
|
||||
local identity = copy.identity
|
||||
local current = game and game.save
|
||||
local currentId = current and current.meta and current.meta.playthroughId
|
||||
local currentId = expectedIdentity and expectedIdentity.playthroughId
|
||||
or (current and current.meta and current.meta.playthroughId)
|
||||
local currentVersion = expectedIdentity and expectedIdentity.gameVersion
|
||||
or (current and current.version)
|
||||
if type(identity) ~= "table" or type(identity.engineVersion) ~= "string"
|
||||
or type(identity.gameVersion) ~= "string"
|
||||
or type(identity.playthroughId) ~= "string" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
|
||||
end
|
||||
if identity.gameVersion ~= current.version then
|
||||
if identity.gameVersion ~= currentVersion then
|
||||
return nil, "wrong_game", "Checkpoint belongs to another game version."
|
||||
end
|
||||
if identity.playthroughId ~= currentId then
|
||||
@@ -383,6 +397,49 @@ local function firstDifference(a, b, path)
|
||||
return nil
|
||||
end
|
||||
|
||||
local emitRestored
|
||||
|
||||
-- Persist the current verified checkpoint as the ordinary progress anchor only
|
||||
-- when this playthrough has never had one. This is intentionally idempotent:
|
||||
-- durable checkpoint tools can make a first session resumable without turning
|
||||
-- every later checkpoint into a hidden normal SAVE. The live runtime must still
|
||||
-- match the supplied checkpoint, and the ordinary save.write veto/lifecycle
|
||||
-- remains authoritative through Game:writeSave().
|
||||
function Checkpoint.ensureNormalSave(game, checkpoint, injectedFs)
|
||||
local capability = Checkpoint.inspect(game)
|
||||
if not capability.canCapture then
|
||||
return false, capability.reason, capability.message
|
||||
end
|
||||
local validated, code, message = validate(game, checkpoint)
|
||||
if not validated then return false, code, message end
|
||||
|
||||
local info, infoCode, infoMessage =
|
||||
SaveData.selectedNormalSaveInfo(game.save, injectedFs)
|
||||
if not info then return false, infoCode, infoMessage end
|
||||
if info.exists then return true, "already_exists" end
|
||||
|
||||
local current, captureCode, captureMessage = Checkpoint.capture(game)
|
||||
if not current then return false, captureCode, captureMessage end
|
||||
if not equalData(current, validated) then
|
||||
return false, "checkpoint_not_current",
|
||||
"The active runtime changed after this checkpoint was captured."
|
||||
end
|
||||
if type(game.writeSave) ~= "function" then
|
||||
return false, "save_unavailable",
|
||||
"The active runtime cannot persist ordinary progress."
|
||||
end
|
||||
local ok, saved = pcall(game.writeSave, game)
|
||||
if not ok or saved == false then
|
||||
return false, "save_failed", "Could not create the first ordinary progress save."
|
||||
end
|
||||
local verified = SaveData.selectedNormalSaveInfo(game.save, injectedFs)
|
||||
if type(verified) ~= "table" or not verified.exists then
|
||||
return false, "save_verify_failed",
|
||||
"The first ordinary progress save could not be verified."
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Checkpoint.restore(game, checkpoint)
|
||||
local capability = Checkpoint.inspect(game)
|
||||
if not capability.canRestore then
|
||||
@@ -400,12 +457,7 @@ function Checkpoint.restore(game, checkpoint)
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and validated.rng == nil then restored.rng = nil end
|
||||
if restored and equalData(restored, validated) then
|
||||
if ModRuntime.wants("checkpoint.restored") then
|
||||
ModRuntime.emit("checkpoint.restored", {
|
||||
game = game,
|
||||
kind = validated.kind,
|
||||
})
|
||||
end
|
||||
emitRestored(game, validated)
|
||||
return true
|
||||
end
|
||||
err = restored and ("restored state differed at "
|
||||
@@ -421,4 +473,84 @@ function Checkpoint.restore(game, checkpoint)
|
||||
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
|
||||
end
|
||||
|
||||
emitRestored = function(game, checkpoint)
|
||||
if ModRuntime.wants("checkpoint.restored") then
|
||||
ModRuntime.emit("checkpoint.restored", {
|
||||
game = game,
|
||||
kind = checkpoint.kind,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function isTitleSession(game)
|
||||
local states = game and game.stack and game.stack.states
|
||||
if type(states) ~= "table" then return false end
|
||||
for _, state in ipairs(states) do
|
||||
if type(state) == "table" and state.screenId == "TitleState" then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function rebuildTitle(game, savedTitle, rng)
|
||||
local save, err = dataCopy(savedTitle)
|
||||
if not save then error("title rollback decode failed: " .. tostring(err), 0) end
|
||||
game.save = save
|
||||
if type(game.adoptSave) == "function" then game:adoptSave(save) end
|
||||
restoreRng(rng)
|
||||
if not (game.stack and game.stack.top and game.stack.pop and game.stack.push
|
||||
and type(game.makeTitleState) == "function") then
|
||||
error("title recovery is unavailable", 0)
|
||||
end
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.stack:push(game:makeTitleState())
|
||||
end
|
||||
|
||||
-- Reconstruct a validated persistent checkpoint from the title session. This
|
||||
-- is intentionally separate from restore(): title has no live gameplay state
|
||||
-- to capture for rollback. Validation happens before any mutation; a failed
|
||||
-- reconstruction rebuilds a fresh usable title session instead of exposing a
|
||||
-- half-installed overworld or battle.
|
||||
function Checkpoint.resume(game, checkpoint)
|
||||
if not isTitleSession(game) then
|
||||
return false, "not_at_title",
|
||||
"Checkpoint resume is available only from the title session."
|
||||
end
|
||||
local save = game and game.save
|
||||
local playthroughId, identityCode, identityMessage =
|
||||
SaveData.selectedPlaythroughId(save)
|
||||
if type(playthroughId) ~= "string" or playthroughId == "" then
|
||||
return false, identityCode, identityMessage
|
||||
end
|
||||
local expected = { gameVersion = save and save.version, playthroughId = playthroughId }
|
||||
local validated, code, message = validate(game, checkpoint, expected)
|
||||
if not validated then return false, code, message end
|
||||
|
||||
local titleSave, titleErr = dataCopy(save)
|
||||
if not titleSave then
|
||||
return false, "title_recovery_unavailable",
|
||||
"Could not preserve the title session: " .. tostring(titleErr)
|
||||
end
|
||||
local titleRng = captureRng()
|
||||
local currentOptions = save.options
|
||||
local ok, err = pcall(apply, game, validated, currentOptions)
|
||||
if ok then
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and validated.rng == nil then restored.rng = nil end
|
||||
if restored and equalData(restored, validated) then
|
||||
emitRestored(game, validated)
|
||||
return true
|
||||
end
|
||||
err = restored and ("resumed state differed at "
|
||||
.. tostring(firstDifference(validated, restored) or "canonical encoding"))
|
||||
or ("resumed state could not be captured: " .. tostring(verifyCode))
|
||||
end
|
||||
|
||||
local recovered, recoveryErr = pcall(rebuildTitle, game, titleSave, titleRng)
|
||||
if not recovered then
|
||||
return false, "title_recovery_failed",
|
||||
"Checkpoint resume failed and title recovery failed: " .. tostring(recoveryErr)
|
||||
end
|
||||
return false, "resume_failed", "Checkpoint resume failed: " .. tostring(err)
|
||||
end
|
||||
|
||||
return Checkpoint
|
||||
|
||||
+13
-2
@@ -203,7 +203,8 @@ function ChipAudio.playMusic(data, header, allowLoops)
|
||||
cmdCh:push({ cmd = "play", gen = gen, header = header,
|
||||
allowLoops = allowLoops, audio = slimAudio(data),
|
||||
channelVolumes = ChipSynth.getChannelVolumes(),
|
||||
channelPitches = ChipSynth.getChannelPitches() })
|
||||
channelPitches = ChipSynth.getChannelPitches(),
|
||||
stereo = ChipSynth.getStereo() })
|
||||
currentMusic = { source = source, gen = gen, threaded = true,
|
||||
started = false, finished = false }
|
||||
-- playback starts in update() once the first buffer arrives (~1 frame)
|
||||
@@ -214,7 +215,8 @@ local function pushChannelMix()
|
||||
if workerReady and cmdCh then
|
||||
cmdCh:push({ cmd = "channelMix",
|
||||
volumes = ChipSynth.getChannelVolumes(),
|
||||
pitches = ChipSynth.getChannelPitches() })
|
||||
pitches = ChipSynth.getChannelPitches(),
|
||||
stereo = ChipSynth.getStereo() })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -352,6 +354,15 @@ function ChipAudio.shutdown()
|
||||
workerReady = false
|
||||
end
|
||||
|
||||
function ChipAudio.setStereo(enabled)
|
||||
ChipSynth.setStereo(enabled)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getStereo()
|
||||
return ChipSynth.getStereo()
|
||||
end
|
||||
|
||||
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
|
||||
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
|
||||
function ChipAudio.setChannelVolume(hw, scale)
|
||||
|
||||
+19
-4
@@ -29,6 +29,18 @@ ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
||||
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
||||
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
||||
|
||||
-- Gen 2 SOUND option (MONO/STEREO): gates Music_StereoPanning's per-song
|
||||
-- panning byte (audio/engine.asm:1987 wOptions STEREO bit).
|
||||
local stereoEnabled = false
|
||||
|
||||
function ChipSynth.setStereo(enabled)
|
||||
stereoEnabled = not not enabled
|
||||
end
|
||||
|
||||
function ChipSynth.getStereo()
|
||||
return stereoEnabled
|
||||
end
|
||||
|
||||
-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise).
|
||||
-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave,
|
||||
-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next
|
||||
@@ -760,11 +772,14 @@ function Channel:nextEventGen2()
|
||||
-- no-op for the PCM renderer
|
||||
elseif command == 0xEE then -- unknownmusic0xee
|
||||
self:word()
|
||||
elseif command == 0xEF then -- stereo_panning (honor always; options.stereo)
|
||||
elseif command == 0xEF then
|
||||
-- audio/engine.asm:1987 Music_StereoPanning: apply only when STEREO is on
|
||||
local packed = self:byte()
|
||||
local mask = bit.lshift(1, self.hardware - 1)
|
||||
local default = bit.bor(bit.lshift(mask, 4), mask)
|
||||
self.tracks = bit.band(packed, default)
|
||||
if stereoEnabled then
|
||||
local mask = bit.lshift(1, self.hardware - 1)
|
||||
local default = bit.bor(bit.lshift(mask, 4), mask)
|
||||
self.tracks = bit.band(packed, default)
|
||||
end
|
||||
elseif command == 0xF0 then -- sfx_toggle_noise
|
||||
if self.noiseSampling then
|
||||
self.noiseSampling = false
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
-- Shared local date/time presentation for engine UI and mods. Preferences
|
||||
-- live in options.lua, never checkpoint progress. "device" uses the process
|
||||
-- time locale when the platform supplies one and otherwise falls back to the
|
||||
-- deterministic DD-MM-YYYY / 24-hour convention.
|
||||
|
||||
local DateTime = {}
|
||||
|
||||
local DATE_FORMATS = {
|
||||
dmy = "%d-%m-%Y",
|
||||
mdy = "%m-%d-%Y",
|
||||
ymd = "%Y-%m-%d",
|
||||
}
|
||||
local TIME_FORMATS = {
|
||||
["24h"] = "%H:%M",
|
||||
["12h"] = "%I:%M %p",
|
||||
}
|
||||
|
||||
local function validTimestamp(value)
|
||||
return type(value) == "number" and value == value and value >= 0
|
||||
and value ~= math.huge and value ~= -math.huge
|
||||
end
|
||||
|
||||
local function render(timestamp, pattern)
|
||||
local ok, value = pcall(os.date, pattern, math.floor(timestamp))
|
||||
if not ok or type(value) ~= "string" or value == "" then return nil end
|
||||
return value
|
||||
end
|
||||
|
||||
local function currentLocale()
|
||||
if not os.setlocale then return nil end
|
||||
local ok, value = pcall(os.setlocale, nil, "time")
|
||||
if not ok or type(value) ~= "string" then return nil end
|
||||
return value
|
||||
end
|
||||
|
||||
local function deviceAvailable(localeName)
|
||||
return type(localeName) == "string" and localeName ~= ""
|
||||
and localeName ~= "C" and localeName ~= "POSIX"
|
||||
end
|
||||
|
||||
function DateTime.formatWithLocale(timestamp, datePreference, timePreference, localeName)
|
||||
if not validTimestamp(timestamp) then return { date = "----", time = "----" } end
|
||||
local datePattern = DATE_FORMATS[datePreference]
|
||||
local timePattern = TIME_FORMATS[timePreference]
|
||||
if not datePattern then
|
||||
datePattern = deviceAvailable(localeName) and "%x" or DATE_FORMATS.dmy
|
||||
end
|
||||
if not timePattern then
|
||||
if deviceAvailable(localeName) then
|
||||
local sample = render(timestamp, "%X") or ""
|
||||
local marker = render(timestamp, "%p") or ""
|
||||
timePattern = marker ~= "" and sample:find(marker, 1, true)
|
||||
and TIME_FORMATS["12h"] or TIME_FORMATS["24h"]
|
||||
else
|
||||
timePattern = TIME_FORMATS["24h"]
|
||||
end
|
||||
end
|
||||
return {
|
||||
date = render(timestamp, datePattern) or "----",
|
||||
time = render(timestamp, timePattern) or "----",
|
||||
}
|
||||
end
|
||||
|
||||
local function preferences(game)
|
||||
local options = game and game.save and game.save.options
|
||||
options = type(options) == "table" and options or {}
|
||||
return options.dateFormat or "device", options.timeFormat or "device"
|
||||
end
|
||||
|
||||
function DateTime.date(game, timestamp)
|
||||
local datePreference, timePreference = preferences(game)
|
||||
return DateTime.formatWithLocale(timestamp, datePreference, timePreference,
|
||||
currentLocale()).date
|
||||
end
|
||||
|
||||
function DateTime.time(game, timestamp)
|
||||
local datePreference, timePreference = preferences(game)
|
||||
return DateTime.formatWithLocale(timestamp, datePreference, timePreference,
|
||||
currentLocale()).time
|
||||
end
|
||||
|
||||
function DateTime.dateTime(game, timestamp)
|
||||
local datePreference, timePreference = preferences(game)
|
||||
local value = DateTime.formatWithLocale(timestamp, datePreference, timePreference,
|
||||
currentLocale())
|
||||
if value.date == "----" or value.time == "----" then return "----" end
|
||||
return value.date .. " " .. value.time
|
||||
end
|
||||
|
||||
return DateTime
|
||||
+13
-28
@@ -140,7 +140,7 @@ Game2.anchorNewGameClock = anchorNewGameClock
|
||||
|
||||
function Game2.new()
|
||||
local self = setmetatable({
|
||||
speedOverride = 1,
|
||||
speedOverride = nil,
|
||||
capturePath = nil,
|
||||
world = nil,
|
||||
status = nil,
|
||||
@@ -975,27 +975,9 @@ function Game2:load()
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
Pipelines.install(self.data)
|
||||
Pipelines.applyOptions(self.options)
|
||||
-- Gold composites the WHOLE-FRAME half of a pipeline (`present`) and not the
|
||||
-- world half: its overworld draws straight to the window rather than into a
|
||||
-- canvas the way src/world/OverworldController.lua:4827 hands one to
|
||||
-- Pipelines.drawWorld, so there is nothing here for drawWorld to replace yet.
|
||||
-- A restored level for a world-only pipeline is retired rather than left
|
||||
-- switched on, because on Gold it would render nothing AND hold TILT off
|
||||
-- (Pipelines.setLevel's tilt exclusion). The stored level in
|
||||
-- options.pipelines is left untouched, so the mode comes back the day Gold
|
||||
-- grows a world canvas; Tilt is re-applied from the option the exclusion just
|
||||
-- cleared.
|
||||
local retired = false
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.drawWorld and not entry.def.present
|
||||
and Pipelines.level(entry.id) > 0 then
|
||||
Pipelines.setLevel(entry.id, 0)
|
||||
retired = true
|
||||
end
|
||||
end
|
||||
if retired then
|
||||
require("src.render.Tilt").applyOptions(self.options)
|
||||
end
|
||||
-- Both halves run on Gold now: `present` folds over the composite in
|
||||
-- Game2:draw, `drawWorld` owns the world pass in World:drawPipeline. So a
|
||||
-- restored world level stays switched on, as it does for Gen 1.
|
||||
|
||||
-- After the merge, so a font override and a translation mod's catalog
|
||||
-- (#501) are both in Data before the first screen draws a glyph. Gen 1
|
||||
@@ -1112,19 +1094,20 @@ function Game2:update(dt)
|
||||
-- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265):
|
||||
-- they are presentational, so fast-forward must not speed them up.
|
||||
require("src.render.Pipelines").update(dt)
|
||||
if self.phase == "boot" then
|
||||
FixedStep.maxAccum = 0.25
|
||||
FixedStep:update(dt)
|
||||
return
|
||||
end
|
||||
if not self.world or not self.world.map then return end
|
||||
-- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does:
|
||||
-- audio runs off its own real-time accumulator, so music and sfx keep their
|
||||
-- tempo at every multiplier. speedOverride is the driver/CLI hook and wins
|
||||
-- over the saved option.
|
||||
-- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld
|
||||
local speed = math.max(1,
|
||||
tonumber(self.speedOverride) or tonumber(self.options and self.options.speed)
|
||||
or 1)
|
||||
if self.phase == "boot" then
|
||||
FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05)
|
||||
FixedStep:update(dt * speed)
|
||||
return
|
||||
end
|
||||
if not self.world or not self.world.map then return end
|
||||
FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05)
|
||||
FixedStep:update(dt * speed)
|
||||
end
|
||||
@@ -1921,6 +1904,8 @@ function Game2:applyOptions()
|
||||
-- the mod pipeline ladder rides options.pipelines and restores with the rest
|
||||
-- of the display block, as it does in src/core/Game.lua:1041
|
||||
require("src.render.Pipelines").applyOptions(options)
|
||||
-- src/core/Game.lua:1121 mirrors this call for Gen 1
|
||||
Input:applyBindings(options.bindings)
|
||||
-- options.touchControls (the launcher editor's per-orientation layouts) and
|
||||
-- options.haptics, the same two keys Gen 1 hands over here
|
||||
-- (src/core/Game.lua:1073). One options.lua serves both games, so the pad a
|
||||
|
||||
@@ -460,6 +460,8 @@ end
|
||||
function Music.applyOptions(opts)
|
||||
Music.setVolumeLevel(opts and opts.musicVol or 7)
|
||||
Music.setFilterLevel(opts and opts.musicFilter or 0)
|
||||
-- engine/menus/options_menu.asm SOUND row (wOptions STEREO bit)
|
||||
require("src.core.ChipAudio").setStereo(opts and opts.sound == "STEREO")
|
||||
end
|
||||
|
||||
local function sourceStopped(src)
|
||||
|
||||
+87
-3
@@ -341,6 +341,12 @@ function SaveData.defaultOptions()
|
||||
-- gets the tick without going looking for the row. Inert wherever the
|
||||
-- overlay never appears (desktop) or LOVE has no vibrator.
|
||||
haptics = "light",
|
||||
-- Shared UI/mod timestamp presentation. DEVICE follows the process time
|
||||
-- locale where the platform exposes one; otherwise DateTime falls back to
|
||||
-- DD-MM-YYYY and 24-hour time. Kept in options.lua so checkpoints never
|
||||
-- rewind presentation preferences.
|
||||
dateFormat = "device", -- device | dmy | mdy | ymd
|
||||
timeFormat = "device", -- device | 24h | 12h
|
||||
}
|
||||
end
|
||||
|
||||
@@ -760,6 +766,15 @@ local function tryMigrateLegacy(version, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots[version] = { list = { id }, active = id }
|
||||
-- A tool may have allocated the legacy scope before the player made their
|
||||
-- first ordinary SAVE. Promoting that flat save into slot1 must preserve the
|
||||
-- same opaque identity; otherwise title-selected mod storage becomes
|
||||
-- unreachable after the migration even though every durable record exists.
|
||||
local ids = opts.playthroughIds and opts.playthroughIds[version]
|
||||
if type(ids) == "table" and type(ids.legacy) == "string" and ids.legacy ~= "" then
|
||||
if type(ids[id]) ~= "string" or ids[id] == "" then ids[id] = ids.legacy end
|
||||
ids.legacy = nil
|
||||
end
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return id
|
||||
end
|
||||
@@ -785,9 +800,9 @@ end
|
||||
|
||||
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||
-- the version, falling back to the flat legacy names when no slot is in use.
|
||||
function saveNames(version)
|
||||
function saveNames(version, injectedFs)
|
||||
version = version or GameVersion.get()
|
||||
local fs = persistFs(nil)
|
||||
local fs = persistFs(injectedFs)
|
||||
ensureVersionSlots(version, fs)
|
||||
local slot = activeSlotCache[version]
|
||||
if slot then return slotNames(version, slot) end
|
||||
@@ -1075,7 +1090,17 @@ local function rememberPlaythroughId(save, opts, injectedFs)
|
||||
if type(id) ~= "string" or id == "" then return opts, false end
|
||||
local version = save.version or GameVersion.get()
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
opts = opts or SaveData.loadOptions(injectedFs)
|
||||
local persisted = SaveData.loadOptions(injectedFs)
|
||||
if opts then
|
||||
-- Slot selection and opaque playthrough routing are engine-owned launcher
|
||||
-- state. A live game may carry an options snapshot from before a legacy
|
||||
-- save was promoted to slot1; writing that stale snapshot must not erase
|
||||
-- the freshly persisted routing and strand tool storage on next boot.
|
||||
opts.saveSlots = deepCopy(persisted.saveSlots)
|
||||
opts.playthroughIds = deepCopy(persisted.playthroughIds)
|
||||
else
|
||||
opts = persisted
|
||||
end
|
||||
opts.playthroughIds = opts.playthroughIds or {}
|
||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||
local changed = opts.playthroughIds[version][scope] ~= id
|
||||
@@ -1110,6 +1135,65 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
||||
return id
|
||||
end
|
||||
|
||||
-- Resolve the already-selected playthrough without changing the supplied save
|
||||
-- or allocating a replacement id. Title tools use this before a normal SAVE:
|
||||
-- Game:load intentionally owns a fresh skeleton there, while the selected
|
||||
-- launcher slot's durable tool data remains bound by the engine-owned mapping.
|
||||
-- This does not make arbitrary identities addressable; callers still need a
|
||||
-- higher-level engine capability that decides when selected resolution is safe.
|
||||
function SaveData.selectedPlaythroughId(save, injectedFs)
|
||||
if type(save) ~= "table" then
|
||||
return nil, "not_in_playthrough", "No selected playthrough is available."
|
||||
end
|
||||
local version = save.version or GameVersion.get()
|
||||
if not knownVersion(version) then
|
||||
return nil, "unknown_game", "The selected game version is unavailable."
|
||||
end
|
||||
local id = save.meta and save.meta.playthroughId
|
||||
if type(id) == "string" and id ~= "" then return id end
|
||||
|
||||
-- Resolve the selected scope first. That may perform the one-time legacy
|
||||
-- save-to-slot migration, which also moves the opaque identity mapping; only
|
||||
-- then read options so this lookup never observes the pre-migration table.
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
local opts = SaveData.loadOptions(injectedFs)
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||
id = byVersion and byVersion[scope] or nil
|
||||
if type(id) ~= "string" or id == "" then
|
||||
return nil, "no_selected_playthrough",
|
||||
"The selected playthrough has no durable tool state."
|
||||
end
|
||||
return id
|
||||
end
|
||||
|
||||
-- Read only the chronology of the ordinary selected save for title tools.
|
||||
-- This intentionally returns no canonical progress, slot id, path, or raw
|
||||
-- save handle. A legacy pre-id normal save is valid when the selected scope's
|
||||
-- engine-owned mapping identifies it; a stamped id must match exactly.
|
||||
function SaveData.selectedNormalSaveInfo(save, injectedFs)
|
||||
local playthroughId, code, message = SaveData.selectedPlaythroughId(save, injectedFs)
|
||||
if not playthroughId then return nil, code, message end
|
||||
local version = save and (save.version or GameVersion.get())
|
||||
local fs = persistFs(injectedFs)
|
||||
local main, backup, staged = saveNames(version, injectedFs)
|
||||
local normal = readTable(fs, main)
|
||||
or readTable(fs, staged)
|
||||
or readTable(fs, backup)
|
||||
if type(normal) ~= "table" or normal.version ~= version then
|
||||
return { exists = false, savedAt = nil }
|
||||
end
|
||||
local normalId = normal.meta and normal.meta.playthroughId
|
||||
if type(normalId) == "string" and normalId ~= "" and normalId ~= playthroughId then
|
||||
return { exists = false, savedAt = nil }
|
||||
end
|
||||
local savedAt = normal.meta and normal.meta.savedAt
|
||||
if type(savedAt) ~= "number" or savedAt < 0 or savedAt ~= savedAt
|
||||
or savedAt == math.huge or savedAt == -math.huge then
|
||||
savedAt = nil
|
||||
end
|
||||
return { exists = true, savedAt = savedAt }
|
||||
end
|
||||
|
||||
-- ------- meta
|
||||
|
||||
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
||||
|
||||
@@ -53,6 +53,9 @@ local function handle(cmd)
|
||||
if cmd.channelPitches ~= nil then
|
||||
ChipSynth.setChannelPitches(cmd.channelPitches)
|
||||
end
|
||||
if cmd.stereo ~= nil then
|
||||
ChipSynth.setStereo(cmd.stereo)
|
||||
end
|
||||
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
||||
{ allowLoops = cmd.allowLoops })
|
||||
if ok then
|
||||
@@ -69,6 +72,7 @@ local function handle(cmd)
|
||||
elseif cmd.cmd == "channelMix" then
|
||||
if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end
|
||||
if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end
|
||||
if cmd.stereo ~= nil then ChipSynth.setStereo(cmd.stereo) end
|
||||
elseif cmd.cmd == "invalidate" then
|
||||
ChipSynth.invalidateBanks()
|
||||
elseif cmd.cmd == "quit" then
|
||||
|
||||
@@ -96,7 +96,7 @@ function Nests.landmark(data, index)
|
||||
end
|
||||
|
||||
local function landmarkOfMap(data, mapId)
|
||||
local def = data and data.maps and data.maps[mapId]
|
||||
local def = data and data.gen2Maps and data.gen2Maps[mapId]
|
||||
return def and def.landmark
|
||||
end
|
||||
|
||||
@@ -131,7 +131,7 @@ function Nests.find(data, species, region, save)
|
||||
out[#out + 1] = landmark
|
||||
end
|
||||
|
||||
local enc = data and data.encounters
|
||||
local enc = data and data.gen2Encounters
|
||||
for _, key in ipairs({ "grass", "water" }) do
|
||||
for mapId, entry in pairs((enc and enc[key]) or {}) do
|
||||
if tableHasSpecies(entry, species) then
|
||||
|
||||
@@ -127,6 +127,9 @@ end
|
||||
Save.filenames = saveNames
|
||||
|
||||
local function fs()
|
||||
-- portable.txt: same standard/portable root as Gen 1 (SaveData.persistenceFs).
|
||||
local ok, SaveData = pcall(require, "src.core.SaveData")
|
||||
if ok and SaveData.persistenceFs then return SaveData.persistenceFs() end
|
||||
return love.filesystem
|
||||
end
|
||||
|
||||
|
||||
@@ -241,7 +241,10 @@ function RomExtractorGen2:writeCompressedPic(label, tiles, relative)
|
||||
while #pixels < byteLength do pixels[#pixels + 1] = 0 end
|
||||
while #pixels > byteLength do table.remove(pixels) end
|
||||
pixels = ImageWriter.columnsToRows(pixels, tiles, tiles)
|
||||
self:write2bpp(pixels, size, size, relative)
|
||||
-- pokegold engine/battle/core.asm GetTrainerBackpic: no hardware masking,
|
||||
-- so matte the white backdrop like Gen 1's writeCompressedPic does.
|
||||
self:save(ImageWriter.matteColor0(
|
||||
ImageWriter.decode2bpp(pixels, size, size)), relative)
|
||||
end
|
||||
|
||||
function RomExtractorGen2:extractConstants()
|
||||
|
||||
@@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion")
|
||||
local Version = require("src.core.Version")
|
||||
local Assets = require("src.render.Assets")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local DateTime = require("src.core.DateTime")
|
||||
local AssetTransform = require("src.mods.AssetTransform")
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local Merge = require("src.mods.Merge")
|
||||
@@ -22,6 +23,8 @@ local Loader = {}
|
||||
Loader.__index = Loader
|
||||
|
||||
local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only
|
||||
local OPTION_SCHEMAS_FILENAME = "mod_option_schemas.json"
|
||||
local OPTION_SCHEMAS_VERSION = 1
|
||||
|
||||
-- The working tree's engine version is the "0.0.0-dev" placeholder that CI
|
||||
-- restamps into the packed game.love (src/core/Version.lua:7), and it sorts
|
||||
@@ -338,6 +341,57 @@ function Loader:_saveState()
|
||||
SaveData.saveOptions(options, self.fs)
|
||||
end
|
||||
|
||||
-- Export the runtime option schemas after mod entry chunks have run. This
|
||||
-- is an optional, data-only handoff for native launchers: they must not run
|
||||
-- arbitrary mod code before boot just to discover settings. The snapshot is
|
||||
-- deliberately written beside options.lua so every platform's native shell
|
||||
-- can use the same filesystem contract.
|
||||
function Loader:_writeOptionSchemas()
|
||||
if not self.fs.write then return end
|
||||
|
||||
local mods = {}
|
||||
for id, mod in pairs(self.mods) do
|
||||
if mod.enabled and not mod.failed then
|
||||
local schema = self.optionSchemas[id]
|
||||
-- Keep the legacy manifest options_schema path visible to native
|
||||
-- consumers too. ManagerState loads this same data-only chunk on
|
||||
-- demand; using it here means older mods do not need to migrate to
|
||||
-- mod.options:define just to appear in a launcher settings screen.
|
||||
if schema == nil and mod.manifest.options_schema and self.fs.load then
|
||||
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
|
||||
if chunk then
|
||||
local ok, rows = pcall(chunk)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
end
|
||||
if schema ~= nil then
|
||||
mods[id] = schema
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Do not create storage on a fresh mod-free boot, but do overwrite an old
|
||||
-- snapshot when the current boot has no schemas so disabled/failed mods do
|
||||
-- not leave stale native settings rows behind.
|
||||
if next(mods) == nil
|
||||
and not (self.fs.getInfo and self.fs.getInfo(OPTION_SCHEMAS_FILENAME)) then
|
||||
return
|
||||
end
|
||||
|
||||
local ok, encoded = pcall(Json.encode, {
|
||||
schema_version = OPTION_SCHEMAS_VERSION,
|
||||
mods = mods,
|
||||
})
|
||||
if not ok then
|
||||
Logger.warn("mod option schema export: failed to encode: %s", tostring(encoded))
|
||||
return
|
||||
end
|
||||
local written, err = self.fs.write(OPTION_SCHEMAS_FILENAME, encoded)
|
||||
if not written then
|
||||
Logger.warn("mod option schema export: failed to write: %s", tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
function Loader:setEnabled(id, enabled)
|
||||
if not self.mods[id] then return false end
|
||||
self.disabled[id] = not enabled
|
||||
@@ -917,6 +971,16 @@ function Loader:_api(mod)
|
||||
-- the widget toolkit facade (12 4.5) is one shared surface, not
|
||||
-- per-mod state; each widget inside it loads on first touch
|
||||
ui = ModUI,
|
||||
-- Read-only shared timestamp presentation using current options.lua
|
||||
-- preferences. The live game supplies only the current option context;
|
||||
-- checkpoint/save data never changes as a side effect.
|
||||
datetime = {
|
||||
date = function(_, game, timestamp) return DateTime.date(game, timestamp) end,
|
||||
time = function(_, game, timestamp) return DateTime.time(game, timestamp) end,
|
||||
dateTime = function(_, game, timestamp)
|
||||
return DateTime.dateTime(game, timestamp)
|
||||
end,
|
||||
},
|
||||
-- namespaced per mod; M11 backs these with save.modData /
|
||||
-- options.modOptions, the shape mods compile against is already final
|
||||
save = {
|
||||
@@ -940,6 +1004,7 @@ function Loader:_api(mod)
|
||||
-- callers never receive paths or a raw filesystem handle.
|
||||
storage = {
|
||||
context = function(_, game) return storage:context(game) end,
|
||||
selected = function(_, game) return storage:selected(game) end,
|
||||
write = function(_, game, key, value) return storage:write(game, key, value) end,
|
||||
read = function(_, game, key) return storage:read(game, key) end,
|
||||
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
||||
@@ -953,6 +1018,12 @@ function Loader:_api(mod)
|
||||
restore = function(_, game, checkpoint)
|
||||
return Checkpoint.restore(game, checkpoint)
|
||||
end,
|
||||
resume = function(_, game, checkpoint)
|
||||
return Checkpoint.resume(game, checkpoint)
|
||||
end,
|
||||
ensureNormalSave = function(_, game, checkpoint)
|
||||
return Checkpoint.ensureNormalSave(game, checkpoint, loader.fs)
|
||||
end,
|
||||
},
|
||||
options = {
|
||||
define = function(_, schema)
|
||||
@@ -1403,6 +1474,7 @@ function Loader:load(data)
|
||||
-- which resolves every path to itself (14 §asset resolution).
|
||||
Assets.installLoader(self)
|
||||
self.events:emit("mods.loaded", { loader = self, data = data })
|
||||
self:_writeOptionSchemas()
|
||||
self.initialized = true
|
||||
return #self.errors == 0
|
||||
end
|
||||
|
||||
@@ -79,6 +79,64 @@ function Storage:_scope(game)
|
||||
base = base, fs = fs }
|
||||
end
|
||||
|
||||
local function isTitleSession(game)
|
||||
local states = game and game.stack and game.stack.states
|
||||
if type(states) ~= "table" then return false end
|
||||
for _, state in ipairs(states) do
|
||||
if type(state) == "table" and state.screenId == "TitleState" then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Bind this mod only to the engine-selected existing playthrough while the
|
||||
-- title session is active. Unlike _scope this must never allocate an identity:
|
||||
-- browsing history before the first normal SAVE is a read of durable state,
|
||||
-- not the start of a New Game. The returned facade closes over its private
|
||||
-- proxy game, so callers cannot substitute another playthrough id or path.
|
||||
function Storage:selected(game)
|
||||
if not isTitleSession(game) then
|
||||
return failure("not_at_title",
|
||||
"Selected playthrough storage is available only from the title session.")
|
||||
end
|
||||
local save = game and game.save
|
||||
local version = save and save.version
|
||||
if not validSegment(version) then
|
||||
return failure("not_in_playthrough",
|
||||
"The title session has no selected game version.")
|
||||
end
|
||||
local playthroughId, code, message =
|
||||
SaveData.selectedPlaythroughId(save, self.injectedFs)
|
||||
if not validSegment(playthroughId) then return failure(code, message) end
|
||||
|
||||
local selectedGame = {
|
||||
save = { version = version, meta = { playthroughId = playthroughId } },
|
||||
}
|
||||
local context = {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = version,
|
||||
playthroughId = playthroughId,
|
||||
}
|
||||
local normal = SaveData.selectedNormalSaveInfo(save, self.injectedFs)
|
||||
if type(normal) == "table" and normal.savedAt ~= nil then
|
||||
context.normalSavedAt = normal.savedAt
|
||||
end
|
||||
return {
|
||||
context = function()
|
||||
local copy = {
|
||||
engineVersion = context.engineVersion,
|
||||
gameVersion = context.gameVersion,
|
||||
playthroughId = context.playthroughId,
|
||||
}
|
||||
if context.normalSavedAt ~= nil then copy.normalSavedAt = context.normalSavedAt end
|
||||
return copy
|
||||
end,
|
||||
read = function(_, key) return self:read(selectedGame, key) end,
|
||||
write = function(_, key, value) return self:write(selectedGame, key, value) end,
|
||||
list = function(_, prefix) return self:list(selectedGame, prefix) end,
|
||||
delete = function(_, key) return self:delete(selectedGame, key) end,
|
||||
}
|
||||
end
|
||||
|
||||
function Storage:context(game)
|
||||
local scope, code, message = self:_scope(game)
|
||||
if not scope then return nil, code, message end
|
||||
|
||||
@@ -19,6 +19,8 @@ TextBox.isTextBox = true
|
||||
-- construction time, so an unthemed boot stays byte-identical
|
||||
local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6
|
||||
local MAX_COLS = 18
|
||||
-- pokegold constants/ram_constants.asm: TEXT_DELAY_FAST/MED/SLOW = 1/3/5
|
||||
local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
|
||||
|
||||
-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops
|
||||
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
|
||||
@@ -352,7 +354,8 @@ function TextBox:update(dt)
|
||||
-- typewriter cadence: one character every N frames, N = the OPTION
|
||||
-- text speed (TextSpeedOptionData frame delays 1/3/5); holding A/B
|
||||
-- prints every frame like the original's held-button fast path
|
||||
local delay = (self.game.save.options and self.game.save.options.textSpeed) or 3
|
||||
local rawSpeed = self.game.save.options and self.game.save.options.textSpeed
|
||||
local delay = NAME_DELAYS[rawSpeed] or rawSpeed or 3
|
||||
if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end
|
||||
if input:isDown("a") or input:isDown("b") then delay = 1 end
|
||||
self.charTimer = (self.charTimer or 0) + 1
|
||||
|
||||
+41
-22
@@ -280,10 +280,44 @@ function Commands.save_end_battle_text(ctx, textId)
|
||||
ctx.endBattleText = TextBox.substitute(ctx.game, text or textId)
|
||||
end
|
||||
|
||||
-- Route scripted battles through the standard entry transition. In the
|
||||
-- originals, InitWildBattle (engine/battle/init_battle.asm) always calls
|
||||
-- DoBattleTransitionAndInitBattleVariables (engine/battle/core.asm), with
|
||||
-- no old-man or Pikachu-demo exception; BattleTransition then selects the
|
||||
-- wipe for the battle kind. Some tests provide only a partial overworld
|
||||
-- double, so retain a logged fallback even though it skips the transition
|
||||
-- and battle music.
|
||||
function Commands.pushBattle(ctx, battle)
|
||||
if ctx.overworld and ctx.overworld.pushBattle then
|
||||
ctx.overworld:pushBattle(battle)
|
||||
else
|
||||
Logger.warn("pushBattle: no overworld:pushBattle, skipping the transition wipe")
|
||||
ctx.game.stack:push(battle)
|
||||
end
|
||||
end
|
||||
|
||||
-- start_battle "wild" species level | start_battle "trainer" OPP_CLASS partyIndex
|
||||
function Commands.start_battle(ctx, kind, a, b)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local runner = ctx.runner
|
||||
local resumed = ctx.resumeBattle
|
||||
if resumed then
|
||||
ctx.resumeBattle = nil
|
||||
local result, restoredBattle = resumed.result, resumed.battle
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
if ctx.overworld then
|
||||
if result == "win" then
|
||||
ctx.afterScript = ctx.afterScript or {}
|
||||
table.insert(ctx.afterScript, function()
|
||||
ctx.overworld:afterBattle(result, restoredBattle)
|
||||
end)
|
||||
else
|
||||
ctx.overworld:afterBattle(result, restoredBattle)
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
local battle
|
||||
if kind == "wild" then
|
||||
battle = BattleState.newWild(ctx.game, a, b)
|
||||
@@ -293,6 +327,10 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
-- one SaveEndBattleTextPointers arms one battle; leaving it set would leak
|
||||
-- the line into the next scripted fight
|
||||
battle.endBattleText, ctx.endBattleText = ctx.endBattleText, nil
|
||||
if runner and runner.battleCheckpointOrigin then
|
||||
battle.checkpointOrigin = runner:battleCheckpointOrigin(battle)
|
||||
if battle.checkpointOrigin then runner.checkpointBattle = battle end
|
||||
end
|
||||
battle.onFinish = function(result)
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
@@ -312,19 +350,8 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
end
|
||||
runner:resume()
|
||||
end
|
||||
-- Every battle enters through the transition wipe, script-driven ones
|
||||
-- included: BattleTransition (engine/battle/battle_transitions.asm:1) runs
|
||||
-- from DoBattleTransitionAndInitBattleVariables for all of them, and
|
||||
-- GetBattleTransitionID_WildOrTrainer picks the style from the battle kind.
|
||||
-- Pushing the BattleState straight onto the stack skipped the wipe
|
||||
-- entirely, so every scripted trainer -- gym leaders, the rival, Giovanni --
|
||||
-- and every scripted wild battle simply cut to the battle screen. The
|
||||
-- trainer-sight path already went through pushBattle; this one did not.
|
||||
if ctx.overworld and ctx.overworld.pushBattle then
|
||||
ctx.overworld:pushBattle(battle)
|
||||
else
|
||||
ctx.game.stack:push(battle)
|
||||
end
|
||||
-- A direct stack push would skip the battle-entry transition.
|
||||
Commands.pushBattle(ctx, battle)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
@@ -818,15 +845,7 @@ function Commands.old_man_demo(ctx, outcome)
|
||||
local battle = BattleState.newWild(ctx.game, om.species, om.level)
|
||||
battle:makeOldManDemo(nil, outcome == "fail")
|
||||
battle.onFinish = function() runner:resume() end
|
||||
-- InitWildBattle calls DoBattleTransitionAndInitBattleVariables
|
||||
-- unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN
|
||||
-- special case -- so the catch tutorial gets the wipe like any other
|
||||
-- wild battle
|
||||
if ctx.overworld and ctx.overworld.pushBattle then
|
||||
ctx.overworld:pushBattle(battle)
|
||||
else
|
||||
ctx.game.stack:push(battle)
|
||||
end
|
||||
Commands.pushBattle(ctx, battle)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
local Commands = require("src.script.Commands")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
|
||||
@@ -116,7 +117,7 @@ function ScriptRunner:makeContext(extra)
|
||||
return ctx
|
||||
end
|
||||
|
||||
function ScriptRunner:run(script, extra)
|
||||
function ScriptRunner:run(script, extra, startPc)
|
||||
assert(not self:isRunning(), "script already running")
|
||||
local ctx = self:makeContext(extra)
|
||||
self.ctx = ctx
|
||||
@@ -124,7 +125,7 @@ function ScriptRunner:run(script, extra)
|
||||
Runtime.emit("script.started", { ctx = ctx })
|
||||
end
|
||||
self.co = coroutine.create(function()
|
||||
self:exec(script, ctx)
|
||||
self:exec(script, ctx, startPc)
|
||||
-- Commands can defer a game action until the script's own dialogue is
|
||||
-- done. start_battle uses this for win-path evolutions, which must not
|
||||
-- be covered by post-battle trainer text.
|
||||
@@ -140,11 +141,13 @@ end
|
||||
-- Execute a command list. Supports labels via jump commands: a script is
|
||||
-- an array of rows; control commands return a new program counter, as a
|
||||
-- row number or a label name.
|
||||
function ScriptRunner:exec(script, ctx)
|
||||
function ScriptRunner:exec(script, ctx, startPc)
|
||||
local labels = ScriptRunner.scanLabels(script)
|
||||
local data = self.game and self.game.data
|
||||
local pc = 1
|
||||
local pc = startPc or 1
|
||||
self.script = script
|
||||
while pc <= #script do
|
||||
self.pc = pc
|
||||
local row = script[pc]
|
||||
local name = row[1]
|
||||
local fn, meta = Commands.resolve(data, name)
|
||||
@@ -189,6 +192,60 @@ function ScriptRunner:exec(script, ctx)
|
||||
end
|
||||
end
|
||||
|
||||
local CHECKPOINT_BATTLE_COMMANDS = {
|
||||
start_battle = true,
|
||||
static_battle = true,
|
||||
rival_battle = true,
|
||||
}
|
||||
|
||||
local function dataCopy(value)
|
||||
local ok, encoded = pcall(SaveSerializer.encode, value)
|
||||
if not ok then return nil end
|
||||
return SaveSerializer.decode(encoded)
|
||||
end
|
||||
|
||||
-- Return a detached semantic continuation only for built-in battle commands
|
||||
-- whose post-yield behavior can be replayed from the current row. The live
|
||||
-- coroutine and arbitrary completion callbacks never cross this boundary.
|
||||
function ScriptRunner:battleCheckpointOrigin(battle)
|
||||
local row = self.script and self.script[self.pc]
|
||||
if not self:isRunning() or type(row) ~= "table"
|
||||
or not CHECKPOINT_BATTLE_COMMANDS[row[1]] then
|
||||
return nil
|
||||
end
|
||||
local ctx = self.ctx or {}
|
||||
if ctx.onDone ~= nil and ctx.checkpointOnDone ~= "release_npc" then
|
||||
return nil
|
||||
end
|
||||
local npcId = ctx.npc and ctx.npc.id or nil
|
||||
if ctx.checkpointOnDone == "release_npc" and type(npcId) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local map = self.overworld and self.overworld.map and self.overworld.map.id
|
||||
if type(map) ~= "string" then return nil end
|
||||
local origin = {
|
||||
kind = "script_battle",
|
||||
map = map,
|
||||
script = self.script,
|
||||
pc = self.pc,
|
||||
command = row[1],
|
||||
npcId = npcId,
|
||||
source = ctx.source,
|
||||
battleKind = battle and battle.kind,
|
||||
trainerClass = battle and battle.oppClass or nil,
|
||||
partyIndex = battle and battle.partyIndex or nil,
|
||||
wildSpecies = battle and battle.enemy and battle.enemy.mon
|
||||
and battle.enemy.mon.species or nil,
|
||||
wildLevel = battle and battle.enemy and battle.enemy.mon
|
||||
and battle.enemy.mon.level or nil,
|
||||
}
|
||||
return dataCopy(origin)
|
||||
end
|
||||
|
||||
function ScriptRunner:isCheckpointBattle(battle)
|
||||
return self.checkpointBattle == battle and self:isRunning()
|
||||
end
|
||||
|
||||
-- Called by blocking commands from inside the coroutine.
|
||||
function ScriptRunner:yield()
|
||||
return coroutine.yield()
|
||||
|
||||
+10
-1
@@ -236,6 +236,12 @@ local function runCmd(self, cmd, op)
|
||||
if self.hidePicFn then self.hidePicFn() end
|
||||
elseif op == "writetext" or op == "farwritetext" then
|
||||
self:showText(cmd.text)
|
||||
if self.nextOp == "playsound" then
|
||||
-- pokegold home/joypad.asm PromptButton: the real press this box's
|
||||
-- own close absorbed plays SFX_READ_TEXT_2; drain it before the
|
||||
-- script's own playsound or Sound.lua's priority gate drops it.
|
||||
coroutine.yield({ kind = "waitsfx" })
|
||||
end
|
||||
elseif op == "rawtext" then
|
||||
-- NOT a cart opcode. `writetext`'s operand is a KEY into text.lua, and
|
||||
-- text.lua only holds strings the extractor reached through a script
|
||||
@@ -384,9 +390,12 @@ local function runCmd(self, cmd, op)
|
||||
local scene = self.getMapSceneFn and self.getMapSceneFn(group, mapNum)
|
||||
self.scriptVar = scene or 0xff
|
||||
elseif op == "turnobject" then
|
||||
-- engine/events/std_scripts.asm: turnobject LAST_TALKED resolves to the NPC last talked to
|
||||
local facing = Movement.dir(cmd.facing or 0)
|
||||
local object = cmd.object or 0
|
||||
if object == LAST_TALKED then object = self.lastTalked end
|
||||
if self.turnObjectFn then
|
||||
self.turnObjectFn(cmd.object or 0, facing)
|
||||
self.turnObjectFn(object, facing)
|
||||
end
|
||||
elseif op == "applymovement" or op == "applymovementlasttalked" then
|
||||
local object = cmd.object or 0
|
||||
|
||||
@@ -74,7 +74,10 @@ function ChoiceBox:draw()
|
||||
if r and r.setUIAnchor then
|
||||
r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, self.anchor)
|
||||
end
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
-- pokegold home/menu.asm YesNoBox: font-page tiles take the screen's own
|
||||
-- BG palette 0 colour 0, same as TextBox.lua's paper fold.
|
||||
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
|
||||
Font.drawBox(tx, ty, tw, th, paper)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
|
||||
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
|
||||
|
||||
@@ -49,6 +49,30 @@ local Rulesets = {
|
||||
modern_clean = require("src.battle.rulesets.modern_clean"),
|
||||
}
|
||||
local FILTERS = { "OFF", "1X", "2X", "3X" }
|
||||
local DATE_FORMATS = {
|
||||
{ "device", "DEVICE" }, { "dmy", "DD-MM-YYYY" },
|
||||
{ "mdy", "MM-DD-YYYY" }, { "ymd", "YYYY-MM-DD" },
|
||||
}
|
||||
local TIME_FORMATS = {
|
||||
{ "device", "DEVICE" }, { "24h", "24 HOUR" }, { "12h", "12 HOUR" },
|
||||
}
|
||||
|
||||
local function preferenceIndex(rows, value)
|
||||
for index, row in ipairs(rows) do
|
||||
if row[1] == value then return index end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
local function preferenceStep(rows, value, direction)
|
||||
local index = preferenceIndex(rows, value)
|
||||
direction = direction and direction < 0 and -1 or 1
|
||||
return rows[((index - 1 + direction) % #rows) + 1][1]
|
||||
end
|
||||
|
||||
local function preferenceLabel(rows, value)
|
||||
return rows[preferenceIndex(rows, value)][2]
|
||||
end
|
||||
|
||||
local function speedIndex(game)
|
||||
-- default matches InitOptions' TEXT_DELAY_MEDIUM in wOptions
|
||||
@@ -439,6 +463,24 @@ local function buildRows(game)
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "BindingsMenu")
|
||||
end },
|
||||
{ id = "dateFormat", label = Strings("DATE FORMAT"),
|
||||
value = function(g)
|
||||
return Strings(preferenceLabel(DATE_FORMATS, g.save.options.dateFormat))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
g.save.options.dateFormat = preferenceStep(
|
||||
DATE_FORMATS, g.save.options.dateFormat, dir)
|
||||
return true
|
||||
end },
|
||||
{ id = "timeFormat", label = Strings("TIME FORMAT"),
|
||||
value = function(g)
|
||||
return Strings(preferenceLabel(TIME_FORMATS, g.save.options.timeFormat))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
g.save.options.timeFormat = preferenceStep(
|
||||
TIME_FORMATS, g.save.options.timeFormat, dir)
|
||||
return true
|
||||
end },
|
||||
-- permanent on-screen pad toggle (#327); layout editing stays in the
|
||||
-- launcher. Hidden where the overlay never appears (desktop without
|
||||
-- POKEPORT_TOUCH), so the row costs a non-mobile install nothing.
|
||||
|
||||
+164
-36
@@ -43,8 +43,9 @@ local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
BattleState.isOpaque = true
|
||||
|
||||
-- How long a message stays before the next event runs, in logic steps. The
|
||||
-- cart waits for A on most lines; holding A skips faster, same as text boxes.
|
||||
-- Armed while a battle line waits for PromptButton (home/text.asm). Any
|
||||
-- positive value means "hold until A/B"; the cart never times these out, so
|
||||
-- the victory jingle can keep looping through the post-win prompts.
|
||||
local MESSAGE_FRAMES = 48
|
||||
|
||||
-- home/hm_moves.asm:17-25 IsHMMove's .HMMoves.
|
||||
@@ -244,6 +245,7 @@ function BattleState.new(game, opts)
|
||||
-- was extracted has no `dudeBack`, and falls back to the player's own.
|
||||
self.showPlayerTrainer = true
|
||||
self.playerBackImage = nil
|
||||
self.playerBackTrueColor = false
|
||||
local hudGfx = data.gen2MenuGfx and data.gen2MenuGfx.battleHud
|
||||
local backPath = hudGfx and hudGfx.playerBack
|
||||
if self.tutorial and hudGfx and hudGfx.dudeBack then
|
||||
@@ -251,7 +253,11 @@ function BattleState.new(game, opts)
|
||||
end
|
||||
-- player.sprite, the same hook and payload Gen 1 raises for its own back pic
|
||||
-- (src/pokemon/Sprites.lua): the Dude's stand-in is the `demo` flag there.
|
||||
backPath = Sprites.playerPic(backPath, {
|
||||
-- Both return values matter here -- a mod's trueColor answer has to survive
|
||||
-- to drawPic, or GbcPalette treats the replacement art as a grayscale 2bpp
|
||||
-- sheet and remaps it through a palette instead of leaving it alone.
|
||||
local backTrueColor
|
||||
backPath, backTrueColor = Sprites.playerPic(backPath, {
|
||||
side = "back", kind = "battle", demo = self.tutorial and true or false,
|
||||
battle = self.battle, data = data,
|
||||
})
|
||||
@@ -262,6 +268,7 @@ function BattleState.new(game, opts)
|
||||
-- Kept so battle_sprite_scales can be looked up for this pic too: it is
|
||||
-- not a species' pic, so its asset path is the only key it has.
|
||||
self.playerBackPath = backPath
|
||||
self.playerBackTrueColor = backTrueColor and true or false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -547,7 +554,10 @@ function BattleState:drawPic(mon, back)
|
||||
-- Before SendOutPlayerMon the player's box holds ChrisBackpic instead, in
|
||||
-- the same 6x6 box at hlcoord 2, 6 that the mon's backpic uses.
|
||||
local trainerBack = back and self.showPlayerTrainer and self.playerBackImage
|
||||
if trainerBack then image, path = trainerBack, self.playerBackPath end
|
||||
if trainerBack then
|
||||
image, path = trainerBack, self.playerBackPath
|
||||
trueColor = self.playerBackTrueColor
|
||||
end
|
||||
-- And the enemy's box holds the trainer's own frontpic until EnemySwitch
|
||||
-- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848).
|
||||
local enemyTrainer = (not back) and self.showEnemyTrainer
|
||||
@@ -564,8 +574,10 @@ function BattleState:drawPic(mon, back)
|
||||
local px, py
|
||||
local boxTiles
|
||||
if back then
|
||||
px = BattleState.PLAYER_PIC_TILE_X * 8
|
||||
py = BattleState.PLAYER_PIC_TILE_Y * 8
|
||||
-- pokegold engine/battle/core.asm:8569: 6x6 box, bottom-aligned/centred
|
||||
local box = BattleState.PLAYER_PIC_TILES * 8
|
||||
px = BattleState.PLAYER_PIC_TILE_X * 8 + math.floor((box - w) / 2)
|
||||
py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h)
|
||||
boxTiles = BattleState.PLAYER_PIC_TILES
|
||||
else
|
||||
-- Bottom-aligned and horizontally centred inside the 7x7 box.
|
||||
@@ -872,8 +884,12 @@ function BattleState:startAnim(key, opts)
|
||||
if not self.anims.scripts[key] then return false end
|
||||
-- BattleAnimRunScript's own gate: `bit BATTLE_SCENE, [wOptions]` skips the
|
||||
-- move animation entirely, which is the OPTION screen's BATTLE SCENE row.
|
||||
-- The check only applies to a real move id (wFXAnimID+1 == 0); non-move
|
||||
-- ids (isMove unset here) branch straight to .not_move and always run.
|
||||
local options = self.game and self.game.options
|
||||
if options and options.battleScene == false then return false end
|
||||
if options and options.battleScene == false and opts and opts.isMove then
|
||||
return false
|
||||
end
|
||||
opts = opts or {}
|
||||
local data = (self.game and self.game.data) or {}
|
||||
local audio = data.audio or {}
|
||||
@@ -930,11 +946,38 @@ function BattleState:startAnim(key, opts)
|
||||
return true
|
||||
end
|
||||
|
||||
-- wBattleAfterAnim target for this attacker's turn
|
||||
-- (effect_commands.asm:1963-1972): player swing -> enemy shake, and reverse.
|
||||
function BattleState:afterAnimFor(side)
|
||||
if side == "player" then return "ANIM_ENEMY_DAMAGE" end
|
||||
return "ANIM_PLAYER_DAMAGE"
|
||||
end
|
||||
|
||||
function BattleState:animForMove(moveId, side)
|
||||
local key = self.anims and self.anims.moves and self.anims.moves[moveId]
|
||||
return self:startAnim(key, {
|
||||
local started = self:startAnim(key, {
|
||||
turn = self:turnFor(side), animId = moveId, isMove = true,
|
||||
})
|
||||
if started then
|
||||
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
|
||||
-- restores HUDs it immediately runs wBattleAfterAnim (the hit shake).
|
||||
-- Queue it so stepAnim chains without waiting on the next event.
|
||||
self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side }
|
||||
end
|
||||
return started
|
||||
end
|
||||
|
||||
-- Kick off a queued after-anim; returns true when one is now running.
|
||||
function BattleState:startPendingAfterAnim()
|
||||
local pending = self.pendingAfterAnim
|
||||
if not pending then return false end
|
||||
self.pendingAfterAnim = nil
|
||||
if self:animForId(pending.name, pending.side) then
|
||||
-- dealDamage's default ANIM_x_DAMAGE is this same shake; skip it there.
|
||||
self.afterAnimPlayed = true
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- True while BattleAnimClearHud has that side's HUD blanked.
|
||||
@@ -960,10 +1003,16 @@ function BattleState:stepAnim(input)
|
||||
-- tilemap is whatever they had got to and nothing is latched -- the
|
||||
-- explicit latches (a catch) are the only ones that survive a skip.
|
||||
self.anim = nil
|
||||
-- Cart still reaches the after-anim arm after a move script ends; a skip
|
||||
-- of the move should not drop the hit shake that follows it.
|
||||
if self:startPendingAfterAnim() then return end
|
||||
return self:endSendOutAnim()
|
||||
end
|
||||
if not self.anim:step() then
|
||||
self.anim = nil
|
||||
-- pokegold data/moves/animations.asm .Click: anim_keepsprites means
|
||||
-- the OAM outlives the script, so keep the runner for drawing too.
|
||||
if not self.anim.keepSprites then self.anim = nil end
|
||||
if self:startPendingAfterAnim() then return end
|
||||
return self:endSendOutAnim()
|
||||
end
|
||||
end
|
||||
@@ -1094,7 +1143,14 @@ function BattleState:advanceQueue()
|
||||
-- until the next damage or heal event moved it.
|
||||
local battle = self.battle
|
||||
local mon = battle and battle.party and battle.party[event.index]
|
||||
-- pokegold engine/battle/core.asm:7057-7069: every mon that leveled
|
||||
-- gets the stats box, not just the mon currently on the field.
|
||||
self.pendingStatsMon = mon
|
||||
-- BattleText_StringBuffer1GrewToLevel ends in text_end (battle.asm:336-343),
|
||||
-- and the active mon never even prints it (core.asm:7044-7056 jumps to the
|
||||
-- stats box). Either way there is no PromptButton before the stats box.
|
||||
if mon and mon == battle.player then
|
||||
event.text = nil
|
||||
if self.shownHp then
|
||||
self.shownHp.player = mon.hp or 0
|
||||
if self.hpAnim and self.hpAnim.side == "player" then
|
||||
@@ -1248,7 +1304,19 @@ function BattleState:advanceQueue()
|
||||
end
|
||||
if event.text then
|
||||
self.message = event.text
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
-- Lines that must not hold the queue for A/B:
|
||||
-- move UsedMoveText -> text_end, then moveanim
|
||||
-- level GrewToLevel is text_end (battle.asm:336-343), then the stats
|
||||
-- box's WaitPressAorB is the real hold
|
||||
-- experience keeps the wait: _ExpPointsText ends in `prompt`
|
||||
-- (common_1.asm:1660-1665). update() runs stepExpAnim before that wait,
|
||||
-- so the bar crawls under the line and A dismisses it before the battle
|
||||
-- can end.
|
||||
if event.kind == "move" or event.kind == "level" then
|
||||
self.messageTimer = 0
|
||||
else
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
end
|
||||
-- BattleStartMessage's own line: the enemy HUD comes up on the step after
|
||||
-- it returns (engine/battle/core.asm:7808-7817), not with it.
|
||||
if event.intro then self.introTextShown = true end
|
||||
@@ -1268,14 +1336,25 @@ function BattleState:advanceQueue()
|
||||
end
|
||||
end
|
||||
-- The move's own animation plays over its "used X!" line, which is where
|
||||
-- PlayBattleAnim sits in the effect command list. A damage event that
|
||||
-- follows gets the shared hit animation instead.
|
||||
-- PlayBattleAnim sits in the effect command list. Its after-anim (the hit
|
||||
-- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript.
|
||||
-- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens
|
||||
-- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a
|
||||
-- move that missed burns the delay and plays nothing. Battle:markMissed sets
|
||||
-- event.missed on every wAttackMissed path.
|
||||
if event.kind == "move" and not event.missed then
|
||||
self:animForMove(event.move, event.side)
|
||||
self.afterAnimPlayed = nil
|
||||
self.pendingAfterAnim = nil
|
||||
if not self:animForMove(event.move, event.side) then
|
||||
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
|
||||
-- (anim_commands.asm:55-72 .disabled fallthrough).
|
||||
local options = self.game and self.game.options
|
||||
if options and options.battleScene == false then
|
||||
if self:animForId(self:afterAnimFor(event.side), event.side) then
|
||||
self.afterAnimPlayed = true
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif event.kind == "damage" and event.side then
|
||||
-- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972),
|
||||
-- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203).
|
||||
@@ -1284,10 +1363,25 @@ function BattleState:advanceQueue()
|
||||
if event.animMove then
|
||||
self:animForMove(event.animMove, from)
|
||||
elseif event.anim ~= false then
|
||||
self:animForId(event.anim
|
||||
local hit = event.anim
|
||||
or (event.side == "enemy" and "ANIM_ENEMY_DAMAGE"
|
||||
or "ANIM_PLAYER_DAMAGE"), from)
|
||||
or "ANIM_PLAYER_DAMAGE")
|
||||
-- Already played as the move's after-anim; do not shake twice.
|
||||
if self.afterAnimPlayed
|
||||
and (hit == "ANIM_ENEMY_DAMAGE" or hit == "ANIM_PLAYER_DAMAGE") then
|
||||
self.afterAnimPlayed = nil
|
||||
else
|
||||
self:animForId(hit, from)
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Status moves still chain the after-anim but emit no damage event to
|
||||
-- consume the latch; drop it before the next unrelated line.
|
||||
self.afterAnimPlayed = nil
|
||||
end
|
||||
if event.kind == "heal" and event.anim and event.side then
|
||||
-- pokegold engine/battle/core.asm:4074 ItemRecoveryAnim
|
||||
self:animForMove(event.anim, event.side)
|
||||
elseif event.kind == "send" and event.side then
|
||||
-- Every enemy send-out goes through ShowSetEnemyMonAndSendOutAnimation
|
||||
-- (engine/battle/core.asm:3354) -- the faint replacement out of
|
||||
@@ -1584,7 +1678,9 @@ function BattleState:update(_dt)
|
||||
|
||||
-- An animation owns the screen for as long as it runs, exactly the way
|
||||
-- RunBattleAnimScript owns the main loop.
|
||||
if self.anim then
|
||||
-- pokegold data/moves/animations.asm .Click: a finished keepsprites run
|
||||
-- no longer owns the loop, just the OAM the draw path still reads.
|
||||
if self.anim and not (self.anim:done() and self.anim.keepSprites) then
|
||||
self:stepAnim(input)
|
||||
return
|
||||
end
|
||||
@@ -1602,32 +1698,45 @@ function BattleState:update(_dt)
|
||||
if Sound.isPlaying(self.waitSfx) then return end
|
||||
self.waitSfx = nil
|
||||
end
|
||||
-- AnimateExpBar sits right after PrintText Text_MonGainedExpPoint
|
||||
-- (core.asm:6881-6888), with that line still on screen. Run the crawl
|
||||
-- before any PromptButton wait so the bar does not sit frozen until A.
|
||||
if self:stepExpAnim() then return end
|
||||
if self.messageTimer > 0 then
|
||||
if self.tutorial then
|
||||
-- PromptButton really does wait for the button; MESSAGE_FRAMES is this
|
||||
-- screen's shortcut for that, and the tutorial cannot take it. The
|
||||
-- DUDE's A lands on frame 0x51 (DudeAutoInput_A), so a line that timed
|
||||
-- out at 48 would hand his press to whichever screen came next.
|
||||
-- PromptButton waits for the button; the tutorial cannot press it, so
|
||||
-- DudeAutoInput_A (frame 0x51) answers. Never auto-timeout here: a
|
||||
-- 48-frame skip would hand his press to the next screen.
|
||||
self:dudeInput(CatchTutorial.PROMPT_STREAM,
|
||||
"prompt:" .. tostring(self.message))
|
||||
else
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
end
|
||||
-- A is the page-advance, exactly like a text box.
|
||||
-- PromptButton (home/text.asm): A/B pages; no frame countdown.
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
-- AnimateExpBar is called from GiveExperiencePoints AFTER
|
||||
-- Text_MonGainedExpPoint has been read (engine/battle/core.asm:6884-6888),
|
||||
-- so the crawl runs with that line still standing and the "grew to level"
|
||||
-- line waits behind it.
|
||||
if self:stepExpAnim() then return end
|
||||
-- pokegold engine/battle/core.asm:7057-7069: the stats box shows once
|
||||
-- the "grew to level" line has finished, held for A/B.
|
||||
if self.pendingStatsMon then
|
||||
self.statsBoxMon = self.pendingStatsMon
|
||||
self.pendingStatsMon = nil
|
||||
self.phase = "stats-box"
|
||||
return
|
||||
end
|
||||
self:advanceQueue()
|
||||
return
|
||||
end
|
||||
|
||||
-- pokegold engine/battle/core.asm:7069 (WaitPressAorB_BlinkCursor).
|
||||
if self.phase == "stats-box" then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.statsBoxMon = nil
|
||||
self.phase = "resolving"
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- The turn CheckPlayerLockedIn skipped the menu for. No input is read: the
|
||||
-- cart falls straight into ParsePlayerAction, whose .locked_in arm has no
|
||||
-- MoveSelectionScreen in front of it.
|
||||
@@ -1644,6 +1753,8 @@ function BattleState:update(_dt)
|
||||
|
||||
if self.phase == "menu" then
|
||||
-- 2x2 grid: left/right swap the column, up/down the row.
|
||||
-- MenuClickSound / PlayClickSFX (home/menu.asm:746-762): SFX_READ_TEXT_2
|
||||
-- on A/B only, never on D-pad.
|
||||
if input:wasPressed("left") or input:wasPressed("right") then
|
||||
self.menuIndex = self.menuIndex % 2 == 1 and self.menuIndex + 1
|
||||
or self.menuIndex - 1
|
||||
@@ -1651,6 +1762,7 @@ function BattleState:update(_dt)
|
||||
self.menuIndex = self.menuIndex <= 2 and self.menuIndex + 2
|
||||
or self.menuIndex - 2
|
||||
elseif input:wasPressed("a") then
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
local choice = MENU[self.menuIndex]
|
||||
if choice == "FIGHT" then
|
||||
-- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm
|
||||
@@ -1701,11 +1813,13 @@ function BattleState:update(_dt)
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
-- B leaves the list, and a mark never survives it
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
elseif input:wasPressed("a") then
|
||||
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
|
||||
-- cancels a pending swap rather than performing it
|
||||
self:playSfx("Sfx_ReadText2")
|
||||
self.moveSwapIndex = nil
|
||||
local move = moves[self.moveIndex]
|
||||
if not move then return end
|
||||
@@ -1724,7 +1838,6 @@ function BattleState:update(_dt)
|
||||
-- AskGiveNicknameText ends on `done`, so the line stands while the box is
|
||||
-- up rather than paging away from under it.
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1745,7 +1858,6 @@ function BattleState:update(_dt)
|
||||
-- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310).
|
||||
if self.phase == "ask-shift" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1766,7 +1878,6 @@ function BattleState:update(_dt)
|
||||
|
||||
if self.phase == "refuse-shift" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1788,7 +1899,6 @@ function BattleState:update(_dt)
|
||||
-- what ForcePickPartyMonInBattle's `jr c, .loop` does with the carry.
|
||||
if self.phase == "refuse-switch" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1807,7 +1917,6 @@ function BattleState:update(_dt)
|
||||
|
||||
if self.phase == "refuse-move" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1820,7 +1929,6 @@ function BattleState:update(_dt)
|
||||
|
||||
if self.phase == "learn-intro" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1834,7 +1942,6 @@ function BattleState:update(_dt)
|
||||
|
||||
if self.phase == "ask-forget" or self.phase == "stop-learning" then
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -1855,7 +1962,6 @@ function BattleState:update(_dt)
|
||||
-- MoveCantForgetHMText holds like any prompt, then `jr .loop` reprints
|
||||
-- MoveAskForgetText over the list (engine/pokemon/learn.asm:193-197).
|
||||
if self.messageTimer > 0 then
|
||||
self.messageTimer = self.messageTimer - 1
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
@@ -3080,9 +3186,31 @@ function BattleState:drawPanel()
|
||||
Chrome.cursor(left + 1, index == 1 and 8 or 10)
|
||||
end
|
||||
end
|
||||
if self.phase == "stats-box" and self.statsBoxMon then
|
||||
self:drawStatsBox(self.statsBoxMon)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- pokegold engine/pokemon/mon_stats.asm:118-124 (PrintTempMonStats.StatNames).
|
||||
local STATS_BOX_ROWS = {
|
||||
{ "ATTACK", "attack" }, { "DEFENSE", "defense" },
|
||||
{ "SPCL.ATK", "specialAttack" }, { "SPCL.DEF", "specialDefense" },
|
||||
{ "SPEED", "speed" },
|
||||
}
|
||||
|
||||
-- pokegold engine/battle/core.asm:7060-7066 (box at hlcoord 9,0, stats at 11,y).
|
||||
function BattleState:drawStatsBox(mon)
|
||||
local stats = mon and mon.stats
|
||||
if not stats then return end
|
||||
Chrome.textbox(9, 0, 9, 10)
|
||||
for i, row in ipairs(STATS_BOX_ROWS) do
|
||||
local ty = 1 + (i - 1) * 2
|
||||
Chrome.print(Strings(row[1]), 11, ty)
|
||||
Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1)
|
||||
end
|
||||
end
|
||||
|
||||
-- The BG layer, plus whatever the animation is doing to it, plus the OBJ
|
||||
-- layer on top. OBJs are not affected by SCX/SCY, which is why they are drawn
|
||||
-- after the scanline blit rather than into the canvas with everything else.
|
||||
|
||||
+54
-20
@@ -43,6 +43,7 @@
|
||||
local Assets = require("src.render.Assets")
|
||||
local Boxes = require("src.core.gen2.Boxes")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
@@ -74,6 +75,14 @@ local PARTY_BOX = 0
|
||||
-- can destroy a mon.
|
||||
local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" }
|
||||
|
||||
-- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows.
|
||||
local WITHDRAW_SUBMENU = { "WITHDRAW", "STATS", "RELEASE", "CANCEL" }
|
||||
|
||||
function BoxMenu:submenuRows()
|
||||
if self.mode == "move" then return MOVE_SUBMENU end
|
||||
return WITHDRAW_SUBMENU
|
||||
end
|
||||
|
||||
-- MovePKMNWithoutMail_InsertMon's .Saving_LeaveOn, printed for 20 frames while
|
||||
-- the mon is written into its new home. It stays up here until a button
|
||||
-- clears it, because it is also the only confirmation the player gets that the
|
||||
@@ -164,14 +173,16 @@ end
|
||||
-- The cart's own prompts (PCString_*): short, because the box they print in
|
||||
-- is one row of 18 columns.
|
||||
function BoxMenu:prompt()
|
||||
if self.mode == "deposit" then return "Deposit which one?" end
|
||||
-- engine/pokemon/bills_pc.asm:356-369: PrepSubmenu places PCString_WhatsUp.
|
||||
if self.phase == "submenu" then return "What's up?" end
|
||||
if self.mode == "move" then
|
||||
-- .Init, .PrepSubmenu and .PrepInsertCursor each place their own string.
|
||||
-- .Init and .PrepInsertCursor each place their own string.
|
||||
if self.phase == "insert" then return "Move to where?" end
|
||||
if self.phase == "submenu" then return "What's up?" end
|
||||
return "Choose a <PK><MN>."
|
||||
end
|
||||
return "Choose a POKéMON."
|
||||
-- PCString_ChooseaPKMN: _DepositPKMN.Init and BillsPC_Withdraw.Init both
|
||||
-- place this exact string (engine/pokemon/bills_pc.asm:2185).
|
||||
return "Choose a <PK><MN>."
|
||||
end
|
||||
|
||||
function BoxMenu:total()
|
||||
@@ -208,21 +219,15 @@ function BoxMenu:act()
|
||||
if self.onClose then self.onClose() end
|
||||
return
|
||||
end
|
||||
-- .a_button: the move screen never acts on the list itself. It checks that
|
||||
-- the row really is a mon and steps to $2, .PrepSubmenu.
|
||||
if self.mode == "move" then
|
||||
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu.
|
||||
if self.mode == "move" or self.mode == "withdraw" then
|
||||
if not self:selected() then return end
|
||||
self.phase = "submenu"
|
||||
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE.
|
||||
self.submenuIndex = 1
|
||||
return
|
||||
end
|
||||
local ok, result
|
||||
if self.mode == "deposit" then
|
||||
ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
|
||||
else
|
||||
ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index)
|
||||
end
|
||||
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
|
||||
if not ok then
|
||||
self.message = result
|
||||
return
|
||||
@@ -294,12 +299,28 @@ function BoxMenu:openStats()
|
||||
})
|
||||
end
|
||||
|
||||
-- engine/pokemon/bills_pc.asm:397-411: failed withdraw stays on the submenu.
|
||||
function BoxMenu:doWithdraw()
|
||||
local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index)
|
||||
if not ok then
|
||||
self.message = result
|
||||
return
|
||||
end
|
||||
self.message = nil
|
||||
self.phase = nil
|
||||
self:clampIndex()
|
||||
end
|
||||
|
||||
function BoxMenu:chooseSubmenu()
|
||||
local row = MOVE_SUBMENU[self.submenuIndex]
|
||||
local row = self:submenuRows()[self.submenuIndex]
|
||||
if row == "MOVE" then
|
||||
self:beginMove()
|
||||
elseif row == "WITHDRAW" then
|
||||
self:doWithdraw()
|
||||
elseif row == "STATS" then
|
||||
self:openStats()
|
||||
elseif row == "RELEASE" then
|
||||
self:askRelease()
|
||||
else
|
||||
-- .Cancel: `ld a, $0 / ld [wJumptableIndex], a`.
|
||||
self.phase = nil
|
||||
@@ -417,11 +438,12 @@ function BoxMenu:update(_dt)
|
||||
|
||||
-- .MoveMonWOMailSubmenu, a VerticalMenu: up/down, A picks, B is its carry.
|
||||
if self.phase == "submenu" then
|
||||
local submenu = self:submenuRows()
|
||||
if input:wasPressed("up") then
|
||||
self.submenuIndex = self.submenuIndex > 1 and self.submenuIndex - 1
|
||||
or #MOVE_SUBMENU
|
||||
or #submenu
|
||||
elseif input:wasPressed("down") then
|
||||
self.submenuIndex = self.submenuIndex < #MOVE_SUBMENU
|
||||
self.submenuIndex = self.submenuIndex < #submenu
|
||||
and self.submenuIndex + 1 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
self:chooseSubmenu()
|
||||
@@ -518,6 +540,7 @@ function BoxMenu:askRelease()
|
||||
return
|
||||
end
|
||||
self.message = name .. " was released."
|
||||
self.phase = nil
|
||||
self:clampIndex()
|
||||
end, { defaultNo = true }))
|
||||
end
|
||||
@@ -644,12 +667,12 @@ end
|
||||
|
||||
-- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor
|
||||
-- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top
|
||||
-- left at pixel (71, 31), stepping 16 pixels per row. Those cursor tiles are
|
||||
-- left at pixel (71, 25), stepping 16 pixels per row. Those cursor tiles are
|
||||
-- not extracted, so the frame is drawn as an outline at exactly those pixels,
|
||||
-- which is what the sprite frame looks like.
|
||||
function BoxMenu:drawSelectionFrame(row)
|
||||
local G = love.graphics
|
||||
local x, y = 71, 31 + (row - 1) * 16
|
||||
local x, y = 71, 25 + (row - 1) * 16
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.setLineWidth(1)
|
||||
G.rectangle("line", x + 0.5, y + 0.5, 80 - 1, 16 - 1)
|
||||
@@ -679,6 +702,9 @@ function BoxMenu:panelMon()
|
||||
end
|
||||
|
||||
function BoxMenu:drawPanel()
|
||||
-- BillsPC_InitGFX loads FontsBattleExtra once for the whole screen and
|
||||
-- never restores the standard font (engine/pokemon/bills_pc.asm:2169).
|
||||
local wasBattle = Font.useBattleExtra(true)
|
||||
Chrome.clear()
|
||||
|
||||
-- Box name header, then the list box hanging off it. BillsPC_BoxName is a
|
||||
@@ -686,6 +712,11 @@ function BoxMenu:drawPanel()
|
||||
Chrome.box(8, 0, 12, 3)
|
||||
Chrome.print(self:title(), 10, 1)
|
||||
Chrome.box(8, 2, 12, 12)
|
||||
-- BillsPC_RefreshTextboxes overwrites its own top corners with '└'/'┘'
|
||||
-- (engine/pokemon/bills_pc.asm:1204-1211) so the list reads as hanging
|
||||
-- off the name box above it.
|
||||
Font.drawCode(Font.BORDER.bl, 8 * 8, 2 * 8)
|
||||
Font.drawCode(Font.BORDER.br, 19 * 8, 2 * 8)
|
||||
|
||||
local list = self:list()
|
||||
local inserting = self.phase == "insert"
|
||||
@@ -724,7 +755,9 @@ function BoxMenu:drawPanel()
|
||||
self:drawEggPic(mon)
|
||||
else
|
||||
self:drawPic(mon)
|
||||
Chrome.print(":L" .. tostring(mon.level or 1), PIC_X, 12)
|
||||
-- PrintLevel always writes the single bold glyph, not ":L"
|
||||
-- (home/pokemon.asm:178-183).
|
||||
Chrome.print("<LV>" .. tostring(mon.level or 1), PIC_X, 12)
|
||||
if mon.gender == "male" then
|
||||
Chrome.print("\xe2\x99\x82", 5, 12)
|
||||
elseif mon.gender == "female" then
|
||||
@@ -756,13 +789,14 @@ function BoxMenu:drawPanel()
|
||||
-- top spacing puts MOVE at (11,6), one row per two tiles.
|
||||
if self.phase == "submenu" then
|
||||
Chrome.box(9, 4, 11, 10)
|
||||
for i, label in ipairs(MOVE_SUBMENU) do
|
||||
for i, label in ipairs(self:submenuRows()) do
|
||||
local ty = 6 + (i - 1) * 2
|
||||
if i == self.submenuIndex then Chrome.cursor(10, ty) end
|
||||
Chrome.print(label, 11, ty)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.useBattleExtra(wasBattle)
|
||||
end
|
||||
|
||||
function BoxMenu:draw()
|
||||
|
||||
@@ -896,7 +896,7 @@ local function printPriceOpaque(amount, ty)
|
||||
end
|
||||
|
||||
function MartMenu:drawBuyList()
|
||||
Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H)
|
||||
-- pokegold engine/menus/scrolling_menu.asm _InitScrollingMenu: no border for the buy list
|
||||
for row = 1, VISIBLE_ROWS do
|
||||
local i = row + self.scroll
|
||||
local ty = LIST_Y + (row - 1) * LIST_SPACING
|
||||
|
||||
@@ -86,6 +86,10 @@ local ROWS = {
|
||||
--
|
||||
-- The two volume rows clamp at the ends rather than wrapping, the way
|
||||
-- pokered's text-speed cursor does, so holding left reaches OFF and stays.
|
||||
{ id = "controls", label = "CONTROLS", port = true,
|
||||
activate = function(game)
|
||||
require("src.ui.Screens").push(game, "BindingsMenu")
|
||||
end },
|
||||
{ label = "MUSIC VOL", key = "musicVol", port = true,
|
||||
cycle = function(options, delta)
|
||||
options.musicVol = stepVolume(options.musicVol, delta)
|
||||
|
||||
@@ -188,6 +188,22 @@ function PokedexMenu.new(game, opts)
|
||||
self.dexPalette = gfx.palette
|
||||
end
|
||||
|
||||
-- pokegold engine/pokegear/pokegear.asm Pokedex_GetArea: the AREA page
|
||||
-- draws through the Pokegear's own town-map tiles and TownMapPals, not the
|
||||
-- dex's PokedexLZ sheet.
|
||||
local mapGfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear
|
||||
self.mapGfx = mapGfx
|
||||
if mapGfx then
|
||||
self.mapSheet = TileSheet.new({
|
||||
path = mapGfx.tiles, wide = mapGfx.tilesWide or 16, firstTile = 0,
|
||||
paletteFor = function(tile)
|
||||
if not mapGfx.palettes then return nil end
|
||||
if tile >= 0x60 then return mapGfx.palettes[1] end
|
||||
return mapGfx.palettes[(mapGfx.palMap and mapGfx.palMap[tile + 1]) or 1]
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- Pokedex_LoadUnownFont: 27 tiles at vTiles2 tile FIRST_UNOWN_CHAR, live
|
||||
-- only while UNOWN MODE is on screen. It is a sheet rather than a font
|
||||
-- page (see PokedexMenu:unownGlyph), and it draws through the dex palette
|
||||
@@ -793,7 +809,7 @@ end
|
||||
function PokedexMenu:playerLandmark()
|
||||
local save = self.game and self.game.save
|
||||
local mapId = save and save.position and save.position.map
|
||||
local def = mapId and self.data and self.data.maps and self.data.maps[mapId]
|
||||
local def = mapId and self.data and self.data.gen2Maps and self.data.gen2Maps[mapId]
|
||||
return def and def.landmark
|
||||
end
|
||||
|
||||
@@ -803,11 +819,13 @@ end
|
||||
-- substitutes them), and borrowing Pokegear's would freeze the map's ink.
|
||||
function PokedexMenu:drawTilemap(cells)
|
||||
if type(cells) ~= "table" then return end
|
||||
local sheet = self.mapSheet
|
||||
if not sheet then return end
|
||||
local i = 1
|
||||
for ty = 0, Chrome.SCREEN_H - 1 do
|
||||
for tx = 0, Chrome.SCREEN_W - 1 do
|
||||
local id = cells[i]
|
||||
if id then self:tile(id, tx, ty) end
|
||||
if id then sheet:draw(id, tx, ty) end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
@@ -837,7 +855,7 @@ function PokedexMenu:drawArea()
|
||||
-- uses. Without it (a cache imported before the town map was extracted) the
|
||||
-- page still lists the landmark NAMES, which is the information the screen
|
||||
-- exists to convey.
|
||||
local maps = self.gfx and self.gfx.maps
|
||||
local maps = self.mapGfx and self.mapGfx.maps
|
||||
local cells = maps and maps[region]
|
||||
if cells then
|
||||
self:drawTilemap(cells)
|
||||
|
||||
@@ -1537,6 +1537,9 @@ function Pokegear:callContact(id)
|
||||
text = self:phoneText("GearOutOfService") }
|
||||
return
|
||||
end
|
||||
-- pokegold engine/pokegear/pokegear.asm:883-889: SFX_CALL rings before the call connects.
|
||||
local world = self.game and self.game.world
|
||||
if world then world:playSfxNamed("Sfx_Call", 106) end
|
||||
local call = Phone.call(self.save, id, context)
|
||||
local name, className = Phone.contactName(id, self.trainers)
|
||||
call.name, call.className = name, className
|
||||
@@ -1562,6 +1565,11 @@ end
|
||||
|
||||
-- HangUp: the click, the boops, and back to "Whom do you want to call?".
|
||||
function Pokegear:hangUp()
|
||||
-- pokegold engine/phone/phone.asm:517-519: HangUp_Beep plays SFX_HANG_UP.
|
||||
if self.call and self.call.kind ~= "nosignal" then
|
||||
local world = self.game and self.game.world
|
||||
if world then world:playSfxNamed("Sfx_HangUp", 107) end
|
||||
end
|
||||
self.call = nil
|
||||
end
|
||||
|
||||
@@ -1768,14 +1776,13 @@ function Pokegear:loadArrowSheet()
|
||||
self.arrow = false
|
||||
local gfx = self.gfx
|
||||
if gfx and gfx.sprites then
|
||||
self:loadPlayerIcon()
|
||||
self.arrow = TileSheet.new({
|
||||
path = gfx.sprites, wide = gfx.spritesWide or 2, firstTile = 0,
|
||||
-- The icon strip's palette (cream / orange / brown / black), not BG
|
||||
-- palette 0's greys: the arrow is an OBJ and the cart tints it to match
|
||||
-- the card icons it points at. The extract carries the gear's BG
|
||||
-- palettes only, and palMap gives every icon-strip tile this same index,
|
||||
-- so it is the one that reproduces the cart rather than a guess.
|
||||
palette = gfx.palettes and (gfx.palettes[4] or gfx.palettes[1]),
|
||||
-- pokegold data/sprite_anims/oam.asm .OAMData_RedWalk: STILL_CURSOR's
|
||||
-- oamset reuses RED_WALK's OAM data, so this wears PAL_OW_RED.
|
||||
palette = (self.playerIcon and self.playerIcon.objColors)
|
||||
or (gfx.palettes and gfx.palettes[1]),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -538,6 +538,10 @@ function TradeAnimView:drawStats(record, offset)
|
||||
G.push()
|
||||
G.translate(offset, WINDOW_Y)
|
||||
Chrome.textbox(PANEL_X, PANEL_Y, PANEL_INNER_W, PANEL_INNER_H)
|
||||
-- pokegold engine/movie/trade_animation.asm:883-897,925-929: PlaceString
|
||||
-- and PrintNum overwrite the border's own tile at cols 4-12, row 0.
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", (PANEL_X + 1) * 8, PANEL_Y * 8, 9 * 8, 8)
|
||||
for _, row in ipairs(TEMPLATE_ROWS) do
|
||||
Chrome.print(row.text, PANEL_X + 1, row.row)
|
||||
end
|
||||
|
||||
@@ -453,9 +453,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- walks out of the warp, not beside him (#863)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self, opts, true)
|
||||
|
||||
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
|
||||
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
|
||||
-- keepMusicOnce is the play_music opts.keep one-shot of the same bit
|
||||
-- opts.keepMusic preserves the Oak-escort song across the lab warp,
|
||||
-- matching BIT_NO_MAP_MUSIC: MUSIC_MUSEUM_GUY in Yellow and
|
||||
-- MUSIC_MEET_PROF_OAK in Red/Blue. keepMusicOnce is the equivalent
|
||||
-- one-shot set by play_music opts.keep.
|
||||
local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce
|
||||
self.keepMusicOnce = nil
|
||||
if not keepMusic then
|
||||
@@ -3394,6 +3395,7 @@ function OverworldState:showMapText(textConst, npc, onDone)
|
||||
-- the winning contribution's rows run as their owner (09 §4.4): mod:
|
||||
-- field routing, strict dispatch and error reports all read the source
|
||||
self.runner:run(script, { npc = npc, onDone = onDone,
|
||||
checkpointOnDone = onDone and "release_npc" or nil,
|
||||
source = mapScripts.talkSource(self.map.id, textConst) })
|
||||
return
|
||||
end
|
||||
@@ -4066,6 +4068,30 @@ function OverworldState:restoreBattleContinuation(battle, origin)
|
||||
battle.onFinish = function(result) self:afterBattle(result, battle) end
|
||||
return true
|
||||
end
|
||||
if origin.kind == "script_battle" then
|
||||
if origin.battleKind ~= battle.kind
|
||||
or (battle.kind == "trainer" and (origin.trainerClass ~= battle.oppClass
|
||||
or origin.partyIndex ~= (battle.partyIndex or 1)))
|
||||
or type(origin.script) ~= "table" or type(origin.pc) ~= "number" then
|
||||
return false
|
||||
end
|
||||
local npc = origin.npcId and self.npcPool and self.npcPool[origin.npcId] or nil
|
||||
if origin.npcId and not npc then return false end
|
||||
battle.onFinish = function(result)
|
||||
local runner = self.runner
|
||||
if not runner or runner:isRunning() then
|
||||
runner = ScriptRunner.new(game, self)
|
||||
self.runner = runner
|
||||
end
|
||||
runner:run(origin.script, {
|
||||
npc = npc,
|
||||
source = origin.source,
|
||||
resumeBattle = { result = result, battle = battle },
|
||||
}, origin.pc)
|
||||
end
|
||||
battle.checkpointScriptContinuation = true
|
||||
return true
|
||||
end
|
||||
if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer"
|
||||
or origin.trainerClass ~= battle.oppClass
|
||||
or origin.partyIndex ~= (battle.partyIndex or 1)
|
||||
|
||||
@@ -69,6 +69,22 @@ local function mapTileRows(map)
|
||||
return rows, detailRows
|
||||
end
|
||||
|
||||
local function acceptsMenuInput(game, ow)
|
||||
local stack = game and game.stack
|
||||
local runner = ow and ow.runner
|
||||
return ow and stack and stack.top and stack:top() == ow
|
||||
and not ow.transitioning and not ow.flyAnim and not ow.teleportOut
|
||||
and not ow.engaging and not ow.emote and not ow.pikaHop and not ow.healAnim
|
||||
and not (ow.player and (ow.player.moving or ow.player.inputLocked))
|
||||
and not (runner and runner.isRunning and runner:isRunning())
|
||||
and #(ow.scriptMoves or {}) == 0
|
||||
end
|
||||
|
||||
local function validPartySlot(party, slot)
|
||||
return type(slot) == "number" and slot == math.floor(slot)
|
||||
and party[slot] ~= nil
|
||||
end
|
||||
|
||||
function WorldAPI.new(game, modId)
|
||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||
end
|
||||
@@ -98,6 +114,31 @@ function WorldAPI:current()
|
||||
facing = p and p.facing }
|
||||
end
|
||||
|
||||
-- Companion UIs may offer party ordering while the player is in free roam.
|
||||
-- The same guard that makes opening a menu safe keeps scripts, transitions,
|
||||
-- movement and screens above the overworld from observing a mid-action swap.
|
||||
function WorldAPI:canReorderParty()
|
||||
local game, ow = self.game, self:overworld()
|
||||
local party = game and game.save and game.save.party or {}
|
||||
return #party > 1 and not not acceptsMenuInput(game, ow)
|
||||
end
|
||||
|
||||
function WorldAPI:reorderParty(fromSlot, toSlot)
|
||||
local game, ow = self.game, self:overworld()
|
||||
if not ow then return nil, NO_OVERWORLD end
|
||||
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
|
||||
local party = game.save and game.save.party or {}
|
||||
if not validPartySlot(party, fromSlot)
|
||||
or not validPartySlot(party, toSlot) then
|
||||
return nil, "invalid party slot"
|
||||
end
|
||||
if fromSlot ~= toSlot then
|
||||
party[fromSlot], party[toSlot] = party[toSlot], party[fromSlot]
|
||||
require("src.core.Sound").play(game.data, "Swap")
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- A compact, read-only view of the active map for minimaps and companion UIs.
|
||||
-- `rows` describes collision terrain; optional `tileRows` reduces each real
|
||||
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
|
||||
|
||||
@@ -175,6 +175,20 @@ function Permissions.currentDirection(coll)
|
||||
return CURRENT_DIR[coll % 4]
|
||||
end
|
||||
|
||||
-- DoPlayerMovement .CheckTile, HI_NYBBLE_WARPS arm (.warps): landing on a
|
||||
-- door/staircase/cave forces a walk DOWN off it (engine/overworld/player_movement.asm).
|
||||
local DOOR_FORCED = {
|
||||
[0x71] = true, -- COLL_DOOR
|
||||
[0x79] = true, -- COLL_DOOR_79 (unused)
|
||||
[0x7a] = true, -- COLL_STAIRCASE
|
||||
[0x7b] = true, -- COLL_CAVE
|
||||
}
|
||||
|
||||
function Permissions.doorForcedDirection(coll)
|
||||
if DOOR_FORCED[coll] then return "down" end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- CheckCutCollision (engine/overworld/tile_events.asm): the collisions CUT is
|
||||
-- allowed to swing at. Both grasses are in it, which is why CUT mows a patch
|
||||
-- of tall grass down to bare ground and not only trees.
|
||||
|
||||
@@ -140,6 +140,9 @@ function Player:facingCell()
|
||||
end
|
||||
|
||||
function Player:walkPhase()
|
||||
-- pokegold engine/overworld/map_objects.asm StepFunction_Turn: forces the
|
||||
-- walking leg frame for the whole 4-frame turn-in-place.
|
||||
if self.turnTimer > 0 then return 1 end
|
||||
if not self.moving then return 0 end
|
||||
local p = self.animClock % STEP_FRAMES
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
@@ -167,9 +170,9 @@ function Player:update()
|
||||
self.px = self.cellX * 16 + dx * adv
|
||||
self.py = self.cellY * 16 + dy * adv
|
||||
if self.jumping then
|
||||
-- The hop arc. Cosmetic: the grid position is the straight-line
|
||||
-- interpolation above, only the drawn pixels rise.
|
||||
self.py = self.py - math.floor(6 * math.sin(math.pi * self.progress / frames))
|
||||
-- pokegold engine/overworld/map_objects.asm: UpdateJumpPosition's
|
||||
-- y_offsets table peaks at -12.
|
||||
self.py = self.py - math.floor(12 * math.sin(math.pi * self.progress / frames))
|
||||
end
|
||||
if self.progress >= frames then
|
||||
self.cellX, self.cellY = self.targetX, self.targetY
|
||||
|
||||
+140
-52
@@ -46,6 +46,7 @@ local Music = require("src.core.Music")
|
||||
local NPC = require("src.world.gen2.Npc")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Player = require("src.world.gen2.Player")
|
||||
local Pokerus = require("src.core.gen2.Pokerus")
|
||||
local Roamers = require("src.core.gen2.Roamers")
|
||||
@@ -9555,11 +9556,13 @@ function World:stepBody()
|
||||
-- is a scripted step under World:busy, which returns above this line.
|
||||
local dir = self.heldDir
|
||||
if not p.moving then
|
||||
local current = Permissions.currentDirection(self:playerCollision())
|
||||
local coll = self:playerCollision()
|
||||
local current = Permissions.currentDirection(coll)
|
||||
or Permissions.doorForcedDirection(coll)
|
||||
if current then
|
||||
dir = current
|
||||
elseif self.turningDirection
|
||||
and Permissions.isIce(self:playerCollision()) then
|
||||
and Permissions.isIce(coll) then
|
||||
dir = self.turningDirection
|
||||
elseif not dir then
|
||||
self.turningDirection = nil
|
||||
@@ -9615,7 +9618,17 @@ function World:drawGround(s)
|
||||
-- clear colour. LoadMetatiles fills it with wMapBorderBlock instead, and
|
||||
-- the connection strips and the map draw straight over the top of it.
|
||||
if self.map then
|
||||
local bw, bh = G.getDimensions()
|
||||
-- Destination size must be the CURRENT canvas (tilt grows it past the
|
||||
-- window). getDimensions() is always the window, so a grown tilt capture
|
||||
-- used to tile the void against the wrong view and the fill drifted off
|
||||
-- the map grid as the camera moved.
|
||||
local canvas = G.getCanvas()
|
||||
local bw, bh
|
||||
if canvas then
|
||||
bw, bh = canvas:getDimensions()
|
||||
else
|
||||
bw, bh = G.getDimensions()
|
||||
end
|
||||
BorderFill.draw(self, self:borderImageFor(self.map.id),
|
||||
cam.x, cam.y, bw, bh, s, self.map.id)
|
||||
end
|
||||
@@ -9691,45 +9704,51 @@ function World:drawPeople(s, billboard)
|
||||
end
|
||||
end
|
||||
|
||||
if self.emote and self.emote.image then
|
||||
local e = self.emote
|
||||
local ex = math.floor((e.entity.px - cam.x) * s)
|
||||
local ey = math.floor((e.entity.py - 16 - cam.y) * s)
|
||||
-- SpawnEmote.EmoteObject (engine/overworld/map_objects.asm:2029) spawns the
|
||||
-- bubble as an OBJ on PAL_OW_EMOTE, which LoadMapPals resolves to the
|
||||
-- "silver" row of gfx/overworld/npc_sprites.pal (white / white / RGB
|
||||
-- 13,13,13 / black). That row is byte-identical in all four daytime
|
||||
-- blocks, so the bubble is the same at any hour, but it still goes through
|
||||
-- the daytime lookup because that is what LoadMapPals does and it keeps the
|
||||
-- emote on the same path as every other OW sprite. Blitting the extracted
|
||||
-- sheet raw left the interior at the DMG ramp's shade 1 (170 grey) instead
|
||||
-- of white: the Gen 2 repeat of #505.
|
||||
local emoteColors = Palettes.spritePalette(self.palettes,
|
||||
self.daytime or Palettes.daytimeFor(self.map and self.map.def,
|
||||
self:hour(), self.flashUsed),
|
||||
{ paletteId = 5 })
|
||||
local function blit()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(e.image, ex, ey, 0, s, s)
|
||||
end
|
||||
local function body()
|
||||
-- GbcPalette.with, not useRaw: the DMG and CLASSIC colour modes still
|
||||
-- have to collapse the row to their own ramps, and it restores whatever
|
||||
-- shader the billboard pass had set rather than assuming none.
|
||||
if emoteColors and GbcPalette.available() then
|
||||
GbcPalette.with(emoteColors, blit)
|
||||
else
|
||||
blit()
|
||||
end
|
||||
end
|
||||
if billboard then
|
||||
billboard(ex + 8 * s, ey + 32 * s, body)
|
||||
self:drawEmote(s, billboard)
|
||||
self:drawHealAnim(s, billboard)
|
||||
end
|
||||
|
||||
-- Split out of drawPeople so World:drawPipeline composites the one copy the
|
||||
-- flat and tilt paths draw, not a second transcription of it.
|
||||
function World:drawEmote(s, billboard)
|
||||
if not (self.emote and self.emote.image) then return end
|
||||
local G = love.graphics
|
||||
local cam = self.camera
|
||||
local e = self.emote
|
||||
local ex = math.floor((e.entity.px - cam.x) * s)
|
||||
local ey = math.floor((e.entity.py - 16 - cam.y) * s)
|
||||
-- SpawnEmote.EmoteObject (engine/overworld/map_objects.asm:2029) spawns the
|
||||
-- bubble as an OBJ on PAL_OW_EMOTE, which LoadMapPals resolves to the
|
||||
-- "silver" row of gfx/overworld/npc_sprites.pal (white / white / RGB
|
||||
-- 13,13,13 / black). That row is byte-identical in all four daytime
|
||||
-- blocks, so the bubble is the same at any hour, but it still goes through
|
||||
-- the daytime lookup because that is what LoadMapPals does and it keeps the
|
||||
-- emote on the same path as every other OW sprite. Blitting the extracted
|
||||
-- sheet raw left the interior at the DMG ramp's shade 1 (170 grey) instead
|
||||
-- of white: the Gen 2 repeat of #505.
|
||||
local emoteColors = Palettes.spritePalette(self.palettes,
|
||||
self.daytime or Palettes.daytimeFor(self.map and self.map.def,
|
||||
self:hour(), self.flashUsed),
|
||||
{ paletteId = 5 })
|
||||
local function blit()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(e.image, ex, ey, 0, s, s)
|
||||
end
|
||||
local function body()
|
||||
-- GbcPalette.with, not useRaw: the DMG and CLASSIC colour modes still
|
||||
-- have to collapse the row to their own ramps, and it restores whatever
|
||||
-- shader the billboard pass had set rather than assuming none.
|
||||
if emoteColors and GbcPalette.available() then
|
||||
GbcPalette.with(emoteColors, blit)
|
||||
else
|
||||
body()
|
||||
blit()
|
||||
end
|
||||
end
|
||||
|
||||
self:drawHealAnim(s, billboard)
|
||||
if billboard then
|
||||
billboard(ex + 8 * s, ey + 32 * s, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
end
|
||||
|
||||
function World:drawWorldBody(s)
|
||||
@@ -9737,6 +9756,52 @@ function World:drawWorldBody(s)
|
||||
self:drawPeople(s)
|
||||
end
|
||||
|
||||
-- Gold's half of the world-pipeline seam: same ctx keys, same order and the
|
||||
-- same nil-falls-back-to-2D rule as src/world/OverworldController.lua:4867.
|
||||
function World:drawPipeline(id, w, h, s)
|
||||
local G = love.graphics
|
||||
local cam = self.camera
|
||||
local ctx = {
|
||||
state = self, cam = cam,
|
||||
vw = self.viewW, vh = self.viewH,
|
||||
-- No BG-only shake here: World:draw slides the whole frame through
|
||||
-- camera.y, so the ground row IS the camera row.
|
||||
bgY = cam.y,
|
||||
width = w, height = h, scale = s,
|
||||
level = Pipelines.level(id),
|
||||
-- imageFor keys its bakes by GbcPalette.mode, so the colour is already in
|
||||
-- the art: nil, like Gen 1 returns in its true-colour modes.
|
||||
paletteFor = function() return nil end,
|
||||
spriteColors = function() return nil end,
|
||||
-- Gold's only standing effects; it has no dust/cutTree/bird/rod overlay,
|
||||
-- and Gen 1's `at` skips a nil body, so those keys are simply absent.
|
||||
fx = {
|
||||
emote = function() self:drawEmote(1, nil) end,
|
||||
heal = function() self:drawHealAnim(1, nil) end,
|
||||
},
|
||||
}
|
||||
-- `project(wx, wy)` -> canvas pixels, nil behind the camera. s = 1 lays the
|
||||
-- closures out in world pixels off the flat foot, the unit Gen 1 uses.
|
||||
ctx.drawFx = function(project, scale)
|
||||
scale = scale or s
|
||||
local function at(fx, fy, body)
|
||||
local sx, sy = project(fx + cam.x, fy + cam.y)
|
||||
if not sx then return end -- behind the camera
|
||||
G.push()
|
||||
G.scale(scale, scale)
|
||||
G.translate(sx / scale - fx, sy / scale - fy)
|
||||
body()
|
||||
G.pop()
|
||||
end
|
||||
self:drawEmote(1, at)
|
||||
self:drawHealAnim(1, at)
|
||||
end
|
||||
local override = Pipelines.drawWorld(id, ctx)
|
||||
-- world post-processes fold in here, so they never touch the text box on top
|
||||
if override then override = Pipelines.worldPresent(override, ctx) end
|
||||
return override
|
||||
end
|
||||
|
||||
-- The perspective quad TILT draws the ground onto. The shader and the
|
||||
-- 4-vertex mesh are the renderer's -- the projection is the same one the Gen 1
|
||||
-- world pass uses, so there is no reason for a second copy of either.
|
||||
@@ -9748,21 +9813,25 @@ function World:tiltMesh()
|
||||
return mesh, shader
|
||||
end
|
||||
|
||||
function World:drawTilted(w, h, s)
|
||||
-- `gw, gh` are the grown capture size from World:draw (Tilt.viewGrowth),
|
||||
-- matching Renderer:worldViewSize. Camera is already followed for that view.
|
||||
function World:drawTilted(w, h, s, gw, gh)
|
||||
local mesh, shader = self:tiltMesh()
|
||||
if not mesh then
|
||||
self:drawWorldBody(s)
|
||||
return
|
||||
end
|
||||
local G = love.graphics
|
||||
gw = gw or w
|
||||
gh = gh or h
|
||||
-- Linear sampling on the tilt canvas softens the shimmer the perspective
|
||||
-- warp would otherwise put on every pixel edge; the flat path keeps nearest.
|
||||
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= w
|
||||
or self.tiltCanvas:getHeight() ~= h then
|
||||
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= gw
|
||||
or self.tiltCanvas:getHeight() ~= gh then
|
||||
if self.tiltCanvas and self.tiltCanvas.release then
|
||||
self.tiltCanvas:release()
|
||||
end
|
||||
self.tiltCanvas = G.newCanvas(w, h)
|
||||
self.tiltCanvas = G.newCanvas(gw, gh)
|
||||
self.tiltCanvas:setFilter("linear", "linear")
|
||||
end
|
||||
|
||||
@@ -9778,11 +9847,14 @@ function World:drawTilted(w, h, s)
|
||||
G.setCanvas(previous)
|
||||
|
||||
mesh:setTexture(self.tiltCanvas)
|
||||
mesh:setVertices(Tilt.meshCorners(w, h))
|
||||
mesh:setVertices(Tilt.meshCorners(gw, gh))
|
||||
G.push()
|
||||
G.translate((w - gw) / 2, (h - gh) / 2)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.setShader(shader)
|
||||
G.draw(mesh)
|
||||
G.setShader()
|
||||
G.pop()
|
||||
|
||||
-- ...and the standing things over it, each translated from its flat foot
|
||||
-- onto that foot's projection. Nothing here is sheared or resized: tilt
|
||||
@@ -9791,10 +9863,10 @@ function World:drawTilted(w, h, s)
|
||||
-- The ground quad carries the flat canvas and nothing else, so a foot
|
||||
-- outside it has no ground under it; drawing it anyway put NPCs from two
|
||||
-- screens away over the border fill, where the map stops being drawn.
|
||||
if not Tilt.onGround(fx, fy, w, h, 32 * s) then return end
|
||||
local sx, sy = Tilt.groundPoint(fx, fy, w, h)
|
||||
if not Tilt.onGround(fx, fy, gw, gh, 32 * s) then return end
|
||||
local sx, sy = Tilt.groundPoint(fx, fy, gw, gh)
|
||||
G.push()
|
||||
G.translate(sx - fx, sy - fy)
|
||||
G.translate(sx - fx + (w - gw) / 2, sy - fy + (h - gh) / 2)
|
||||
body()
|
||||
G.pop()
|
||||
end)
|
||||
@@ -9832,8 +9904,18 @@ function World:draw()
|
||||
end
|
||||
|
||||
local s = self:zoomScale()
|
||||
local vw = math.ceil(w / s)
|
||||
local vh = math.ceil(h / s)
|
||||
-- Decided before sizing the view: a world pipeline wins over tilt, and tilt
|
||||
-- grows the capture the way Renderer:worldViewSize does on Gen 1 so the
|
||||
-- camera, BorderFill and tilt canvas all share one grid.
|
||||
local pipelineId = Pipelines.worldPipeline()
|
||||
local tilt = (not pipelineId) and Tilt.active() and self:tiltMesh() ~= nil
|
||||
local gw, gh = w, h
|
||||
if tilt then
|
||||
local g = Tilt.viewGrowth()
|
||||
gw, gh = math.ceil(w * g), math.ceil(h * g)
|
||||
end
|
||||
local vw = math.ceil(gw / s)
|
||||
local vh = math.ceil(gh / s)
|
||||
if vw % 2 ~= 0 then vw = vw + 1 end
|
||||
if vh % 2 ~= 0 then vh = vh + 1 end
|
||||
if vw ~= self.viewW or vh ~= self.viewH then
|
||||
@@ -9850,12 +9932,18 @@ function World:draw()
|
||||
self.camera.y = self.camera.y + (self.shake.phase or 0)
|
||||
end
|
||||
|
||||
local override = pipelineId and self:drawPipeline(pipelineId, w, h, s) or nil
|
||||
|
||||
-- TILT projects the finished world frame, so with it on the map, people and
|
||||
-- emote go into a canvas first and that canvas is drawn as a perspective
|
||||
-- quad. Everything after -- the encounter pic and the survey HUD -- stays
|
||||
-- flat, the same split the Gen 1 renderer makes.
|
||||
if Tilt.active() and self:tiltMesh() then
|
||||
self:drawTilted(w, h, s)
|
||||
-- flat, the same split the Gen 1 renderer makes; a pipeline's finished image
|
||||
-- lands in exactly the same place.
|
||||
if override then
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(override, 0, 0)
|
||||
elseif tilt then
|
||||
self:drawTilted(w, h, s, gw, gh)
|
||||
else
|
||||
self:drawWorldBody(s)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
-- Gold's world-pipeline seam (World:drawPipeline), the Gen 2 peer of the
|
||||
-- render_pipelines path src/world/OverworldController.lua:4867 gives Gen 1.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_pipeline_shots.lua love .
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-pipeline (default)
|
||||
--
|
||||
-- Registers a pipeline that paints an unmistakable magenta field, switches it
|
||||
-- on, and asserts what the seam is supposed to guarantee: drawWorld owns the
|
||||
-- frame, ctx carries the Gen 1 keys, ctx.drawFx anchors the standing FX,
|
||||
-- worldPresent folds over the result, tilt is forced off, and a declined
|
||||
-- frame falls back to the vanilla 2D draw instead of a blank screen.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-pipeline"
|
||||
local failures = 0
|
||||
|
||||
local function ok(label, condition, detail)
|
||||
if condition then
|
||||
print("[pipeline] ok " .. label)
|
||||
else
|
||||
failures = failures + 1
|
||||
print("[pipeline] FAIL " .. label .. " " .. tostring(detail))
|
||||
end
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
world:setMap("ROUTE_30", 10, 10, "down")
|
||||
U.wait(10)
|
||||
U.shot(game, out .. "/00-vanilla.png")
|
||||
|
||||
-- ---------------------------------------------------------------- record
|
||||
local seen, canvas = {}, nil
|
||||
local decline = false
|
||||
local pipeline = {
|
||||
label = "TESTPIPE",
|
||||
levels = { "OFF", "ON" },
|
||||
drawWorld = function(ctx)
|
||||
seen.ctx = ctx
|
||||
seen.drawWorld = (seen.drawWorld or 0) + 1
|
||||
if decline then return nil end
|
||||
local G = love.graphics
|
||||
local w, h = ctx.width, ctx.height
|
||||
if not canvas or canvas:getWidth() ~= w or canvas:getHeight() ~= h then
|
||||
canvas = G.newCanvas(w, h)
|
||||
end
|
||||
local previous = G.getCanvas()
|
||||
G.push("all")
|
||||
G.origin()
|
||||
G.setCanvas(canvas)
|
||||
G.clear(0.8, 0.1, 0.6, 1)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
for x = 0, w, 32 do G.rectangle("fill", x, 0, 1, h) end
|
||||
for y = 0, h, 32 do G.rectangle("fill", 0, y, w, 1) end
|
||||
-- the standing FX, anchored under this pipeline's own (identity) camera
|
||||
ctx.drawFx(function(wx, wy)
|
||||
return (wx - ctx.cam.x) * ctx.scale, (wy - ctx.cam.y) * ctx.scale
|
||||
end, ctx.scale)
|
||||
G.setCanvas(previous)
|
||||
G.pop()
|
||||
seen.drew = true
|
||||
return canvas
|
||||
end,
|
||||
worldPresent = function(image, ctx)
|
||||
seen.worldPresent = (seen.worldPresent or 0) + 1
|
||||
seen.presentCtx = ctx
|
||||
return image
|
||||
end,
|
||||
}
|
||||
|
||||
-- A fresh table so Pipelines.list()'s identity-keyed memo re-sorts; the
|
||||
-- mod merge hands it a new one for the same reason.
|
||||
game.data.render_pipelines = { testpipe = pipeline }
|
||||
Pipelines.install(game.data)
|
||||
ok("registered", Pipelines.get("testpipe") ~= nil, "not in the registry")
|
||||
|
||||
-- ------------------------------------------------------------ switched on
|
||||
Tilt.setLevel(1)
|
||||
Pipelines.setLevel("testpipe", 1)
|
||||
ok("tilt forced off", Tilt.level == 0, "tilt still " .. tostring(Tilt.level))
|
||||
ok("world pipeline claimed", Pipelines.worldPipeline() == "testpipe",
|
||||
tostring(Pipelines.worldPipeline()))
|
||||
U.wait(5)
|
||||
U.shot(game, out .. "/01-pipeline-on.png")
|
||||
|
||||
ok("drawWorld ran", (seen.drawWorld or 0) > 0, "never called")
|
||||
ok("drawWorld drew", seen.drew == true, "declined every frame")
|
||||
ok("worldPresent ran", (seen.worldPresent or 0) > 0, "never called")
|
||||
|
||||
local ctx = seen.ctx
|
||||
ok("ctx.state is the world", ctx and ctx.state == world, "wrong state")
|
||||
ok("ctx.cam is the camera", ctx and ctx.cam == world.camera, "wrong camera")
|
||||
ok("ctx.scale is zoomScale", ctx and ctx.scale == world:zoomScale(),
|
||||
ctx and tostring(ctx.scale))
|
||||
ok("ctx.bgY is the camera row", ctx and ctx.bgY == world.camera.y,
|
||||
ctx and tostring(ctx.bgY))
|
||||
ok("ctx.vw/vh are the view", ctx and ctx.vw == world.viewW
|
||||
and ctx.vh == world.viewH, ctx and tostring(ctx.vw))
|
||||
ok("ctx.level is the ladder", ctx and ctx.level == 1, ctx and tostring(ctx.level))
|
||||
ok("ctx.width/height are the window",
|
||||
ctx and ctx.width == love.graphics.getWidth()
|
||||
and ctx.height == love.graphics.getHeight(), "mismatch")
|
||||
ok("ctx.paletteFor is nil-valued (art is baked)",
|
||||
ctx and ctx.paletteFor and ctx.paletteFor(world.map) == nil, "returned colours")
|
||||
ok("ctx.spriteColors is nil-valued",
|
||||
ctx and ctx.spriteColors and ctx.spriteColors() == nil, "returned colours")
|
||||
ok("ctx.fx has Gold's two effects",
|
||||
ctx and ctx.fx and type(ctx.fx.emote) == "function"
|
||||
and type(ctx.fx.heal) == "function", "missing fx")
|
||||
ok("ctx.drawFx is callable", ctx and type(ctx.drawFx) == "function", "missing")
|
||||
ok("worldPresent got the same ctx", seen.presentCtx == seen.ctx, "different ctx")
|
||||
|
||||
-- ------------------------------------------------- the FX composite path
|
||||
-- An emote over the player exercises ctx.drawFx end to end: it must be the
|
||||
-- pipeline that composites it, and the vanilla drawPeople must not also.
|
||||
local sheet
|
||||
for _, img in pairs(world.emoteImages or {}) do sheet = img break end
|
||||
if sheet then
|
||||
world.emote = { image = sheet, entity = world.player, left = 240 }
|
||||
end
|
||||
U.wait(4)
|
||||
ok("emote is up", world.emote ~= nil, "no emote sheet loaded")
|
||||
local fxOk = pcall(function()
|
||||
-- the same call the pipeline made, run again outside the guard so a throw
|
||||
-- surfaces here rather than only retiring the pipeline
|
||||
seen.ctx.drawFx(function(wx, wy) return wx, wy end, 1)
|
||||
end)
|
||||
ok("drawFx composites without throwing", fxOk, "threw")
|
||||
U.shot(game, out .. "/02-pipeline-emote.png")
|
||||
|
||||
-- ----------------------------------------------------- a declined frame
|
||||
decline = true
|
||||
U.wait(5)
|
||||
U.shot(game, out .. "/03-pipeline-declined.png")
|
||||
ok("declined frames still call drawWorld", (seen.drawWorld or 0) > 1, "stopped")
|
||||
decline = false
|
||||
|
||||
-- ------------------------------------------------------------ switched off
|
||||
Pipelines.setLevel("testpipe", 0)
|
||||
local before = seen.drawWorld
|
||||
U.wait(5)
|
||||
ok("off means not called", seen.drawWorld == before,
|
||||
"still drawing at level 0")
|
||||
U.shot(game, out .. "/04-pipeline-off.png")
|
||||
|
||||
if failures == 0 then
|
||||
print("[pipeline] PASS")
|
||||
else
|
||||
print("[pipeline] FAILURES: " .. failures)
|
||||
end
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
end
|
||||
@@ -15,7 +15,8 @@ local StateStack = require("src.core.StateStack")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
|
||||
local function makeGame()
|
||||
local function makeGame(kind)
|
||||
kind = kind or "wild"
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "battle-playthrough"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
@@ -29,12 +30,24 @@ local function makeGame()
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}
|
||||
function overworld:captureSave(progress)
|
||||
progress.player.map = self.map.id
|
||||
progress.player.x = self.player.cellX
|
||||
progress.player.y = self.player.cellY
|
||||
progress.player.facing = self.player.facing
|
||||
end
|
||||
local game = { data = Data, save = save, stack = stack, overworld = overworld }
|
||||
stack.states[1] = overworld
|
||||
local battle = BattleState.newWild(game, "FIXMON_B", 12)
|
||||
local battle = kind == "trainer"
|
||||
and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
or BattleState.newWild(game, "FIXMON_B", 12)
|
||||
battle.phase = "menu"
|
||||
battle.queue = {}
|
||||
battle.checkpointOrigin = { kind = "wild_encounter" }
|
||||
battle.checkpointOrigin = kind == "trainer"
|
||||
and { kind = "trainer_encounter", map = save.player.map,
|
||||
npcId = "TRAINER_1", trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
event = "EVENT_BEAT_TRAINER_1" }
|
||||
or { kind = "wild_encounter" }
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return game, overworld, battle
|
||||
@@ -45,6 +58,42 @@ T.same(Checkpoint.inspect(game), {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "settled standard wild battle is a checkpoint boundary")
|
||||
|
||||
local function settleRealBattle(kind)
|
||||
local liveGame, _, liveBattle = makeGame(kind)
|
||||
liveBattle.phase, liveBattle.queue = nil, {}
|
||||
liveGame.input = {
|
||||
wasPressed = function(_, button) return button == "a" end,
|
||||
isDown = function(_, button) return button == "a" end,
|
||||
}
|
||||
liveBattle:enter()
|
||||
local frames = 0
|
||||
while liveBattle.phase ~= "menu" and frames < 10000 do
|
||||
frames = frames + 1
|
||||
liveBattle:update(1 / 60)
|
||||
end
|
||||
T.eq(liveBattle.phase, "menu", "the real battle intro reaches its command menu")
|
||||
return liveGame
|
||||
end
|
||||
|
||||
local realGame = settleRealBattle("wild")
|
||||
T.same(Checkpoint.inspect(realGame), {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "the completed real battle intro is a checkpoint boundary")
|
||||
local oldGetRandomState, oldSetRandomState =
|
||||
love.math.getRandomState, love.math.setRandomState
|
||||
love.math.getRandomState = function() return "real-boundary-rng" end
|
||||
love.math.setRandomState = function() end
|
||||
local realSnapshot, realCaptureCode = Checkpoint.capture(realGame)
|
||||
T.check(type(realSnapshot) == "table" and realSnapshot.kind == "battle",
|
||||
"the first real command decision captures for deferred tools: "
|
||||
.. tostring(realCaptureCode))
|
||||
love.math.getRandomState, love.math.setRandomState =
|
||||
oldGetRandomState, oldSetRandomState
|
||||
local realTrainerGame = settleRealBattle("trainer")
|
||||
T.same(Checkpoint.inspect(realTrainerGame), {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "the completed real trainer intro is a checkpoint boundary")
|
||||
|
||||
local function refused(mutator, code, label)
|
||||
local game2, ow2, battle2 = makeGame()
|
||||
mutator(game2, ow2, battle2)
|
||||
@@ -64,7 +113,7 @@ refused(function(_, _, b) b.enemy.mon.hp = b.enemy.mon.hp - 1 end,
|
||||
refused(function(_, _, b) b.player.mustRecharge = true end,
|
||||
"battle_phase_busy", "automatic locked action is rejected")
|
||||
refused(function(_, ow) ow.runner = { isRunning = function() return true end } end,
|
||||
"script_busy", "suspended script beneath battle is rejected")
|
||||
"script_busy", "unknown suspended script beneath battle is rejected")
|
||||
refused(function(_, _, b) b.checkpointOrigin = nil end,
|
||||
"battle_origin_unsupported", "unknown completion closure is rejected")
|
||||
refused(function(_, _, b) b.safari = { balls = 30, steps = 10 } end,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local DateTime = require("src.core.DateTime")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local stamp = os.time({ year = 2026, month = 8, day = 11, hour = 17, min = 5, sec = 0 })
|
||||
|
||||
local defaults = SaveData.defaultOptions()
|
||||
T.eq(defaults.dateFormat, "device", "date format defaults to device locale")
|
||||
T.eq(defaults.timeFormat, "device", "time format defaults to device locale")
|
||||
|
||||
local game = { save = { options = { dateFormat = "dmy", timeFormat = "24h" } } }
|
||||
T.eq(DateTime.date(game, stamp), os.date("%d-%m-%Y", stamp),
|
||||
"explicit DMY uses day-month-year")
|
||||
T.eq(DateTime.time(game, stamp), os.date("%H:%M", stamp),
|
||||
"explicit 24-hour time omits seconds")
|
||||
T.eq(DateTime.dateTime(game, stamp),
|
||||
os.date("%d-%m-%Y %H:%M", stamp),
|
||||
"combined formatter composes exact date and time preferences")
|
||||
|
||||
game.save.options.dateFormat = "mdy"
|
||||
T.eq(DateTime.date(game, stamp), os.date("%m-%d-%Y", stamp),
|
||||
"explicit MDY is available")
|
||||
game.save.options.dateFormat = "ymd"
|
||||
T.eq(DateTime.date(game, stamp), os.date("%Y-%m-%d", stamp),
|
||||
"explicit YMD is available")
|
||||
game.save.options.timeFormat = "12h"
|
||||
T.eq(DateTime.time(game, stamp), os.date("%I:%M %p", stamp),
|
||||
"explicit 12-hour time is available")
|
||||
|
||||
local fallback = DateTime.formatWithLocale(stamp, "device", "device", "C")
|
||||
T.eq(fallback.date, os.date("%d-%m-%Y", stamp),
|
||||
"missing device locale falls back to requested DMY")
|
||||
T.eq(fallback.time, os.date("%H:%M", stamp),
|
||||
"missing device locale falls back to requested 24-hour time")
|
||||
|
||||
local invalid = DateTime.date({}, -1)
|
||||
T.eq(invalid, "----", "invalid timestamps fail closed")
|
||||
|
||||
T.finish("date_time")
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Commands.pushBattle (src/script/Commands.lua): the shared entry point
|
||||
-- for start_battle, old_man_demo, and the PALLET_TOWN Pikachu catch
|
||||
-- (data/scripts/story2.lua). Every one of those call sites used to carry
|
||||
-- its own copy of "if ctx.overworld.pushBattle then ... else
|
||||
-- ctx.game.stack:push(battle) end"; this locks the dedup in so a future
|
||||
-- edit to one call site can't silently drop the transition wipe for the
|
||||
-- others.
|
||||
-- luajit tests/engine/push_battle_transition.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
local eq = T.eq
|
||||
|
||||
local Commands = require("src.script.Commands")
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local battle = { id = "the battle" }
|
||||
|
||||
-- Runs fn() with Logger.warn spied instead of hitting the real
|
||||
-- print()/Logger.history ring buffer, and returns the last formatted
|
||||
-- warning (or nil if none). pcall-wrapped so an error inside fn() still
|
||||
-- restores Logger.warn before propagating -- a leaked spy would swallow
|
||||
-- every later warning in the same process silently.
|
||||
local function withWarnSpy(fn)
|
||||
local warned
|
||||
local origWarn = Logger.warn
|
||||
Logger.warn = function(fmt, ...) warned = string.format(fmt, ...) end
|
||||
local ok, err = pcall(fn)
|
||||
Logger.warn = origWarn
|
||||
if not ok then error(err, 0) end
|
||||
return warned
|
||||
end
|
||||
|
||||
-- ctx.overworld has a real pushBattle: it must be used, not a bare stack
|
||||
-- push, so the flash/wipe transition and the battle-theme start survive.
|
||||
do
|
||||
local pushed
|
||||
local ow = { pushBattle = function(self, b) pushed = b end }
|
||||
local stackPushed
|
||||
local ctx = { overworld = ow, game = { stack = {
|
||||
push = function(_, b) stackPushed = b end } } }
|
||||
Commands.pushBattle(ctx, battle)
|
||||
eq(pushed, battle, "ctx.overworld:pushBattle is called with the battle")
|
||||
check(stackPushed == nil, "the bare stack push is not also taken")
|
||||
end
|
||||
|
||||
-- ctx.overworld without a pushBattle method (a partial test double, per
|
||||
-- BattleState:finish's "no live children" contract) falls back to a
|
||||
-- bare stack push and logs, rather than silently skipping the transition.
|
||||
do
|
||||
local stackPushed
|
||||
local ctx = { overworld = {}, game = { stack = {
|
||||
push = function(_, b) stackPushed = b end } } }
|
||||
local warned = withWarnSpy(function() Commands.pushBattle(ctx, battle) end)
|
||||
eq(stackPushed, battle, "falls back to ctx.game.stack:push")
|
||||
check(warned ~= nil, "the fallback logs a warning")
|
||||
check(warned and warned:find("pushBattle") ~= nil,
|
||||
"the warning names pushBattle")
|
||||
end
|
||||
|
||||
-- ctx.overworld absent entirely (headless script tests that never set
|
||||
-- one up): same fallback, no crash on the ctx.overworld.pushBattle read
|
||||
-- -- and still spied, since this also takes the warning path.
|
||||
do
|
||||
local stackPushed
|
||||
local ctx = { game = { stack = {
|
||||
push = function(_, b) stackPushed = b end } } }
|
||||
local warned = withWarnSpy(function() Commands.pushBattle(ctx, battle) end)
|
||||
eq(stackPushed, battle, "falls back to ctx.game.stack:push with no overworld")
|
||||
check(warned ~= nil, "this fallback also logs a warning")
|
||||
end
|
||||
|
||||
T.finish("push_battle_transition")
|
||||
@@ -0,0 +1,178 @@
|
||||
-- Scripted story battles checkpoint a semantic row-list continuation. The
|
||||
-- suspended Lua coroutine is deliberately never serialized.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local oldGetRandomState = love.math.getRandomState
|
||||
local oldSetRandomState = love.math.setRandomState
|
||||
local checkpointRng = "scripted-battle-rng"
|
||||
love.math.getRandomState = function() return checkpointRng end
|
||||
love.math.setRandomState = function(state) checkpointRng = state end
|
||||
|
||||
local T = require("tests.harness").suite("scripted battle checkpoints")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Checkpoint = require("src.core.Checkpoint")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local GameMethods = require("src.core.Game")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
|
||||
local function makeGame()
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "script-battle-playthrough"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
SaveData.validate(save, Data)
|
||||
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
|
||||
save.player.facing, save.player.surfing = "left", false
|
||||
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local game
|
||||
local ow = setmetatable({
|
||||
map = { id = "FIX_TOWN" },
|
||||
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}, { __index = OverworldState })
|
||||
function ow:captureSave(target)
|
||||
target.player.map = self.map.id
|
||||
target.player.x, target.player.y = self.player.cellX, self.player.cellY
|
||||
target.player.facing, target.player.surfing = self.player.facing, false
|
||||
end
|
||||
function ow:pushBattle(battle) game.stack:push(battle) end
|
||||
function ow:afterBattle(result, battle)
|
||||
self.after = { result = result, battle = battle }
|
||||
end
|
||||
game = setmetatable({ data = Data, save = save, stack = stack, overworld = ow },
|
||||
{ __index = GameMethods })
|
||||
function game:restoreCheckpointSave(loaded)
|
||||
self.save = loaded
|
||||
ow.map = { id = loaded.player.map }
|
||||
ow.player = {
|
||||
cellX = loaded.player.x, cellY = loaded.player.y,
|
||||
facing = loaded.player.facing, surfing = loaded.player.surfing and true or false,
|
||||
}
|
||||
ow.parallelRunners, ow.pendingScripts, ow.parallelQueue, ow.scriptMoves = {}, {}, {}, {}
|
||||
ow.runner = ScriptRunner.new(self, ow)
|
||||
self.stack.states = { ow }
|
||||
end
|
||||
stack.states[1] = ow
|
||||
ow.runner = ScriptRunner.new(game, ow)
|
||||
return game, ow
|
||||
end
|
||||
|
||||
local rows = {
|
||||
{ "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 },
|
||||
{ "set_flag", "EVENT_STORY_CONTINUED" },
|
||||
}
|
||||
|
||||
local game, ow = makeGame()
|
||||
ow.runner:run(rows)
|
||||
local battle = game.stack:top()
|
||||
T.check(getmetatable(battle) == BattleState,
|
||||
"script command starts the fixture trainer battle")
|
||||
T.check(type(battle.checkpointOrigin) == "table"
|
||||
and battle.checkpointOrigin.kind == "script_battle",
|
||||
"script battle owns a semantic checkpoint continuation")
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.afterQueue, battle.introSlide = nil, nil
|
||||
battle.player.shownHP, battle.player.shownStatus =
|
||||
battle.player.mon.hp, battle.player.mon.status
|
||||
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||
battle.enemy.mon.hp, battle.enemy.mon.status
|
||||
local scriptedCapability = Checkpoint.inspect(game)
|
||||
T.same(scriptedCapability, {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "settled scripted trainer decision is checkpoint-safe: "
|
||||
.. tostring(scriptedCapability.reason))
|
||||
|
||||
local origin = battle.checkpointOrigin
|
||||
T.check(type(origin.script) == "table" and origin.pc == 1,
|
||||
"continuation records detached rows and the command program counter")
|
||||
T.eq(origin.resumeCoroutine, nil,
|
||||
"continuation never exposes a coroutine or Lua execution stack")
|
||||
|
||||
local snapshot, captureCode, captureMessage = Checkpoint.capture(game)
|
||||
T.check(snapshot and snapshot.kind == "battle",
|
||||
"scripted battle captures through the generic checkpoint API: "
|
||||
.. tostring(captureCode or captureMessage))
|
||||
if snapshot then
|
||||
game.save.money = 1
|
||||
local restored, restoreCode, restoreMessage = Checkpoint.restore(game, snapshot)
|
||||
T.check(restored == true,
|
||||
"scripted battle reconstructs through the generic checkpoint API: "
|
||||
.. tostring(restoreCode or restoreMessage))
|
||||
local rebuilt = game.stack:top()
|
||||
T.check(rebuilt ~= battle and getmetatable(rebuilt) == BattleState,
|
||||
"scripted restore creates a fresh battle controller")
|
||||
T.same(Checkpoint.capture(game), snapshot,
|
||||
"scripted battle capture/restore/capture is a differential roundtrip")
|
||||
end
|
||||
|
||||
-- Rebind the continuation on a freshly reconstructed overworld. Completing
|
||||
-- the battle must replay the current command as an already-completed battle,
|
||||
-- then execute the remaining story rows exactly once.
|
||||
local restoredGame, restoredOw = makeGame()
|
||||
local restoredBattle = BattleState.newTrainer(restoredGame,
|
||||
"OPP_FIX_YOUNGSTER", 1)
|
||||
restoredBattle.checkpointOrigin = origin
|
||||
T.check(restoredOw:restoreBattleContinuation(restoredBattle, origin) == true,
|
||||
"script continuation reconstructs without the old runner")
|
||||
restoredBattle.onFinish("win")
|
||||
T.check(restoredGame.save.flags.EVENT_STORY_CONTINUED == true,
|
||||
"restored battle resumes subsequent story progress")
|
||||
T.same(restoredOw.after, { result = "win", battle = restoredBattle },
|
||||
"restored script battle uses the canonical afterBattle path")
|
||||
T.check(not restoredOw.runner:isRunning(),
|
||||
"reconstructed continuation completes without a suspended runner")
|
||||
|
||||
-- Unsafe rows and opaque completion callbacks must stay fail-closed.
|
||||
local unsafeGame, unsafeOw = makeGame()
|
||||
local unsafeRows = {
|
||||
{ "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 },
|
||||
}
|
||||
unsafeRows.opaque = function() end
|
||||
unsafeOw.runner:run(unsafeRows)
|
||||
local unsafeBattle = unsafeGame.stack:top()
|
||||
unsafeBattle.phase, unsafeBattle.queue = "menu", {}
|
||||
T.eq(unsafeBattle.checkpointOrigin, nil,
|
||||
"non-data-only script arguments do not create a continuation")
|
||||
T.eq(Checkpoint.inspect(unsafeGame).reason, "battle_origin_unsupported",
|
||||
"non-data-only scripted battle remains unavailable")
|
||||
|
||||
local callbackGame, callbackOw = makeGame()
|
||||
callbackOw.runner:run(rows, { onDone = function() end })
|
||||
local callbackBattle = callbackGame.stack:top()
|
||||
callbackBattle.phase, callbackBattle.queue = "menu", {}
|
||||
T.eq(callbackBattle.checkpointOrigin, nil,
|
||||
"opaque script completion callbacks are not guessed")
|
||||
T.eq(Checkpoint.inspect(callbackGame).reason, "battle_origin_unsupported",
|
||||
"opaque scripted completion remains fail-closed")
|
||||
|
||||
local rivalGame, rivalOw = makeGame()
|
||||
local rivalRows = {
|
||||
{ "rival_battle", "OPP_FIX_YOUNGSTER", 1 },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "set_flag", "EVENT_RIVAL_STORY_CONTINUED" },
|
||||
}
|
||||
rivalOw.runner:run(rivalRows)
|
||||
local rivalBattle = rivalGame.stack:top()
|
||||
local rivalOrigin = rivalBattle.checkpointOrigin
|
||||
T.check(rivalOrigin and rivalOrigin.command == "rival_battle" and rivalOrigin.pc == 1,
|
||||
"wrapper battle records the wrapper command rather than skipping its tail")
|
||||
local rivalResumeGame, rivalResumeOw = makeGame()
|
||||
local rivalRestored = BattleState.newTrainer(rivalResumeGame,
|
||||
"OPP_FIX_YOUNGSTER", 1)
|
||||
T.check(rivalResumeOw:restoreBattleContinuation(rivalRestored, rivalOrigin) == true,
|
||||
"rival wrapper continuation reconstructs")
|
||||
rivalRestored.onFinish("win")
|
||||
T.check(rivalResumeGame.save.flags.EVENT_RIVAL_STORY_CONTINUED == true,
|
||||
"rival wrapper and following branch execute exactly once after restore")
|
||||
|
||||
love.math.getRandomState = oldGetRandomState
|
||||
love.math.setRandomState = oldSetRandomState
|
||||
T.finish()
|
||||
@@ -0,0 +1,155 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local phase, root = arg[1], arg[2]
|
||||
assert(phase == "capture" or phase == "resume", "phase must be capture or resume")
|
||||
assert(type(root) == "string" and root ~= "", "test needs a persistence root")
|
||||
|
||||
local function quote(value)
|
||||
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function full(path) return root .. "/" .. path end
|
||||
|
||||
local fs = {}
|
||||
function fs.createDirectory(path)
|
||||
return os.execute("mkdir -p " .. quote(full(path))) == 0
|
||||
end
|
||||
function fs.write(path, body)
|
||||
local parent = path:match("^(.*)/[^/]+$")
|
||||
if parent then assert(fs.createDirectory(parent)) end
|
||||
local handle = assert(io.open(full(path), "wb"))
|
||||
handle:write(body)
|
||||
handle:close()
|
||||
return true
|
||||
end
|
||||
function fs.read(path)
|
||||
local handle = io.open(full(path), "rb")
|
||||
if not handle then return nil end
|
||||
local body = handle:read("*a")
|
||||
handle:close()
|
||||
return body
|
||||
end
|
||||
function fs.remove(path)
|
||||
os.remove(full(path))
|
||||
return true
|
||||
end
|
||||
function fs.getInfo(path)
|
||||
if os.execute("test -d " .. quote(full(path))) == 0 then
|
||||
return { type = "directory" }
|
||||
end
|
||||
local handle = io.open(full(path), "rb")
|
||||
if handle then handle:close(); return { type = "file" } end
|
||||
return nil
|
||||
end
|
||||
function fs.load(path)
|
||||
local body = fs.read(path)
|
||||
if not body then return nil, "no file: " .. path end
|
||||
return load(body, "@" .. path)
|
||||
end
|
||||
function fs.getDirectoryItems(path)
|
||||
local items = {}
|
||||
local pipe = io.popen("find " .. quote(full(path))
|
||||
.. " -mindepth 1 -maxdepth 1 -printf '%f\\n' 2>/dev/null")
|
||||
if pipe then
|
||||
for item in pipe:lines() do items[#items + 1] = item end
|
||||
pipe:close()
|
||||
end
|
||||
table.sort(items)
|
||||
return items
|
||||
end
|
||||
function fs.getSaveDirectory() return root end
|
||||
|
||||
love = require("tests.love_stub")
|
||||
love.filesystem = fs
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local function writeProbe()
|
||||
fs.write("mods/cold_start_probe/manifest.json",
|
||||
'{"id":"cold_start_probe","name":"cold start probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","api":2,"profile":"content"}')
|
||||
fs.write("mods/cold_start_probe/main.lua", [[
|
||||
return function(mod)
|
||||
_G.COLD_STORAGE = mod.storage
|
||||
_G.COLD_CHECKPOINTS = mod.checkpoints
|
||||
end
|
||||
]])
|
||||
end
|
||||
|
||||
local function runtime(save, title)
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local game
|
||||
local overworld = {
|
||||
map = { id = "PALLET_TOWN" },
|
||||
player = { cellX = 3, cellY = 6, facing = "down", surfing = false },
|
||||
scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {},
|
||||
runner = { isRunning = function() return false end },
|
||||
}
|
||||
function overworld:captureSave(target)
|
||||
target.player.map, target.player.x, target.player.y = self.map.id,
|
||||
self.player.cellX, self.player.cellY
|
||||
target.player.facing, target.player.surfing = self.player.facing,
|
||||
self.player.surfing and true or false
|
||||
end
|
||||
function overworld:enter(mapId, x, y, facing)
|
||||
self.map = { id = mapId }
|
||||
self.player = { cellX = x, cellY = y, facing = facing,
|
||||
surfing = game.save.player.surfing and true or false }
|
||||
self.scriptMoves, self.pendingScripts = {}, {}
|
||||
self.parallelRunners, self.parallelQueue = {}, {}
|
||||
self.runner = { isRunning = function() return false end }
|
||||
end
|
||||
game = setmetatable({
|
||||
save = save, stack = stack, overworld = overworld,
|
||||
data = {
|
||||
pokemon = {}, moves = { TACKLE = { pp = 35 } }, items = { POTION = {} },
|
||||
constants = { fallbackMove = "TACKLE" },
|
||||
field = { boot = { startMap = "PALLET_TOWN", startX = 3, startY = 6 } },
|
||||
maps = { PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 } },
|
||||
},
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = title and { screenId = "TitleState" } or overworld
|
||||
if title then function game:makeTitleState() return { screenId = "TitleState" } end end
|
||||
return game
|
||||
end
|
||||
|
||||
writeProbe()
|
||||
SaveData.resetSlotState()
|
||||
local loader = Loader.new({ fs = fs })
|
||||
|
||||
if phase == "capture" then
|
||||
local game = runtime(SaveData.newGame({ version = "red" }), false)
|
||||
loader.game = game
|
||||
assert(loader:load({}) == true)
|
||||
assert(_G.COLD_STORAGE:write(game, "history/index", { newest = "q0001" }))
|
||||
local checkpoint = assert(_G.COLD_CHECKPOINTS:capture(game))
|
||||
assert(_G.COLD_STORAGE:write(game, "history/q0001", checkpoint))
|
||||
local id = assert(game.save.meta.playthroughId)
|
||||
assert(_G.COLD_CHECKPOINTS:ensureNormalSave(game, checkpoint))
|
||||
local normal = assert(SaveData.load("red"))
|
||||
assert(normal.meta.playthroughId == id)
|
||||
fs.write("cold-start-witness.lua", SaveSerializer.encode({ playthroughId = id }))
|
||||
print("cold-start capture persisted")
|
||||
else
|
||||
local title = runtime(SaveData.newGame({ version = "red" }), true)
|
||||
title.save.options = { volume = 7, bindings = {} }
|
||||
loader.game = title
|
||||
assert(loader:load({}) == true)
|
||||
local selected = assert(_G.COLD_STORAGE:selected(title))
|
||||
local witness = assert(SaveSerializer.decode(assert(fs.read("cold-start-witness.lua"))))
|
||||
assert(selected:context().playthroughId == witness.playthroughId)
|
||||
assert(selected:read("history/index").newest == "q0001")
|
||||
local checkpoint = assert(selected:read("history/q0001"))
|
||||
assert(_G.COLD_CHECKPOINTS:resume(title, checkpoint))
|
||||
assert(title.save.meta.playthroughId == witness.playthroughId)
|
||||
assert(title.save.options.volume == 7)
|
||||
assert(SaveSerializer.encode(_G.COLD_CHECKPOINTS:capture(title))
|
||||
== SaveSerializer.encode(checkpoint))
|
||||
local normal = assert(SaveData.load("red"))
|
||||
assert(normal.meta.playthroughId == witness.playthroughId)
|
||||
print("cold-start resume reconstructed")
|
||||
end
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
test_root="$(mktemp -d)"
|
||||
trap 'rm -rf "$test_root"' EXIT
|
||||
|
||||
"${LUA:-luajit}" tests/integration/title_checkpoint_cold_start.lua capture "$test_root"
|
||||
"${LUA:-luajit}" tests/integration/title_checkpoint_cold_start.lua resume "$test_root"
|
||||
|
||||
@@ -303,6 +303,109 @@ do
|
||||
"with the env var unset, the saved disable is left alone")
|
||||
end
|
||||
|
||||
-- ------- runtime option schema export
|
||||
-- The optional native-launcher contract is written only after enabled mods
|
||||
-- have successfully run, and stale snapshots are cleared when the load set
|
||||
-- no longer contains schema-bearing mods.
|
||||
do
|
||||
local Json = require("src.link.Json")
|
||||
local schemaFiles = {
|
||||
["options.lua"] = "return { mods = { quiet = false } }",
|
||||
["mods/loud/manifest.json"] = manifestJson("loud"),
|
||||
["mods/loud/main.lua"] = [[
|
||||
return function(mod)
|
||||
mod.options:define({
|
||||
{ key = "hardcore", type = "toggle", label = "Hardcore", default = false },
|
||||
{ key = "difficulty", type = "choice", label = "Difficulty", default = "normal",
|
||||
choices = { { "Easy", "easy" }, { "Normal", "normal" } } },
|
||||
{ key = "rate", type = "number", label = "Rate", default = 10,
|
||||
min = 0, max = 100, step = 5 },
|
||||
{ key = "nickname", type = "text", label = "Nickname", default = "", maxLen = 7 },
|
||||
})
|
||||
end
|
||||
]],
|
||||
["mods/quiet/manifest.json"] = manifestJson("quiet"),
|
||||
["mods/quiet/main.lua"] = [[
|
||||
return function(mod)
|
||||
mod.options:define({ { key = "shh", type = "toggle", default = true } })
|
||||
end
|
||||
]],
|
||||
["mods/legacy/manifest.json"] = [[
|
||||
{"id":"legacy","name":"legacy","version":"1.0.0","entry":"main.lua",
|
||||
"options_schema":"options.lua"}
|
||||
]],
|
||||
["mods/legacy/main.lua"] = "return function(mod) end",
|
||||
["mods/legacy/options.lua"] = [[
|
||||
return {
|
||||
{ key = "legacy_toggle", type = "toggle", label = "Legacy", default = true },
|
||||
}
|
||||
]],
|
||||
}
|
||||
local writes = {}
|
||||
local fs = memfs(schemaFiles)
|
||||
fs.write = function(path, contents)
|
||||
writes[path] = contents
|
||||
schemaFiles[path] = contents
|
||||
return true
|
||||
end
|
||||
|
||||
local loader = Loader.new({ fs = fs })
|
||||
check(loader:load({ pokemon = {} }) == true,
|
||||
"schema export fixture boots clean")
|
||||
local decoded = writes["mod_option_schemas.json"]
|
||||
and Json.decode(writes["mod_option_schemas.json"])
|
||||
check(decoded and decoded.schema_version == 1,
|
||||
"schema export has an explicit version")
|
||||
check(decoded and decoded.mods and decoded.mods.loud ~= nil,
|
||||
"enabled mod schema is exported")
|
||||
check(decoded and decoded.mods and decoded.mods.quiet == nil,
|
||||
"disabled mod schema is not exported")
|
||||
check(decoded and decoded.mods and decoded.mods.legacy
|
||||
and decoded.mods.legacy[1].key == "legacy_toggle",
|
||||
"manifest options_schema is exported")
|
||||
local rows = decoded and decoded.mods.loud or {}
|
||||
local byKey = {}
|
||||
for _, row in ipairs(rows) do byKey[row.key] = row end
|
||||
check(byKey.hardcore and byKey.hardcore.type == "toggle",
|
||||
"toggle row round-trips")
|
||||
check(byKey.difficulty and byKey.difficulty.choices
|
||||
and byKey.difficulty.choices[1][1] == "Easy"
|
||||
and byKey.difficulty.choices[1][2] == "easy",
|
||||
"choice row round-trips")
|
||||
check(byKey.rate and byKey.rate.min == 0 and byKey.rate.max == 100
|
||||
and byKey.rate.step == 5, "number bounds round-trip")
|
||||
check(byKey.nickname and byKey.nickname.maxLen == 7,
|
||||
"text length round-trips")
|
||||
|
||||
local readOnlyLoader = Loader.new({ fs = memfs(schemaFiles) })
|
||||
check(readOnlyLoader:load({ pokemon = {} }) == true,
|
||||
"read-only filesystems tolerate schema export")
|
||||
|
||||
-- A schema captured before an entry failure is rolled back and must not
|
||||
-- leak into the native snapshot.
|
||||
schemaFiles["mods/broken/manifest.json"] = manifestJson("broken")
|
||||
schemaFiles["mods/broken/main.lua"] = [[
|
||||
return function(mod)
|
||||
mod.options:define({ { key = "ghost", type = "toggle", default = true } })
|
||||
error("broken entry")
|
||||
end
|
||||
]]
|
||||
local failedLoader = Loader.new({ fs = fs })
|
||||
check(failedLoader:load({ pokemon = {} }) == false,
|
||||
"a failing entry is reported")
|
||||
local afterFailure = Json.decode(writes["mod_option_schemas.json"])
|
||||
check(afterFailure and afterFailure.mods and afterFailure.mods.broken == nil,
|
||||
"a failed mod schema is not exported")
|
||||
|
||||
loader:setEnabled("loud", false)
|
||||
loader:setEnabled("legacy", false)
|
||||
loader:_writeOptionSchemas()
|
||||
local cleared = Json.decode(writes["mod_option_schemas.json"])
|
||||
check(cleared and cleared.schema_version == 1 and next(cleared.mods) == nil,
|
||||
"disabling the only schema-bearing mod clears the snapshot")
|
||||
|
||||
end
|
||||
|
||||
-- leave shared singletons the way we found them for later chained tests
|
||||
local StateStack = require("src.core.StateStack")
|
||||
while StateStack:top() do StateStack:pop() end
|
||||
|
||||
@@ -193,6 +193,26 @@ do
|
||||
"battle status HUD returns when the hook is removed")
|
||||
end
|
||||
|
||||
-- ------- battle.caught_marker_visible (caught wild marker)
|
||||
|
||||
do
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local state = { kind = "wild", enemy = { mon = { species = "RATTATA" } },
|
||||
game = { save = { pokedex = { owned = { RATTATA = true } } } } }
|
||||
check(not BattleState.caughtMarkerVisible(state),
|
||||
"caught marker is opt-in")
|
||||
local unsub = wrap("battle.caught_marker_visible", function() return true end)
|
||||
check(BattleState.caughtMarkerVisible(state),
|
||||
"a mod can show a caught wild marker")
|
||||
state.kind = "trainer"
|
||||
check(not BattleState.caughtMarkerVisible(state),
|
||||
"trainer battles never show a caught marker")
|
||||
state.kind, state.game.save.pokedex.owned.RATTATA = "wild", false
|
||||
check(not BattleState.caughtMarkerVisible(state),
|
||||
"uncaught wild Pokemon have no marker")
|
||||
unsub()
|
||||
end
|
||||
|
||||
-- ------- grid navigation ownership (alternate menu renderers)
|
||||
|
||||
do
|
||||
|
||||
+18
-1
@@ -288,7 +288,7 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
|
||||
"tilt", "gbcfx", "zoom", "voidFill", "videoMode",
|
||||
"faithfulRes", "fpsCap",
|
||||
"speedOverworld", "speedBattle", "speedMenu",
|
||||
"mods", "controls" }
|
||||
"mods", "controls", "dateFormat", "timeFormat" }
|
||||
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
||||
for i, id in ipairs(WANT_IDS) do
|
||||
check(om.rows[i].id == id, "options row order: " .. id)
|
||||
@@ -408,6 +408,23 @@ check(bm.items[1].label == "UP" and bm.items[1].right == "UP/D-UP"
|
||||
"with no rebind the rows mirror the fixed map, key and pad both (#589)")
|
||||
check(cbGame.save.options.bindings == nil,
|
||||
"opening the screen alone writes nothing")
|
||||
|
||||
-- shared date/time presentation stays in options.lua and is available to
|
||||
-- engine UI and mods without becoming checkpoint progress
|
||||
om.game.save.options.dateFormat = "device"
|
||||
om.game.save.options.timeFormat = "device"
|
||||
check(om.rows[26].value(om.game) == "DEVICE",
|
||||
"DATE FORMAT defaults to device locale")
|
||||
om.rows[26].step(om.game, 1)
|
||||
check(om.game.save.options.dateFormat == "dmy"
|
||||
and om.rows[26].value(om.game) == "DD-MM-YYYY",
|
||||
"DATE FORMAT exposes deterministic DMY override")
|
||||
check(om.rows[27].value(om.game) == "DEVICE",
|
||||
"TIME FORMAT defaults to device locale")
|
||||
om.rows[27].step(om.game, 1)
|
||||
check(om.game.save.options.timeFormat == "24h"
|
||||
and om.rows[27].value(om.game) == "24 HOUR",
|
||||
"TIME FORMAT exposes deterministic 24-hour override")
|
||||
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
|
||||
"no raw-input claim until a capture is armed")
|
||||
press(bm, "a")
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local FIXTURE = {
|
||||
["mods/date_probe/manifest.json"] = [[{
|
||||
"id": "date_probe",
|
||||
"name": "Date Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/date_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.datetime = mod.datetime
|
||||
]],
|
||||
}
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/date_probe" }, { fs = T.sdk.memfs(FIXTURE) })
|
||||
T.eq(#run.errors, 0, "fixture mod loads cleanly")
|
||||
local datetime = run.loader.exports.date_probe.datetime
|
||||
T.eq(type(datetime), "table", "mod object exposes datetime formatting")
|
||||
T.eq(type(datetime.date), "function", "public formatter exposes date")
|
||||
T.eq(type(datetime.time), "function", "public formatter exposes time")
|
||||
T.eq(type(datetime.dateTime), "function", "public formatter exposes date-time")
|
||||
|
||||
local stamp = os.time({ year = 2026, month = 8, day = 11, hour = 17, min = 5, sec = 0 })
|
||||
local game = { save = { options = { dateFormat = "dmy", timeFormat = "24h" } } }
|
||||
T.eq(datetime:date(game, stamp), os.date("%d-%m-%Y", stamp),
|
||||
"mod date follows current global engine preference")
|
||||
T.eq(datetime:time(game, stamp), os.date("%H:%M", stamp),
|
||||
"mod time follows current global engine preference")
|
||||
T.eq(datetime:dateTime(game, stamp), os.date("%d-%m-%Y %H:%M", stamp),
|
||||
"mod date-time composes the same preference")
|
||||
|
||||
run.release()
|
||||
T.finish("date_time")
|
||||
@@ -0,0 +1,301 @@
|
||||
-- A tool can persist a checkpoint before the first normal Pokémon save. After
|
||||
-- a restart the title runtime is deliberately a fresh save skeleton, so it
|
||||
-- needs a non-allocating binding to the already-selected playthrough -- not a
|
||||
-- call to the normal active-playthrough storage methods, which would mint an id.
|
||||
--
|
||||
-- This is a public SDK contract test. The fixture never reaches into storage
|
||||
-- paths or launcher slot internals.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("mod title playthrough context")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local Version = require("src.core.Version")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
local realFs = love.filesystem
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
read = function(path) return files[path] end,
|
||||
write = function(path, body) files[path] = body return true end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
createDirectory = function() return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
load = function(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end,
|
||||
getDirectoryItems = function(path)
|
||||
local prefix, seen, out = path .. "/", {}, {}
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then seen[child] = true; out[#out + 1] = child end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local files = {
|
||||
["mods/probe/manifest.json"] =
|
||||
'{"id":"probe","name":"probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","api":2,"profile":"content"}',
|
||||
["mods/probe/main.lua"] = [[
|
||||
return function(mod)
|
||||
_G.MOD_TITLE_STORAGE = mod.storage
|
||||
_G.MOD_TITLE_CHECKPOINTS = mod.checkpoints
|
||||
mod.events:on("checkpoint.restored", function(ev)
|
||||
_G.MOD_TITLE_RESTORE_COUNT = (_G.MOD_TITLE_RESTORE_COUNT or 0) + 1
|
||||
_G.MOD_TITLE_RESTORE_KIND = ev.kind
|
||||
end)
|
||||
end
|
||||
]],
|
||||
}
|
||||
local fs = memfs(files)
|
||||
love.filesystem = fs
|
||||
-- Production storage and checkpoint resume share the same engine persistence
|
||||
-- backend. Route the test's default SaveData lookup to this fixture backend so
|
||||
-- the restart path exercises that shared mapping rather than host test files.
|
||||
local originalLoadOptions = SaveData.loadOptions
|
||||
SaveData.loadOptions = function(injectedFs)
|
||||
return originalLoadOptions(injectedFs or fs)
|
||||
end
|
||||
local active = { save = SaveData.newGame({ version = "red" }) }
|
||||
local loader = Loader.new({ fs = fs })
|
||||
loader.game = active
|
||||
T.check(loader:load({}) == true, "title-context fixture mod loads")
|
||||
|
||||
local storage = _G.MOD_TITLE_STORAGE
|
||||
T.check(type(storage) == "table", "loader exposes the public storage facade")
|
||||
if type(storage) == "table" then
|
||||
local written, writeCode, writeMessage = storage:write(active, "history/index", {
|
||||
format = 1, newest = "q0001",
|
||||
})
|
||||
T.check(written == true,
|
||||
"a fresh playthrough can durably store tool history: "
|
||||
.. tostring(writeCode or writeMessage))
|
||||
local originalId = active.save.meta and active.save.meta.playthroughId
|
||||
T.check(type(originalId) == "string" and originalId ~= "",
|
||||
"first tool persistence allocates the opaque active playthrough identity")
|
||||
local nonTitleSelected, nonTitleCode = storage:selected(active)
|
||||
T.check(nonTitleSelected == nil and nonTitleCode == "not_at_title",
|
||||
"selected-playthrough storage cannot be used from active gameplay")
|
||||
|
||||
-- Simulate a fresh process/title session. The normal save was never written:
|
||||
-- only the engine-owned slot/playthrough mapping and this mod's durable data
|
||||
-- exist. The title skeleton must remain unmodified by browsing.
|
||||
SaveData.resetSlotState()
|
||||
local title = {
|
||||
save = SaveData.newGame({ version = "red" }),
|
||||
stack = {
|
||||
states = { { screenId = "TitleState" } },
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
},
|
||||
}
|
||||
T.check(title.save.meta.playthroughId == nil,
|
||||
"title starts from an unbound fresh skeleton before normal SAVE")
|
||||
T.check(type(storage.selected) == "function",
|
||||
"public storage exposes a read-only selected-playthrough binding at title")
|
||||
|
||||
if type(storage.selected) == "function" then
|
||||
local selected, selectedCode, selectedMessage = storage:selected(title)
|
||||
T.check(type(selected) == "table",
|
||||
"title resolves the selected existing playthrough: "
|
||||
.. tostring(selectedCode or selectedMessage))
|
||||
if type(selected) == "table" then
|
||||
T.same(selected:context(), {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = "red",
|
||||
playthroughId = originalId,
|
||||
}, "selected binding reports the durable playthrough without exposing a slot path")
|
||||
T.same(selected:read("history/index"), { format = 1, newest = "q0001" },
|
||||
"title reads only this mod's selected-playthrough durable history")
|
||||
T.check(selected:write("history/title-operation", { allowed = true }) == true,
|
||||
"title binding supports safe same-namespace durable operations")
|
||||
T.same(selected:read("history/title-operation"), { allowed = true },
|
||||
"title durable operation remains scoped to the selected playthrough")
|
||||
end
|
||||
T.check(title.save.meta.playthroughId == nil,
|
||||
"opening title history never allocates or adopts a playthrough identity")
|
||||
end
|
||||
|
||||
local function makeRuntime(save, title)
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local game
|
||||
local overworld = {
|
||||
map = { id = "PALLET_TOWN" },
|
||||
player = { cellX = 3, cellY = 6, facing = "down", surfing = false },
|
||||
scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {},
|
||||
runner = { isRunning = function() return false end },
|
||||
}
|
||||
function overworld:captureSave(target)
|
||||
target.player.map, target.player.x, target.player.y = self.map.id,
|
||||
self.player.cellX, self.player.cellY
|
||||
target.player.facing, target.player.surfing = self.player.facing,
|
||||
self.player.surfing and true or false
|
||||
end
|
||||
function overworld:enter(mapId, x, y, facing)
|
||||
self.map = { id = mapId }
|
||||
self.player = { cellX = x, cellY = y, facing = facing,
|
||||
surfing = game.save.player.surfing and true or false }
|
||||
self.scriptMoves, self.pendingScripts = {}, {}
|
||||
self.parallelRunners, self.parallelQueue = {}, {}
|
||||
self.runner = { isRunning = function() return false end }
|
||||
end
|
||||
game = setmetatable({
|
||||
save = save, stack = stack, overworld = overworld,
|
||||
data = {
|
||||
pokemon = {}, moves = { TACKLE = { pp = 35 } }, items = { POTION = {} },
|
||||
constants = { fallbackMove = "TACKLE" },
|
||||
field = { boot = { startMap = "PALLET_TOWN", startX = 3, startY = 6 } },
|
||||
maps = { PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 } },
|
||||
},
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = title and { screenId = "TitleState" } or overworld
|
||||
if title then
|
||||
-- The failure-injection path needs the same title recovery contract as a
|
||||
-- real Game without constructing renderer-owned title content.
|
||||
function game:makeTitleState() return { screenId = "TitleState" } end
|
||||
end
|
||||
return game
|
||||
end
|
||||
|
||||
local runtime = makeRuntime(active.save, false)
|
||||
local checkpoints = _G.MOD_TITLE_CHECKPOINTS
|
||||
T.check(type(checkpoints) == "table", "loader exposes the public checkpoint facade")
|
||||
local checkpoint = checkpoints and checkpoints:capture(runtime)
|
||||
T.check(type(checkpoint) == "table",
|
||||
"a fresh playthrough can capture a stable overworld checkpoint")
|
||||
T.check(type(checkpoints and checkpoints.ensureNormalSave) == "function",
|
||||
"public checkpoints expose an idempotent first-save anchor")
|
||||
local normalWrites = 0
|
||||
local writeSave = runtime.writeSave
|
||||
function runtime:writeSave()
|
||||
normalWrites = normalWrites + 1
|
||||
return writeSave(self)
|
||||
end
|
||||
local anchored, anchorCode, anchorMessage =
|
||||
checkpoints:ensureNormalSave(runtime, checkpoint)
|
||||
T.check(anchored == true,
|
||||
"first persisted checkpoint can anchor normal progress: "
|
||||
.. tostring(anchorCode or anchorMessage))
|
||||
T.eq(normalWrites, 1,
|
||||
"first checkpoint creates exactly one normal Pokemon save")
|
||||
local anchoredAgain, againCode = checkpoints:ensureNormalSave(runtime, checkpoint)
|
||||
T.check(anchoredAgain == true and againCode == "already_exists",
|
||||
"later checkpoints leave the established normal save independent")
|
||||
T.eq(normalWrites, 1,
|
||||
"idempotent anchor never rewrites the established normal save")
|
||||
local normalBytes = files["save.lua"]
|
||||
T.check(type(normalBytes) == "string" and normalBytes ~= "",
|
||||
"first checkpoint anchor is durably represented before restart")
|
||||
local anchoredAt = SaveSerializer.decode(normalBytes).meta.savedAt
|
||||
SaveData.resetSlotState()
|
||||
local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true)
|
||||
titleRuntime.save.options = { volume = 9, bindings = {} }
|
||||
T.check(type(checkpoints and checkpoints.resume) == "function",
|
||||
"public checkpoints expose validated title-session resume")
|
||||
if type(checkpoints and checkpoints.resume) == "function" and checkpoint then
|
||||
local resumed, resumeCode, resumeMessage = checkpoints:resume(titleRuntime, checkpoint)
|
||||
T.check(resumed == true,
|
||||
"title resumes the durable checkpoint: " .. tostring(resumeCode or resumeMessage))
|
||||
T.eq(titleRuntime.save.meta.playthroughId, originalId,
|
||||
"title bootstrap retains the checkpoint's original playthrough identity")
|
||||
T.eq(titleRuntime.save.options.volume, 9,
|
||||
"title bootstrap preserves current options rather than rewinding them")
|
||||
T.eq(SaveData.selectedNormalSaveInfo({
|
||||
version = "red", meta = { playthroughId = originalId },
|
||||
}, fs).savedAt, anchoredAt,
|
||||
"title bootstrap never rewrites the first normal save")
|
||||
T.same(checkpoints:capture(titleRuntime), checkpoint,
|
||||
"bootstrapped overworld differentially recaptures the selected checkpoint")
|
||||
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
|
||||
"a successfully verified title resume emits checkpoint.restored exactly once")
|
||||
T.eq(_G.MOD_TITLE_RESTORE_KIND, "overworld",
|
||||
"title resume lifecycle reports the reconstructed checkpoint kind")
|
||||
|
||||
-- Force a failure after restoreCheckpointSave has already installed the
|
||||
-- checkpoint's canonical save and overworld. Title has no live checkpoint
|
||||
-- rollback, so it must rebuild a clean title session.
|
||||
SaveData.resetSlotState()
|
||||
local failingTitle = makeRuntime(SaveData.newGame({ version = "red" }), true)
|
||||
failingTitle.save.options = { volume = 7, bindings = {} }
|
||||
local restoreCheckpointSave = failingTitle.restoreCheckpointSave
|
||||
function failingTitle:restoreCheckpointSave(loaded)
|
||||
restoreCheckpointSave(self, loaded)
|
||||
error("forced title reconstruction failure")
|
||||
end
|
||||
local failed, failureCode = checkpoints:resume(failingTitle, checkpoint)
|
||||
T.check(failed == false and failureCode == "resume_failed",
|
||||
"failed title reconstruction reports a recoverable bootstrap failure")
|
||||
T.eq(failingTitle.stack:top().screenId, "TitleState",
|
||||
"failed title reconstruction returns to a usable title session")
|
||||
T.check(failingTitle.save.meta.playthroughId == nil,
|
||||
"failed title reconstruction restores the unbound title skeleton")
|
||||
T.eq(failingTitle.save.options.volume, 7,
|
||||
"failed title reconstruction retains current title options")
|
||||
T.eq(SaveData.selectedNormalSaveInfo({
|
||||
version = "red", meta = { playthroughId = originalId },
|
||||
}, fs).savedAt, anchoredAt,
|
||||
"failed title reconstruction never rewrites the normal Pokémon save")
|
||||
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
|
||||
"failed title reconstruction emits no additional restored lifecycle event")
|
||||
end
|
||||
|
||||
-- A title policy may compare its own durable checkpoint chronology with the
|
||||
-- ordinary CONTINUE target, but it must never receive that save's contents,
|
||||
-- slot path, or a way to open another playthrough. This fixture writes the
|
||||
-- canonical normal save directly to model an already-completed vanilla SAVE.
|
||||
active.save.meta.savedAt = 4321
|
||||
T.check(SaveData.save(active.save) == true,
|
||||
"fixture updates the selected normal save chronology")
|
||||
SaveData.resetSlotState()
|
||||
local titleWithNormalSave = {
|
||||
save = SaveData.newGame({ version = "red" }),
|
||||
stack = { states = { { screenId = "TitleState" } } },
|
||||
}
|
||||
local selectedWithNormal, normalCode, normalMessage = storage:selected(titleWithNormalSave)
|
||||
T.check(type(selectedWithNormal) == "table",
|
||||
"legacy-to-slot migration keeps the selected playthrough identity: "
|
||||
.. tostring(normalCode or normalMessage))
|
||||
if type(selectedWithNormal) == "table" then
|
||||
T.eq(selectedWithNormal:context().normalSavedAt, 4321,
|
||||
"title selected context exposes only matching normal-save chronology")
|
||||
end
|
||||
T.check(titleWithNormalSave.save.meta.playthroughId == nil,
|
||||
"normal-save chronology lookup does not bind the fresh title skeleton")
|
||||
|
||||
local explicitNewGame = SaveData.newGame({ version = "red" })
|
||||
local freshContext = storage:context({ save = explicitNewGame })
|
||||
T.check(freshContext and freshContext.playthroughId ~= originalId,
|
||||
"an explicit New Game receives a distinct identity and cannot inherit old history")
|
||||
end
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_TITLE_STORAGE = nil
|
||||
_G.MOD_TITLE_CHECKPOINTS = nil
|
||||
_G.MOD_TITLE_RESTORE_COUNT = nil
|
||||
_G.MOD_TITLE_RESTORE_KIND = nil
|
||||
SaveData.resetSlotState()
|
||||
SaveData.loadOptions = originalLoadOptions
|
||||
love.filesystem = realFs
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Public party-ordering contract over an idle overworld fixture. No ROM data
|
||||
-- is needed, so companion UIs exercise this seam in the normal mod-SDK tier.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("mod world party reorder")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local WorldAPI = require("src.world.WorldAPI")
|
||||
|
||||
local first = { species = "BULBASAUR" }
|
||||
local second = { species = "CHARMANDER" }
|
||||
local runner = { running = false }
|
||||
function runner:isRunning() return self.running end
|
||||
|
||||
local ow = {
|
||||
isOverworld = true,
|
||||
map = { id = "PALLET_TOWN" },
|
||||
player = { moving = false, inputLocked = false },
|
||||
runner = runner,
|
||||
scriptMoves = {},
|
||||
}
|
||||
local stack = setmetatable({ states = { ow } }, { __index = StateStack })
|
||||
local game = {
|
||||
data = {},
|
||||
save = { party = { first, second } },
|
||||
stack = stack,
|
||||
overworld = ow,
|
||||
}
|
||||
local api = WorldAPI.new(game, "fixture")
|
||||
|
||||
T.check(api:canReorderParty(), "idle free roam allows party reordering")
|
||||
|
||||
local Sound = require("src.core.Sound")
|
||||
local realPlay, played = Sound.play
|
||||
Sound.play = function(_, name) played = name end
|
||||
T.check(api:reorderParty(1, 2) == true, "valid slots reorder")
|
||||
Sound.play = realPlay
|
||||
T.check(game.save.party[1] == second and game.save.party[2] == first,
|
||||
"the live party is swapped")
|
||||
T.eq(played, "Swap", "the normal party swap sound is used")
|
||||
|
||||
local value, err = api:reorderParty(1.5, 2)
|
||||
T.check(value == nil and err == "invalid party slot",
|
||||
"non-integer slots are rejected")
|
||||
value, err = api:reorderParty("1", 2)
|
||||
T.check(value == nil and err == "invalid party slot",
|
||||
"string slots are rejected")
|
||||
|
||||
stack:push({ screenId = "SomeMenu" })
|
||||
T.check(not api:canReorderParty(), "a screen above the world blocks reordering")
|
||||
value, err = api:reorderParty(1, 2)
|
||||
T.check(value == nil and err == "world is busy",
|
||||
"reordering refuses while another screen owns input")
|
||||
stack:pop()
|
||||
|
||||
ow.player.moving = true
|
||||
T.check(not api:canReorderParty(), "movement blocks reordering")
|
||||
ow.player.moving = false
|
||||
runner.running = true
|
||||
T.check(not api:canReorderParty(), "scripts block reordering")
|
||||
runner.running = false
|
||||
ow.transitioning = true
|
||||
T.check(not api:canReorderParty(), "map transitions block reordering")
|
||||
|
||||
T.finish()
|
||||
@@ -452,4 +452,50 @@ do
|
||||
"the caught Weedle is NOT added to the dex")
|
||||
end
|
||||
|
||||
-- (5b) Yellow's SimulatedInputBattleItemList (core.asm:2316-2319) drops
|
||||
-- the same canned bag to a single POKé BALL, x1 -- pokered's
|
||||
-- OldManItemList (core.asm:2212-2214, checked above) stays x50. Only
|
||||
-- the item count changes; the scripted no-input menu flow is identical
|
||||
-- and already covered above, so this jumps straight to the bag.
|
||||
do
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local oldVersion = GameVersion.get()
|
||||
GameVersion.set("yellow")
|
||||
local ok, err = pcall(function()
|
||||
local pressed = {}
|
||||
local stack = { states = {} }
|
||||
function stack:push(s) table.insert(self.states, s) end
|
||||
function stack:pop() return table.remove(self.states) end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
local fg = {
|
||||
data = Data,
|
||||
save = require("src.core.SaveData").newGame(),
|
||||
input = { wasPressed = function(_, k) return pressed[k] or false end,
|
||||
isDown = function(_, k) return pressed[k] or false end },
|
||||
stack = stack,
|
||||
}
|
||||
fg.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
|
||||
local demo = BattleState.newWild(fg, "PIKACHU", 5)
|
||||
demo:makeOldManDemo("PROF.OAK")
|
||||
stack:push(demo)
|
||||
demo:enter()
|
||||
for _ = 1, 300 do
|
||||
if demo.phase == "menu" then break end
|
||||
pressed.a = true
|
||||
demo:update(1 / 60)
|
||||
end
|
||||
pressed.a = false
|
||||
eq(demo.phase, "menu", "Yellow: the demo reaches the battle menu")
|
||||
for _ = 1, 200 do
|
||||
if stack:top() ~= demo then break end
|
||||
demo:update(1 / 60)
|
||||
end
|
||||
local bag = stack:top()
|
||||
eq(bag.items and bag.items[1] and bag.items[1].right, "x1",
|
||||
"Yellow's old-man-style bag lists x1 (SimulatedInputBattleItemList)")
|
||||
end)
|
||||
GameVersion.set(oldVersion)
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
-- Parity test for Yellow's Pallet Town intro (Oak stops the player,
|
||||
-- catches a wild Pikachu, walks them to the lab). Drives the real
|
||||
-- onStep closure in data/scripts/story2.lua through the real
|
||||
-- StateStack/OverworldState, with no mocked battle or overworld, up
|
||||
-- through the point Oak's Pikachu catch is armed: covers two of the
|
||||
-- four fixes on this branch -- Music_MuseumGuy staying off in
|
||||
-- Red/Blue, and the 2-frame hold before the Pikachu battle handing
|
||||
-- off to BattleTransition rather than a bare push.
|
||||
--
|
||||
-- Deliberately stops there rather than also driving the demo battle
|
||||
-- to completion to assert Music_MuseumGuy fires in Yellow (the third
|
||||
-- fix): that would mean re-deriving frame budgets for the battle
|
||||
-- menu/bag/throw sequence tests/parity_J.lua already exercises, on
|
||||
-- top of everything already driven here, for one more assertion --
|
||||
-- more coupling to unrelated timing than the fix is worth. That side
|
||||
-- is manually verified instead (see the PR description).
|
||||
--
|
||||
-- Sources: scripts/PalletTown.asm, engine/overworld/auto_movement.asm,
|
||||
-- home/overworld.asm, engine/battle/core.asm (see the commits on this
|
||||
-- branch for the exact citations).
|
||||
-- luajit tests/parity_yellow_pallet_pikachu.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local S = require("tests.harness").suite("parity Yellow Pallet Town Pikachu")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Game = require("src.core.Game")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local BattleTransition = require("src.render.BattleTransition")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Music = require("src.core.Music")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
|
||||
local pallet = mapScripts.get("PALLET_TOWN")
|
||||
check(pallet and pallet.onStep, "PALLET_TOWN exposes onStep")
|
||||
|
||||
local oldVersion = GameVersion.get()
|
||||
local prevGame = { data = Game.data, save = Game.save, stack = Game.stack,
|
||||
input = Game.input, renderer = Game.renderer,
|
||||
overworld = Game.overworld }
|
||||
|
||||
local function freshGame(mapX, mapY)
|
||||
Game.data = Data
|
||||
Game.save = SaveData.newGame(Data)
|
||||
Game.save.player.name = "RED"
|
||||
StateStack:init()
|
||||
Game.stack = StateStack
|
||||
local pressed = {}
|
||||
Game.input = {
|
||||
isDown = function() return false end,
|
||||
wasPressed = function(_, b) return pressed[b] or false end,
|
||||
step = function() end, state = {}, pressQueue = {},
|
||||
}
|
||||
Game.renderer = {
|
||||
beginWorldPass = function() end, endWorldPass = function() end,
|
||||
beginUIPass = function() end, endUIPass = function() end,
|
||||
worldViewSize = function() return 160, 144 end,
|
||||
setSGBZones = function() end,
|
||||
}
|
||||
StateStack:push(OverworldState, "PALLET_TOWN", mapX, mapY, "up")
|
||||
Game.overworld = OverworldState
|
||||
return pressed
|
||||
end
|
||||
|
||||
-- mash "a" whenever anything but the overworld is on top (dismisses
|
||||
-- text boxes; scriptMove/hold/BattleTransition/BattleState's own
|
||||
-- scripted-menu phases all ignore it)
|
||||
local function pump(pressed, n)
|
||||
for _ = 1, n do
|
||||
pressed.a = Game.stack:top() ~= OverworldState
|
||||
Game.stack:update(1 / 60)
|
||||
end
|
||||
end
|
||||
|
||||
local function mashUntil(pressed, cond, cap)
|
||||
for _ = 1, cap do
|
||||
if cond() then return true end
|
||||
pressed.a = Game.stack:top() ~= OverworldState
|
||||
Game.stack:update(1 / 60)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Every scenario mutates the Game/GameVersion/StateStack singletons the
|
||||
-- rest of the aggregate tests/run_tests.lua run depends on; a genuine
|
||||
-- error partway through (plausible -- this drives real scriptMove,
|
||||
-- pathfinding, TextBox paging and BattleState code, not test doubles)
|
||||
-- must not skip the restore, or it cascades into every later suite in
|
||||
-- the same process. StateStack:init() leaves it empty rather than
|
||||
-- pointed at this scenario's pushed OverworldState/TextBox/
|
||||
-- BattleTransition instances.
|
||||
local function scenario(fn)
|
||||
local ok, err = pcall(fn)
|
||||
GameVersion.set(oldVersion)
|
||||
for k, v in pairs(prevGame) do Game[k] = v end
|
||||
StateStack:init()
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- =====================================================================
|
||||
-- (A) Red/Blue: MUSIC_MUSEUM_GUY must never play. Only "red" is driven
|
||||
-- here -- story2.lua's onStep branches on GameVersion.isYellow() alone
|
||||
-- and never calls isBlue(), so Red and Blue share this exact code path
|
||||
-- and a separate Blue run would exercise nothing new. pokered's copy of
|
||||
-- PalletMovementScript_OakMoveLeft only sets BIT_NO_MAP_MUSIC and never
|
||||
-- calls PlayMusic -- MUSIC_MEET_PROF_OAK (started when Oak appears)
|
||||
-- keeps running straight into the lab.
|
||||
-- =====================================================================
|
||||
scenario(function()
|
||||
GameVersion.set("red")
|
||||
local pressed = freshGame(8, 2)
|
||||
local played = {}
|
||||
local origPlay = Music.play
|
||||
Music.play = function(data, song, ...)
|
||||
table.insert(played, song)
|
||||
return origPlay(data, song, ...)
|
||||
end
|
||||
local ok, err = pcall(function()
|
||||
local ow = OverworldState
|
||||
check(pallet.onStep(Game, ow, 8, 1) == true,
|
||||
"Red: onStep claims the trigger tile")
|
||||
-- HeyWaitDontGoOutText (auto) -> "!" bubble hold(50) -> Oak
|
||||
-- approaches -> "It's unsafe!" text -> escortToLab -> the walk to
|
||||
-- the lab door. 3000 frames (50s of game time) covers it; the
|
||||
-- negative Music_MuseumGuy checks below only mean something once
|
||||
-- the escort has actually finished, not merely started.
|
||||
pump(pressed, 3000)
|
||||
check(Game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB == true,
|
||||
"Red: the escort actually reaches the lab within the frame budget")
|
||||
end)
|
||||
Music.play = origPlay
|
||||
if not ok then error(err, 0) end
|
||||
local sawMuseumGuy = false
|
||||
for _, s in ipairs(played) do
|
||||
if s == "Music_MuseumGuy" then sawMuseumGuy = true end
|
||||
end
|
||||
check(not sawMuseumGuy, "Red/Blue: Music_MuseumGuy never plays for this escort")
|
||||
check(played[1] == "Music_MeetProfOak",
|
||||
"Red/Blue: Music_MeetProfOak is still the only cutscene cue played")
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- (B) Yellow: the turn-then-battle timing and the transition wipe.
|
||||
-- =====================================================================
|
||||
scenario(function()
|
||||
GameVersion.set("yellow")
|
||||
local pressed = freshGame(10, 1)
|
||||
local ok, err = pcall(function()
|
||||
local ow = OverworldState
|
||||
check(pallet.onStep(Game, ow, 10, 0) == true,
|
||||
"Yellow: onStep claims the north-exit tile")
|
||||
local heyWaitBox = Game.stack:top()
|
||||
check(getmetatable(heyWaitBox) == TextBox,
|
||||
"Yellow: onStep opens with a text box (HeyWaitDontGoOutText)")
|
||||
|
||||
-- HeyWaitDontGoOutText (auto, ~20 frames) -> "!" bubble hold(50) ->
|
||||
-- Oak approaches (findPath(10,4,10,1): 3 steps) -> hold(6) -> the
|
||||
-- next real text box is ThatWasClose (button-dismissed, unlike the
|
||||
-- first).
|
||||
local sawThatWasClose = mashUntil(pressed, function()
|
||||
local top = Game.stack:top()
|
||||
return top ~= heyWaitBox and getmetatable(top) == TextBox
|
||||
end, 3000)
|
||||
check(sawThatWasClose, "Yellow: reaches the ThatWasClose text")
|
||||
|
||||
local oak
|
||||
for _, n in ipairs(ow.npcs) do
|
||||
if n.def and n.def.name == "PALLETTOWN_OAK" then oak = n end
|
||||
end
|
||||
check(oak ~= nil, "Yellow: PALLETTOWN_OAK is spawned")
|
||||
|
||||
-- dismiss ThatWasClose (a multi-page text: mash through the
|
||||
-- typewriter cadence and the page turn); oak.facing flips
|
||||
-- synchronously in the close callback (x == 10 -> "right"), then
|
||||
-- hold(2) starts
|
||||
local sawHold = mashUntil(pressed, function() return ow.emote ~= nil end, 2000)
|
||||
check(sawHold, "Yellow: ThatWasClose closes and the hold before battle arms")
|
||||
eq(oak and oak.facing, "right", "Oak turns to face the grass (x==10 -> right)")
|
||||
check(ow.emote ~= nil and ow.emote.frames == 2,
|
||||
"the hold before the battle is armed for exactly 2 frames")
|
||||
|
||||
-- tick 1: 2 -> 1, battle not pushed yet
|
||||
Game.stack:update(1 / 60)
|
||||
check(getmetatable(Game.stack:top()) ~= BattleTransition,
|
||||
"1 frame in: the battle has not started yet")
|
||||
-- tick 2: 1 -> 0, the demo battle is pushed through the transition
|
||||
Game.stack:update(1 / 60)
|
||||
check(getmetatable(Game.stack:top()) == BattleTransition,
|
||||
"2 frames in: Oak's catch enters through BattleTransition, not a bare push")
|
||||
end)
|
||||
if not ok then error(err, 0) end
|
||||
end)
|
||||
|
||||
S.finish()
|
||||
+97
-7
@@ -649,7 +649,9 @@ for name, registry in pairs(loader.content) do
|
||||
end
|
||||
end
|
||||
if patcher and not defined then
|
||||
row("ORPHAN", name, id, patcher)
|
||||
local gen2Routed = Schemas.targetFor(name, registry.spec, 2)
|
||||
~= registry.spec.target
|
||||
row("ORPHAN", name, id, patcher, tostring(gen2Routed))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -696,6 +698,77 @@ def classify_error(message, fallback="MK100"):
|
||||
return fallback
|
||||
|
||||
|
||||
def _generated_data_dir_ok(path):
|
||||
# 'true' when path looks like rom-derived generated data
|
||||
# pokemon.lua preserves the existing imported-dataset probe;
|
||||
# data:load is authority for the rest, including optional namespaces e.g. audio
|
||||
return bool(path) and os.path.isfile(os.path.join(path, "pokemon.lua"))
|
||||
|
||||
|
||||
def _love_user_data_root():
|
||||
# get desktop save root
|
||||
identity = os.environ.get("POKEPORT_IDENTITY") or "pokemon-love2d"
|
||||
|
||||
if sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA")
|
||||
return os.path.join(appdata, "LOVE", identity) if appdata else None
|
||||
|
||||
if sys.platform == "darwin":
|
||||
return os.path.join(
|
||||
os.path.expanduser("~"),
|
||||
"Library", "Application Support", "LOVE", identity)
|
||||
|
||||
if sys.platform.startswith(("linux", "freebsd")):
|
||||
data_home = os.environ.get("XDG_DATA_HOME")
|
||||
if not data_home:
|
||||
data_home = os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
return os.path.join(data_home, "love", identity)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def imported_data_dir(repo):
|
||||
# locate datasets that would be used at runtime
|
||||
# priority:
|
||||
# 1 explicit POKEPORT_DATA_DIR
|
||||
# 2 versioned portable/source-adjacent cache
|
||||
# 3 versioned LOVE user cache
|
||||
# 4 historical source-tree/generated dev dataset
|
||||
# POKEPORT_VERSION defaults to red
|
||||
explicit = os.environ.get("POKEPORT_DATA_DIR")
|
||||
if explicit:
|
||||
path = os.path.abspath(os.path.expanduser(explicit))
|
||||
return path if _generated_data_dir_ok(path) else None
|
||||
|
||||
version = (os.environ.get("POKEPORT_VERSION") or "red").lower()
|
||||
if version not in ("red", "blue", "yellow"):
|
||||
version = "red"
|
||||
|
||||
candidates = [
|
||||
os.path.join(repo, version, "data", "generated"),
|
||||
]
|
||||
|
||||
user_root = _love_user_data_root()
|
||||
if user_root:
|
||||
candidates.append(os.path.join(
|
||||
user_root, version, "data", "generated"))
|
||||
|
||||
# preserve old source-tree developer data as final fallback
|
||||
# we prefer the real versioned cache first
|
||||
# because a checkout may have a partially generated dataset
|
||||
candidates.append(os.path.join(repo, "data", "generated"))
|
||||
|
||||
seen = set()
|
||||
for path in candidates:
|
||||
path = os.path.abspath(path)
|
||||
if path in seen:
|
||||
continue
|
||||
seen.add(path)
|
||||
if _generated_data_dir_ok(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
FIXTURE_BASE = 'require("tests.fixture_data").load()'
|
||||
IMPORTED_BASE = ('(function() local D = require("src.core.Data") '
|
||||
'D:load() return D end)()')
|
||||
@@ -708,11 +781,11 @@ def resolve_base(repo, choice):
|
||||
is skipped instead of reported."""
|
||||
if choice != "auto":
|
||||
return choice
|
||||
imported = os.path.join(repo, "data", "generated", "pokemon.lua")
|
||||
return "imported" if os.path.isfile(imported) else "fixture"
|
||||
return "imported" if imported_data_dir(repo) else "fixture"
|
||||
|
||||
|
||||
def run_loader(repo, mod_dir, findings, base="fixture", notes=None):
|
||||
def run_loader(repo, mod_dir, findings, base="fixture", notes=None,
|
||||
manifest=None):
|
||||
"""Drive the engine loader headlessly with the mod mounted; the base
|
||||
dataset is the ROM-free fixture, or the imported cache with
|
||||
--base imported (for mods that reference vanilla Red content).
|
||||
@@ -775,8 +848,9 @@ def run_loader(repo, mod_dir, findings, base="fixture", notes=None):
|
||||
if "unknown permission" in parts[1]:
|
||||
continue
|
||||
add(Finding(classify_error(parts[1], "MK001"), "error", parts[1]))
|
||||
elif kind == "ORPHAN" and len(parts) >= 4:
|
||||
elif kind == "ORPHAN" and len(parts) >= 5:
|
||||
registry, target, owner = parts[1], parts[2], parts[3]
|
||||
gen2_routed = parts[4] == "true"
|
||||
# only the imported dataset owns the real vanilla id space. The
|
||||
# fixture stands in for three species, so "not in base data" there
|
||||
# is a fact about the fixture, not about the mod -- MK103 has no
|
||||
@@ -786,6 +860,11 @@ def run_loader(repo, mod_dir, findings, base="fixture", notes=None):
|
||||
if base != "imported":
|
||||
skipped.add("MK103")
|
||||
continue
|
||||
if gen2_routed and declares_gen2(repo, manifest):
|
||||
# tools/build_data.py never writes a Gen 2 cache, so the
|
||||
# imported dataset has no Gold/Crystal ground truth either.
|
||||
skipped.add("MK103")
|
||||
continue
|
||||
add(Finding(
|
||||
"MK103", "error",
|
||||
f"{owner}: patch target {target!r} exists in neither "
|
||||
@@ -834,7 +913,7 @@ def cmd_validate(args, repo):
|
||||
findings.extend(gh_findings)
|
||||
notes.extend(gh_notes)
|
||||
findings.extend(check_permissions(repo, manifest))
|
||||
run_loader(repo, mod_dir, findings, args.base, notes)
|
||||
run_loader(repo, mod_dir, findings, args.base, notes, manifest)
|
||||
findings.extend(check_requires(repo, mod_dir, manifest))
|
||||
findings.extend(lint_dir(repo, mod_dir, manifest))
|
||||
name = manifest.get("id") if manifest else os.path.basename(mod_dir)
|
||||
@@ -1130,7 +1209,7 @@ def cmd_pack(args, repo):
|
||||
return 1
|
||||
findings = list(check_permissions(repo, manifest))
|
||||
notes = []
|
||||
run_loader(repo, mod_dir, findings, args.base, notes)
|
||||
run_loader(repo, mod_dir, findings, args.base, notes, manifest)
|
||||
findings.extend(check_requires(repo, mod_dir, manifest))
|
||||
findings.extend(lint_dir(repo, mod_dir, manifest))
|
||||
# pack runs validate --strict (20-developer-tooling.md 5), so a warning
|
||||
@@ -3554,6 +3633,17 @@ def main(argv):
|
||||
return 2
|
||||
repo = os.path.abspath(repo)
|
||||
|
||||
# Normal game boot mounts the selected version's private ROM cache before
|
||||
# Data:load(). modkit runs outside LÖVE, so reproduce that dataset
|
||||
# selection through Data.lua's existing POKEPORT_DATA_DIR override.
|
||||
#
|
||||
# Setting it once here means validate, pack, and translation all inherit
|
||||
# the same imported dataset in their LuaJIT child processes.
|
||||
if hasattr(args, "base") and resolve_base(repo, args.base) == "imported":
|
||||
data_dir = imported_data_dir(repo)
|
||||
if data_dir:
|
||||
os.environ["POKEPORT_DATA_DIR"] = data_dir
|
||||
|
||||
handler = {
|
||||
"scaffold": cmd_scaffold,
|
||||
"validate": cmd_validate,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Windows ships a python3.exe under WindowsApps that exists purely to advertise
|
||||
# the Microsoft Store, so `command -v python3` succeeds and running it fails.
|
||||
# build_android.sh calls python3 three times (the Yellow manifest check, the
|
||||
# gradle.properties rewrite and the manifest permission trim), and this puts a
|
||||
# real interpreter behind that name for the length of the build.
|
||||
exec py "$@"
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stands in for Info-ZIP's `zip`, which Git for Windows does not ship. Put
|
||||
# this directory first on PATH before running scripts/build_android.sh; see
|
||||
# zip_impl.py for what is and is not supported.
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec py "$here/zip_impl.py" "$@"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Enough of Info-ZIP's `zip` for scripts/build_android.sh, on Windows.
|
||||
|
||||
Git for Windows ships unzip but not zip, which is the one tool standing between
|
||||
a Windows checkout and a local Android build. Rather than fetching a binary
|
||||
from somewhere unaudited, this covers exactly the two forms the build script
|
||||
uses and refuses anything it does not understand, so a future flag fails loudly
|
||||
instead of silently producing a wrong archive:
|
||||
|
||||
zip -q -9 -r out.love main.lua src data -x '*.DS_Store' -x 'data/generated/*'
|
||||
zip -q out.love src/core/Version.lua # add or replace one entry
|
||||
|
||||
Paths are stored relative to the working directory with forward slashes, and
|
||||
directories are walked in sorted order so the archive is reproducible.
|
||||
"""
|
||||
import fnmatch
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
|
||||
def excluded(name, patterns):
|
||||
# zip's wildcards cross directory separators, which is also what fnmatch
|
||||
# does, so the build script's '*/.git/*' behaves the same either way.
|
||||
return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
|
||||
|
||||
|
||||
def collect(paths, patterns):
|
||||
"""-> archive-relative names, in a stable order."""
|
||||
names = []
|
||||
for path in paths:
|
||||
if os.path.isdir(path):
|
||||
for root, dirs, files in os.walk(path):
|
||||
dirs.sort()
|
||||
for f in sorted(files):
|
||||
full = os.path.join(root, f)
|
||||
name = os.path.relpath(full, ".").replace(os.sep, "/")
|
||||
if not excluded(name, patterns):
|
||||
names.append(name)
|
||||
elif os.path.isfile(path):
|
||||
name = os.path.relpath(path, ".").replace(os.sep, "/")
|
||||
if not excluded(name, patterns):
|
||||
names.append(name)
|
||||
else:
|
||||
sys.stderr.write("zip: %s not found\n" % path)
|
||||
return None
|
||||
return names
|
||||
|
||||
|
||||
def main(argv):
|
||||
patterns, paths, archive = [], [], None
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "-x":
|
||||
# Info-ZIP accepts one -x followed by several patterns, which is how
|
||||
# pack_love.sh and build.sh write their excludes. Consume every
|
||||
# following non-flag argument as a pattern; paths always come before
|
||||
# -x in those scripts, so this does not steal archive members.
|
||||
i += 1
|
||||
if i >= len(argv) or argv[i].startswith("-"):
|
||||
sys.stderr.write("zip: -x needs a pattern\n")
|
||||
return 2
|
||||
while i < len(argv) and not argv[i].startswith("-"):
|
||||
patterns.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
elif arg in ("-q", "-9", "-r", "-X", "-o"):
|
||||
pass # quiet, compression level, recurse, no-extra, ordering
|
||||
elif arg.startswith("-"):
|
||||
sys.stderr.write("zip shim: unsupported flag %s\n" % arg)
|
||||
return 2
|
||||
elif archive is None:
|
||||
archive = arg
|
||||
else:
|
||||
paths.append(arg)
|
||||
i += 1
|
||||
|
||||
if archive is None or not paths:
|
||||
sys.stderr.write("usage: zip [-q9r] [-x pat] archive path...\n")
|
||||
return 2
|
||||
|
||||
names = collect(paths, patterns)
|
||||
if names is None:
|
||||
return 1
|
||||
|
||||
# Adding to an existing archive means rewriting it: zipfile can append, but
|
||||
# appending a name that is already in there leaves both copies and readers
|
||||
# disagree about which one wins. The version stamp does exactly that.
|
||||
keep = []
|
||||
if os.path.exists(archive):
|
||||
replacing = set(names)
|
||||
with zipfile.ZipFile(archive, "r") as old:
|
||||
for info in old.infolist():
|
||||
if info.filename not in replacing:
|
||||
keep.append((info, old.read(info.filename)))
|
||||
|
||||
tmp = archive + ".shimtmp"
|
||||
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as out:
|
||||
for info, data in keep:
|
||||
out.writestr(info, data)
|
||||
for name in names:
|
||||
out.write(name, name)
|
||||
|
||||
if os.path.exists(archive):
|
||||
os.remove(archive)
|
||||
os.rename(tmp, archive)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user