Compare commits

..

1 Commits

Author SHA1 Message Date
bryanthaboi e3fcdd0776 Merge pull request #761 from bryanthaboi/dev
bugs and switch
2026-08-03 17:22:17 -04:00
46 changed files with 211 additions and 1957 deletions
Binary file not shown.
-13
View File
@@ -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.
-11
View File
@@ -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
+4 -26
View File
@@ -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)
+7 -8
View File
@@ -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
+1 -3
View File
@@ -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 },
+2 -18
View File
@@ -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
+18 -64
View File
@@ -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
-- --------------------------------------------------------------------------
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+6 -98
View File
@@ -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?)"
+1 -5
View File
@@ -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
+6 -12
View File
@@ -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")
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 = {
+30 -49
View File
@@ -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
+1 -1
View File
@@ -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),
+4 -15
View File
@@ -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
-7
View File
@@ -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()
+34 -205
View File
@@ -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,
+2 -21
View File
@@ -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()
+47 -86
View File
@@ -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
+3 -78
View File
@@ -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
-24
View File
@@ -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
+3 -9
View File
@@ -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
+1 -184
View File
@@ -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)
+2 -6
View File
@@ -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
+16 -18
View File
@@ -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
+2 -5
View File
@@ -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),
+10 -21
View File
@@ -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
-83
View File
@@ -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")
+2 -4
View File
@@ -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")
-129
View File
@@ -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")
-226
View File
@@ -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")
+3 -28
View File
@@ -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,
-32
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
*.gb
*.sym
__pycache__/
-21
View File
@@ -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.
-97
View File
@@ -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`.
-246
View File
@@ -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())
+2 -31
View File
@@ -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")