Use a 16:9 image (1920×1080 recommended). Spirit center-crops it, packs it into the native LandingPage texture layout, and builds a Unity bundle for players.
+
+
+
+
-
+
Looking for LandingPage bundles...
@@ -919,10 +956,11 @@ async function deleteShopItem(id) {
/* ---------------- Pages ---------------- */
const PAGE_FOREVER = 4102444800000;
const PAGE_TEMPLATES = [
- {id:'LandingPageRight', label:'Artwork on right', sub:'Message and CTA on the left', visual:'visual-right', copy:'left', buttons:true},
- {id:'LandingPageLeft', label:'Artwork on left', sub:'Message and CTA on the right', visual:'visual-left', copy:'right', buttons:true},
- {id:'LandingPageRightNoButtons', label:'Artwork right, no CTA', sub:'Message on the left', visual:'visual-right no-buttons', copy:'left', buttons:false},
- {id:'LandingPageLeftNoButtons', label:'Artwork left, no CTA', sub:'Message on the right', visual:'visual-left no-buttons', copy:'right', buttons:false}
+ // Unity names these prefabs for the TEXT side, not the artwork side.
+ {id:'LandingPageLeft', label:'Artwork on right', sub:'Message and CTA on the left', visual:'visual-right', copy:'left', buttons:true},
+ {id:'LandingPageRight', label:'Artwork on left', sub:'Message and CTA on the right', visual:'visual-left', copy:'right', buttons:true},
+ {id:'LandingPageLeftNoButtons', label:'Artwork right, no CTA', sub:'Message on the left', visual:'visual-right no-buttons', copy:'left', buttons:false},
+ {id:'LandingPageRightNoButtons', label:'Artwork left, no CTA', sub:'Message on the right', visual:'visual-left no-buttons', copy:'right', buttons:false}
];
const PAGE_CTA_LABELS = {
shop:'VISIT SHOP', redeem:'REDEEM CODE', tournament:'VIEW EVENTS', trade:'OPEN TRADE', url:'LEARN MORE'
@@ -934,6 +972,7 @@ let PAGE_ASSETS_LOADED = false;
let PAGE_ASSET_REVISION = 0;
let PAGE_BASE_CONTENT = {};
let PAGE_EDITOR_INITIALIZED = false;
+let PAGE_CUSTOM_PREVIEW_URL = '';
const cloneJson = value => JSON.parse(JSON.stringify(value || {}));
const pageAssetName = path => String(path || '').trim().replaceAll('\\', '/').split('/').pop();
@@ -954,7 +993,7 @@ function defaultDynamicContent(type='landing') {
};
}
return {
- template: 'LandingPageRight', sortOrder: nextPageOrder(), startTime: 0, endTime: PAGE_FOREVER,
+ template: 'LandingPageLeft', sortOrder: nextPageOrder(), startTime: 0, endTime: PAGE_FOREVER,
labels: {GameText: {token: 'spirit.dynamic_page.game_text', bundle: {en_US: ''}}},
images: {}, actions: {}
};
@@ -1007,7 +1046,7 @@ function hydratePageEditor(page) {
$('#pageSort').value = page.sort_order ?? content.sortOrder ?? nextPageOrder();
$('#pageEnabled').checked = page.enabled !== false;
- const template = PAGE_TEMPLATES.some(t => t.id === content.template) ? content.template : 'LandingPageRight';
+ const template = PAGE_TEMPLATES.some(t => t.id === content.template) ? content.template : 'LandingPageLeft';
$('#pageTemplate').value = template;
$('#pageAssetPath').value = backgroundPath(content);
$('#pageMessage').value = labelText(content, 'GameText');
@@ -1052,7 +1091,7 @@ function selectPageTemplate(template) {
function pageTypeChanged() {
const maintenance = $('#pageType').value === 'maintenance';
if (!maintenance && !PAGE_TEMPLATES.some(t => t.id === $('#pageTemplate').value)) {
- $('#pageTemplate').value = 'LandingPageRight';
+ $('#pageTemplate').value = 'LandingPageLeft';
}
if (maintenance && !$('#pageStart').value && !$('#pageEnd').value) {
const start = Math.floor(Date.now() / 60000) * 60000;
@@ -1347,6 +1386,8 @@ function renderSelectedPageAsset() {
}
if (!meta) {
$('#pageAssetStatus').innerHTML = `Custom path${esc(name)}`;
+ } else if (meta.custom) {
+ $('#pageAssetStatus').innerHTML = `Custom · ready for players${esc(meta.title)}`;
} else if (meta.installed) {
$('#pageAssetStatus').innerHTML = `Ready for players${esc(meta.title)}`;
} else {
@@ -1404,30 +1445,36 @@ async function loadPageAssets(force=false) {
try {
const d = await api(force ? 'pages/assets/refresh' : 'pages/assets', force ? {} : undefined);
if (force) PAGE_ASSET_REVISION += 1;
- PAGE_ASSETS = d.assets || [];
- PAGE_ASSET_BY_NAME = Object.fromEntries(PAGE_ASSETS.map(a => [a.name.toLowerCase(), a]));
- PAGE_ASSETS_LOADED = true;
- const installed = PAGE_ASSETS.filter(a => a.installed).length;
- const sourceText = (d.sources || []).map(s => `${s.textures} from ${s.source}`).join(' + ');
- const errorText = d.errors?.length
- ? ` ${d.errors.length} bundle${d.errors.length === 1 ? '' : 's'} could not be read.`
- : '';
- $('#pageAssetSourceSummary').textContent = PAGE_ASSETS.length
- ? `${PAGE_ASSETS.length} artworks found (${installed} already server-ready). ${sourceText}.${errorText}`
- : `No LandingPage bundles were found. Set PTCGO_CACHE_DIR or place the original cache in this repository.${errorText}`;
- renderPageAssetGrid();
- renderSelectedPageAsset();
- renderPageLibrary();
- if (PAGE_EDITOR_INITIALIZED) pageEditorChanged();
+ applyPageAssetCatalog(d);
} catch(e) {
$('#pageAssetSourceSummary').textContent = 'Could not scan landing-page bundles: ' + e.message;
}
}
+function applyPageAssetCatalog(d) {
+ PAGE_ASSETS = d.assets || [];
+ PAGE_ASSET_BY_NAME = Object.fromEntries(PAGE_ASSETS.map(a => [a.name.toLowerCase(), a]));
+ PAGE_ASSETS_LOADED = true;
+ const installed = PAGE_ASSETS.filter(a => a.installed).length;
+ const custom = PAGE_ASSETS.filter(a => a.custom).length;
+ const sourceText = (d.sources || []).map(s => `${s.textures} from ${s.source}`).join(' + ');
+ const errorText = d.errors?.length
+ ? ` ${d.errors.length} bundle${d.errors.length === 1 ? '' : 's'} could not be read.`
+ : '';
+ const customText = custom ? ` ${custom} custom.` : '';
+ $('#pageAssetSourceSummary').textContent = PAGE_ASSETS.length
+ ? `${PAGE_ASSETS.length} artworks found (${installed} server-ready).${customText} ${sourceText}.${errorText}`
+ : `No LandingPage bundles were found. Set PTCGO_CACHE_DIR or place the original cache in this repository.${errorText}`;
+ renderPageAssetGrid();
+ renderSelectedPageAsset();
+ renderPageLibrary();
+ if (PAGE_EDITOR_INITIALIZED) pageEditorChanged();
+}
+
async function refreshPageAssets() {
PAGE_ASSETS_LOADED = false;
await loadPageAssets(true);
- toast(`Found ${PAGE_ASSETS.length} landing-page artworks`);
+ toast(`Found ${PAGE_ASSETS.length} landing-page artworks, including ${PAGE_ASSETS.filter(a => a.custom).length} custom`);
}
function openPageAssetPicker() {
@@ -1439,6 +1486,90 @@ function openPageAssetPicker() {
}
function closePageAssetPicker() { $('#pageAssetOverlay').classList.remove('open'); }
+function openCustomPageArtwork() {
+ openPageAssetPicker();
+ $('#pageCustomArtworkPanel').open = true;
+ setTimeout(() => $('#pageCustomFile').focus(), 60);
+}
+
+function customPageAssetName(filename) {
+ let name = String(filename || '').replace(/\.[^.]+$/, '');
+ name = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
+ name = name.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
+ if (name && !name.endsWith('_landingpage')) name += '_landingpage';
+ return name;
+}
+
+function customPageFileChanged() {
+ const file = $('#pageCustomFile').files?.[0];
+ const preview = $('#pageCustomPreview');
+ $('#pageCustomStatus').textContent = '';
+ if (PAGE_CUSTOM_PREVIEW_URL) URL.revokeObjectURL(PAGE_CUSTOM_PREVIEW_URL);
+ PAGE_CUSTOM_PREVIEW_URL = '';
+ if (!file) {
+ preview.innerHTML = 'Choose an image';
+ return;
+ }
+ const nameInput = $('#pageCustomName');
+ if (!nameInput.value.trim() || nameInput.dataset.auto !== 'false') {
+ nameInput.value = customPageAssetName(file.name);
+ nameInput.dataset.auto = 'true';
+ }
+ PAGE_CUSTOM_PREVIEW_URL = URL.createObjectURL(file);
+ preview.innerHTML = ``;
+ $('#pageCustomStatus').textContent = `${file.name} · ${(file.size / 1024 / 1024).toFixed(2)} MB`;
+}
+
+function readPageImageDataUrl(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(new Error('The browser could not read that image'));
+ reader.readAsDataURL(file);
+ });
+}
+
+async function uploadCustomPageArtwork() {
+ const file = $('#pageCustomFile').files?.[0];
+ const name = $('#pageCustomName').value.trim();
+ const status = $('#pageCustomStatus');
+ if (!file) return toast('Choose an artwork image first', true);
+ if (!name) return toast('Give the custom artwork an asset name', true);
+ if (file.size > 12 * 1024 * 1024) return toast('Custom artwork must be 12 MB or smaller', true);
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) return toast('Use a PNG, JPEG, or WebP image', true);
+
+ const button = $('#pageCustomUploadButton');
+ button.disabled = true;
+ button.textContent = 'Building Unity bundle...';
+ status.textContent = 'Uploading and packing the native 2048×2048 LandingPage texture...';
+ try {
+ const d = await api('pages/assets/custom', {
+ name,
+ image:await readPageImageDataUrl(file),
+ replace:$('#pageCustomReplace').checked
+ });
+ PAGE_ASSET_REVISION += 1;
+ applyPageAssetCatalog(d);
+ const imported = d.asset?.catalog_asset;
+ if (!imported) throw new Error('The bundle built, but the new artwork was not cataloged');
+ $('#pageCustomFile').value = '';
+ $('#pageCustomName').value = '';
+ $('#pageCustomName').dataset.auto = 'true';
+ $('#pageCustomReplace').checked = false;
+ if (PAGE_CUSTOM_PREVIEW_URL) URL.revokeObjectURL(PAGE_CUSTOM_PREVIEW_URL);
+ PAGE_CUSTOM_PREVIEW_URL = '';
+ $('#pageCustomPreview').innerHTML = 'Choose an image';
+ selectPageAsset(imported.name);
+ toast(`${imported.title} is built, selected, and ready for players`);
+ } catch(e) {
+ status.textContent = e.message;
+ toast(e.message, true);
+ } finally {
+ button.disabled = false;
+ button.textContent = 'Build & add';
+ }
+}
+
function renderPageAssetGrid() {
const grid = $('#pageAssetGrid');
if (!grid) return;
@@ -1446,15 +1577,17 @@ function renderPageAssetGrid() {
const filter = $('#pageAssetFilter')?.value || 'all';
const selected = pageAssetName($('#pageAssetPath')?.value || '').toLowerCase();
const matches = PAGE_ASSETS.filter(a => {
+ if (filter === 'custom' && !a.custom) return false;
if (filter === 'installed' && !a.installed) return false;
if (filter === 'source' && a.installed) return false;
return !query || `${a.title} ${a.name} ${a.bundle}`.toLowerCase().includes(query);
});
grid.innerHTML = matches.map(a => `
`).join('') || '
No artwork matches this filter.
';
}
diff --git a/spirit/server/admin_api.py b/spirit/server/admin_api.py
index 5407fb6..806f97d 100644
--- a/spirit/server/admin_api.py
+++ b/spirit/server/admin_api.py
@@ -1,9 +1,12 @@
+import base64
+import binascii
import json
import logging
import os
import re
-from urllib.parse import unquote
+from urllib.parse import unquote
+from spirit.server import http_server
from spirit.database import db_session, Account, TradeOffer
from spirit.database import economy_data
from spirit.game import season_manager
@@ -32,6 +35,27 @@ def _err(status, message):
return _json(status, {"ok": False, "error": message})
+def _decode_image_data_url(value):
+ if not isinstance(value, str):
+ raise ValueError("image data is required")
+ header, separator, encoded = value.partition(',')
+ allowed = {
+ 'data:image/png;base64',
+ 'data:image/jpeg;base64',
+ 'data:image/jpg;base64',
+ 'data:image/webp;base64',
+ }
+ if separator != ',' or header.lower() not in allowed:
+ raise ValueError("upload a PNG, JPEG, or WebP image")
+ max_encoded = ((dynamic_pages.MAX_CUSTOM_IMAGE_BYTES + 2) // 3) * 4
+ if len(encoded) > max_encoded:
+ raise ValueError("custom artwork must be 12 MB or smaller")
+ try:
+ return base64.b64decode(encoded, validate=True)
+ except (binascii.Error, ValueError) as exc:
+ raise ValueError("image upload is not valid base64 data") from exc
+
+
def _products_summary():
from spirit.game.scripts.products import loader as product_loader
if not product_loader.products:
@@ -493,7 +517,38 @@ def _dispatch(method, endpoint, data):
if method == 'POST' and endpoint == 'pages/assets/refresh':
dynamic_pages.invalidate_asset_catalog()
- return _ok(dynamic_pages.asset_catalog_payload())
+ try:
+ build = dynamic_pages.compile_custom_landing_bundle(force=True)
+ except dynamic_pages.PageValidationError as exc:
+ return _err(400, str(exc))
+ dynamic_pages.invalidate_asset_catalog()
+ if build.get("built"):
+ http_server.register_asset_path(dynamic_pages.BUNDLE_CACHE_DIR)
+ return _ok({**dynamic_pages.asset_catalog_payload(), "custom_build": build})
+
+ if method == 'POST' and endpoint == 'pages/assets/custom':
+ try:
+ image_bytes = _decode_image_data_url(data.get("image"))
+ imported = dynamic_pages.save_custom_landing_image(
+ data.get("name", ""),
+ image_bytes,
+ replace=bool(data.get("replace", False)),
+ )
+ except (ValueError, dynamic_pages.PageValidationError) as exc:
+ return _err(400, str(exc))
+
+ from spirit.server import http_server
+ http_server.register_asset_path(dynamic_pages.BUNDLE_CACHE_DIR)
+ catalog = dynamic_pages.asset_catalog_payload()
+ imported["catalog_asset"] = next(
+ (
+ asset
+ for asset in catalog["assets"]
+ if asset["name"].casefold() == imported["name"].casefold()
+ ),
+ None,
+ )
+ return _ok({"asset": imported, **catalog})
if method == 'GET' and endpoint.startswith('pages/assets/thumb/'):
asset_name = unquote(endpoint[len('pages/assets/thumb/'):])
diff --git a/spirit/server/auto_bundle.py b/spirit/server/auto_bundle.py
index d0e8786..bc10a89 100644
--- a/spirit/server/auto_bundle.py
+++ b/spirit/server/auto_bundle.py
@@ -10,6 +10,7 @@ from PIL import Image, ImageFilter
from spirit.game.attributes import AttrID, TrainerType
from spirit.game.scripts.cards import loader
from spirit.server.auto_bundle_cosmetics import compile_all_cosmetics
+from spirit.server import dynamic_pages
ASSETS_DIR = "spirit/assets"
BUNDLE_CACHE_DIR = os.path.join(ASSETS_DIR, "bundleCache")
@@ -223,6 +224,12 @@ def check_and_generate_bundles() -> int:
except Exception as e:
logging.error(f"[AutoBundle] Failed to compile cosmetics: {e}")
+ logging.info("[AutoBundle] Checking custom landing-page artwork...")
+ try:
+ dynamic_pages.compile_custom_landing_bundle()
+ except Exception as e:
+ logging.error(f"[AutoBundle] Failed to compile custom landing pages: {e}")
+
logging.info("[AutoBundle] Checking for missing card AssetBundles...")
if not os.path.exists(DEFAULT_TEMPLATE):
diff --git a/spirit/server/dynamic_pages.py b/spirit/server/dynamic_pages.py
index cead3b5..27ebb2d 100644
--- a/spirit/server/dynamic_pages.py
+++ b/spirit/server/dynamic_pages.py
@@ -14,16 +14,19 @@ players can load the same art that an admin selected in the preview.
from __future__ import annotations
import copy
+import hashlib
import io
import logging
import os
+import re
import shutil
import threading
+import unicodedata
from collections import OrderedDict
from pathlib import Path
import UnityPy
-from PIL import Image
+from PIL import Image, ImageOps
LANDING_TEMPLATES = {
@@ -49,6 +52,14 @@ _PROJECT_DIR = _SPIRIT_DIR.parent
BUNDLE_CACHE_DIR = _SPIRIT_DIR / "assets" / "bundleCache"
EXTERNAL_CACHE_DIR = _SPIRIT_DIR / "assets" / "externalCache"
ORIGINAL_CACHE_DIR = _PROJECT_DIR / "original_game_cache"
+CUSTOM_IMAGE_DIR = _SPIRIT_DIR / "assets" / "landing_pages"
+CUSTOM_BUNDLE_NAME = "en_US_LandingPage_Custom"
+CUSTOM_BUNDLE_DIR = BUNDLE_CACHE_DIR / CUSTOM_BUNDLE_NAME
+CUSTOM_BUNDLE_DATA_PATH = (
+ CUSTOM_BUNDLE_DIR / "00000000000000000000000001000000" / "__data"
+)
+MAX_CUSTOM_IMAGE_BYTES = 12 * 1024 * 1024
+MAX_CUSTOM_IMAGE_PIXELS = 40_000_000
_catalog_lock = threading.RLock()
_catalog: dict[str, dict] | None = None
@@ -59,6 +70,8 @@ _image_lock = threading.RLock()
_image_cache: OrderedDict[tuple, bytes] = OrderedDict()
_IMAGE_CACHE_MAX = 128
+_custom_bundle_lock = threading.RLock()
+
class PageValidationError(ValueError):
"""Raised when a page would violate the Unity client's wire contract."""
@@ -150,6 +163,29 @@ def _asset_rank(asset: dict) -> tuple:
)
+def _bundle_texture_path_ids(env) -> set[int] | None:
+ """Returns Texture2D PathIDs exported by an AssetBundle container.
+
+ UnityPy exposes orphaned serialized objects too. Custom bundles are rebuilt
+ from a real landing bundle and may retain unused prototype objects, so only
+ container-exported textures belong in the dashboard/manifest catalog.
+ Environments without an AssetBundle object (including loose test fixtures)
+ intentionally return ``None`` and keep the historical scan behavior.
+ """
+ for obj in env.objects:
+ if obj.type.name != "AssetBundle":
+ continue
+ try:
+ bundle = obj.read()
+ return {
+ int(info.asset.m_PathID)
+ for _, info in getattr(bundle, "m_Container", [])
+ }
+ except Exception:
+ return None
+ return None
+
+
def _scan_catalog() -> tuple[dict[str, dict], list[dict], list[str]]:
assets: dict[str, dict] = {}
roots_public: list[dict] = []
@@ -180,9 +216,15 @@ def _scan_catalog() -> tuple[dict[str, dict], list[dict], list[str]]:
try:
stat = data_path.stat()
env = UnityPy.load(str(data_path))
+ exported_texture_ids = _bundle_texture_path_ids(env)
for obj in env.objects:
if obj.type.name != "Texture2D":
continue
+ if (
+ exported_texture_ids is not None
+ and int(obj.path_id) not in exported_texture_ids
+ ):
+ continue
texture = obj.read()
name = str(getattr(texture, "m_Name", "") or "").strip()
if not name:
@@ -197,6 +239,8 @@ def _scan_catalog() -> tuple[dict[str, dict], list[dict], list[str]]:
"width": int(getattr(texture, "m_Width", 0) or 0),
"height": int(getattr(texture, "m_Height", 0) or 0),
"installed": bool(installed),
+ "custom": bundle_dir.name.casefold()
+ == CUSTOM_BUNDLE_NAME.casefold(),
"source": source,
"data_path": str(data_path),
"bundle_dir": str(bundle_dir),
@@ -253,7 +297,14 @@ def asset_catalog_payload() -> dict:
roots = copy.deepcopy(_catalog_roots)
errors = list(_catalog_errors)
public_assets = []
- for item in sorted(catalog.values(), key=lambda a: (a["title"].casefold(), a["name"].casefold())):
+ for item in sorted(
+ catalog.values(),
+ key=lambda a: (
+ not bool(a.get("custom")),
+ a["title"].casefold(),
+ a["name"].casefold(),
+ ),
+ ):
public_assets.append(
{
key: item[key]
@@ -265,6 +316,7 @@ def asset_catalog_payload() -> dict:
"width",
"height",
"installed",
+ "custom",
"source",
)
}
@@ -344,6 +396,283 @@ def render_asset_jpeg(name_or_path: str, variant: str = "preview") -> bytes | No
return rendered
+def normalize_custom_asset_name(value: str) -> str:
+ """Turns an upload/file name into a collision-resistant Unity asset name."""
+ text = Path(str(value or "").strip().replace("\\", "/")).name
+ text = Path(text).stem
+ text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
+ text = re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").lower()
+ text = re.sub(r"_+", "_", text)
+ if not text:
+ raise PageValidationError("Custom artwork needs a file-safe asset name")
+ suffix = "_landingpage"
+ if not text.endswith(suffix):
+ text += suffix
+ if len(text) > 96:
+ text = text[: 96 - len(suffix)].rstrip("_") + suffix
+ return text
+
+
+def _custom_source_images() -> list[tuple[str, Path]]:
+ if not CUSTOM_IMAGE_DIR.is_dir():
+ return []
+ sources: dict[str, Path] = {}
+ paths = (
+ path
+ for path in CUSTOM_IMAGE_DIR.iterdir()
+ if path.is_file() and path.suffix.casefold() == ".png"
+ )
+ for path in sorted(paths, key=lambda item: item.name.casefold()):
+ asset_name = normalize_custom_asset_name(path.name)
+ previous = sources.get(asset_name)
+ if previous is not None:
+ raise PageValidationError(
+ f"{previous.name} and {path.name} normalize to the same landing asset name"
+ )
+ sources[asset_name] = path
+ return list(sources.items())
+
+
+def _prepare_custom_texture(image: Image.Image, size: tuple[int, int]) -> Image.Image:
+ """Packs a normal 16:9 image into the square texture sampled by the prefab."""
+ width, height = size
+ x, y, crop_w, crop_h = _LANDING_UV_RECT
+ left = max(0, round(width * x))
+ top = max(0, round(height * y))
+ right = min(width, round(width * (x + crop_w)))
+ bottom = min(height, round(height * (y + crop_h)))
+ visible_size = (max(1, right - left), max(1, bottom - top))
+ artwork = ImageOps.fit(
+ ImageOps.exif_transpose(image).convert("RGBA"),
+ visible_size,
+ method=Image.Resampling.LANCZOS,
+ centering=(0.5, 0.5),
+ )
+ texture = Image.new("RGBA", size, (0, 0, 0, 255))
+ texture.paste(artwork, (left, top))
+ return texture
+
+
+def _custom_bundle_template_path() -> Path:
+ catalog = _get_catalog()
+ candidates = [
+ Path(item["data_path"])
+ for item in catalog.values()
+ if item.get("bundle", "").casefold() != CUSTOM_BUNDLE_NAME.casefold()
+ ]
+ if candidates:
+ return sorted(candidates, key=lambda path: str(path).casefold())[0]
+ if CUSTOM_BUNDLE_DATA_PATH.is_file():
+ return CUSTOM_BUNDLE_DATA_PATH
+ raise PageValidationError(
+ "A real LandingPage Unity bundle is required as the custom-art template. "
+ "Set PTCGO_CACHE_DIR or add the original game cache, then rescan."
+ )
+
+
+def _rename_custom_bundle_cab(env) -> None:
+ new_cab = f"CAB-{hashlib.md5(CUSTOM_BUNDLE_NAME.encode('utf-8')).hexdigest()}"
+ bundle_file = env.file
+ files = getattr(bundle_file, "files", None)
+ if isinstance(files, dict):
+ old_keys = [key for key in list(files) if str(key).startswith("CAB-")]
+ if old_keys:
+ serialized_file = files.pop(old_keys[0])
+ for duplicate in old_keys[1:]:
+ files.pop(duplicate, None)
+ files[new_cab] = serialized_file
+ for asset in env.assets:
+ if hasattr(asset, "name"):
+ asset.name = new_cab
+
+
+def compile_custom_landing_bundle(force: bool = False) -> dict:
+ """Builds all ``assets/landing_pages/*.png`` files into one Unity bundle.
+
+ This mirrors the custom-card workflow: source PNGs remain easy to edit and
+ the ignored bundleCache output is regenerated on startup or from the admin
+ artwork rescan button.
+ """
+ with _custom_bundle_lock:
+ sources = _custom_source_images()
+ if not sources:
+ return {
+ "built": False,
+ "assets": 0,
+ "bundle": CUSTOM_BUNDLE_NAME,
+ }
+
+ newest_source = max(
+ [path.stat().st_mtime_ns for _, path in sources]
+ + [Path(__file__).stat().st_mtime_ns]
+ )
+ if (
+ not force
+ and CUSTOM_BUNDLE_DATA_PATH.is_file()
+ and CUSTOM_BUNDLE_DATA_PATH.stat().st_mtime_ns >= newest_source
+ ):
+ return {
+ "built": False,
+ "assets": len(sources),
+ "bundle": CUSTOM_BUNDLE_NAME,
+ }
+
+ template_path = _custom_bundle_template_path()
+ env = UnityPy.load(str(template_path))
+
+ serialized_asset = None
+ asset_bundle_obj = None
+ texture_objects = []
+ for asset in env.assets:
+ objects = list(asset.objects.values())
+ bundle_obj = next(
+ (obj for obj in objects if obj.type.name == "AssetBundle"), None
+ )
+ textures = [obj for obj in objects if obj.type.name == "Texture2D"]
+ if bundle_obj is not None and textures:
+ serialized_asset = asset
+ asset_bundle_obj = bundle_obj
+ texture_objects = textures
+ break
+ if serialized_asset is None or asset_bundle_obj is None or not texture_objects:
+ raise PageValidationError("LandingPage template has no editable Texture2D assets")
+
+ asset_bundle = asset_bundle_obj.read()
+ prototype = texture_objects[0]
+ prototype_info = next(
+ (
+ info
+ for _, info in asset_bundle.m_Container
+ if info.asset.m_PathID == prototype.path_id
+ ),
+ None,
+ )
+ if prototype_info is None:
+ raise PageValidationError("LandingPage template has no texture container mapping")
+
+ # Reuse existing texture slots first. Clone the prototype only when the
+ # custom collection is larger than its source template.
+ target_objects = texture_objects[: len(sources)]
+ next_path_id = max(serialized_asset.objects) + 1
+ while len(target_objects) < len(sources):
+ cloned = copy.copy(prototype)
+ cloned.path_id = next_path_id
+ serialized_asset.objects[next_path_id] = cloned
+ target_objects.append(cloned)
+ next_path_id += 1
+
+ new_mappings = []
+ for (asset_name, source_path), target_obj in zip(sources, target_objects):
+ texture = target_obj.read()
+ with Image.open(source_path) as source_image:
+ texture.image = _prepare_custom_texture(
+ source_image,
+ (int(texture.m_Width), int(texture.m_Height)),
+ )
+ texture.m_Name = asset_name
+ target_obj.save_typetree(texture)
+
+ mapping = copy.copy(prototype_info)
+ mapping.asset = copy.copy(prototype_info.asset)
+ mapping.asset.m_PathID = target_obj.path_id
+ new_mappings.append((asset_name, mapping))
+
+ asset_bundle.m_Container = new_mappings
+ asset_bundle.m_Name = CUSTOM_BUNDLE_NAME
+ asset_bundle_obj.save_typetree(asset_bundle)
+
+ used_path_ids = {obj.path_id for obj in target_objects}
+ for obj in texture_objects:
+ if obj.path_id not in used_path_ids:
+ serialized_asset.objects.pop(obj.path_id, None)
+
+ _rename_custom_bundle_cab(env)
+ CUSTOM_BUNDLE_DATA_PATH.parent.mkdir(parents=True, exist_ok=True)
+ temp_path = CUSTOM_BUNDLE_DATA_PATH.with_name(
+ f"{CUSTOM_BUNDLE_DATA_PATH.name}.{threading.get_ident()}.tmp"
+ )
+ try:
+ temp_path.write_bytes(env.file.save(packer="lz4"))
+ os.replace(temp_path, CUSTOM_BUNDLE_DATA_PATH)
+ finally:
+ temp_path.unlink(missing_ok=True)
+
+ invalidate_asset_catalog()
+ logging.info(
+ "[Admin] Built %s with %d custom landing artworks",
+ CUSTOM_BUNDLE_NAME,
+ len(sources),
+ )
+ return {
+ "built": True,
+ "assets": len(sources),
+ "bundle": CUSTOM_BUNDLE_NAME,
+ }
+
+
+def save_custom_landing_image(
+ name: str,
+ payload: bytes,
+ *,
+ replace: bool = False,
+) -> dict:
+ """Validates, stores, and bundles an admin-uploaded landing-page image."""
+ if not isinstance(payload, bytes) or not payload:
+ raise PageValidationError("Choose a PNG, JPEG, or WebP image to upload")
+ if len(payload) > MAX_CUSTOM_IMAGE_BYTES:
+ raise PageValidationError("Custom artwork must be 12 MB or smaller")
+
+ asset_name = normalize_custom_asset_name(name)
+ existing_catalog_asset = _get_catalog().get(asset_name.casefold())
+ if existing_catalog_asset and not existing_catalog_asset.get("custom"):
+ raise PageValidationError(
+ f"{asset_name} conflicts with original game artwork; choose another name"
+ )
+
+ try:
+ with Image.open(io.BytesIO(payload)) as uploaded:
+ width, height = uploaded.size
+ if width * height > MAX_CUSTOM_IMAGE_PIXELS:
+ raise PageValidationError("Custom artwork may not exceed 40 megapixels")
+ if width < 320 or height < 180:
+ raise PageValidationError("Custom artwork must be at least 320x180 pixels")
+ if uploaded.format not in {"PNG", "JPEG", "WEBP"}:
+ raise PageValidationError("Custom artwork must be PNG, JPEG, or WebP")
+ uploaded.load()
+ normalized_image = ImageOps.exif_transpose(uploaded).convert("RGBA")
+ except PageValidationError:
+ raise
+ except Exception as exc:
+ raise PageValidationError("The uploaded file is not a readable image") from exc
+
+ with _custom_bundle_lock:
+ CUSTOM_IMAGE_DIR.mkdir(parents=True, exist_ok=True)
+ target = CUSTOM_IMAGE_DIR / f"{asset_name}.png"
+ if target.exists() and not replace:
+ raise PageValidationError(
+ f"{asset_name} already exists; enable Replace existing artwork to update it"
+ )
+ temp_path = target.with_name(f".{target.name}.{threading.get_ident()}.tmp")
+ try:
+ normalized_image.save(temp_path, format="PNG", optimize=True)
+ os.replace(temp_path, target)
+ finally:
+ temp_path.unlink(missing_ok=True)
+
+ build = compile_custom_landing_bundle(force=True)
+
+ asset = _get_catalog().get(asset_name.casefold())
+ if asset is None:
+ raise PageValidationError("Custom artwork bundle was built but could not be indexed")
+ return {
+ "name": asset_name,
+ "request_path": f"LandingPage/{asset_name}",
+ "width": width,
+ "height": height,
+ "build": build,
+ }
+
+
def _background_asset_paths(content: dict) -> set[str]:
paths: set[str] = set()
images = content.get("images")
diff --git a/spirit/server/manifest_manager.py b/spirit/server/manifest_manager.py
index b8e1756..ffce329 100644
--- a/spirit/server/manifest_manager.py
+++ b/spirit/server/manifest_manager.py
@@ -162,9 +162,29 @@ class ManifestManager:
prefix = "vstartoken"
elif "landingpage" in lower_entry:
prefix = "LandingPage"
-
+
+ exported_texture_ids = None
+ if "landingpage" in lower_entry:
+ for obj in env.objects:
+ if obj.type.name != "AssetBundle":
+ continue
+ try:
+ bundle = obj.read()
+ exported_texture_ids = {
+ int(info.asset.m_PathID)
+ for _, info in bundle.m_Container
+ }
+ except Exception:
+ exported_texture_ids = None
+ break
+
for obj in env.objects:
if obj.type.name == "Texture2D":
+ if (
+ exported_texture_ids is not None
+ and int(obj.path_id) not in exported_texture_ids
+ ):
+ continue
tex_name = obj.read().m_Name.lower()
exact_assets.append(tex_name)
if prefix:
diff --git a/tests/test_dynamic_pages.py b/tests/test_dynamic_pages.py
index 90d6bab..bccb062 100644
--- a/tests/test_dynamic_pages.py
+++ b/tests/test_dynamic_pages.py
@@ -262,6 +262,69 @@ class DynamicPageAssetTests(unittest.TestCase):
self.assertFalse(installed)
self.assertEqual(unresolved, ["Custom/preview_art"])
+ def test_custom_art_is_packed_into_the_native_visible_texture_region(self):
+ source = Image.new("RGB", (1600, 900), (25, 140, 220))
+
+ texture = dynamic_pages._prepare_custom_texture(source, (2048, 2048))
+
+ self.assertEqual(texture.size, (2048, 2048))
+ self.assertEqual(texture.getpixel((0, 0)), (0, 0, 0, 255))
+ red, green, blue, alpha = texture.getpixel((1024, 1024))
+ self.assertLess(red, 35)
+ self.assertGreater(green, 130)
+ self.assertGreater(blue, 210)
+ self.assertEqual(alpha, 255)
+
+ def test_custom_upload_normalizes_saves_and_rebuilds_the_bundle(self):
+ image = Image.new("RGB", (1280, 720), "purple")
+ payload = io.BytesIO()
+ image.save(payload, format="PNG")
+ asset_name = "summer_event_landingpage"
+ catalog_asset = {
+ "name": asset_name,
+ "custom": True,
+ "request_path": f"LandingPage/{asset_name}",
+ }
+
+ with workspace_temp_dir() as root:
+ with (
+ patch.object(dynamic_pages, "CUSTOM_IMAGE_DIR", root),
+ patch.object(
+ dynamic_pages,
+ "_get_catalog",
+ side_effect=[{}, {asset_name: catalog_asset}],
+ ),
+ patch.object(
+ dynamic_pages,
+ "compile_custom_landing_bundle",
+ return_value={"built": True, "assets": 1},
+ ) as compile_bundle,
+ ):
+ imported = dynamic_pages.save_custom_landing_image(
+ "Summer Event.jpg", payload.getvalue()
+ )
+
+ saved = root / f"{asset_name}.png"
+ self.assertTrue(saved.is_file())
+ with Image.open(saved) as saved_image:
+ self.assertEqual(saved_image.size, (1280, 720))
+ self.assertEqual(imported["name"], asset_name)
+ compile_bundle.assert_called_once_with(force=True)
+
+ def test_custom_upload_does_not_shadow_original_game_art(self):
+ image = Image.new("RGB", (640, 360), "blue")
+ payload = io.BytesIO()
+ image.save(payload, format="PNG")
+ original = {"summer_landingpage": {"custom": False}}
+
+ with patch.object(dynamic_pages, "_get_catalog", return_value=original):
+ with self.assertRaisesRegex(
+ dynamic_pages.PageValidationError, "conflicts with original game artwork"
+ ):
+ dynamic_pages.save_custom_landing_image(
+ "summer", payload.getvalue()
+ )
+
class LandingPageManifestTests(unittest.TestCase):
def test_manifest_publishes_prefixed_landing_texture_key(self):