mirror of
https://github.com/Cesarus85/ringfall-mjolnir-vr.git
synced 2026-10-23 10:00:30 +02:00
279 lines
13 KiB
Python
279 lines
13 KiB
Python
"""List what the scripts of Custom Edition maps use that this build lacks.
|
|
|
|
Xbox maps are read too, so a campaign level's scripts can be written out.
|
|
|
|
A Custom Edition map's compiled scripts keep the name of every function they
|
|
call and every engine global they use (custom_edition_scripts.c finds them
|
|
again by name). This reads those names out of each map's scenario and checks
|
|
them against this build's tables, source/hs/hs.c's functions and
|
|
source/hs/hs_globals_external.c's globals.
|
|
|
|
python tools/custom_edition_script_names.py [--all] [--scripts] MAP_OR_FOLDER...
|
|
|
|
It prints each missing name with the maps that use it, most used first. With
|
|
--all, every name used is listed with whether this build has it. With
|
|
--scripts, each map's scripts are listed too: name, type and the missing
|
|
names each one reaches. Nothing is written.
|
|
"""
|
|
|
|
import argparse
|
|
import collections
|
|
from pathlib import Path
|
|
import re
|
|
import struct
|
|
import sys
|
|
|
|
from custom_edition_tag_footprints import Cache, CUSTOM_EDITION_VERSION, XBOX_VERSION, read_cache
|
|
|
|
SOURCE = Path(__file__).resolve().parent.parent / "source" / "hs"
|
|
|
|
# where the scripts are in a scenario (scenario_definitions.h)
|
|
SCENARIO_SYNTAX_DATA = 0x474
|
|
SCENARIO_STRING_CONSTANTS = 0x488
|
|
SCENARIO_SCRIPTS = 0x49C
|
|
SCENARIO_GLOBALS = 0x4A8
|
|
TAG_DATA_ADDRESS = 12
|
|
DATA_ARRAY_BYTES = 0x38
|
|
SYNTAX_NODE_BYTES = 20
|
|
SCRIPT_BYTES = 92
|
|
GLOBAL_BYTES = 92
|
|
SCRIPT_TYPES = ("startup", "dormant", "continuous", "static", "stub")
|
|
|
|
# a syntax node's flags (hs_compile.c)
|
|
PRIMITIVE = 1 << 0
|
|
SCRIPT = 1 << 1
|
|
GLOBAL = 1 << 2
|
|
# an engine global's designator has this bit (hs_find_global_by_name)
|
|
EXTERNAL_GLOBAL = 1 << 15
|
|
# the type of a call's first child, which names its function
|
|
FUNCTION_NAME = 2
|
|
# the value types whose constants are written out as values (hs.h)
|
|
BOOLEAN, REAL, SHORT, LONG, STRING = 5, 6, 7, 8, 9
|
|
CONSTANT_TYPES = (BOOLEAN, REAL, SHORT, LONG, STRING)
|
|
|
|
|
|
# a function definition in hs.c: return type, flags, name, parser, evaluator,
|
|
# documentation, usage, parameter count and parameter types
|
|
C_STRING = r'(?:(?:"(?:[^"\\]|\\.)*"\s*)+|NULL)'
|
|
FUNCTION_DEFINITION = re.compile(
|
|
r'_definition\s*=\s*\{\s*\{?\s*(_hs_\w+),\s*\w+,\s*"([^"]+)",\s*(\w+),\s*\w+,\s*'
|
|
+ C_STRING + r',\s*' + C_STRING + r',\s*(\d+)\s*,?\s*(?:\{([^}]*)\})?'
|
|
# (the parameters past the first, in the definition's own array)
|
|
r'(?:\s*,?\s*\},?\s*\{([^}]*)\})?', re.S)
|
|
|
|
|
|
def build_types():
|
|
"""hs.h's value types, by name, numbered as in the enumeration"""
|
|
body = (SOURCE / "hs.h").read_text(encoding="latin-1")
|
|
body = body[body.index("_hs_unparsed = 0"):body.index("NUMBER_OF_HS_TYPES")]
|
|
return {name: number for number, name in enumerate(re.findall(r'(_hs_\w+)', body))}
|
|
|
|
|
|
def build_names():
|
|
"""This build's script functions, by name: (return type, parser,
|
|
parameter types), and its engine globals' names."""
|
|
types = build_types()
|
|
functions = {}
|
|
for return_type, name, parser, count, first, rest in FUNCTION_DEFINITION.findall(
|
|
(SOURCE / "hs.c").read_text(encoding="latin-1")):
|
|
parameters = [types.get(parameter.strip()) for parameter in (first + "," + rest).split(",") if parameter.strip()]
|
|
functions[name.lower()] = (types.get(return_type), parser, parameters[:int(count)])
|
|
globals_ = set(re.findall(r'definition\s*=\s*\{\s*"([a-z0-9_]+)"',
|
|
(SOURCE / "hs_globals_external.c").read_text(encoding="latin-1")))
|
|
return functions, {name.lower() for name in globals_}
|
|
|
|
|
|
class MapScripts:
|
|
"""The function and engine global names one map's scripts use."""
|
|
|
|
def __init__(self, path):
|
|
self.cache = Cache(path)
|
|
scenario = [key for key in self.cache.addresses if key[0] == "scnr"]
|
|
if not scenario:
|
|
raise ValueError(f"{path}: no scenario")
|
|
self.scenario = self.cache.addresses[scenario[0]] - self.cache.base
|
|
self.strings = self.data(SCENARIO_STRING_CONSTANTS)
|
|
syntax = self.data(SCENARIO_SYNTAX_DATA)
|
|
self.nodes = []
|
|
if len(syntax) >= DATA_ARRAY_BYTES:
|
|
maximum, size = struct.unpack_from("<hh", syntax, 32)
|
|
count, = struct.unpack_from("<h", syntax, 46)
|
|
if size != SYNTAX_NODE_BYTES:
|
|
raise ValueError(f"{path}: syntax nodes of {size} bytes")
|
|
for index in range(count):
|
|
self.nodes.append(struct.unpack_from("<hhhhiii", syntax, DATA_ARRAY_BYTES + index * SYNTAX_NODE_BYTES))
|
|
self.functions = collections.Counter()
|
|
self.globals = collections.Counter()
|
|
# each call's function and the types its arguments were compiled to
|
|
self.calls = []
|
|
for index, node in enumerate(self.nodes):
|
|
kind, name = self.uses(node)
|
|
if kind == "function":
|
|
self.functions[name] += 1
|
|
self.calls.append((name, self.argument_types(node)))
|
|
elif kind == "global":
|
|
self.globals[name] += 1
|
|
|
|
def data(self, offset):
|
|
size, = struct.unpack_from("<I", self.cache.tag_data, self.scenario + offset)
|
|
address, = struct.unpack_from("<I", self.cache.tag_data, self.scenario + offset + TAG_DATA_ADDRESS)
|
|
start = address - self.cache.base
|
|
if not size or not 0 <= start <= len(self.cache.tag_data) - size:
|
|
return b""
|
|
return self.cache.tag_data[start:start + size]
|
|
|
|
def block(self, offset, element_bytes):
|
|
count, address = struct.unpack_from("<iI", self.cache.tag_data, self.scenario + offset)
|
|
start = address - self.cache.base
|
|
return [self.cache.tag_data[start + i * element_bytes:start + (i + 1) * element_bytes] for i in range(count)]
|
|
|
|
def string(self, offset):
|
|
if not 0 <= offset < len(self.strings):
|
|
return None
|
|
return self.strings[offset:self.strings.index(b"\0", offset)].decode("latin-1").lower()
|
|
|
|
def uses(self, node):
|
|
"""("function" or "global", name) for a call or an engine global
|
|
reference, else (None, None)"""
|
|
header, index, value_type, flags, next_node, string_offset, data = node
|
|
if not header:
|
|
return None, None
|
|
if not flags & (PRIMITIVE | SCRIPT):
|
|
name_index = data & 0xFFFF
|
|
if name_index < len(self.nodes) and self.nodes[name_index][2] == FUNCTION_NAME:
|
|
return "function", self.string(self.nodes[name_index][5])
|
|
elif flags & PRIMITIVE and flags & GLOBAL and data & EXTERNAL_GLOBAL:
|
|
return "global", self.string(string_offset)
|
|
return None, None
|
|
|
|
def argument_types(self, call):
|
|
"""the types of a call's arguments: the nodes after its name"""
|
|
types = []
|
|
index = self.nodes[call[6] & 0xFFFF][4]
|
|
while index != -1 and (index & 0xFFFF) < len(self.nodes) and len(types) < 64:
|
|
node = self.nodes[index & 0xFFFF]
|
|
types.append(node[2])
|
|
index = node[4]
|
|
return tuple(types)
|
|
|
|
def reached(self, root):
|
|
"""the nodes under a script's root expression"""
|
|
seen, pending = set(), [root & 0xFFFF]
|
|
while pending:
|
|
index = pending.pop()
|
|
if index in seen or index >= len(self.nodes):
|
|
continue
|
|
seen.add(index)
|
|
header, _, _, flags, next_node, _, data = self.nodes[index]
|
|
if next_node != -1:
|
|
pending.append(next_node & 0xFFFF)
|
|
if not flags & PRIMITIVE and data != -1:
|
|
pending.append(data & 0xFFFF)
|
|
return seen
|
|
|
|
def source(self, index, depth=0):
|
|
"""the expression at a node, written out as script source"""
|
|
header, constant_type, value_type, flags, next_node, string_offset, data = self.nodes[index & 0xFFFF]
|
|
if not flags & PRIMITIVE:
|
|
parts, child = [], data
|
|
while child != -1 and len(parts) < 256:
|
|
parts.append(self.source(child, depth + 1))
|
|
child = self.nodes[child & 0xFFFF][4]
|
|
line = "(" + " ".join(parts) + ")"
|
|
if len(line) + 4 * depth <= 100:
|
|
return line
|
|
return "(" + parts[0] + "".join("\n" + " " * (depth + 1) + part for part in parts[1:]) + ")"
|
|
# (a constant's value is in the low bytes its type needs; the rest is
|
|
# whatever the compiler left there)
|
|
if flags & GLOBAL or value_type not in CONSTANT_TYPES:
|
|
text = self.string(string_offset)
|
|
return text if text is not None else str(data)
|
|
if value_type == BOOLEAN:
|
|
return "true" if data & 0xFF else "false"
|
|
if value_type == REAL:
|
|
return f"{struct.unpack('<f', struct.pack('<i', data))[0]:g}"
|
|
if value_type == SHORT:
|
|
return str(struct.unpack("<h", struct.pack("<i", data)[:2])[0])
|
|
if value_type == STRING:
|
|
return '"' + (self.string(string_offset) or "") + '"'
|
|
return str(data)
|
|
|
|
def scripts(self):
|
|
"""(name, type, root expression) of each script"""
|
|
for element in self.block(SCENARIO_SCRIPTS, SCRIPT_BYTES):
|
|
name = element[:32].split(b"\0")[0].decode("latin-1")
|
|
script_type, _, root = struct.unpack_from("<hhi", element, 32)
|
|
yield name, SCRIPT_TYPES[script_type] if 0 <= script_type < len(SCRIPT_TYPES) else script_type, root
|
|
|
|
|
|
def maps_in(paths):
|
|
for path in map(Path, paths):
|
|
for candidate in sorted(path.glob("*.map")) if path.is_dir() else [path]:
|
|
try:
|
|
# (Xbox maps too: their scripts are laid out the same, which
|
|
# is handy for reading a campaign level's)
|
|
if read_cache(candidate)[0] in (CUSTOM_EDITION_VERSION, XBOX_VERSION):
|
|
yield candidate
|
|
except (ValueError, OSError, struct.error):
|
|
continue
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--all", action="store_true", help="list every name used, not only the missing ones")
|
|
parser.add_argument("--scripts", action="store_true", help="list each map's scripts")
|
|
parser.add_argument("--source", metavar="SCRIPT", action="append", default=[],
|
|
help="write out a script's source (any number; * for every script)")
|
|
parser.add_argument("paths", nargs="+")
|
|
arguments = parser.parse_args(argv)
|
|
|
|
functions, globals_ = build_names()
|
|
type_names = {number: name for name, number in build_types().items()}
|
|
users = {"function": collections.defaultdict(set), "global": collections.defaultdict(set)}
|
|
# calls whose arguments aren't this build's parameters: (function,
|
|
# argument types) -> maps
|
|
mismatches = collections.defaultdict(set)
|
|
for path in maps_in(arguments.paths):
|
|
try:
|
|
scripts = MapScripts(path)
|
|
except (ValueError, struct.error) as error:
|
|
print(f"{path.name}: {error}", file=sys.stderr)
|
|
continue
|
|
for name in scripts.functions:
|
|
users["function"][name].add(path.stem)
|
|
for name in scripts.globals:
|
|
users["global"][name].add(path.stem)
|
|
for name, arguments_types in scripts.calls:
|
|
definition = functions.get(name)
|
|
if definition and definition[1] == "hs_macro_function_parse" and arguments_types != tuple(definition[2]):
|
|
mismatches[(name, arguments_types)].add(path.stem)
|
|
if arguments.scripts:
|
|
print(f"{path.stem}: {len(scripts.nodes)} nodes")
|
|
for name, script_type, root in scripts.scripts():
|
|
missing = sorted({used for kind, used in map(scripts.uses, (scripts.nodes[i] for i in scripts.reached(root)))
|
|
if kind and used and used not in (functions if kind == "function" else globals_)})
|
|
print(f" {script_type:<10} {name}" + (f" (missing: {', '.join(missing)})" if missing else ""))
|
|
for name, script_type, root in scripts.scripts():
|
|
if name in arguments.source or "*" in arguments.source:
|
|
print(f"\n; {path.stem}\n(script {script_type} {name}\n {scripts.source(root, 1)})")
|
|
|
|
for kind, known in (("function", functions), ("global", globals_)):
|
|
names = sorted(users[kind], key=lambda name: (-len(users[kind][name]), name))
|
|
shown = [name for name in names if arguments.all or name not in known]
|
|
print(f"\n{kind}s{'' if arguments.all else ' this build lacks'}: {len(shown)}")
|
|
for name in shown:
|
|
mark = "" if not arguments.all else (" " if name in known else "- ")
|
|
print(f" {mark}{name} ({len(users[kind][name])}: {', '.join(sorted(users[kind][name])[:6])})")
|
|
|
|
def signature(types):
|
|
return ", ".join(type_names.get(number, str(number)).replace("_hs_type_", "") for number in types)
|
|
|
|
print(f"\ncalls whose arguments differ from this build's parameters: {len(mismatches)}")
|
|
for (name, arguments_types), maps in sorted(mismatches.items(), key=lambda item: (-len(item[1]), item[0])):
|
|
print(f" {name}({signature(arguments_types)}), here ({signature(functions[name][2])})"
|
|
f" ({len(maps)}: {', '.join(sorted(maps)[:6])})")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|