CLOSES #779 , CLOSES #743 + new font

This commit is contained in:
bryanthaboi
2026-08-04 06:41:34 -04:00
parent fe139ca7f4
commit 3a6557ffe2
30 changed files with 1214 additions and 79 deletions
Binary file not shown.
+13
View File
@@ -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.
+13
View File
@@ -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 `<PK>`-style
macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas
Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters,
11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on
the registry entry: `file` for a mod-shipped TTF, `size` (the font's
design em; Plain Pixel rasterizes cleanly only at multiples of 15),
`spacing` added to every advance, `yOffset` for vertical alignment
against the 8px cell grid, 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)
+3 -1
View File
@@ -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 },
+18 -2
View File
@@ -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
+54 -15
View File
@@ -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
@@ -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
-- --------------------------------------------------------------------------
+8 -3
View File
@@ -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 <file>" for whichever
# entry happened to match first (see the same fix in pack_love.sh).
LOVE_LISTING="$WORK/love-listing.txt"
unzip -Z1 "$LOVE_FILE" > "$LOVE_LISTING"
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' "$LOVE_LISTING"; then
fail "game.love unexpectedly contains generated ROM data"
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)"
+5 -1
View File
@@ -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
+8 -5
View File
@@ -380,18 +380,21 @@ local CHARGE_TEXT = {
-- pokered's <USER>/<TARGET> text macros (home/text.asm
-- PlaceMoveUsersName): battle texts naming the enemy mon print
-- "Enemy " before the nickname; player-side mons never get it.
-- Translatable as one "Enemy %s" template (#779) so languages that
-- qualify after the name, or decline, can (e.g. "%s ennemi").
local function displayName(b)
return b.isPlayer and b.name or ("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)
+1 -1
View File
@@ -29,7 +29,7 @@ end
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
-- mon's nickname (home/text.asm PlaceMoveUsersName)
local function displayName(b)
return b.isPlayer and b.name or ("Enemy " .. b.name)
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
end
EffectRegistry.displayName = displayName
+1 -1
View File
@@ -23,7 +23,7 @@ local MoveEffects = {}
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the
-- enemy mon's nickname (home/text.asm PlaceMoveUsersName)
local function displayName(b)
return b.isPlayer and b.name or ("Enemy " .. b.name)
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
end
local STAT_LABEL = {
+1 -1
View File
@@ -12,7 +12,7 @@ local StatusRegistry = {}
-- pokered's <USER>/<TARGET> text macros (home/text.asm
-- PlaceMoveUsersName): enemy-mon texts print "Enemy " before the name
local function displayName(b)
return b.isPlayer and b.name or ("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),
+1 -1
View File
@@ -27,7 +27,7 @@ local TrainerAI = {}
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the
-- enemy mon's nickname (home/text.asm PlaceMoveUsersName)
local function displayName(b)
return b.isPlayer and b.name or ("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 }
+115 -34
View File
@@ -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,
+15 -2
View File
@@ -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()
+20
View File
@@ -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
+136 -1
View File
@@ -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 (<PK>, the 'd ligatures)
-- and the sub-0x80 chrome glyphs (borders, arrows) keep their tiles, which
-- is why the box border never depends on the TTF's coverage.
local Assets = require("src.render.Assets")
@@ -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, <PK> 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
+6 -2
View File
@@ -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
+83
View File
@@ -0,0 +1,83 @@
-- Driver: TTF text mode through the bundled Plain Pixel font. Teleports
-- straight into the overworld (no intro) and shows one dialogue line per
-- language back to back, shooting each: lang_english.png, lang_french.png,
-- lang_german.png, lang_spanish.png, lang_russian.png, lang_japanese.png,
-- lang_chinese.png in SHOT_DIR. Judge glyph coverage, spacing, baseline,
-- and that the tile box border survives every one of them.
-- POKEPORT_DRIVER=tests/drivers/plainpixel_font_test.lua \
-- POKEPORT_IDENTITY=ttfdemo POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Font = require("src.render.Font")
local TextBox = require("src.render.TextBox")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- preconditions -----------------------------------------------------
check("the bundled TTF is present",
love.filesystem.getInfo(Font.PLAINPIXEL) ~= nil)
local okLoad, ttf = pcall(love.graphics.newFont, Font.PLAINPIXEL,
Font.PLAINPIXEL_SIZE)
check("real LOVE can load it", okLoad and ttf ~= nil)
game.save.player.name = "bryan"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
-- what a translation's register("ttf", {}) merges to
game.data.font.ttf = {}
Font.invalidate()
check("ttf active after the merge", Font.ttfActive())
local LINES = {
{ "english", "The quick brown fox\njumps over the lazy dog!" },
{ "french", "Un \195\169clair enveloppe le\nPOK\195\169MON sauvage! \195\135a alors!" },
{ "german", "Gr\195\182\195\159e und Gewicht des\nPOK\195\169MON \195\188berpr\195\188ft!" },
{ "spanish", "\194\161El POK\195\169MON enemigo\nest\195\161 paralizado! \194\191Y ahora?" },
{ "russian", "\208\148\208\184\208\186\208\184\208\185 "
.. "\208\159\208\158\208\154\208\149\208\156\208\158\208\157 "
.. "\208\191\208\190\209\143\208\178\208\184\208\187\209\129\209\143!" },
{ "japanese", "\227\129\147\227\130\147\227\129\171\227\129\161\227\129\175! "
.. "\227\131\157\227\130\177\227\131\162\227\131\179\227\129\174\n"
.. "\227\129\155\227\129\139\227\129\132\227\129\184 "
.. "\227\130\136\227\129\134\227\129\147\227\129\157!" },
-- NB: Plain Pixel 0.009's CJK block is partial (e.g. U+62D3, U+5922
-- draw as tofu); stick to characters it covers
{ "chinese", "\228\189\160\229\165\189! \230\136\145\230\152\175"
.. "\229\164\167\229\178\169\229\141\154\229\163\171!" },
}
for _, entry in ipairs(LINES) do
local name, text = entry[1], entry[2]
local done = false
local box = TextBox.new(game, text, function() done = true end)
game.stack:push(box)
-- let the typewriter finish the page before shooting it
for _ = 1, 300 do
if box.waiting or box.done then break end
U.wait(1)
end
U.wait(5)
U.shot(game, DIR .. "/lang_" .. name .. ".png")
for _ = 1, 20 do
if done then break end
U.tap(game, "a")
U.wait(10)
end
U.wait(5)
check("dismissed " .. name, done)
end
game.data.font.ttf = nil
Font.invalidate()
check("clearing the entry restores tile mode", not Font.ttfActive())
U.log("shots in", DIR)
U.log("DONE")
love.event.quit(0)
end
@@ -0,0 +1,63 @@
-- Wheel scrolling must survive the launcher's immediate-mode frame order:
-- FlexLove.update(dt) runs in love.update, but beginFrame (in draw) resets
-- the accumulated dt to 0 BEFORE endFrame updates the elements, so
-- ScrollManager:update always receives dt = 0 there. The smooth-scroll
-- interpolation must still advance (it once used a fixed per-frame
-- fraction; the dt-aware blend has to treat dt <= 0 as one nominal 60 Hz
-- step, or every launcher list stops responding to the wheel entirely).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
love.window.getMode = function() return 1024, 768, { fullscreen = false } end
love.window.getDesktopDimensions = function() return 1920, 1080 end
love.mouse = love.mouse or {}
love.mouse.getPosition = function() return 100, 100 end
love.mouse.isDown = love.mouse.isDown or function() return false end
package.loaded["libs.flexlove.modules.UTF8"] = {
char = string.char, charpattern = ".", codepoint = string.byte,
len = string.len, offset = function(_, n) return n end,
codes = function(s)
local i = 0
return function() i = i + 1; if i <= #s then return i, s:byte(i) end end
end,
}
local T = require("tests.modkit")
local FlexLove = require("libs.flexlove.FlexLove")
FlexLove.init({
immediateMode = true,
performanceMonitoring = false,
keyboardNavigation = false,
})
-- One launcher-shaped frame: update BEFORE beginFrame, exactly like
-- LauncherView.update / draw, so endFrame sees _accumulatedDt == 0.
local function frame(wheelAfterBuild)
FlexLove.update(1 / 60)
FlexLove.beginFrame()
local scroller = FlexLove.new({
id = "scroller", x = 0, y = 0, width = 400, height = 300,
overflowY = "scroll", hideScrollbars = true,
smoothScrollEnabled = true, scrollSpeed = 60,
positioning = "flex", flexDirection = "vertical",
})
for i = 1, 30 do
FlexLove.new({ parent = scroller, id = "row" .. i, width = 380, height = 40 })
end
FlexLove.endFrame()
if wheelAfterBuild then FlexLove.wheelmoved(0, -1) end
local sm = scroller._scrollManager
return sm and sm._scrollY or nil, sm and sm._targetScrollY or nil
end
for _ = 1, 3 do frame(false) end
local y0, t0 = frame(true)
T.eq(t0, 60, "one wheel notch targets one scrollSpeed step")
T.eq(y0, 0, "the notch lands on the frame after input, not the same one")
local y = y0
for _ = 1, 5 do y = frame(false) end
T.check(y ~= nil and y > 30, "smooth scroll advances past halfway within 5 dt=0 frames")
for _ = 1, 60 do y = frame(false) end
T.check(y ~= nil and math.abs(y - 60) < 1, "and converges on the target")
T.finish("flexlove wheel scroll at dt=0")
+4 -2
View File
@@ -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")
+183
View File
@@ -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 (<PK> macros, the 'd ligatures) and the sub-0x80
-- box chrome keep their tiles, so borders never depend on TTF coverage;
-- * advances come from the font's metrics (5px Latin, 11px double-width
-- kana/CJK in Plain Pixel), which TextBox's pixel-budget paginate and
-- its per-glyph draw pen both honor;
-- * the "ttf" id is a legal font-registry entry and merges to
-- data.font.ttf, rebuilt each merge so disabling the mod disables it.
--
-- luajit tests/engine/ttf_font_mode.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Font = require("src.render.Font")
local BASE = Font.TTF_BASE
-- a charmap with a single-char entry, a multi-byte single char, and a
-- two-char ligature: the three shapes split() has to tell apart
local CHARMAP = {
{ code = 0x80, seq = "A" },
{ code = 0xBA, seq = "\195\169" }, -- é
{ code = 0xD0, seq = "'d" },
}
-- ------------------------------------------------- vanilla stays vanilla
Font.load({ font = { charmap = CHARMAP } })
T.check(not Font.ttfActive(), "no ttf entry means tile mode")
T.eq(Font.encode("A")[1], 0x80, "charmap keeps mapping to tiles")
T.eq(Font.advanceOf(0x80), 8, "and the pen stays 8px monospace")
-- ------------------------------------------------- the ttf takes over
Font.load({ font = { charmap = CHARMAP, ttf = {} } })
T.check(Font.ttfActive(), "an empty ttf table loads the bundled font")
T.eq(Font.encode("A")[1], BASE + 65,
"a single character routes to the TTF even with a charmap entry")
T.eq(Font.encode("\195\169")[1], BASE + 0xE9,
"a multi-byte single character does too")
T.eq(Font.encode("'d")[1], 0xD0,
"a multi-character ligature keeps its tile glyph")
T.eq(#Font.encode("'d"), 1, "and still consumes the whole sequence")
T.eq(Font.encode("\227\129\130")[1], BASE + 0x3042,
"kana with no charmap entry at all still gets a glyph")
-- 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")
+28 -3
View File
@@ -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,
+32
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
*.gb
*.sym
__pycache__/
+21
View File
@@ -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.
+97
View File
@@ -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`.
+246
View File
@@ -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())
+31 -2
View File
@@ -7,7 +7,7 @@ Subcommands:
scaffold <id> [--profile content|overhaul|total_conversion] [--api 2]
[--github owner/repo] [--experimental] [--dest DIR] [--force]
translation <id> [--language NAME] [--base auto|fixture|imported]
[--refresh] [--dest DIR]
[--refresh] [--dest DIR] [--pixel-font]
validate <id|path> [--strict] [--base auto|fixture|imported]
lint <id|path>
pack <mod-dir> [-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 <PK>-style macros keep their tiles. Options:
-- { file = mod.assets:path("myfont.ttf"), size = 15, spacing = 0,
-- yOffset = -6, bold = true } -- size is the font's design em (Plain
-- Pixel only rasterizes cleanly at multiples of 15), bold thickens a
-- 1px-stroke font that reads too light.
{{ttf_register}}mod.content.font:register("ttf", {})
-- Register the sheet BEFORE anything asks for a glyph on it. base is
-- the first code the page owns; 0x100 and up is free space above the
-- vanilla pages, so a new alphabet never collides with them.
@@ -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")