diff --git a/gen/airfields.py b/gen/airfields.py index 38d4ccdb..8b98668d 100644 --- a/gen/airfields.py +++ b/gen/airfields.py @@ -637,11 +637,3 @@ AIRFIELD_DATA = { atc=AtcData(MHz(3, 775), MHz(118, 50), MHz(38, 450), MHz(250, 50)), ), } - -# TODO: Add list of all beacons on the map so we can reserve those frequencies. -# This list could be generated from the beasons.lua file in the terrain mod -# directory. As-is, we're allocating channels that might include VOR beacons, -# and those will broadcast their callsign consistently (probably with a lot of -# static, depending on how far away the beacon is. The F-16's VHF radio starts -# at 116 MHz, which happens to be the Damascus VOR beacon, so this is more or -# less guaranteed to happen. diff --git a/gen/beacons.py b/gen/beacons.py new file mode 100644 index 00000000..b54eacb1 --- /dev/null +++ b/gen/beacons.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass +from enum import auto, IntEnum +import json +from pathlib import Path +from typing import Iterable, Optional + +from gen.radios import RadioFrequency +from gen.tacan import TacanBand, TacanChannel + + +BEACONS_RESOURCE_PATH = Path("resources/dcs/beacons") + + +class BeaconType(IntEnum): + BEACON_TYPE_NULL = auto() + BEACON_TYPE_VOR = auto() + BEACON_TYPE_DME = auto() + BEACON_TYPE_VOR_DME = auto() + BEACON_TYPE_TACAN = auto() + BEACON_TYPE_VORTAC = auto() + BEACON_TYPE_RSBN = auto() + BEACON_TYPE_BROADCAST_STATION = auto() + + BEACON_TYPE_HOMER = auto() + BEACON_TYPE_AIRPORT_HOMER = auto() + BEACON_TYPE_AIRPORT_HOMER_WITH_MARKER = auto() + BEACON_TYPE_ILS_FAR_HOMER = auto() + BEACON_TYPE_ILS_NEAR_HOMER = auto() + + BEACON_TYPE_ILS_LOCALIZER = auto() + BEACON_TYPE_ILS_GLIDESLOPE = auto() + + BEACON_TYPE_PRMG_LOCALIZER = auto() + BEACON_TYPE_PRMG_GLIDESLOPE = auto() + + BEACON_TYPE_ICLS_LOCALIZER = auto() + BEACON_TYPE_ICLS_GLIDESLOPE = auto() + + BEACON_TYPE_NAUTICAL_HOMER = auto() + + +@dataclass(frozen=True) +class Beacon: + name: str + callsign: str + beacon_type: BeaconType + hertz: int + channel: Optional[int] + + @property + def frequency(self) -> RadioFrequency: + return RadioFrequency(self.hertz) + + @property + def is_tacan(self) -> bool: + return self.beacon_type in ( + BeaconType.BEACON_TYPE_VORTAC, + BeaconType.BEACON_TYPE_TACAN, + ) + + @property + def tacan_channel(self) -> TacanChannel: + assert self.is_tacan + assert self.channel is not None + return TacanChannel(self.channel, TacanBand.X) + + +def load_beacons_for_terrain(name: str) -> Iterable[Beacon]: + beacons_file = BEACONS_RESOURCE_PATH / f"{name.lower()}.json" + if not beacons_file.exists(): + raise RuntimeError(f"Beacon file {beacons_file.resolve()} is missing") + + for beacon in json.loads(beacons_file.read_text()): + yield Beacon(**beacon) diff --git a/resources/tools/import_beacons.py b/resources/tools/import_beacons.py new file mode 100644 index 00000000..9f3dd38e --- /dev/null +++ b/resources/tools/import_beacons.py @@ -0,0 +1,183 @@ +"""Generates resources/dcs/beacons.json from the DCS installation. + +DCS has a beacons.lua file for each terrain mod that includes information about +the radio beacons present on the map: + +beacons = { + { + display_name = _('INCIRLIC'); + beaconId = 'airfield16_0'; + type = BEACON_TYPE_VORTAC; + callsign = 'DAN'; + frequency = 108400000.000000; + channel = 21; + position = { 222639.437500, 73.699811, -33216.257813 }; + direction = 0.000000; + positionGeo = { latitude = 37.015611, longitude = 35.448194 }; + sceneObjects = {'t:124814096'}; + }; + ... +} + +""" +import argparse +from contextlib import contextmanager +import dataclasses +import gettext +import os +from pathlib import Path +import textwrap +from typing import Dict, Iterable, Union + +import lupa + +import game # Needed to resolve cyclic import, for some reason. +from gen.beacons import Beacon, BeaconType, BEACONS_RESOURCE_PATH + +THIS_DIR = Path(__file__).parent.resolve() +SRC_DIR = THIS_DIR.parents[1] +EXPORT_DIR = SRC_DIR / BEACONS_RESOURCE_PATH + + +@contextmanager +def cd(path: Path): + cwd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(cwd) + + +def convert_lua_frequency(raw: Union[float, int]) -> int: + if isinstance(raw, float): + if not raw.is_integer(): + # The values are in hertz, and everything should be a whole number. + raise ValueError(f"Unexpected non-integer frequency: {raw}") + return int(raw) + else: + return raw + + +def beacons_from_terrain(dcs_path: Path, path: Path) -> Iterable[Beacon]: + # TODO: Fix case-sensitive issues. + # The beacons.lua file differs by case in some terrains. Will need to be + # fixed if the tool is to be run on Linux, but presumably the server + # wouldn't be able to find these anyway. + beacons_lua = path / "beacons.lua" + with cd(dcs_path): + lua = lupa.LuaRuntime() + + lua.execute(textwrap.dedent("""\ + function module(name) + end + + """)) + + bind_gettext = lua.eval(textwrap.dedent("""\ + function(py_gettext) + package.preload["i_18n"] = function() + return { + translate = py_gettext + } + end + end + + """)) + translator = gettext.translation( + "messages", path / "l10n", languages=["en"]) + + def translate(message_name: str) -> str: + if not message_name: + return message_name + return translator.gettext(message_name) + bind_gettext(translate) + + src = beacons_lua.read_text() + lua.execute(src) + + beacon_types_map: Dict[int, BeaconType] = {} + for beacon_type in BeaconType: + beacon_value = lua.eval(beacon_type.name) + beacon_types_map[beacon_value] = beacon_type + + beacons = lua.eval("beacons") + for beacon in beacons.values(): + beacon_type_lua = beacon["type"] + if beacon_type_lua not in beacon_types_map: + raise KeyError( + f"Unknown beacon type {beacon_type_lua}. Check that all " + f"beacon types in {beacon_types_path} are present in " + f"{BeaconType.__class__.__name__}" + ) + beacon_type = beacon_types_map[beacon_type_lua] + + yield Beacon( + beacon["display_name"], + beacon["callsign"], + beacon_type, + convert_lua_frequency(beacon["frequency"]), + getattr(beacon, "channel", None) + ) + + +class Importer: + """Imports beacon definitions from each available terrain mod. + + Only beacons for maps owned by the user can be imported. Other maps that + have been previously imported will not be disturbed. + """ + + def __init__(self, dcs_path: Path, export_dir: Path) -> None: + self.dcs_path = dcs_path + self.export_dir = export_dir + + def run(self) -> None: + """Exports the beacons for each available terrain mod.""" + terrains_path = self.dcs_path / "Mods" / "terrains" + self.export_dir.mkdir(parents=True, exist_ok=True) + for terrain in terrains_path.iterdir(): + beacons = beacons_from_terrain(self.dcs_path, terrain) + self.export_beacons(terrain.name, beacons) + + def export_beacons(self, terrain: str, beacons: Iterable[Beacon]) -> None: + terrain_py_path = self.export_dir / f"{terrain.lower()}.json" + import json + terrain_py_path.write_text(json.dumps([ + dataclasses.asdict(b) for b in beacons + ], indent=True)) + + + +def parse_args() -> argparse.Namespace: + """Parses and returns command line arguments.""" + parser = argparse.ArgumentParser() + + def resolved_path(val: str) -> Path: + """Returns the given string as a fully resolved Path.""" + return Path(val).resolve() + + parser.add_argument( + "--export-to", + type=resolved_path, + default=EXPORT_DIR, + help="Output directory for generated JSON files.") + + parser.add_argument( + "dcs_path", + metavar="DCS_PATH", + type=resolved_path, + help="Path to DCS installation." + ) + + return parser.parse_args() + + +def main() -> None: + """Program entry point.""" + args = parse_args() + Importer(args.dcs_path, args.export_to).run() + + +if __name__ == "__main__": + main()