diff --git a/README.md b/README.md index aaa67fa2..45b8e352 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,12 @@ Editable examples live in `sdk/examples/`; `tools/package_mod.py` validates and Extract the Windows release ZIP and run `skate3rust.exe`. Select your Skate 3 Xbox 360 ISO, or select `default.xex` in an extracted game folder. Keep its -`data` folder alongside it. Setup prepares the skater, animations and all disc maps, then -starts University. No Blender, Python or Rust installation is needed. +`data` folder alongside it. Setup extracts all original content, then prepares the +skater, clothing, animations, original HUDs, environment assets and all disc maps +before starting University. No Blender, Python or Rust installation is needed. ISO extraction needs internet access. The first conversion can take a while. +Allow space for both the original source cache and converted runtime assets. +See [setup content coverage](docs/setup-content.md) and [HUD cache repair](docs/hud-installation.md). Use an XInput controller to play. Escape opens graphics, difficulty and map settings. Maps can be switched without restarting the game. diff --git a/docs/setup-content.md b/docs/setup-content.md new file mode 100644 index 00000000..a12c80dd --- /dev/null +++ b/docs/setup-content.md @@ -0,0 +1,63 @@ +# Setup content ownership + +Setup owns extraction and preparation. Features must read installed asset paths; +private development launchers and environment overrides are not installation steps. + +The selected owned game tree is extracted into `assets/private/original` before +runtime conversion. All EB BIG v3 and BIG4/BIGF banks are decoded, including nested +audio banks, and loose files are copied. Each bank has its own namespace so two +archives with the same member name cannot overwrite each other. The private +`original/manifest.json` records source archive hashes, member names, output sizes +and decoded hashes. Repeating this extraction verifies and reuses matching files. +Unsafe paths, ambiguous names and unsupported archive formats fail explicitly. +The installation retains this source cache; temporary map conversion files are +still removed after successful preparation. + +| Runtime content | Setup owner | +| --- | --- | +| Stock animation banks, action/motion/camera graphs, gestures, input and recipes | Stock BIG and loose animation extraction | +| Physics, difficulty, scoring and other VLT tuning; skeleton metadata | VLT and physics-skeleton converters | +| Skater mesh, textures and authored character lighting | Character and lighting converters | +| Resident character choices, original textures, tattoos and native presets | `prepare_customisation.py` using existing CAC converters | +| Original trickdisplay actions, shapes and bitmap fonts | `prepare_hud.py` | +| Original session-marker geometry, labels and Xbox button images | `extract_session_marker.py` using the vendored toolkit | +| Retail skies, render/exposure settings, travel destinations and foliage | Existing environment converters | +| Authored movable-object models and placement | DMO catalog and per-map conversion | +| Every disc district map, textures, collision and retained extensions | Existing map jobs | + +The final `runtime_content` check verifies manifests and referenced files, +including HUD RGBA lengths, character scene/texture dependencies, lighting, +per-map skies and movable objects. It writes `private/content-manifest.json`. +Only after this check and the existing runtime asset checks pass does setup +publish `installation.json` and report success. CAC conversion errors now stop +setup rather than publishing an incomplete library. + +Extraction is not a claim of full engine support for every original format. +Unported audio/frontend data remains in the indexed source cache. The ocean PCA +table is executable-resident: its existing converter accepts a hash-verified +mapped TU3 image, which ordinary disc setup does not produce. The owned XEX is +preserved, but that optional table still needs the mapped-image converter; the +renderer retains its documented fallback when absent. User-authored animations +and third-party mods are not disc assets and are not generated by extraction. + +Existing installations can add the original HUDs using the targeted +[cache repair](hud-installation.md), or prepare only missing feature caches with +the respective tools. Updating source code does not automatically rerun setup +or replace an already installed package. + +## Offline validation + +The 2026-09-09 setup integration was checked without running the game, recomp, +Steam or gameplay tests. Archive tests cover BIG4 RefPack payloads, nested banks, +duplicate names, unsafe paths, reuse and repair. The owned source inventory reads +51 top-level banks and identifies 28 nested EB banks. HUD installation tests cover +missing/corrupt files and byte preservation; the vendored marker compiler produces +the same 15 meshes and seven texture payloads as the prepared native overlay. +The data-only original movie audit passes 1,800 frames. The existing owned CAC +audit validates 480 models, 2,427 materials and 170 tattoos. +The dependency validator also passes against 3,435 prepared runtime files assembled +from the existing caches with a University map. The packaged setup worker passes +data-only archive extraction and HUD installation checks; no setup GUI is launched. + +Full installation and GPU/gameplay behavior still require manual validation; +prepared maps and dependencies were reused rather than rebuilding the installation. diff --git a/tools/asset_pipeline/install.py b/tools/asset_pipeline/install.py index 62c55ce1..569a815a 100644 --- a/tools/asset_pipeline/install.py +++ b/tools/asset_pipeline/install.py @@ -163,8 +163,12 @@ def install(iso,base,game_exe,report,game_root=None): run([extractor,'-x',iso,'-d',game_root],log,report) else:game_root=game_root.resolve() for required in ['default.xex','data/big/miscload.big','data/big/miscboot.big','data/big/db.big', + 'data/big/fedata.big','data/big/fetexture.big','data/big/fedynamic.big', 'data/content/createacharacter.big','data/content/worldDIST_University.big']: if not (game_root/required).is_file():raise RuntimeError('This is not a supported Skate 3 disc: missing '+required) + report('Extracting and indexing all original game content') + from .original_content import extract as extract_original + extract_original(game_root, private/'original', report) report('Extracting animation banks, graphs and gameplay inputs') stock=private/'stock' extract(game_root/'data/big/miscload.big',stock) @@ -181,6 +185,11 @@ def install(iso,base,game_exe,report,game_root=None): (stock/'skater-collections.json').write_text(json.dumps(converted),encoding='utf-8') skeleton=convert_skeleton(stock/'data/anim/OnBoard.abin') (stock/'physics-skeletons.json').write_text(json.dumps(skeleton),encoding='utf-8') + report('Preparing original scoring and session-marker HUD assets') + run(task(TOOLS/'prepare_hud.py', '--game', game_root, '--output', private/'hud', + '--collections', stock/'skater-collections.json'), log, report) + run(task(TOOLS/'extract_session_marker.py', '--game', game_root, + '--output', private/'session-marker'), log, report) report('Preparing the skater model and textures') manifest=json.loads((TOOLS/'default_skater_retail_manifest.json').read_text()) needed=set() @@ -196,6 +205,9 @@ def install(iso,base,game_exe,report,game_root=None): write_character(character/'selected/models',private,manifest) from .character_lighting import convert as write_character_lighting write_character_lighting(character/'selected/models',private,converted) + report('Preparing original character customisation models, textures and menus') + run(task(TOOLS/'prepare_customisation.py', '--game', game_root, + '--assets', stage/'assets'), log, report) game_manifest={'version':1,'character_scene':'private/skater.glb','initial_animation':'R_IDLE_HCOM_000', 'action_graph':'private/stock/data/state/ActionGraph_OnBoard.stategraph', 'motion_graph':'private/stock/data/state/MotionGraph_OnBoard.stategraph'} @@ -210,7 +222,7 @@ def install(iso,base,game_exe,report,game_root=None): write_teleports(game_root,stage/'assets',converted) report('Preparing global foliage backdrops') from .backdrop import convert as write_backdrops - write_backdrops(game_root,stage/'assets',converted) + backdrop_count = write_backdrops(game_root,stage/'assets',converted) report('Preparing authored movable-object models') from .dynamic_props import prepare_catalog prepare_catalog(game_root,work/'dmo') @@ -241,6 +253,9 @@ def install(iso,base,game_exe,report,game_root=None): settings=stage/'settings';settings.mkdir() (settings/'default-map.json').write_text(json.dumps('maps/University.skate'),encoding='utf-8') (stage/'maps.json').write_text(json.dumps(catalog,indent=2),encoding='utf-8') + report('Checking original content and every prepared runtime asset group') + from .runtime_content import validate as validate_content + validate_content(stage, catalog, expected_backdrops=backdrop_count) remove_intermediate(work,stage) # Publish only after every conversion and the runtime's own load succeeds. marker=base/'installation.json.new' diff --git a/tools/asset_pipeline/original_content.py b/tools/asset_pipeline/original_content.py new file mode 100644 index 00000000..5bcaa12b --- /dev/null +++ b/tools/asset_pipeline/original_content.py @@ -0,0 +1,97 @@ +"""Lossless, indexed extraction of the selected owned game tree. + +Keep each bank in its own namespace: duplicated names in different banks must +not overwrite one another. Container decoding is separate from runtime format +conversion; unported audio/frontend/etc. payloads remain original private data. +""" +from pathlib import Path +import hashlib +import json +import shutil + +from tools.owned_game.big import BigArchive + + +def digest(path): + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def destination(root, relative): + target = (root / BigArchive.safe_relative(relative)).resolve() + if not target.is_relative_to(root.resolve()): + raise ValueError(f"Original content path escaped output: {relative}") + return target + + +def extract(game_root, output, report=print): + game_root, output = game_root.resolve(strict=True), output.resolve() + if not game_root.is_dir(): + raise ValueError("Owned game source must be a directory") + if output.is_relative_to(game_root) or game_root.is_relative_to(output): + raise ValueError("Original content output must be separate from the owned source") + output.mkdir(parents=True, exist_ok=True) + index = output / "manifest.json" + prior = json.loads(index.read_text(encoding="utf-8")) if index.is_file() else {} + records = [] + old = {r["output"]: r for r in prior.get("files", [])} + claimed = set() + + def claim(relative): + key = relative.casefold() + if key in claimed: + raise ValueError(f"Ambiguous original content path: {relative}") + claimed.add(key) + return destination(output, relative) + + def bank(path, relative, lineage, depth=0): + if depth > 16: + raise ValueError("Excessive nested original archives") + archive = BigArchive(path) + identity = digest(path) + report(f"Extracting original bank: {lineage} ({len(archive.entries)} entries)") + for entry in archive.entries: + name = BigArchive.safe_relative(entry.path).as_posix() + member = relative + "/" + name + target = claim(member) + previous = old.get(member, {}) + if not (previous.get("archive_sha256") == identity and target.is_file() + and target.stat().st_size == entry.unpacked_size + and digest(target) == previous.get("sha256")): + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(entry)) + records.append(dict(output=member, archive=lineage, entry=entry.path, + archive_sha256=identity, size=entry.unpacked_size, sha256=digest(target))) + if name.lower().endswith(".big"): + bank(target, member + ".contents", lineage + "!/" + name, depth + 1) + + for source in sorted(game_root.rglob("*")): + if not source.is_file(): + continue + relative = source.relative_to(game_root).as_posix() + if source.suffix.lower() == ".big": + bank(source, "banks/" + relative, relative) + else: + name = "loose/" + relative + target = claim(name) + sha256 = digest(source) + if not target.is_file() or digest(target) != sha256: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + if digest(target) != sha256: + raise ValueError(f"Original file copy failed: {relative}") + records.append(dict(output=name, source=relative, size=source.stat().st_size, sha256=sha256)) + manifest = dict(version=1, files=records) + temporary = index.with_suffix(".json.new") + temporary.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + temporary.replace(index) + return manifest + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--game", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + extract(args.game, args.output) diff --git a/tools/asset_pipeline/runtime_content.py b/tools/asset_pipeline/runtime_content.py new file mode 100644 index 00000000..aa271f8d --- /dev/null +++ b/tools/asset_pipeline/runtime_content.py @@ -0,0 +1,87 @@ +"""Verify installed runtime dependencies before publishing setup completion.""" +import hashlib +import json +from pathlib import Path + +from tools.install_prepared_hud import runtime_files +from .original_content import destination + + +def validate(stage, maps, expected_backdrops=None): + assets = stage / "assets" + files = {} + + def require(relative): + path = destination(assets, relative) + if relative in files: + return path + if not path.is_file() or not path.stat().st_size: + raise ValueError(f"Setup is missing runtime content: {relative}") + with path.open("rb") as stream: + sha = hashlib.file_digest(stream, "sha256").hexdigest() + files[relative] = sha + return path + + def read(relative): + return json.loads(require(relative).read_text(encoding="utf-8")) + + manifest = read("private/game.json") + for key in ("character_scene", "action_graph", "motion_graph"): + require(manifest[key]) + for name in ("OnBoard", "OffBoard"): + require(f"private/stock/data/anim/{name}.abin") + for name in ("skater-collections.json", "physics-skeletons.json", "data/config/input.cfg", + "data/script/camera/Default_cameragraph.stategraph", "data/camera/1.shk", "data/camera/2.shk"): + require("private/stock/" + name) + for folder, marker in (("hud", False), ("session-marker", True)): + for relative in runtime_files(assets / "private" / folder, marker): + require("private/" + folder + "/" + relative) + library = read("private/customisation/library-v3.json") + if library.get("version") != 3 or library.get("errors") or not library.get("models"): + raise ValueError("Setup character library is incomplete") + read("private/customisation/extra-menu.json") + for model in library["models"].values(): + require(model["scene"]) + for material in library["materials"].values(): + for key in ("diffuse", "normal", "rough", "opacity"): + if material.get(key): + require(material[key]) + for tattoo in library["tattoos"].values(): + require(tattoo["texture"]) + lighting = read("private/character-lighting.json") + for material in lighting["materials"].values(): + if material.get("specular"): + require(material["specular"]) + for name in ("render-parameters", "exposure", "exposure-profiles", "teleports"): + read(f"private/{name}.json") + backdrops = list((assets / "private/native-backdrops").glob("*.skate")) + if expected_backdrops is not None and len(backdrops) != expected_backdrops: + raise ValueError("Setup foliage backdrop count does not match conversion") + for backdrop in backdrops: + require(backdrop.relative_to(assets).as_posix()) + if not maps: + raise ValueError("Setup has no converted maps") + for row in maps: + path = destination(stage, row["path"]) + if not path.is_file() or not path.stat().st_size: + raise ValueError(f"Setup map is missing: {row['path']}") + name = row["name"] + sky = read(f"private/native-skies/{name}.json") + for suffix, width, height in (("rgba", sky["width"], sky["height"]), + ("sun.rgba", sky["sun_width"], sky["sun_height"])): + if require(f"private/native-skies/{name}.{suffix}").stat().st_size != width * height * 4: + raise ValueError(f"Invalid sky texture: {name}.{suffix}") + props = read(f"private/native-props/{name}.json") + if props.get("instances"): + require(f"private/native-props/{name}.skate") + original = read("private/original/manifest.json") + if original.get("version") != 1 or not original.get("files"): + raise ValueError("Setup has no indexed original content") + result = dict(version=1, runtime_files=files, maps=maps, + original_files=len(original["files"]), + preserved_source_only=["Unported audio and frontend payloads", + "Executable-resident tables without a disc decoder (ocean PCA)"]) + temporary = assets / "private/content-manifest.json.new" + temporary.write_text(json.dumps(result, indent=2), encoding="utf-8") + temporary.replace(assets / "private/content-manifest.json") + return result diff --git a/tools/asset_pipeline/test_original_content.py b/tools/asset_pipeline/test_original_content.py new file mode 100644 index 00000000..6faeb33c --- /dev/null +++ b/tools/asset_pipeline/test_original_content.py @@ -0,0 +1,75 @@ +from pathlib import Path +import json +import struct +import tempfile +import unittest + +from tools.asset_pipeline.original_content import extract +from tools.owned_game.big import BigArchive + + +def big4(entries): + end = 16 + sum(9 + len(name.encode()) for name, _ in entries) + size = end + sum(len(data) for _, data in entries) + directory, payload, offset = bytearray(), bytearray(), end + for name, data in entries: + directory += struct.pack('>II', offset, len(data)) + name.encode() + b'\0' + payload += data + offset += len(data) + return b'BIG4' + struct.pack('II', len(entries), end) + directory + payload + + +class OriginalContentTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.game, self.out = self.root/'game', self.root/'output' + self.game.mkdir() + + def test_nested_banks_duplicate_names_loose_files_and_repair(self): + (self.game/'a.big').write_bytes(big4([('same', b'a'), ('nested.big', big4([('child', b'c')]))])) + (self.game/'b.big').write_bytes(big4([('same', b'b')])) + (self.game/'loose.bin').write_bytes(b'original') + first = extract(self.game, self.out, lambda _: None) + self.assertEqual(len(first['files']), 5) + self.assertEqual((self.out/'banks/a.big/same').read_bytes(), b'a') + self.assertEqual((self.out/'banks/b.big/same').read_bytes(), b'b') + self.assertEqual((self.out/'banks/a.big/nested.big.contents/child').read_bytes(), b'c') + target = self.out/'banks/a.big/same' + stamp = target.stat().st_mtime_ns + self.assertEqual(extract(self.game, self.out, lambda _: None), first) + self.assertEqual(stamp, target.stat().st_mtime_ns) + target.write_bytes(b'x') + extract(self.game, self.out, lambda _: None) + self.assertEqual(target.read_bytes(), b'a') + + def test_big4_refpack_and_invalid_offsets(self): + # Header, literal of three bytes, end marker. + path = self.game/'compressed.big' + path.write_bytes(big4([('data', b'\x10\xfb\x00\x00\x03\xffabc')])) + archive = BigArchive(path) + self.assertEqual(archive.read(archive.entries[0]), b'abc') + raw = bytearray(path.read_bytes()) + raw[16:20] = struct.pack('>I', len(raw) + 1) + path.write_bytes(raw) + with self.assertRaisesRegex(ValueError, 'bounds'): + BigArchive(path) + + def test_unsafe_member_and_source_overlap_fail(self): + (self.game/'bad.big').write_bytes(big4([('../escape', b'bad')])) + with self.assertRaisesRegex(ValueError, 'unsafe'): + extract(self.game, self.out, lambda _: None) + self.assertFalse((self.out/'manifest.json').exists()) + with self.assertRaisesRegex(ValueError, 'separate'): + extract(self.game, self.game/'output', lambda _: None) + + def test_case_collisions_do_not_publish_completed_index(self): + (self.game/'bad.big').write_bytes(big4([('Name', b'a'), ('name', b'b')])) + with self.assertRaisesRegex(ValueError, 'Ambiguous'): + extract(self.game, self.out, lambda _: None) + self.assertFalse((self.out/'manifest.json').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/asset_pipeline/test_runtime_content.py b/tools/asset_pipeline/test_runtime_content.py new file mode 100644 index 00000000..419274ed --- /dev/null +++ b/tools/asset_pipeline/test_runtime_content.py @@ -0,0 +1,32 @@ +import json +from pathlib import Path +import tempfile +import unittest + +from tools.asset_pipeline.runtime_content import validate + + +class RuntimeContentTests(unittest.TestCase): + def test_missing_hud_cannot_publish_complete_content(self): + with tempfile.TemporaryDirectory() as directory: + stage = Path(directory) + assets = stage/'assets' + def write(name, value=b'fixture'): + path = assets/name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(value) + write('private/game.json', json.dumps(dict(character_scene='private/skater.glb', + action_graph='private/action', motion_graph='private/motion')).encode()) + for name in ('private/skater.glb', 'private/action', 'private/motion', + 'private/stock/data/anim/OnBoard.abin', 'private/stock/data/anim/OffBoard.abin'): + write(name) + for name in ('skater-collections.json', 'physics-skeletons.json', 'data/config/input.cfg', + 'data/script/camera/Default_cameragraph.stategraph', 'data/camera/1.shk', 'data/camera/2.shk'): + write('private/stock/'+name) + with self.assertRaises(FileNotFoundError): + validate(stage, []) + self.assertFalse((assets/'private/content-manifest.json').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/owned_game/big.py b/tools/owned_game/big.py index 9af625fa..70a733f1 100644 --- a/tools/owned_game/big.py +++ b/tools/owned_game/big.py @@ -74,13 +74,17 @@ def _decompress_chunkref(data: bytes, expected_size: int) -> bytes: class BigArchive: - """Streaming reader for the EB BIG v3 archives used by Skate 3.""" + """Streaming reader for Skate 3's EB BIG v3 and legacy BIG4/BIGF banks.""" def __init__(self, path: Path): self.path = Path(path).resolve() self.file_size = self.path.stat().st_size with self.path.open("rb") as stream: header = stream.read(48) + if header[:4] in (b"BIG4", b"BIGF"): + self.metadata = b"" + self.entries = self._parse_big4(stream, header) + return if len(header) != 48: raise FormatError(f"{self.path}: truncated BIG header") header_reader = Reader(header, str(self.path)) @@ -93,6 +97,41 @@ class BigArchive: self.metadata = stream.read(metadata_size) self.entries = self._parse() + def _parse_big4(self, stream, header): + """Older EA containers, including the disc's music overlays bank.""" + if len(header) < 16: + raise FormatError("truncated BIG4 header") + count = int.from_bytes(header[8:12], "big") + end = int.from_bytes(header[12:16], "big") + if count > 1_000_000 or end < 16 or end > self.file_size: + raise FormatError("invalid BIG4 directory bounds") + stream.seek(16) + directory = stream.read(end - 16) + cursor = 0 + entries = [] + for index in range(count): + if cursor + 8 > len(directory): + raise FormatError("truncated BIG4 entry") + offset = int.from_bytes(directory[cursor:cursor+4], "big") + size = int.from_bytes(directory[cursor+4:cursor+8], "big") + name_end = directory.find(b"\0", cursor + 8) + if name_end < 0 or offset < end or offset + size > self.file_size: + raise FormatError("invalid BIG4 entry bounds") + name = directory[cursor+8:name_end].decode("utf-8") + self.safe_relative(name) + stream.seek(offset) + prefix = stream.read(min(size, 6)) + compressed = len(prefix) >= 2 and prefix[0] in (0x10, 0x90) and prefix[1] == 0xfb + unpacked = size + if compressed: + length = 6 if prefix[0] == 0x90 else 5 + if len(prefix) < length: + raise FormatError("truncated BIG4 RefPack header") + unpacked = int.from_bytes(prefix[2:length], "big") + entries.append(BigEntry(index, name, offset, size, unpacked, int(compressed))) + cursor = name_end + 1 + return entries + def _parse(self) -> list[BigEntry]: reader = Reader(self.metadata, str(self.path)) if reader.u16be(0) != 0x4542: diff --git a/tools/prepare_customisation.py b/tools/prepare_customisation.py new file mode 100644 index 00000000..fb844d27 --- /dev/null +++ b/tools/prepare_customisation.py @@ -0,0 +1,28 @@ +"""Prepare original resident character choices during setup, without a game.""" +import argparse +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from tools.asset_pipeline.customisation_catalog import prepare as catalog +from tools.asset_pipeline.customisation_library import prepare as library +from tools.asset_pipeline.customisation_profiles import generate + + +def prepare(game, assets): + cache = assets / "private/customisation" + catalog(game, cache) + result = library({"game_root": str(game), "assets": str(assets), "library_index": "library-v3.json"}) + if result["errors"]: + raise ValueError(f"Original character library is incomplete: {result['errors']}") + native = json.loads((cache / "native.json").read_text(encoding="utf-8")) + (cache / "extra-menu.json").write_text(json.dumps(generate(native), indent=2), encoding="utf-8") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--game", type=Path, required=True) + parser.add_argument("--assets", type=Path, required=True) + args = parser.parse_args() + prepare(args.game, args.assets) diff --git a/tools/setup.py b/tools/setup.py index 913a200e..feca9009 100644 --- a/tools/setup.py +++ b/tools/setup.py @@ -29,7 +29,7 @@ def main(): if icon.is_file():window.iconbitmap(str(icon)) frame=ttk.Frame(window,padding=24);frame.pack(fill='both',expand=True) ttk.Label(frame,text='Set up Skate 3 Rust Engine',font=('Segoe UI',20)).pack(anchor='w',pady=(0,16)) - ttk.Label(frame,text='Select your Skate 3 Xbox 360 ISO, or default.xex inside an\nextracted game folder. Keep the game data beside default.xex.\nSetup prepares the skater, animations and all disc maps.\nNo other apps need installing.\n\nISO extraction needs internet access. Allow free disk space\nand time for the first conversion.', + ttk.Label(frame,text='Select your Skate 3 Xbox 360 ISO, or default.xex inside an\nextracted game folder. Keep the game data beside default.xex.\nSetup extracts all original content and prepares the maps,\nskater, clothing, animations, HUDs and environment assets.\nNo other apps need installing.\n\nAllow free disk space for original and converted content.\nISO extraction needs internet access.', font=('Segoe UI',11),justify='left').pack(anchor='w') status=tk.StringVar(value='Choose your game to begin.') ttk.Label(frame,textvariable=status,wraplength=600).pack(anchor='w',pady=(18,8))