The slider is shared, and that is deliberate -- the two upscalers want the
same thing from the user and a second control would only let them disagree.
But the NUMBER means different things to each: RCAS stops for FSR1, a 0..2
edge-sharpness factor for SGSR. Labelling it "FSR Sharpness" while SGSR is
selected says the setting belongs to something that is not running.
It now names whichever is selected, in both pickers.
The upscaler control exists TWICE -- once in the Renderer settings tab and
once in the in-game menu -- and only the first one learned about SGSR, so the
option was unreachable from the place people actually change these mid-game.
Both are pickers now rather than an FSR1 on/off switch. The in-game one was a
MenuSwitchRow, which was fine while FSR1 was the only upscaler and is the
wrong shape for three mutually exclusive ones.
The settings search index pointed at renderer.fsr1.label, which is no longer
a row anywhere; searching for it would have landed on nothing.
A third display upscaler beside FSR1, and the first one written for the
hardware this app actually runs on: FSR1's two compute passes are the
expensive outlier on mobile, while SGSR is a single pass Qualcomm designed
for Adreno.
The filter is Qualcomm's, BSD-3-Clause, unchanged in substance. What differs
is the shape around it -- theirs is a fragment shader over a fullscreen
triangle, this is a compute pass, because that is what GSDevice already knows
how to schedule. So the interpolated texcoord becomes a UV computed from the
invocation id and the fragment output becomes an imageStore. The crop
handling and the widened 0..2 sharpness range come from the Eden Emulator
Project's port (GPL-3.0-or-later, compatible with PCSX2's GPL-3.0+); PCSX2
hands the pass a display rectangle inside a larger target, which is the same
problem FsrEasuConOffset solves for FSR1.
Deliberately a strict subset of what FSR1 already requires -- same descriptor
types, same rgba8 storage image, textureGather with a constant component and
no offset, which is core Vulkan 1.0 and needs no optional feature. So any
driver already running FSR1 can run this, Turnip included, and there is no
vendor gate on either. A driver that cannot compile the pipeline clears
Features().sgsr and the renderer falls back to plain bilinear with an OSD
notice, rather than failing.
The Android upscaler control becomes a picker rather than an on/off toggle:
three mutually exclusive upscalers expressed as two toggles that silently
switch each other off is a worse way to say it than one list. FSR1 and SGSR
share the existing sharpness slider -- the number means different things to
each, but it is the same intent, and a second slider would only invite the
two to disagree.
★ The Settings.kt clamp on the persisted enum was still bounded at
UPSCALER_FSR1, and would have silently rewritten any SGSR selection back to
Off. That clamp's own comment warns about exactly this failure; it still had
to be updated by hand. Worth remembering the next time the enum grows.
Suggested by CamilleLaVey, who authored the upstream changes (eden-emu #4293).
Float.MIN_VALUE is the smallest POSITIVE float in Kotlin and Java (1.4e-45);
the most negative is -Float.MAX_VALUE. The overlay hides a figure by testing
`value > ARMSX2_THERMAL_NONE` against -1000.0f, so the sentinel sailed
through as a real reading and printed as "0°".
Both sides are -1000.0f now. Every use is an exact assignment or comparison
against the constant, and the plausibility gate (-20..150) means a real
reading can never collide with it.
Worth noting when this was visible: never, on a device whose zones all
resolve, because the sentinel is then never sent. It shows up only on the
devices the fallback exists for -- a readable CPU zone and no GPU zone would
print "CPU 47° GPU 0°". Found by reading the code during the ARMSX3 port,
not by testing, which is the only way it could have been found.
Giving the cover cell the art's own 1.4 ratio at the column width produced
869px on a 1080px-tall panel: one tile taking most of the screen and shoving
everything below it off the bottom. The panel is landscape and the art is
portrait, so deriving the height from the width was the wrong axis entirely
-- it made the cell taller the WIDER the panel got.
FIT_CENTER already guarantees the whole cover is visible in a box of any
shape, so the box only has to be a reasonable size rather than the art's
shape. A third of the panel height reads as a cover without crowding out the
tiles people actually press, with a floor for very short displays, and the
tile-height slider still overrides it.
FIT_CENTER only helps when there is a box to fit into. The cover cell was
WRAP_CONTENT around an image, which gives the cell no definite height, so the
art sized itself and spilled past the cell to be clipped by the tile outline
-- the same cut-off cover, arrived at a different way. Box art is about 1.4
times as tall as it is wide, so the cell is now given that shape at the
column width and the whole cover fits inside it. adjustViewBounds went with
it; it fights a definite box rather than helping.
The achievement tile showed a hardcore count and a casual count above the
total, which reads as three unrelated numbers -- "0 next to the trophy, then
another 0/64" -- and says nothing about which mode is actually active, which
was the one thing it needed to say. It now names the mode, with the trophy
for hardcore and the medal for casual, over unlocked-of-total.
The cover was CENTER_CROP in a grid cell. Box art is portrait and a cell is
not, so filling the cell ate the top and bottom of the cover -- the parts
carrying the logo and the title. It fits inside the tile now and letterboxes
against the tile background.
The panel root could not scroll, which was survivable while the tile list was
short and stopped being so the moment four more tiles were added to it. The
panel has no say in its own height -- the user chooses how many tiles and how
tall each one is -- so anything that did not fit was simply clipped off the
bottom of the display with nothing to say it was there.
"12/40" did not say whether those were earned in hardcore or casual, which is
the distinction RetroAchievements cares most about. The counts now carry
marks for both. rc_client reports a per-achievement mask where a hardcore
unlock also sets the softcore bit, so the casual figure is the softcore-ONLY
count rather than the total -- otherwise every hardcore unlock would be
counted twice.
The first migration only upgraded a layout that was byte-for-byte the old
default, reasoning that anything else was a deliberate arrangement to leave
alone. That fails for the ordinary case: toggle one tile on and off and the
layout is no longer the default, so someone who had barely touched it never
saw the new tiles at all. Which is exactly what happened -- a panel that had
been poked at during testing kept its old contents and looked like the
feature had not shipped.
Now version-stamped and additive: whatever the user arranged stays arranged,
anything missing from the new set is appended, and the stamp means it runs
exactly once -- so removing a tile afterwards sticks.
Shipping them as chips nobody would find was the wrong call -- a tile that
has to be discovered in a settings screen may as well not exist. Cover,
Achievements, RA points and rich presence are now part of the default panel.
A saved layout still wins, because that is what saving it is for. But a
layout that is byte-for-byte the OLD default is not a choice anyone made,
it is just what they were handed, so those upgrade once to the new default.
Anything actually arranged is left alone.
The cover tile also gets a visible fallback. With no game, or before the art
downloads, it was an empty box -- and an empty box is indistinguishable from
a broken tile. It now shows "Cover" with no game and the game's title once
there is one, with the picture drawn over the top when it arrives. A custom
cover file that cannot be decoded falls through to the fetched URL instead of
leaving the tile blank.
getAchievementsJSON is VM-scoped -- the JNI entry calls
Achievements::GetAchievementsAsJSON() with no guard at all, unlike the rich
presence getter directly below it which checks HasRichPresence() first. With
no game loaded it dereferences null and takes the process down.
The previous commit hoisted the unlock tracking out of the Achievements
tile's text builder so the new Latest-unlock tile would work on its own. That
was right, but it moved the call onto the panel tick, which runs whether or
not a game is running -- so the panel crashed the app the moment it appeared,
on launch, before anything could be touched. Every other caller of this
getter is inside a VM-scoped context by construction; this one was not.
Now polled only with a live VM, and the read-outs fall back to a dash
otherwise. runCatching was never going to help: a SIGSEGV is not a Throwable.
The native getter is still a landmine for any future caller and should get
the same guard its neighbour has.
Cover art tile. The panel had the game as a line of text, and on a screen
sitting beside you the cover is what makes it read as "this game" at a
glance. It resolves through the same two sources the library uses -- a
user-set custom cover first, then the fetched URL -- so the panel shows what
the library shows, including a cover picked by hand. The only tile that is a
picture rather than text, and it re-resolves on the tick keyed on the game,
so switching game changes it without rebuilding the panel.
Three more RetroAchievements read-outs: points earned against the total,
which is the figure RA itself leads with; the latest unlock on its own; and
the rich presence line, which is the one thing here that says something a
number cannot.
Two things fixed while adding them. The unlock tracking lived inside the
Achievements tile's own text builder, so the new Latest-unlock tile would
have read "—" forever unless the Achievements tile happened to be placed as
well -- it is now a per-tick step that runs regardless of which tiles exist.
And the achievements JSON was parsed per tile, so placing all three would
have parsed the same string three times a tick for identical results; it is
parsed once now and shared.
The panel reads its colours when it BUILDS its views, so publishing a new
scheme changed nothing for a panel that was already open -- it kept the
colours it was born with, which is why switching theme appeared to do
nothing to it. It now rebuilds when the theme changes, keyed on the
user-facing switches rather than on the scheme object: the RGB mode produces
a new scheme every hue step, and tearing the panel down and back up sixty
times a cycle would be absurd.
The editor's auto-dock addressed the wrong half of the problem. Moving the
panel out of the way once a widget is selected cannot help you SELECT a
widget under the panel, because the obstruction happens before there is
anything to react to. The grip row now has a collapse toggle: one tap leaves
just the grip and uncovers everything beneath it, one tap brings the controls
back. Not persisted, and reset on leaving the editor -- it is a momentary
"let me see under this", and opening the editor to a panel with no controls
on it would look broken. Auto-dock stays; it still helps once a widget is
picked.
Device temperatures on the overlay now default ON, confirmed reading real
values. It sits with CPU/GPU load, the poll is one file read every couple of
seconds, and a device with no readable zone shows nothing rather than
something wrong.
Custom panel background. Theme / library / black covered three of the four
asks; "an own background" needed a picker. Takes the persistable read grant
like the library's own picker -- without it the URI works until the process
restarts and then resolves to nothing, which reads as the background
disappearing on its own. Darkened by the same scrim as the library backdrop,
because an arbitrary photo has no obligation to be dark and tile text still
has to be readable.
Clock and battery move into a status bar across the top instead of being two
grid cells. Same information, but it stops the clock competing for space with
the things you actually press, and the grid gets two cells back.
The rest of the in-game OSD's figures reach the panel: VPS, EE / GS / GPU
load and frame time. These were not missing by choice -- getFPS() was the
only figure with a way across the JNI boundary, so the panel could show
frames and a percentage of nominal and nothing else. PerformanceMetrics
already computed all of it for the overlay. Each getter returns 0 with no VM
rather than the last value, so an idle panel reads as idle instead of frozen
on whatever the last game was doing.
Tile height is now settable. Columns already decided width -- tiles split the
row equally, so choosing columns IS choosing width, and a second width
control would only be a way to disagree with it. Height had no control at
all, which is why a panel could only ever be as tall as its text.
A display can be told to stay out of it. "The second screen also still
appears on the external monitor when connected via usbc" is not a bug by the
display-picking rule -- a USB-C monitor is a perfectly good second display --
so this records a preference instead of guessing: the panel's own Not-this-
screen tile drops the display it is on, and settings can re-enable them.
Keyed by display NAME, since ids are reassigned across replugs. Guessing from
internal-vs-external would have been wrong anyway; Android has no stable
public display type before API 34.
Device temperatures on the performance overlay, which is where they were
asked for. The core cannot read a temperature -- there is no portable API,
and on Android the only route is a vendor-specific sysfs the app layer
already discovers for the panel -- so the app pushes the values in and the
overlay draws what it was given. Atomics because the writer is a UI-thread
poll and the reader is the GS thread. A sensor that could not be read is
omitted rather than drawn as a zero.
The editor panel was a floating window pinned to the top of the screen, so
every button underneath it had to be uncovered by hand before it could be
touched. Dragging it was not an occasional adjustment, it was the price of
editing anything in the top half of the layout.
It now docks to the opposite half from whatever is selected: pick a button up
top and the panel goes to the bottom, pick one at the bottom and it returns.
Halves rather than real overlap maths, deliberately -- a panel that darts
around as rectangles graze each other is less predictable than one that is
simply never on the same side as the thing you are working on.
Manual dragging stays for anything unusual. The stored offset means "away
from the anchored edge" and so flips with the anchor; without that, a panel
the user had nudged down would be nudged straight off the bottom of the
screen the moment it docked there.
The panel carried a hand-written palette of neutral greys, on the reasoning
that a Presentation sits outside the Compose tree and reading MaterialTheme
from a plain View would mean holding a composition alive for six colours. The
reasoning was right and the conclusion was not: ARMSX2's night theme is BLUE,
so grey was not a neutral choice, it was a different app on the second
screen. That is what "still looks quite unpleasant... more like stock android
instead of armsx2" was describing.
Armsx2Theme now publishes the RESOLVED scheme for code that cannot be a
composable, and the panel reads that. No composition is held and there is no
second copy of the theme logic to drift, so the panel follows Blue, Purple,
OLED, Custom, Material You and the animated RGB mode without knowing any of
them exist.
Action tiles get a glyph over the label, because a tile has to be recognised
from across a desk. Geometric Unicode rather than emoji: emoji bring their
own colours and their own house style, which is the stock-Android look this
is moving away from, while a glyph takes the accent like everything else.
The two tiles that carry state SWAP their glyph rather than appending a line
-- Pause shows what the tap will do, and Fast Forward no longer grows when
you use it, which was half of the ragged-row problem.
Panel background is now a choice: the theme's own ground, the library's
backdrop darkened so it reads as the same app as the screen beside it, or
solid black for an OLED second display.
CPU, GPU and battery temperature tiles, asked for by two people. Android has
no supported API -- HardwarePropertiesManager is signature-gated -- so this
reads the thermal sysfs, which is permissionless but is not a contract: zone
count, naming and even the UNIT are vendor-specific. Zones are discovered
once by name, the unit is inferred by magnitude (no phone runs at 1000C and
none idles at 0.045C, so the ranges cannot overlap), implausible values are
dropped rather than displayed, and a device that exposes nothing shows a dash
instead of a wrong number. Polling is on its own interval, 1 to 5 seconds --
that interval is the mitigation asked about, and it is why the panel tick can
call it every frame for free.
Pause did not stick. The stuck-paused backstop resumes a VM that is paused
with nothing covering the screen, reasoning that such a state can only be a
lost resume. That held while every pause came with a frontend over it --
pauseForOverlay() is what the quick menu, the library and backgrounding all
use. The second screen's Pause tile is the only caller of plain pause(), and
it deliberately leaves the game on screen, so the backstop undid it 700ms
later. That is the "pause immediately unpauses itself" two people reported.
A pause the user asked for is now marked as such and the backstop leaves it
alone; resume() clears the mark.
Tiles were different heights. An active tile appends a state line ("\n❚❚" on
Pause, likewise Fast Forward), and with maxLines alone a tile grew the moment
you used it, so the row went ragged -- which is what made Fast Forward the
one people noticed. Every tile now reserves both lines whether or not it is
showing state, which also scales with the text size instead of a fixed
height, and row children stretch to the tallest so a Button's padding cannot
show as a ragged edge against a TextView's.
On-screen buttons get rapid-fire (#619). Physical buttons have had turbo and
the on-screen ones never did, which is the asymmetry the request was about --
and it is the same asymmetry tap-to-hold had in the other direction. Per
button, off by default, cycled from the editor toolbar. It runs on the macro
Frequency timer rather than a second one of its own, so it inherits the
sampling floor that stops the fastest settings from emitting presses the VM
never samples, and it composes with tap-to-hold: set both and a tap starts
the autofire and the next tap stops it.
Feature requested by shinobumaehara (#619)
connect() resolves the Discord app through
DiscordSocialSdkInit.getEngineActivity() and hands the result straight to
Context.getPackageManager() with no null check. That static was only ever
set by DiscordAuthActivity, which runs during sign-in -- so every launch
with a cached token started the SDK with it null, and :discord died with an
NPE. Android restarted the service, which started again and died again. The
app process never saw any of it, because the protocol is poll-only: the UI
just sat on "Connecting" forever.
Binding the SDK only during sign-in was wrong independently of that. The
SDK's statics are per-process and Android restarts :discord whenever it
likes, so the binding has to be re-established on demand rather than assumed
to survive.
Handing the Activity over is now separate from opening the browser:
DiscordAuthActivity takes an EXTRA_AUTHORIZE flag and always binds, and the
service will not call start() until a binding exists, launching the
invisible Activity itself when there is none.
Follow-up to 7aa4547e60. Four ports -- flux, plasma, solarwinds and
hyperspace -- still opened port_free with "if (!g_started) return;". That is
unreachable today, since the JNI only calls destroy after a successful
create, but unreachability is a property of the current call graph rather
than of the function, and it contradicts the rule the fix established: gl1
is given back on every path out, because whatever it holds belongs to an
EGL context that is about to die. All six are now unconditional.
Same argument one level up. port_new opened with "if (g_started) return 1",
which was defensible when gl1's state was whatever the last saver left, but
nativeInit now calls gl1_lost() first -- so gl1 is guaranteed DOWN on entry
and reporting success there would hand the caller a saver with no shim under
it. A stale run is torn down instead, and gl1_init always runs.
No behaviour change on any path reachable today. The point is that a saver
added later, or an upstream cleanup that returns early, fails in its own
saver instead of poisoning the next one.
The on-screen controls have had tap-to-hold since they existed; physical
buttons always followed the button exactly. A game that wants one held while
another control is worked -- MGS2 holding R1 to aim -- is then unplayable for
anyone who cannot hold two controls at once, which is what the request was
about.
Modelled as a transform on the event stream rather than a branch beside
turbo: a tap becomes a synthetic KeyDown, the next tap a synthetic KeyUp,
and everything between is swallowed. Turbo composes with it for free -- flag
a button both and a tap toggles autofire on and off.
Keyed on the physical code for "is this a fresh press", because ACTION_DOWN
auto-repeats while a key is held and each repeat would otherwise toggle, and
on the PS2 target for "is it latched", so two physical buttons bound to the
same button cannot desync.
The state lives in the companion because the boot path that clears it runs
there -- a latch must not outlive the game it was set in -- while the
dispatch that sets it is an instance method. Changing the setting releases
whatever is held: turning it off for a button that is latched down would
otherwise strand it pressed, with no second tap left to release it.
Stored per action per player like turbo, and off by default.
Feature requested by bobo123g (#612)
A saver that dies natively made ARMSX2 unlaunchable. The choice is a
persisted pref read on the library screen -- the first screen -- so the
crash repeated on every launch and Settings was never reachable to turn it
off. The only escape was clearing app data, which takes memory cards and
save states with it. A user lost their saves that way.
Cause: gl1's state is a file-scope global holding GL object names, and
gl1_init() early-returns on g.ready. Skyrocket and Lattice defer initSaver()
to port_resize, so a create-then-teardown with no surface size left
g_started false and their port_free returned BEFORE gl1_shutdown(); flux,
plasma and solarwinds leaked it the same way when initSaver() left
readyToDraw clear, since returning 0 means port_free is never called. Either
way g.ready stayed set with names from a destroyed EGL context, and the next
saver -- new view, new context -- drew against them. Drivers answer that
with anything from a black screen to a segfault.
Each port now gives gl1 back on every path out, and nativeInit calls
gl1_lost() as the invariant: a new context never inherits old GL names.
Contained separately, because native GL can always find a new way to die:
the setting arms itself with a synchronous commit() before the render thread
starts and disarms when that thread exits in an orderly way. Still armed at
startup means the last run died with a saver up, so the background switches
off and the user is told which one. runCatching was never going to catch a
SIGSEGV.
Also guards Thread.start(): it asks for a 16MB stack (Skyrocket declares a
3MB starmap as a local) and an OutOfMemoryError there is an uncaught throw
on the main thread -- the same lockout with no native crash involved.
It fired on its very first run against an APK that DID contain the library. `unzip -l | grep -q`
under `set -o pipefail` is a false-failure generator: grep exits on the first match, SIGPIPEs
unzip, and pipefail then reports the pipeline as failed. Capture the listing and match it with
`case` instead, which is what the existing notes on this already say to do.
2.6.6.8 shipped without Discord and it was only noticed after publication. The SDK is resolved
from $DISCORD_SDK_DIR at configure time and gated on include/discordpp.h existing, so with the
variable unset the build quietly omits it -- no error, no warning, nothing in the log to read
afterwards. Every check that already runs on these artifacts (both cores, alignment, signing,
package, MANAGE_EXTERNAL_STORAGE) would have caught this class of mistake if one had existed.
Both scripts now verify libdiscord_partner_sdk.so in the output. With DISCORD_SDK_DIR set and
the library absent, that is FATAL -- it means the staged directory was wrong, which is easy to
get wrong given the raw SDK download ships an x86-64 .so and only the .aar carries arm64. With
the variable unset it warns loudly instead of failing, because a Discord-less build is still a
legitimate thing to produce on purpose.