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/new-features.md b/docs/new-features.md index 02aab6ec..c3c901e3 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -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/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 9808556a..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 @@ -926,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 @@ -969,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 @@ -997,8 +1013,13 @@ function ScrollManager:update(dt) -- 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) * (dt or 0.016)) + 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 @@ -1028,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 b45ebfe7..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): 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) 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/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 c086c113..9654029a 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -27,7 +27,7 @@ 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 ("Enemy " .. b.name) + 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 } diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 94506d86..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, @@ -1019,24 +1071,33 @@ local function buildModsPanel(imp, parent, m) 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, }) @@ -1107,6 +1168,16 @@ local function buildModsPanel(imp, parent, m) }) 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) @@ -1129,7 +1200,10 @@ local function buildModsPanel(imp, parent, m) 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 @@ -1302,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 @@ -1322,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 429ceb3e..749b5b9f 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2611,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) @@ -2986,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/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/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/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/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")