Steam renamed its cached art, so most covers were never found — and the banner that stood in blew the iOS tile open #265

Merged
enricobuehler merged 3 commits from worktree-steam-cover-capsule into main 2026-08-16 08:47:47 +00:00
4 changed files with 109 additions and 13 deletions
@@ -63,9 +63,10 @@ private extension Image {
/// Sequentially tries cover-art URLs over `loader` (so a paired client can reach the host's own
/// art proxy, not just public CDNs see `LibraryArtLoader`), advancing past any that fail to
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private`
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, and a fallback
/// banner (wide hero/header art, used when a title has no portrait capsule) is cropped to the same
/// tile rather than allowed to size it see the `Color.clear` in `body` for why that takes more
/// than a `.frame(maxWidth:)` and a `.clipped()`. Not `private`
/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art.
struct PosterImage: View {
let candidates: [URL]
@@ -84,9 +85,20 @@ struct PosterImage: View {
var body: some View {
Group {
if let image {
Image(platformImage: image)
.resizable()
.scaledToFill()
// `Color.clear` is what takes the proposed size; the art rides along as its
// overlay, where it can be DRAWN but never MEASURED. Handing the image the sizing
// role instead is what let a fallback banner escape the tile: `scaledToFill`
// reports a size that covers the proposal, and the flexible frame below clamps it
// to `.infinity` i.e. not at all. Measured offscreen, a 460×215 `header.jpg` in a
// 170pt grid column resolved the tile to 545×255 and overran its neighbours, while
// a 300×450 cover in the same chain came out correct which is why this only ever
// showed on the titles whose cover was missing.
Color.clear
.overlay {
Image(platformImage: image)
.resizable()
.scaledToFill()
}
.transition(.opacity)
} else if index < candidates.count {
ZStack { placeholder; ProgressView() }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-kit",
"version": "0.4.1",
"version": "0.4.2",
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
"type": "module",
"license": "MIT OR Apache-2.0",
+34 -6
View File
@@ -54,21 +54,44 @@ export const steamCdnUrl = (
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/${file}`;
};
/** Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper). */
/**
* Filenames Steam's local `librarycache` uses per kind, in preference order (2x is sharper).
*
* Two spellings per kind, because Steam renamed these assets and one cache holds both eras side by
* side — on a 779-app cache, 594 appids carry `header.jpg` and 122 carry `library_header.jpg`, and
* NO appid carries both. Same story for the cover: 46 appids have only `library_capsule.jpg`. The
* renamed files are the same assets, byte-for-byte the same shapes (cover 300×450, header 460×215),
* so which name wins is cosmetic — but knowing only one name loses the art outright.
*
* Missing the cover is the one that shows: the fallback is then the flat CDN URL, which 404s for
* anything Valve has re-hashed, so the client walks on to the header and draws a BANNER in a 2:3
* poster slot (Forza Horizon 6 / appid 2483190 is the reference case).
*/
const localFilenames = (kind: ArtKind): string[] =>
kind === "portrait"
? ["library_600x900_2x.jpg", "library_600x900.jpg"]
? ["library_600x900_2x.jpg", "library_600x900.jpg", "library_capsule.jpg"]
: kind === "hero"
? ["library_hero.jpg"]
: kind === "logo"
? ["logo.png"]
: // Steam's local cache names the header asset differently from the store CDN's
// `header.jpg` — this trips everyone once.
["library_header.jpg"];
// `header.jpg` — this trips everyone once. Newer entries use the CDN's name, so
// both belong here.
["library_header.jpg", "header.jpg"];
/**
* This kind's file under one Steam root's `appcache/librarycache/<appid>/<hash>/`, or `undefined`.
* Steam reuses one hash dir per asset version, so there is normally exactly one candidate.
* This kind's file under one Steam root's `appcache/librarycache/`, or `undefined`.
*
* Three layouts, all of them live in the same cache at the same time — a title's art is in exactly
* one of them, so all three have to be checked or its cover is simply not found:
*
* 1. `<appid>/<hash>/<name>` — per-asset-version hash dir. Steam reuses one hash dir per version,
* so there is normally exactly one candidate. Checked first: where a title has been re-fetched
* into this layout, this is the copy Steam itself is displaying.
* 2. `<appid>/<name>` — straight in the appid dir, and the MAJORITY case (623 of 779 appids on the
* reference cache). A hash-dir-only walk misses every one of them, which stayed invisible only
* because the flat CDN URL those titles fall back to still resolves for older appids.
* 3. `<appid>_<name>` flat in `librarycache/` — the oldest layout.
*/
export const findLocalArtFile = (
root: string,
@@ -82,6 +105,11 @@ export const findLocalArtFile = (
if (isFile(p)) return p;
}
}
// Layout 2: no hash dir, the asset sits directly in the appid dir.
for (const name of localFilenames(kind)) {
const p = path.join(base, name);
if (isFile(p)) return p;
}
// Older Steam wrote the files directly under `librarycache/` with the appid in the name.
for (const name of localFilenames(kind)) {
const flat = path.join(
+56
View File
@@ -280,6 +280,62 @@ describe("art locations", () => {
fs.rmSync(dir, { recursive: true, force: true });
});
test("finds a cover cached under Steam's newer `library_capsule` name", () => {
// The bug this pins: appid 2483190 (Forza Horizon 6) caches its 300×450 cover as
// `library_capsule.jpg`, the flat CDN URL for its `library_600x900.jpg` 404s, and the client
// therefore fell through to the header and drew a banner in a 2:3 poster slot.
const dir = tmp("art-capsule");
const hashDir = path.join(
dir,
"appcache",
"librarycache",
"2483190",
"711e",
);
fs.mkdirSync(hashDir, { recursive: true });
fs.writeFileSync(path.join(hashDir, "library_capsule.jpg"), "x");
expect(findLocalArtFile(dir, 2483190, "portrait")).toBe(
path.join(hashDir, "library_capsule.jpg"),
);
fs.rmSync(dir, { recursive: true, force: true });
});
test("finds art stored straight in the appid dir, with no hash dir", () => {
// The majority layout — 623 of 779 appids on the reference cache. A hash-dir-only walk finds
// none of it and silently falls back to a CDN URL that 404s for anything re-hashed.
const dir = tmp("art-flat");
const appDir = path.join(dir, "appcache", "librarycache", "813230");
fs.mkdirSync(appDir, { recursive: true });
fs.writeFileSync(path.join(appDir, "library_600x900.jpg"), "x");
fs.writeFileSync(path.join(appDir, "header.jpg"), "x");
expect(findLocalArtFile(dir, 813230, "portrait")).toBe(
path.join(appDir, "library_600x900.jpg"),
);
// `header.jpg` is the CDN's name, but the local cache uses it too for newer entries — the
// two spellings are the same 460×215 asset and never appear together for one appid.
expect(findLocalArtFile(dir, 813230, "header")).toBe(
path.join(appDir, "header.jpg"),
);
expect(findLocalArtFile(dir, 813230, "hero")).toBeUndefined();
fs.rmSync(dir, { recursive: true, force: true });
});
test("a hash dir wins over a loose file of the same kind", () => {
// No appid on the reference cache carries both, so this is only about which copy is the
// current one if Steam ever leaves the old layout behind: the hash dir is what it re-fetches
// into, so that is the copy it is itself displaying.
const dir = tmp("art-both");
const appDir = path.join(dir, "appcache", "librarycache", "570");
const hashDir = path.join(appDir, "abc123");
fs.mkdirSync(hashDir, { recursive: true });
fs.writeFileSync(path.join(appDir, "library_600x900.jpg"), "x");
fs.writeFileSync(path.join(hashDir, "library_600x900.jpg"), "x");
expect(findLocalArtFile(dir, 570, "portrait")).toBe(
path.join(hashDir, "library_600x900.jpg"),
);
fs.rmSync(dir, { recursive: true, force: true });
});
test("fileUrl produces the host's local-art contract shape", () => {
const u = fileUrl(path.join(path.sep, "home", "u", "My Games", "c.jpg"));
expect(u.startsWith("file:///")).toBe(true);