madkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
madkit/__init__.py ADDED
File without changes
madkit/cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import app, main
2
+
3
+ __all__ = ["app", "main"]
madkit/cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
madkit/cli/cli.py ADDED
@@ -0,0 +1,15 @@
1
+ import cyclopts
2
+
3
+ app = cyclopts.App(name="madkit", version="1.0.0-LittleEndian")
4
+
5
+
6
+ def main() -> None:
7
+ from .commands import unpack # type: ignore # noqa: F401
8
+ from .commands import unpack_directory # type: ignore # noqa: F401
9
+ from .commands import repack # type: ignore # noqa: F401
10
+ #from .commands import txd_unpack # type: ignore # noqa: F401
11
+ #from .commands import txd_add_texture # type: ignore # noqa: F401
12
+ from .commands import mod # type: ignore # noqa: F401
13
+ from .commands import config # type: ignore # noqa: F401
14
+
15
+ app()
File without changes
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+
3
+ from ..cli import app
4
+ from ..settings import SETTINGS_FILE, load_settings, save_settings
5
+
6
+
7
+ @app.command
8
+ def config(game_dir: Path | None = None, levels_dir: Path | None = None):
9
+ """Create or update settings.toml, then print it.
10
+
11
+ Parameters
12
+ ----------
13
+ game_dir : Path
14
+ Game install folder (the one containing Levels/).
15
+ levels_dir : Path
16
+ Folder with the stock .stream files that mods are built from.
17
+ """
18
+ settings = load_settings()
19
+ if game_dir is not None:
20
+ settings.game_dir = game_dir
21
+ if levels_dir is not None:
22
+ settings.levels_dir = levels_dir
23
+
24
+ save_settings(settings)
25
+ print(f"{SETTINGS_FILE.resolve()}:\n")
26
+ print(settings.to_toml())
@@ -0,0 +1,138 @@
1
+ import shutil
2
+ import tomllib
3
+ from pathlib import Path
4
+
5
+ import cyclopts
6
+
7
+ from ..cli import app
8
+ from ..settings import load_settings
9
+ from madformats.mod import collect
10
+ from madformats.stream import RW_StreamFile, load_stream
11
+
12
+ MODS_DIR = Path("mods")
13
+
14
+ mod_app = cyclopts.App(name="mod", help="Create, build and install mods.")
15
+ app.command(mod_app)
16
+
17
+ MOD_TOML_TEMPLATE = """\
18
+ name = "{name}"
19
+ description = ""
20
+ """
21
+
22
+ BUILD_PY_TEMPLATE = """\
23
+ from pathlib import Path
24
+
25
+ from madformats.mod import level
26
+ from madformats.stream import RW_StreamFile
27
+
28
+ ASSETS = Path(__file__).parent / "assets"
29
+
30
+
31
+ @level("{level}")
32
+ def edit_{level}({level}: RW_StreamFile) -> None:
33
+ # =================================
34
+
35
+ # YOUR MODDING CODE HERE ;)
36
+
37
+ # =================================
38
+ pass
39
+ """
40
+
41
+
42
+ def _mod_dir(name: str) -> Path:
43
+ mod_dir = MODS_DIR / name
44
+ if not (mod_dir / "build.py").is_file():
45
+ raise SystemExit(f"No mod at {mod_dir} (missing build.py)")
46
+ return mod_dir
47
+
48
+
49
+ @mod_app.command
50
+ def init(name: str, level: str = "kingofny"):
51
+ """Create a new mod folder from the template.
52
+
53
+ Parameters
54
+ ----------
55
+ name : str
56
+ Mod name, becomes the folder mods/NAME.
57
+ level : str
58
+ Level the template's first @level function edits.
59
+ """
60
+ mod_dir = MODS_DIR / name
61
+ if mod_dir.exists():
62
+ raise SystemExit(f"{mod_dir} already exists")
63
+
64
+ (mod_dir / "assets").mkdir(parents=True)
65
+ (mod_dir / "mod.toml").write_text(MOD_TOML_TEMPLATE.format(name=name))
66
+ (mod_dir / "build.py").write_text(BUILD_PY_TEMPLATE.format(level=level))
67
+ print(f"Created {mod_dir}")
68
+
69
+
70
+ def _build(name: str) -> list[Path]:
71
+ mod_dir = _mod_dir(name)
72
+ with open(mod_dir / "mod.toml", "rb") as f:
73
+ manifest = tomllib.load(f)
74
+
75
+ entries = collect(mod_dir / "build.py")
76
+ if not entries:
77
+ raise SystemExit(f"{mod_dir / 'build.py'} has no @level functions")
78
+
79
+ levels_dir = load_settings().levels_dir
80
+ levels: dict[str, RW_StreamFile] = {}
81
+ for names, _ in entries:
82
+ for level_name in names:
83
+ if level_name not in levels:
84
+ levels[level_name] = load_stream(levels_dir / f"{level_name}.stream")
85
+
86
+ for names, fn in entries:
87
+ print(f"{fn.__name__}({', '.join(names)})")
88
+ fn(*(levels[n] for n in names))
89
+
90
+ out_dir = mod_dir / "build"
91
+ out_dir.mkdir(exist_ok=True)
92
+ written: list[Path] = []
93
+ for level_name, strm in levels.items():
94
+ strm.updatePlacementNew()
95
+ strm.verify()
96
+ out_file = out_dir / f"{level_name}.stream"
97
+ strm.save(out_file)
98
+ written.append(out_file)
99
+
100
+ print(f"Built {manifest.get('name', name)}: {', '.join(str(p) for p in written)}")
101
+ return written
102
+
103
+
104
+ @mod_app.command
105
+ def build(name: str):
106
+ """Run a mod's @level functions and write the levels to mods/NAME/build/.
107
+
108
+ Parameters
109
+ ----------
110
+ name : str
111
+ Mod name (folder in mods/).
112
+ """
113
+ _build(name)
114
+
115
+
116
+ @mod_app.command
117
+ def install(name: str, game_dir: Path | None = None):
118
+ """Build a mod and copy its levels into the game's Levels folder.
119
+
120
+ Parameters
121
+ ----------
122
+ name : str
123
+ Mod name (folder in mods/).
124
+ game_dir : Path
125
+ Game install folder (the one containing Levels/). Defaults to game_dir in settings.toml.
126
+ """
127
+ if game_dir is None:
128
+ game_dir = load_settings().game_dir
129
+ if game_dir is None:
130
+ raise SystemExit("Pass --game-dir or run: uv run madkit config --game-dir PATH")
131
+
132
+ game_levels = game_dir / "Levels"
133
+ if not game_levels.is_dir():
134
+ raise SystemExit(f"{game_levels} does not exist")
135
+
136
+ for built in _build(name):
137
+ shutil.copy2(built, game_levels / built.name)
138
+ print(f"Installed {game_levels / built.name}")
@@ -0,0 +1,72 @@
1
+ from ..cli import app
2
+ from ..utils import ValidatedDir, ValidatedFile, progress
3
+ from madformats.lib.rwConstants import strfunc_func
4
+ import json
5
+ import io
6
+ from pathlib import Path
7
+ from typing import cast
8
+ from madformats.streamfuncs.stringfuncs.sf_LoadEmbeddedAsset import RW_sf_LoadEmbeddedAsset
9
+ from madformats.stream import RW_StreamFile
10
+ from madformats.streamfuncs import STRFUNC_REGISTRY
11
+ from madformats.lib.rw_basics import RW_StreamFunc_NotImplemented
12
+
13
+ @app.command
14
+ def repack(
15
+ input_dir: ValidatedDir, out_stream_file_dir: ValidatedFile, gzipped: bool = False
16
+ ):
17
+ """Repack a stream from a directory (including a manifest).
18
+
19
+ Parameters
20
+ ----------
21
+ input_dir : str
22
+ Directory to repack from.
23
+ out_stream_file_dir : str
24
+ File to repack into.
25
+ gzipped : bool
26
+ Whether the output stream file should be gzipped or not.
27
+ """
28
+
29
+ MANIFEST_FILE = Path(input_dir, "manifest.json")
30
+
31
+ with open(MANIFEST_FILE, "r") as f:
32
+ manifest_contents = json.load(f)
33
+
34
+ strm = RW_StreamFile()
35
+ for sec_dict in progress(manifest_contents, description="Processing..."):
36
+ streamfunc_name = sec_dict.pop("streamfunc")
37
+ file_name = sec_dict.pop("file_name", None)
38
+ streamfunc = strfunc_func[streamfunc_name] if streamfunc_name else None
39
+
40
+ assert streamfunc is not None, (
41
+ f"Streamfunc {streamfunc_name} not found in manifest."
42
+ )
43
+
44
+ streamfunc_class = STRFUNC_REGISTRY.get(
45
+ streamfunc.value, RW_StreamFunc_NotImplemented
46
+ )
47
+
48
+ streamfunc_inst = streamfunc_class.from_dict(sec_dict)
49
+
50
+ if streamfunc == strfunc_func.sf_LoadEmbeddedAsset:
51
+ sec = cast(RW_sf_LoadEmbeddedAsset, streamfunc_inst)
52
+ assert file_name is not None, (
53
+ "Manifest entry is missing 'file_name' for sf_LoadEmbeddedAsset."
54
+ )
55
+ with open(Path(input_dir, file_name), "rb") as f:
56
+ sec.data = f.read()
57
+ strm.contents.append(streamfunc_inst)
58
+
59
+ buf = io.BytesIO()
60
+ strm.verify()
61
+ strm.updatePlacementNew()
62
+ strm.write(buf, 0x1802FFFF)
63
+
64
+ if gzipped:
65
+ import gzip
66
+
67
+ with open(out_stream_file_dir, "wb") as f:
68
+ f.write(gzip.compress(buf.getvalue(), compresslevel=6))
69
+ else:
70
+ with open(out_stream_file_dir, "wb") as f:
71
+ f.write(buf.getvalue())
72
+
@@ -0,0 +1,43 @@
1
+ from madformats.sections.TEXTURENATIVE_0015 import create_texture
2
+
3
+ from ..cli import app
4
+ from ..utils import (
5
+ ValidatedExistingFile,
6
+ )
7
+ from madformats.txd import load_txd
8
+ from pathlib import Path
9
+ from PIL import Image
10
+
11
+ @app.command
12
+ def txd_add_texture(
13
+ txd_file: ValidatedExistingFile,
14
+ texture_file: ValidatedExistingFile,
15
+ ):
16
+ """Add a texture to a TXD. THIS ONLY WORKS FOR XBOX TEXTURE DICTIONARYS!
17
+
18
+ Parameters
19
+ ----------
20
+ txd_file : str
21
+ File to unpack.
22
+ texture_file : str
23
+ BMP file to add
24
+ """
25
+
26
+ texture_name = Path(texture_file).stem
27
+
28
+ image = Image.open(texture_file).convert("RGBA")
29
+
30
+ pixel_data = image.tobytes()
31
+
32
+ txdfile = load_txd(txd_file)
33
+ txdfile.add_texture(
34
+ create_texture(
35
+ name=texture_name,
36
+ rgba=pixel_data,
37
+ width=image.size[0],
38
+ height=image.size[1],
39
+ )
40
+ )
41
+
42
+ with open(txd_file, "wb") as f:
43
+ txdfile.write(f, txdfile.header.library_id_stamp)
@@ -0,0 +1,26 @@
1
+ from ..cli import app
2
+ from ..utils import (
3
+ ValidatedExistingFile,
4
+ ValidatedDir,
5
+ )
6
+ from madformats.txd import load_txd
7
+ import os
8
+
9
+ @app.command
10
+ def txd_unpack(
11
+ txd_file: ValidatedExistingFile,
12
+ output_dir: ValidatedDir,
13
+ ):
14
+ """Unpack all textures of a txd into a folder
15
+
16
+ Parameters
17
+ ----------
18
+ txd_file : str
19
+ File to unpack.
20
+ output_dir : str
21
+ Directory to unpack the stream file into.
22
+ """
23
+
24
+ os.makedirs(output_dir, exist_ok=True)
25
+ txdfile = load_txd(txd_file)
26
+ txdfile.export_all(output_dir, raise_on_error=True)
@@ -0,0 +1,96 @@
1
+ from enum import Enum
2
+
3
+ from ..cli import app
4
+ from ..utils import (
5
+ ValidatedExistingFile,
6
+ ValidatedDir,
7
+ progress,
8
+ loadEmbeddedAssets_to_file_name,
9
+ sanitize_filename,
10
+ )
11
+ from madformats.stream import load_stream
12
+ from madformats.lib.rwConstants import strfunc_func
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Any, cast
17
+ from madformats.streamfuncs.stringfuncs.sf_LoadEmbeddedAsset import RW_sf_LoadEmbeddedAsset
18
+
19
+
20
+ class FileNaming(Enum):
21
+ GUID = 0
22
+ SEC_IDX = 1
23
+
24
+
25
+ @app.command
26
+ def unpack(
27
+ stream_file: ValidatedExistingFile,
28
+ output_dir: ValidatedDir,
29
+ naming: FileNaming = FileNaming.SEC_IDX,
30
+ human: bool = True,
31
+ gzipped: bool = False,
32
+ ):
33
+ """Unpack a stream files assets into a directory (including a manifest).
34
+
35
+ Parameters
36
+ ----------
37
+ stream_file : str
38
+ File to unpack.
39
+ output_dir : str
40
+ Directory to unpack the stream file into.
41
+ human : bool
42
+ Whether to make the manifest more human-readable (pretty-printed).
43
+ gzipped : bool
44
+ Whether the stream file is gzipped or not.
45
+ """
46
+
47
+ strm = load_stream(stream_file, gzipped=gzipped)
48
+
49
+ os.makedirs(output_dir, exist_ok=True)
50
+
51
+ MANIFEST_FILE = Path(output_dir, "manifest.json")
52
+ MANIFEST_CONTENTS: list[dict[str, Any]] = []
53
+
54
+ enumerated_contents = enumerate(strm.contents)
55
+ for i, sec in progress(
56
+ enumerate(strm.contents),
57
+ description=f"Processing {stream_file.name}...",
58
+ total=len(list(enumerated_contents)),
59
+ ):
60
+ extra: dict[str, Any] = {}
61
+
62
+ if sec.streamfunc == strfunc_func.sf_LoadEmbeddedAsset:
63
+ sec = cast(RW_sf_LoadEmbeddedAsset, sec)
64
+
65
+ base_file_name = loadEmbeddedAssets_to_file_name(sec)
66
+
67
+ stem = Path(base_file_name).stem
68
+ suffix = Path(base_file_name).suffix
69
+
70
+ if naming == FileNaming.GUID:
71
+ file_name = f"{stem}_{{{sec.guid}}}{suffix}"
72
+
73
+ elif naming == FileNaming.SEC_IDX:
74
+ file_name = f"{i}_{stem}{suffix}"
75
+
76
+ file_name = sanitize_filename(file_name)
77
+
78
+ with open(Path(output_dir, file_name), "wb") as f:
79
+ f.write(sec.data)
80
+
81
+ extra["file_name"] = file_name
82
+
83
+ sec_dict = sec.to_dict()
84
+ MANIFEST_CONTENTS.append(
85
+ {
86
+ "streamfunc": sec.streamfunc.name if sec.streamfunc else None,
87
+ **sec_dict,
88
+ **extra,
89
+ }
90
+ )
91
+
92
+ with open(MANIFEST_FILE, "w") as f:
93
+ if human:
94
+ json.dump(MANIFEST_CONTENTS, f, indent=4)
95
+ else:
96
+ json.dump(MANIFEST_CONTENTS, f)
@@ -0,0 +1,31 @@
1
+ from pathlib import Path
2
+
3
+ from ..cli import app
4
+ from ..utils import (
5
+ ValidatedExistingDir,
6
+ )
7
+ from .unpack import unpack, FileNaming
8
+ import os
9
+
10
+ @app.command
11
+ def unpack_directory(
12
+ stream_folder_dir: ValidatedExistingDir,
13
+ naming: FileNaming = FileNaming.SEC_IDX,
14
+ human: bool = True,
15
+ gzipped: bool = False,
16
+ ):
17
+ """Unpack all stream files in a directory, for more info look at the command "unpack".
18
+
19
+ Parameters
20
+ ----------
21
+ stream_folder_dir : str
22
+ Folder with the .stream files in it
23
+ human : bool
24
+ Whether to make the manifest more human-readable (pretty-printed).
25
+ gzipped : bool
26
+ Whether the stream file is gzipped or not.
27
+ """
28
+ for file in Path(stream_folder_dir).glob("*.stream"):
29
+ output_dir = Path(stream_folder_dir, file.stem)
30
+ os.makedirs(output_dir, exist_ok=True)
31
+ unpack(file, output_dir, naming=naming, human=human, gzipped=gzipped)
madkit/cli/settings.py ADDED
@@ -0,0 +1,38 @@
1
+ import tomllib
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ SETTINGS_FILE = Path("settings.toml")
6
+
7
+
8
+ @dataclass
9
+ class Settings:
10
+ game_dir: Path | None = None # game install folder, the one containing Levels/
11
+ levels_dir: Path = Path("Levels") # stock .stream files that mods are built from
12
+
13
+ def to_toml(self) -> str:
14
+ game_dir = f"'{self.game_dir.as_posix()}'" if self.game_dir else "''"
15
+ return (
16
+ "# Generated by `uv run madkit config`. Machine-specific, not tracked by git.\n\n"
17
+ "# Game install folder (the one containing Levels/), used by `cli mod install`\n"
18
+ f"game_dir = {game_dir}\n\n"
19
+ "# Stock .stream files that mods are built from\n"
20
+ f"levels_dir = '{self.levels_dir.as_posix()}'\n"
21
+ )
22
+
23
+
24
+ def load_settings() -> Settings:
25
+ if not SETTINGS_FILE.is_file():
26
+ return Settings()
27
+ with open(SETTINGS_FILE, "rb") as f:
28
+ data = tomllib.load(f)
29
+ settings = Settings()
30
+ if data.get("game_dir"):
31
+ settings.game_dir = Path(data["game_dir"])
32
+ if data.get("levels_dir"):
33
+ settings.levels_dir = Path(data["levels_dir"])
34
+ return settings
35
+
36
+
37
+ def save_settings(settings: Settings) -> None:
38
+ SETTINGS_FILE.write_text(settings.to_toml())
madkit/cli/utils.py ADDED
@@ -0,0 +1,89 @@
1
+ from collections.abc import Iterable, Iterator, Sized
2
+ from rich.progress import (
3
+ Progress,
4
+ TextColumn,
5
+ BarColumn,
6
+ TaskProgressColumn,
7
+ MofNCompleteColumn,
8
+ TimeRemainingColumn,
9
+ )
10
+ import re
11
+ from cyclopts import Parameter, validators
12
+ from typing import Annotated
13
+ from pathlib import Path
14
+
15
+ from madformats.streamfuncs.stringfuncs.sf_LoadEmbeddedAsset import RW_sf_LoadEmbeddedAsset
16
+
17
+ def progress[T](
18
+ iterable: Iterable[T],
19
+ description: str = "Processing...",
20
+ total: int | None = None,
21
+ ) -> Iterator[T]:
22
+ if total is None and isinstance(iterable, Sized):
23
+ total = len(iterable)
24
+
25
+ with Progress(
26
+ TextColumn("[progress.description]{task.description}"),
27
+ BarColumn(),
28
+ TaskProgressColumn(),
29
+ MofNCompleteColumn(),
30
+ TimeRemainingColumn(),
31
+ ) as p:
32
+ task = p.add_task(description, total=total)
33
+
34
+ for item in iterable:
35
+ yield item
36
+ p.advance(task)
37
+
38
+ def sanitize_filename(file_name: str) -> str:
39
+ """Make a filename valid on Windows."""
40
+ return re.sub(r'[<>:"/\\|?*]', "_", file_name)
41
+
42
+ ValidatedExistingPath = Annotated[
43
+ Path,
44
+ Parameter(validator=validators.Path(exists=True)),
45
+ ]
46
+ ValidatedExistingFile = Annotated[
47
+ Path,
48
+ Parameter(validator=validators.Path(exists=True, file_okay=True, dir_okay=False)),
49
+ ]
50
+ ValidatedExistingDir = Annotated[
51
+ Path,
52
+ Parameter(validator=validators.Path(exists=True, file_okay=False, dir_okay=True)),
53
+ ]
54
+ ValidatedDir = Annotated[
55
+ Path,
56
+ Parameter(validator=validators.Path(file_okay=False, dir_okay=True)),
57
+ ]
58
+ ValidatedFile = Annotated[
59
+ Path,
60
+ Parameter(validator=validators.Path(file_okay=True, dir_okay=False)),
61
+ ]
62
+
63
+
64
+ def loadEmbeddedAssets_to_file_name(sec: RW_sf_LoadEmbeddedAsset) -> str:
65
+ file_name = Path(sec.name).stem
66
+ if sec.type == "rwID_TEXDICTIONARY": # Texture Dictionary
67
+ file_name += ".txd"
68
+ elif sec.type == "rwaID_WAVEDICT": # Wave Dictionary
69
+ file_name += ".rws"
70
+ elif sec.type == "rwID_WORLD": # World
71
+ file_name += ".bsp"
72
+ elif sec.type == "TextStringDict": # Text String Dict (Localization)
73
+ file_name += ".txl"
74
+ elif sec.type == "rwID_CLUMP": # Clump (Model)
75
+ file_name += ".dff"
76
+ elif sec.type == "rwID_HANIMANIMATION": # Bone Animation
77
+ file_name += ".anm"
78
+ elif sec.type == "SCRIPT": # TFBScript
79
+ file_name += ".ai"
80
+ elif sec.type == "rwID_2DFONT":
81
+ file_name += ".fnt"
82
+ elif sec.type == "KFset":
83
+ file_name += ".lpa"
84
+ elif sec.type == "TEXT":
85
+ file_name += ".txt" # NOT CONFIRMED EXTENSION
86
+ else:
87
+ file_name += Path(sec.name).suffix
88
+
89
+ return file_name
File without changes
@@ -0,0 +1,5 @@
1
+ """Interactive RenderWare file analyzer GUI."""
2
+
3
+ from .app import run
4
+
5
+ __all__ = ["run"]
@@ -0,0 +1,28 @@
1
+ """CLI entry point: ``python -m madkit.tools.analyze [FILE]``."""
2
+
3
+ import argparse
4
+
5
+ from .app import run
6
+
7
+ # Previously hardcoded samples, kept for quick reference:
8
+ # Levels/KingOfNY/170_marty.dff
9
+ # Levels/KingOfNY-unchanged/92_TreasureDoor.dff
10
+ # Levels/KingOfNY-unchanged/2_TD_LEVEL FOLDER.txd
11
+ # Levels/KingOfNY/48_janitor_walk.anm
12
+ DEFAULT_FILE = "Levels/KingOfNY-unchanged/416_marty_jump.anm"
13
+
14
+
15
+ def main():
16
+ ap = argparse.ArgumentParser(prog="analyze", description=__doc__)
17
+ ap.add_argument(
18
+ "file",
19
+ nargs="?",
20
+ default=DEFAULT_FILE,
21
+ help=f"RenderWare file to open (default: {DEFAULT_FILE})",
22
+ )
23
+ args = ap.parse_args()
24
+ run(args.file)
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()