Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8b7c468a4 | |||
| 33998aab5d | |||
| 6cfc85ca83 | |||
| 3a6557ffe2 | |||
| fe139ca7f4 | |||
| 5c617d3e32 | |||
| 8d11c04d43 | |||
| 8da51aa7ba | |||
| 78f87111de | |||
| 6ea59f1a79 | |||
| a2bf08c6ff |
@@ -0,0 +1,13 @@
|
|||||||
|
# Plain Pixel font
|
||||||
|
|
||||||
|
"Plain Pixel Font" by Douglas Vautour (Burpy Fresh) is licensed under
|
||||||
|
CC-BY 4.0: https://burpyfresh.itch.io
|
||||||
|
|
||||||
|
Version 0.009 (CJK character additions), unmodified. Characters for most
|
||||||
|
languages have a 5x11 base but can extend vertically; double-width
|
||||||
|
characters such as Hiragana and Katakana are 11x11.
|
||||||
|
|
||||||
|
Bundled so a translation mod can opt into TTF text rendering
|
||||||
|
(`mod.content.font:register("ttf", {})`; see the Translation support
|
||||||
|
section of docs/new-features.md) instead of drawing hundreds of glyph-page
|
||||||
|
tiles. The tile font extracted from the player's ROM stays the default.
|
||||||
@@ -153,6 +153,17 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
|||||||
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
|
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
|
||||||
`options.mods[id]` so a later reinstall starts from the loader's default
|
`options.mods[id]` so a later reinstall starts from the loader's default
|
||||||
(enabled). The mods panel Delete control calls this and re-derives the list.
|
(enabled). The mods panel Delete control calls this and re-derives the list.
|
||||||
|
- A mod that declares `github` shows its total GitHub downloads (every
|
||||||
|
release's summed asset `download_count`, from the same cached release
|
||||||
|
fetch the update check uses) as a highlighted body line like "12,345
|
||||||
|
downloads across all releases - Released 2024-05-31 - Updated 2026-07-01"
|
||||||
|
(first and latest `published_at`). Old cache entries written before the
|
||||||
|
counts existed show no line rather than a wrong zero; a manual check
|
||||||
|
refreshes them.
|
||||||
|
- The MODS panel sorts its rows by Name, Popularity (downloads),
|
||||||
|
Release date (first release), or Last updated, chosen by chips under the
|
||||||
|
header and persisted in `options.modSort`. Mods without release data
|
||||||
|
(no `github` field, or a stale cache) sink to the bottom of data sorts.
|
||||||
|
|
||||||
## Import / Export save
|
## Import / Export save
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# New features (deliberate additions beyond the original)
|
# New features (deliberate additions beyond the original)
|
||||||
|
|
||||||
Intentional enhancements this port adds on top of faithful Pokémon Red
|
Intentional enhancements this port adds on top of faithful Pokémon Red, Blue,
|
||||||
behavior. They have no Game Boy equivalent and are kept by design.
|
and Yellow behavior. They have no Game Boy equivalent and are kept by design.
|
||||||
Genuine divergences from the original (things still missing, wrong, or
|
Genuine divergences from the original (things still missing, wrong, or
|
||||||
approximated) live in docs/known-differences.md; faithfully-ported
|
approximated) live in docs/known-differences.md; faithfully-ported behavior is
|
||||||
behavior is in docs/behavior-porting-notes.md.
|
in docs/behavior-porting-notes.md.
|
||||||
|
|
||||||
## Survey zoom
|
## Survey zoom
|
||||||
|
|
||||||
@@ -376,6 +376,28 @@ plus a glyph-page and charmap stub, a naming-grid stub, and a
|
|||||||
be packed). `--refresh` re-harvests after an engine update, keeping
|
be packed). `--refresh` re-harvests after an engine update, keeping
|
||||||
existing translations and parking orphaned keys rather than dropping them.
|
existing translations and parking orphaned keys rather than dropping them.
|
||||||
|
|
||||||
|
A translation can also skip glyph pages entirely: scaffolding with
|
||||||
|
`--pixel-font` (or registering `mod.content.font:register("ttf", {})` in
|
||||||
|
an existing mod) renders text through a bundled TTF covering Latin with
|
||||||
|
diacritics, Cyrillic, kana and CJK, while box borders and `<PK>`-style
|
||||||
|
macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas
|
||||||
|
Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters,
|
||||||
|
11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on
|
||||||
|
the registry entry: `file` for a mod-shipped TTF, `size` (the font's
|
||||||
|
design em; Plain Pixel rasterizes cleanly only at multiples of 15),
|
||||||
|
`spacing` added to every advance, `yOffset` for vertical alignment
|
||||||
|
against the 8px cell grid, `bold`, which double-prints at a 1px
|
||||||
|
offset for fonts whose strokes read too light, and `tiles`, the
|
||||||
|
characters that keep their ROM tile instead of coming from the TTF.
|
||||||
|
|
||||||
|
`tiles` matters for a CJK translation. Sizing the font so a kana fills
|
||||||
|
the 8px cell leaves Latin narrower than the tile font it replaces, which
|
||||||
|
pulls the numeric columns out of line: the party menu's `:L12` stops
|
||||||
|
sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the
|
||||||
|
vanilla tiles, so numbers render exactly as they do in English while
|
||||||
|
kana still come from the font. It takes a string of characters, or a
|
||||||
|
list when a multi-character charmap sequence is meant.
|
||||||
|
|
||||||
See the wiki's Translations guide.
|
See the wiki's Translations guide.
|
||||||
|
|
||||||
## Save editor (bundled, reachable from the launcher)
|
## Save editor (bundled, reachable from the launcher)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
# What This Port Requires
|
# What This Port Requires
|
||||||
|
|
||||||
The packaged desktop app requires one user-supplied input on first boot: a
|
The packaged desktop app requires one user-supplied input on first boot: a
|
||||||
canonical 1 MiB US Pokemon Red ROM.
|
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
|
||||||
|
|
||||||
The importer verifies SHA-1
|
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
||||||
`ea9bcae617fdf159b045185467ae58b2e4a48b9a`. Other revisions, Virtual
|
for specific hashes). Other revisions and Virtual Console releases are rejected
|
||||||
Console releases, and Pokemon Blue are rejected rather than decoded with
|
rather than decoded with incorrect addresses.
|
||||||
incorrect addresses.
|
|
||||||
|
|
||||||
After verification, the app generates its private cache in the LÖVE save
|
After verification, the app generates its private cache in the LÖVE save
|
||||||
directory. It does not keep a copy of the ROM. Later boots use the cache.
|
directory. It does not keep a copy of the ROM. Later boots use the cache.
|
||||||
@@ -15,9 +14,11 @@ Python and Pillow are not required by the packaged app.
|
|||||||
## Bundled Metadata
|
## Bundled Metadata
|
||||||
|
|
||||||
Assembly removes high-level names and some relationships that the Lua port
|
Assembly removes high-level names and some relationships that the Lua port
|
||||||
needs. `tools/rom_manifest.json` therefore contains:
|
needs. The version-specific files `tools/rom_manifest.json`,
|
||||||
|
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
|
||||||
|
contain:
|
||||||
|
|
||||||
- the 3,268 ROM symbol addresses actually read by the extractor
|
- the ROM symbol addresses actually read by the extractor
|
||||||
- symbolic IDs and ordering for maps, species, moves, items, and trainers
|
- symbolic IDs and ordering for maps, species, moves, items, and trainers
|
||||||
- source-erased dimensions, image names, and map object integration names
|
- source-erased dimensions, image names, and map object integration names
|
||||||
- hand-ported field/script integration tables
|
- hand-ported field/script integration tables
|
||||||
|
|||||||
@@ -230,7 +230,9 @@ function flexlove.init(config)
|
|||||||
-- Initialize Performance if available
|
-- Initialize Performance if available
|
||||||
if Performance then
|
if Performance then
|
||||||
flexlove._Performance = Performance.init({
|
flexlove._Performance = Performance.init({
|
||||||
enabled = config.performanceMonitoring or true,
|
-- ~= false (not `or true`): `performanceMonitoring = false` must
|
||||||
|
-- actually disable the per-frame timers + memory sampling.
|
||||||
|
enabled = config.performanceMonitoring ~= false,
|
||||||
hudEnabled = false, -- Start with HUD disabled
|
hudEnabled = false, -- Start with HUD disabled
|
||||||
hudToggleKey = config.performanceHudKey or "f3",
|
hudToggleKey = config.performanceHudKey or "f3",
|
||||||
hudPosition = config.performanceHudPosition or { x = 10, y = 10 },
|
hudPosition = config.performanceHudPosition or { x = 10, y = 10 },
|
||||||
|
|||||||
@@ -2709,6 +2709,7 @@ function Element:_drawChildren(backdropCanvas)
|
|||||||
|
|
||||||
-- Set up clipping: rounded-corners (stencil) > overflow (scissor) > none
|
-- Set up clipping: rounded-corners (stencil) > overflow (scissor) > none
|
||||||
local clipMode = "none"
|
local clipMode = "none"
|
||||||
|
local prevSx, prevSy, prevSw, prevSh
|
||||||
if hasRoundedCorners then
|
if hasRoundedCorners then
|
||||||
local roundedBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
|
local roundedBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
|
||||||
local roundedBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
|
local roundedBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
|
||||||
@@ -2721,7 +2722,18 @@ function Element:_drawChildren(backdropCanvas)
|
|||||||
love.graphics.setStencilTest("greater", 0)
|
love.graphics.setStencilTest("greater", 0)
|
||||||
clipMode = "stencil"
|
clipMode = "stencil"
|
||||||
elseif needsOverflowClipping then
|
elseif needsOverflowClipping then
|
||||||
love.graphics.setScissor(self.x + self.padding.left, self.y + self.padding.top, self.width, self.height)
|
-- Intersect with (and later restore) any ancestor scissor: replacing
|
||||||
|
-- it let a nested scroller widen its parent's clip, and the bare
|
||||||
|
-- setScissor() restore then cleared it entirely - scrolled page
|
||||||
|
-- content drew straight over the pinned header.
|
||||||
|
-- transformPoint: scissor rects live in window coordinates and ignore
|
||||||
|
-- the scroll translate an ancestor scroller has pushed, so a nested
|
||||||
|
-- scroller's clip sat at its untranslated layout position and cut its
|
||||||
|
-- own rows away once the page scrolled.
|
||||||
|
prevSx, prevSy, prevSw, prevSh = love.graphics.getScissor()
|
||||||
|
local scx, scy = love.graphics.transformPoint(
|
||||||
|
self.x + self.padding.left, self.y + self.padding.top)
|
||||||
|
love.graphics.intersectScissor(scx, scy, self.width, self.height)
|
||||||
clipMode = "scissor"
|
clipMode = "scissor"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2745,7 +2757,11 @@ function Element:_drawChildren(backdropCanvas)
|
|||||||
if clipMode == "stencil" then
|
if clipMode == "stencil" then
|
||||||
love.graphics.setStencilTest()
|
love.graphics.setStencilTest()
|
||||||
elseif clipMode == "scissor" then
|
elseif clipMode == "scissor" then
|
||||||
love.graphics.setScissor()
|
if prevSx then
|
||||||
|
love.graphics.setScissor(prevSx, prevSy, prevSw, prevSh)
|
||||||
|
else
|
||||||
|
love.graphics.setScissor()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ function ScrollManager.new(config, deps)
|
|||||||
self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true
|
self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true
|
||||||
self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true
|
self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true
|
||||||
self.bounceEnabled = config.bounceEnabled ~= false -- Default true
|
self.bounceEnabled = config.bounceEnabled ~= false -- Default true
|
||||||
self.scrollFriction = config.scrollFriction or 0.95 -- Exponential decay per frame
|
self.scrollFriction = config.scrollFriction or 0.95 -- legacy per-frame decay; superseded by _momentumDecel
|
||||||
|
self._momentumDecel = config.momentumDecel or 2.0 -- fling decay rate (1/s), dt-based; ~iOS "normal"
|
||||||
self.bounceStiffness = config.bounceStiffness or 0.2 -- Spring constant
|
self.bounceStiffness = config.bounceStiffness or 0.2 -- Spring constant
|
||||||
self.maxOverscroll = config.maxOverscroll or 100 -- pixels
|
self.maxOverscroll = config.maxOverscroll or 100 -- pixels
|
||||||
|
|
||||||
@@ -127,7 +128,8 @@ function ScrollManager.new(config, deps)
|
|||||||
self._scrollY = config._scrollY or 0
|
self._scrollY = config._scrollY or 0
|
||||||
self._targetScrollX = nil
|
self._targetScrollX = nil
|
||||||
self._targetScrollY = nil
|
self._targetScrollY = nil
|
||||||
self._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster)
|
self._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster); legacy, superseded by _smoothScrollRate
|
||||||
|
self._smoothScrollRate = config.smoothScrollRate or 30 -- dt-based decay constant (1/s); ~90% converged in 2.3/rate s
|
||||||
self.smoothScrollEnabled = config.smoothScrollEnabled or false -- Enable smooth wheel scrolling
|
self.smoothScrollEnabled = config.smoothScrollEnabled or false -- Enable smooth wheel scrolling
|
||||||
self._maxScrollX = 0
|
self._maxScrollX = 0
|
||||||
self._maxScrollY = 0
|
self._maxScrollY = 0
|
||||||
@@ -925,9 +927,15 @@ function ScrollManager:handleTouchMove(touchX, touchY)
|
|||||||
dx = -dx
|
dx = -dx
|
||||||
dy = -dy
|
dy = -dy
|
||||||
|
|
||||||
-- Calculate velocity (pixels per second)
|
-- Velocity tracking (pixels per second). Not the raw last-sample d/dt:
|
||||||
self._scrollVelocityX = dx / dt
|
-- touch samples jitter, and the final pre-release sample often reads near
|
||||||
self._scrollVelocityY = dy / dt
|
-- zero, which is why single-sample flings feel dead compared to a native
|
||||||
|
-- list. Blend toward the instantaneous velocity with a ~50 ms time
|
||||||
|
-- constant instead, like OS velocity trackers that average recent samples.
|
||||||
|
local instX, instY = dx / dt, dy / dt
|
||||||
|
local blend = math.min(1, dt / 0.05)
|
||||||
|
self._scrollVelocityX = self._scrollVelocityX + (instX - self._scrollVelocityX) * blend
|
||||||
|
self._scrollVelocityY = self._scrollVelocityY + (instY - self._scrollVelocityY) * blend
|
||||||
|
|
||||||
-- Apply scroll with bounce if enabled
|
-- Apply scroll with bounce if enabled
|
||||||
if self.bounceEnabled then
|
if self.bounceEnabled then
|
||||||
@@ -968,6 +976,15 @@ function ScrollManager:handleTouchRelease()
|
|||||||
|
|
||||||
self._touchScrolling = false
|
self._touchScrolling = false
|
||||||
|
|
||||||
|
-- A finger that pauses and then lifts is a stop, not a fling: without
|
||||||
|
-- this, the smoothed velocity from the earlier drag still launches the
|
||||||
|
-- list after a deliberate hold (native lists kill the tracker the same
|
||||||
|
-- way).
|
||||||
|
if love.timer.getTime() - (self._lastTouchTime or 0) > 0.1 then
|
||||||
|
self._scrollVelocityX = 0
|
||||||
|
self._scrollVelocityY = 0
|
||||||
|
end
|
||||||
|
|
||||||
-- Start momentum scrolling if enabled and velocity is significant
|
-- Start momentum scrolling if enabled and velocity is significant
|
||||||
if self.momentumScrollEnabled then
|
if self.momentumScrollEnabled then
|
||||||
local velocityThreshold = 50 -- pixels per second
|
local velocityThreshold = 50 -- pixels per second
|
||||||
@@ -990,12 +1007,23 @@ end
|
|||||||
--- Update momentum scrolling (call every frame with dt)
|
--- Update momentum scrolling (call every frame with dt)
|
||||||
---@param dt number Delta time in seconds
|
---@param dt number Delta time in seconds
|
||||||
function ScrollManager:update(dt)
|
function ScrollManager:update(dt)
|
||||||
-- Smooth scroll interpolation
|
-- Smooth scroll interpolation. The blend factor is derived from dt
|
||||||
|
-- (exponential approach) rather than a fixed per-frame fraction, so the
|
||||||
|
-- animation converges in the same wall-clock time regardless of frame
|
||||||
|
-- rate; a fixed fraction made scrolling visibly slower whenever frame
|
||||||
|
-- time rose. _smoothScrollRate is the decay constant in 1/seconds:
|
||||||
|
-- ~90% of the remaining distance is covered in 2.3/rate seconds.
|
||||||
|
-- dt can legitimately be 0 here: in immediate mode beginFrame resets the
|
||||||
|
-- accumulated dt BEFORE endFrame updates the elements, so the launcher's
|
||||||
|
-- update order always hands this 0. Treat that (and nil) as one nominal
|
||||||
|
-- 60 Hz step so the interpolation still advances one frame's worth.
|
||||||
|
local step = (dt and dt > 0) and dt or (1 / 60)
|
||||||
if self._targetScrollX or self._targetScrollY then
|
if self._targetScrollX or self._targetScrollY then
|
||||||
|
local alpha = 1 - math.exp(-(self._smoothScrollRate or 30) * step)
|
||||||
if self._targetScrollY then
|
if self._targetScrollY then
|
||||||
local diff = self._targetScrollY - self._scrollY
|
local diff = self._targetScrollY - self._scrollY
|
||||||
if math.abs(diff) > 0.5 then
|
if math.abs(diff) > 0.5 then
|
||||||
self._scrollY = self._scrollY + diff * self._smoothScrollSpeed
|
self._scrollY = self._scrollY + diff * alpha
|
||||||
else
|
else
|
||||||
self._scrollY = self._targetScrollY
|
self._scrollY = self._targetScrollY
|
||||||
self._targetScrollY = nil
|
self._targetScrollY = nil
|
||||||
@@ -1005,7 +1033,7 @@ function ScrollManager:update(dt)
|
|||||||
if self._targetScrollX then
|
if self._targetScrollX then
|
||||||
local diff = self._targetScrollX - self._scrollX
|
local diff = self._targetScrollX - self._scrollX
|
||||||
if math.abs(diff) > 0.5 then
|
if math.abs(diff) > 0.5 then
|
||||||
self._scrollX = self._scrollX + diff * self._smoothScrollSpeed
|
self._scrollX = self._scrollX + diff * alpha
|
||||||
else
|
else
|
||||||
self._scrollX = self._targetScrollX
|
self._scrollX = self._targetScrollX
|
||||||
self._targetScrollX = nil
|
self._targetScrollX = nil
|
||||||
@@ -1021,25 +1049,43 @@ function ScrollManager:update(dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Apply velocity to scroll position
|
-- Apply velocity to scroll position (step, not dt: dt is 0 in the
|
||||||
local dx = self._scrollVelocityX * dt
|
-- immediate-mode endFrame path, which left touch flings completely dead)
|
||||||
local dy = self._scrollVelocityY * dt
|
local dx = self._scrollVelocityX * step
|
||||||
|
local dy = self._scrollVelocityY * step
|
||||||
|
|
||||||
if self.bounceEnabled then
|
if self.bounceEnabled then
|
||||||
-- Allow overscroll during momentum
|
-- Allow overscroll during momentum, but never past maxOverscroll: an
|
||||||
self._scrollX = self._scrollX + dx
|
-- unclamped fling carried a list hundreds of pixels past its edge
|
||||||
self._scrollY = self._scrollY + dy
|
-- (rows vanished into a blank card until the spring crawled back).
|
||||||
|
self._scrollX = self._utils.clamp(self._scrollX + dx,
|
||||||
|
-self.maxOverscroll, self._maxScrollX + self.maxOverscroll)
|
||||||
|
self._scrollY = self._utils.clamp(self._scrollY + dy,
|
||||||
|
-self.maxOverscroll, self._maxScrollY + self.maxOverscroll)
|
||||||
else
|
else
|
||||||
self:scrollBy(dx, dy)
|
self:scrollBy(dx, dy)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Apply friction (exponential decay)
|
-- Frame-rate-independent exponential decay tuned to native list feel:
|
||||||
self._scrollVelocityX = self._scrollVelocityX * self.scrollFriction
|
-- iOS "normal" deceleration is ~0.998 per millisecond, i.e. exp(-2.0 t).
|
||||||
self._scrollVelocityY = self._scrollVelocityY * self.scrollFriction
|
-- The old per-frame 0.95 factor killed a fling in a fraction of a second
|
||||||
|
-- (and faster the higher the frame rate). Total fling travel is
|
||||||
|
-- velocity / rate.
|
||||||
|
local decay = math.exp(-(self._momentumDecel or 2.0) * step)
|
||||||
|
-- Past the content edge, kill the fling an order of magnitude faster so
|
||||||
|
-- the rubber band absorbs it instead of stretching to the clamp and
|
||||||
|
-- sitting there (native lists do the same).
|
||||||
|
local overX = self._scrollX < 0 or self._scrollX > self._maxScrollX
|
||||||
|
local overY = self._scrollY < 0 or self._scrollY > self._maxScrollY
|
||||||
|
if overX or overY then
|
||||||
|
decay = decay * math.exp(-20 * step)
|
||||||
|
end
|
||||||
|
self._scrollVelocityX = self._scrollVelocityX * decay
|
||||||
|
self._scrollVelocityY = self._scrollVelocityY * decay
|
||||||
|
|
||||||
-- Stop momentum when velocity is very low
|
-- Stop momentum when velocity is very low
|
||||||
local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2)
|
local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2)
|
||||||
if totalVelocity < 1 then
|
if totalVelocity < 5 then
|
||||||
self._momentumScrolling = false
|
self._momentumScrolling = false
|
||||||
self._scrollVelocityX = 0
|
self._scrollVelocityX = 0
|
||||||
self._scrollVelocityY = 0
|
self._scrollVelocityY = 0
|
||||||
|
|||||||
@@ -214,10 +214,13 @@ local function onDraw(element, _ctx)
|
|||||||
if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then
|
if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- Clear any parent scissor clipping before drawing scrollbars so they render
|
-- Lift the parent scissor while drawing scrollbars so they render fully
|
||||||
-- fully visible (scrollbars must not be clipped by ancestor overflow).
|
-- visible, then RESTORE it: clearing it outright let every later sibling
|
||||||
|
-- draw unclipped (scrolled page content over the pinned header).
|
||||||
|
local sx, sy, sw, sh = love.graphics.getScissor()
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
|
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
|
||||||
|
if sx then love.graphics.setScissor(sx, sy, sw, sh) end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- --------------------------------------------------------------------------
|
-- --------------------------------------------------------------------------
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 42 KiB |
@@ -72,8 +72,13 @@ rm -f "$LOVE_FILE"
|
|||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json \
|
tools/rom_manifest_yellow.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
# Materialize the listing once and grep the file: piping unzip straight into
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
# grep -q under `set -o pipefail` SIGPIPEs unzip when grep exits early on a
|
||||||
|
# match, and the pipeline's failure reads as "missing <file>" for whichever
|
||||||
|
# entry happened to match first (see the same fix in pack_love.sh).
|
||||||
|
LOVE_LISTING="$WORK/love-listing.txt"
|
||||||
|
unzip -Z1 "$LOVE_FILE" > "$LOVE_LISTING"
|
||||||
|
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' "$LOVE_LISTING"; then
|
||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
# The editor is only reachable if its entry point and both module directories
|
# The editor is only reachable if its entry point and both module directories
|
||||||
@@ -86,7 +91,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
|||||||
libs/flexlove/FlexLove.lua \
|
libs/flexlove/FlexLove.lua \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json; do
|
tools/rom_manifest_yellow.json; do
|
||||||
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
grep -qxF "$required" "$LOVE_LISTING" \
|
||||||
|| fail "game.love is missing $required"
|
|| fail "game.love is missing $required"
|
||||||
done
|
done
|
||||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||||
@@ -116,6 +121,32 @@ else
|
|||||||
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- app icon
|
||||||
|
# One source of truth for every platform's launcher icon; iOS resizes the
|
||||||
|
# same file in scripts/build_ios.sh (apply_ios_icon) and the Android res/
|
||||||
|
# drawables are generated from it too.
|
||||||
|
ICON_SRC="$ROOT/assets/logo/gen1recomp_cover.png"
|
||||||
|
|
||||||
|
# pipx installs peresed (Windows exe icon patcher) here, off the default PATH.
|
||||||
|
PATH="$PATH:$HOME/.local/bin"
|
||||||
|
|
||||||
|
make_icns() { # $1 = output .icns path
|
||||||
|
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||||
|
local iconset="$WORK/GameIcon.iconset" size scaled
|
||||||
|
rm -rf "$iconset"; mkdir -p "$iconset"
|
||||||
|
for size in 16 32 128 256 512; do
|
||||||
|
sips -z "$size" "$size" "$ICON_SRC" --out "$iconset/icon_${size}x${size}.png" >/dev/null
|
||||||
|
scaled=$((size * 2))
|
||||||
|
sips -z "$scaled" "$scaled" "$ICON_SRC" --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null
|
||||||
|
done
|
||||||
|
iconutil -c icns "$iconset" -o "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
make_ico() { # $1 = output .ico path
|
||||||
|
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||||
|
magick "$ICON_SRC" -define icon:auto-resize=256,128,64,48,32,16 "$1"
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- macOS
|
# --------------------------------------------------------------- macOS
|
||||||
build_mac() {
|
build_mac() {
|
||||||
say "building macOS app"
|
say "building macOS app"
|
||||||
@@ -142,9 +173,21 @@ build_mac() {
|
|||||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \
|
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \
|
||||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $VERSION" "$plist"
|
|| /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $VERSION" "$plist"
|
||||||
|
|
||||||
if [ -f "$ROOT/assets/icon.icns" ]; then
|
# Brand the app icon. LÖVE.app resolves its icon through CFBundleIconName ->
|
||||||
cp "$ROOT/assets/icon.icns" "$out_app/Contents/Resources/GameIcon.icns"
|
# Assets.car first, so overwriting the loose .icns files alone changes
|
||||||
|
# nothing; the compiled asset catalog has to go and the plist has to fall
|
||||||
|
# back to CFBundleIconFile.
|
||||||
|
local icns="$ROOT/assets/icon.icns"
|
||||||
|
if [ ! -f "$icns" ]; then
|
||||||
|
icns="$WORK/GameIcon.icns"
|
||||||
|
make_icns "$icns"
|
||||||
fi
|
fi
|
||||||
|
cp "$icns" "$out_app/Contents/Resources/GameIcon.icns"
|
||||||
|
cp "$icns" "$out_app/Contents/Resources/OS X AppIcon.icns"
|
||||||
|
rm -f "$out_app/Contents/Resources/Assets.car"
|
||||||
|
/usr/libexec/PlistBuddy -c "Delete :CFBundleIconName" "$plist" 2>/dev/null || true
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile OS X AppIcon" "$plist" 2>/dev/null \
|
||||||
|
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string 'OS X AppIcon'" "$plist"
|
||||||
|
|
||||||
local id="$IDENTITY"
|
local id="$IDENTITY"
|
||||||
if [ -z "$id" ]; then
|
if [ -z "$id" ]; then
|
||||||
@@ -218,7 +261,47 @@ build_win() {
|
|||||||
cp "$love_dir"/*.dll "$out_dir"/
|
cp "$love_dir"/*.dll "$out_dir"/
|
||||||
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
|
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
|
||||||
|
|
||||||
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
|
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
||||||
|
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
||||||
|
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
|
||||||
|
# only raw --set-resource, so split the .ico into RT_ICON blobs plus a
|
||||||
|
# GRPICONDIR that reuses love.exe's existing resource ids (1..N, lang 1033).
|
||||||
|
local ico="$WORK/$APP_NAME.ico"
|
||||||
|
make_ico "$ico"
|
||||||
|
if command -v peresed >/dev/null 2>&1; then
|
||||||
|
local ico_parts="$WORK/ico-parts"
|
||||||
|
rm -rf "$ico_parts"; mkdir -p "$ico_parts"
|
||||||
|
python3 - "$ico" "$ico_parts" <<'PY'
|
||||||
|
import struct, sys
|
||||||
|
data = open(sys.argv[1], "rb").read()
|
||||||
|
outdir = sys.argv[2]
|
||||||
|
count = struct.unpack_from("<H", data, 4)[0]
|
||||||
|
group = struct.pack("<HHH", 0, 1, count)
|
||||||
|
for i in range(count):
|
||||||
|
w, h, colors, res, planes, bpp, size, off = struct.unpack_from("<BBBBHHII", data, 6 + 16 * i)
|
||||||
|
open("%s/icon_%d.bin" % (outdir, i + 1), "wb").write(data[off:off + size])
|
||||||
|
group += struct.pack("<BBBBHHIH", w, h, colors, res, planes, bpp, size, i + 1)
|
||||||
|
open(outdir + "/group.bin", "wb").write(group)
|
||||||
|
print(count)
|
||||||
|
PY
|
||||||
|
local n_icons args=()
|
||||||
|
n_icons=$(ls "$ico_parts" | grep -c '^icon_')
|
||||||
|
for i in $(seq 1 "$n_icons"); do
|
||||||
|
args+=(-R RT_ICON "#$i" 1033 "$ico_parts/icon_$i.bin")
|
||||||
|
done
|
||||||
|
args+=(-R RT_GROUP_ICON "#1" 1033 "$ico_parts/group.bin")
|
||||||
|
local exe_branded="$WORK/love-branded.exe"
|
||||||
|
cp "$love_dir/love.exe" "$exe_branded"
|
||||||
|
if peresed "${args[@]}" "$exe_branded" >/dev/null; then
|
||||||
|
cat "$exe_branded" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
|
||||||
|
else
|
||||||
|
warn "peresed failed to patch the exe icon, shipping stock LÖVE icon"
|
||||||
|
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "peresed not found (pipx install pe_tools), shipping stock LÖVE exe icon"
|
||||||
|
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
|
||||||
|
fi
|
||||||
|
|
||||||
local zip_out="$DIST/win/$APP_NAME-win64.zip"
|
local zip_out="$DIST/win/$APP_NAME-win64.zip"
|
||||||
rm -f "$zip_out"
|
rm -f "$zip_out"
|
||||||
@@ -273,6 +356,15 @@ build_linux() {
|
|||||||
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
|
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
|
||||||
|
|
||||||
cp "$LOVE_FILE" "$appdir/game.love"
|
cp "$LOVE_FILE" "$appdir/game.love"
|
||||||
|
|
||||||
|
# The .desktop's Icon=love resolves against the AppDir root by basename,
|
||||||
|
# so drop the stock love.svg and provide our PNG under the same name;
|
||||||
|
# .DirIcon is what appimaged/thumbnailers show for the file itself.
|
||||||
|
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||||
|
rm -f "$appdir/love.svg" "$appdir/.DirIcon"
|
||||||
|
sips -z 512 512 "$ICON_SRC" --out "$appdir/love.png" >/dev/null
|
||||||
|
cp "$appdir/love.png" "$appdir/.DirIcon"
|
||||||
|
|
||||||
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
||||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||||
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
||||||
|
|||||||
@@ -695,7 +695,11 @@ run_xcodebuild() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love.
|
# Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love.
|
||||||
if [ ! -f "$app/game.love" ]; then
|
# Byte-compare, never just existence: xcodebuild's incremental Copy Bundle
|
||||||
|
# Resources can leave a previous build's game.love in a surviving .app, and
|
||||||
|
# an existence check shipped that stale payload in the .ipa (today's Lua
|
||||||
|
# fixes present in ios/resources/ but absent from the installed app).
|
||||||
|
if ! cmp -s "$LOVE_FILE" "$app/game.love"; then
|
||||||
say "fusing game.love into $(basename "$app")"
|
say "fusing game.love into $(basename "$app")"
|
||||||
cp "$LOVE_FILE" "$app/game.love"
|
cp "$LOVE_FILE" "$app/game.love"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -380,18 +380,21 @@ local CHARGE_TEXT = {
|
|||||||
-- pokered's <USER>/<TARGET> text macros (home/text.asm
|
-- pokered's <USER>/<TARGET> text macros (home/text.asm
|
||||||
-- PlaceMoveUsersName): battle texts naming the enemy mon print
|
-- PlaceMoveUsersName): battle texts naming the enemy mon print
|
||||||
-- "Enemy " before the nickname; player-side mons never get it.
|
-- "Enemy " before the nickname; player-side mons never get it.
|
||||||
|
-- Translatable as one "Enemy %s" template (#779) so languages that
|
||||||
|
-- qualify after the name, or decline, can (e.g. "%s ennemi").
|
||||||
local function displayName(b)
|
local function displayName(b)
|
||||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
return b.isPlayer and b.name or Strings("Enemy %s", b.name)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Apply the "Enemy " prefix to a pre-built message from a module that
|
-- Apply the enemy qualifier to a pre-built message from a module that
|
||||||
-- only knows the raw nickname (Status.beforeMove/residual,
|
-- only knows the raw nickname (Status.beforeMove/residual): replace the
|
||||||
-- TrainerAI.useItem): splice it in before the first name occurrence.
|
-- first name occurrence with the qualified form.
|
||||||
local function prefixEnemy(msg, battler)
|
local function prefixEnemy(msg, battler)
|
||||||
if battler.isPlayer then return msg end
|
if battler.isPlayer then return msg end
|
||||||
local s = msg:find(battler.name, 1, true)
|
local s = msg:find(battler.name, 1, true)
|
||||||
if not s then return msg end
|
if not s then return msg end
|
||||||
return msg:sub(1, s - 1) .. "Enemy " .. msg:sub(s)
|
return msg:sub(1, s - 1) .. Strings("Enemy %s", battler.name)
|
||||||
|
.. msg:sub(s + #battler.name)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Level-up stats window (PrintStatsBox .LevelUpStatsBox: box (9,2)
|
-- Level-up stats window (PrintStatsBox .LevelUpStatsBox: box (9,2)
|
||||||
@@ -3044,8 +3047,11 @@ function BattleState:executeAction(user, target, action)
|
|||||||
-- trainer class AI actions (engine/battle/trainer_ai.asm)
|
-- trainer class AI actions (engine/battle/trainer_ai.asm)
|
||||||
if action.special == "aiItem" then
|
if action.special == "aiItem" then
|
||||||
self.aiUses = (self.aiUses or 1) - 1
|
self.aiUses = (self.aiUses or 1) - 1
|
||||||
|
-- useItem's messages arrive final: its item line prints the raw
|
||||||
|
-- nickname on purpose (no "Enemy " in AIPrintItemUseText), so the
|
||||||
|
-- prefix splice must not touch them.
|
||||||
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
|
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
|
||||||
self:sayNext(prefixEnemy(m, self.enemy))
|
self:sayNext(m)
|
||||||
end
|
end
|
||||||
self:drainNext()
|
self:drainNext()
|
||||||
require("src.core.Sound").play(self.data, "Heal_Ailment")
|
require("src.core.Sound").play(self.data, "Heal_Ailment")
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ end
|
|||||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||||
-- mon's nickname (home/text.asm PlaceMoveUsersName)
|
-- mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||||
local function displayName(b)
|
local function displayName(b)
|
||||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||||
end
|
end
|
||||||
EffectRegistry.displayName = displayName
|
EffectRegistry.displayName = displayName
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ local MoveEffects = {}
|
|||||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the
|
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the
|
||||||
-- enemy mon's nickname (home/text.asm PlaceMoveUsersName)
|
-- enemy mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||||
local function displayName(b)
|
local function displayName(b)
|
||||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||||
end
|
end
|
||||||
|
|
||||||
local STAT_LABEL = {
|
local STAT_LABEL = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
-- back to the vanilla records, which is bit-identical behavior.
|
-- back to the vanilla records, which is bit-identical behavior.
|
||||||
|
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
local Status = {}
|
local Status = {}
|
||||||
|
|
||||||
@@ -34,8 +35,8 @@ end
|
|||||||
-- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
|
-- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
|
||||||
-- decline differently once translated, so a shared fragment cannot be the
|
-- decline differently once translated, so a shared fragment cannot be the
|
||||||
-- translatable unit.
|
-- translatable unit.
|
||||||
local function damageOverTime(template)
|
local function damageOverTime(label, template)
|
||||||
return function(battler)
|
return function(battler, _, battle)
|
||||||
local mon = battler.mon
|
local mon = battler.mon
|
||||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||||
local dmg = base
|
local dmg = base
|
||||||
@@ -44,7 +45,7 @@ local function damageOverTime(template)
|
|||||||
battler.toxicCounter = battler.toxicCounter + 1
|
battler.toxicCounter = battler.toxicCounter + 1
|
||||||
end
|
end
|
||||||
mon.hp = math.max(0, mon.hp - dmg)
|
mon.hp = math.max(0, mon.hp - dmg)
|
||||||
return { Strings(template, name(battler)) }
|
return { romText(battle and battle.data, label, template, name(battler)) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -63,53 +64,63 @@ Status.RECORDS = {
|
|||||||
id = "SLP", label = "SLP", hudLabel = "SLP",
|
id = "SLP", label = "SLP", hudLabel = "SLP",
|
||||||
catchBonus = 25, shakeBonus = 10,
|
catchBonus = 25, shakeBonus = 10,
|
||||||
beforeMovePriority = 40,
|
beforeMovePriority = 40,
|
||||||
beforeMove = function(battler)
|
beforeMove = function(battler, _, battle)
|
||||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||||
if battler.sleepTurns <= 0 then
|
if battler.sleepTurns <= 0 then
|
||||||
battler.mon.status = nil
|
battler.mon.status = nil
|
||||||
return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
|
-- wakes, loses the turn
|
||||||
|
return false, { romText(battle and battle.data, "_WokeUpText",
|
||||||
|
"%s\nwoke up!", name(battler)) }
|
||||||
end
|
end
|
||||||
return false, { Strings("%s\nis fast asleep!", name(battler)) }
|
return false, { romText(battle and battle.data, "_FastAsleepText",
|
||||||
|
"%s\nis fast asleep!", name(battler)) }
|
||||||
end,
|
end,
|
||||||
onInflict = function(battle, target, opts, display)
|
onInflict = function(battle, target, opts, display)
|
||||||
target.sleepTurns = battle.rng(1, 7)
|
target.sleepTurns = battle.rng(1, 7)
|
||||||
return { Strings("%s\nfell asleep!", display) }
|
return { romText(battle.data, "_FellAsleepText",
|
||||||
|
"%s\nfell asleep!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
FRZ = {
|
FRZ = {
|
||||||
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
|
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
|
||||||
catchBonus = 25, shakeBonus = 10,
|
catchBonus = 25, shakeBonus = 10,
|
||||||
beforeMovePriority = 30,
|
beforeMovePriority = 30,
|
||||||
beforeMove = function(battler)
|
beforeMove = function(battler, _, battle)
|
||||||
return false, { Strings("%s\nis frozen solid!", name(battler)) }
|
return false, { romText(battle and battle.data, "_IsFrozenText",
|
||||||
|
"%s\nis frozen solid!", name(battler)) }
|
||||||
end,
|
end,
|
||||||
canInflict = function(target) return not hasType(target, "ICE") end,
|
canInflict = function(target) return not hasType(target, "ICE") end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(battle, _, _, display)
|
||||||
return { Strings("%s\nwas frozen solid!", display) }
|
return { romText(battle and battle.data, "_FrozenText",
|
||||||
|
"%s\nwas frozen solid!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
PSN = {
|
PSN = {
|
||||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||||
catchBonus = 12, shakeBonus = 5,
|
catchBonus = 12, shakeBonus = 5,
|
||||||
residual = damageOverTime(Strings.source("%s's\nhurt by poison!")),
|
residual = damageOverTime("_HurtByPoisonText",
|
||||||
|
Strings.source("%s's\nhurt by poison!")),
|
||||||
canInflict = function(target) return not hasType(target, "POISON") end,
|
canInflict = function(target) return not hasType(target, "POISON") end,
|
||||||
onInflict = function(_, target, opts, display)
|
onInflict = function(battle, target, opts, display)
|
||||||
if opts.toxic then
|
if opts.toxic then
|
||||||
target.toxicCounter = 1
|
target.toxicCounter = 1
|
||||||
-- _BadlyPoisonedText
|
return { romText(battle and battle.data, "_BadlyPoisonedText",
|
||||||
return { Strings("%s's\nbadly poisoned!", display) }
|
"%s's\nbadly poisoned!", display) }
|
||||||
end
|
end
|
||||||
return { Strings("%s\nwas poisoned!", display) }
|
return { romText(battle and battle.data, "_PoisonedText",
|
||||||
|
"%s\nwas poisoned!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
BRN = {
|
BRN = {
|
||||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||||
catchBonus = 12, shakeBonus = 5,
|
catchBonus = 12, shakeBonus = 5,
|
||||||
statPenalty = { stat = "attack", div = 2 },
|
statPenalty = { stat = "attack", div = 2 },
|
||||||
residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")),
|
residual = damageOverTime("_HurtByBurnText",
|
||||||
|
Strings.source("%s's\nhurt by the burn!")),
|
||||||
canInflict = function(target) return not hasType(target, "FIRE") end,
|
canInflict = function(target) return not hasType(target, "FIRE") end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(battle, _, _, display)
|
||||||
return { Strings("%s\nwas burned!", display) }
|
return { romText(battle and battle.data, "_BurnedText",
|
||||||
|
"%s\nwas burned!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
PAR = {
|
PAR = {
|
||||||
@@ -117,10 +128,11 @@ Status.RECORDS = {
|
|||||||
catchBonus = 12, shakeBonus = 5,
|
catchBonus = 12, shakeBonus = 5,
|
||||||
statPenalty = { stat = "speed", div = 4 },
|
statPenalty = { stat = "speed", div = 4 },
|
||||||
beforeMovePriority = 10,
|
beforeMovePriority = 10,
|
||||||
beforeMove = function(battler, rng)
|
beforeMove = function(battler, rng, battle)
|
||||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||||
if rng(0, 255) < 63 then
|
if rng(0, 255) < 63 then
|
||||||
return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
|
return false, { romText(battle and battle.data, "_FullyParalyzedText",
|
||||||
|
"%s's\nfully paralyzed!", name(battler)) }
|
||||||
end
|
end
|
||||||
return true, {}
|
return true, {}
|
||||||
end,
|
end,
|
||||||
@@ -128,9 +140,10 @@ Status.RECORDS = {
|
|||||||
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
|
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
|
||||||
return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND"))
|
return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND"))
|
||||||
end,
|
end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(battle, _, _, display)
|
||||||
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
-- primary and secondary paralysis share this line
|
||||||
return { Strings("%s's\nparalyzed! It may\nnot attack!", display) }
|
return { romText(battle and battle.data, "_ParalyzedMayNotAttackText",
|
||||||
|
"%s's\nparalyzed! It may\nnot attack!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -166,7 +179,8 @@ function Status.beforeMove(battler, rng, battle)
|
|||||||
end
|
end
|
||||||
if battler.flinched then
|
if battler.flinched then
|
||||||
battler.flinched = false
|
battler.flinched = false
|
||||||
return false, { Strings("%s\nflinched!", name(battler)) }
|
return false, { romText(battle and battle.data, "_FlinchedText",
|
||||||
|
"%s\nflinched!", name(battler)) }
|
||||||
end
|
end
|
||||||
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
||||||
local handler = record and record.beforeMove
|
local handler = record and record.beforeMove
|
||||||
@@ -184,23 +198,27 @@ function Status.beforeMove(battler, rng, battle)
|
|||||||
end
|
end
|
||||||
if battler.boundTurns and battler.boundTurns > 0 then
|
if battler.boundTurns and battler.boundTurns > 0 then
|
||||||
battler.boundTurns = battler.boundTurns - 1
|
battler.boundTurns = battler.boundTurns - 1
|
||||||
msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
|
msgs[#msgs + 1] = romText(battle and battle.data, "_CantMoveText",
|
||||||
|
"%s\ncan't move!", name(battler))
|
||||||
return false, msgs
|
return false, msgs
|
||||||
end
|
end
|
||||||
if battler.disabledTurns then
|
if battler.disabledTurns then
|
||||||
battler.disabledTurns = battler.disabledTurns - 1
|
battler.disabledTurns = battler.disabledTurns - 1
|
||||||
if battler.disabledTurns <= 0 then
|
if battler.disabledTurns <= 0 then
|
||||||
battler.disabledTurns, battler.disabledSlot = nil, nil
|
battler.disabledTurns, battler.disabledSlot = nil, nil
|
||||||
table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler)))
|
table.insert(msgs, romText(battle and battle.data, "_DisabledNoMoreText",
|
||||||
|
"%s's\ndisabled no more!", name(battler)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if battler.confusedTurns then
|
if battler.confusedTurns then
|
||||||
battler.confusedTurns = battler.confusedTurns - 1
|
battler.confusedTurns = battler.confusedTurns - 1
|
||||||
if battler.confusedTurns <= 0 then
|
if battler.confusedTurns <= 0 then
|
||||||
battler.confusedTurns = nil
|
battler.confusedTurns = nil
|
||||||
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
|
table.insert(msgs, romText(battle and battle.data, "_ConfusedNoMoreText",
|
||||||
|
"%s\nsnapped out of\nconfusion!", name(battler)))
|
||||||
else
|
else
|
||||||
table.insert(msgs, Strings("%s\nis confused!", name(battler)))
|
table.insert(msgs, romText(battle and battle.data, "_IsConfusedText",
|
||||||
|
"%s\nis confused!", name(battler)))
|
||||||
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
||||||
if rng(0, 255) < 128 then
|
if rng(0, 255) < 128 then
|
||||||
return false, msgs, true -- hurt itself
|
return false, msgs, true -- hurt itself
|
||||||
@@ -241,7 +259,8 @@ function Status.residual(battler, opponent, battle)
|
|||||||
dmg = math.min(dmg, mon.hp)
|
dmg = math.min(dmg, mon.hp)
|
||||||
mon.hp = mon.hp - dmg
|
mon.hp = mon.hp - dmg
|
||||||
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
||||||
table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler)))
|
table.insert(msgs, romText(battle and battle.data, "_HurtByLeechSeedText",
|
||||||
|
"LEECH SEED saps\n%s!", name(battler)))
|
||||||
end
|
end
|
||||||
return msgs
|
return msgs
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ local StatusRegistry = {}
|
|||||||
-- pokered's <USER>/<TARGET> text macros (home/text.asm
|
-- pokered's <USER>/<TARGET> text macros (home/text.asm
|
||||||
-- PlaceMoveUsersName): enemy-mon texts print "Enemy " before the name
|
-- PlaceMoveUsersName): enemy-mon texts print "Enemy " before the name
|
||||||
local function displayName(b)
|
local function displayName(b)
|
||||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||||
end
|
end
|
||||||
|
|
||||||
-- opts: toxic (start the Toxic counter), moveType (for the type gates),
|
-- opts: toxic (start the Toxic counter), moveType (for the type gates),
|
||||||
|
|||||||
@@ -20,9 +20,16 @@
|
|||||||
|
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
local TrainerAI = {}
|
local TrainerAI = {}
|
||||||
|
|
||||||
|
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the
|
||||||
|
-- enemy mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||||
|
local function displayName(b)
|
||||||
|
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||||
|
end
|
||||||
|
|
||||||
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
|
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
|
||||||
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
|
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
|
||||||
|
|
||||||
@@ -95,12 +102,16 @@ function TrainerAI.switchAction(battle)
|
|||||||
return { special = "aiSwitch", index = alive[1] }
|
return { special = "aiSwitch", index = alive[1] }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Apply an aiItem action to the enemy battler; returns messages.
|
-- Apply an aiItem action to the enemy battler; returns messages, already
|
||||||
|
-- final: the item line prints the raw nickname (AIPrintItemUseText has no
|
||||||
|
-- "Enemy " prefix in pokered), the stat lines carry it via displayName, so
|
||||||
|
-- the caller must not run these through prefixEnemy.
|
||||||
function TrainerAI.useItem(battle, item)
|
function TrainerAI.useItem(battle, item)
|
||||||
local enemy = battle.enemy
|
local enemy = battle.enemy
|
||||||
local trainerName = battle.trainer.name
|
local trainerName = battle.trainer.name
|
||||||
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
||||||
local msgs = { Strings("%s\nused %s!", trainerName, itemName) }
|
local msgs = { romText(battle.data, "_AIBattleUseItemText",
|
||||||
|
"%s\nused %s!", trainerName, itemName, enemy.name) }
|
||||||
if item == "FULL_HEAL" then
|
if item == "FULL_HEAL" then
|
||||||
enemy.mon.status = nil
|
enemy.mon.status = nil
|
||||||
enemy.toxicCounter = nil
|
enemy.toxicCounter = nil
|
||||||
@@ -113,10 +124,10 @@ function TrainerAI.useItem(battle, item)
|
|||||||
elseif X_STAT[item] then
|
elseif X_STAT[item] then
|
||||||
local stat = X_STAT[item]
|
local stat = X_STAT[item]
|
||||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
||||||
table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper()))
|
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper()))
|
||||||
elseif item == "GUARD_SPEC" then
|
elseif item == "GUARD_SPEC" then
|
||||||
enemy.mist = true
|
enemy.mist = true
|
||||||
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name))
|
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy)))
|
||||||
end
|
end
|
||||||
return msgs
|
return msgs
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ function Game:load()
|
|||||||
-- render pipelines dispatch off the merged dataset; point them at the
|
-- render pipelines dispatch off the merged dataset; point them at the
|
||||||
-- one the mods just merged into before anything can draw a frame
|
-- one the mods just merged into before anything can draw a frame
|
||||||
require("src.render.Pipelines").install(Data)
|
require("src.render.Pipelines").install(Data)
|
||||||
|
-- Same reason, same moment: TypeChart caches the merged type records in an
|
||||||
|
-- upvalue, and until now only BattleState loaded it, on entering a battle.
|
||||||
|
-- Every non-battle reader of a type -- the summary screen's TYPE1/TYPE2
|
||||||
|
-- rows, the move-select TYPE/ box -- ran against an unloaded module and got
|
||||||
|
-- the raw id back instead of the display name, so a translation could not
|
||||||
|
-- reach them. Loading here means a type reads the same whoever asks first.
|
||||||
|
require("src.battle.TypeChart").load(Data)
|
||||||
|
|
||||||
self.input = Input
|
self.input = Input
|
||||||
Input:init()
|
Input:init()
|
||||||
|
|||||||
@@ -143,21 +143,24 @@ LauncherView._refreshAutoHeight = refreshAutoHeight
|
|||||||
|
|
||||||
-- ------- lifecycle
|
-- ------- lifecycle
|
||||||
|
|
||||||
-- NX-only: FlexLove's init maps `performanceMonitoring = false` to true
|
-- All platforms: FlexLove used to map `performanceMonitoring = false` to
|
||||||
-- (`false or true`), which leaves layout/render timers + memory sampling on
|
-- true (`false or true`), leaving layout/render timers + memory sampling on
|
||||||
-- every immediate-mode frame and makes the pad cursor feel lagged. Force
|
-- every immediate-mode frame (pad-cursor lag on NX, scroll drag on desktop).
|
||||||
-- them off after init. Desktop keeps the library default. Exported so the
|
-- The vendored init is fixed, but force the flags off here too so a hot
|
||||||
-- engine tier can assert the Switch guards without drawing the full tree.
|
-- reload against an already-inited FlexLove stays clean. Exported so the
|
||||||
|
-- engine tier can assert the guards without drawing the full tree.
|
||||||
function LauncherView.applyNxPerfGuards(imp)
|
function LauncherView.applyNxPerfGuards(imp)
|
||||||
if not (imp and imp.isNX and FlexLove.isReady() and FlexLove._Performance) then
|
if not (imp and FlexLove.isReady() and FlexLove._Performance) then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
FlexLove._Performance.enabled = false
|
FlexLove._Performance.enabled = false
|
||||||
local mp = FlexLove._Performance._memoryProfiler
|
local mp = FlexLove._Performance._memoryProfiler
|
||||||
if mp then mp.enabled = false end
|
if mp then mp.enabled = false end
|
||||||
-- Immediate-mode rebuilds allocate a full tree every frame; the default
|
-- Immediate-mode rebuilds allocate a full tree every frame; the default
|
||||||
-- auto GC steps hitch the pad cursor on Switch. Less frequent steps, higher
|
-- "auto" GC strategy triggers a full blocking collect past 100 MB, a
|
||||||
-- threshold — desktop keeps FlexLove defaults.
|
-- visible hitch mid-scroll. Less frequent steps, higher threshold, on
|
||||||
|
-- every platform (was NX-only; desktop hit the same hitch, see the
|
||||||
|
-- scroll-sluggishness investigation).
|
||||||
if FlexLove._gcConfig then
|
if FlexLove._gcConfig then
|
||||||
FlexLove._gcConfig.strategy = "periodic"
|
FlexLove._gcConfig.strategy = "periodic"
|
||||||
FlexLove._gcConfig.interval = 90
|
FlexLove._gcConfig.interval = 90
|
||||||
@@ -332,12 +335,22 @@ end
|
|||||||
local function textWidth(size, text) return mfont(size):getWidth(text) end
|
local function textWidth(size, text) return mfont(size):getWidth(text) end
|
||||||
local function textHeight(size) return mfont(size):getHeight() end
|
local function textHeight(size) return mfont(size):getHeight() end
|
||||||
|
|
||||||
-- wrapped text height at a width, from the same font the element renders
|
-- wrapped text height at a width, from the same font the element renders.
|
||||||
|
-- Memoized: the immediate-mode rebuild asks for the same (size, width,
|
||||||
|
-- text) every frame for every visible row, and Font:getWrap re-shapes the
|
||||||
|
-- whole string each time - one of the hottest calls in the frame on mobile.
|
||||||
|
-- The key space is small (mod summaries and notes at a handful of widths).
|
||||||
|
local wrapHeightCache = {}
|
||||||
local function wrapHeight(size, text, width)
|
local function wrapHeight(size, text, width)
|
||||||
if not text or text == "" or (width or 0) <= 0 then return 0 end
|
if not text or text == "" or (width or 0) <= 0 then return 0 end
|
||||||
|
local key = size .. ":" .. width .. ":" .. text
|
||||||
|
local h = wrapHeightCache[key]
|
||||||
|
if h then return h end
|
||||||
local f = mfont(size)
|
local f = mfont(size)
|
||||||
local _, lines = f:getWrap(text, width)
|
local _, lines = f:getWrap(text, width)
|
||||||
return math.max(1, #lines) * f:getHeight()
|
h = math.max(1, #lines) * f:getHeight()
|
||||||
|
wrapHeightCache[key] = h
|
||||||
|
return h
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Every text size in this file is already scaled by m.s, so FlexLove's own
|
-- Every text size in this file is already scaled by m.s, so FlexLove's own
|
||||||
@@ -421,6 +434,45 @@ local function button(imp, parent, key, text, opts)
|
|||||||
return mk(p)
|
return mk(p)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Measured single-row width of a header's labels and buttons, using the
|
||||||
|
-- same integer-sized fonts label()/button() render with (button() adds
|
||||||
|
-- 12px horizontal padding + 1px border per side). Tab headers use this to
|
||||||
|
-- decide between one row and a split title/buttons pair: flexWrap cannot
|
||||||
|
-- save a narrow window here, because the engine does not grow an
|
||||||
|
-- auto-sized parent for wrapped children (#748 family), so a wrapped
|
||||||
|
-- button used to land on top of whatever followed the header.
|
||||||
|
local function headNeededW(m, items)
|
||||||
|
local w, n = 0, 0
|
||||||
|
for _, it in ipairs(items) do
|
||||||
|
local size = math.floor(it.size + 0.5)
|
||||||
|
local tw = math.ceil(textWidth(size, it.text))
|
||||||
|
w = w + (it.btn and (tw + 26) or tw)
|
||||||
|
n = n + 1
|
||||||
|
end
|
||||||
|
-- inter-item gaps, plus one gap of slack where the flex spacer sits
|
||||||
|
return w + n * (10 * m.s)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One header row when everything fits, otherwise a title row and a
|
||||||
|
-- right-aligned button row stacked under it. Returns the row for the
|
||||||
|
-- title/status labels and a function to call AFTER adding them, which
|
||||||
|
-- returns the row for the buttons (inserting the flex spacer so buttons
|
||||||
|
-- sit flush right in both shapes).
|
||||||
|
local function headRows(parent, m, items)
|
||||||
|
local split = headNeededW(m, items) > (parent._innerW or m.contentW)
|
||||||
|
local function row()
|
||||||
|
return mk({ parent = parent, width = "100%",
|
||||||
|
positioning = "flex", flexDirection = "horizontal",
|
||||||
|
alignItems = "center", gap = 10 * m.s })
|
||||||
|
end
|
||||||
|
local titleRow = row()
|
||||||
|
return titleRow, function()
|
||||||
|
local btnRow = split and row() or titleRow
|
||||||
|
mk({ parent = btnRow, flex = 1 })
|
||||||
|
return btnRow
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function card(parent, props)
|
local function card(parent, props)
|
||||||
local p = {
|
local p = {
|
||||||
parent = parent,
|
parent = parent,
|
||||||
@@ -1012,30 +1064,40 @@ end
|
|||||||
|
|
||||||
local function buildModsPanel(imp, parent, m)
|
local function buildModsPanel(imp, parent, m)
|
||||||
imp:_ensureMods()
|
imp:_ensureMods()
|
||||||
|
local ModUpdate = require("src.mods.ModUpdate")
|
||||||
local mods = imp.mods or {}
|
local mods = imp.mods or {}
|
||||||
local enabledCount = 0
|
local enabledCount = 0
|
||||||
for _, mod in ipairs(mods) do
|
for _, mod in ipairs(mods) do
|
||||||
if mod.enabled then enabledCount = enabledCount + 1 end
|
if mod.enabled then enabledCount = enabledCount + 1 end
|
||||||
end
|
end
|
||||||
|
|
||||||
local head = mk({ parent = parent, width = "100%",
|
local title = Strings("Mods")
|
||||||
positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap",
|
local count = Strings("%d of %d enabled", enabledCount, #mods)
|
||||||
alignItems = "center", gap = 10 * m.s })
|
local importLabel = imp:_modsImportButtonLabel()
|
||||||
label(head, Strings("Mods"), 22 * m.s + 4, C("white"), { textWrap = false })
|
local items = {
|
||||||
label(head, Strings("%d of %d enabled", enabledCount, #mods),
|
{ size = 22 * m.s + 4, text = title },
|
||||||
12 * m.s + 2, C("warn"), { textWrap = false })
|
{ size = 12 * m.s + 2, text = count },
|
||||||
mk({ parent = head, flex = 1 })
|
{ size = 13 * m.s + 1, text = importLabel, btn = true },
|
||||||
|
}
|
||||||
if #mods > 0 then
|
if #mods > 0 then
|
||||||
button(imp, head, "mods-enable-all", Strings("Enable all"), {
|
items[#items + 1] = { size = 11 * m.s + 1, text = Strings("Enable all"), btn = true }
|
||||||
|
items[#items + 1] = { size = 11 * m.s + 1, text = Strings("Disable all"), btn = true }
|
||||||
|
end
|
||||||
|
local head, buttonsRow = headRows(parent, m, items)
|
||||||
|
label(head, title, 22 * m.s + 4, C("white"), { textWrap = false })
|
||||||
|
label(head, count, 12 * m.s + 2, C("warn"), { textWrap = false })
|
||||||
|
local btnRow = buttonsRow()
|
||||||
|
if #mods > 0 then
|
||||||
|
button(imp, btnRow, "mods-enable-all", Strings("Enable all"), {
|
||||||
size = 11 * m.s + 1, kind = "neutral",
|
size = 11 * m.s + 1, kind = "neutral",
|
||||||
action = function() imp:_setAllMods(true) end,
|
action = function() imp:_setAllMods(true) end,
|
||||||
})
|
})
|
||||||
button(imp, head, "mods-disable-all", Strings("Disable all"), {
|
button(imp, btnRow, "mods-disable-all", Strings("Disable all"), {
|
||||||
size = 11 * m.s + 1, kind = "neutral",
|
size = 11 * m.s + 1, kind = "neutral",
|
||||||
action = function() imp:_setAllMods(false) end,
|
action = function() imp:_setAllMods(false) end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
button(imp, head, "mods-import", imp:_modsImportButtonLabel(), {
|
button(imp, btnRow, "mods-import", importLabel, {
|
||||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||||
action = function() imp:chooseMod() end,
|
action = function() imp:chooseMod() end,
|
||||||
})
|
})
|
||||||
@@ -1060,6 +1122,89 @@ local function buildModsPanel(imp, parent, m)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Sort row: Name / Popularity / Release date / Last updated. The choice
|
||||||
|
-- persists in options.modSort; data-less mods (no github field, or a
|
||||||
|
-- cache that predates the feature) sink to the bottom of data sorts.
|
||||||
|
local sortKey = imp.modSort or "name"
|
||||||
|
if imp.modSort == nil then
|
||||||
|
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
|
||||||
|
if ok and type(opts) == "table" and type(opts.modSort) == "string" then
|
||||||
|
sortKey = opts.modSort
|
||||||
|
imp.modSort = sortKey
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local sortRow = mk({ parent = parent, width = "100%",
|
||||||
|
positioning = "flex", flexDirection = "horizontal",
|
||||||
|
flexWrap = "wrap", alignItems = "center", gap = 6 * m.s })
|
||||||
|
label(sortRow, Strings("Sort:"), 11 * m.s + 2, C("detail"), { textWrap = false })
|
||||||
|
local sorts = {
|
||||||
|
{ key = "name", label = Strings("Name") },
|
||||||
|
{ key = "popularity", label = Strings("Popularity") },
|
||||||
|
{ key = "release", label = Strings("Release date") },
|
||||||
|
{ key = "updated", label = Strings("Last updated") },
|
||||||
|
}
|
||||||
|
for _, s in ipairs(sorts) do
|
||||||
|
local active = sortKey == s.key
|
||||||
|
local key = "mod-sort-" .. s.key
|
||||||
|
mk({
|
||||||
|
parent = sortRow, text = s.label,
|
||||||
|
textColor = active and C("green")
|
||||||
|
or (imp._hot[key] and C("white") or C("detail")),
|
||||||
|
textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false,
|
||||||
|
backgroundColor = active and C("green", 0.18) or C("border", 0.10),
|
||||||
|
border = 1,
|
||||||
|
borderColor = active and C("green", 0.6) or C("border", 0.35),
|
||||||
|
cornerRadius = 999,
|
||||||
|
padding = { horizontal = 10, vertical = 4 },
|
||||||
|
onEvent = handler(imp, key, function()
|
||||||
|
imp.modSort = s.key
|
||||||
|
pcall(function()
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local opts = SaveData.loadOptions()
|
||||||
|
opts.modSort = s.key
|
||||||
|
SaveData.saveOptions(opts)
|
||||||
|
end)
|
||||||
|
end),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Immediate mode rebuilds this panel every frame; re-sorting the whole
|
||||||
|
-- mod list per frame (with lowercased-string allocations in the
|
||||||
|
-- comparator) fed the GC for nothing. Cache the sorted array, keyed on
|
||||||
|
-- the list identity/length, the sort mode, and the update-info revision
|
||||||
|
-- that _syncModUpdateInfo bumps when release data changes.
|
||||||
|
local cache = imp._modSortCache
|
||||||
|
if cache and cache.src == mods and cache.n == #mods
|
||||||
|
and cache.key == sortKey and cache.rev == (imp._modUpdateRev or 0) then
|
||||||
|
mods = cache.list
|
||||||
|
else
|
||||||
|
local sorted = {}
|
||||||
|
for i, v in ipairs(mods) do sorted[i] = v end
|
||||||
|
table.sort(sorted, function(a, b)
|
||||||
|
local function value(mod)
|
||||||
|
if sortKey == "name" then return (mod.name or ""):lower() end
|
||||||
|
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
|
||||||
|
if sortKey == "popularity" then
|
||||||
|
return info and info.downloads and info.downloads.total or -1
|
||||||
|
end
|
||||||
|
local date = info and info.dates
|
||||||
|
if sortKey == "release" then
|
||||||
|
return date and date.first or "0000-00-00"
|
||||||
|
end
|
||||||
|
return date and date.latest or "0000-00-00"
|
||||||
|
end
|
||||||
|
local va, vb = value(a), value(b)
|
||||||
|
if va ~= vb then
|
||||||
|
if sortKey == "name" then return va < vb end
|
||||||
|
return va > vb -- data sorts newest / most popular first
|
||||||
|
end
|
||||||
|
return (a.name or ""):lower() < (b.name or ""):lower()
|
||||||
|
end)
|
||||||
|
imp._modSortCache = { src = mods, n = #mods, key = sortKey,
|
||||||
|
rev = imp._modUpdateRev or 0, list = sorted }
|
||||||
|
mods = sorted
|
||||||
|
end
|
||||||
|
|
||||||
-- Explicit column widths AND heights: a flex-grown container collapses
|
-- Explicit column widths AND heights: a flex-grown container collapses
|
||||||
-- its children's layout in this engine, and card auto-height came up
|
-- its children's layout in this engine, and card auto-height came up
|
||||||
-- short on some displays, dropping the bottom action row out of the card.
|
-- short on some displays, dropping the bottom action row out of the card.
|
||||||
@@ -1091,6 +1236,19 @@ local function buildModsPanel(imp, parent, m)
|
|||||||
elseif mod.github and mod.github ~= "" then
|
elseif mod.github and mod.github ~= "" then
|
||||||
checkLine, checkCol = Strings("Not checked for updates yet"), "warn"
|
checkLine, checkCol = Strings("Not checked for updates yet"), "warn"
|
||||||
end
|
end
|
||||||
|
-- Total downloads plus first/latest release dates, all from the same
|
||||||
|
-- cached release fetch. Only shown once that data actually carries
|
||||||
|
-- counts, so a pre-downloads cache entry costs the line, not a wrong "0".
|
||||||
|
local dlLine
|
||||||
|
if info and info.downloads then
|
||||||
|
local formatted = ModUpdate.formatCount(info.downloads.total)
|
||||||
|
if info.dates then
|
||||||
|
dlLine = Strings("%s downloads across all releases - Released %s - Updated %s",
|
||||||
|
formatted, info.dates.first, info.dates.latest)
|
||||||
|
else
|
||||||
|
dlLine = Strings("%s downloads across all releases", formatted)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- measure the body: name (with the badge beside it only when it fits),
|
-- measure the body: name (with the badge beside it only when it fits),
|
||||||
-- version, check line, wrapped description
|
-- version, check line, wrapped description
|
||||||
@@ -1104,6 +1262,9 @@ local function buildModsPanel(imp, parent, m)
|
|||||||
if checkLine then
|
if checkLine then
|
||||||
bodyH = bodyH + 4 + wrapHeight(smallSize, checkLine, bodyW)
|
bodyH = bodyH + 4 + wrapHeight(smallSize, checkLine, bodyW)
|
||||||
end
|
end
|
||||||
|
if dlLine then
|
||||||
|
bodyH = bodyH + 4 + wrapHeight(smallSize, dlLine, bodyW)
|
||||||
|
end
|
||||||
if mod.description ~= "" then
|
if mod.description ~= "" then
|
||||||
bodyH = bodyH + 4 + wrapHeight(smallSize, mod.description, bodyW)
|
bodyH = bodyH + 4 + wrapHeight(smallSize, mod.description, bodyW)
|
||||||
end
|
end
|
||||||
@@ -1155,6 +1316,9 @@ local function buildModsPanel(imp, parent, m)
|
|||||||
if checkLine then
|
if checkLine then
|
||||||
label(body, checkLine, smallSize, C(checkCol), { width = "100%" })
|
label(body, checkLine, smallSize, C(checkCol), { width = "100%" })
|
||||||
end
|
end
|
||||||
|
if dlLine then
|
||||||
|
label(body, dlLine, smallSize, C("gold"), { width = "100%" })
|
||||||
|
end
|
||||||
if mod.description ~= "" then
|
if mod.description ~= "" then
|
||||||
label(body, mod.description, smallSize, C("detail"), { width = "100%" })
|
label(body, mod.description, smallSize, C("detail"), { width = "100%" })
|
||||||
end
|
end
|
||||||
@@ -1212,18 +1376,26 @@ local function buildFindPanel(imp, parent, m)
|
|||||||
local rows = imp:_findRows()
|
local rows = imp:_findRows()
|
||||||
local total = #((imp.findIndex and imp.findIndex.mods) or {})
|
local total = #((imp.findIndex and imp.findIndex.mods) or {})
|
||||||
|
|
||||||
local head = mk({ parent = parent, width = "100%",
|
local title = Strings("Find Mods")
|
||||||
positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap",
|
local count = (#sources > 0) and ((#rows == total) and Strings("%d mods listed", total)
|
||||||
alignItems = "center", gap = 10 * m.s })
|
or Strings("%d of %d mods", #rows, total)) or nil
|
||||||
label(head, Strings("Find Mods"), 22 * m.s + 4, C("white"), { textWrap = false })
|
local addLabel = (#sources == 0) and Strings("Add an index") or Strings("Add index")
|
||||||
|
local items = {
|
||||||
|
{ size = 22 * m.s + 4, text = title },
|
||||||
|
{ size = 13 * m.s + 1, text = addLabel, btn = true },
|
||||||
|
}
|
||||||
|
if count then items[#items + 1] = { size = 12 * m.s + 2, text = count } end
|
||||||
if #sources > 0 then
|
if #sources > 0 then
|
||||||
label(head, (#rows == total) and Strings("%d mods listed", total)
|
items[#items + 1] = { size = 13 * m.s + 1, text = Strings("Refresh"), btn = true }
|
||||||
or Strings("%d of %d mods", #rows, total), 12 * m.s + 2, C("warn"),
|
|
||||||
{ textWrap = false })
|
|
||||||
end
|
end
|
||||||
mk({ parent = head, flex = 1 })
|
local head, buttonsRow = headRows(parent, m, items)
|
||||||
|
label(head, title, 22 * m.s + 4, C("white"), { textWrap = false })
|
||||||
|
if count then
|
||||||
|
label(head, count, 12 * m.s + 2, C("warn"), { textWrap = false })
|
||||||
|
end
|
||||||
|
local btnRow = buttonsRow()
|
||||||
if #sources > 0 then
|
if #sources > 0 then
|
||||||
button(imp, head, "find-refresh", Strings("Refresh"), {
|
button(imp, btnRow, "find-refresh", Strings("Refresh"), {
|
||||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||||
action = function()
|
action = function()
|
||||||
imp._findSearchFocus = false
|
imp._findSearchFocus = false
|
||||||
@@ -1232,11 +1404,10 @@ local function buildFindPanel(imp, parent, m)
|
|||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
button(imp, head, "find-add",
|
button(imp, btnRow, "find-add", addLabel, {
|
||||||
(#sources == 0) and Strings("Add an index") or Strings("Add index"), {
|
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
action = function() imp:_promptAddIndex() end,
|
||||||
action = function() imp:_promptAddIndex() end,
|
})
|
||||||
})
|
|
||||||
|
|
||||||
if imp.findNotice then
|
if imp.findNotice then
|
||||||
label(parent, imp.findNotice.text, 12 * m.s + 2,
|
label(parent, imp.findNotice.text, 12 * m.s + 2,
|
||||||
|
|||||||
@@ -2593,6 +2593,8 @@ function RomImporter:_syncModUpdateInfo(force)
|
|||||||
latest = best and best.version or nil,
|
latest = best and best.version or nil,
|
||||||
best = best,
|
best = best,
|
||||||
releases = packed.releases,
|
releases = packed.releases,
|
||||||
|
downloads = ModUpdate.totalDownloads(packed.releases),
|
||||||
|
dates = ModUpdate.releaseDates(packed.releases),
|
||||||
err = nil,
|
err = nil,
|
||||||
checkedAt = packed.checkedAt or os.time(),
|
checkedAt = packed.checkedAt or os.time(),
|
||||||
}
|
}
|
||||||
@@ -2609,6 +2611,9 @@ function RomImporter:_syncModUpdateInfo(force)
|
|||||||
self.modUpdateInfo[m.id] = nil
|
self.modUpdateInfo[m.id] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- Bump so the view's sorted-list cache (keyed on this revision) rebuilds
|
||||||
|
-- when release/download data actually changes, not every frame.
|
||||||
|
self._modUpdateRev = (self._modUpdateRev or 0) + 1
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_modUpdateInfo(id)
|
function RomImporter:_modUpdateInfo(id)
|
||||||
@@ -2722,6 +2727,8 @@ function RomImporter:_modGithubAction(id, action)
|
|||||||
self.modUpdateInfo[row.id] = {
|
self.modUpdateInfo[row.id] = {
|
||||||
status = status, latest = best and best.version, best = best,
|
status = status, latest = best and best.version, best = best,
|
||||||
releases = releases,
|
releases = releases,
|
||||||
|
downloads = ModUpdate.totalDownloads(releases),
|
||||||
|
dates = ModUpdate.releaseDates(releases),
|
||||||
}
|
}
|
||||||
self._modVersions = {
|
self._modVersions = {
|
||||||
id = row.id, name = row.name, current = row.version,
|
id = row.id, name = row.name, current = row.version,
|
||||||
@@ -2765,6 +2772,8 @@ function RomImporter:_modGithubAction(id, action)
|
|||||||
self.modUpdateInfo[row.id] = {
|
self.modUpdateInfo[row.id] = {
|
||||||
status = status, latest = best and best.version, best = best,
|
status = status, latest = best and best.version, best = best,
|
||||||
releases = releases, checkedAt = os.time(),
|
releases = releases, checkedAt = os.time(),
|
||||||
|
downloads = ModUpdate.totalDownloads(releases),
|
||||||
|
dates = ModUpdate.releaseDates(releases),
|
||||||
}
|
}
|
||||||
if status == "available" and best then
|
if status == "available" and best then
|
||||||
self.modNotice = { ok = true,
|
self.modNotice = { ok = true,
|
||||||
@@ -2980,12 +2989,22 @@ end
|
|||||||
-- The rows the filters leave, and the installed-mod context the compatibility
|
-- The rows the filters leave, and the installed-mod context the compatibility
|
||||||
-- warnings are judged against.
|
-- warnings are judged against.
|
||||||
function RomImporter:_findRows()
|
function RomImporter:_findRows()
|
||||||
local ModIndex = require("src.mods.ModIndex")
|
|
||||||
local all = (self.findIndex and self.findIndex.mods) or {}
|
local all = (self.findIndex and self.findIndex.mods) or {}
|
||||||
return ModIndex.filter(all, {
|
-- The view asks every frame (immediate mode); only re-filter when the
|
||||||
|
-- index, query, or category actually changed.
|
||||||
|
local c = self._findRowsCache
|
||||||
|
if c and c.src == all and c.query == self.findQuery
|
||||||
|
and c.category == self.findCategory then
|
||||||
|
return c.rows
|
||||||
|
end
|
||||||
|
local ModIndex = require("src.mods.ModIndex")
|
||||||
|
local rows = ModIndex.filter(all, {
|
||||||
query = self.findQuery,
|
query = self.findQuery,
|
||||||
category = self.findCategory,
|
category = self.findCategory,
|
||||||
})
|
})
|
||||||
|
self._findRowsCache = { src = all, query = self.findQuery,
|
||||||
|
category = self.findCategory, rows = rows }
|
||||||
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_findInstalledMap()
|
function RomImporter:_findInstalledMap()
|
||||||
|
|||||||
@@ -12,9 +12,19 @@
|
|||||||
|
|
||||||
local Flags = require("src.script.Flags")
|
local Flags = require("src.script.Flags")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
local ItemEffects = {}
|
local ItemEffects = {}
|
||||||
|
|
||||||
|
local function notTime(data, save)
|
||||||
|
return romText(data, "_ItemUseNotTimeText",
|
||||||
|
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function noEffect(data)
|
||||||
|
return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.")
|
||||||
|
end
|
||||||
|
|
||||||
local HEAL_AMOUNT = {
|
local HEAL_AMOUNT = {
|
||||||
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
|
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
|
||||||
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
|
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
|
||||||
@@ -82,6 +92,14 @@ local function cureActiveToxic(battle, target)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- the per-item cure lines (item_effects.asm .cureStatusAilment picks the
|
||||||
|
-- text by item id); FULL_RESTORE lands here too when it acts as a cure
|
||||||
|
local CURE_TEXT = {
|
||||||
|
ANTIDOTE = "_AntidoteText", BURN_HEAL = "_BurnHealText",
|
||||||
|
ICE_HEAL = "_IceHealText", AWAKENING = "_AwakeningText",
|
||||||
|
PARLYZ_HEAL = "_ParlyzHealText", FULL_HEAL = "_FullHealText",
|
||||||
|
}
|
||||||
|
|
||||||
-- battle-only stat boosters (engine/items/item_effects.asm ItemUseXStat)
|
-- battle-only stat boosters (engine/items/item_effects.asm ItemUseXStat)
|
||||||
local X_ITEMS = {
|
local X_ITEMS = {
|
||||||
X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed",
|
X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed",
|
||||||
@@ -130,8 +148,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
|
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
|
||||||
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
||||||
or (itemDef and itemDef.machine)) then
|
or (itemDef and itemDef.machine)) then
|
||||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
return "failed", { notTime(data, save) }
|
||||||
save.player.name) }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if BALLS[itemId] then
|
if BALLS[itemId] then
|
||||||
@@ -148,13 +165,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- data/scripts/story.lua's snorlaxWake)
|
-- data/scripts/story.lua's snorlaxWake)
|
||||||
local mapId, npc = adjacentSleepingSnorlax(save, ow)
|
local mapId, npc = adjacentSleepingSnorlax(save, ow)
|
||||||
if npc then
|
if npc then
|
||||||
return "flute_wake", { data.text._PlayedFluteHadEffectText
|
return "flute_wake", { romText(data, "_PlayedFluteHadEffectText",
|
||||||
or Strings("{PLAYER} played the\nPOKé FLUTE.") },
|
"{PLAYER} played the\nPOKé FLUTE.") },
|
||||||
{ mapId = mapId, npc = npc }
|
{ mapId = mapId, npc = npc }
|
||||||
end
|
end
|
||||||
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
|
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
|
||||||
-- PlayedFluteNoEffectText branch)
|
-- PlayedFluteNoEffectText branch)
|
||||||
return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
return "flute_field", { romText(data, "_PlayedFluteNoEffectText",
|
||||||
|
"Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||||
end
|
end
|
||||||
local woke = false
|
local woke = false
|
||||||
local function wake(mon)
|
local function wake(mon)
|
||||||
@@ -169,17 +187,20 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- WakeUpEntireParty runs on the enemy's bench too
|
-- WakeUpEntireParty runs on the enemy's bench too
|
||||||
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
|
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
|
||||||
if not woke then
|
if not woke then
|
||||||
return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
return "failed", { romText(data, "_PlayedFluteNoEffectText",
|
||||||
|
"Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||||
end
|
end
|
||||||
return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
|
return "flute", { romText(data, "_PlayedFluteHadEffectText",
|
||||||
Strings("All sleeping\nPOKéMON woke up!") }
|
"%s played the\nPOKé FLUTE.", save.player.name),
|
||||||
|
romText(data, "_FluteWokeUpText",
|
||||||
|
"All sleeping\nPOKéMON woke up!") }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- battle-only items
|
-- battle-only items
|
||||||
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
||||||
or itemId == "POKE_DOLL" then
|
or itemId == "POKE_DOLL" then
|
||||||
if not battle then
|
if not battle then
|
||||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
return "failed", { notTime(data, save) }
|
||||||
end
|
end
|
||||||
local b = battle.player
|
local b = battle.player
|
||||||
-- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy /
|
-- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy /
|
||||||
@@ -201,7 +222,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
||||||
-- just prints "Nothing happened!"
|
-- just prints "Nothing happened!"
|
||||||
if cur >= 6 then
|
if cur >= 6 then
|
||||||
return "consumed", { Strings("Nothing happened!") }
|
return "consumed", { romText(data, "_NothingHappenedText",
|
||||||
|
"Nothing happened!") }
|
||||||
end
|
end
|
||||||
b.stages[stat] = cur + 1
|
b.stages[stat] = cur + 1
|
||||||
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
|
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
|
||||||
@@ -210,7 +232,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- the item, even when it is already active
|
-- the item, even when it is already active
|
||||||
if itemId == "DIRE_HIT" then
|
if itemId == "DIRE_HIT" then
|
||||||
b.focusEnergy = true
|
b.focusEnergy = true
|
||||||
return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
|
return "consumed", { romText(data, "_GettingPumpedText",
|
||||||
|
"%s's\ngetting pumped!", b.name) }
|
||||||
end
|
end
|
||||||
if itemId == "GUARD_SPEC" then
|
if itemId == "GUARD_SPEC" then
|
||||||
b.mist = true
|
b.mist = true
|
||||||
@@ -219,10 +242,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
if itemId == "POKE_DOLL" then
|
if itemId == "POKE_DOLL" then
|
||||||
if battle.kind ~= "wild" then
|
if battle.kind ~= "wild" then
|
||||||
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
||||||
return "failed", { Strings(
|
return "failed", { notTime(data, save) }
|
||||||
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
|
||||||
end
|
end
|
||||||
return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
|
return "consumed_escape", { romText(data, "_WildRanText",
|
||||||
|
"The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -231,7 +254,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- restore every move with no menu.
|
-- restore every move with no menu.
|
||||||
if itemId == "ETHER" or itemId == "MAX_ETHER"
|
if itemId == "ETHER" or itemId == "MAX_ETHER"
|
||||||
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
|
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
|
||||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
if not target then return "failed", { noEffect(data) } end
|
||||||
local restored = false
|
local restored = false
|
||||||
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
|
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
|
||||||
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
|
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
|
||||||
@@ -253,9 +276,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
restored = mv and restore(mv) or false
|
restored = mv and restore(mv) or false
|
||||||
end
|
end
|
||||||
if not restored then
|
if not restored then
|
||||||
return "failed", { Strings("It won't have\nany effect.") }
|
return "failed", { noEffect(data) }
|
||||||
end
|
end
|
||||||
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
-- pokered's line names no mon, so the extracted text takes no args
|
||||||
|
return "consumed", { romText(data, "_PPRestoredText", "PP was restored.") }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to
|
-- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to
|
||||||
@@ -280,10 +304,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
target.status = nil
|
target.status = nil
|
||||||
cureActiveToxic(battle, target)
|
cureActiveToxic(battle, target)
|
||||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||||
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
return "consumed", { romText(data, CURE_TEXT.FULL_HEAL,
|
||||||
|
"%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||||
end
|
end
|
||||||
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
|
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
|
||||||
return "failed", { Strings("It won't have\nany effect.") }
|
return "failed", { noEffect(data) }
|
||||||
end
|
end
|
||||||
-- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE
|
-- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE
|
||||||
-- the item landed (item_effects.asm latches it with the party menu still
|
-- the item landed (item_effects.asm latches it with the party menu still
|
||||||
@@ -295,7 +320,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
else
|
else
|
||||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||||
end
|
end
|
||||||
local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) }
|
-- _PotionText's second slot is the recovered amount ({NUM:
|
||||||
|
-- wHPBarHPDifference}); the engine fallback never prints it
|
||||||
|
local msgs = { romText(data, "_PotionText", "%s's HP\nwas restored!",
|
||||||
|
monName(data, target), target.hp - before) }
|
||||||
if itemId == "FULL_RESTORE" then
|
if itemId == "FULL_RESTORE" then
|
||||||
target.status = nil
|
target.status = nil
|
||||||
cureActiveToxic(battle, target)
|
cureActiveToxic(battle, target)
|
||||||
@@ -307,17 +335,18 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
local cures = STATUS_HEAL[itemId]
|
local cures = STATUS_HEAL[itemId]
|
||||||
if cures then
|
if cures then
|
||||||
if not target or not target.status or not cures[target.status] then
|
if not target or not target.status or not cures[target.status] then
|
||||||
return "failed", { Strings("It won't have\nany effect.") }
|
return "failed", { noEffect(data) }
|
||||||
end
|
end
|
||||||
target.status = nil
|
target.status = nil
|
||||||
cureActiveToxic(battle, target)
|
cureActiveToxic(battle, target)
|
||||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||||
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
return "consumed", { romText(data, CURE_TEXT[itemId],
|
||||||
|
"%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
|
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
|
||||||
if not target or target.hp > 0 then
|
if not target or target.hp > 0 then
|
||||||
return "failed", { Strings("It won't have\nany effect.") }
|
return "failed", { noEffect(data) }
|
||||||
end
|
end
|
||||||
target.status = nil
|
target.status = nil
|
||||||
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
|
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
|
||||||
@@ -329,13 +358,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
if battle and battle.participants then
|
if battle and battle.participants then
|
||||||
battle.participants[target] = true
|
battle.participants[target] = true
|
||||||
end
|
end
|
||||||
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) },
|
return "consumed", { romText(data, "_ReviveText",
|
||||||
|
"%s\nis revitalized!", monName(data, target)) },
|
||||||
{ healedFrom = 0 }
|
{ healedFrom = 0 }
|
||||||
end
|
end
|
||||||
|
|
||||||
if itemId == "RARE_CANDY" then
|
if itemId == "RARE_CANDY" then
|
||||||
if not target or target.level >= 100 then
|
if not target or target.level >= 100 then
|
||||||
return "failed", { Strings("It won't have\nany effect.") }
|
return "failed", { noEffect(data) }
|
||||||
end
|
end
|
||||||
local Growth = require("src.pokemon.Growth")
|
local Growth = require("src.pokemon.Growth")
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
@@ -348,12 +378,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540)
|
-- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540)
|
||||||
require("src.world.PikachuFollower")
|
require("src.world.PikachuFollower")
|
||||||
.modifyHappiness(save, "LEVELUP", target)
|
.modifyHappiness(save, "LEVELUP", target)
|
||||||
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
|
return "consumed", { romText(data, "_RareCandyText",
|
||||||
|
"%s grew\nto level %d!", monName(data, target), target.level) },
|
||||||
{ leveledTo = target.level }
|
{ leveledTo = target.level }
|
||||||
end
|
end
|
||||||
|
|
||||||
if STONES[itemId] then
|
if STONES[itemId] then
|
||||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
if not target then return "failed", { noEffect(data) } end
|
||||||
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
|
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
|
||||||
-- IsThisPartyMonStarterPikachu (OT identity match) before
|
-- IsThisPartyMonStarterPikachu (OT identity match) before
|
||||||
-- TryEvolvingMon and bails with the voiced cry + RefusingText.
|
-- TryEvolvingMon and bails with the voiced cry + RefusingText.
|
||||||
@@ -363,10 +394,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
and target.ot == save.player.name
|
and target.ot == save.player.name
|
||||||
and target.otId == save.player.id then
|
and target.otId == save.player.id then
|
||||||
require("src.core.Sound").playCry(data, "PIKACHU")
|
require("src.core.Sound").playCry(data, "PIKACHU")
|
||||||
local raw = data.text and data.text._RefusingText
|
return "failed", { romText(data, "_RefusingText",
|
||||||
local line = raw and raw:gsub("{RAM:[^}]*}", monName(data, target))
|
"%s\nis refusing!", monName(data, target)) }
|
||||||
or Strings("%s\nis refusing!", monName(data, target))
|
|
||||||
return "failed", { line }
|
|
||||||
end
|
end
|
||||||
local speciesDef = data.pokemon[target.species]
|
local speciesDef = data.pokemon[target.species]
|
||||||
for _, evo in ipairs(speciesDef.evolutions) do
|
for _, evo in ipairs(speciesDef.evolutions) do
|
||||||
@@ -374,44 +403,48 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
return "consumed", nil, { evolveTo = evo.species }
|
return "consumed", nil, { evolveTo = evo.species }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return "failed", { Strings("It won't have\nany effect.") }
|
return "failed", { noEffect(data) }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
|
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
|
||||||
-- engine/items/item_effects.asm)
|
-- engine/items/item_effects.asm)
|
||||||
local vitaminStat = VITAMINS[itemId]
|
local vitaminStat = VITAMINS[itemId]
|
||||||
if vitaminStat then
|
if vitaminStat then
|
||||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
if not target then return "failed", { noEffect(data) } end
|
||||||
target.statExp = target.statExp or {}
|
target.statExp = target.statExp or {}
|
||||||
local cur = target.statExp[vitaminStat] or 0
|
local cur = target.statExp[vitaminStat] or 0
|
||||||
if cur >= 25600 then
|
if cur >= 25600 then
|
||||||
return "failed", { Strings("It won't have\nany effect.") }
|
return "failed", { noEffect(data) }
|
||||||
end
|
end
|
||||||
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
|
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
target.stats = Stats.calc(data.pokemon[target.species], target.level,
|
target.stats = Stats.calc(data.pokemon[target.species], target.level,
|
||||||
target.dvs, target.statExp)
|
target.dvs, target.statExp)
|
||||||
target.hp = math.min(target.hp, target.stats.hp)
|
target.hp = math.min(target.hp, target.stats.hp)
|
||||||
|
-- _VitaminStatRoseText's slot order is localization-dependent (the
|
||||||
|
-- Spanish ROM puts the stat before the name), so the extracted line
|
||||||
|
-- cannot be filled positionally; the engine wording stands
|
||||||
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||||
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||||
if itemId == "PP_UP" then
|
if itemId == "PP_UP" then
|
||||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
if not target then return "failed", { noEffect(data) } end
|
||||||
local mv = target.moves[moveIndex or 1]
|
local mv = target.moves[moveIndex or 1]
|
||||||
local mdef = mv and data.moves[mv.id]
|
local mdef = mv and data.moves[mv.id]
|
||||||
if mdef and (mv.ppUps or 0) < 3 then
|
if mdef and (mv.ppUps or 0) < 3 then
|
||||||
mv.ppUps = (mv.ppUps or 0) + 1
|
mv.ppUps = (mv.ppUps or 0) + 1
|
||||||
-- each PP UP adds maxPP/5 uses on top of the base maximum
|
-- each PP UP adds maxPP/5 uses on top of the base maximum
|
||||||
mv.pp = mv.pp + math.floor(mdef.pp / 5)
|
mv.pp = mv.pp + math.floor(mdef.pp / 5)
|
||||||
return "consumed", { Strings("%s's PP\nincreased!", mdef.name) }
|
return "consumed", { romText(data, "_PPIncreasedText",
|
||||||
|
"%s's PP\nincreased!", mdef.name) }
|
||||||
end
|
end
|
||||||
return "failed", { Strings("It won't have\nany effect.") }
|
return "failed", { noEffect(data) }
|
||||||
end
|
end
|
||||||
|
|
||||||
if itemDef and itemDef.machine then
|
if itemDef and itemDef.machine then
|
||||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
if not target then return "failed", { noEffect(data) } end
|
||||||
local speciesDef = data.pokemon[target.species]
|
local speciesDef = data.pokemon[target.species]
|
||||||
local ok = false
|
local ok = false
|
||||||
for _, m in ipairs(speciesDef.tmhm) do
|
for _, m in ipairs(speciesDef.tmhm) do
|
||||||
@@ -422,11 +455,16 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
|
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
|
||||||
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
|
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
|
||||||
require("src.core.Sound").play(data, "Denied")
|
require("src.core.Sound").play(data, "Denied")
|
||||||
return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) }
|
local moveName = data.moves[itemDef.machine.move].name
|
||||||
|
return "failed", { romText(data, "_MonCannotLearnMachineMoveText",
|
||||||
|
"%s can't\nlearn that move!",
|
||||||
|
monName(data, target), moveName, moveName) }
|
||||||
end
|
end
|
||||||
for _, mv in ipairs(target.moves) do
|
for _, mv in ipairs(target.moves) do
|
||||||
if mv.id == itemDef.machine.move then
|
if mv.id == itemDef.machine.move then
|
||||||
return "failed", { Strings("It knows that\nmove already!") }
|
return "failed", { romText(data, "_AlreadyKnowsText",
|
||||||
|
"It knows that\nmove already!",
|
||||||
|
monName(data, target), data.moves[itemDef.machine.move].name) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- HMs are never consumed; TMs are single-use
|
-- HMs are never consumed; TMs are single-use
|
||||||
@@ -435,21 +473,21 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
|
|
||||||
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
|
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
|
||||||
if battle then
|
if battle then
|
||||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
return "failed", { notTime(data, save) }
|
||||||
end
|
end
|
||||||
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
|
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
|
||||||
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
|
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
|
||||||
-- on that carry -- surfing refuses the rod with the same OAK text as
|
-- on that carry -- surfing refuses the rod with the same OAK text as
|
||||||
-- the mid-battle case above, no rod-specific message (#533)
|
-- the mid-battle case above, no rod-specific message (#533)
|
||||||
if ow and ow.player and ow.player.surfing then
|
if ow and ow.player and ow.player.surfing then
|
||||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
return "failed", { notTime(data, save) }
|
||||||
end
|
end
|
||||||
return "fish", itemId
|
return "fish", itemId
|
||||||
end
|
end
|
||||||
|
|
||||||
if itemId == "BICYCLE" then
|
if itemId == "BICYCLE" then
|
||||||
if battle then
|
if battle then
|
||||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
return "failed", { notTime(data, save) }
|
||||||
end
|
end
|
||||||
return "bicycle"
|
return "bicycle"
|
||||||
end
|
end
|
||||||
@@ -459,18 +497,19 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
end
|
end
|
||||||
if itemId == "TOWN_MAP" then
|
if itemId == "TOWN_MAP" then
|
||||||
if battle then
|
if battle then
|
||||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
return "failed", { notTime(data, save) }
|
||||||
end
|
end
|
||||||
return "townmap"
|
return "townmap"
|
||||||
end
|
end
|
||||||
if itemId == "ITEMFINDER" then
|
if itemId == "ITEMFINDER" then
|
||||||
if battle then
|
if battle then
|
||||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
return "failed", { notTime(data, save) }
|
||||||
end
|
end
|
||||||
return "itemfinder"
|
return "itemfinder"
|
||||||
end
|
end
|
||||||
if itemId == "COIN_CASE" then
|
if itemId == "COIN_CASE" then
|
||||||
return "failed", { Strings("Coin count:\n%d", save.coins or 0) }
|
return "failed", { romText(data, "_CoinCaseNumCoinsText",
|
||||||
|
"Coin count:\n%d", save.coins or 0) }
|
||||||
end
|
end
|
||||||
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
|
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
|
||||||
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
||||||
@@ -478,7 +517,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
|
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
|
||||||
end
|
end
|
||||||
|
|
||||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
return "failed", { notTime(data, save) }
|
||||||
end
|
end
|
||||||
|
|
||||||
return ItemEffects
|
return ItemEffects
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ function ModUpdate.previewLine(text, maxChars)
|
|||||||
return s
|
return s
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Decode one GitHub release object into { version, tag, zip, prerelease, body }.
|
-- Decode one GitHub release object into { version, tag, zip, prerelease,
|
||||||
|
-- name, body, downloads }. `downloads` is the sum of every asset's
|
||||||
|
-- download_count, GitHub's own measure of a release's downloads.
|
||||||
function ModUpdate.parseRelease(doc, modId)
|
function ModUpdate.parseRelease(doc, modId)
|
||||||
if type(doc) ~= "table" or not doc.tag_name then
|
if type(doc) ~= "table" or not doc.tag_name then
|
||||||
return nil, "no tag_name in release"
|
return nil, "no tag_name in release"
|
||||||
@@ -114,6 +116,13 @@ function ModUpdate.parseRelease(doc, modId)
|
|||||||
local triple = version:match("^(%d+%.%d+%.%d+)")
|
local triple = version:match("^(%d+%.%d+%.%d+)")
|
||||||
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
|
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
|
||||||
local body = type(doc.body) == "string" and doc.body or ""
|
local body = type(doc.body) == "string" and doc.body or ""
|
||||||
|
local downloads = 0
|
||||||
|
if type(doc.assets) == "table" then
|
||||||
|
for _, a in ipairs(doc.assets) do
|
||||||
|
local d = type(a) == "table" and tonumber(a.download_count)
|
||||||
|
if d and d > 0 then downloads = downloads + d end
|
||||||
|
end
|
||||||
|
end
|
||||||
return {
|
return {
|
||||||
version = triple,
|
version = triple,
|
||||||
tag = tostring(doc.tag_name),
|
tag = tostring(doc.tag_name),
|
||||||
@@ -121,6 +130,8 @@ function ModUpdate.parseRelease(doc, modId)
|
|||||||
prerelease = doc.prerelease == true,
|
prerelease = doc.prerelease == true,
|
||||||
name = type(doc.name) == "string" and doc.name or triple,
|
name = type(doc.name) == "string" and doc.name or triple,
|
||||||
body = body,
|
body = body,
|
||||||
|
downloads = downloads,
|
||||||
|
published = (doc.published_at or doc.created_at or ""):match("^(%d+%-%d+%-%d+)"),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -154,7 +165,7 @@ function ModUpdate.parseReleases(jsonText, modId, Json)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ModUpdate.apiReleasesUrl(repo)
|
function ModUpdate.apiReleasesUrl(repo)
|
||||||
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30"
|
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=100"
|
||||||
end
|
end
|
||||||
|
|
||||||
function ModUpdate.apiLatestUrl(repo)
|
function ModUpdate.apiLatestUrl(repo)
|
||||||
@@ -180,6 +191,52 @@ function ModUpdate.pickBest(releases)
|
|||||||
return releases[1]
|
return releases[1]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Sum of per-release download counts. Returns nil when no release carries
|
||||||
|
-- the field (a cache entry written before downloads existed), else
|
||||||
|
-- { total = <sum>, releases = <release count> } so a caller can tell a
|
||||||
|
-- real "0 downloads" apart from "no data yet".
|
||||||
|
function ModUpdate.totalDownloads(releases)
|
||||||
|
if type(releases) ~= "table" or #releases == 0 then return nil end
|
||||||
|
local total, hasData = 0, false
|
||||||
|
for _, rel in ipairs(releases) do
|
||||||
|
local d = type(rel) == "table" and tonumber(rel.downloads)
|
||||||
|
if d then
|
||||||
|
hasData = true
|
||||||
|
total = total + d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not hasData then return nil end
|
||||||
|
return { total = total, releases = #releases }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- First and latest release dates ("YYYY-MM-DD", ISO strings compare in
|
||||||
|
-- calendar order). Returns nil when no release carries a date -- same
|
||||||
|
-- "no data yet" rule as totalDownloads.
|
||||||
|
function ModUpdate.releaseDates(releases)
|
||||||
|
if type(releases) ~= "table" or #releases == 0 then return nil end
|
||||||
|
local first, latest, has = nil, nil, false
|
||||||
|
for _, rel in ipairs(releases) do
|
||||||
|
local p = type(rel) == "table" and rel.published
|
||||||
|
if type(p) == "string" and p ~= "" then
|
||||||
|
has = true
|
||||||
|
if not first or p < first then first = p end
|
||||||
|
if not latest or p > latest then latest = p end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not has then return nil end
|
||||||
|
return { first = first, latest = latest }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Thousands-separated count for the launcher ("12,345"), plain for small
|
||||||
|
-- numbers. Never throws; garbage in, "0" out.
|
||||||
|
function ModUpdate.formatCount(n)
|
||||||
|
n = tonumber(n)
|
||||||
|
if not n or n ~= n or n < 0 then return "0" end
|
||||||
|
local s = tostring(math.floor(n))
|
||||||
|
s = s:reverse():gsub("(%d%d%d)", "%1,"):reverse()
|
||||||
|
return (s:gsub("^,", ""))
|
||||||
|
end
|
||||||
|
|
||||||
-- ------- cache (options.modUpdateCache[repo])
|
-- ------- cache (options.modUpdateCache[repo])
|
||||||
|
|
||||||
local function cacheStore()
|
local function cacheStore()
|
||||||
@@ -210,6 +267,22 @@ function ModUpdate.cacheFresh(entry, now, ttl)
|
|||||||
and (now - entry.checkedAt) < ttl
|
and (now - entry.checkedAt) < ttl
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A cache entry written before download counts existed carries no
|
||||||
|
-- `downloads` on any release. It is provably stale -- parseRelease always
|
||||||
|
-- sets the field now -- so treat it as expired: the next fetch rewrites
|
||||||
|
-- the entry in the current format, one refetch per repo, and the launcher's
|
||||||
|
-- download line stops hiding behind an old cache.
|
||||||
|
function ModUpdate.cacheUsable(cached)
|
||||||
|
if type(cached) ~= "table" or type(cached.releases) ~= "table" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if #cached.releases == 0 then return true end
|
||||||
|
for _, rel in ipairs(cached.releases) do
|
||||||
|
if tonumber(rel.downloads) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
function ModUpdate.writeCache(repo, releases)
|
function ModUpdate.writeCache(repo, releases)
|
||||||
if type(repo) ~= "string" or repo == "" then return false end
|
if type(repo) ~= "string" or repo == "" then return false end
|
||||||
local ok = pcall(function()
|
local ok = pcall(function()
|
||||||
@@ -226,6 +299,8 @@ function ModUpdate.writeCache(repo, releases)
|
|||||||
name = rel.name,
|
name = rel.name,
|
||||||
prerelease = rel.prerelease == true,
|
prerelease = rel.prerelease == true,
|
||||||
body = type(rel.body) == "string" and rel.body or "",
|
body = type(rel.body) == "string" and rel.body or "",
|
||||||
|
downloads = tonumber(rel.downloads) or 0,
|
||||||
|
published = rel.published,
|
||||||
zip = rel.zip and {
|
zip = rel.zip and {
|
||||||
name = rel.zip.name,
|
name = rel.zip.name,
|
||||||
url = rel.zip.url,
|
url = rel.zip.url,
|
||||||
@@ -280,7 +355,7 @@ function ModUpdate.fetchReleases(repo, modId, opts)
|
|||||||
end
|
end
|
||||||
if not opts.force then
|
if not opts.force then
|
||||||
local cached = ModUpdate.readCache(repo)
|
local cached = ModUpdate.readCache(repo)
|
||||||
if ModUpdate.cacheFresh(cached) then
|
if ModUpdate.cacheFresh(cached) and ModUpdate.cacheUsable(cached) then
|
||||||
return cached.releases, nil, { fromCache = true }
|
return cached.releases, nil, { fromCache = true }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1064,6 +1064,12 @@ local function fontIsCharmap(id)
|
|||||||
return tostring(id):match("^charmap:.+$") ~= nil
|
return tostring(id):match("^charmap:.+$") ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- the third id form: "ttf" switches text rendering to a real TTF (the
|
||||||
|
-- bundled Plain Pixel when `file` is omitted -- src/render/Font.lua)
|
||||||
|
local function fontIsTtf(id)
|
||||||
|
return tostring(id) == "ttf"
|
||||||
|
end
|
||||||
|
|
||||||
R.font = {
|
R.font = {
|
||||||
semantics = "record", target = "font",
|
semantics = "record", target = "font",
|
||||||
value = f.union{
|
value = f.union{
|
||||||
@@ -1071,6 +1077,13 @@ R.font = {
|
|||||||
advance = f.opt(f.int(1)),
|
advance = f.opt(f.int(1)),
|
||||||
charmap = f.opt(f.list(f.rec{ code = f.int(0), seq = f.str })) },
|
charmap = f.opt(f.list(f.rec{ code = f.int(0), seq = f.str })) },
|
||||||
f.rec{ seq = f.str, code = f.int(0) },
|
f.rec{ seq = f.str, code = f.int(0) },
|
||||||
|
f.rec{ file = f.opt(f.path), size = f.opt(f.int(1)),
|
||||||
|
spacing = f.opt(f.num), yOffset = f.opt(f.num),
|
||||||
|
bold = f.opt(f.bool),
|
||||||
|
-- characters that keep their ROM tile instead of coming from the
|
||||||
|
-- TTF: a string of them, or a list when a multi-character charmap
|
||||||
|
-- sequence is meant (src/render/Font.lua)
|
||||||
|
tiles = f.opt(f.union{ f.str, f.list(f.str) }) },
|
||||||
},
|
},
|
||||||
extra = function(id, value)
|
extra = function(id, value)
|
||||||
if fontIsCharmap(id) then
|
if fontIsCharmap(id) then
|
||||||
@@ -1080,12 +1093,18 @@ R.font = {
|
|||||||
if type(value.code) ~= "number" then
|
if type(value.code) ~= "number" then
|
||||||
return "a charmap: entry needs a code"
|
return "a charmap: entry needs a code"
|
||||||
end
|
end
|
||||||
|
elseif fontIsTtf(id) then
|
||||||
|
-- every field optional: {} is "the bundled font at its native size"
|
||||||
|
if value.image ~= nil or value.base ~= nil then
|
||||||
|
return 'the "ttf" entry takes file/size/spacing/yOffset/bold/tiles, not a page'
|
||||||
|
end
|
||||||
elseif value.image == nil or value.base == nil then
|
elseif value.image == nil or value.base == nil then
|
||||||
return "a font page needs an image and a base"
|
return "a font page needs an image and a base"
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
baseAt = function(base, id)
|
baseAt = function(base, id)
|
||||||
if fontIsCharmap(id) then return nil end
|
if fontIsCharmap(id) then return nil end
|
||||||
|
if fontIsTtf(id) then return base.ttf end
|
||||||
return base.pages and base.pages[id] or nil
|
return base.pages and base.pages[id] or nil
|
||||||
end,
|
end,
|
||||||
baseIds = function(base)
|
baseIds = function(base)
|
||||||
@@ -1096,6 +1115,9 @@ R.font = {
|
|||||||
write = function(target, registry)
|
write = function(target, registry)
|
||||||
local pages = target.pages or {}
|
local pages = target.pages or {}
|
||||||
target.pages = pages
|
target.pages = pages
|
||||||
|
-- the extractor never emits a ttf entry, so like the charmap rows it is
|
||||||
|
-- rebuilt from the registry each merge: disabling the mod disables it
|
||||||
|
target.ttf = nil
|
||||||
-- the extractor's rows have no id and stay put; the registry's own are
|
-- the extractor's rows have no id and stay put; the registry's own are
|
||||||
-- rebuilt every merge so a re-merge replaces them instead of stacking
|
-- rebuilt every merge so a re-merge replaces them instead of stacking
|
||||||
local rows = {}
|
local rows = {}
|
||||||
@@ -1108,6 +1130,8 @@ R.font = {
|
|||||||
if value ~= nil then
|
if value ~= nil then
|
||||||
rows[#rows + 1] = { id = id, seq = value.seq, code = value.code }
|
rows[#rows + 1] = { id = id, seq = value.seq, code = value.code }
|
||||||
end
|
end
|
||||||
|
elseif fontIsTtf(id) then
|
||||||
|
target.ttf = value
|
||||||
else
|
else
|
||||||
pages[id] = value
|
pages[id] = value
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ local Screens = require("src.ui.Screens")
|
|||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
local Evolution = {}
|
local Evolution = {}
|
||||||
|
|
||||||
@@ -140,7 +141,8 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
|||||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("%s learned\n%s!", name, mdef.name), nextStep))
|
romText(game.data, "_LearnedMove1Text",
|
||||||
|
"%s learned\n%s!", name, mdef.name), nextStep))
|
||||||
else
|
else
|
||||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||||
@@ -161,8 +163,12 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
|||||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||||
Evolution.apply(game, mon, newSpecies, via)
|
Evolution.apply(game, mon, newSpecies, via)
|
||||||
local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
|
-- the congrats page keeps the engine wording: _EvolvedText extracts
|
||||||
oldName, oldName, game.data.pokemon[newSpecies].name)
|
-- truncated (it stops at a dynamic marker the decoder does not follow)
|
||||||
|
local msg = romText(game.data, "_IsEvolvingText",
|
||||||
|
"What?\n%s is\nevolving!", oldName)
|
||||||
|
.. "\f" .. Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||||
|
oldName, game.data.pokemon[newSpecies].name)
|
||||||
game.stack:push(TextBox.new(game, msg, function()
|
game.stack:push(TextBox.new(game, msg, function()
|
||||||
Music.restoreMap(game.data)
|
Music.restoreMap(game.data)
|
||||||
-- re-run the evolved species' level-up learn check before onDone
|
-- re-run the evolved species' level-up learn check before onDone
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
-- for variable-width text; the default is the GB's flat 8px.
|
-- for variable-width text; the default is the GB's flat 8px.
|
||||||
-- The charmap is matched greedily (longest sequence first) so multi-byte
|
-- The charmap is matched greedily (longest sequence first) so multi-byte
|
||||||
-- UTF-8 chars and ligature glyphs like 'd 'l 's map to single glyphs.
|
-- UTF-8 chars and ligature glyphs like 'd 'l 's map to single glyphs.
|
||||||
|
--
|
||||||
|
-- A translation may instead set data.font.ttf and render its text through a
|
||||||
|
-- real TTF (the bundled Plain Pixel by default), so a script that would need
|
||||||
|
-- hundreds of page tiles works out of the box. Single characters then draw
|
||||||
|
-- from the TTF; multi-character charmap sequences (<PK>, the 'd ligatures)
|
||||||
|
-- and the sub-0x80 chrome glyphs (borders, arrows) keep their tiles, which
|
||||||
|
-- is why the box border never depends on the TTF's coverage.
|
||||||
|
|
||||||
local Assets = require("src.render.Assets")
|
local Assets = require("src.render.Assets")
|
||||||
|
|
||||||
@@ -13,6 +20,19 @@ local Font = {}
|
|||||||
|
|
||||||
local GLYPH = 8
|
local GLYPH = 8
|
||||||
|
|
||||||
|
-- TTF glyph codes are the Unicode codepoint offset far above any page base,
|
||||||
|
-- so they flow through the same span/encode/drawCode pipeline as tiles.
|
||||||
|
local TTF_BASE = 0x400000
|
||||||
|
Font.TTF_BASE = TTF_BASE
|
||||||
|
|
||||||
|
-- The engine's bundled TTF (assets/fonts/plainpixel/README.md: CC-BY 4.0,
|
||||||
|
-- Douglas Vautour). data.font.ttf.file overrides for a mod-shipped font.
|
||||||
|
-- 15 is the font's design em: its glyphs only rasterize at their true
|
||||||
|
-- pixel size (5x11 base, 11x11 double-width) at multiples of 15; at any
|
||||||
|
-- other size they downscale unevenly (at 11, M comes out 4px and A 5px).
|
||||||
|
Font.PLAINPIXEL = "assets/fonts/plainpixel/PlainPixel-Regular.ttf"
|
||||||
|
Font.PLAINPIXEL_SIZE = 15
|
||||||
|
|
||||||
local state
|
local state
|
||||||
local loadedFrom
|
local loadedFrom
|
||||||
|
|
||||||
@@ -35,6 +55,34 @@ local function pagesOf(def)
|
|||||||
return pages
|
return pages
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ttf.tiles as a lookup keyed by charmap sequence. Accepts a plain string
|
||||||
|
-- ("0123456789"), which is split into UTF-8 characters, or a list of
|
||||||
|
-- sequences ({ "0", "1", "<PK>" }) when a multi-character macro is meant.
|
||||||
|
local function tileSet(spec)
|
||||||
|
local set = {}
|
||||||
|
if type(spec) == "table" then
|
||||||
|
for _, seq in ipairs(spec) do set[tostring(seq)] = true end
|
||||||
|
elseif type(spec) == "string" then
|
||||||
|
-- split on UTF-8 lead bytes rather than utf8Decode, which this file
|
||||||
|
-- declares further down and would be nil here
|
||||||
|
local i, n = 1, #spec
|
||||||
|
while i <= n do
|
||||||
|
local last = i
|
||||||
|
if spec:byte(i) >= 0xC0 then
|
||||||
|
local k = i + 1
|
||||||
|
while k <= n do
|
||||||
|
local b = spec:byte(k)
|
||||||
|
if b < 0x80 or b > 0xBF then break end
|
||||||
|
last, k = k, k + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
set[spec:sub(i, last)] = true
|
||||||
|
i = last + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return set
|
||||||
|
end
|
||||||
|
|
||||||
function Font.load(data)
|
function Font.load(data)
|
||||||
loadedFrom = data
|
loadedFrom = data
|
||||||
local def = data.font
|
local def = data.font
|
||||||
@@ -83,6 +131,51 @@ function Font.load(data)
|
|||||||
Font.BORDER = {}
|
Font.BORDER = {}
|
||||||
for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end
|
for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end
|
||||||
for key, code in pairs(def.border or {}) do Font.BORDER[key] = code end
|
for key, code in pairs(def.border or {}) do Font.BORDER[key] = code end
|
||||||
|
|
||||||
|
-- TTF mode. All fields optional: {} means "the bundled Plain Pixel at
|
||||||
|
-- its native 11px". A failed load logs and falls back to tiles, so a
|
||||||
|
-- typo'd path degrades exactly like a missing page image does above.
|
||||||
|
if type(def.ttf) == "table" then
|
||||||
|
local file = def.ttf.file or Font.PLAINPIXEL
|
||||||
|
local ok, obj = pcall(love.graphics.newFont, file,
|
||||||
|
def.ttf.size or Font.PLAINPIXEL_SIZE, "mono")
|
||||||
|
if ok and obj then
|
||||||
|
-- nearest keeps the pixel font crisp under the integer UI scale
|
||||||
|
if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end
|
||||||
|
state.ttf = {
|
||||||
|
font = obj, file = file,
|
||||||
|
-- the font's own advances already carry a 1px gap at its design
|
||||||
|
-- size; spacing adds to (or, negative, takes from) every advance
|
||||||
|
spacing = def.ttf.spacing or 0,
|
||||||
|
-- bold double-prints each glyph at a 1px offset, for fonts whose
|
||||||
|
-- single-pixel strokes read too light against the tile art
|
||||||
|
bold = def.ttf.bold == true,
|
||||||
|
-- glyphs are taller than the 8px cell (11px base, and the em box
|
||||||
|
-- reserves even more for vertical extension); anchor the font's
|
||||||
|
-- baseline to the tile font's, which sits on row 7 of the cell, so
|
||||||
|
-- caps line up and descenders hang below as the GB font's own do
|
||||||
|
yOffset = def.ttf.yOffset or (obj.getBaseline
|
||||||
|
and (GLYPH - 1 - obj:getBaseline()) or (GLYPH - obj:getHeight())),
|
||||||
|
-- Single characters that keep their ROM tile instead of coming from
|
||||||
|
-- the TTF. A CJK translation sizes the font so a kana fills the 8px
|
||||||
|
-- cell, which leaves Latin narrower than the tile font it replaces:
|
||||||
|
-- the numbers in a right-aligned column (the party menu's ":L12" over
|
||||||
|
-- "34/ 34") then no longer land where the 8px-per-character layout put
|
||||||
|
-- them. Naming "0123456789" here keeps digits on the vanilla tiles --
|
||||||
|
-- identical to the English build -- while kana still come from the
|
||||||
|
-- font. Sequence keys, so "é" or a "<PK>" macro can be listed too.
|
||||||
|
tiles = tileSet(def.ttf.tiles),
|
||||||
|
widths = {}, chars = {},
|
||||||
|
}
|
||||||
|
else
|
||||||
|
require("src.core.Logger").warn("font: could not load ttf %q (%s)",
|
||||||
|
tostring(file), tostring(obj))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Font.ttfActive()
|
||||||
|
return state ~= nil and state.ttf ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- re-run load against the data it last saw, so hot reload picks up an
|
-- re-run load against the data it last saw, so hot reload picks up an
|
||||||
@@ -104,6 +197,49 @@ end
|
|||||||
|
|
||||||
local SPACE = 0x7F
|
local SPACE = 0x7F
|
||||||
|
|
||||||
|
-- Decode one UTF-8 sequence: codepoint and the index of its last byte, or
|
||||||
|
-- nil on a malformed lead/continuation (the caller falls back to bytes).
|
||||||
|
local function utf8Decode(text, i)
|
||||||
|
local b = text:byte(i)
|
||||||
|
if not b then return nil, i end
|
||||||
|
if b < 0x80 then return b, i end
|
||||||
|
local cont, cp
|
||||||
|
if b >= 0xF0 then cont, cp = 3, b - 0xF0
|
||||||
|
elseif b >= 0xE0 then cont, cp = 2, b - 0xE0
|
||||||
|
elseif b >= 0xC0 then cont, cp = 1, b - 0xC0
|
||||||
|
else return nil, i end
|
||||||
|
for k = i + 1, i + cont do
|
||||||
|
local c = text:byte(k)
|
||||||
|
if not c or c < 0x80 or c > 0xBF then return nil, i end
|
||||||
|
cp = cp * 64 + (c - 0x80)
|
||||||
|
end
|
||||||
|
return cp, i + cont
|
||||||
|
end
|
||||||
|
|
||||||
|
local function utf8Encode(cp)
|
||||||
|
if cp < 0x80 then return string.char(cp) end
|
||||||
|
if cp < 0x800 then
|
||||||
|
return string.char(0xC0 + math.floor(cp / 64), 0x80 + cp % 64)
|
||||||
|
end
|
||||||
|
if cp < 0x10000 then
|
||||||
|
return string.char(0xE0 + math.floor(cp / 4096),
|
||||||
|
0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
|
||||||
|
end
|
||||||
|
return string.char(0xF0 + math.floor(cp / 262144),
|
||||||
|
0x80 + math.floor(cp / 4096) % 64,
|
||||||
|
0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the character a TTF code draws, cached per code
|
||||||
|
local function ttfChar(ttf, code)
|
||||||
|
local ch = ttf.chars[code]
|
||||||
|
if not ch then
|
||||||
|
ch = utf8Encode(code - TTF_BASE)
|
||||||
|
ttf.chars[code] = ch
|
||||||
|
end
|
||||||
|
return ch
|
||||||
|
end
|
||||||
|
|
||||||
-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per
|
-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per
|
||||||
-- drawn glyph, code nil when the charmap has nothing. A span is a whole
|
-- drawn glyph, code nil when the charmap has nothing. A span is a whole
|
||||||
-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature
|
-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature
|
||||||
@@ -119,6 +255,7 @@ local SPACE = 0x7F
|
|||||||
-- boundaries, which is all a headless paginate needs.
|
-- boundaries, which is all a headless paginate needs.
|
||||||
function Font.split(text)
|
function Font.split(text)
|
||||||
local spans = {}
|
local spans = {}
|
||||||
|
local ttf = state and state.ttf
|
||||||
local i, n = 1, #text
|
local i, n = 1, #text
|
||||||
while i <= n do
|
while i <= n do
|
||||||
local span
|
local span
|
||||||
@@ -127,11 +264,24 @@ function Font.split(text)
|
|||||||
for _, entry in ipairs(candidates) do
|
for _, entry in ipairs(candidates) do
|
||||||
local len = #entry.seq
|
local len = #entry.seq
|
||||||
if text:sub(i, i + len - 1) == entry.seq then
|
if text:sub(i, i + len - 1) == entry.seq then
|
||||||
|
if ttf and not ttf.tiles[entry.seq] then
|
||||||
|
-- single characters belong to the TTF; only multi-character
|
||||||
|
-- sequences (ligatures, <PK> macros) keep their tile mapping,
|
||||||
|
-- plus anything the mod named in ttf.tiles (see Font.load)
|
||||||
|
local cp, last = utf8Decode(entry.seq, 1)
|
||||||
|
if cp and last == len then break end
|
||||||
|
end
|
||||||
span = { from = i, to = i + len - 1, code = entry.code }
|
span = { from = i, to = i + len - 1, code = entry.code }
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if not span and ttf then
|
||||||
|
local cp, last = utf8Decode(text, i)
|
||||||
|
if cp and cp >= 0x20 then
|
||||||
|
span = { from = i, to = last, code = TTF_BASE + cp }
|
||||||
|
end
|
||||||
|
end
|
||||||
if not span then
|
if not span then
|
||||||
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
|
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
|
||||||
-- lands mid-character even for a glyph we cannot draw.
|
-- lands mid-character even for a glyph we cannot draw.
|
||||||
@@ -185,14 +335,37 @@ function Font.encode(text)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Font.drawCode(code, x, y)
|
function Font.drawCode(code, x, y)
|
||||||
|
local ttf = state and state.ttf
|
||||||
|
if ttf and code >= TTF_BASE then
|
||||||
|
local prev = love.graphics.getFont()
|
||||||
|
love.graphics.setFont(ttf.font)
|
||||||
|
local ch = ttfChar(ttf, code)
|
||||||
|
love.graphics.print(ch, x, y + ttf.yOffset)
|
||||||
|
if ttf.bold then love.graphics.print(ch, x + 1, y + ttf.yOffset) end
|
||||||
|
if prev then love.graphics.setFont(prev) end
|
||||||
|
return
|
||||||
|
end
|
||||||
local page = pageFor(code)
|
local page = pageFor(code)
|
||||||
if not page then return end
|
if not page then return end
|
||||||
local quad = page.quads[code - page.base]
|
local quad = page.quads[code - page.base]
|
||||||
if quad then love.graphics.draw(page.image, quad, x, y) end
|
if quad then love.graphics.draw(page.image, quad, x, y) end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- how far the pen moves past a glyph; 8 unless its page says otherwise
|
-- how far the pen moves past a glyph; 8 unless its page says otherwise.
|
||||||
|
-- TTF glyphs answer with the font's own metrics (5px base, 11px for
|
||||||
|
-- double-width kana/CJK in Plain Pixel), which is what makes TextBox's
|
||||||
|
-- pixel-budget pagination fit more of a narrow script per line.
|
||||||
function Font.advanceOf(code)
|
function Font.advanceOf(code)
|
||||||
|
local ttf = state and state.ttf
|
||||||
|
if ttf and code >= TTF_BASE then
|
||||||
|
local w = ttf.widths[code]
|
||||||
|
if not w then
|
||||||
|
w = ttf.font:getWidth(ttfChar(ttf, code)) + ttf.spacing
|
||||||
|
+ (ttf.bold and 1 or 0)
|
||||||
|
ttf.widths[code] = w
|
||||||
|
end
|
||||||
|
return w
|
||||||
|
end
|
||||||
local page = pageFor(code)
|
local page = pageFor(code)
|
||||||
return page and page.advance or GLYPH
|
return page and page.advance or GLYPH
|
||||||
end
|
end
|
||||||
@@ -232,8 +405,18 @@ for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end
|
|||||||
|
|
||||||
-- Draw a Game Boy style bordered box in tile coordinates.
|
-- Draw a Game Boy style bordered box in tile coordinates.
|
||||||
function Font.drawBox(tx, ty, tw, th)
|
function Font.drawBox(tx, ty, tw, th)
|
||||||
|
-- The white interior is a fill, so it needs the color; everything after it
|
||||||
|
-- is a glyph and needs the caller's. Restoring is not cosmetic: the tile
|
||||||
|
-- pages are black glyphs on transparent, so they come out black whatever
|
||||||
|
-- the color is, and leaking white here was invisible for as long as every
|
||||||
|
-- glyph was a tile. TTF text is not immune -- it draws in the current
|
||||||
|
-- color -- so a leaked white left every label printed after a box white on
|
||||||
|
-- white. On the summary screen that erased ATTACK/DEFENSE/SPEED/SPECIAL
|
||||||
|
-- and TYPE1/TYPE2 while the numbers beside them, still tiles, stayed put.
|
||||||
|
local r, g, b, a = love.graphics.getColor()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
|
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
|
||||||
|
love.graphics.setColor(r, g, b, a)
|
||||||
local B = Font.BORDER
|
local B = Font.BORDER
|
||||||
Font.drawCode(B.tl, tx * 8, ty * 8)
|
Font.drawCode(B.tl, tx * 8, ty * 8)
|
||||||
Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8)
|
Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8)
|
||||||
|
|||||||
@@ -370,8 +370,12 @@ function TextBox:draw()
|
|||||||
local ys = { self.line1Y, self.line2Y }
|
local ys = { self.line1Y, self.line2Y }
|
||||||
for i, line in ipairs(self.shown) do
|
for i, line in ipairs(self.shown) do
|
||||||
local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
|
local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
|
||||||
for j, code in ipairs(line) do
|
-- the pen advances per glyph, matching the pixel budget paginate
|
||||||
Font.drawCode(code, self.textX + (j - 1) * 8, y)
|
-- measured with; every fixed-width page still lands on the 8px grid
|
||||||
|
local pen = self.textX
|
||||||
|
for _, code in ipairs(line) do
|
||||||
|
Font.drawCode(code, pen, y)
|
||||||
|
pen = pen + Font.advanceOf(code)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if (self.waiting or (self.done and not self.choice and not self.auto
|
if (self.waiting or (self.done and not self.choice and not self.auto
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ local function decodeName(bytes, off, len)
|
|||||||
return table.concat(out)
|
return table.concat(out)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function encodeName(buf, off, len, text)
|
local function encodeName(buf, off, len, text, padTail)
|
||||||
local i, pos = 0, 1
|
local i, pos = 0, 1
|
||||||
while i < len - 1 and pos <= #text do
|
while i < len - 1 and pos <= #text do
|
||||||
-- a bracketed control token (e.g. "<DOT>", from decodeName reading a
|
-- a bracketed control token (e.g. "<DOT>", from decodeName reading a
|
||||||
@@ -259,16 +259,17 @@ local function encodeName(buf, off, len, text)
|
|||||||
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
||||||
i, pos = i + 1, pos + clen
|
i, pos = i + 1, pos + clen
|
||||||
end
|
end
|
||||||
-- Write exactly ONE $50 terminator and then $50-pad the rest of the
|
-- Write exactly ONE $50 terminator. The tail past it is $50-padded only
|
||||||
-- field: every real save the naming screen ever wrote fills the tail
|
-- on a templateless (engine-origin) export, where the zero-filled buffer
|
||||||
-- with $50, and a zero tail is what PKHeX renders as garbage glyphs
|
-- is what PKHeX renders as garbage glyphs after the name ("JOHN{}", #206).
|
||||||
-- after the name ("JOHN{}", #206). Template bytes that are NOT zero
|
-- With a template the tail keeps the original save's bytes verbatim --
|
||||||
-- stay untouched, so an unchanged name still round-trips
|
-- real cartridge saves legitimately hold 0x00 (and other stale glyph)
|
||||||
-- byte-identical and stale template data survives.
|
-- bytes after the terminator, and rewriting any of them broke the
|
||||||
|
-- import->export byte-identical round trip (the game and PKHeX both stop
|
||||||
|
-- reading at the terminator, so preserved tails are always safe).
|
||||||
if i < len then setByte(buf, off + i, 0x50) end
|
if i < len then setByte(buf, off + i, 0x50) end
|
||||||
for j = i + 1, len - 1 do
|
if padTail then
|
||||||
local cur = buf[off + j + 1]
|
for j = i + 1, len - 1 do setByte(buf, off + j, 0x50) end
|
||||||
if cur == nil or cur:byte() == 0 then setByte(buf, off + j, 0x50) end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -781,8 +782,9 @@ function GenSave.encode(save, data, template)
|
|||||||
for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end
|
for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end
|
||||||
end
|
end
|
||||||
|
|
||||||
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
|
local padTail = not src
|
||||||
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
|
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED", padTail)
|
||||||
|
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE", padTail)
|
||||||
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
|
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
|
||||||
-- wOptions (engine/menus/main_menu.asm InitOptions): bit 7 = battle
|
-- wOptions (engine/menus/main_menu.asm InitOptions): bit 7 = battle
|
||||||
-- effects OFF, bit 6 = SET style, bits 2-0 = text speed -- the recomp's
|
-- effects OFF, bit 6 = SET style, bits 2-0 = text speed -- the recomp's
|
||||||
@@ -875,11 +877,11 @@ function GenSave.encode(save, data, template)
|
|||||||
encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw)
|
encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw)
|
||||||
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
|
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
|
||||||
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
|
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
|
||||||
mon.ot or (save.player and save.player.name) or "RED")
|
mon.ot or (save.player and save.player.name) or "RED", padTail)
|
||||||
-- no nickname stores the species' DISPLAY name, not its ROM constant id
|
-- no nickname stores the species' DISPLAY name, not its ROM constant id
|
||||||
-- ("NIDORAN_M" would charmap the "_" to "?") (#257)
|
-- ("NIDORAN_M" would charmap the "_" to "?") (#257)
|
||||||
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
|
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
|
||||||
mon.nickname or speciesName(cw, mon.species))
|
mon.nickname or speciesName(cw, mon.species), padTail)
|
||||||
end
|
end
|
||||||
-- $FF-terminate the species index list right after the last real mon. The
|
-- $FF-terminate the species index list right after the last real mon. The
|
||||||
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
|
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
|
||||||
@@ -900,9 +902,9 @@ function GenSave.encode(save, data, template)
|
|||||||
encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw)
|
encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw)
|
||||||
setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0)
|
setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0)
|
||||||
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
|
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
|
||||||
mon.ot or (save.player and save.player.name) or "RED")
|
mon.ot or (save.player and save.player.name) or "RED", padTail)
|
||||||
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
|
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
|
||||||
mon.nickname or speciesName(cw, mon.species)) -- #257, as above
|
mon.nickname or speciesName(cw, mon.species), padTail) -- #257, as above
|
||||||
end
|
end
|
||||||
-- $FF-terminate the species list after the last real mon; empty slots past
|
-- $FF-terminate the species list after the last real mon; empty slots past
|
||||||
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
|
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
local EvolutionState = {}
|
local EvolutionState = {}
|
||||||
EvolutionState.__index = EvolutionState
|
EvolutionState.__index = EvolutionState
|
||||||
@@ -89,9 +90,9 @@ function EvolutionState:update(dt)
|
|||||||
self.done = true
|
self.done = true
|
||||||
self.canceled = true
|
self.canceled = true
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
-- mirrors data/generated/text.lua _StoppedEvolvingText
|
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("Huh? %s\nstopped evolving!", self.oldName),
|
romText(game.data, "_StoppedEvolvingText",
|
||||||
|
"Huh? %s\nstopped evolving!", self.oldName),
|
||||||
function()
|
function()
|
||||||
Music.restoreMap(game.data)
|
Music.restoreMap(game.data)
|
||||||
game.stack:pop() -- the evolution screen itself
|
game.stack:pop() -- the evolution screen itself
|
||||||
@@ -106,6 +107,8 @@ function EvolutionState:update(dt)
|
|||||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local newName = game.data.pokemon[self.newSpecies].name
|
local newName = game.data.pokemon[self.newSpecies].name
|
||||||
|
-- _EvolvedText extracts truncated (it stops at a dynamic marker the
|
||||||
|
-- decoder does not follow), so the engine's wording stands here
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||||
self.oldName, newName),
|
self.oldName, newName),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
local MoveLearnMenu = {}
|
local MoveLearnMenu = {}
|
||||||
MoveLearnMenu.__index = MoveLearnMenu
|
MoveLearnMenu.__index = MoveLearnMenu
|
||||||
@@ -43,10 +44,13 @@ function MoveLearnMenu:enter()
|
|||||||
local mdef = game.data.moves[self.newMoveId]
|
local mdef = game.data.moves[self.newMoveId]
|
||||||
local name = self:monName()
|
local name = self:monName()
|
||||||
self.selecting = false
|
self.selecting = false
|
||||||
|
-- _TryingToLearnText is the whole exchange in pokered, delete prompt
|
||||||
|
-- included, so the extracted line carries all four slots at once
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f",
|
romText(game.data, "_TryingToLearnText",
|
||||||
name, mdef.name, name) ..
|
"%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f"
|
||||||
Strings("Delete an older\nmove to make room\vfor %s?", mdef.name),
|
.. "Delete an older\nmove to make room\vfor %s?",
|
||||||
|
name, mdef.name, name, mdef.name),
|
||||||
nil, {
|
nil, {
|
||||||
choice = function(yes)
|
choice = function(yes)
|
||||||
if yes then
|
if yes then
|
||||||
@@ -77,7 +81,8 @@ function MoveLearnMenu:update(dt)
|
|||||||
-- HMCantDeleteText, then back to the forget list
|
-- HMCantDeleteText, then back to the forget list
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
self.game.stack:push(TextBox.new(self.game,
|
self.game.stack:push(TextBox.new(self.game,
|
||||||
Strings("HM techniques\ncan't be deleted!")))
|
romText(self.game.data, "_HMCantDeleteText",
|
||||||
|
"HM techniques\ncan't be deleted!")))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local mdef = self.game.data.moves[self.newMoveId]
|
local mdef = self.game.data.moves[self.newMoveId]
|
||||||
@@ -97,7 +102,8 @@ function MoveLearnMenu:confirmAbandon()
|
|||||||
local mdef = game.data.moves[self.newMoveId]
|
local mdef = game.data.moves[self.newMoveId]
|
||||||
self.selecting = false
|
self.selecting = false
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("Abandon learning\n%s?", mdef.name), nil, {
|
romText(game.data, "_AbandonLearningText",
|
||||||
|
"Abandon learning\n%s?", mdef.name), nil, {
|
||||||
choice = function(yes)
|
choice = function(yes)
|
||||||
if yes then self:finish(false) else self:enter() end
|
if yes then self:finish(false) else self:enter() end
|
||||||
end,
|
end,
|
||||||
@@ -113,12 +119,17 @@ function MoveLearnMenu:finish(learned)
|
|||||||
game.stack:pop()
|
game.stack:pop()
|
||||||
local msg
|
local msg
|
||||||
if learned then
|
if learned then
|
||||||
-- OneTwoAndText/PoofText/ForgotAndText
|
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
||||||
msg = Strings("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!",
|
-- the "And..." tail
|
||||||
name, self.forgot, name, mdef.name)
|
msg = romText(game.data, "_OneTwoAndText", "1, 2 and...")
|
||||||
|
.. romText(game.data, "_PoofText", " Poof!")
|
||||||
|
.. romText(game.data, "_ForgotAndText",
|
||||||
|
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
|
||||||
|
.. "\f" .. romText(game.data, "_LearnedMove1Text",
|
||||||
|
"%s learned\n%s!", name, mdef.name)
|
||||||
else
|
else
|
||||||
-- DidNotLearnText
|
msg = romText(game.data, "_DidNotLearnText",
|
||||||
msg = Strings("%s\ndid not learn\v%s!", name, mdef.name)
|
"%s\ndid not learn\v%s!", name, mdef.name)
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game, msg, function()
|
game.stack:push(TextBox.new(game, msg, function()
|
||||||
if self.onDone then self.onDone(learned) end
|
if self.onDone then self.onDone(learned) end
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
-- Driver: TTF text mode through the bundled Plain Pixel font. Teleports
|
||||||
|
-- straight into the overworld (no intro) and shows one dialogue line per
|
||||||
|
-- language back to back, shooting each: lang_english.png, lang_french.png,
|
||||||
|
-- lang_german.png, lang_spanish.png, lang_russian.png, lang_japanese.png,
|
||||||
|
-- lang_chinese.png in SHOT_DIR. Judge glyph coverage, spacing, baseline,
|
||||||
|
-- and that the tile box border survives every one of them.
|
||||||
|
-- POKEPORT_DRIVER=tests/drivers/plainpixel_font_test.lua \
|
||||||
|
-- POKEPORT_IDENTITY=ttfdemo POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||||
|
-- SHOT_DIR=/tmp/shots love .
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||||
|
local Font = require("src.render.Font")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
local function check(label, ok)
|
||||||
|
U.log(ok and "PASS" or "FAIL", label)
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- preconditions -----------------------------------------------------
|
||||||
|
check("the bundled TTF is present",
|
||||||
|
love.filesystem.getInfo(Font.PLAINPIXEL) ~= nil)
|
||||||
|
local okLoad, ttf = pcall(love.graphics.newFont, Font.PLAINPIXEL,
|
||||||
|
Font.PLAINPIXEL_SIZE)
|
||||||
|
check("real LOVE can load it", okLoad and ttf ~= nil)
|
||||||
|
|
||||||
|
game.save.player.name = "bryan"
|
||||||
|
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||||
|
U.wait(10)
|
||||||
|
|
||||||
|
-- what a translation's register("ttf", {}) merges to
|
||||||
|
game.data.font.ttf = {}
|
||||||
|
Font.invalidate()
|
||||||
|
check("ttf active after the merge", Font.ttfActive())
|
||||||
|
|
||||||
|
local LINES = {
|
||||||
|
{ "english", "The quick brown fox\njumps over the lazy dog!" },
|
||||||
|
{ "french", "Un \195\169clair enveloppe le\nPOK\195\169MON sauvage! \195\135a alors!" },
|
||||||
|
{ "german", "Gr\195\182\195\159e und Gewicht des\nPOK\195\169MON \195\188berpr\195\188ft!" },
|
||||||
|
{ "spanish", "\194\161El POK\195\169MON enemigo\nest\195\161 paralizado! \194\191Y ahora?" },
|
||||||
|
{ "russian", "\208\148\208\184\208\186\208\184\208\185 "
|
||||||
|
.. "\208\159\208\158\208\154\208\149\208\156\208\158\208\157 "
|
||||||
|
.. "\208\191\208\190\209\143\208\178\208\184\208\187\209\129\209\143!" },
|
||||||
|
{ "japanese", "\227\129\147\227\130\147\227\129\171\227\129\161\227\129\175! "
|
||||||
|
.. "\227\131\157\227\130\177\227\131\162\227\131\179\227\129\174\n"
|
||||||
|
.. "\227\129\155\227\129\139\227\129\132\227\129\184 "
|
||||||
|
.. "\227\130\136\227\129\134\227\129\147\227\129\157!" },
|
||||||
|
-- NB: Plain Pixel 0.009's CJK block is partial (e.g. U+62D3, U+5922
|
||||||
|
-- draw as tofu); stick to characters it covers
|
||||||
|
{ "chinese", "\228\189\160\229\165\189! \230\136\145\230\152\175"
|
||||||
|
.. "\229\164\167\229\178\169\229\141\154\229\163\171!" },
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry in ipairs(LINES) do
|
||||||
|
local name, text = entry[1], entry[2]
|
||||||
|
local done = false
|
||||||
|
local box = TextBox.new(game, text, function() done = true end)
|
||||||
|
game.stack:push(box)
|
||||||
|
-- let the typewriter finish the page before shooting it
|
||||||
|
for _ = 1, 300 do
|
||||||
|
if box.waiting or box.done then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
U.wait(5)
|
||||||
|
U.shot(game, DIR .. "/lang_" .. name .. ".png")
|
||||||
|
for _ = 1, 20 do
|
||||||
|
if done then break end
|
||||||
|
U.tap(game, "a")
|
||||||
|
U.wait(10)
|
||||||
|
end
|
||||||
|
U.wait(5)
|
||||||
|
check("dismissed " .. name, done)
|
||||||
|
end
|
||||||
|
|
||||||
|
game.data.font.ttf = nil
|
||||||
|
Font.invalidate()
|
||||||
|
check("clearing the entry restores tile mode", not Font.ttfActive())
|
||||||
|
|
||||||
|
U.log("shots in", DIR)
|
||||||
|
U.log("DONE")
|
||||||
|
love.event.quit(0)
|
||||||
|
end
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- Wheel scrolling must survive the launcher's immediate-mode frame order:
|
||||||
|
-- FlexLove.update(dt) runs in love.update, but beginFrame (in draw) resets
|
||||||
|
-- the accumulated dt to 0 BEFORE endFrame updates the elements, so
|
||||||
|
-- ScrollManager:update always receives dt = 0 there. The smooth-scroll
|
||||||
|
-- interpolation must still advance (it once used a fixed per-frame
|
||||||
|
-- fraction; the dt-aware blend has to treat dt <= 0 as one nominal 60 Hz
|
||||||
|
-- step, or every launcher list stops responding to the wheel entirely).
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
love.window.getMode = function() return 1024, 768, { fullscreen = false } end
|
||||||
|
love.window.getDesktopDimensions = function() return 1920, 1080 end
|
||||||
|
love.mouse = love.mouse or {}
|
||||||
|
love.mouse.getPosition = function() return 100, 100 end
|
||||||
|
love.mouse.isDown = love.mouse.isDown or function() return false end
|
||||||
|
package.loaded["libs.flexlove.modules.UTF8"] = {
|
||||||
|
char = string.char, charpattern = ".", codepoint = string.byte,
|
||||||
|
len = string.len, offset = function(_, n) return n end,
|
||||||
|
codes = function(s)
|
||||||
|
local i = 0
|
||||||
|
return function() i = i + 1; if i <= #s then return i, s:byte(i) end end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local FlexLove = require("libs.flexlove.FlexLove")
|
||||||
|
FlexLove.init({
|
||||||
|
immediateMode = true,
|
||||||
|
performanceMonitoring = false,
|
||||||
|
keyboardNavigation = false,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- One launcher-shaped frame: update BEFORE beginFrame, exactly like
|
||||||
|
-- LauncherView.update / draw, so endFrame sees _accumulatedDt == 0.
|
||||||
|
local function frame(wheelAfterBuild)
|
||||||
|
FlexLove.update(1 / 60)
|
||||||
|
FlexLove.beginFrame()
|
||||||
|
local scroller = FlexLove.new({
|
||||||
|
id = "scroller", x = 0, y = 0, width = 400, height = 300,
|
||||||
|
overflowY = "scroll", hideScrollbars = true,
|
||||||
|
smoothScrollEnabled = true, scrollSpeed = 60,
|
||||||
|
positioning = "flex", flexDirection = "vertical",
|
||||||
|
})
|
||||||
|
for i = 1, 30 do
|
||||||
|
FlexLove.new({ parent = scroller, id = "row" .. i, width = 380, height = 40 })
|
||||||
|
end
|
||||||
|
FlexLove.endFrame()
|
||||||
|
if wheelAfterBuild then FlexLove.wheelmoved(0, -1) end
|
||||||
|
local sm = scroller._scrollManager
|
||||||
|
return sm and sm._scrollY or nil, sm and sm._targetScrollY or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
for _ = 1, 3 do frame(false) end
|
||||||
|
local y0, t0 = frame(true)
|
||||||
|
T.eq(t0, 60, "one wheel notch targets one scrollSpeed step")
|
||||||
|
T.eq(y0, 0, "the notch lands on the frame after input, not the same one")
|
||||||
|
|
||||||
|
local y = y0
|
||||||
|
for _ = 1, 5 do y = frame(false) end
|
||||||
|
T.check(y ~= nil and y > 30, "smooth scroll advances past halfway within 5 dt=0 frames")
|
||||||
|
for _ = 1, 60 do y = frame(false) end
|
||||||
|
T.check(y ~= nil and math.abs(y - 60) < 1, "and converges on the target")
|
||||||
|
|
||||||
|
T.finish("flexlove wheel scroll at dt=0")
|
||||||
@@ -253,8 +253,10 @@ do
|
|||||||
"NX guard disables Performance.enabled")
|
"NX guard disables Performance.enabled")
|
||||||
check(view:find("mp.enabled = false", 1, true) ~= nil,
|
check(view:find("mp.enabled = false", 1, true) ~= nil,
|
||||||
"NX guard disables memory profiling")
|
"NX guard disables memory profiling")
|
||||||
check(view:find("if not (imp and imp.isNX", 1, true) ~= nil,
|
-- The guard used to be NX-only; the same perf-timer and GC hitches showed
|
||||||
"perf guard is gated on imp.isNX")
|
-- up in desktop scrolling, so it now applies on every platform.
|
||||||
|
check(view:find("if not (imp and FlexLove.isReady()", 1, true) ~= nil,
|
||||||
|
"perf guard applies on every platform (not gated on imp.isNX)")
|
||||||
check(view:find("parkNxPointerForHost", 1, true) ~= nil,
|
check(view:find("parkNxPointerForHost", 1, true) ~= nil,
|
||||||
"detach parks NX pointer before tearing down")
|
"detach parks NX pointer before tearing down")
|
||||||
|
|
||||||
|
|||||||
@@ -108,4 +108,133 @@ do
|
|||||||
check(preview:find("Changes", 1, true), "previewLine keeps heading text")
|
check(preview:find("Changes", 1, true), "previewLine keeps heading text")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- downloads: per-release sum over every asset's download_count
|
||||||
|
do
|
||||||
|
local body = Json.encode({
|
||||||
|
{
|
||||||
|
tag_name = "v1.2.0",
|
||||||
|
assets = {
|
||||||
|
{ name = "demo-1.2.0.zip", browser_download_url = "https://x/d.zip",
|
||||||
|
size = 99, download_count = 41 },
|
||||||
|
{ name = "demo-1.2.0.sha256", browser_download_url = "https://x/d.sha",
|
||||||
|
download_count = 9 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
local list = ModUpdate.parseReleases(body, "demo")
|
||||||
|
eq(list[1].downloads, 50, "downloads sums every asset, not just the zip")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- published: the release date is kept as an ISO day
|
||||||
|
do
|
||||||
|
local body = Json.encode({
|
||||||
|
{ tag_name = "v1.2.0", published_at = "2025-04-13T09:24:00Z",
|
||||||
|
assets = { { name = "demo-1.2.0.zip",
|
||||||
|
browser_download_url = "https://x/d.zip" } } },
|
||||||
|
})
|
||||||
|
local list = ModUpdate.parseReleases(body, "demo")
|
||||||
|
eq(list[1].published, "2025-04-13", "published_at is reduced to the day")
|
||||||
|
local noDate = ModUpdate.parseReleases(Json.encode({
|
||||||
|
{ tag_name = "v1.0.0",
|
||||||
|
assets = { { name = "demo-1.0.0.zip",
|
||||||
|
browser_download_url = "https://x/d.zip" } } },
|
||||||
|
}), "demo")
|
||||||
|
check(noDate[1].published == nil, "missing dates stay nil, never throw")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- releaseDates: first and latest across releases
|
||||||
|
do
|
||||||
|
local d = ModUpdate.releaseDates({
|
||||||
|
{ version = "1.0.0", published = "2024-05-31" },
|
||||||
|
{ version = "1.2.0", published = "2025-11-02" },
|
||||||
|
{ version = "1.1.0", published = "2025-01-15" },
|
||||||
|
})
|
||||||
|
eq(d.first, "2024-05-31", "first is the oldest release date")
|
||||||
|
eq(d.latest, "2025-11-02", "latest is the newest release date")
|
||||||
|
check(ModUpdate.releaseDates({ { version = "1.0.0" } }) == nil,
|
||||||
|
"releases without dates report no data")
|
||||||
|
check(ModUpdate.releaseDates({}) == nil, "empty list reports no data")
|
||||||
|
check(ModUpdate.releaseDates(nil) == nil, "nil list reports no data")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- totalDownloads: totals across releases; nil until data actually exists
|
||||||
|
do
|
||||||
|
local new = {
|
||||||
|
{ version = "1.0.0", downloads = 41 },
|
||||||
|
{ version = "1.1.0", downloads = 9 },
|
||||||
|
}
|
||||||
|
local dl = ModUpdate.totalDownloads(new)
|
||||||
|
eq(dl.total, 50, "totals across releases")
|
||||||
|
eq(dl.releases, 2, "counts the releases")
|
||||||
|
dl = ModUpdate.totalDownloads({ { version = "1.0.0", downloads = 0 } })
|
||||||
|
eq(dl.total, 0, "a real zero stays zero")
|
||||||
|
dl = ModUpdate.totalDownloads({ { version = "1.0.0" } })
|
||||||
|
check(dl == nil, "pre-downloads cache rows report no data")
|
||||||
|
check(ModUpdate.totalDownloads({}) == nil, "empty list reports no data")
|
||||||
|
check(ModUpdate.totalDownloads(nil) == nil, "nil list reports no data")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- formatCount: thousands separators, never throws
|
||||||
|
eq(ModUpdate.formatCount(0), "0", "zero formats plain")
|
||||||
|
eq(ModUpdate.formatCount(999), "999", "below 1000 formats plain")
|
||||||
|
eq(ModUpdate.formatCount(1000), "1,000", "1000 gets a separator")
|
||||||
|
eq(ModUpdate.formatCount(1234567), "1,234,567", "large counts group by 3")
|
||||||
|
eq(ModUpdate.formatCount("12345"), "12,345", "numeric strings are accepted")
|
||||||
|
eq(ModUpdate.formatCount(nil), "0", "nil formats as zero")
|
||||||
|
eq(ModUpdate.formatCount("garbage"), "0", "garbage formats as zero")
|
||||||
|
|
||||||
|
-- cacheUsable: a cache entry from before downloads existed must not be
|
||||||
|
-- trusted, everything current is
|
||||||
|
do
|
||||||
|
local old = { checkedAt = os.time(),
|
||||||
|
releases = { { version = "1.0.0", tag = "v1.0.0" } } }
|
||||||
|
check(not ModUpdate.cacheUsable(old), "pre-downloads cache is unusable")
|
||||||
|
local zero = { checkedAt = os.time(),
|
||||||
|
releases = { { version = "1.0.0", downloads = 0 } } }
|
||||||
|
check(ModUpdate.cacheUsable(zero), "current cache is usable even at zero")
|
||||||
|
check(ModUpdate.cacheUsable({ checkedAt = os.time(), releases = {} }),
|
||||||
|
"empty release list stays usable")
|
||||||
|
check(not ModUpdate.cacheUsable(nil), "nil cache is unusable")
|
||||||
|
check(not ModUpdate.cacheUsable({}), "cache without releases is unusable")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- fetchReleases: an old-format cache entry is refetched once and rewritten
|
||||||
|
-- in the current format; a current one is served untouched
|
||||||
|
do
|
||||||
|
local HostShell = require("src.core.HostShell")
|
||||||
|
local realRead, realWrite = ModUpdate.readCache, ModUpdate.writeCache
|
||||||
|
local realCanFetch, realHttpGet = HostShell.canFetch, HostShell.httpGet
|
||||||
|
local cached, fetched
|
||||||
|
ModUpdate.readCache = function() return cached end
|
||||||
|
ModUpdate.writeCache = function(repo, releases)
|
||||||
|
fetched = releases
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
HostShell.canFetch = function() return true end
|
||||||
|
HostShell.httpGet = function()
|
||||||
|
return Json.encode({ {
|
||||||
|
tag_name = "v1.0.0",
|
||||||
|
assets = { { name = "demo-1.0.0.zip",
|
||||||
|
browser_download_url = "https://x/d.zip", download_count = 7 } },
|
||||||
|
} })
|
||||||
|
end
|
||||||
|
|
||||||
|
cached = { checkedAt = os.time(),
|
||||||
|
releases = { { version = "1.0.0", tag = "v1.0.0" } } }
|
||||||
|
fetched = nil
|
||||||
|
local list = ModUpdate.fetchReleases("acme/mod", "demo")
|
||||||
|
check(fetched ~= nil, "old-format cache triggers a refetch")
|
||||||
|
check(list[1].downloads == 7, "refetched release carries downloads")
|
||||||
|
|
||||||
|
cached = { checkedAt = os.time(),
|
||||||
|
releases = { { version = "1.0.0", tag = "v1.0.0", downloads = 0 } } }
|
||||||
|
fetched = nil
|
||||||
|
list = ModUpdate.fetchReleases("acme/mod", "demo")
|
||||||
|
check(fetched == nil, "current cache is served without refetch")
|
||||||
|
check(list[1].downloads == 0, "cached zero stays zero")
|
||||||
|
|
||||||
|
ModUpdate.readCache, ModUpdate.writeCache = realRead, realWrite
|
||||||
|
HostShell.canFetch, HostShell.httpGet = realCanFetch, realHttpGet
|
||||||
|
end
|
||||||
|
|
||||||
print("ok mod_update_tests")
|
print("ok mod_update_tests")
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
-- TTF TEXT MODE: a translation registers `font:register("ttf", {})` and the
|
||||||
|
-- engine renders ordinary characters through a real TTF (the bundled Plain
|
||||||
|
-- Pixel, assets/fonts/plainpixel/) instead of demanding a hand-drawn glyph
|
||||||
|
-- page per script. The contracts under test:
|
||||||
|
--
|
||||||
|
-- * with no ttf entry nothing changes: vanilla text stays tile-for-tile
|
||||||
|
-- identical (the same guarantee Strings makes for an absent catalog);
|
||||||
|
-- * with it, single characters become TTF glyph codes while multi-char
|
||||||
|
-- charmap sequences (<PK> macros, the 'd ligatures) and the sub-0x80
|
||||||
|
-- box chrome keep their tiles, so borders never depend on TTF coverage;
|
||||||
|
-- * advances come from the font's metrics (5px Latin, 11px double-width
|
||||||
|
-- kana/CJK in Plain Pixel), which TextBox's pixel-budget paginate and
|
||||||
|
-- its per-glyph draw pen both honor;
|
||||||
|
-- * the "ttf" id is a legal font-registry entry and merges to
|
||||||
|
-- data.font.ttf, rebuilt each merge so disabling the mod disables it.
|
||||||
|
--
|
||||||
|
-- luajit tests/engine/ttf_font_mode.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Font = require("src.render.Font")
|
||||||
|
|
||||||
|
local BASE = Font.TTF_BASE
|
||||||
|
|
||||||
|
-- a charmap with a single-char entry, a multi-byte single char, and a
|
||||||
|
-- two-char ligature: the three shapes split() has to tell apart
|
||||||
|
local CHARMAP = {
|
||||||
|
{ code = 0x80, seq = "A" },
|
||||||
|
{ code = 0xBA, seq = "\195\169" }, -- é
|
||||||
|
{ code = 0xD0, seq = "'d" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------- vanilla stays vanilla
|
||||||
|
|
||||||
|
Font.load({ font = { charmap = CHARMAP } })
|
||||||
|
T.check(not Font.ttfActive(), "no ttf entry means tile mode")
|
||||||
|
T.eq(Font.encode("A")[1], 0x80, "charmap keeps mapping to tiles")
|
||||||
|
T.eq(Font.advanceOf(0x80), 8, "and the pen stays 8px monospace")
|
||||||
|
|
||||||
|
-- ------------------------------------------------- the ttf takes over
|
||||||
|
|
||||||
|
Font.load({ font = { charmap = CHARMAP, ttf = {} } })
|
||||||
|
T.check(Font.ttfActive(), "an empty ttf table loads the bundled font")
|
||||||
|
|
||||||
|
T.eq(Font.encode("A")[1], BASE + 65,
|
||||||
|
"a single character routes to the TTF even with a charmap entry")
|
||||||
|
T.eq(Font.encode("\195\169")[1], BASE + 0xE9,
|
||||||
|
"a multi-byte single character does too")
|
||||||
|
T.eq(Font.encode("'d")[1], 0xD0,
|
||||||
|
"a multi-character ligature keeps its tile glyph")
|
||||||
|
T.eq(#Font.encode("'d"), 1, "and still consumes the whole sequence")
|
||||||
|
T.eq(Font.encode("\227\129\130")[1], BASE + 0x3042,
|
||||||
|
"kana with no charmap entry at all still gets a glyph")
|
||||||
|
|
||||||
|
-- ------------------------------------------------- ttf.tiles opts back out
|
||||||
|
|
||||||
|
-- A CJK translation sizes the font so a kana fills the 8px cell, which leaves
|
||||||
|
-- Latin narrower than the tile font it replaces and pulls the numeric columns
|
||||||
|
-- (the party menu's ":L12" over "34/ 34") out of line. ttf.tiles names the
|
||||||
|
-- characters that keep their ROM tile anyway, so numbers stay identical to the
|
||||||
|
-- English build while kana still come from the font.
|
||||||
|
Font.load({ font = { charmap = CHARMAP, ttf = { tiles = "A" } } })
|
||||||
|
T.eq(Font.encode("A")[1], 0x80, "a character in ttf.tiles keeps its tile glyph")
|
||||||
|
T.eq(Font.advanceOf(0x80), 8, "and its 8px monospace advance with it")
|
||||||
|
T.eq(Font.encode("\195\169")[1], BASE + 0xE9,
|
||||||
|
"while everything else still routes to the TTF")
|
||||||
|
|
||||||
|
-- a list form, for naming a multi-character sequence explicitly
|
||||||
|
Font.load({ font = { charmap = CHARMAP, ttf = { tiles = { "\195\169" } } } })
|
||||||
|
T.eq(Font.encode("\195\169")[1], 0xBA,
|
||||||
|
"the list form accepts a multi-byte character")
|
||||||
|
T.eq(Font.encode("A")[1], BASE + 65, "and leaves the others on the TTF")
|
||||||
|
|
||||||
|
Font.load({ font = { charmap = CHARMAP, ttf = {} } })
|
||||||
|
T.eq(Font.encode("A")[1], BASE + 65, "no tiles list means the TTF takes it back")
|
||||||
|
|
||||||
|
-- ------------------------------------------------- drawBox restores color
|
||||||
|
|
||||||
|
-- drawBox fills its interior white and used to leave the color that way.
|
||||||
|
-- Tile pages are black glyphs on transparent, so they draw black whatever the
|
||||||
|
-- color is and the leak stayed invisible for as long as every glyph was a
|
||||||
|
-- tile. TTF text draws in the current color, so every label printed after a
|
||||||
|
-- box came out white on white -- the summary screen lost ATTACK/DEFENSE/
|
||||||
|
-- SPEED/SPECIAL and TYPE1/TYPE2 while the numbers beside them survived.
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.drawBox(0, 0, 4, 4)
|
||||||
|
local r, g, b, a = love.graphics.getColor()
|
||||||
|
T.eq(("%s,%s,%s,%s"):format(r, g, b, a), "0,0,0,1",
|
||||||
|
"drawBox leaves the caller's color alone")
|
||||||
|
|
||||||
|
-- metrics come straight from the font object (the stub: half the point
|
||||||
|
-- size per codepoint, doubled from U+1000 up, mimicking Plain Pixel's
|
||||||
|
-- single/double width split)
|
||||||
|
local latin = Font.advanceOf(BASE + 65)
|
||||||
|
local kana = Font.advanceOf(BASE + 0x3042)
|
||||||
|
T.check(latin > 0, "a TTF glyph reports a positive advance")
|
||||||
|
T.eq(kana, latin * 2, "double-width kana advances twice a Latin glyph")
|
||||||
|
T.eq(Font.width("AB"), latin * 2, "width() sums TTF advances")
|
||||||
|
T.eq(Font.draw("AB", 0, 0), latin * 2, "draw() returns the same pen travel")
|
||||||
|
|
||||||
|
-- border chrome stays on tiles: below every page base here, drawCode must
|
||||||
|
-- not touch the TTF path (nothing to assert beyond "does not blow up",
|
||||||
|
-- because with no page image loaded the tile path draws nothing)
|
||||||
|
Font.drawCode(Font.BORDER.tl, 0, 0)
|
||||||
|
|
||||||
|
-- ------------------------------------------------- draw details
|
||||||
|
|
||||||
|
-- drawCode prints through the TTF at the configured vertical offset
|
||||||
|
-- (default bottom-aligns the tall glyph to the 8px cell) and restores
|
||||||
|
-- whatever font the caller had set
|
||||||
|
do
|
||||||
|
local g = love.graphics
|
||||||
|
local prints = {}
|
||||||
|
local oldPrint, oldSetFont = g.print, g.setFont
|
||||||
|
local marker = { marker = true }
|
||||||
|
g.setFont(marker)
|
||||||
|
g.print = function(text, x, y) prints[#prints + 1] = { text = text, x = x, y = y } end
|
||||||
|
Font.drawCode(BASE + 65, 10, 20)
|
||||||
|
g.print = oldPrint
|
||||||
|
T.eq(#prints, 1, "a TTF code draws through one love.graphics.print")
|
||||||
|
T.eq(prints[1].text, "A", "printing the character it encodes")
|
||||||
|
T.eq(prints[1].x, 10, "at the pen x")
|
||||||
|
-- stub baseline is px - 2 = 13 at the size-15 default; tile row 7
|
||||||
|
T.eq(prints[1].y, 20 + (7 - 13), "baseline-aligned to row 7 of the cell")
|
||||||
|
T.eq(g.getFont(), marker, "the caller's font is restored")
|
||||||
|
g.setFont = oldSetFont
|
||||||
|
end
|
||||||
|
|
||||||
|
-- bold = true double-prints at a 1px offset and widens the advance
|
||||||
|
Font.load({ font = { charmap = {}, ttf = { bold = true } } })
|
||||||
|
do
|
||||||
|
local g = love.graphics
|
||||||
|
local prints = {}
|
||||||
|
local oldPrint = g.print
|
||||||
|
g.print = function(_, x) prints[#prints + 1] = x end
|
||||||
|
Font.drawCode(BASE + 65, 10, 20)
|
||||||
|
g.print = oldPrint
|
||||||
|
T.eq(#prints, 2, "bold prints twice")
|
||||||
|
T.eq(prints[2], 11, "the second pass sits 1px over")
|
||||||
|
T.eq(Font.advanceOf(BASE + 65), latin + 1, "and the advance grows with it")
|
||||||
|
end
|
||||||
|
Font.load({ font = { charmap = CHARMAP, ttf = {} } })
|
||||||
|
|
||||||
|
-- spacing and yOffset are honored when a mod tunes them
|
||||||
|
Font.load({ font = { charmap = {}, ttf = { spacing = 2, yOffset = -2 } } })
|
||||||
|
T.eq(Font.advanceOf(BASE + 65), latin + 2, "spacing pads every advance")
|
||||||
|
do
|
||||||
|
local g = love.graphics
|
||||||
|
local y
|
||||||
|
local oldPrint = g.print
|
||||||
|
g.print = function(_, _, py) y = py end
|
||||||
|
Font.drawCode(BASE + 65, 0, 20)
|
||||||
|
g.print = oldPrint
|
||||||
|
T.eq(y, 18, "yOffset overrides the bottom-align default")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- a bad file degrades to tile mode instead of crashing the boot
|
||||||
|
Font.load({ font = { charmap = CHARMAP,
|
||||||
|
ttf = { file = "no/such/font.ttf" } } })
|
||||||
|
T.check(not Font.ttfActive(), "an unloadable ttf falls back to tiles")
|
||||||
|
T.eq(Font.encode("A")[1], 0x80, "and the charmap works as if never asked")
|
||||||
|
|
||||||
|
-- ------------------------------------------------- TextBox draws with it
|
||||||
|
|
||||||
|
-- the dialogue box pen must advance per glyph (it measured that way in
|
||||||
|
-- paginate all along); with the TTF active a line of 5px glyphs would
|
||||||
|
-- otherwise be drawn spread across the 8px grid
|
||||||
|
Font.load({ font = { charmap = CHARMAP, ttf = {} } })
|
||||||
|
do
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local box = setmetatable({
|
||||||
|
boxTx = 0, boxTy = 12, boxTw = 20, boxTh = 6,
|
||||||
|
textX = 8, line1Y = 112, line2Y = 128,
|
||||||
|
shown = { Font.encode("AB'd") }, blink = 0,
|
||||||
|
}, TextBox)
|
||||||
|
local pens = {}
|
||||||
|
local oldDraw = Font.drawCode
|
||||||
|
Font.drawCode = function(code, x, y)
|
||||||
|
if code >= BASE or code == 0xD0 then pens[#pens + 1] = { code = code, x = x } end
|
||||||
|
end
|
||||||
|
box:draw()
|
||||||
|
Font.drawCode = oldDraw
|
||||||
|
T.eq(#pens, 3, "three glyphs drawn for AB'd")
|
||||||
|
T.eq(pens[1].x, 8, "the pen starts at textX")
|
||||||
|
T.eq(pens[2].x, 8 + latin, "and moves by the TTF advance, not 8px")
|
||||||
|
T.eq(pens[3].x, 8 + latin * 2, "the ligature tile sits after the TTF pair")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------- the registry entry
|
||||||
|
|
||||||
|
do
|
||||||
|
local Schemas = require("src.mods.Schemas")
|
||||||
|
local spec = Schemas.REGISTRIES["font"]
|
||||||
|
T.check(select(1, Schemas.check(spec, "font", "ttf", {})) == true,
|
||||||
|
'register("ttf", {}) is legal: every field is optional')
|
||||||
|
T.check(select(1, Schemas.check(spec, "font", "ttf",
|
||||||
|
{ file = "mods/x/f.ttf", size = 11, spacing = 1, yOffset = -3 })) == true,
|
||||||
|
"so is a fully tuned entry")
|
||||||
|
T.check(select(1, Schemas.check(spec, "font", "ttf",
|
||||||
|
{ size = 10, tiles = "0123456789/:" })) == true,
|
||||||
|
"tiles takes a string of characters")
|
||||||
|
T.check(select(1, Schemas.check(spec, "font", "ttf",
|
||||||
|
{ tiles = { "0", "<PK>" } })) == true, "or a list of charmap sequences")
|
||||||
|
T.check(Schemas.check(spec, "font", "ttf", { tiles = 10 }) == nil,
|
||||||
|
"but not a number")
|
||||||
|
T.check(Schemas.check(spec, "font", "ttf", { image = "x.png", base = 0x100 })
|
||||||
|
== nil, "a page payload under the ttf id is refused")
|
||||||
|
T.check(Schemas.check(spec, "font", "page", {}) == nil,
|
||||||
|
"and a bare page still needs image and base")
|
||||||
|
|
||||||
|
-- write(): the entry lands on data.font.ttf and is rebuilt per merge
|
||||||
|
local target = { charmap = {}, pages = {} }
|
||||||
|
local registry = { order = { "ttf" },
|
||||||
|
get = function(_, id) return { size = 11 } end }
|
||||||
|
spec.write(target, registry)
|
||||||
|
T.eq(target.ttf and target.ttf.size, 11, "write() fills data.font.ttf")
|
||||||
|
T.check(target.pages.ttf == nil, "and does not mistake it for a page")
|
||||||
|
spec.write(target, { order = {}, get = function() return nil end })
|
||||||
|
T.check(target.ttf == nil, "a merge without the mod clears it again")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- leave the shared Font module in tile mode for whatever runs next
|
||||||
|
Font.load({ font = { charmap = {} } })
|
||||||
|
|
||||||
|
T.finish("ttf font mode")
|
||||||
@@ -74,11 +74,36 @@ stub.graphics = {
|
|||||||
-- newMesh / stencil stay absent on purpose: tools/save-editor/Theme.lua
|
-- newMesh / stencil stay absent on purpose: tools/save-editor/Theme.lua
|
||||||
-- probes for them and falls back to flat fills, which is the path a
|
-- probes for them and falls back to flat fills, which is the path a
|
||||||
-- headless run should take.
|
-- headless run should take.
|
||||||
newFont = function(size)
|
-- Accepts both real signatures: newFont(size) and
|
||||||
local px = size or 12
|
-- newFont(filename, size, hinting), the latter for the TTF text mode
|
||||||
|
-- (src/render/Font.lua). Width counts codepoints, not bytes, and CJK /
|
||||||
|
-- kana measure double, so tests can assert the wide-glyph metrics a real
|
||||||
|
-- pixel font (5px base, 11px double-width) exhibits without rasterizing.
|
||||||
|
newFont = function(a, b)
|
||||||
|
if type(a) == "string" then
|
||||||
|
-- real LÖVE raises on a missing file; callers pcall and fall back
|
||||||
|
local handle = io.open(a, "rb")
|
||||||
|
if not handle then error("Could not open file " .. a) end
|
||||||
|
handle:close()
|
||||||
|
end
|
||||||
|
local px = (type(a) == "number" and a) or b or 12
|
||||||
|
local unit = math.max(1, px * 0.5)
|
||||||
return {
|
return {
|
||||||
getWidth = function(_, text) return #tostring(text) * math.max(1, px * 0.5) end,
|
getWidth = function(_, text)
|
||||||
|
text = tostring(text)
|
||||||
|
local w, i, n = 0, 1, #text
|
||||||
|
while i <= n do
|
||||||
|
local byte = text:byte(i)
|
||||||
|
local len = byte >= 0xF0 and 4 or byte >= 0xE0 and 3
|
||||||
|
or byte >= 0xC0 and 2 or 1
|
||||||
|
w = w + (byte >= 0xE1 and 2 or 1) * unit -- U+1000+: double width
|
||||||
|
i = i + len
|
||||||
|
end
|
||||||
|
return w
|
||||||
|
end,
|
||||||
getHeight = function() return px end,
|
getHeight = function() return px end,
|
||||||
|
getBaseline = function() return px - 2 end,
|
||||||
|
setFilter = noop,
|
||||||
}
|
}
|
||||||
end,
|
end,
|
||||||
setFont = function(f) gstate.font = f end,
|
setFont = function(f) gstate.font = f end,
|
||||||
|
|||||||
@@ -590,5 +590,37 @@ do
|
|||||||
.. data.pokemon.NIDORAN_F.catchRate .. ")")
|
.. data.pokemon.NIDORAN_F.catchRate .. ")")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- (4) name-field tails: real cartridge saves legitimately hold 0x00 (and
|
||||||
|
-- stale glyph) bytes after a name's $50 terminator, and rewriting them broke
|
||||||
|
-- the import -> export byte-identical round trip. With a template every
|
||||||
|
-- tail byte must survive verbatim; only a templateless (engine-origin)
|
||||||
|
-- export $50-pads the tail (#206).
|
||||||
|
do
|
||||||
|
local s = SaveData.newGame({ playerName = "RED" })
|
||||||
|
for i = 1, 12 do s.boxes = s.boxes or {}; s.boxes[i] = {} end
|
||||||
|
local b1 = GenSave.encode(s, data, nil)
|
||||||
|
-- templateless: the tail past "RED@" must be all $50 padding
|
||||||
|
local ok50 = true
|
||||||
|
for i = 4, 10 do
|
||||||
|
if b1:byte(OFF.playerName + i + 1) ~= 0x50 then ok50 = false end
|
||||||
|
end
|
||||||
|
check(ok50, "templateless export $50-pads the player-name tail (#206)")
|
||||||
|
-- build a template whose player-name tail mixes 0x00 and stale glyphs
|
||||||
|
-- after the terminator, exactly like a real traded/edited cartridge save
|
||||||
|
local tpl = {}
|
||||||
|
for i = 1, #b1 do tpl[i] = b1:sub(i, i) end
|
||||||
|
tpl[OFF.playerName + 5] = string.char(0x00)
|
||||||
|
tpl[OFF.playerName + 6] = string.char(0x81)
|
||||||
|
tpl[OFF.playerName + 7] = string.char(0x00)
|
||||||
|
local tplBytes = table.concat(tpl)
|
||||||
|
local decoded = GenSave.decode(tplBytes, data)
|
||||||
|
check(decoded.player.name == "RED",
|
||||||
|
"a 0x00/stale tail past the terminator does not leak into the name")
|
||||||
|
local b2 = GenSave.encode(decoded, data)
|
||||||
|
check(b2:sub(OFF.playerName + 1, OFF.playerName + 11)
|
||||||
|
== tplBytes:sub(OFF.playerName + 1, OFF.playerName + 11),
|
||||||
|
"template name tails (incl. 0x00 bytes) round-trip byte-identical")
|
||||||
|
end
|
||||||
|
|
||||||
print(string.format("save convert: %d/%d checks passed", checks - failures, checks))
|
print(string.format("save convert: %d/%d checks passed", checks - failures, checks))
|
||||||
if failures > 0 then os.exit(1) end
|
if failures > 0 then os.exit(1) end
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
*.gb
|
||||||
|
*.sym
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 hernan0078
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# gbromdiff
|
||||||
|
|
||||||
|
Compare two Game Boy ROMs bank by bank, and say **where** and **how** they
|
||||||
|
disagree.
|
||||||
|
|
||||||
|
Written for one specific problem: bringing a localised Gen 1 Pokémon release
|
||||||
|
into a disassembly. It is not specific to Pokémon, or to Gen 1 — any pair of
|
||||||
|
Game Boy ROMs will do.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The European Pokémon releases are rebuilds of their US counterparts. Most
|
||||||
|
banks are byte-identical, the text banks are entirely different, and a handful
|
||||||
|
of code banks are the US code with pointers shifted. Which bank is which is
|
||||||
|
the first thing you need to know and the last thing anyone writes down.
|
||||||
|
|
||||||
|
That distinction is the whole point of this tool:
|
||||||
|
|
||||||
|
```
|
||||||
|
bank differing bytes shape
|
||||||
|
0x04 30 ( 0.2%) patched
|
||||||
|
0x10 16328 ( 99.7%) replaced
|
||||||
|
```
|
||||||
|
|
||||||
|
A bank differing in 99.7% of its bytes is a **different payload** — translated
|
||||||
|
text — and needs new source. A bank differing in 0.2% is **the same code with
|
||||||
|
values moved**, and usually needs a pointer table corrected rather than a
|
||||||
|
rewrite. Those two findings lead to completely different work, and a plain
|
||||||
|
`cmp` cannot tell them apart.
|
||||||
|
|
||||||
|
## The loop it is built for
|
||||||
|
|
||||||
|
```
|
||||||
|
1. build the disassembly
|
||||||
|
2. diff the build against the retail ROM ← this tool
|
||||||
|
3. fix the banks that disagree
|
||||||
|
4. go to 1, until nothing disagrees
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit status is **0 when the ROMs are identical** and **1 when they differ**, so
|
||||||
|
it drops straight into that loop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make && ./gbromdiff.py build.gb retail.gb || echo "not there yet"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gbromdiff.py A.gb B.gb # bank-by-bank summary
|
||||||
|
./gbromdiff.py A.gb B.gb --regions # contiguous differing runs
|
||||||
|
./gbromdiff.py A.gb B.gb --sym pokeyellow.sym # name the symbols involved
|
||||||
|
./gbromdiff.py A.gb B.gb --json # machine-readable
|
||||||
|
```
|
||||||
|
|
||||||
|
With an rgbds `.sym` file it names the symbol each differing region falls
|
||||||
|
inside, which turns
|
||||||
|
|
||||||
|
```
|
||||||
|
0x0703A1 differs
|
||||||
|
```
|
||||||
|
|
||||||
|
into
|
||||||
|
|
||||||
|
```
|
||||||
|
0x0703A1-0x0703B4 bank 0x1C 20 bytes TextPredef+0x3A1
|
||||||
|
```
|
||||||
|
|
||||||
|
`--gap N` controls how many matching bytes are tolerated inside one region
|
||||||
|
(default 16). Without it, a shifted pointer table reports as hundreds of
|
||||||
|
one-byte findings instead of one region worth looking at.
|
||||||
|
|
||||||
|
No dependencies beyond Python 3.
|
||||||
|
|
||||||
|
## What this does not do
|
||||||
|
|
||||||
|
It does not build anything, and it does not write a disassembly. It tells you
|
||||||
|
where two ROMs differ. Turning that into a source tree that rebuilds a
|
||||||
|
localised ROM byte-for-byte is the actual project; this is the instrument you
|
||||||
|
point at it between iterations.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Spanish Red and Blue releases are supported in
|
||||||
|
[gen1recomp](https://github.com/bryanthaboi/gen1recomp) by way of
|
||||||
|
[einstein95/pokered-es](https://github.com/einstein95/pokered-es), a
|
||||||
|
shift-matching disassembly whose `.sym` files provide Spanish addresses for
|
||||||
|
every symbol.
|
||||||
|
|
||||||
|
Spanish **Yellow** has no equivalent, so it cannot be supported the same way.
|
||||||
|
French (`Narishma-gb/pokeyellow-fr`) and German (`Brianum/pokeyellow-de`)
|
||||||
|
Yellow disassemblies both exist, so adapting pret's Yellow to a European
|
||||||
|
release is demonstrably possible — it simply has not been done for Spanish.
|
||||||
|
This tool exists to make that attempt less tedious.
|
||||||
|
|
||||||
|
MIT licensed. Contributions welcome, particularly from anyone actually
|
||||||
|
attempting `pokeyellow-es`.
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compare two Game Boy ROMs bank by bank, and say where they disagree.
|
||||||
|
|
||||||
|
Written for the problem of bringing a localised Gen 1 release into a
|
||||||
|
disassembly. The European Pokemon releases are rebuilds of their US
|
||||||
|
counterparts: most banks are byte-identical, the text banks are wholly
|
||||||
|
different, and a handful of code banks are the US code with shifted
|
||||||
|
pointers. Knowing WHICH is which is the first thing you need and the
|
||||||
|
last thing anyone writes down.
|
||||||
|
|
||||||
|
The loop this is built for:
|
||||||
|
|
||||||
|
1. build the disassembly
|
||||||
|
2. diff the build against the retail ROM
|
||||||
|
3. fix the banks that disagree
|
||||||
|
4. go to 1, until nothing disagrees
|
||||||
|
|
||||||
|
Step 2 is this script. With a .sym file it also names the symbols that
|
||||||
|
live inside each differing region, which turns "bank 0x1C differs at
|
||||||
|
0x4A31" into "bank 0x1C differs, starting inside TextPredef".
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
gbromdiff.py A.gb B.gb bank-by-bank summary
|
||||||
|
gbromdiff.py A.gb B.gb --regions contiguous differing runs
|
||||||
|
gbromdiff.py A.gb B.gb --sym pokeyellow.sym name the symbols involved
|
||||||
|
gbromdiff.py A.gb B.gb --json machine-readable
|
||||||
|
|
||||||
|
Exit status is 0 when the ROMs are identical, 1 when they differ, 2 on a
|
||||||
|
usage error -- so it can drive a build loop directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BANK_SIZE = 0x4000
|
||||||
|
|
||||||
|
|
||||||
|
def load(path):
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
|
||||||
|
def header(rom):
|
||||||
|
"""Title, CGB flag and global checksum, straight out of the cartridge
|
||||||
|
header. Useful for saying WHICH releases are being compared without
|
||||||
|
the caller having to know the hashes."""
|
||||||
|
if len(rom) < 0x150:
|
||||||
|
return {}
|
||||||
|
title = rom[0x134:0x143].split(b"\x00")[0]
|
||||||
|
try:
|
||||||
|
title = title.decode("ascii", "replace").strip()
|
||||||
|
except Exception:
|
||||||
|
title = repr(title)
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"cgb": rom[0x143],
|
||||||
|
"rom_size_code": rom[0x148],
|
||||||
|
"global_checksum": (rom[0x14E] << 8) | rom[0x14F],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def banks(rom):
|
||||||
|
return (len(rom) + BANK_SIZE - 1) // BANK_SIZE
|
||||||
|
|
||||||
|
|
||||||
|
def bank_report(a, b):
|
||||||
|
"""Per-bank identical / differs / missing, with a byte count."""
|
||||||
|
out = []
|
||||||
|
for i in range(max(banks(a), banks(b))):
|
||||||
|
lo, hi = i * BANK_SIZE, (i + 1) * BANK_SIZE
|
||||||
|
ba, bb = a[lo:hi], b[lo:hi]
|
||||||
|
if not ba or not bb:
|
||||||
|
out.append({"bank": i, "state": "missing",
|
||||||
|
"in_a": bool(ba), "in_b": bool(bb)})
|
||||||
|
continue
|
||||||
|
if ba == bb:
|
||||||
|
out.append({"bank": i, "state": "identical", "differing": 0})
|
||||||
|
continue
|
||||||
|
n = sum(1 for x, y in zip(ba, bb) if x != y)
|
||||||
|
# A bank that differs in nearly every byte is a different payload
|
||||||
|
# (translated text); one that differs in a scatter of bytes is the
|
||||||
|
# same code with pointers moved. That distinction is the whole
|
||||||
|
# reason to look at a percentage rather than a boolean.
|
||||||
|
pct = 100.0 * n / min(len(ba), len(bb))
|
||||||
|
out.append({"bank": i, "state": "differs", "differing": n,
|
||||||
|
"percent": round(pct, 2),
|
||||||
|
"shape": "replaced" if pct > 60 else
|
||||||
|
("patched" if pct < 5 else "mixed")})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def regions(a, b, gap=16):
|
||||||
|
"""Contiguous runs of differing bytes, merging runs separated by fewer
|
||||||
|
than `gap` matching bytes -- otherwise a shifted pointer table reads as
|
||||||
|
hundreds of one-byte findings instead of one region."""
|
||||||
|
out = []
|
||||||
|
n = min(len(a), len(b))
|
||||||
|
start = None
|
||||||
|
last = None
|
||||||
|
for i in range(n):
|
||||||
|
if a[i] != b[i]:
|
||||||
|
if start is None:
|
||||||
|
start = i
|
||||||
|
elif last is not None and i - last > gap:
|
||||||
|
out.append((start, last))
|
||||||
|
start = i
|
||||||
|
last = i
|
||||||
|
if start is not None:
|
||||||
|
out.append((start, last))
|
||||||
|
if len(a) != len(b):
|
||||||
|
out.append((n, max(len(a), len(b)) - 1))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_symbols(path):
|
||||||
|
"""An rgbds .sym file: `BB:AAAA Name` per line. Returned as a list of
|
||||||
|
(absolute_offset, bank, addr, name), sorted, so a region can be mapped
|
||||||
|
to whatever symbol most recently preceded it."""
|
||||||
|
syms = []
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.split(";")[0].strip()
|
||||||
|
if not line or ":" not in line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
where, name = line.split(None, 1)
|
||||||
|
bank_s, addr_s = where.split(":")
|
||||||
|
bank, addr = int(bank_s, 16), int(addr_s, 16)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
# bank 0 is 0000-3FFF; every other bank is paged in at 4000
|
||||||
|
offset = addr if bank == 0 else bank * BANK_SIZE + (addr - 0x4000)
|
||||||
|
syms.append((offset, bank, addr, name.strip()))
|
||||||
|
syms.sort()
|
||||||
|
return syms
|
||||||
|
|
||||||
|
|
||||||
|
def symbol_before(syms, offset):
|
||||||
|
"""The last symbol at or before `offset` -- i.e. the thing this byte is
|
||||||
|
most likely part of. Binary search over the sorted table."""
|
||||||
|
lo, hi = 0, len(syms) - 1
|
||||||
|
best = None
|
||||||
|
while lo <= hi:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
if syms[mid][0] <= offset:
|
||||||
|
best = syms[mid]
|
||||||
|
lo = mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid - 1
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Compare two Game Boy ROMs bank by bank.")
|
||||||
|
ap.add_argument("rom_a")
|
||||||
|
ap.add_argument("rom_b")
|
||||||
|
ap.add_argument("--regions", action="store_true",
|
||||||
|
help="list contiguous differing runs, not just banks")
|
||||||
|
ap.add_argument("--sym", help="rgbds .sym file, to name the symbols "
|
||||||
|
"each differing region falls inside")
|
||||||
|
ap.add_argument("--gap", type=int, default=16,
|
||||||
|
help="matching bytes tolerated inside one region "
|
||||||
|
"(default 16)")
|
||||||
|
ap.add_argument("--limit", type=int, default=40,
|
||||||
|
help="max regions to print (default 40)")
|
||||||
|
ap.add_argument("--json", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
for p in (args.rom_a, args.rom_b):
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
print(f"error: no such file: {p}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
a, b = load(args.rom_a), load(args.rom_b)
|
||||||
|
rep = bank_report(a, b)
|
||||||
|
same = [r for r in rep if r["state"] == "identical"]
|
||||||
|
diff = [r for r in rep if r["state"] == "differs"]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"a": {"path": args.rom_a, "bytes": len(a), **header(a)},
|
||||||
|
"b": {"path": args.rom_b, "bytes": len(b), **header(b)},
|
||||||
|
"banks_total": len(rep),
|
||||||
|
"banks_identical": len(same),
|
||||||
|
"banks_differing": len(diff),
|
||||||
|
"banks": rep,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.regions or args.sym:
|
||||||
|
regs = regions(a, b, args.gap)
|
||||||
|
syms = load_symbols(args.sym) if args.sym else None
|
||||||
|
listed = []
|
||||||
|
for start, end in regs[: args.limit]:
|
||||||
|
item = {"start": start, "end": end, "length": end - start + 1,
|
||||||
|
"bank": start // BANK_SIZE}
|
||||||
|
if syms:
|
||||||
|
s = symbol_before(syms, start)
|
||||||
|
if s:
|
||||||
|
item["symbol"] = s[3]
|
||||||
|
item["symbol_offset"] = start - s[0]
|
||||||
|
listed.append(item)
|
||||||
|
result["regions_total"] = len(regs)
|
||||||
|
result["regions"] = listed
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
return 0 if not diff and len(a) == len(b) else 1
|
||||||
|
|
||||||
|
ha, hb = result["a"], result["b"]
|
||||||
|
print(f"A {ha.get('title','?'):<16} {len(a):>8} bytes {args.rom_a}")
|
||||||
|
print(f"B {hb.get('title','?'):<16} {len(b):>8} bytes {args.rom_b}")
|
||||||
|
print()
|
||||||
|
if not diff and len(a) == len(b):
|
||||||
|
print(f"IDENTICAL — all {len(rep)} banks match")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"{len(same)}/{len(rep)} banks identical, {len(diff)} differ")
|
||||||
|
print()
|
||||||
|
print(" bank differing bytes shape")
|
||||||
|
for r in diff:
|
||||||
|
if r["state"] != "differs":
|
||||||
|
continue
|
||||||
|
print(f" 0x{r['bank']:02X} {r['differing']:>6} "
|
||||||
|
f"({r['percent']:>5.1f}%) {r['shape']}")
|
||||||
|
print()
|
||||||
|
print(" replaced = a different payload (translated text)")
|
||||||
|
print(" patched = the same code with a few values moved")
|
||||||
|
|
||||||
|
if "regions" in result:
|
||||||
|
print()
|
||||||
|
print(f"{result['regions_total']} differing regions "
|
||||||
|
f"(showing {len(result['regions'])}):")
|
||||||
|
for item in result["regions"]:
|
||||||
|
line = (f" 0x{item['start']:06X}-0x{item['end']:06X} "
|
||||||
|
f"bank 0x{item['bank']:02X} {item['length']:>6} bytes")
|
||||||
|
if "symbol" in item:
|
||||||
|
line += f" {item['symbol']}+0x{item['symbol_offset']:X}"
|
||||||
|
print(line)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -7,7 +7,7 @@ Subcommands:
|
|||||||
scaffold <id> [--profile content|overhaul|total_conversion] [--api 2]
|
scaffold <id> [--profile content|overhaul|total_conversion] [--api 2]
|
||||||
[--github owner/repo] [--experimental] [--dest DIR] [--force]
|
[--github owner/repo] [--experimental] [--dest DIR] [--force]
|
||||||
translation <id> [--language NAME] [--base auto|fixture|imported]
|
translation <id> [--language NAME] [--base auto|fixture|imported]
|
||||||
[--refresh] [--dest DIR]
|
[--refresh] [--dest DIR] [--pixel-font]
|
||||||
validate <id|path> [--strict] [--base auto|fixture|imported]
|
validate <id|path> [--strict] [--base auto|fixture|imported]
|
||||||
lint <id|path>
|
lint <id|path>
|
||||||
pack <mod-dir> [-o out.modpkg]
|
pack <mod-dir> [-o out.modpkg]
|
||||||
@@ -1475,6 +1475,17 @@ return function(mod)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- ---- glyphs -------------------------------------------------------
|
-- ---- glyphs -------------------------------------------------------
|
||||||
|
-- Text rendering through the bundled Plain Pixel TTF ("Plain Pixel
|
||||||
|
-- Font" by Douglas Vautour (Burpy Fresh), CC-BY 4.0 -- see
|
||||||
|
-- assets/fonts/plainpixel/README.md). Registered, it replaces the tile
|
||||||
|
-- font for ordinary characters, so a translation needs no glyph sheet
|
||||||
|
-- at all; box borders and <PK>-style macros keep their tiles. Options:
|
||||||
|
-- { file = mod.assets:path("myfont.ttf"), size = 15, spacing = 0,
|
||||||
|
-- yOffset = -6, bold = true } -- size is the font's design em (Plain
|
||||||
|
-- Pixel only rasterizes cleanly at multiples of 15), bold thickens a
|
||||||
|
-- 1px-stroke font that reads too light.
|
||||||
|
{{ttf_register}}mod.content.font:register("ttf", {})
|
||||||
|
|
||||||
-- Register the sheet BEFORE anything asks for a glyph on it. base is
|
-- Register the sheet BEFORE anything asks for a glyph on it. base is
|
||||||
-- the first code the page owns; 0x100 and up is free space above the
|
-- the first code the page owns; 0x100 and up is free space above the
|
||||||
-- vanilla pages, so a new alphabet never collides with them.
|
-- vanilla pages, so a new alphabet never collides with them.
|
||||||
@@ -1567,6 +1578,14 @@ you can translate straight from it.
|
|||||||
|
|
||||||
## Start with the font, not the text
|
## Start with the font, not the text
|
||||||
|
|
||||||
|
The fast path: scaffold with `--pixel-font` (or uncomment the
|
||||||
|
`mod.content.font:register("ttf", {})` line in `main.lua`) and the game
|
||||||
|
renders text through the engine's bundled Plain Pixel TTF, which already
|
||||||
|
covers Latin with diacritics, Cyrillic, kana and CJK. No glyph sheet, no
|
||||||
|
charmap; `lang/font.lua` and `lang/charmap.lua` can stay empty. The rest
|
||||||
|
of this section is for translations that want the hand-drawn tile look
|
||||||
|
instead.
|
||||||
|
|
||||||
The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap
|
The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap
|
||||||
saying which byte sequence draws which cell. The vanilla pages sit at `$60`
|
saying which byte sequence draws which cell. The vanilla pages sit at `$60`
|
||||||
and `$80`. Anything from `0x100` up is free, so a new alphabet is added
|
and `$80`. Anything from `0x100` up is free, so a new alphabet is added
|
||||||
@@ -1665,7 +1684,13 @@ Nothing is translated yet: {{total}} strings are waiting in `lang/`.
|
|||||||
- `assets/font/` - your glyph sheet
|
- `assets/font/` - your glyph sheet
|
||||||
'''
|
'''
|
||||||
|
|
||||||
FONT_README = '''Put your glyph sheet here.
|
FONT_README = '''You may not need this directory at all: scaffold with
|
||||||
|
`--pixel-font` (or uncomment the `register("ttf", {})` line in main.lua)
|
||||||
|
and text renders through the engine's bundled Plain Pixel TTF, which
|
||||||
|
covers Latin, kana and CJK out of the box. A glyph sheet is only for a
|
||||||
|
translation that wants the hand-drawn GB look.
|
||||||
|
|
||||||
|
Put your glyph sheet here.
|
||||||
|
|
||||||
A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes
|
A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes
|
||||||
run left to right and top to bottom starting at the page's `base`, so the
|
run left to right and top to bottom starting at the page's `base`, so the
|
||||||
@@ -1761,6 +1786,7 @@ def cmd_translation(args, repo):
|
|||||||
"{{extra}}": "",
|
"{{extra}}": "",
|
||||||
"{{github_line}}": "",
|
"{{github_line}}": "",
|
||||||
"{{experimental}}": "false",
|
"{{experimental}}": "false",
|
||||||
|
"{{ttf_register}}": "" if args.pixel_font else "-- ",
|
||||||
"{{total}}": str(sum(counts.values())),
|
"{{total}}": str(sum(counts.values())),
|
||||||
"{{table}}": "\n".join(
|
"{{table}}": "\n".join(
|
||||||
f"| `lang/{name}.lua` | {counts[name]} |" for name, *_ in catalogs),
|
f"| `lang/{name}.lua` | {counts[name]} |" for name, *_ in catalogs),
|
||||||
@@ -1999,6 +2025,9 @@ def main(argv):
|
|||||||
p.add_argument("--dest")
|
p.add_argument("--dest")
|
||||||
p.add_argument("--base", default="auto",
|
p.add_argument("--base", default="auto",
|
||||||
choices=["auto", "fixture", "imported"])
|
choices=["auto", "fixture", "imported"])
|
||||||
|
p.add_argument("--pixel-font", action="store_true",
|
||||||
|
help="render text through the bundled Plain Pixel TTF "
|
||||||
|
"instead of the tile font (no glyph sheet needed)")
|
||||||
p.add_argument("--refresh", action="store_true",
|
p.add_argument("--refresh", action="store_true",
|
||||||
help="re-harvest the catalogs, keeping existing work")
|
help="re-harvest the catalogs, keeping existing work")
|
||||||
p.add_argument("--force", action="store_true")
|
p.add_argument("--force", action="store_true")
|
||||||
|
|||||||