Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3fcdd0776 |
@@ -1,13 +0,0 @@
|
||||
# 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,17 +153,6 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
|
||||
`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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# New features (deliberate additions beyond the original)
|
||||
|
||||
Intentional enhancements this port adds on top of faithful Pokémon Red, Blue,
|
||||
and Yellow behavior. They have no Game Boy equivalent and are kept by design.
|
||||
Intentional enhancements this port adds on top of faithful Pokémon Red
|
||||
behavior. They have no Game Boy equivalent and are kept by design.
|
||||
Genuine divergences from the original (things still missing, wrong, or
|
||||
approximated) live in docs/known-differences.md; faithfully-ported behavior is
|
||||
in docs/behavior-porting-notes.md.
|
||||
approximated) live in docs/known-differences.md; faithfully-ported
|
||||
behavior is in docs/behavior-porting-notes.md.
|
||||
|
||||
## Survey zoom
|
||||
|
||||
@@ -376,28 +376,6 @@ plus a glyph-page and charmap stub, a naming-grid stub, and a
|
||||
be packed). `--refresh` re-harvests after an engine update, keeping
|
||||
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.
|
||||
|
||||
## Save editor (bundled, reachable from the launcher)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# What This Port Requires
|
||||
|
||||
The packaged desktop app requires one user-supplied input on first boot: a
|
||||
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
|
||||
canonical 1 MiB US Pokemon Red ROM.
|
||||
|
||||
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
||||
for specific hashes). Other revisions and Virtual Console releases are rejected
|
||||
rather than decoded with incorrect addresses.
|
||||
The importer verifies SHA-1
|
||||
`ea9bcae617fdf159b045185467ae58b2e4a48b9a`. Other revisions, Virtual
|
||||
Console releases, and Pokemon Blue are rejected rather than decoded with
|
||||
incorrect addresses.
|
||||
|
||||
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.
|
||||
@@ -14,11 +15,9 @@ Python and Pillow are not required by the packaged app.
|
||||
## Bundled Metadata
|
||||
|
||||
Assembly removes high-level names and some relationships that the Lua port
|
||||
needs. The version-specific files `tools/rom_manifest.json`,
|
||||
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
|
||||
contain:
|
||||
needs. `tools/rom_manifest.json` therefore contains:
|
||||
|
||||
- the ROM symbol addresses actually read by the extractor
|
||||
- the 3,268 ROM symbol addresses actually read by the extractor
|
||||
- symbolic IDs and ordering for maps, species, moves, items, and trainers
|
||||
- source-erased dimensions, image names, and map object integration names
|
||||
- hand-ported field/script integration tables
|
||||
|
||||
@@ -230,9 +230,7 @@ function flexlove.init(config)
|
||||
-- Initialize Performance if available
|
||||
if Performance then
|
||||
flexlove._Performance = Performance.init({
|
||||
-- ~= false (not `or true`): `performanceMonitoring = false` must
|
||||
-- actually disable the per-frame timers + memory sampling.
|
||||
enabled = config.performanceMonitoring ~= false,
|
||||
enabled = config.performanceMonitoring or true,
|
||||
hudEnabled = false, -- Start with HUD disabled
|
||||
hudToggleKey = config.performanceHudKey or "f3",
|
||||
hudPosition = config.performanceHudPosition or { x = 10, y = 10 },
|
||||
|
||||
@@ -2709,7 +2709,6 @@ function Element:_drawChildren(backdropCanvas)
|
||||
|
||||
-- Set up clipping: rounded-corners (stencil) > overflow (scissor) > none
|
||||
local clipMode = "none"
|
||||
local prevSx, prevSy, prevSw, prevSh
|
||||
if hasRoundedCorners then
|
||||
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)
|
||||
@@ -2722,18 +2721,7 @@ function Element:_drawChildren(backdropCanvas)
|
||||
love.graphics.setStencilTest("greater", 0)
|
||||
clipMode = "stencil"
|
||||
elseif needsOverflowClipping then
|
||||
-- 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)
|
||||
love.graphics.setScissor(self.x + self.padding.left, self.y + self.padding.top, self.width, self.height)
|
||||
clipMode = "scissor"
|
||||
end
|
||||
|
||||
@@ -2757,11 +2745,7 @@ function Element:_drawChildren(backdropCanvas)
|
||||
if clipMode == "stencil" then
|
||||
love.graphics.setStencilTest()
|
||||
elseif clipMode == "scissor" then
|
||||
if prevSx then
|
||||
love.graphics.setScissor(prevSx, prevSy, prevSw, prevSh)
|
||||
else
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -112,8 +112,7 @@ function ScrollManager.new(config, deps)
|
||||
self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true
|
||||
self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true
|
||||
self.bounceEnabled = config.bounceEnabled ~= false -- Default true
|
||||
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.scrollFriction = config.scrollFriction or 0.95 -- Exponential decay per frame
|
||||
self.bounceStiffness = config.bounceStiffness or 0.2 -- Spring constant
|
||||
self.maxOverscroll = config.maxOverscroll or 100 -- pixels
|
||||
|
||||
@@ -128,8 +127,7 @@ function ScrollManager.new(config, deps)
|
||||
self._scrollY = config._scrollY or 0
|
||||
self._targetScrollX = nil
|
||||
self._targetScrollY = nil
|
||||
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._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster)
|
||||
self.smoothScrollEnabled = config.smoothScrollEnabled or false -- Enable smooth wheel scrolling
|
||||
self._maxScrollX = 0
|
||||
self._maxScrollY = 0
|
||||
@@ -927,15 +925,9 @@ function ScrollManager:handleTouchMove(touchX, touchY)
|
||||
dx = -dx
|
||||
dy = -dy
|
||||
|
||||
-- Velocity tracking (pixels per second). Not the raw last-sample d/dt:
|
||||
-- touch samples jitter, and the final pre-release sample often reads near
|
||||
-- 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
|
||||
-- Calculate velocity (pixels per second)
|
||||
self._scrollVelocityX = dx / dt
|
||||
self._scrollVelocityY = dy / dt
|
||||
|
||||
-- Apply scroll with bounce if enabled
|
||||
if self.bounceEnabled then
|
||||
@@ -976,15 +968,6 @@ function ScrollManager:handleTouchRelease()
|
||||
|
||||
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
|
||||
if self.momentumScrollEnabled then
|
||||
local velocityThreshold = 50 -- pixels per second
|
||||
@@ -1007,23 +990,12 @@ end
|
||||
--- Update momentum scrolling (call every frame with dt)
|
||||
---@param dt number Delta time in seconds
|
||||
function ScrollManager:update(dt)
|
||||
-- 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)
|
||||
-- Smooth scroll interpolation
|
||||
if self._targetScrollX or self._targetScrollY then
|
||||
local alpha = 1 - math.exp(-(self._smoothScrollRate or 30) * step)
|
||||
if self._targetScrollY then
|
||||
local diff = self._targetScrollY - self._scrollY
|
||||
if math.abs(diff) > 0.5 then
|
||||
self._scrollY = self._scrollY + diff * alpha
|
||||
self._scrollY = self._scrollY + diff * self._smoothScrollSpeed
|
||||
else
|
||||
self._scrollY = self._targetScrollY
|
||||
self._targetScrollY = nil
|
||||
@@ -1033,7 +1005,7 @@ function ScrollManager:update(dt)
|
||||
if self._targetScrollX then
|
||||
local diff = self._targetScrollX - self._scrollX
|
||||
if math.abs(diff) > 0.5 then
|
||||
self._scrollX = self._scrollX + diff * alpha
|
||||
self._scrollX = self._scrollX + diff * self._smoothScrollSpeed
|
||||
else
|
||||
self._scrollX = self._targetScrollX
|
||||
self._targetScrollX = nil
|
||||
@@ -1049,43 +1021,25 @@ function ScrollManager:update(dt)
|
||||
return
|
||||
end
|
||||
|
||||
-- Apply velocity to scroll position (step, not dt: dt is 0 in the
|
||||
-- immediate-mode endFrame path, which left touch flings completely dead)
|
||||
local dx = self._scrollVelocityX * step
|
||||
local dy = self._scrollVelocityY * step
|
||||
-- Apply velocity to scroll position
|
||||
local dx = self._scrollVelocityX * dt
|
||||
local dy = self._scrollVelocityY * dt
|
||||
|
||||
if self.bounceEnabled then
|
||||
-- Allow overscroll during momentum, but never past maxOverscroll: an
|
||||
-- unclamped fling carried a list hundreds of pixels past its edge
|
||||
-- (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)
|
||||
-- Allow overscroll during momentum
|
||||
self._scrollX = self._scrollX + dx
|
||||
self._scrollY = self._scrollY + dy
|
||||
else
|
||||
self:scrollBy(dx, dy)
|
||||
end
|
||||
|
||||
-- Frame-rate-independent exponential decay tuned to native list feel:
|
||||
-- iOS "normal" deceleration is ~0.998 per millisecond, i.e. exp(-2.0 t).
|
||||
-- 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
|
||||
-- Apply friction (exponential decay)
|
||||
self._scrollVelocityX = self._scrollVelocityX * self.scrollFriction
|
||||
self._scrollVelocityY = self._scrollVelocityY * self.scrollFriction
|
||||
|
||||
-- Stop momentum when velocity is very low
|
||||
local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2)
|
||||
if totalVelocity < 5 then
|
||||
if totalVelocity < 1 then
|
||||
self._momentumScrolling = false
|
||||
self._scrollVelocityX = 0
|
||||
self._scrollVelocityY = 0
|
||||
|
||||
@@ -214,13 +214,10 @@ local function onDraw(element, _ctx)
|
||||
if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then
|
||||
return
|
||||
end
|
||||
-- Lift the parent scissor while drawing scrollbars so they render fully
|
||||
-- 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()
|
||||
-- Clear any parent scissor clipping before drawing scrollbars so they render
|
||||
-- fully visible (scrollbars must not be clipped by ancestor overflow).
|
||||
love.graphics.setScissor()
|
||||
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
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 10 KiB |
@@ -72,13 +72,8 @@ rm -f "$LOVE_FILE"
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
# Materialize the listing once and grep the file: piping unzip straight into
|
||||
# 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
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
# The editor is only reachable if its entry point and both module directories
|
||||
@@ -91,7 +86,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
libs/flexlove/FlexLove.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json; do
|
||||
grep -qxF "$required" "$LOVE_LISTING" \
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
||||
|| fail "game.love is missing $required"
|
||||
done
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||
@@ -121,32 +116,6 @@ else
|
||||
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
||||
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
|
||||
build_mac() {
|
||||
say "building macOS app"
|
||||
@@ -173,21 +142,9 @@ build_mac() {
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $VERSION" "$plist"
|
||||
|
||||
# Brand the app icon. LÖVE.app resolves its icon through CFBundleIconName ->
|
||||
# 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"
|
||||
if [ -f "$ROOT/assets/icon.icns" ]; then
|
||||
cp "$ROOT/assets/icon.icns" "$out_app/Contents/Resources/GameIcon.icns"
|
||||
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"
|
||||
if [ -z "$id" ]; then
|
||||
@@ -261,47 +218,7 @@ build_win() {
|
||||
cp "$love_dir"/*.dll "$out_dir"/
|
||||
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
|
||||
|
||||
# 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
|
||||
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
|
||||
|
||||
local zip_out="$DIST/win/$APP_NAME-win64.zip"
|
||||
rm -f "$zip_out"
|
||||
@@ -356,15 +273,6 @@ build_linux() {
|
||||
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
|
||||
|
||||
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"
|
||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
||||
|
||||
@@ -695,11 +695,7 @@ run_xcodebuild() {
|
||||
fi
|
||||
|
||||
# Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love.
|
||||
# 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
|
||||
if [ ! -f "$app/game.love" ]; then
|
||||
say "fusing game.love into $(basename "$app")"
|
||||
cp "$LOVE_FILE" "$app/game.love"
|
||||
fi
|
||||
|
||||
@@ -380,21 +380,18 @@ local CHARGE_TEXT = {
|
||||
-- pokered's <USER>/<TARGET> text macros (home/text.asm
|
||||
-- PlaceMoveUsersName): battle texts naming the enemy mon print
|
||||
-- "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)
|
||||
return b.isPlayer and b.name or Strings("Enemy %s", b.name)
|
||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
||||
end
|
||||
|
||||
-- Apply the enemy qualifier to a pre-built message from a module that
|
||||
-- only knows the raw nickname (Status.beforeMove/residual): replace the
|
||||
-- first name occurrence with the qualified form.
|
||||
-- Apply the "Enemy " prefix to a pre-built message from a module that
|
||||
-- only knows the raw nickname (Status.beforeMove/residual,
|
||||
-- TrainerAI.useItem): splice it in before the first name occurrence.
|
||||
local function prefixEnemy(msg, battler)
|
||||
if battler.isPlayer then return msg end
|
||||
local s = msg:find(battler.name, 1, true)
|
||||
if not s then return msg end
|
||||
return msg:sub(1, s - 1) .. Strings("Enemy %s", battler.name)
|
||||
.. msg:sub(s + #battler.name)
|
||||
return msg:sub(1, s - 1) .. "Enemy " .. msg:sub(s)
|
||||
end
|
||||
|
||||
-- Level-up stats window (PrintStatsBox .LevelUpStatsBox: box (9,2)
|
||||
@@ -3047,11 +3044,8 @@ function BattleState:executeAction(user, target, action)
|
||||
-- trainer class AI actions (engine/battle/trainer_ai.asm)
|
||||
if action.special == "aiItem" then
|
||||
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
|
||||
self:sayNext(m)
|
||||
self:sayNext(prefixEnemy(m, self.enemy))
|
||||
end
|
||||
self:drainNext()
|
||||
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
|
||||
-- mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||
local function displayName(b)
|
||||
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
||||
end
|
||||
EffectRegistry.displayName = displayName
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ local MoveEffects = {}
|
||||
-- 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
|
||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
||||
end
|
||||
|
||||
local STAT_LABEL = {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
-- back to the vanilla records, which is bit-identical behavior.
|
||||
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local Status = {}
|
||||
|
||||
@@ -35,8 +34,8 @@ end
|
||||
-- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
|
||||
-- decline differently once translated, so a shared fragment cannot be the
|
||||
-- translatable unit.
|
||||
local function damageOverTime(label, template)
|
||||
return function(battler, _, battle)
|
||||
local function damageOverTime(template)
|
||||
return function(battler)
|
||||
local mon = battler.mon
|
||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||
local dmg = base
|
||||
@@ -45,7 +44,7 @@ local function damageOverTime(label, template)
|
||||
battler.toxicCounter = battler.toxicCounter + 1
|
||||
end
|
||||
mon.hp = math.max(0, mon.hp - dmg)
|
||||
return { romText(battle and battle.data, label, template, name(battler)) }
|
||||
return { Strings(template, name(battler)) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -64,63 +63,53 @@ Status.RECORDS = {
|
||||
id = "SLP", label = "SLP", hudLabel = "SLP",
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 40,
|
||||
beforeMove = function(battler, _, battle)
|
||||
beforeMove = function(battler)
|
||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||
if battler.sleepTurns <= 0 then
|
||||
battler.mon.status = nil
|
||||
-- wakes, loses the turn
|
||||
return false, { romText(battle and battle.data, "_WokeUpText",
|
||||
"%s\nwoke up!", name(battler)) }
|
||||
return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
|
||||
end
|
||||
return false, { romText(battle and battle.data, "_FastAsleepText",
|
||||
"%s\nis fast asleep!", name(battler)) }
|
||||
return false, { Strings("%s\nis fast asleep!", name(battler)) }
|
||||
end,
|
||||
onInflict = function(battle, target, opts, display)
|
||||
target.sleepTurns = battle.rng(1, 7)
|
||||
return { romText(battle.data, "_FellAsleepText",
|
||||
"%s\nfell asleep!", display) }
|
||||
return { Strings("%s\nfell asleep!", display) }
|
||||
end,
|
||||
},
|
||||
FRZ = {
|
||||
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 30,
|
||||
beforeMove = function(battler, _, battle)
|
||||
return false, { romText(battle and battle.data, "_IsFrozenText",
|
||||
"%s\nis frozen solid!", name(battler)) }
|
||||
beforeMove = function(battler)
|
||||
return false, { Strings("%s\nis frozen solid!", name(battler)) }
|
||||
end,
|
||||
canInflict = function(target) return not hasType(target, "ICE") end,
|
||||
onInflict = function(battle, _, _, display)
|
||||
return { romText(battle and battle.data, "_FrozenText",
|
||||
"%s\nwas frozen solid!", display) }
|
||||
onInflict = function(_, _, _, display)
|
||||
return { Strings("%s\nwas frozen solid!", display) }
|
||||
end,
|
||||
},
|
||||
PSN = {
|
||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
residual = damageOverTime("_HurtByPoisonText",
|
||||
Strings.source("%s's\nhurt by poison!")),
|
||||
residual = damageOverTime(Strings.source("%s's\nhurt by poison!")),
|
||||
canInflict = function(target) return not hasType(target, "POISON") end,
|
||||
onInflict = function(battle, target, opts, display)
|
||||
onInflict = function(_, target, opts, display)
|
||||
if opts.toxic then
|
||||
target.toxicCounter = 1
|
||||
return { romText(battle and battle.data, "_BadlyPoisonedText",
|
||||
"%s's\nbadly poisoned!", display) }
|
||||
-- _BadlyPoisonedText
|
||||
return { Strings("%s's\nbadly poisoned!", display) }
|
||||
end
|
||||
return { romText(battle and battle.data, "_PoisonedText",
|
||||
"%s\nwas poisoned!", display) }
|
||||
return { Strings("%s\nwas poisoned!", display) }
|
||||
end,
|
||||
},
|
||||
BRN = {
|
||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "attack", div = 2 },
|
||||
residual = damageOverTime("_HurtByBurnText",
|
||||
Strings.source("%s's\nhurt by the burn!")),
|
||||
residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")),
|
||||
canInflict = function(target) return not hasType(target, "FIRE") end,
|
||||
onInflict = function(battle, _, _, display)
|
||||
return { romText(battle and battle.data, "_BurnedText",
|
||||
"%s\nwas burned!", display) }
|
||||
onInflict = function(_, _, _, display)
|
||||
return { Strings("%s\nwas burned!", display) }
|
||||
end,
|
||||
},
|
||||
PAR = {
|
||||
@@ -128,11 +117,10 @@ Status.RECORDS = {
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "speed", div = 4 },
|
||||
beforeMovePriority = 10,
|
||||
beforeMove = function(battler, rng, battle)
|
||||
beforeMove = function(battler, rng)
|
||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||
if rng(0, 255) < 63 then
|
||||
return false, { romText(battle and battle.data, "_FullyParalyzedText",
|
||||
"%s's\nfully paralyzed!", name(battler)) }
|
||||
return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
|
||||
end
|
||||
return true, {}
|
||||
end,
|
||||
@@ -140,10 +128,9 @@ Status.RECORDS = {
|
||||
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
|
||||
return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND"))
|
||||
end,
|
||||
onInflict = function(battle, _, _, display)
|
||||
-- primary and secondary paralysis share this line
|
||||
return { romText(battle and battle.data, "_ParalyzedMayNotAttackText",
|
||||
"%s's\nparalyzed! It may\nnot attack!", display) }
|
||||
onInflict = function(_, _, _, display)
|
||||
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
||||
return { Strings("%s's\nparalyzed! It may\nnot attack!", display) }
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -179,8 +166,7 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
if battler.flinched then
|
||||
battler.flinched = false
|
||||
return false, { romText(battle and battle.data, "_FlinchedText",
|
||||
"%s\nflinched!", name(battler)) }
|
||||
return false, { Strings("%s\nflinched!", name(battler)) }
|
||||
end
|
||||
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
||||
local handler = record and record.beforeMove
|
||||
@@ -198,27 +184,23 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
if battler.boundTurns and battler.boundTurns > 0 then
|
||||
battler.boundTurns = battler.boundTurns - 1
|
||||
msgs[#msgs + 1] = romText(battle and battle.data, "_CantMoveText",
|
||||
"%s\ncan't move!", name(battler))
|
||||
msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
|
||||
return false, msgs
|
||||
end
|
||||
if battler.disabledTurns then
|
||||
battler.disabledTurns = battler.disabledTurns - 1
|
||||
if battler.disabledTurns <= 0 then
|
||||
battler.disabledTurns, battler.disabledSlot = nil, nil
|
||||
table.insert(msgs, romText(battle and battle.data, "_DisabledNoMoreText",
|
||||
"%s's\ndisabled no more!", name(battler)))
|
||||
table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler)))
|
||||
end
|
||||
end
|
||||
if battler.confusedTurns then
|
||||
battler.confusedTurns = battler.confusedTurns - 1
|
||||
if battler.confusedTurns <= 0 then
|
||||
battler.confusedTurns = nil
|
||||
table.insert(msgs, romText(battle and battle.data, "_ConfusedNoMoreText",
|
||||
"%s\nsnapped out of\nconfusion!", name(battler)))
|
||||
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
|
||||
else
|
||||
table.insert(msgs, romText(battle and battle.data, "_IsConfusedText",
|
||||
"%s\nis confused!", name(battler)))
|
||||
table.insert(msgs, Strings("%s\nis confused!", name(battler)))
|
||||
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
||||
if rng(0, 255) < 128 then
|
||||
return false, msgs, true -- hurt itself
|
||||
@@ -259,8 +241,7 @@ function Status.residual(battler, opponent, battle)
|
||||
dmg = math.min(dmg, mon.hp)
|
||||
mon.hp = mon.hp - dmg
|
||||
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
||||
table.insert(msgs, romText(battle and battle.data, "_HurtByLeechSeedText",
|
||||
"LEECH SEED saps\n%s!", name(battler)))
|
||||
table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler)))
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ local StatusRegistry = {}
|
||||
-- pokered's <USER>/<TARGET> text macros (home/text.asm
|
||||
-- PlaceMoveUsersName): enemy-mon texts print "Enemy " before the name
|
||||
local function displayName(b)
|
||||
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
||||
end
|
||||
|
||||
-- opts: toxic (start the Toxic counter), moveType (for the type gates),
|
||||
|
||||
@@ -20,16 +20,9 @@
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
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 X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
|
||||
|
||||
@@ -102,16 +95,12 @@ function TrainerAI.switchAction(battle)
|
||||
return { special = "aiSwitch", index = alive[1] }
|
||||
end
|
||||
|
||||
-- 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.
|
||||
-- Apply an aiItem action to the enemy battler; returns messages.
|
||||
function TrainerAI.useItem(battle, item)
|
||||
local enemy = battle.enemy
|
||||
local trainerName = battle.trainer.name
|
||||
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
||||
local msgs = { romText(battle.data, "_AIBattleUseItemText",
|
||||
"%s\nused %s!", trainerName, itemName, enemy.name) }
|
||||
local msgs = { Strings("%s\nused %s!", trainerName, itemName) }
|
||||
if item == "FULL_HEAL" then
|
||||
enemy.mon.status = nil
|
||||
enemy.toxicCounter = nil
|
||||
@@ -124,10 +113,10 @@ function TrainerAI.useItem(battle, item)
|
||||
elseif X_STAT[item] then
|
||||
local stat = X_STAT[item]
|
||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
||||
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper()))
|
||||
table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper()))
|
||||
elseif item == "GUARD_SPEC" then
|
||||
enemy.mist = true
|
||||
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy)))
|
||||
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name))
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
|
||||
@@ -41,13 +41,6 @@ function Game:load()
|
||||
-- render pipelines dispatch off the merged dataset; point them at the
|
||||
-- one the mods just merged into before anything can draw a frame
|
||||
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
|
||||
Input:init()
|
||||
|
||||
@@ -143,24 +143,21 @@ LauncherView._refreshAutoHeight = refreshAutoHeight
|
||||
|
||||
-- ------- lifecycle
|
||||
|
||||
-- All platforms: FlexLove used to map `performanceMonitoring = false` to
|
||||
-- true (`false or true`), leaving layout/render timers + memory sampling on
|
||||
-- every immediate-mode frame (pad-cursor lag on NX, scroll drag on desktop).
|
||||
-- The vendored init is fixed, but force the flags off here too so a hot
|
||||
-- reload against an already-inited FlexLove stays clean. Exported so the
|
||||
-- engine tier can assert the guards without drawing the full tree.
|
||||
-- NX-only: FlexLove's init maps `performanceMonitoring = false` to true
|
||||
-- (`false or true`), which leaves layout/render timers + memory sampling on
|
||||
-- every immediate-mode frame and makes the pad cursor feel lagged. Force
|
||||
-- them off after init. Desktop keeps the library default. Exported so the
|
||||
-- engine tier can assert the Switch guards without drawing the full tree.
|
||||
function LauncherView.applyNxPerfGuards(imp)
|
||||
if not (imp and FlexLove.isReady() and FlexLove._Performance) then
|
||||
if not (imp and imp.isNX and FlexLove.isReady() and FlexLove._Performance) then
|
||||
return false
|
||||
end
|
||||
FlexLove._Performance.enabled = false
|
||||
local mp = FlexLove._Performance._memoryProfiler
|
||||
if mp then mp.enabled = false end
|
||||
-- Immediate-mode rebuilds allocate a full tree every frame; the default
|
||||
-- "auto" GC strategy triggers a full blocking collect past 100 MB, a
|
||||
-- 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).
|
||||
-- auto GC steps hitch the pad cursor on Switch. Less frequent steps, higher
|
||||
-- threshold — desktop keeps FlexLove defaults.
|
||||
if FlexLove._gcConfig then
|
||||
FlexLove._gcConfig.strategy = "periodic"
|
||||
FlexLove._gcConfig.interval = 90
|
||||
@@ -335,22 +332,12 @@ end
|
||||
local function textWidth(size, text) return mfont(size):getWidth(text) end
|
||||
local function textHeight(size) return mfont(size):getHeight() end
|
||||
|
||||
-- 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 = {}
|
||||
-- wrapped text height at a width, from the same font the element renders
|
||||
local function wrapHeight(size, text, width)
|
||||
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 _, lines = f:getWrap(text, width)
|
||||
h = math.max(1, #lines) * f:getHeight()
|
||||
wrapHeightCache[key] = h
|
||||
return h
|
||||
return math.max(1, #lines) * f:getHeight()
|
||||
end
|
||||
|
||||
-- Every text size in this file is already scaled by m.s, so FlexLove's own
|
||||
@@ -434,45 +421,6 @@ local function button(imp, parent, key, text, opts)
|
||||
return mk(p)
|
||||
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 p = {
|
||||
parent = parent,
|
||||
@@ -1064,40 +1012,30 @@ end
|
||||
|
||||
local function buildModsPanel(imp, parent, m)
|
||||
imp:_ensureMods()
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local mods = imp.mods or {}
|
||||
local enabledCount = 0
|
||||
for _, mod in ipairs(mods) do
|
||||
if mod.enabled then enabledCount = enabledCount + 1 end
|
||||
end
|
||||
|
||||
local title = Strings("Mods")
|
||||
local count = Strings("%d of %d enabled", enabledCount, #mods)
|
||||
local importLabel = imp:_modsImportButtonLabel()
|
||||
local items = {
|
||||
{ size = 22 * m.s + 4, text = title },
|
||||
{ size = 12 * m.s + 2, text = count },
|
||||
{ size = 13 * m.s + 1, text = importLabel, btn = true },
|
||||
}
|
||||
local head = mk({ parent = parent, width = "100%",
|
||||
positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap",
|
||||
alignItems = "center", gap = 10 * m.s })
|
||||
label(head, Strings("Mods"), 22 * m.s + 4, C("white"), { textWrap = false })
|
||||
label(head, Strings("%d of %d enabled", enabledCount, #mods),
|
||||
12 * m.s + 2, C("warn"), { textWrap = false })
|
||||
mk({ parent = head, flex = 1 })
|
||||
if #mods > 0 then
|
||||
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"), {
|
||||
button(imp, head, "mods-enable-all", Strings("Enable all"), {
|
||||
size = 11 * m.s + 1, kind = "neutral",
|
||||
action = function() imp:_setAllMods(true) end,
|
||||
})
|
||||
button(imp, btnRow, "mods-disable-all", Strings("Disable all"), {
|
||||
button(imp, head, "mods-disable-all", Strings("Disable all"), {
|
||||
size = 11 * m.s + 1, kind = "neutral",
|
||||
action = function() imp:_setAllMods(false) end,
|
||||
})
|
||||
end
|
||||
button(imp, btnRow, "mods-import", importLabel, {
|
||||
button(imp, head, "mods-import", imp:_modsImportButtonLabel(), {
|
||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||
action = function() imp:chooseMod() end,
|
||||
})
|
||||
@@ -1122,89 +1060,6 @@ local function buildModsPanel(imp, parent, m)
|
||||
return
|
||||
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
|
||||
-- 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.
|
||||
@@ -1236,19 +1091,6 @@ local function buildModsPanel(imp, parent, m)
|
||||
elseif mod.github and mod.github ~= "" then
|
||||
checkLine, checkCol = Strings("Not checked for updates yet"), "warn"
|
||||
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),
|
||||
-- version, check line, wrapped description
|
||||
@@ -1262,9 +1104,6 @@ local function buildModsPanel(imp, parent, m)
|
||||
if checkLine then
|
||||
bodyH = bodyH + 4 + wrapHeight(smallSize, checkLine, bodyW)
|
||||
end
|
||||
if dlLine then
|
||||
bodyH = bodyH + 4 + wrapHeight(smallSize, dlLine, bodyW)
|
||||
end
|
||||
if mod.description ~= "" then
|
||||
bodyH = bodyH + 4 + wrapHeight(smallSize, mod.description, bodyW)
|
||||
end
|
||||
@@ -1316,9 +1155,6 @@ local function buildModsPanel(imp, parent, m)
|
||||
if checkLine then
|
||||
label(body, checkLine, smallSize, C(checkCol), { width = "100%" })
|
||||
end
|
||||
if dlLine then
|
||||
label(body, dlLine, smallSize, C("gold"), { width = "100%" })
|
||||
end
|
||||
if mod.description ~= "" then
|
||||
label(body, mod.description, smallSize, C("detail"), { width = "100%" })
|
||||
end
|
||||
@@ -1376,26 +1212,18 @@ local function buildFindPanel(imp, parent, m)
|
||||
local rows = imp:_findRows()
|
||||
local total = #((imp.findIndex and imp.findIndex.mods) or {})
|
||||
|
||||
local title = Strings("Find Mods")
|
||||
local count = (#sources > 0) and ((#rows == total) and Strings("%d mods listed", total)
|
||||
or Strings("%d of %d mods", #rows, total)) or nil
|
||||
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
|
||||
local head = mk({ parent = parent, width = "100%",
|
||||
positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap",
|
||||
alignItems = "center", gap = 10 * m.s })
|
||||
label(head, Strings("Find Mods"), 22 * m.s + 4, C("white"), { textWrap = false })
|
||||
if #sources > 0 then
|
||||
items[#items + 1] = { size = 13 * m.s + 1, text = Strings("Refresh"), btn = true }
|
||||
label(head, (#rows == total) and Strings("%d mods listed", total)
|
||||
or Strings("%d of %d mods", #rows, total), 12 * m.s + 2, C("warn"),
|
||||
{ textWrap = false })
|
||||
end
|
||||
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()
|
||||
mk({ parent = head, flex = 1 })
|
||||
if #sources > 0 then
|
||||
button(imp, btnRow, "find-refresh", Strings("Refresh"), {
|
||||
button(imp, head, "find-refresh", Strings("Refresh"), {
|
||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||
action = function()
|
||||
imp._findSearchFocus = false
|
||||
@@ -1404,10 +1232,11 @@ local function buildFindPanel(imp, parent, m)
|
||||
end,
|
||||
})
|
||||
end
|
||||
button(imp, btnRow, "find-add", addLabel, {
|
||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||
action = function() imp:_promptAddIndex() end,
|
||||
})
|
||||
button(imp, head, "find-add",
|
||||
(#sources == 0) and Strings("Add an index") or Strings("Add index"), {
|
||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||
action = function() imp:_promptAddIndex() end,
|
||||
})
|
||||
|
||||
if imp.findNotice then
|
||||
label(parent, imp.findNotice.text, 12 * m.s + 2,
|
||||
|
||||
@@ -2593,8 +2593,6 @@ function RomImporter:_syncModUpdateInfo(force)
|
||||
latest = best and best.version or nil,
|
||||
best = best,
|
||||
releases = packed.releases,
|
||||
downloads = ModUpdate.totalDownloads(packed.releases),
|
||||
dates = ModUpdate.releaseDates(packed.releases),
|
||||
err = nil,
|
||||
checkedAt = packed.checkedAt or os.time(),
|
||||
}
|
||||
@@ -2611,9 +2609,6 @@ function RomImporter:_syncModUpdateInfo(force)
|
||||
self.modUpdateInfo[m.id] = nil
|
||||
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
|
||||
|
||||
function RomImporter:_modUpdateInfo(id)
|
||||
@@ -2727,8 +2722,6 @@ function RomImporter:_modGithubAction(id, action)
|
||||
self.modUpdateInfo[row.id] = {
|
||||
status = status, latest = best and best.version, best = best,
|
||||
releases = releases,
|
||||
downloads = ModUpdate.totalDownloads(releases),
|
||||
dates = ModUpdate.releaseDates(releases),
|
||||
}
|
||||
self._modVersions = {
|
||||
id = row.id, name = row.name, current = row.version,
|
||||
@@ -2772,8 +2765,6 @@ function RomImporter:_modGithubAction(id, action)
|
||||
self.modUpdateInfo[row.id] = {
|
||||
status = status, latest = best and best.version, best = best,
|
||||
releases = releases, checkedAt = os.time(),
|
||||
downloads = ModUpdate.totalDownloads(releases),
|
||||
dates = ModUpdate.releaseDates(releases),
|
||||
}
|
||||
if status == "available" and best then
|
||||
self.modNotice = { ok = true,
|
||||
@@ -2989,22 +2980,12 @@ end
|
||||
-- The rows the filters leave, and the installed-mod context the compatibility
|
||||
-- warnings are judged against.
|
||||
function RomImporter:_findRows()
|
||||
local all = (self.findIndex and self.findIndex.mods) or {}
|
||||
-- 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, {
|
||||
local all = (self.findIndex and self.findIndex.mods) or {}
|
||||
return ModIndex.filter(all, {
|
||||
query = self.findQuery,
|
||||
category = self.findCategory,
|
||||
})
|
||||
self._findRowsCache = { src = all, query = self.findQuery,
|
||||
category = self.findCategory, rows = rows }
|
||||
return rows
|
||||
end
|
||||
|
||||
function RomImporter:_findInstalledMap()
|
||||
|
||||
@@ -12,19 +12,9 @@
|
||||
|
||||
local Flags = require("src.script.Flags")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
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 = {
|
||||
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
|
||||
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
|
||||
@@ -92,14 +82,6 @@ local function cureActiveToxic(battle, target)
|
||||
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)
|
||||
local X_ITEMS = {
|
||||
X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed",
|
||||
@@ -148,7 +130,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
|
||||
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
||||
or (itemDef and itemDef.machine)) then
|
||||
return "failed", { notTime(data, save) }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
||||
save.player.name) }
|
||||
end
|
||||
|
||||
if BALLS[itemId] then
|
||||
@@ -165,14 +148,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- data/scripts/story.lua's snorlaxWake)
|
||||
local mapId, npc = adjacentSleepingSnorlax(save, ow)
|
||||
if npc then
|
||||
return "flute_wake", { romText(data, "_PlayedFluteHadEffectText",
|
||||
"{PLAYER} played the\nPOKé FLUTE.") },
|
||||
return "flute_wake", { data.text._PlayedFluteHadEffectText
|
||||
or Strings("{PLAYER} played the\nPOKé FLUTE.") },
|
||||
{ mapId = mapId, npc = npc }
|
||||
end
|
||||
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
|
||||
-- PlayedFluteNoEffectText branch)
|
||||
return "flute_field", { romText(data, "_PlayedFluteNoEffectText",
|
||||
"Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
end
|
||||
local woke = false
|
||||
local function wake(mon)
|
||||
@@ -187,20 +169,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- WakeUpEntireParty runs on the enemy's bench too
|
||||
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
|
||||
if not woke then
|
||||
return "failed", { romText(data, "_PlayedFluteNoEffectText",
|
||||
"Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
end
|
||||
return "flute", { romText(data, "_PlayedFluteHadEffectText",
|
||||
"%s played the\nPOKé FLUTE.", save.player.name),
|
||||
romText(data, "_FluteWokeUpText",
|
||||
"All sleeping\nPOKéMON woke up!") }
|
||||
return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
|
||||
Strings("All sleeping\nPOKéMON woke up!") }
|
||||
end
|
||||
|
||||
-- battle-only items
|
||||
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
||||
or itemId == "POKE_DOLL" then
|
||||
if not battle then
|
||||
return "failed", { notTime(data, save) }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
local b = battle.player
|
||||
-- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy /
|
||||
@@ -222,8 +201,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
||||
-- just prints "Nothing happened!"
|
||||
if cur >= 6 then
|
||||
return "consumed", { romText(data, "_NothingHappenedText",
|
||||
"Nothing happened!") }
|
||||
return "consumed", { Strings("Nothing happened!") }
|
||||
end
|
||||
b.stages[stat] = cur + 1
|
||||
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
|
||||
@@ -232,8 +210,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- the item, even when it is already active
|
||||
if itemId == "DIRE_HIT" then
|
||||
b.focusEnergy = true
|
||||
return "consumed", { romText(data, "_GettingPumpedText",
|
||||
"%s's\ngetting pumped!", b.name) }
|
||||
return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
|
||||
end
|
||||
if itemId == "GUARD_SPEC" then
|
||||
b.mist = true
|
||||
@@ -242,10 +219,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if itemId == "POKE_DOLL" then
|
||||
if battle.kind ~= "wild" then
|
||||
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
||||
return "failed", { notTime(data, save) }
|
||||
return "failed", { Strings(
|
||||
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "consumed_escape", { romText(data, "_WildRanText",
|
||||
"The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) }
|
||||
return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -254,7 +231,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- restore every move with no menu.
|
||||
if itemId == "ETHER" or itemId == "MAX_ETHER"
|
||||
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
local restored = false
|
||||
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
|
||||
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
|
||||
@@ -276,10 +253,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
restored = mv and restore(mv) or false
|
||||
end
|
||||
if not restored then
|
||||
return "failed", { noEffect(data) }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
-- pokered's line names no mon, so the extracted text takes no args
|
||||
return "consumed", { romText(data, "_PPRestoredText", "PP was restored.") }
|
||||
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
||||
end
|
||||
|
||||
-- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to
|
||||
@@ -304,11 +280,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||
return "consumed", { romText(data, CURE_TEXT.FULL_HEAL,
|
||||
"%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
end
|
||||
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
|
||||
return "failed", { noEffect(data) }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
-- 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
|
||||
@@ -320,10 +295,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
else
|
||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||
end
|
||||
-- _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) }
|
||||
local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) }
|
||||
if itemId == "FULL_RESTORE" then
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
@@ -335,18 +307,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local cures = STATUS_HEAL[itemId]
|
||||
if cures then
|
||||
if not target or not target.status or not cures[target.status] then
|
||||
return "failed", { noEffect(data) }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||
return "consumed", { romText(data, CURE_TEXT[itemId],
|
||||
"%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
end
|
||||
|
||||
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
|
||||
if not target or target.hp > 0 then
|
||||
return "failed", { noEffect(data) }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
target.status = nil
|
||||
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
|
||||
@@ -358,14 +329,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if battle and battle.participants then
|
||||
battle.participants[target] = true
|
||||
end
|
||||
return "consumed", { romText(data, "_ReviveText",
|
||||
"%s\nis revitalized!", monName(data, target)) },
|
||||
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) },
|
||||
{ healedFrom = 0 }
|
||||
end
|
||||
|
||||
if itemId == "RARE_CANDY" then
|
||||
if not target or target.level >= 100 then
|
||||
return "failed", { noEffect(data) }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
@@ -378,13 +348,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540)
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(save, "LEVELUP", target)
|
||||
return "consumed", { romText(data, "_RareCandyText",
|
||||
"%s grew\nto level %d!", monName(data, target), target.level) },
|
||||
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
|
||||
{ leveledTo = target.level }
|
||||
end
|
||||
|
||||
if STONES[itemId] then
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
|
||||
-- IsThisPartyMonStarterPikachu (OT identity match) before
|
||||
-- TryEvolvingMon and bails with the voiced cry + RefusingText.
|
||||
@@ -394,8 +363,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
and target.ot == save.player.name
|
||||
and target.otId == save.player.id then
|
||||
require("src.core.Sound").playCry(data, "PIKACHU")
|
||||
return "failed", { romText(data, "_RefusingText",
|
||||
"%s\nis refusing!", monName(data, target)) }
|
||||
local raw = data.text and data.text._RefusingText
|
||||
local line = raw and raw:gsub("{RAM:[^}]*}", monName(data, target))
|
||||
or Strings("%s\nis refusing!", monName(data, target))
|
||||
return "failed", { line }
|
||||
end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
for _, evo in ipairs(speciesDef.evolutions) do
|
||||
@@ -403,48 +374,44 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
return "consumed", nil, { evolveTo = evo.species }
|
||||
end
|
||||
end
|
||||
return "failed", { noEffect(data) }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
|
||||
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
|
||||
-- engine/items/item_effects.asm)
|
||||
local vitaminStat = VITAMINS[itemId]
|
||||
if vitaminStat then
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
target.statExp = target.statExp or {}
|
||||
local cur = target.statExp[vitaminStat] or 0
|
||||
if cur >= 25600 then
|
||||
return "failed", { noEffect(data) }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
target.stats = Stats.calc(data.pokemon[target.species], target.level,
|
||||
target.dvs, target.statExp)
|
||||
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),
|
||||
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
||||
end
|
||||
|
||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||
if itemId == "PP_UP" then
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
local mv = target.moves[moveIndex or 1]
|
||||
local mdef = mv and data.moves[mv.id]
|
||||
if mdef and (mv.ppUps or 0) < 3 then
|
||||
mv.ppUps = (mv.ppUps or 0) + 1
|
||||
-- each PP UP adds maxPP/5 uses on top of the base maximum
|
||||
mv.pp = mv.pp + math.floor(mdef.pp / 5)
|
||||
return "consumed", { romText(data, "_PPIncreasedText",
|
||||
"%s's PP\nincreased!", mdef.name) }
|
||||
return "consumed", { Strings("%s's PP\nincreased!", mdef.name) }
|
||||
end
|
||||
return "failed", { noEffect(data) }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
|
||||
if itemDef and itemDef.machine then
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
local ok = false
|
||||
for _, m in ipairs(speciesDef.tmhm) do
|
||||
@@ -455,16 +422,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
|
||||
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
|
||||
require("src.core.Sound").play(data, "Denied")
|
||||
local moveName = data.moves[itemDef.machine.move].name
|
||||
return "failed", { romText(data, "_MonCannotLearnMachineMoveText",
|
||||
"%s can't\nlearn that move!",
|
||||
monName(data, target), moveName, moveName) }
|
||||
return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) }
|
||||
end
|
||||
for _, mv in ipairs(target.moves) do
|
||||
if mv.id == itemDef.machine.move then
|
||||
return "failed", { romText(data, "_AlreadyKnowsText",
|
||||
"It knows that\nmove already!",
|
||||
monName(data, target), data.moves[itemDef.machine.move].name) }
|
||||
return "failed", { Strings("It knows that\nmove already!") }
|
||||
end
|
||||
end
|
||||
-- HMs are never consumed; TMs are single-use
|
||||
@@ -473,21 +435,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 battle then
|
||||
return "failed", { notTime(data, save) }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
|
||||
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
|
||||
-- on that carry -- surfing refuses the rod with the same OAK text as
|
||||
-- the mid-battle case above, no rod-specific message (#533)
|
||||
if ow and ow.player and ow.player.surfing then
|
||||
return "failed", { notTime(data, save) }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "fish", itemId
|
||||
end
|
||||
|
||||
if itemId == "BICYCLE" then
|
||||
if battle then
|
||||
return "failed", { notTime(data, save) }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "bicycle"
|
||||
end
|
||||
@@ -497,19 +459,18 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
end
|
||||
if itemId == "TOWN_MAP" then
|
||||
if battle then
|
||||
return "failed", { notTime(data, save) }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "townmap"
|
||||
end
|
||||
if itemId == "ITEMFINDER" then
|
||||
if battle then
|
||||
return "failed", { notTime(data, save) }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "itemfinder"
|
||||
end
|
||||
if itemId == "COIN_CASE" then
|
||||
return "failed", { romText(data, "_CoinCaseNumCoinsText",
|
||||
"Coin count:\n%d", save.coins or 0) }
|
||||
return "failed", { Strings("Coin count:\n%d", save.coins or 0) }
|
||||
end
|
||||
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
|
||||
@@ -517,7 +478,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
|
||||
end
|
||||
|
||||
return "failed", { notTime(data, save) }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
|
||||
return ItemEffects
|
||||
|
||||
@@ -102,9 +102,7 @@ function ModUpdate.previewLine(text, maxChars)
|
||||
return s
|
||||
end
|
||||
|
||||
-- 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.
|
||||
-- Decode one GitHub release object into { version, tag, zip, prerelease, body }.
|
||||
function ModUpdate.parseRelease(doc, modId)
|
||||
if type(doc) ~= "table" or not doc.tag_name then
|
||||
return nil, "no tag_name in release"
|
||||
@@ -116,13 +114,6 @@ function ModUpdate.parseRelease(doc, modId)
|
||||
local triple = version:match("^(%d+%.%d+%.%d+)")
|
||||
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
|
||||
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 {
|
||||
version = triple,
|
||||
tag = tostring(doc.tag_name),
|
||||
@@ -130,8 +121,6 @@ function ModUpdate.parseRelease(doc, modId)
|
||||
prerelease = doc.prerelease == true,
|
||||
name = type(doc.name) == "string" and doc.name or triple,
|
||||
body = body,
|
||||
downloads = downloads,
|
||||
published = (doc.published_at or doc.created_at or ""):match("^(%d+%-%d+%-%d+)"),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -165,7 +154,7 @@ function ModUpdate.parseReleases(jsonText, modId, Json)
|
||||
end
|
||||
|
||||
function ModUpdate.apiReleasesUrl(repo)
|
||||
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=100"
|
||||
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30"
|
||||
end
|
||||
|
||||
function ModUpdate.apiLatestUrl(repo)
|
||||
@@ -191,52 +180,6 @@ function ModUpdate.pickBest(releases)
|
||||
return releases[1]
|
||||
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])
|
||||
|
||||
local function cacheStore()
|
||||
@@ -267,22 +210,6 @@ function ModUpdate.cacheFresh(entry, now, ttl)
|
||||
and (now - entry.checkedAt) < ttl
|
||||
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)
|
||||
if type(repo) ~= "string" or repo == "" then return false end
|
||||
local ok = pcall(function()
|
||||
@@ -299,8 +226,6 @@ function ModUpdate.writeCache(repo, releases)
|
||||
name = rel.name,
|
||||
prerelease = rel.prerelease == true,
|
||||
body = type(rel.body) == "string" and rel.body or "",
|
||||
downloads = tonumber(rel.downloads) or 0,
|
||||
published = rel.published,
|
||||
zip = rel.zip and {
|
||||
name = rel.zip.name,
|
||||
url = rel.zip.url,
|
||||
@@ -355,7 +280,7 @@ function ModUpdate.fetchReleases(repo, modId, opts)
|
||||
end
|
||||
if not opts.force then
|
||||
local cached = ModUpdate.readCache(repo)
|
||||
if ModUpdate.cacheFresh(cached) and ModUpdate.cacheUsable(cached) then
|
||||
if ModUpdate.cacheFresh(cached) then
|
||||
return cached.releases, nil, { fromCache = true }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1064,12 +1064,6 @@ local function fontIsCharmap(id)
|
||||
return tostring(id):match("^charmap:.+$") ~= nil
|
||||
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 = {
|
||||
semantics = "record", target = "font",
|
||||
value = f.union{
|
||||
@@ -1077,13 +1071,6 @@ R.font = {
|
||||
advance = f.opt(f.int(1)),
|
||||
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{ 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)
|
||||
if fontIsCharmap(id) then
|
||||
@@ -1093,18 +1080,12 @@ R.font = {
|
||||
if type(value.code) ~= "number" then
|
||||
return "a charmap: entry needs a code"
|
||||
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
|
||||
return "a font page needs an image and a base"
|
||||
end
|
||||
end,
|
||||
baseAt = function(base, id)
|
||||
if fontIsCharmap(id) then return nil end
|
||||
if fontIsTtf(id) then return base.ttf end
|
||||
return base.pages and base.pages[id] or nil
|
||||
end,
|
||||
baseIds = function(base)
|
||||
@@ -1115,9 +1096,6 @@ R.font = {
|
||||
write = function(target, registry)
|
||||
local pages = target.pages or {}
|
||||
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
|
||||
-- rebuilt every merge so a re-merge replaces them instead of stacking
|
||||
local rows = {}
|
||||
@@ -1130,8 +1108,6 @@ R.font = {
|
||||
if value ~= nil then
|
||||
rows[#rows + 1] = { id = id, seq = value.seq, code = value.code }
|
||||
end
|
||||
elseif fontIsTtf(id) then
|
||||
target.ttf = value
|
||||
else
|
||||
pages[id] = value
|
||||
end
|
||||
|
||||
@@ -15,7 +15,6 @@ local Screens = require("src.ui.Screens")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local Evolution = {}
|
||||
|
||||
@@ -141,8 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
game.stack:push(TextBox.new(game,
|
||||
romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name), nextStep))
|
||||
Strings("%s learned\n%s!", name, mdef.name), nextStep))
|
||||
else
|
||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||
@@ -163,12 +161,8 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
Evolution.apply(game, mon, newSpecies, via)
|
||||
-- the congrats page keeps the engine wording: _EvolvedText extracts
|
||||
-- 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)
|
||||
local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
|
||||
oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
-- re-run the evolved species' level-up learn check before onDone
|
||||
|
||||
@@ -6,13 +6,6 @@
|
||||
-- for variable-width text; the default is the GB's flat 8px.
|
||||
-- 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.
|
||||
--
|
||||
-- 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")
|
||||
|
||||
@@ -20,19 +13,6 @@ local Font = {}
|
||||
|
||||
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 loadedFrom
|
||||
|
||||
@@ -55,34 +35,6 @@ local function pagesOf(def)
|
||||
return pages
|
||||
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)
|
||||
loadedFrom = data
|
||||
local def = data.font
|
||||
@@ -131,51 +83,6 @@ function Font.load(data)
|
||||
Font.BORDER = {}
|
||||
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
|
||||
|
||||
-- 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
|
||||
|
||||
-- re-run load against the data it last saw, so hot reload picks up an
|
||||
@@ -197,49 +104,6 @@ end
|
||||
|
||||
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
|
||||
-- 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
|
||||
@@ -255,7 +119,6 @@ end
|
||||
-- boundaries, which is all a headless paginate needs.
|
||||
function Font.split(text)
|
||||
local spans = {}
|
||||
local ttf = state and state.ttf
|
||||
local i, n = 1, #text
|
||||
while i <= n do
|
||||
local span
|
||||
@@ -264,24 +127,11 @@ function Font.split(text)
|
||||
for _, entry in ipairs(candidates) do
|
||||
local len = #entry.seq
|
||||
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 }
|
||||
break
|
||||
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
|
||||
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
|
||||
-- lands mid-character even for a glyph we cannot draw.
|
||||
@@ -335,37 +185,14 @@ function Font.encode(text)
|
||||
end
|
||||
|
||||
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)
|
||||
if not page then return end
|
||||
local quad = page.quads[code - page.base]
|
||||
if quad then love.graphics.draw(page.image, quad, x, y) end
|
||||
end
|
||||
|
||||
-- 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.
|
||||
-- how far the pen moves past a glyph; 8 unless its page says otherwise
|
||||
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)
|
||||
return page and page.advance or GLYPH
|
||||
end
|
||||
@@ -405,18 +232,8 @@ for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end
|
||||
|
||||
-- Draw a Game Boy style bordered box in tile coordinates.
|
||||
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.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
local B = Font.BORDER
|
||||
Font.drawCode(B.tl, tx * 8, ty * 8)
|
||||
Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8)
|
||||
|
||||
@@ -370,12 +370,8 @@ function TextBox:draw()
|
||||
local ys = { self.line1Y, self.line2Y }
|
||||
for i, line in ipairs(self.shown) do
|
||||
local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
|
||||
-- the pen advances per glyph, matching the pixel budget paginate
|
||||
-- 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)
|
||||
for j, code in ipairs(line) do
|
||||
Font.drawCode(code, self.textX + (j - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
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)
|
||||
end
|
||||
|
||||
local function encodeName(buf, off, len, text, padTail)
|
||||
local function encodeName(buf, off, len, text)
|
||||
local i, pos = 0, 1
|
||||
while i < len - 1 and pos <= #text do
|
||||
-- a bracketed control token (e.g. "<DOT>", from decodeName reading a
|
||||
@@ -259,17 +259,16 @@ local function encodeName(buf, off, len, text, padTail)
|
||||
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
||||
i, pos = i + 1, pos + clen
|
||||
end
|
||||
-- Write exactly ONE $50 terminator. The tail past it is $50-padded only
|
||||
-- on a templateless (engine-origin) export, where the zero-filled buffer
|
||||
-- is what PKHeX renders as garbage glyphs after the name ("JOHN{}", #206).
|
||||
-- With a template the tail keeps the original save's bytes verbatim --
|
||||
-- real cartridge saves legitimately hold 0x00 (and other stale glyph)
|
||||
-- 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).
|
||||
-- Write exactly ONE $50 terminator and then $50-pad the rest of the
|
||||
-- field: every real save the naming screen ever wrote fills the tail
|
||||
-- with $50, and a zero tail is what PKHeX renders as garbage glyphs
|
||||
-- after the name ("JOHN{}", #206). Template bytes that are NOT zero
|
||||
-- stay untouched, so an unchanged name still round-trips
|
||||
-- byte-identical and stale template data survives.
|
||||
if i < len then setByte(buf, off + i, 0x50) end
|
||||
if padTail then
|
||||
for j = i + 1, len - 1 do setByte(buf, off + j, 0x50) end
|
||||
for j = i + 1, len - 1 do
|
||||
local cur = buf[off + j + 1]
|
||||
if cur == nil or cur:byte() == 0 then setByte(buf, off + j, 0x50) end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -782,9 +781,8 @@ function GenSave.encode(save, data, template)
|
||||
for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end
|
||||
end
|
||||
|
||||
local padTail = not src
|
||||
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)
|
||||
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
|
||||
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
|
||||
-- wOptions (engine/menus/main_menu.asm InitOptions): bit 7 = battle
|
||||
-- effects OFF, bit 6 = SET style, bits 2-0 = text speed -- the recomp's
|
||||
@@ -877,11 +875,11 @@ function GenSave.encode(save, data, template)
|
||||
encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw)
|
||||
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
|
||||
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.ot or (save.player and save.player.name) or "RED", padTail)
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
-- no nickname stores the species' DISPLAY name, not its ROM constant id
|
||||
-- ("NIDORAN_M" would charmap the "_" to "?") (#257)
|
||||
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or speciesName(cw, mon.species), padTail)
|
||||
mon.nickname or speciesName(cw, mon.species))
|
||||
end
|
||||
-- $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
|
||||
@@ -902,9 +900,9 @@ function GenSave.encode(save, data, template)
|
||||
encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw)
|
||||
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,
|
||||
mon.ot or (save.player and save.player.name) or "RED", padTail)
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or speciesName(cw, mon.species), padTail) -- #257, as above
|
||||
mon.nickname or speciesName(cw, mon.species)) -- #257, as above
|
||||
end
|
||||
-- $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
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local EvolutionState = {}
|
||||
EvolutionState.__index = EvolutionState
|
||||
@@ -90,9 +89,9 @@ function EvolutionState:update(dt)
|
||||
self.done = true
|
||||
self.canceled = true
|
||||
local TextBox = require("src.render.TextBox")
|
||||
-- mirrors data/generated/text.lua _StoppedEvolvingText
|
||||
game.stack:push(TextBox.new(game,
|
||||
romText(game.data, "_StoppedEvolvingText",
|
||||
"Huh? %s\nstopped evolving!", self.oldName),
|
||||
Strings("Huh? %s\nstopped evolving!", self.oldName),
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
@@ -107,8 +106,6 @@ function EvolutionState:update(dt)
|
||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
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,
|
||||
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||
self.oldName, newName),
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local MoveLearnMenu = {}
|
||||
MoveLearnMenu.__index = MoveLearnMenu
|
||||
@@ -44,13 +43,10 @@ function MoveLearnMenu:enter()
|
||||
local mdef = game.data.moves[self.newMoveId]
|
||||
local name = self:monName()
|
||||
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,
|
||||
romText(game.data, "_TryingToLearnText",
|
||||
"%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f"
|
||||
.. "Delete an older\nmove to make room\vfor %s?",
|
||||
name, mdef.name, name, mdef.name),
|
||||
Strings("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f",
|
||||
name, mdef.name, name) ..
|
||||
Strings("Delete an older\nmove to make room\vfor %s?", mdef.name),
|
||||
nil, {
|
||||
choice = function(yes)
|
||||
if yes then
|
||||
@@ -81,8 +77,7 @@ function MoveLearnMenu:update(dt)
|
||||
-- HMCantDeleteText, then back to the forget list
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
romText(self.game.data, "_HMCantDeleteText",
|
||||
"HM techniques\ncan't be deleted!")))
|
||||
Strings("HM techniques\ncan't be deleted!")))
|
||||
return
|
||||
end
|
||||
local mdef = self.game.data.moves[self.newMoveId]
|
||||
@@ -102,8 +97,7 @@ function MoveLearnMenu:confirmAbandon()
|
||||
local mdef = game.data.moves[self.newMoveId]
|
||||
self.selecting = false
|
||||
game.stack:push(TextBox.new(game,
|
||||
romText(game.data, "_AbandonLearningText",
|
||||
"Abandon learning\n%s?", mdef.name), nil, {
|
||||
Strings("Abandon learning\n%s?", mdef.name), nil, {
|
||||
choice = function(yes)
|
||||
if yes then self:finish(false) else self:enter() end
|
||||
end,
|
||||
@@ -119,17 +113,12 @@ function MoveLearnMenu:finish(learned)
|
||||
game.stack:pop()
|
||||
local msg
|
||||
if learned then
|
||||
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
||||
-- the "And..." tail
|
||||
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)
|
||||
-- OneTwoAndText/PoofText/ForgotAndText
|
||||
msg = Strings("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!",
|
||||
name, self.forgot, name, mdef.name)
|
||||
else
|
||||
msg = romText(game.data, "_DidNotLearnText",
|
||||
"%s\ndid not learn\v%s!", name, mdef.name)
|
||||
-- DidNotLearnText
|
||||
msg = Strings("%s\ndid not learn\v%s!", name, mdef.name)
|
||||
end
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
if self.onDone then self.onDone(learned) end
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,63 +0,0 @@
|
||||
-- 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,10 +253,8 @@ do
|
||||
"NX guard disables Performance.enabled")
|
||||
check(view:find("mp.enabled = false", 1, true) ~= nil,
|
||||
"NX guard disables memory profiling")
|
||||
-- The guard used to be NX-only; the same perf-timer and GC hitches showed
|
||||
-- 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("if not (imp and imp.isNX", 1, true) ~= nil,
|
||||
"perf guard is gated on imp.isNX")
|
||||
check(view:find("parkNxPointerForHost", 1, true) ~= nil,
|
||||
"detach parks NX pointer before tearing down")
|
||||
|
||||
|
||||
@@ -108,133 +108,4 @@ do
|
||||
check(preview:find("Changes", 1, true), "previewLine keeps heading text")
|
||||
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")
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
-- 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,36 +74,11 @@ stub.graphics = {
|
||||
-- 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
|
||||
-- headless run should take.
|
||||
-- Accepts both real signatures: newFont(size) and
|
||||
-- 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)
|
||||
newFont = function(size)
|
||||
local px = size or 12
|
||||
return {
|
||||
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,
|
||||
getWidth = function(_, text) return #tostring(text) * math.max(1, px * 0.5) end,
|
||||
getHeight = function() return px end,
|
||||
getBaseline = function() return px - 2 end,
|
||||
setFilter = noop,
|
||||
}
|
||||
end,
|
||||
setFont = function(f) gstate.font = f end,
|
||||
|
||||
@@ -590,37 +590,5 @@ do
|
||||
.. data.pokemon.NIDORAN_F.catchRate .. ")")
|
||||
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))
|
||||
if failures > 0 then os.exit(1) end
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
*.gb
|
||||
*.sym
|
||||
__pycache__/
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1,97 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,246 +0,0 @@
|
||||
#!/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]
|
||||
[--github owner/repo] [--experimental] [--dest DIR] [--force]
|
||||
translation <id> [--language NAME] [--base auto|fixture|imported]
|
||||
[--refresh] [--dest DIR] [--pixel-font]
|
||||
[--refresh] [--dest DIR]
|
||||
validate <id|path> [--strict] [--base auto|fixture|imported]
|
||||
lint <id|path>
|
||||
pack <mod-dir> [-o out.modpkg]
|
||||
@@ -1475,17 +1475,6 @@ return function(mod)
|
||||
end
|
||||
|
||||
-- ---- 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
|
||||
-- the first code the page owns; 0x100 and up is free space above the
|
||||
-- vanilla pages, so a new alphabet never collides with them.
|
||||
@@ -1578,14 +1567,6 @@ you can translate straight from it.
|
||||
|
||||
## 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
|
||||
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
|
||||
@@ -1684,13 +1665,7 @@ Nothing is translated yet: {{total}} strings are waiting in `lang/`.
|
||||
- `assets/font/` - your glyph sheet
|
||||
'''
|
||||
|
||||
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.
|
||||
FONT_README = '''Put your glyph sheet here.
|
||||
|
||||
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
|
||||
@@ -1786,7 +1761,6 @@ def cmd_translation(args, repo):
|
||||
"{{extra}}": "",
|
||||
"{{github_line}}": "",
|
||||
"{{experimental}}": "false",
|
||||
"{{ttf_register}}": "" if args.pixel_font else "-- ",
|
||||
"{{total}}": str(sum(counts.values())),
|
||||
"{{table}}": "\n".join(
|
||||
f"| `lang/{name}.lua` | {counts[name]} |" for name, *_ in catalogs),
|
||||
@@ -2025,9 +1999,6 @@ def main(argv):
|
||||
p.add_argument("--dest")
|
||||
p.add_argument("--base", default="auto",
|
||||
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",
|
||||
help="re-harvest the catalogs, keeping existing work")
|
||||
p.add_argument("--force", action="store_true")
|
||||
|
||||