rpgsync 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.
rpgsync/compat.py ADDED
@@ -0,0 +1,203 @@
1
+ """Engine compatibility checks: does the game only use what its engine supports?
2
+
3
+ RPG Maker 2000 lacks several commands and parameters of RPG Maker 2003, and
4
+ patches (Maniac Patch, EasyRPG extensions) add commands that the plain
5
+ engines do not understand. The generated test suite of a scripts folder
6
+ runs these checks on every script, and the sync refuses to write an event
7
+ that fails them into the game.
8
+
9
+ The target engine and allowed patches are declared in the scripts'
10
+ pyproject.toml::
11
+
12
+ [tool.rpgsync]
13
+ engine = "2003" # "2000" | "2003"
14
+ patches = ["dynrpg"] # dynrpg | maniac | easyrpg
15
+ allow_commands = [3007] # individual command codes
16
+
17
+ With DynRPG, the calls of plugins (``dyn.name(...)``) are checked against the
18
+ plugins rpgsync knows (rpgsync/dynrpg.py) and, by `rpgsync check`, against the
19
+ ones in the game's DynPlugins folder and the [DynRPG] Plugins of its EasyRPG.ini.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import tomllib
26
+ from collections.abc import Iterable, Sequence
27
+
28
+ from . import dynparams, dynrpg, maniac
29
+ from .commands import CODE_NAMES
30
+ from .lcf import Command
31
+
32
+ # Commands that only exist in RPG Maker 2003
33
+ ONLY_2K3 = {1005, 1006, 1007, 1008, 1009, 5001, 5002, 5003, 5004, 5005, 13260}
34
+
35
+ # Commands whose parameter list grew in RPG Maker 2003: maximum length in 2000
36
+ MAX_PARAMS_2K = {
37
+ 10230: 5, # Timer: no timer 2
38
+ 10710: 6, # Battle: no condition / formation
39
+ 10810: 3, # Teleport: no facing direction
40
+ 10860: 4, # Set Event Location: no facing direction
41
+ 11040: 6, # Flash Screen: no begin/end mode
42
+ 11050: 4, # Shake Screen: no begin/end mode
43
+ 11410: 1, # Wait: no "wait for key"
44
+ 11610: 10, # Key Input: 2000 layout
45
+ }
46
+
47
+ PATCH_RANGES = {"easyrpg": range(2000, 3000), "maniac": range(3000, 4000)}
48
+ PATCHES = ("dynrpg", "maniac", "easyrpg")
49
+ ENGINES = {"2000": "2k", "2003": "2k3"}
50
+
51
+
52
+ def load_config(script_dir: str) -> dict:
53
+ """The [tool.rpgsync] table of <script_dir>/pyproject.toml ({} if none)."""
54
+ try:
55
+ with open(os.path.join(script_dir, "pyproject.toml"), "rb") as f:
56
+ return tomllib.load(f).get("tool", {}).get("rpgsync", {})
57
+ except (OSError, tomllib.TOMLDecodeError):
58
+ return {}
59
+
60
+
61
+ def load_rules(script_dir: str) -> tuple[list[str], list[int]]:
62
+ """(patches, allow_commands) declared in <script_dir>/pyproject.toml."""
63
+ config = load_config(script_dir)
64
+ return list(config.get("patches", [])), list(config.get("allow_commands", []))
65
+
66
+
67
+ def target_engine(script_dir: str, detected: str) -> str:
68
+ """Engine the checks target: `engine = "2000"` in pyproject.toml keeps an
69
+ RPG Maker 2003 game compatible with 2000; default: the game's own."""
70
+ declared = str(load_config(script_dir).get("engine", ""))
71
+ if declared and declared not in ENGINES:
72
+ raise ValueError('[tool.rpgsync] engine must be "2000" or "2003", got %r' % declared)
73
+ return ENGINES.get(declared, detected)
74
+
75
+
76
+ def describe(code: int) -> str:
77
+ return "%s (%d)" % (CODE_NAMES.get(code, "command"), code)
78
+
79
+
80
+ def command_problems(
81
+ cmds: Sequence[Command],
82
+ engine: str,
83
+ patches: Iterable[str] = (),
84
+ allow: Iterable[int] = (),
85
+ plugins: dynrpg.Installed | None = None,
86
+ tables: dict[str, int] | None = None,
87
+ ) -> list[dynparams.Problem]:
88
+ """Problems of a command list for the given engine ("2k" or "2k3"): errors
89
+ break the game in RPG_RT, warnings do not. `plugins`: the game's DynRPG
90
+ plugins (None: not known, their checks are skipped); `tables`: the size of its
91
+ variables, actors, items and skills, for the ids plugin calls name."""
92
+ patches, allow = set(patches), set(allow)
93
+ problems = []
94
+
95
+ def add(error: bool, message: str) -> None:
96
+ problems.append(dynparams.Problem(error=error, message=message))
97
+
98
+ for c in cmds:
99
+ code = c.code
100
+ if code in allow:
101
+ continue
102
+ if code == 12410 and c.string.startswith(b"@") and "dynrpg" not in patches:
103
+ if "easyrpg" in patches and c.string.startswith(b"@easyrpg_"):
104
+ continue # EasyRPG Player runs @easyrpg_ comments without DynRPG
105
+ name = c.string[1:].split(b" ", 1)[0].decode("ascii", "replace")
106
+ add(
107
+ False,
108
+ 'DynRPG command dyn.%s() runs as a plain comment: add "dynrpg" to [tool.rpgsync] patches '
109
+ "if the game uses DynRPG" % name,
110
+ )
111
+ continue
112
+ if code not in CODE_NAMES:
113
+ if code in maniac.CODES and "maniac" not in patches:
114
+ add(True, 'Maniac command maniac.%s(): add "maniac" to [tool.rpgsync] patches' % maniac.name_of(code))
115
+ continue
116
+ if not any(code in r for p, r in PATCH_RANGES.items() if p in patches):
117
+ patch = next((p for p, r in PATCH_RANGES.items() if code in r), None)
118
+ hint = ' (a %s command: add "%s" to [tool.rpgsync] patches)' % (patch, patch) if patch else ""
119
+ add(True, "unknown command %d%s" % (code, hint))
120
+ continue
121
+ if engine == "2k":
122
+ if code in ONLY_2K3:
123
+ add(True, "%s only exists in RPG Maker 2003" % describe(code))
124
+ elif code in MAX_PARAMS_2K and len(c.params) > MAX_PARAMS_2K[code]:
125
+ add(
126
+ False,
127
+ "%s uses RPG Maker 2003 parameters (%d, 2000 has %d): RPG Maker 2000 ignores them"
128
+ % (describe(code), len(c.params), MAX_PARAMS_2K[code]),
129
+ )
130
+ return problems + dynparams.problems(list(cmds)) + dynrpg.problems(cmds, patches, plugins, tables)
131
+
132
+
133
+ def check_commands(
134
+ cmds: Sequence[Command], engine: str, patches: Iterable[str] = (), allow: Iterable[int] = ()
135
+ ) -> list[str]:
136
+ """Problems of a command list that break the game in RPG_RT."""
137
+ return [p.message for p in command_problems(cmds, engine, patches, allow) if p.error]
138
+
139
+
140
+ def check_warnings(
141
+ specs: Sequence,
142
+ engine: str,
143
+ patches: Iterable[str] = (),
144
+ allow: Iterable[int] = (),
145
+ plugins: dynrpg.Installed | None = None,
146
+ tables: dict[str, int] | None = None,
147
+ ) -> list[str]:
148
+ """Problems that do not break the game (see check_specs for the ones that do;
149
+ command_problems for `plugins` and `tables`)."""
150
+ out = []
151
+ for spec in specs:
152
+ if hasattr(spec, "pages"):
153
+ what = "troop" if _is_troop(spec) else "event"
154
+ lists = [("%s %d page %d" % (what, spec.id, n), page.commands) for n, page in enumerate(spec.pages, 1)]
155
+ elif hasattr(spec, "commands"):
156
+ lists = [("common event %d" % spec.id, spec.commands)]
157
+ else:
158
+ continue # database entries have no event commands
159
+ for where, cmds in lists:
160
+ problems = command_problems(cmds, engine, patches, allow, plugins, tables)
161
+ out += ["%s: %s" % (where, p.message) for p in problems if not p.error]
162
+ return list(dict.fromkeys(out))
163
+
164
+
165
+ def check_page_condition(condition: dict, engine: str) -> list[str]:
166
+ problems = []
167
+ if engine == "2k":
168
+ if "variable" in condition and condition["variable"][1] != 1:
169
+ problems.append("page condition: RPG Maker 2000 only supports variables[id] >= value")
170
+ if "timer2" in condition:
171
+ problems.append("page condition: timer2 only exists in RPG Maker 2003")
172
+ return problems
173
+
174
+
175
+ def check_troop_page_condition(condition: dict, engine: str) -> list[str]:
176
+ from .script import TROOP_CONDITIONS_2K3
177
+
178
+ if engine != "2k":
179
+ return []
180
+ return ["page condition: %s only exists in RPG Maker 2003" % k for k in TROOP_CONDITIONS_2K3 if k in condition]
181
+
182
+
183
+ def _is_troop(spec) -> bool:
184
+ return type(spec).__name__ == "TroopSpec"
185
+
186
+
187
+ def check_specs(specs: Sequence, engine: str, patches: Iterable[str] = (), allow: Iterable[int] = ()) -> list[str]:
188
+ """Problems of compiled map events (EventSpec), common events (CommonEventSpec) or
189
+ troop battle events (TroopSpec)."""
190
+ out = []
191
+ for spec in specs:
192
+ if not hasattr(spec, "pages") and not hasattr(spec, "commands"):
193
+ continue # database entries have no event commands
194
+ if hasattr(spec, "pages"):
195
+ troop = _is_troop(spec)
196
+ for n, page in enumerate(spec.pages, 1):
197
+ where = "%s %d page %d" % ("troop" if troop else "event", spec.id, n)
198
+ check = check_troop_page_condition if troop else check_page_condition
199
+ out += ["%s: %s" % (where, p) for p in check(page.props["condition"], engine)]
200
+ out += ["%s: %s" % (where, p) for p in check_commands(page.commands, engine, patches, allow)]
201
+ else:
202
+ out += ["common event %d: %s" % (spec.id, p) for p in check_commands(spec.commands, engine, patches, allow)]
203
+ return list(dict.fromkeys(out)) # one line per problem and place, not per command