Skip to content

Render community-mod cards, relics, and potions#496

Open
ptrlrd wants to merge 15 commits into
mainfrom
feat/modded-cards
Open

Render community-mod cards, relics, and potions#496
ptrlrd wants to merge 15 commits into
mainfrom
feat/modded-cards

Conversation

@ptrlrd

@ptrlrd ptrlrd commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Render community-mod cards, relics, and potions on the run and live pages, as a third source after main and beta.

Supersedes #493, #494, #495.

How modded cards get their image

The 1:1 card images are produced by the game engine, the same way base/beta cards are (per GENERATING_CARD_RENDERS.md), not by a CSS reconstruction. This MR adds a headless render pipeline that runs the game with the mod loaded:

  • tools/render-exporter/ - a BaseLib mod (typed port of the compendium card renderer). When STS2_RENDER_OUT is set it renders every card in ModelDb.AllCards (filtered to a mod's id prefix) with the game's own NCard renderer and saves PNGs. Inert during normal play. Modded cards are in AllCards when the mod is loaded, so they render 1:1.
  • tools/render-container/ - a multi-stage image: steamcmd installs the native Linux build, the game runs under Xvfb + Mesa llvmpipe (Godot --headless draws nothing), the exporter renders to a volume, then PNG->webp and an R2 sync to cards-full/mods/<modid>/.

Catalog + resolution (the text + wiring)

  • tools/parse_mod.py turns the mod's C# classes + localization into the site's 33-field catalog shape at data-mod/<key>/<lang>/, reusing description_resolver so descriptions bake identically. This supplies the name/cost/description text that complements the engine image.
  • Backend serves it at GET /api/mods/{key}/{entity} (data-mod loader).
  • Frontend: fullCardUrl routes a modded id (by prefix -> mod key, from /api/mods) to cards-full/mods/<key>/<id>.webp; the run and live pages gap-fill the mod catalog so names/art resolve (precedence main > beta > mod).
  • tools/ingest_mod.py + upload_mod_assets.py ship the portrait art (catalog image_url).

Verified vs not

  • Parser: 90 cards / 8 relics / 4 potions x 7 langs, mechanics + baked descriptions correct, zero unresolved tokens.
  • Backend endpoints, frontend typecheck, and the convert step are checked here.
  • The full container run (steamcmd login, native launch, engine render, R2 sync) can only be validated on a machine with the game license, a display, and the R2 keys. The mod source, Dockerfile, and scripts are written and lint-checked; first-run caveats are in tools/render-container/README.md.

Notes

  • data-mod/ is committed like data-beta/.
  • The render image bundles a licensed game install + proprietary DLLs and touches Steam creds: keep it private.

ptrlrd added 6 commits June 16, 2026 08:58
Foundation for resolving modded entity art as a third fallback after main and
beta on the run and live pages. data/mods.json is a vetted directory of mods
whose image repos are named by the in-game entity id (lowercased), matching the
official convention, so a modded id maps straight to a raw URL
(<raw_base>/<paths[type]>/<id>.<ext>). /api/mods serves the directory. Seeded
with WatcherMod (lamali292/WatcherMod), whose relics/cards/potions follow the
convention (relic DAMARU -> relics/damaru.png, verified 200 on raw GitHub).

Frontend wiring (resolve unresolved ids to the mod URL, with a placeholder as
the final fallback) is the next step, since it touches several entity-image
sites across the live and run components and is best done with one shared
resolver.
The run and live pages fall back to modded art after main and beta by
fetching a mod repo's id-named images. tools/ingest_mod.py takes a mod
key from data/mods.json plus a source (a loose image folder or a Godot
.pck unpacked statically with GDRE), normalizes each per-type folder to
id-named webp, and stages it under extraction/mods/<key>/<type>/<id>.webp
ready for CDN upload.

It reads the per-type subpaths and source extension from data/mods.json
so the input matches the layout the frontend already expects, ignores
Godot .import sidecars, and lowercases ids to match the in-game id
convention. Verified on WatcherMod: 92 cards and 9 relics staged.
The run and live pages render an entity from its catalog entry after
falling back main -> beta -> mod. This adds tools/parse_mod.py, which
turns a mod's C# card/relic/potion classes plus its localization JSON
into the same per-language catalog shape the site already renders for
base entities, written to data-mod/<key>/<lang>/.

It reuses the base-game parser machinery: description_resolver bakes the
SmartFormat tokens ({Damage:diff()}, {IfUpgraded:show:...}, [gold] tags)
into final text exactly as for base cards, and the 33-field card schema
and enums come from card_parser. The mod-specific part is reading
mechanics from the fluent builder calls (WithDamage, WithBlock, WithVar,
WithKeyword, WithCostUpgradeBy, WithCalculatedDamage, HasEnergyCostX)
rather than the decompiled game's DynamicVar declarations, and namespacing
ids with the mod prefix (Eruption -> WATCHER-ERUPTION) so they never
collide with base ids.

The mod's repo layout, id prefix, color, and languages come from its
data/mods.json entry. Verified against WatcherMod: 90 cards, 8 relics,
4 potions across 7 languages, with cost, type, rarity, damage/block,
custom vars, keywords, X-cost, and upgrade descriptions all resolving
correctly. One potion (Bottled Miracle) shows its count as the runtime
"X" because that number lives in an effect method rather than a builder
call; everything else bakes its numbers in.
Add a data-mod loader and a GET /api/mods/{key}/{entity} endpoint so the
run and live pages can resolve modded card/relic/potion metadata as the
third source after stable and beta.

load_mod_entities reads data-mod/<key>/<lang>/<entity>.json with a
per-language fallback to English and no stable fallback (modded entities
exist only in the mod tree), keyed in an lru_cache by (key, lang, entity).
The endpoint validates the entity type and mod key, returns the same
per-entity shape the stable /api/<entity> endpoints return, and 404s on an
unknown mod or entity.
Base and beta cards get full card images from the game engine; mods have
no engine, so the site's CardRender component is the renderer. This adds
the pipeline that turns the data-mod catalog into the same full card
images the rest of the site already serves.

- frontend/app/internal/mod-render: a bake-only render surface (gated
  behind ENABLE_MOD_RENDER, 404 otherwise) that draws one card with
  CardRender on an opaque backdrop, so headless Chrome can screenshot it
  with no site chrome bleeding in.
- tools/bake_mod_cards.py: drives Chrome against that route for every card
  (base and upgraded), keys out the backdrop to transparency, and writes
  extraction/mods/<key>/cards-full/<id>.webp.
- tools/upload_mod_assets.py: syncs the baked cards and the ingested
  portraits to R2 at the prefixes the frontend resolves (cards-full/mods/
  and mods/<key>/...), mirroring the beta image push. Dry run by default.

Verified end to end on WatcherMod: cards render with the correct frame,
cost orb, name, portrait, and baked description, and the upgraded variants
show the green cost/value changes. The baked tree is staged under
extraction (gitignored); the upload step ships it at deploy time.
Wire the third fallback so modded cards/relics/potions render after stable
and beta, on the two pages where modded runs actually show up.

- image-url.ts: a mod id-prefix registry (setModPrefixes) populated from
  /api/mods, plus modPrefixForId. fullCardUrl routes a modded id (one
  carrying a registered prefix, e.g. WATCHER-ERUPTION) to the baked
  cards-full/mods/ tree, so every existing call site resolves a modded
  card image with no per-site branching.
- The run page detects which mods a run references (by id prefix), registers
  their prefixes, and merges each mod's catalog into the card/relic/potion
  maps after the beta merge, gap-fill only: precedence main > beta > mod.
- The live page loads its catalogs in one pass and gap-fills the mod
  catalogs the same way (it had no beta/mod merge before), so the many
  modded live players resolve their entities.
- The /api/mods/{key}/{entity} endpoint returns a bare list to match the
  stable /api/<entity> shape the client already consumes.

Modded entities resolve their name and art; until the baked images are
uploaded, the existing onError chains fall back to the portrait then blank.
ptrlrd added 2 commits June 16, 2026 09:51
Three issues in the baked card exports:

- Color: the Watcher mod themes its cards purple (the C# sets hue 0.8 and a
  violet energy color), but the catalog defaulted them to colorless, so they
  framed grey. The frame system only ships five pre-tinted colors, so map the
  mod to necrobinder, the closest purple with real frame and orb assets. Set
  via the mod's color in data/mods.json and reparsed.
- Clipped energy orb: the cost orb overhangs the card's top-left corner, so a
  window sized flush to the card cut it off. The bake route now centers the
  card and the window carries a margin; chroma_key crops back to the true
  bounds, orb included.
- Dev-mode logo: baking against the dev server painted the Next.js dev
  indicator into the corner of every card. Bake against a production build
  instead; the tool docstring now says so.
The 1:1 card renders come from the game engine (GENERATING_CARD_RENDERS.md),
so the CSS reconstruction approach is dropped in favor of a headless
dockerized render pipeline that runs the game with the mod loaded.

Added:
- tools/render-exporter: a BaseLib mod that, when STS2_RENDER_OUT is set,
  renders every card in ModelDb.AllCards (filtered to a mod's id prefix) with
  the game's own NCard renderer and saves PNGs. A typed port of the compendium
  payload's card export; inert during normal play. Because the content mod
  registers its cards in AllCards, modded cards render 1:1 with the engine.
- tools/render-container: a multi-stage image (steamcmd installs the native
  Linux build; Xvfb + Mesa llvmpipe for software GL since Godot --headless
  draws nothing) that stages BaseLib + the content mod + the exporter, runs the
  game, waits for the _all_done sentinel, converts PNG to webp, and syncs to
  cards-full/mods/<modid>/. convert-cards.sh is verbatim from the doc.

Changed:
- fullCardUrl routes a modded id (by prefix -> mod key) to
  cards-full/mods/<key>/<id>.webp; setMods registers the prefix->key map from
  /api/mods. The run and live pages call setMods.
- upload_mod_assets.py now handles only the portrait art; the container ships
  the full renders.

Removed: the CSS reconstruction (tools/bake_mod_cards.py, the mod-render route).
The data-mod catalog, /api/mods serving, and the metadata gap-fill stay; they
provide the card name/cost/description text that complements the engine image.

The full container run (steamcmd login, native launch, render, R2 sync) can
only be validated on a machine with the game license, a display, and the R2
keys; the mod source, Dockerfile, and scripts are written and lint-checked.
@ptrlrd ptrlrd force-pushed the feat/modded-cards branch from 2fe960c to 2785664 Compare June 16, 2026 19:28
ptrlrd added 7 commits June 16, 2026 12:41
Keep credentials off the command line: docker compose auto-loads .env from the
render-container dir, so STEAM_USER / STEAM_PASS and the run knobs live there.

- .env.example documents every var; .env is gitignored.
- run.sh requires .env and drops the inline-cred requirement (inline env still
  overrides for one-offs).
- README Run section uses the .env flow.
- Also replaced stray em-dashes in the render-container/exporter files.
Verified by building the mod-compile stage in the container. Fixes:
- Book.StS2.RefLib version *-* so it resolves the 0.107 prerelease ref build
  (plain * pulled the stale 0.103 stable and missed 0.107 APIs).
- Fully-qualify System.Environment (Godot also defines Environment).
- using MegaCrit.Sts2.Core.Entities.UI for ModelVisibility.
- AnimatedSprite2D.Stop() instead of the Godot 3 Playing property.
- ModelDb.AllCards.Count() (it is IEnumerable, so Count is a LINQ method)
  plus using System.Linq.
…t it

steamcmd ran as the non-root steam user but installed to /game under root-owned
/, which it could not create ("Disk write failure"). Pre-create /game owned by
steam in the image, add a game-data volume so the ~3GB install persists and the
volume inherits steam ownership, and have run.sh rebuild the image each run so
Dockerfile changes take effect.
The game errored into NGame.TryErrorInit and never loaded mods/ModelDb because
SteamAPI_Init could not dlopen ~/.steam/sdk64/steamclient.so. Symlink steamcmd's
steamclient.so into the SDK paths and export SteamAppId/SteamGameId so init
bootstraps from the cached steamcmd login.
The steam-config named volume mounted ~/.steam root-owned, so the non-root
steam user could not mkdir ~/.steam/sdk64 for the steamclient.so symlink and the
entrypoint aborted under set -e right after locating the game binary. ~/.steam
does not need to persist (the login sentry is in ~/Steam), so it now lives in
the steam-owned home and is rebuilt each run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant