mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
Binary file not shown.
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
*.gb
|
||||
*.sym
|
||||
__pycache__/
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Executable
+246
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user