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 +0 -0
- madkit/cli/__init__.py +3 -0
- madkit/cli/__main__.py +4 -0
- madkit/cli/cli.py +15 -0
- madkit/cli/commands/__init__.py +0 -0
- madkit/cli/commands/config.py +26 -0
- madkit/cli/commands/mod.py +138 -0
- madkit/cli/commands/repack.py +72 -0
- madkit/cli/commands/txd_add_texture.py +43 -0
- madkit/cli/commands/txd_unpack.py +26 -0
- madkit/cli/commands/unpack.py +96 -0
- madkit/cli/commands/unpack_directory.py +31 -0
- madkit/cli/settings.py +38 -0
- madkit/cli/utils.py +89 -0
- madkit/tools/__init__.py +0 -0
- madkit/tools/analyze/__init__.py +5 -0
- madkit/tools/analyze/__main__.py +28 -0
- madkit/tools/analyze/app.py +410 -0
- madkit/tools/analyze/formatting.py +398 -0
- madkit/tools/analyze/rwtree.py +67 -0
- madkit/tools/analyze/syntax.py +140 -0
- madkit/tools/analyze/theme.py +188 -0
- madkit/tools/livescan.py +97 -0
- madkit/tools/nearest.py +245 -0
- madkit/tools/nearest_gui.py +291 -0
- madkit/tools/poschecker.py +201 -0
- madkit/tools/stream/__init__.py +5 -0
- madkit/tools/stream/__main__.py +22 -0
- madkit/tools/stream/app.py +632 -0
- madkit/tools/stream/formatting.py +259 -0
- madkit/tools/stream/syntax.py +140 -0
- madkit/tools/stream/theme.py +328 -0
- madkit/tools/txd/__init__.py +1 -0
- madkit/tools/txd/__main__.py +23 -0
- madkit/tools/txd/app.py +539 -0
- madkit-0.1.0.dist-info/METADATA +187 -0
- madkit-0.1.0.dist-info/RECORD +40 -0
- madkit-0.1.0.dist-info/WHEEL +4 -0
- madkit-0.1.0.dist-info/entry_points.txt +2 -0
- madkit-0.1.0.dist-info/licenses/LICENSE +201 -0
madkit/__init__.py
ADDED
|
File without changes
|
madkit/cli/__init__.py
ADDED
madkit/cli/__main__.py
ADDED
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
|
madkit/tools/__init__.py
ADDED
|
File without changes
|
|
@@ -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()
|