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.
Every Android build has failed since the screensavers landed in 81d0a3e510.
The workflow still reported success, because the Android job is
continue-on-error, so nothing said so and no APK has been uploaded since.
shockwave.cpp asked for <gl/gl.h>. The vendored header is compat/GL/gl.h,
with an uppercase directory. macOS resolves that spelling and the Linux
runner does not, so the build dies on "fatal error: 'gl/gl.h' file not
found".
It is the only miscased include in the target. All 742 includes under
savers/ were checked against real directory entries rather than by testing
whether files exist, since a case-insensitive filesystem answers yes to
both spellings and would have hidden exactly this.
The line is also redundant. shockwave.cpp is never compiled on its own:
unit_shockwave.cpp wraps it in a namespace and already includes <GL/gl.h>
above it, so the corrected line hits the header guard and expands to
nothing. Its five sibling files carry no GL include at all and rely on the
wrapper, so deleting it would match them. These savers are vendored GPL
sources kept close to upstream, so this changes one character instead.
What this does not establish is whether the build then finishes. The error
has masked everything after it for days, so a second one may be waiting.
Nightly and MoonStore builds identify themselves to RetroAchievements as
stock PCSX2, so the server allows softcore only. Hardcore has worked on
locally built IPAs and nowhere else.
Nothing was broken. pcsx2/Host.cpp reads the client version from
ra_ua_secret.h behind __has_include and falls back to a stock agent when
the macro is absent. That header is gitignored, so it has never existed on
a runner.
CI now writes it from the IOS_RA_UA_VERSION repository secret, the way the
Android job already writes its keystores from NIGHTLY_RELEASE_KS_B64. A
missing secret warns and continues, because fork pull requests never
receive secrets and a nightly that fails to publish is worse than one
without hardcore. A malformed version fails the build instead:
RetroAchievements refuses a version it cannot order, and a refused agent is
indistinguishable from an unknown one on the client.
Only the nightly and pushes to master embed it. Pull request artifacts keep
the stock agent, so a test build is not one more public copy of an identity
that needs a release to revoke.
A second step reads the finished binary and fails unless the exact version
is in it, trailing space included, since a stale 1.2.3 is a prefix of a
current 1.2.345. Both iOS jobs are continue-on-error, so that failure does
not stop the workflow, but it does skip the upload that follows it, and the
publish step treats a missing IPA as absent rather than fatal. A softcore
build is not shipped.
None of this makes the version private. The compiler bakes the finished
agent into the binary as a plain literal, so anyone holding a build can
read it out. What the gitignored header prevents is a fork inheriting a
live identity straight from source.
All of it was instrumentation for the Persona 3 FES investigation, and that turned
out not to be a texture problem at all -- three named patch groups were failing to
enable, and the texture pack stopped matching because the mod they gate patches the
game's font data.
Removed: the TCPROBE cross-version probe in HashCacheKey::Create, the hit/miss
counters and their geometric reporter, the per-miss detail listing what the pack
holds for a TEX0, the pack filename samples at map load, the duplicate/shadowed
accounting, and the mtime sidecar the palette fallback needed.
Kept the pre-existing "N indexed for '<serial>' (scanned <dir>)" line, which
predates this and is genuinely useful: a zero there with a path that does not match
the user's pack folder is still the fastest read on a pack that does nothing.
The cache budget work is untouched.
"I enabled HostFS and it is still off", with the switch sitting on. His game INI
proves it: [Cheats] present but empty, and no [Patches] section at all -- the
enable-list write never ran.
toggleLocalCheat flips the switch optimistically, then bails early when
setBodyEnabled produced an identical body:
if (newBody == target.body) return // pushEnableList never reached
setBodyEnabled only comments or uncomments patch= lines, and community pnach files
ship UNCOMMENTED. So for exactly those files enabling is a no-op on the body, the
early return skips the enable list, and Patch.cpp applies a NAMED group only when
its name is in [Patches]/[Cheats] Enable -- unlabelled groups are the only ones
that auto-enable. Switch on, file already correct, patch never applied, nothing
anywhere saying so.
The two writes are independent. Rewrite the body only when it differs; always push
the enable list.
This is what actually blocked the Persona 3 FES mods. The HostFS loader group could
not be enabled at all, so the mod never loaded, and the texture pack keyed to the
modded font stopped matching as a consequence -- which is how it arrived as "my
texture mods broke".
"I enabled HostFS and it is still off." The Patch Manager reads the toggle back as
on, and the patch loader never sees it.
ReloadEnabledLists reads through LayeredSettingsInterface, which returns the FIRST
NON-EMPTY layer with LAYER_GAME ahead of LAYER_BASE. So the moment a game's INI
carries any [Patches] Enable entry, the base list is invisible. setEnabledPatches
targeted the game INI only when a VM was running -- opened from the library it had
nowhere to go but base, where a shadowing game layer swallowed it. The UI then
reads the value straight back, which is why it looks like it took.
For this reporter that patch is the HostFS loader their Persona 3 FES mod runs on,
so it stayed off through repeated attempts to switch it on, the mod never loaded,
and the texture pack keyed to the modded font kept missing.
Take the serial from the caller -- PatchManagerViewModel already resolves one via
bestSerial() -- and resolve the game INI by the same glob gameIniBeginWriteForSerial
uses. With no VM and no serial, or no INI for that serial yet, it still falls
through to base, which is correct for a game that has no per-game file at all.
This is the "wants a serial/CRC parameter" the old comment left as the real fix.
It was a one-time repair for lists poisoned by patches arming themselves, and it
has done more harm than the thing it repaired. It deleted every [Patches]/[Cheats]
Enable entry, not just the self-armed ones, so anyone who had turned a patch on
deliberately lost it silently on update -- and when the patch in question is the
HostFS loader a mod depends on, the mod stops loading, the texture pack keyed to
the modded data stops matching with it, and the whole thing presents as "my
texture mods broke". That cost eleven builds to chase from the wrong end.
Restricting it to lists that look auto-armed was the first attempt at a fix, and
it was still wrong: the threshold is a guess, and someone with thirty cheats
enabled on one game would have been wiped by it just the same. A narrower
silent-data-loss bug is not a fix.
Removing it outright instead, because its job is finished:
- It is a migration, and it has already run for everyone who updated in the past
month. What remains is a shrinking population and a permanent hazard.
- Nothing re-poisons a list now. 10f4f73fe8 removed the unconditional
syncAllEnableLists on Patch Manager open, stopped EnumeratePnachFiles falling
back to the bundled zip over on-disk files, made delete() drop names from the
enable list, and stopped D-pad Right arming the row under the cursor.
- Anyone still carrying a poisoned list can clear it themselves, because
disabling a patch sticks now. That was the original complaint.
Nothing writes these lists except the player, which is how it should have been.
Both existed to work around the wrong diagnosis. The Persona 3 FES mods were not
failing in the texture cache at all -- a PNACH mod was switched off by the
enable-list purge, and because PNACH mods patch game DATA the texture pack keyed
to the patched font stopped matching as a side effect.
The palette fallback substituted a different colour variant of a glyph when the
exact palette was missing. It raised the hit count but could not be right: the
palette IS the colour, so substituted art carries the wrong shade, and it never
addressed why the hashes moved. Gone; the replacement path behaves exactly as it
always did.
The one-time notice goes too, per jpolo1224: the purge no longer takes deliberate
lists, so nothing needs announcing going forward.
Kept: the diagnostics that actually found this -- files scanned versus indexed
versus shadowed, hit and miss counts, and the per-miss detail naming what the pack
holds for that texture. Those are what turned "mods do not apply" into a
measurement, and they cost nothing when nothing is wrong.
This is what broke Persona 3 FES mods, and it was never the texture cache.
10f4f73fe8 fixed a real bug -- opening the Patch Manager armed every uncommented
group of every on-disk .pnach -- and cleaned up the damage with a one-time purge
of [Patches]/[Cheats] Enable. The purge deleted every entry unconditionally,
including the ones people had turned on deliberately, so updating past 2.6.6
silently switched off everyone's patches and cheats.
It surfaces in the worst possible way. The reporter's mod is a PNACH,
94A82AAA_Modern_Persona_Switch_aem.pnach, and PNACH mods patch game DATA. With it
disabled the font glyphs in GS memory differ, so the texture pack keyed to the
patched font stops matching as well -- and the whole thing reads as "my texture
mods stopped applying". Their logs say it plainly: 18,791 patch lines applied on
2.6.6 against 76 now, same .pnach found in both.
Two changes:
- The purge now only fires on lists carrying the auto-armed signature.
syncAllEnableLists persisted every uncommented group across ~4000 bundled
files, so a poisoned list runs to hundreds of names; a hand-built one is a
handful. At or under 25 entries it is treated as deliberate and left alone.
- A one-time notice for the people v1/v2 already hit. Nothing can give them back
what was deleted, and they have no way to know it happened -- the .pnach is
still on disk and still found in the log, it is simply no longer enabled.
Saying so once is the only repair available.
The immediate fix for anyone affected is to re-enable their patches in Patches &
Cheats; the purge is one-shot and will not take them again.
A paletted replacement is keyed on TEX0 hash AND palette hash, so a pack only
applies while the game asks for a palette its packer happened to dump. Persona 3
FES lands exactly there: the pack carries several palette variants of each glyph
and the game asks for one that is not among them, so every glyph misses while the
unpaletted art around it replaces fine -- 189 hits against 323 misses, which is
why the scene looked right and the text did not. With this, 441 against 71, and
none of the remainder are palette mismatches.
Lowest palette hash, deliberately. It is arbitrary but STABLE: the same glyph
resolves to the same file on every draw and every run, so text renders in one
consistent colour. Choosing by file mtime was tried and was worse in a way worth
recording -- different glyphs won different variants and the text came out
multicoloured.
The colour can still be wrong, since the replacement image has the packer's
palette baked in and there is no recolouring it. That is the trade, and it is the
right way round: a mod that applies in the wrong shade beats one that does not
apply at all.
This does NOT explain why the pack matched on 2.6.6. That is a separate finding
and still open: the same glyph at the same address with the same palette hashes
differently now (2.6.6 asks 85076d2a533c0128, current asks f0576dc2f0bb17d5),
with TBP0, TBW, PSM, TW/TH, region and lod all identical between them. The bytes
in GS local memory differ, which is upstream of texture replacement entirely.
Both builds report identical TBP0, TBW, PSM, TW/TH, region and lod for the same
font glyph, and identical leading bytes -- yet different TEX0 hashes. Since
HashTextureLevel reads straight out of GS local memory, the bytes at that address
differ; the shipped hash folds the base level and every mip into one value, so it
cannot say which of them moved.
Recompute the base level alone for the probed textures and log it beside the
combined hash, plus the actual mip range rather than the bool the first probe
recorded. Base matching with the combined differing means the mip chain moved;
base differing means the glyph data itself did. Those are different bugs.
The two builds ask for disjoint TEX0 hashes for the same on-screen font -- nine
values on 2.6.6, eight now, no overlap, same palette hash, same PSMT4. Every
function feeding the hash is byte-identical between them, so what differs is the
DATA, and HashCacheKey records only the region's width and height: never where it
starts, never which address it came from, never whether mips were folded in.
Print exactly what the key discards -- TBP0, TBW, TW/TH, the full region rect
including its origin, whether lod was present, and the first bytes actually
hashed -- so two logs can be diffed instead of theorised about.