diff --git a/assets/fonts/plainpixel/PlainPixel-Regular.ttf b/assets/fonts/plainpixel/PlainPixel-Regular.ttf new file mode 100644 index 00000000..873fe5f5 Binary files /dev/null and b/assets/fonts/plainpixel/PlainPixel-Regular.ttf differ diff --git a/assets/fonts/plainpixel/README.md b/assets/fonts/plainpixel/README.md new file mode 100644 index 00000000..22509351 --- /dev/null +++ b/assets/fonts/plainpixel/README.md @@ -0,0 +1,13 @@ +# Plain Pixel font + +"Plain Pixel Font" by Douglas Vautour (Burpy Fresh) is licensed under +CC-BY 4.0: https://burpyfresh.itch.io + +Version 0.009 (CJK character additions), unmodified. Characters for most +languages have a 5x11 base but can extend vertically; double-width +characters such as Hiragana and Katakana are 11x11. + +Bundled so a translation mod can opt into TTF text rendering +(`mod.content.font:register("ttf", {})`; see the Translation support +section of docs/new-features.md) instead of drawing hundreds of glyph-page +tiles. The tile font extracted from the player's ROM stays the default. diff --git a/docs/launcher.md b/docs/launcher.md index dde41189..065d40d0 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -153,6 +153,17 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only - `LauncherMods.uninstall(id)` removes `mods//` 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 diff --git a/docs/new-features.md b/docs/new-features.md index c3e6d30f..c3c901e3 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -1,10 +1,10 @@ # New features (deliberate additions beyond the original) -Intentional enhancements this port adds on top of faithful Pokémon Red -behavior. They have no Game Boy equivalent and are kept by design. +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. 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,6 +376,19 @@ 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 ``-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, and `bold`, which double-prints at a 1px +offset for fonts whose strokes read too light. + See the wiki's Translations guide. ## Save editor (bundled, reachable from the launcher) diff --git a/docs/required-to-function.md b/docs/required-to-function.md index b6b6ea2a..bc681949 100644 --- a/docs/required-to-function.md +++ b/docs/required-to-function.md @@ -1,12 +1,11 @@ # What This Port Requires The packaged desktop app requires one user-supplied input on first boot: a -canonical 1 MiB US Pokemon Red ROM. +canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM. -The importer verifies SHA-1 -`ea9bcae617fdf159b045185467ae58b2e4a48b9a`. Other revisions, Virtual -Console releases, and Pokemon Blue are rejected rather than decoded with -incorrect addresses. +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. 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. @@ -15,9 +14,11 @@ 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. `tools/rom_manifest.json` therefore contains: +needs. The version-specific files `tools/rom_manifest.json`, +`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore +contain: -- the 3,268 ROM symbol addresses actually read by the extractor +- the ROM symbol addresses actually read by the extractor - symbolic IDs and ordering for maps, species, moves, items, and trainers - source-erased dimensions, image names, and map object integration names - hand-ported field/script integration tables diff --git a/libs/flexlove/FlexLove.lua b/libs/flexlove/FlexLove.lua index 2f72f720..eaf749af 100644 --- a/libs/flexlove/FlexLove.lua +++ b/libs/flexlove/FlexLove.lua @@ -230,7 +230,9 @@ function flexlove.init(config) -- Initialize Performance if available if Performance then flexlove._Performance = Performance.init({ - enabled = config.performanceMonitoring or true, + -- ~= false (not `or true`): `performanceMonitoring = false` must + -- actually disable the per-frame timers + memory sampling. + enabled = config.performanceMonitoring ~= false, hudEnabled = false, -- Start with HUD disabled hudToggleKey = config.performanceHudKey or "f3", hudPosition = config.performanceHudPosition or { x = 10, y = 10 }, diff --git a/libs/flexlove/modules/Element.lua b/libs/flexlove/modules/Element.lua index 5a2da8db..bdf47c63 100644 --- a/libs/flexlove/modules/Element.lua +++ b/libs/flexlove/modules/Element.lua @@ -2709,6 +2709,7 @@ 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) @@ -2721,7 +2722,18 @@ function Element:_drawChildren(backdropCanvas) love.graphics.setStencilTest("greater", 0) clipMode = "stencil" elseif needsOverflowClipping then - love.graphics.setScissor(self.x + self.padding.left, self.y + self.padding.top, self.width, self.height) + -- Intersect with (and later restore) any ancestor scissor: replacing + -- it let a nested scroller widen its parent's clip, and the bare + -- setScissor() restore then cleared it entirely - scrolled page + -- content drew straight over the pinned header. + -- transformPoint: scissor rects live in window coordinates and ignore + -- the scroll translate an ancestor scroller has pushed, so a nested + -- scroller's clip sat at its untranslated layout position and cut its + -- own rows away once the page scrolled. + prevSx, prevSy, prevSw, prevSh = love.graphics.getScissor() + local scx, scy = love.graphics.transformPoint( + self.x + self.padding.left, self.y + self.padding.top) + love.graphics.intersectScissor(scx, scy, self.width, self.height) clipMode = "scissor" end @@ -2745,7 +2757,11 @@ function Element:_drawChildren(backdropCanvas) if clipMode == "stencil" then love.graphics.setStencilTest() elseif clipMode == "scissor" then - love.graphics.setScissor() + if prevSx then + love.graphics.setScissor(prevSx, prevSy, prevSw, prevSh) + else + love.graphics.setScissor() + end end end diff --git a/libs/flexlove/modules/ScrollManager.lua b/libs/flexlove/modules/ScrollManager.lua index a23d4d19..3a616ee3 100644 --- a/libs/flexlove/modules/ScrollManager.lua +++ b/libs/flexlove/modules/ScrollManager.lua @@ -112,7 +112,8 @@ 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 -- Exponential decay per frame + self.scrollFriction = config.scrollFriction or 0.95 -- legacy per-frame decay; superseded by _momentumDecel + self._momentumDecel = config.momentumDecel or 2.0 -- fling decay rate (1/s), dt-based; ~iOS "normal" self.bounceStiffness = config.bounceStiffness or 0.2 -- Spring constant self.maxOverscroll = config.maxOverscroll or 100 -- pixels @@ -127,7 +128,8 @@ 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) + self._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster); legacy, superseded by _smoothScrollRate + self._smoothScrollRate = config.smoothScrollRate or 30 -- dt-based decay constant (1/s); ~90% converged in 2.3/rate s self.smoothScrollEnabled = config.smoothScrollEnabled or false -- Enable smooth wheel scrolling self._maxScrollX = 0 self._maxScrollY = 0 @@ -925,9 +927,15 @@ function ScrollManager:handleTouchMove(touchX, touchY) dx = -dx dy = -dy - -- Calculate velocity (pixels per second) - self._scrollVelocityX = dx / dt - self._scrollVelocityY = dy / dt + -- 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 -- Apply scroll with bounce if enabled if self.bounceEnabled then @@ -968,6 +976,15 @@ 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 @@ -990,12 +1007,23 @@ end --- Update momentum scrolling (call every frame with dt) ---@param dt number Delta time in seconds function ScrollManager:update(dt) - -- Smooth scroll interpolation + -- Smooth scroll interpolation. The blend factor is derived from dt + -- (exponential approach) rather than a fixed per-frame fraction, so the + -- animation converges in the same wall-clock time regardless of frame + -- rate; a fixed fraction made scrolling visibly slower whenever frame + -- time rose. _smoothScrollRate is the decay constant in 1/seconds: + -- ~90% of the remaining distance is covered in 2.3/rate seconds. + -- dt can legitimately be 0 here: in immediate mode beginFrame resets the + -- accumulated dt BEFORE endFrame updates the elements, so the launcher's + -- update order always hands this 0. Treat that (and nil) as one nominal + -- 60 Hz step so the interpolation still advances one frame's worth. + local step = (dt and dt > 0) and dt or (1 / 60) if self._targetScrollX or self._targetScrollY then + 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 * self._smoothScrollSpeed + self._scrollY = self._scrollY + diff * alpha else self._scrollY = self._targetScrollY self._targetScrollY = nil @@ -1005,7 +1033,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 * self._smoothScrollSpeed + self._scrollX = self._scrollX + diff * alpha else self._scrollX = self._targetScrollX self._targetScrollX = nil @@ -1021,25 +1049,43 @@ function ScrollManager:update(dt) return end - -- Apply velocity to scroll position - local dx = self._scrollVelocityX * dt - local dy = self._scrollVelocityY * dt + -- 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 if self.bounceEnabled then - -- Allow overscroll during momentum - self._scrollX = self._scrollX + dx - self._scrollY = self._scrollY + dy + -- 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) else self:scrollBy(dx, dy) end - -- Apply friction (exponential decay) - self._scrollVelocityX = self._scrollVelocityX * self.scrollFriction - self._scrollVelocityY = self._scrollVelocityY * self.scrollFriction + -- 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 -- Stop momentum when velocity is very low local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2) - if totalVelocity < 1 then + if totalVelocity < 5 then self._momentumScrolling = false self._scrollVelocityX = 0 self._scrollVelocityY = 0 diff --git a/libs/flexlove/modules/behaviors/Scrollable.lua b/libs/flexlove/modules/behaviors/Scrollable.lua index 7c909e72..39ad438e 100644 --- a/libs/flexlove/modules/behaviors/Scrollable.lua +++ b/libs/flexlove/modules/behaviors/Scrollable.lua @@ -214,10 +214,13 @@ local function onDraw(element, _ctx) if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then return end - -- Clear any parent scissor clipping before drawing scrollbars so they render - -- fully visible (scrollbars must not be clipped by ancestor overflow). + -- 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() 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 -- -------------------------------------------------------------------------- diff --git a/scripts/build.sh b/scripts/build.sh index 9573f5b9..e21c5832 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -72,8 +72,13 @@ 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/*') -if unzip -Z1 "$LOVE_FILE" \ - | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then +# 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 " for whichever +# entry happened to match first (see the same fix in pack_love.sh). +LOVE_LISTING="$WORK/love-listing.txt" +unzip -Z1 "$LOVE_FILE" > "$LOVE_LISTING" +if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' "$LOVE_LISTING"; then fail "game.love unexpectedly contains generated ROM data" fi # The editor is only reachable if its entry point and both module directories @@ -86,7 +91,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \ libs/flexlove/FlexLove.lua \ tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json; do - unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \ + grep -qxF "$required" "$LOVE_LISTING" \ || fail "game.love is missing $required" done say "game.love: $(du -h "$LOVE_FILE" | cut -f1)" diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index ddc6fb1b..90989f60 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -695,7 +695,11 @@ run_xcodebuild() { fi # Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love. - if [ ! -f "$app/game.love" ]; then + # Byte-compare, never just existence: xcodebuild's incremental Copy Bundle + # Resources can leave a previous build's game.love in a surviving .app, and + # an existence check shipped that stale payload in the .ipa (today's Lua + # fixes present in ios/resources/ but absent from the installed app). + if ! cmp -s "$LOVE_FILE" "$app/game.love"; then say "fusing game.love into $(basename "$app")" cp "$LOVE_FILE" "$app/game.love" fi diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9334bf92..6a95b79b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -380,18 +380,21 @@ local CHARGE_TEXT = { -- pokered's / 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 ("Enemy " .. b.name) + return b.isPlayer and b.name or Strings("Enemy %s", b.name) end --- 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. +-- 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. 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) .. "Enemy " .. msg:sub(s) + return msg:sub(1, s - 1) .. Strings("Enemy %s", battler.name) + .. msg:sub(s + #battler.name) end -- Level-up stats window (PrintStatsBox .LevelUpStatsBox: box (9,2) @@ -3044,8 +3047,11 @@ function BattleState:executeAction(user, target, action) -- trainer class AI actions (engine/battle/trainer_ai.asm) 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(prefixEnemy(m, self.enemy)) + self:sayNext(m) end self:drainNext() require("src.core.Sound").play(self.data, "Heal_Ailment") diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index a6789ade..de443a72 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -29,7 +29,7 @@ end -- pokered's / 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 ("Enemy " .. b.name) + return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779 end EffectRegistry.displayName = displayName diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index acdd2118..e9d97e6c 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -23,7 +23,7 @@ local MoveEffects = {} -- pokered's / 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 ("Enemy " .. b.name) + return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779 end local STAT_LABEL = { diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 8fad8716..a42062ae 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -6,6 +6,7 @@ -- back to the vanilla records, which is bit-identical behavior. local Strings = require("src.core.Strings") +local romText = require("src.core.RomText") local Status = {} @@ -34,8 +35,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(template) - return function(battler) +local function damageOverTime(label, template) + return function(battler, _, battle) local mon = battler.mon local base = math.max(1, math.floor(mon.stats.hp / 16)) local dmg = base @@ -44,7 +45,7 @@ local function damageOverTime(template) battler.toxicCounter = battler.toxicCounter + 1 end mon.hp = math.max(0, mon.hp - dmg) - return { Strings(template, name(battler)) } + return { romText(battle and battle.data, label, template, name(battler)) } end end @@ -63,53 +64,63 @@ Status.RECORDS = { id = "SLP", label = "SLP", hudLabel = "SLP", catchBonus = 25, shakeBonus = 10, beforeMovePriority = 40, - beforeMove = function(battler) + beforeMove = function(battler, _, battle) battler.sleepTurns = (battler.sleepTurns or 1) - 1 if battler.sleepTurns <= 0 then battler.mon.status = nil - return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn + -- wakes, loses the turn + return false, { romText(battle and battle.data, "_WokeUpText", + "%s\nwoke up!", name(battler)) } end - return false, { Strings("%s\nis fast asleep!", name(battler)) } + return false, { romText(battle and battle.data, "_FastAsleepText", + "%s\nis fast asleep!", name(battler)) } end, onInflict = function(battle, target, opts, display) target.sleepTurns = battle.rng(1, 7) - return { Strings("%s\nfell asleep!", display) } + return { romText(battle.data, "_FellAsleepText", + "%s\nfell asleep!", display) } end, }, FRZ = { id = "FRZ", label = "FRZ", hudLabel = "FRZ", catchBonus = 25, shakeBonus = 10, beforeMovePriority = 30, - beforeMove = function(battler) - return false, { Strings("%s\nis frozen solid!", name(battler)) } + beforeMove = function(battler, _, battle) + return false, { romText(battle and battle.data, "_IsFrozenText", + "%s\nis frozen solid!", name(battler)) } end, canInflict = function(target) return not hasType(target, "ICE") end, - onInflict = function(_, _, _, display) - return { Strings("%s\nwas frozen solid!", display) } + onInflict = function(battle, _, _, display) + return { romText(battle and battle.data, "_FrozenText", + "%s\nwas frozen solid!", display) } end, }, PSN = { id = "PSN", label = "PSN", hudLabel = "PSN", catchBonus = 12, shakeBonus = 5, - residual = damageOverTime(Strings.source("%s's\nhurt by poison!")), + residual = damageOverTime("_HurtByPoisonText", + Strings.source("%s's\nhurt by poison!")), canInflict = function(target) return not hasType(target, "POISON") end, - onInflict = function(_, target, opts, display) + onInflict = function(battle, target, opts, display) if opts.toxic then target.toxicCounter = 1 - -- _BadlyPoisonedText - return { Strings("%s's\nbadly poisoned!", display) } + return { romText(battle and battle.data, "_BadlyPoisonedText", + "%s's\nbadly poisoned!", display) } end - return { Strings("%s\nwas poisoned!", display) } + return { romText(battle and battle.data, "_PoisonedText", + "%s\nwas poisoned!", display) } end, }, BRN = { id = "BRN", label = "BRN", hudLabel = "BRN", catchBonus = 12, shakeBonus = 5, statPenalty = { stat = "attack", div = 2 }, - residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")), + residual = damageOverTime("_HurtByBurnText", + Strings.source("%s's\nhurt by the burn!")), canInflict = function(target) return not hasType(target, "FIRE") end, - onInflict = function(_, _, _, display) - return { Strings("%s\nwas burned!", display) } + onInflict = function(battle, _, _, display) + return { romText(battle and battle.data, "_BurnedText", + "%s\nwas burned!", display) } end, }, PAR = { @@ -117,10 +128,11 @@ Status.RECORDS = { catchBonus = 12, shakeBonus = 5, statPenalty = { stat = "speed", div = 4 }, beforeMovePriority = 10, - beforeMove = function(battler, rng) + beforeMove = function(battler, rng, battle) -- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256) if rng(0, 255) < 63 then - return false, { Strings("%s's\nfully paralyzed!", name(battler)) } + return false, { romText(battle and battle.data, "_FullyParalyzedText", + "%s's\nfully paralyzed!", name(battler)) } end return true, {} end, @@ -128,9 +140,10 @@ Status.RECORDS = { -- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND")) end, - onInflict = function(_, _, _, display) - -- _ParalyzedMayNotAttackText (primary and secondary paralysis) - return { Strings("%s's\nparalyzed! It may\nnot attack!", display) } + 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) } end, }, } @@ -166,7 +179,8 @@ function Status.beforeMove(battler, rng, battle) end if battler.flinched then battler.flinched = false - return false, { Strings("%s\nflinched!", name(battler)) } + return false, { romText(battle and battle.data, "_FlinchedText", + "%s\nflinched!", name(battler)) } end local record = Status.recordFor(battleStatuses(battle), mon.status) local handler = record and record.beforeMove @@ -184,23 +198,27 @@ function Status.beforeMove(battler, rng, battle) end if battler.boundTurns and battler.boundTurns > 0 then battler.boundTurns = battler.boundTurns - 1 - msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler)) + msgs[#msgs + 1] = romText(battle and battle.data, "_CantMoveText", + "%s\ncan't move!", name(battler)) return false, msgs end if battler.disabledTurns then battler.disabledTurns = battler.disabledTurns - 1 if battler.disabledTurns <= 0 then battler.disabledTurns, battler.disabledSlot = nil, nil - table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler))) + table.insert(msgs, romText(battle and battle.data, "_DisabledNoMoreText", + "%s's\ndisabled no more!", name(battler))) end end if battler.confusedTurns then battler.confusedTurns = battler.confusedTurns - 1 if battler.confusedTurns <= 0 then battler.confusedTurns = nil - table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler))) + table.insert(msgs, romText(battle and battle.data, "_ConfusedNoMoreText", + "%s\nsnapped out of\nconfusion!", name(battler))) else - table.insert(msgs, Strings("%s\nis confused!", name(battler))) + table.insert(msgs, romText(battle and battle.data, "_IsConfusedText", + "%s\nis confused!", name(battler))) -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256) if rng(0, 255) < 128 then return false, msgs, true -- hurt itself @@ -241,7 +259,8 @@ 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, Strings("LEECH SEED saps\n%s!", name(battler))) + table.insert(msgs, romText(battle and battle.data, "_HurtByLeechSeedText", + "LEECH SEED saps\n%s!", name(battler))) end return msgs end diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua index 55de40c3..ba3afa7e 100644 --- a/src/battle/StatusRegistry.lua +++ b/src/battle/StatusRegistry.lua @@ -12,7 +12,7 @@ local StatusRegistry = {} -- pokered's / 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 ("Enemy " .. b.name) + return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779 end -- opts: toxic (start the Toxic counter), moveType (for the type gates), diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 08b9aca0..9654029a 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -20,9 +20,16 @@ local TypeChart = require("src.battle.TypeChart") local Strings = require("src.core.Strings") +local romText = require("src.core.RomText") local TrainerAI = {} +-- pokered's / 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" } @@ -95,12 +102,16 @@ function TrainerAI.switchAction(battle) return { special = "aiSwitch", index = alive[1] } end --- Apply an aiItem action to the enemy battler; returns messages. +-- Apply an aiItem action to the enemy battler; returns messages, already +-- final: the item line prints the raw nickname (AIPrintItemUseText has no +-- "Enemy " prefix in pokered), the stat lines carry it via displayName, so +-- the caller must not run these through prefixEnemy. function TrainerAI.useItem(battle, item) 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 = { Strings("%s\nused %s!", trainerName, itemName) } + local msgs = { romText(battle.data, "_AIBattleUseItemText", + "%s\nused %s!", trainerName, itemName, enemy.name) } if item == "FULL_HEAL" then enemy.mon.status = nil enemy.toxicCounter = nil @@ -113,10 +124,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!", enemy.name, stat:upper())) + table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper())) elseif item == "GUARD_SPEC" then enemy.mist = true - table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name)) + table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy))) end return msgs end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 04402f42..342cc0b8 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -143,21 +143,24 @@ LauncherView._refreshAutoHeight = refreshAutoHeight -- ------- lifecycle --- 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. +-- 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. function LauncherView.applyNxPerfGuards(imp) - if not (imp and imp.isNX and FlexLove.isReady() and FlexLove._Performance) then + if not (imp and FlexLove.isReady() and FlexLove._Performance) then return false 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 steps hitch the pad cursor on Switch. Less frequent steps, higher - -- threshold — desktop keeps FlexLove defaults. + -- "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). if FlexLove._gcConfig then FlexLove._gcConfig.strategy = "periodic" FlexLove._gcConfig.interval = 90 @@ -332,12 +335,22 @@ 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 +-- wrapped text height at a width, from the same font the element renders. +-- Memoized: the immediate-mode rebuild asks for the same (size, width, +-- text) every frame for every visible row, and Font:getWrap re-shapes the +-- whole string each time - one of the hottest calls in the frame on mobile. +-- The key space is small (mod summaries and notes at a handful of widths). +local wrapHeightCache = {} local function wrapHeight(size, text, width) 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) - return math.max(1, #lines) * f:getHeight() + h = math.max(1, #lines) * f:getHeight() + wrapHeightCache[key] = h + return h end -- Every text size in this file is already scaled by m.s, so FlexLove's own @@ -421,6 +434,45 @@ local function button(imp, parent, key, text, opts) return mk(p) 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, @@ -1012,30 +1064,40 @@ 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 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 }) + 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 }, + } if #mods > 0 then - button(imp, head, "mods-enable-all", Strings("Enable all"), { + items[#items + 1] = { size = 11 * m.s + 1, text = Strings("Enable all"), btn = true } + items[#items + 1] = { size = 11 * m.s + 1, text = Strings("Disable all"), btn = true } + end + local head, buttonsRow = headRows(parent, m, items) + label(head, title, 22 * m.s + 4, C("white"), { textWrap = false }) + label(head, count, 12 * m.s + 2, C("warn"), { textWrap = false }) + local btnRow = buttonsRow() + if #mods > 0 then + button(imp, btnRow, "mods-enable-all", Strings("Enable all"), { size = 11 * m.s + 1, kind = "neutral", action = function() imp:_setAllMods(true) end, }) - button(imp, head, "mods-disable-all", Strings("Disable all"), { + button(imp, btnRow, "mods-disable-all", Strings("Disable all"), { size = 11 * m.s + 1, kind = "neutral", action = function() imp:_setAllMods(false) end, }) end - button(imp, head, "mods-import", imp:_modsImportButtonLabel(), { + button(imp, btnRow, "mods-import", importLabel, { h = m.btnH, size = 13 * m.s + 1, kind = "neutral", action = function() imp:chooseMod() end, }) @@ -1060,6 +1122,89 @@ 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. @@ -1091,6 +1236,19 @@ 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 @@ -1104,6 +1262,9 @@ 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 @@ -1155,6 +1316,9 @@ 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 @@ -1212,18 +1376,26 @@ local function buildFindPanel(imp, parent, m) local rows = imp:_findRows() local total = #((imp.findIndex and imp.findIndex.mods) or {}) - 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 }) + 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 if #sources > 0 then - 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 }) + items[#items + 1] = { size = 13 * m.s + 1, text = Strings("Refresh"), btn = true } end - mk({ parent = head, flex = 1 }) + local head, buttonsRow = headRows(parent, m, items) + label(head, title, 22 * m.s + 4, C("white"), { textWrap = false }) + if count then + label(head, count, 12 * m.s + 2, C("warn"), { textWrap = false }) + end + local btnRow = buttonsRow() if #sources > 0 then - button(imp, head, "find-refresh", Strings("Refresh"), { + button(imp, btnRow, "find-refresh", Strings("Refresh"), { h = m.btnH, size = 13 * m.s + 1, kind = "neutral", action = function() imp._findSearchFocus = false @@ -1232,11 +1404,10 @@ local function buildFindPanel(imp, parent, m) end, }) 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, - }) + button(imp, btnRow, "find-add", addLabel, { + 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, diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 99c0e782..749b5b9f 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2593,6 +2593,8 @@ 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(), } @@ -2609,6 +2611,9 @@ 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) @@ -2722,6 +2727,8 @@ 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, @@ -2765,6 +2772,8 @@ 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, @@ -2980,12 +2989,22 @@ end -- The rows the filters leave, and the installed-mod context the compatibility -- warnings are judged against. function RomImporter:_findRows() - local ModIndex = require("src.mods.ModIndex") local all = (self.findIndex and self.findIndex.mods) or {} - return ModIndex.filter(all, { + -- The view asks every frame (immediate mode); only re-filter when the + -- index, query, or category actually changed. + local c = self._findRowsCache + if c and c.src == all and c.query == self.findQuery + and c.category == self.findCategory then + return c.rows + end + local ModIndex = require("src.mods.ModIndex") + local rows = ModIndex.filter(all, { query = self.findQuery, category = self.findCategory, }) + self._findRowsCache = { src = all, query = self.findQuery, + category = self.findCategory, rows = rows } + return rows end function RomImporter:_findInstalledMap() diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index ad1e2165..f7c1b9b3 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -12,9 +12,19 @@ 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, @@ -82,6 +92,14 @@ 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", @@ -130,8 +148,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP" or itemId == "RARE_CANDY" or itemId == "COIN_CASE" or (itemDef and itemDef.machine)) then - return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", - save.player.name) } + return "failed", { notTime(data, save) } end if BALLS[itemId] then @@ -148,13 +165,14 @@ 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", { data.text._PlayedFluteHadEffectText - or Strings("{PLAYER} played the\nPOKé FLUTE.") }, + return "flute_wake", { romText(data, "_PlayedFluteHadEffectText", + "{PLAYER} played the\nPOKé FLUTE.") }, { mapId = mapId, npc = npc } end -- otherwise: play the tune, nothing happens (ItemUsePokeFlute's -- PlayedFluteNoEffectText branch) - return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") } + return "flute_field", { romText(data, "_PlayedFluteNoEffectText", + "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") } end local woke = false local function wake(mon) @@ -169,17 +187,20 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- WakeUpEntireParty runs on the enemy's bench too for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end if not woke then - return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") } + return "failed", { romText(data, "_PlayedFluteNoEffectText", + "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") } end - return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name), - Strings("All sleeping\nPOKéMON woke up!") } + return "flute", { romText(data, "_PlayedFluteHadEffectText", + "%s played the\nPOKé FLUTE.", save.player.name), + romText(data, "_FluteWokeUpText", + "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", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } + return "failed", { notTime(data, save) } end local b = battle.player -- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy / @@ -201,7 +222,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- effect, so at +6 it is still consumed and StatModifierUpEffect -- just prints "Nothing happened!" if cur >= 6 then - return "consumed", { Strings("Nothing happened!") } + return "consumed", { romText(data, "_NothingHappenedText", + "Nothing happened!") } end b.stages[stat] = cur + 1 return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) } @@ -210,7 +232,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- the item, even when it is already active if itemId == "DIRE_HIT" then b.focusEnergy = true - return "consumed", { Strings("%s's\ngetting pumped!", b.name) } + return "consumed", { romText(data, "_GettingPumpedText", + "%s's\ngetting pumped!", b.name) } end if itemId == "GUARD_SPEC" then b.mist = true @@ -219,10 +242,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", { Strings( - "OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } + return "failed", { notTime(data, save) } end - return "consumed_escape", { Strings("The wild POKéMON\nran away!") } + return "consumed_escape", { romText(data, "_WildRanText", + "The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) } end end @@ -231,7 +254,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", { Strings("It won't have\nany effect.") } end + if not target then return "failed", { noEffect(data) } end local restored = false local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER" local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER" @@ -253,9 +276,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) restored = mv and restore(mv) or false end if not restored then - return "failed", { Strings("It won't have\nany effect.") } + return "failed", { noEffect(data) } end - return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) } + -- pokered's line names no mon, so the extracted text takes no args + return "consumed", { romText(data, "_PPRestoredText", "PP was restored.") } end -- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to @@ -280,10 +304,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) target.status = nil cureActiveToxic(battle, target) require("src.core.Sound").play(data, "Heal_Ailment") - return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) } + return "consumed", { romText(data, CURE_TEXT.FULL_HEAL, + "%s's\nstatus returned\nto normal!", monName(data, target)) } end if not target or target.hp <= 0 or target.hp >= target.stats.hp then - return "failed", { Strings("It won't have\nany effect.") } + return "failed", { noEffect(data) } end -- 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 @@ -295,7 +320,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) else target.hp = math.min(target.stats.hp, target.hp + heal) end - local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) } + -- _PotionText's second slot is the recovered amount ({NUM: + -- wHPBarHPDifference}); the engine fallback never prints it + local msgs = { romText(data, "_PotionText", "%s's HP\nwas restored!", + monName(data, target), target.hp - before) } if itemId == "FULL_RESTORE" then target.status = nil cureActiveToxic(battle, target) @@ -307,17 +335,18 @@ 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", { Strings("It won't have\nany effect.") } + return "failed", { noEffect(data) } end target.status = nil cureActiveToxic(battle, target) require("src.core.Sound").play(data, "Heal_Ailment") - return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) } + return "consumed", { romText(data, CURE_TEXT[itemId], + "%s's\nstatus returned\nto normal!", monName(data, target)) } end if itemId == "REVIVE" or itemId == "MAX_REVIVE" then if not target or target.hp > 0 then - return "failed", { Strings("It won't have\nany effect.") } + return "failed", { noEffect(data) } end target.status = nil target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp @@ -329,13 +358,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if battle and battle.participants then battle.participants[target] = true end - return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }, + return "consumed", { romText(data, "_ReviveText", + "%s\nis revitalized!", monName(data, target)) }, { healedFrom = 0 } end if itemId == "RARE_CANDY" then if not target or target.level >= 100 then - return "failed", { Strings("It won't have\nany effect.") } + return "failed", { noEffect(data) } end local Growth = require("src.pokemon.Growth") local Stats = require("src.pokemon.Stats") @@ -348,12 +378,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540) require("src.world.PikachuFollower") .modifyHappiness(save, "LEVELUP", target) - return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) }, + return "consumed", { romText(data, "_RareCandyText", + "%s grew\nto level %d!", monName(data, target), target.level) }, { leveledTo = target.level } end if STONES[itemId] then - if not target then return "failed", { Strings("It won't have\nany effect.") } end + if not target then return "failed", { noEffect(data) } end -- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs -- IsThisPartyMonStarterPikachu (OT identity match) before -- TryEvolvingMon and bails with the voiced cry + RefusingText. @@ -363,10 +394,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) and target.ot == save.player.name and target.otId == save.player.id then require("src.core.Sound").playCry(data, "PIKACHU") - 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 } + return "failed", { romText(data, "_RefusingText", + "%s\nis refusing!", monName(data, target)) } end local speciesDef = data.pokemon[target.species] for _, evo in ipairs(speciesDef.evolutions) do @@ -374,44 +403,48 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) return "consumed", nil, { evolveTo = evo.species } end end - return "failed", { Strings("It won't have\nany effect.") } + return "failed", { noEffect(data) } 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", { Strings("It won't have\nany effect.") } end + if not target then return "failed", { noEffect(data) } end target.statExp = target.statExp or {} local cur = target.statExp[vitaminStat] or 0 if cur >= 25600 then - return "failed", { Strings("It won't have\nany effect.") } + return "failed", { noEffect(data) } 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", { Strings("It won't have\nany effect.") } end + if not target then return "failed", { noEffect(data) } end local mv = target.moves[moveIndex or 1] local 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", { Strings("%s's PP\nincreased!", mdef.name) } + return "consumed", { romText(data, "_PPIncreasedText", + "%s's PP\nincreased!", mdef.name) } end - return "failed", { Strings("It won't have\nany effect.") } + return "failed", { noEffect(data) } end if itemDef and itemDef.machine then - if not target then return "failed", { Strings("It won't have\nany effect.") } end + if not target then return "failed", { noEffect(data) } end local speciesDef = data.pokemon[target.species] local ok = false for _, m in ipairs(speciesDef.tmhm) do @@ -422,11 +455,16 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic -- ItemUseNotTime/NoCyclingAllowedHere paths are silent) require("src.core.Sound").play(data, "Denied") - return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) } + local moveName = data.moves[itemDef.machine.move].name + return "failed", { romText(data, "_MonCannotLearnMachineMoveText", + "%s can't\nlearn that move!", + monName(data, target), moveName, moveName) } end for _, mv in ipairs(target.moves) do if mv.id == itemDef.machine.move then - return "failed", { Strings("It knows that\nmove already!") } + return "failed", { romText(data, "_AlreadyKnowsText", + "It knows that\nmove already!", + monName(data, target), data.moves[itemDef.machine.move].name) } end end -- HMs are never consumed; TMs are single-use @@ -435,21 +473,21 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then if battle then - return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } + return "failed", { notTime(data, save) } 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", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } + return "failed", { notTime(data, save) } end return "fish", itemId end if itemId == "BICYCLE" then if battle then - return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } + return "failed", { notTime(data, save) } end return "bicycle" end @@ -459,18 +497,19 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) end if itemId == "TOWN_MAP" then if battle then - return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } + return "failed", { notTime(data, save) } end return "townmap" end if itemId == "ITEMFINDER" then if battle then - return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } + return "failed", { notTime(data, save) } end return "itemfinder" end if itemId == "COIN_CASE" then - return "failed", { Strings("Coin count:\n%d", save.coins or 0) } + return "failed", { romText(data, "_CoinCaseNumCoinsText", + "Coin count:\n%d", save.coins or 0) } end 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 @@ -478,7 +517,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) return "consumed", { Strings("%s used\n%s!", save.player.name, name) } end - return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } + return "failed", { notTime(data, save) } end return ItemEffects diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua index ce69328b..ff872232 100644 --- a/src/mods/ModUpdate.lua +++ b/src/mods/ModUpdate.lua @@ -102,7 +102,9 @@ function ModUpdate.previewLine(text, maxChars) return s end --- Decode one GitHub release object into { version, tag, zip, prerelease, body }. +-- Decode one GitHub release object into { version, tag, zip, prerelease, +-- name, body, downloads }. `downloads` is the sum of every asset's +-- download_count, GitHub's own measure of a release's downloads. function ModUpdate.parseRelease(doc, modId) if type(doc) ~= "table" or not doc.tag_name then return nil, "no tag_name in release" @@ -114,6 +116,13 @@ 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), @@ -121,6 +130,8 @@ 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 @@ -154,7 +165,7 @@ function ModUpdate.parseReleases(jsonText, modId, Json) end function ModUpdate.apiReleasesUrl(repo) - return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30" + return "https://api.github.com/repos/" .. repo .. "/releases?per_page=100" end function ModUpdate.apiLatestUrl(repo) @@ -180,6 +191,52 @@ 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 = , releases = } 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() @@ -210,6 +267,22 @@ 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() @@ -226,6 +299,8 @@ 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, @@ -280,7 +355,7 @@ function ModUpdate.fetchReleases(repo, modId, opts) end if not opts.force then local cached = ModUpdate.readCache(repo) - if ModUpdate.cacheFresh(cached) then + if ModUpdate.cacheFresh(cached) and ModUpdate.cacheUsable(cached) then return cached.releases, nil, { fromCache = true } end end diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 42f5bc66..ebd1e7f6 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1064,6 +1064,12 @@ 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{ @@ -1071,6 +1077,9 @@ 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) }, }, extra = function(id, value) if fontIsCharmap(id) then @@ -1080,12 +1089,18 @@ 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, 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) @@ -1096,6 +1111,9 @@ 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 = {} @@ -1108,6 +1126,8 @@ 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 diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index c2df1741..b92352b6 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -15,6 +15,7 @@ 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 = {} @@ -140,7 +141,8 @@ 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, - Strings("%s learned\n%s!", name, mdef.name), nextStep)) + romText(game.data, "_LearnedMove1Text", + "%s learned\n%s!", name, mdef.name), nextStep)) else -- LearnMoveFromLevelUp with a full moveset: the forget UI Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep) @@ -161,8 +163,12 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via) Music.play(game.data, Music.special(game.data, "evolution")) local oldName = mon.nickname or game.data.pokemon[mon.species].name Evolution.apply(game, mon, newSpecies, via) - local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!", - oldName, oldName, game.data.pokemon[newSpecies].name) + -- 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) game.stack:push(TextBox.new(game, msg, function() Music.restoreMap(game.data) -- re-run the evolved species' level-up learn check before onDone diff --git a/src/render/Font.lua b/src/render/Font.lua index 33f1a97a..7015a3fc 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -6,6 +6,13 @@ -- 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 (, 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") @@ -13,6 +20,19 @@ 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 @@ -83,6 +103,42 @@ 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())), + 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 @@ -104,6 +160,49 @@ 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 @@ -119,6 +218,7 @@ local SPACE = 0x7F -- 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 @@ -127,11 +227,23 @@ 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 then + -- single characters belong to the TTF; only multi-character + -- sequences (ligatures, macros) keep their tile mapping + 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. @@ -185,14 +297,37 @@ 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 +-- how far the pen moves past a glyph; 8 unless its page says otherwise. +-- TTF glyphs answer with the font's own metrics (5px base, 11px for +-- double-width kana/CJK in Plain Pixel), which is what makes TextBox's +-- pixel-budget pagination fit more of a narrow script per line. function Font.advanceOf(code) + 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 diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index df502512..43696871 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -370,8 +370,12 @@ 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) - for j, code in ipairs(line) do - Font.drawCode(code, self.textX + (j - 1) * 8, y) + -- 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) end end if (self.waiting or (self.done and not self.choice and not self.auto diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 06d170fc..8c226292 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -240,7 +240,7 @@ local function decodeName(bytes, off, len) return table.concat(out) end -local function encodeName(buf, off, len, text) +local function encodeName(buf, off, len, text, padTail) local i, pos = 0, 1 while i < len - 1 and pos <= #text do -- a bracketed control token (e.g. "", from decodeName reading a @@ -259,16 +259,17 @@ local function encodeName(buf, off, len, text) setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50) i, pos = i + 1, pos + clen end - -- 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. + -- 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). if i < len then setByte(buf, off + i, 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 + if padTail then + for j = i + 1, len - 1 do setByte(buf, off + j, 0x50) end end end @@ -781,8 +782,9 @@ function GenSave.encode(save, data, template) for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end end - 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") + 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) 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 @@ -875,11 +877,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") + mon.ot or (save.player and save.player.name) or "RED", padTail) -- 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)) + mon.nickname or speciesName(cw, mon.species), padTail) 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 @@ -900,9 +902,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") + mon.ot or (save.player and save.player.name) or "RED", padTail) encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH, - mon.nickname or speciesName(cw, mon.species)) -- #257, as above + mon.nickname or speciesName(cw, mon.species), padTail) -- #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 diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 5954d553..c6aebd0c 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -12,6 +12,7 @@ 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 @@ -89,9 +90,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, - Strings("Huh? %s\nstopped evolving!", self.oldName), + romText(game.data, "_StoppedEvolvingText", + "Huh? %s\nstopped evolving!", self.oldName), function() Music.restoreMap(game.data) game.stack:pop() -- the evolution screen itself @@ -106,6 +107,8 @@ 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), diff --git a/src/ui/MoveLearnMenu.lua b/src/ui/MoveLearnMenu.lua index 222f7f1c..f32a60de 100644 --- a/src/ui/MoveLearnMenu.lua +++ b/src/ui/MoveLearnMenu.lua @@ -5,6 +5,7 @@ local Font = require("src.render.Font") local Strings = require("src.core.Strings") +local romText = require("src.core.RomText") local MoveLearnMenu = {} MoveLearnMenu.__index = MoveLearnMenu @@ -43,10 +44,13 @@ 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, - 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), + 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), nil, { choice = function(yes) if yes then @@ -77,7 +81,8 @@ 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, - Strings("HM techniques\ncan't be deleted!"))) + romText(self.game.data, "_HMCantDeleteText", + "HM techniques\ncan't be deleted!"))) return end local mdef = self.game.data.moves[self.newMoveId] @@ -97,7 +102,8 @@ function MoveLearnMenu:confirmAbandon() local mdef = game.data.moves[self.newMoveId] self.selecting = false game.stack:push(TextBox.new(game, - Strings("Abandon learning\n%s?", mdef.name), nil, { + romText(game.data, "_AbandonLearningText", + "Abandon learning\n%s?", mdef.name), nil, { choice = function(yes) if yes then self:finish(false) else self:enter() end end, @@ -113,12 +119,17 @@ function MoveLearnMenu:finish(learned) game.stack:pop() local msg if learned then - -- 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) + -- 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) else - -- DidNotLearnText - msg = Strings("%s\ndid not learn\v%s!", name, mdef.name) + msg = romText(game.data, "_DidNotLearnText", + "%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 diff --git a/tests/drivers/plainpixel_font_test.lua b/tests/drivers/plainpixel_font_test.lua new file mode 100644 index 00000000..f6b42e7b --- /dev/null +++ b/tests/drivers/plainpixel_font_test.lua @@ -0,0 +1,83 @@ +-- Driver: TTF text mode through the bundled Plain Pixel font. Teleports +-- straight into the overworld (no intro) and shows one dialogue line per +-- language back to back, shooting each: lang_english.png, lang_french.png, +-- lang_german.png, lang_spanish.png, lang_russian.png, lang_japanese.png, +-- lang_chinese.png in SHOT_DIR. Judge glyph coverage, spacing, baseline, +-- and that the tile box border survives every one of them. +-- POKEPORT_DRIVER=tests/drivers/plainpixel_font_test.lua \ +-- POKEPORT_IDENTITY=ttfdemo POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Font = require("src.render.Font") + local TextBox = require("src.render.TextBox") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions ----------------------------------------------------- + check("the bundled TTF is present", + love.filesystem.getInfo(Font.PLAINPIXEL) ~= nil) + local okLoad, ttf = pcall(love.graphics.newFont, Font.PLAINPIXEL, + Font.PLAINPIXEL_SIZE) + check("real LOVE can load it", okLoad and ttf ~= nil) + + game.save.player.name = "bryan" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + -- what a translation's register("ttf", {}) merges to + game.data.font.ttf = {} + Font.invalidate() + check("ttf active after the merge", Font.ttfActive()) + + local LINES = { + { "english", "The quick brown fox\njumps over the lazy dog!" }, + { "french", "Un \195\169clair enveloppe le\nPOK\195\169MON sauvage! \195\135a alors!" }, + { "german", "Gr\195\182\195\159e und Gewicht des\nPOK\195\169MON \195\188berpr\195\188ft!" }, + { "spanish", "\194\161El POK\195\169MON enemigo\nest\195\161 paralizado! \194\191Y ahora?" }, + { "russian", "\208\148\208\184\208\186\208\184\208\185 " + .. "\208\159\208\158\208\154\208\149\208\156\208\158\208\157 " + .. "\208\191\208\190\209\143\208\178\208\184\208\187\209\129\209\143!" }, + { "japanese", "\227\129\147\227\130\147\227\129\171\227\129\161\227\129\175! " + .. "\227\131\157\227\130\177\227\131\162\227\131\179\227\129\174\n" + .. "\227\129\155\227\129\139\227\129\132\227\129\184 " + .. "\227\130\136\227\129\134\227\129\147\227\129\157!" }, + -- NB: Plain Pixel 0.009's CJK block is partial (e.g. U+62D3, U+5922 + -- draw as tofu); stick to characters it covers + { "chinese", "\228\189\160\229\165\189! \230\136\145\230\152\175" + .. "\229\164\167\229\178\169\229\141\154\229\163\171!" }, + } + + for _, entry in ipairs(LINES) do + local name, text = entry[1], entry[2] + local done = false + local box = TextBox.new(game, text, function() done = true end) + game.stack:push(box) + -- let the typewriter finish the page before shooting it + for _ = 1, 300 do + if box.waiting or box.done then break end + U.wait(1) + end + U.wait(5) + U.shot(game, DIR .. "/lang_" .. name .. ".png") + for _ = 1, 20 do + if done then break end + U.tap(game, "a") + U.wait(10) + end + U.wait(5) + check("dismissed " .. name, done) + end + + game.data.font.ttf = nil + Font.invalidate() + check("clearing the entry restores tile mode", not Font.ttfActive()) + + U.log("shots in", DIR) + U.log("DONE") + love.event.quit(0) +end diff --git a/tests/engine/flexlove_wheel_scroll_dt0.lua b/tests/engine/flexlove_wheel_scroll_dt0.lua new file mode 100644 index 00000000..825146d6 --- /dev/null +++ b/tests/engine/flexlove_wheel_scroll_dt0.lua @@ -0,0 +1,63 @@ +-- Wheel scrolling must survive the launcher's immediate-mode frame order: +-- FlexLove.update(dt) runs in love.update, but beginFrame (in draw) resets +-- the accumulated dt to 0 BEFORE endFrame updates the elements, so +-- ScrollManager:update always receives dt = 0 there. The smooth-scroll +-- interpolation must still advance (it once used a fixed per-frame +-- fraction; the dt-aware blend has to treat dt <= 0 as one nominal 60 Hz +-- step, or every launcher list stops responding to the wheel entirely). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +love.window.getMode = function() return 1024, 768, { fullscreen = false } end +love.window.getDesktopDimensions = function() return 1920, 1080 end +love.mouse = love.mouse or {} +love.mouse.getPosition = function() return 100, 100 end +love.mouse.isDown = love.mouse.isDown or function() return false end +package.loaded["libs.flexlove.modules.UTF8"] = { + char = string.char, charpattern = ".", codepoint = string.byte, + len = string.len, offset = function(_, n) return n end, + codes = function(s) + local i = 0 + return function() i = i + 1; if i <= #s then return i, s:byte(i) end end + end, +} + +local T = require("tests.modkit") +local FlexLove = require("libs.flexlove.FlexLove") +FlexLove.init({ + immediateMode = true, + performanceMonitoring = false, + keyboardNavigation = false, +}) + +-- One launcher-shaped frame: update BEFORE beginFrame, exactly like +-- LauncherView.update / draw, so endFrame sees _accumulatedDt == 0. +local function frame(wheelAfterBuild) + FlexLove.update(1 / 60) + FlexLove.beginFrame() + local scroller = FlexLove.new({ + id = "scroller", x = 0, y = 0, width = 400, height = 300, + overflowY = "scroll", hideScrollbars = true, + smoothScrollEnabled = true, scrollSpeed = 60, + positioning = "flex", flexDirection = "vertical", + }) + for i = 1, 30 do + FlexLove.new({ parent = scroller, id = "row" .. i, width = 380, height = 40 }) + end + FlexLove.endFrame() + if wheelAfterBuild then FlexLove.wheelmoved(0, -1) end + local sm = scroller._scrollManager + return sm and sm._scrollY or nil, sm and sm._targetScrollY or nil +end + +for _ = 1, 3 do frame(false) end +local y0, t0 = frame(true) +T.eq(t0, 60, "one wheel notch targets one scrollSpeed step") +T.eq(y0, 0, "the notch lands on the frame after input, not the same one") + +local y = y0 +for _ = 1, 5 do y = frame(false) end +T.check(y ~= nil and y > 30, "smooth scroll advances past halfway within 5 dt=0 frames") +for _ = 1, 60 do y = frame(false) end +T.check(y ~= nil and math.abs(y - 60) < 1, "and converges on the target") + +T.finish("flexlove wheel scroll at dt=0") diff --git a/tests/engine/launcher_nx_pad_cursor_test.lua b/tests/engine/launcher_nx_pad_cursor_test.lua index 8e27bffd..eea416b1 100644 --- a/tests/engine/launcher_nx_pad_cursor_test.lua +++ b/tests/engine/launcher_nx_pad_cursor_test.lua @@ -253,8 +253,10 @@ do "NX guard disables Performance.enabled") check(view:find("mp.enabled = false", 1, true) ~= nil, "NX guard disables memory profiling") - check(view:find("if not (imp and imp.isNX", 1, true) ~= nil, - "perf guard is gated on imp.isNX") + -- 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("parkNxPointerForHost", 1, true) ~= nil, "detach parks NX pointer before tearing down") diff --git a/tests/engine/mod_update_tests.lua b/tests/engine/mod_update_tests.lua index 759d2f18..ae7c2c00 100644 --- a/tests/engine/mod_update_tests.lua +++ b/tests/engine/mod_update_tests.lua @@ -108,4 +108,133 @@ 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") diff --git a/tests/engine/ttf_font_mode.lua b/tests/engine/ttf_font_mode.lua new file mode 100644 index 00000000..3f4a4219 --- /dev/null +++ b/tests/engine/ttf_font_mode.lua @@ -0,0 +1,183 @@ +-- 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 ( 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") + +-- 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(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") diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 06fb2a91..56927689 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -74,11 +74,36 @@ 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. - newFont = function(size) - local px = size or 12 + -- 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) return { - getWidth = function(_, text) return #tostring(text) * math.max(1, px * 0.5) end, + getWidth = function(_, text) + text = tostring(text) + local w, i, n = 0, 1, #text + while i <= n do + local byte = text:byte(i) + local len = byte >= 0xF0 and 4 or byte >= 0xE0 and 3 + or byte >= 0xC0 and 2 or 1 + w = w + (byte >= 0xE1 and 2 or 1) * unit -- U+1000+: double width + i = i + len + end + return w + end, getHeight = function() return px end, + getBaseline = function() return px - 2 end, + setFilter = noop, } end, setFont = function(f) gstate.font = f end, diff --git a/tests/save_convert_tests.lua b/tests/save_convert_tests.lua index a775f0c4..bd499eb2 100644 --- a/tests/save_convert_tests.lua +++ b/tests/save_convert_tests.lua @@ -590,5 +590,37 @@ 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 diff --git a/tools/gbromdiff/.gitignore b/tools/gbromdiff/.gitignore new file mode 100644 index 00000000..036f67bf --- /dev/null +++ b/tools/gbromdiff/.gitignore @@ -0,0 +1,3 @@ +*.gb +*.sym +__pycache__/ diff --git a/tools/gbromdiff/LICENSE b/tools/gbromdiff/LICENSE new file mode 100644 index 00000000..97d18c1c --- /dev/null +++ b/tools/gbromdiff/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 hernan0078 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/gbromdiff/README.md b/tools/gbromdiff/README.md new file mode 100644 index 00000000..37e93e2b --- /dev/null +++ b/tools/gbromdiff/README.md @@ -0,0 +1,97 @@ +# gbromdiff + +Compare two Game Boy ROMs bank by bank, and say **where** and **how** they +disagree. + +Written for one specific problem: bringing a localised Gen 1 Pokémon release +into a disassembly. It is not specific to Pokémon, or to Gen 1 — any pair of +Game Boy ROMs will do. + +## Why + +The European Pokémon releases are rebuilds of their US counterparts. Most +banks are byte-identical, the text banks are entirely different, and a handful +of code banks are the US code with pointers shifted. Which bank is which is +the first thing you need to know and the last thing anyone writes down. + +That distinction is the whole point of this tool: + +``` + bank differing bytes shape + 0x04 30 ( 0.2%) patched + 0x10 16328 ( 99.7%) replaced +``` + +A bank differing in 99.7% of its bytes is a **different payload** — translated +text — and needs new source. A bank differing in 0.2% is **the same code with +values moved**, and usually needs a pointer table corrected rather than a +rewrite. Those two findings lead to completely different work, and a plain +`cmp` cannot tell them apart. + +## The loop it is built for + +``` +1. build the disassembly +2. diff the build against the retail ROM ← this tool +3. fix the banks that disagree +4. go to 1, until nothing disagrees +``` + +Exit status is **0 when the ROMs are identical** and **1 when they differ**, so +it drops straight into that loop: + +```bash +make && ./gbromdiff.py build.gb retail.gb || echo "not there yet" +``` + +## Usage + +```bash +./gbromdiff.py A.gb B.gb # bank-by-bank summary +./gbromdiff.py A.gb B.gb --regions # contiguous differing runs +./gbromdiff.py A.gb B.gb --sym pokeyellow.sym # name the symbols involved +./gbromdiff.py A.gb B.gb --json # machine-readable +``` + +With an rgbds `.sym` file it names the symbol each differing region falls +inside, which turns + +``` +0x0703A1 differs +``` + +into + +``` +0x0703A1-0x0703B4 bank 0x1C 20 bytes TextPredef+0x3A1 +``` + +`--gap N` controls how many matching bytes are tolerated inside one region +(default 16). Without it, a shifted pointer table reports as hundreds of +one-byte findings instead of one region worth looking at. + +No dependencies beyond Python 3. + +## What this does not do + +It does not build anything, and it does not write a disassembly. It tells you +where two ROMs differ. Turning that into a source tree that rebuilds a +localised ROM byte-for-byte is the actual project; this is the instrument you +point at it between iterations. + +## Context + +The Spanish Red and Blue releases are supported in +[gen1recomp](https://github.com/bryanthaboi/gen1recomp) by way of +[einstein95/pokered-es](https://github.com/einstein95/pokered-es), a +shift-matching disassembly whose `.sym` files provide Spanish addresses for +every symbol. + +Spanish **Yellow** has no equivalent, so it cannot be supported the same way. +French (`Narishma-gb/pokeyellow-fr`) and German (`Brianum/pokeyellow-de`) +Yellow disassemblies both exist, so adapting pret's Yellow to a European +release is demonstrably possible — it simply has not been done for Spanish. +This tool exists to make that attempt less tedious. + +MIT licensed. Contributions welcome, particularly from anyone actually +attempting `pokeyellow-es`. diff --git a/tools/gbromdiff/gbromdiff.py b/tools/gbromdiff/gbromdiff.py new file mode 100755 index 00000000..ded86510 --- /dev/null +++ b/tools/gbromdiff/gbromdiff.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Compare two Game Boy ROMs bank by bank, and say where they disagree. + +Written for the problem of bringing a localised Gen 1 release into a +disassembly. The European Pokemon releases are rebuilds of their US +counterparts: most banks are byte-identical, the text banks are wholly +different, and a handful of code banks are the US code with shifted +pointers. Knowing WHICH is which is the first thing you need and the +last thing anyone writes down. + +The loop this is built for: + + 1. build the disassembly + 2. diff the build against the retail ROM + 3. fix the banks that disagree + 4. go to 1, until nothing disagrees + +Step 2 is this script. With a .sym file it also names the symbols that +live inside each differing region, which turns "bank 0x1C differs at +0x4A31" into "bank 0x1C differs, starting inside TextPredef". + +Usage: + + gbromdiff.py A.gb B.gb bank-by-bank summary + gbromdiff.py A.gb B.gb --regions contiguous differing runs + gbromdiff.py A.gb B.gb --sym pokeyellow.sym name the symbols involved + gbromdiff.py A.gb B.gb --json machine-readable + +Exit status is 0 when the ROMs are identical, 1 when they differ, 2 on a +usage error -- so it can drive a build loop directly. +""" + +import argparse +import json +import os +import sys + +BANK_SIZE = 0x4000 + + +def load(path): + with open(path, "rb") as fh: + return fh.read() + + +def header(rom): + """Title, CGB flag and global checksum, straight out of the cartridge + header. Useful for saying WHICH releases are being compared without + the caller having to know the hashes.""" + if len(rom) < 0x150: + return {} + title = rom[0x134:0x143].split(b"\x00")[0] + try: + title = title.decode("ascii", "replace").strip() + except Exception: + title = repr(title) + return { + "title": title, + "cgb": rom[0x143], + "rom_size_code": rom[0x148], + "global_checksum": (rom[0x14E] << 8) | rom[0x14F], + } + + +def banks(rom): + return (len(rom) + BANK_SIZE - 1) // BANK_SIZE + + +def bank_report(a, b): + """Per-bank identical / differs / missing, with a byte count.""" + out = [] + for i in range(max(banks(a), banks(b))): + lo, hi = i * BANK_SIZE, (i + 1) * BANK_SIZE + ba, bb = a[lo:hi], b[lo:hi] + if not ba or not bb: + out.append({"bank": i, "state": "missing", + "in_a": bool(ba), "in_b": bool(bb)}) + continue + if ba == bb: + out.append({"bank": i, "state": "identical", "differing": 0}) + continue + n = sum(1 for x, y in zip(ba, bb) if x != y) + # A bank that differs in nearly every byte is a different payload + # (translated text); one that differs in a scatter of bytes is the + # same code with pointers moved. That distinction is the whole + # reason to look at a percentage rather than a boolean. + pct = 100.0 * n / min(len(ba), len(bb)) + out.append({"bank": i, "state": "differs", "differing": n, + "percent": round(pct, 2), + "shape": "replaced" if pct > 60 else + ("patched" if pct < 5 else "mixed")}) + return out + + +def regions(a, b, gap=16): + """Contiguous runs of differing bytes, merging runs separated by fewer + than `gap` matching bytes -- otherwise a shifted pointer table reads as + hundreds of one-byte findings instead of one region.""" + out = [] + n = min(len(a), len(b)) + start = None + last = None + for i in range(n): + if a[i] != b[i]: + if start is None: + start = i + elif last is not None and i - last > gap: + out.append((start, last)) + start = i + last = i + if start is not None: + out.append((start, last)) + if len(a) != len(b): + out.append((n, max(len(a), len(b)) - 1)) + return out + + +def load_symbols(path): + """An rgbds .sym file: `BB:AAAA Name` per line. Returned as a list of + (absolute_offset, bank, addr, name), sorted, so a region can be mapped + to whatever symbol most recently preceded it.""" + syms = [] + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.split(";")[0].strip() + if not line or ":" not in line: + continue + try: + where, name = line.split(None, 1) + bank_s, addr_s = where.split(":") + bank, addr = int(bank_s, 16), int(addr_s, 16) + except ValueError: + continue + # bank 0 is 0000-3FFF; every other bank is paged in at 4000 + offset = addr if bank == 0 else bank * BANK_SIZE + (addr - 0x4000) + syms.append((offset, bank, addr, name.strip())) + syms.sort() + return syms + + +def symbol_before(syms, offset): + """The last symbol at or before `offset` -- i.e. the thing this byte is + most likely part of. Binary search over the sorted table.""" + lo, hi = 0, len(syms) - 1 + best = None + while lo <= hi: + mid = (lo + hi) // 2 + if syms[mid][0] <= offset: + best = syms[mid] + lo = mid + 1 + else: + hi = mid - 1 + return best + + +def main(): + ap = argparse.ArgumentParser( + description="Compare two Game Boy ROMs bank by bank.") + ap.add_argument("rom_a") + ap.add_argument("rom_b") + ap.add_argument("--regions", action="store_true", + help="list contiguous differing runs, not just banks") + ap.add_argument("--sym", help="rgbds .sym file, to name the symbols " + "each differing region falls inside") + ap.add_argument("--gap", type=int, default=16, + help="matching bytes tolerated inside one region " + "(default 16)") + ap.add_argument("--limit", type=int, default=40, + help="max regions to print (default 40)") + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + for p in (args.rom_a, args.rom_b): + if not os.path.isfile(p): + print(f"error: no such file: {p}", file=sys.stderr) + return 2 + + a, b = load(args.rom_a), load(args.rom_b) + rep = bank_report(a, b) + same = [r for r in rep if r["state"] == "identical"] + diff = [r for r in rep if r["state"] == "differs"] + + result = { + "a": {"path": args.rom_a, "bytes": len(a), **header(a)}, + "b": {"path": args.rom_b, "bytes": len(b), **header(b)}, + "banks_total": len(rep), + "banks_identical": len(same), + "banks_differing": len(diff), + "banks": rep, + } + + if args.regions or args.sym: + regs = regions(a, b, args.gap) + syms = load_symbols(args.sym) if args.sym else None + listed = [] + for start, end in regs[: args.limit]: + item = {"start": start, "end": end, "length": end - start + 1, + "bank": start // BANK_SIZE} + if syms: + s = symbol_before(syms, start) + if s: + item["symbol"] = s[3] + item["symbol_offset"] = start - s[0] + listed.append(item) + result["regions_total"] = len(regs) + result["regions"] = listed + + if args.json: + print(json.dumps(result, indent=2)) + return 0 if not diff and len(a) == len(b) else 1 + + ha, hb = result["a"], result["b"] + print(f"A {ha.get('title','?'):<16} {len(a):>8} bytes {args.rom_a}") + print(f"B {hb.get('title','?'):<16} {len(b):>8} bytes {args.rom_b}") + print() + if not diff and len(a) == len(b): + print(f"IDENTICAL — all {len(rep)} banks match") + return 0 + + print(f"{len(same)}/{len(rep)} banks identical, {len(diff)} differ") + print() + print(" bank differing bytes shape") + for r in diff: + if r["state"] != "differs": + continue + print(f" 0x{r['bank']:02X} {r['differing']:>6} " + f"({r['percent']:>5.1f}%) {r['shape']}") + print() + print(" replaced = a different payload (translated text)") + print(" patched = the same code with a few values moved") + + if "regions" in result: + print() + print(f"{result['regions_total']} differing regions " + f"(showing {len(result['regions'])}):") + for item in result["regions"]: + line = (f" 0x{item['start']:06X}-0x{item['end']:06X} " + f"bank 0x{item['bank']:02X} {item['length']:>6} bytes") + if "symbol" in item: + line += f" {item['symbol']}+0x{item['symbol_offset']:X}" + print(line) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/modkit.py b/tools/modkit.py index fb0b9149..56e439fa 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -7,7 +7,7 @@ Subcommands: scaffold [--profile content|overhaul|total_conversion] [--api 2] [--github owner/repo] [--experimental] [--dest DIR] [--force] translation [--language NAME] [--base auto|fixture|imported] - [--refresh] [--dest DIR] + [--refresh] [--dest DIR] [--pixel-font] validate [--strict] [--base auto|fixture|imported] lint pack [-o out.modpkg] @@ -1475,6 +1475,17 @@ 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 -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. @@ -1567,6 +1578,14 @@ 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 @@ -1665,7 +1684,13 @@ Nothing is translated yet: {{total}} strings are waiting in `lang/`. - `assets/font/` - your glyph sheet ''' -FONT_README = '''Put your glyph sheet here. +FONT_README = '''You may not need this directory at all: scaffold with +`--pixel-font` (or uncomment the `register("ttf", {})` line in main.lua) +and text renders through the engine's bundled Plain Pixel TTF, which +covers Latin, kana and CJK out of the box. A glyph sheet is only for a +translation that wants the hand-drawn GB look. + +Put your glyph sheet here. A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes run left to right and top to bottom starting at the page's `base`, so the @@ -1761,6 +1786,7 @@ def cmd_translation(args, repo): "{{extra}}": "", "{{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), @@ -1999,6 +2025,9 @@ 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")