Steam renamed its cached art, so most covers were never found — and the banner that stood in blew the iOS tile open #265
@@ -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,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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user