В первом посте я показал саму идею: выключил HUD в Forza, оставил вид из кабины, а карту вынес на телефон. Получился такой странный автомобильный навигатор для виртуальной машины. Сидишь с рулем, смотришь на дорогу, а телефон рядом показывает, где ты и куда ехать.
Теперь обещанная подкапотка. Начну с места, где проект мог умереть вообще сразу: как получить координаты машины из игры и не превратить все это в сомнительный софт из подвала интернета.
Я сначала морально готовился к худшему. Думал, сейчас начнется классика: искать координаты в памяти процесса, смотреть какие-то адреса, проверять, не отвалится ли все после обновления игры, а потом еще объяснять людям, что это не чит и не попытка открыть портал в бан.
Но тут внезапно повезло. В Forza есть Data Out - нормальная телеметрия по UDP. Это та же идея, которой пользуются приборки, симрейсинговые панели и всякие dashboard-приложения. В настройках включаешь Data Out, указываешь IP 127.0.0.1 и порт, у меня это 5700, и игра начинает сама отправлять пакеты.
Самый красивый хак в проекте - это вовремя не хакать то, что уже умеет работать официально.
С этого момента схема стала приземленной: Python-приложение на ПК слушает UDP, принимает байты, распаковывает телеметрию и дальше уже кормит телефон нормальным JSON. Телефон вообще не знает, что где-то внизу летят бинарные пакеты. Для него это просто веб-страница с живым состоянием машины.
UDP-пакет - это не милый JSON
Я бы очень хотел написать, что игра отправляет что-то вроде {speed: 120, x: 123, y: 456}. Но нет. На вход прилетает бинарный пакет, и его нужно читать строго по структуре: где int, где float, где byte, где signed byte.
Из полезного для навигатора там есть почти все, что хотелось: PositionX, PositionY, PositionZ, Speed, VelocityX/Y/Z, Yaw, Gear, CurrentEngineRpm, EngineMaxRpm, Accel, Brake, DistanceTraveled. Скорость приходит в метрах в секунду, yaw - в радианах, педали - байтами 0-255, координаты - в игровых единицах, которые пока вообще не имеют отношения к картинке карты.
Укороченный фрагмент из fh6_live_map_server.py - схема пакета телеметрии
def build_fh6_fields():
fields = []
def add(name, fmt):
fields.append((name, fmt))
add("IsRaceOn", "i")
add("TimestampMS", "I")
for name in [
"EngineMaxRpm", "EngineIdleRpm", "CurrentEngineRpm",
"AccelerationX", "AccelerationY", "AccelerationZ",
"VelocityX", "VelocityY", "VelocityZ",
"Yaw", "Pitch", "Roll",
]:
add(name, "f")
for name in [
"PositionX", "PositionY", "PositionZ",
"Speed", "Power", "Torque", "DistanceTraveled",
]:
add(name, "f")
for name in ["Accel", "Brake", "Clutch", "HandBrake", "Gear"]:
add(name, "B")
return fields
FH6_FIELDS = build_fh6_fields()
FH6_STRUCT_FORMAT = "<" + "".join(fmt for _, fmt in FH6_FIELDS)
FH6_STRUCT_SIZE = struct.calcsize(FH6_STRUCT_FORMAT)
Дальше все не романтично, зато надежно: проверяем длину пакета, распаковываем через struct, складываем значения в словарь. Это тот самый момент, где проект перестает быть красивой фантазией и начинает пахнуть настоящей программой: байты пришли, Python их понял, координаты появились.
UDP listener - скучная часть, без которой магии не будет
def parse_packet(data: bytes):
values = struct.unpack(FH6_STRUCT_FORMAT, data[:FH6_STRUCT_SIZE])
return dict(zip((name for name, _ in FH6_FIELDS), values))
def udp_listener(bind: str, port: int, stop_event: threading.Event):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((bind, port))
sock.settimeout(0.2)
while not stop_event.is_set():
try:
data, _addr = sock.recvfrom(2048)
except socket.timeout:
continue
if len(data) < FH6_STRUCT_SIZE:
STATE.mark_short_packet(len(data))
continue
STATE.update_from_packet(parse_packet(data), len(data))
Вторая радость быстро закончилась: координаты игры не координаты карты
Когда я впервые увидел живые PositionX и PositionZ, было ощущение: ну все, победа. Сейчас нарисую точку на карте и поедем.
А потом мозг такой: подожди, карта у нас - картинка 20000 на 20000 условных пикселей, а игра отдает свои координаты мира. Это не одна и та же система. Просто взять x и z и вставить в CSS нельзя. Получится не навигатор, а зеленая точка, которая уверенно едет в другой вселенной.
Пришлось делать калибровку. Берем несколько точек, где известно соответствие: в игре машина стоит вот здесь, на карте это вот этот пиксель. Потом подбираем аффинное преобразование. В итоге две игровые координаты превращаются в две координаты карты.
Перевод координат Forza в координаты карты
A, B, C = 0.652837, 0.000763, 10387.027
D, E, F = -0.003754, -0.657135, 9846.097
def forza_to_map(position_x: float, position_z: float):
return (
A * position_x + B * position_z + C,
D * position_x + E * position_z + F,
)
Вот это был первый момент, когда я прям физически почувствовал: оно ожило. Не просто цифры бегут в консоли, а машина реально едет по карте примерно там, где должна. После этого уже можно было заниматься интерфейсом. До этого - только гадать над float-ами и делать вид, что все под контролем.
Направление движения: yaw есть, но он не всегда король
Казалось бы, игра отдает Yaw - берем его и поворачиваем стрелку. Но навигатору важнее не то, куда повернута модель машины в данный кадр, а куда она реально движется по карте.
На скорости я использую VelocityX и VelocityZ, перевожу этот вектор в координаты карты и получаю экранный угол. На маленькой скорости вектор начинает шуметь, поэтому там уже можно вернуться к yaw. Это мелочь, которую никто не заметит, пока она работает. Но если она работает плохо, стрелка начинает жить отдельной жизнью, и вся иммерсивность сразу умирает с неловким звуком.
Направление по фактическому движению, а не только по yaw
def compute_screen_heading_deg(yaw_rad, velocity_x, velocity_z, speed_mps):
if speed_mps > 1.5:
vx_map = A * velocity_x + B * velocity_z
vy_map = D * velocity_x + E * velocity_z
return map_vector_to_screen_angle_deg(vx_map, vy_map)
forward_x = math.sin(yaw_rad)
forward_z = math.cos(yaw_rad)
fx_map = A * forward_x + B * forward_z
fy_map = D * forward_x + E * forward_z
return map_vector_to_screen_angle_deg(fx_map, fy_map)
Пауза в игре и телепорт в подвал мироздания
Отдельный прикол: когда игра на паузе, в меню или теряет фокус, телеметрия может стать странной. В интерфейсе это выглядело так, будто машина внезапно исчезла с дороги и решила пожить где-то в координатном аду.
Решение получилось бытовое: если скорость почти нулевая, передача 0, а до этого была нормальная позиция, я держу последнюю хорошую координату. Навигатор не должен нервно прыгать только потому, что я открыл меню.
Защита от прыжка координат при паузе
pause_coordinate_hold = bool(
speed_mps <= 0.20 and gear == 0 and self.last_good_position is not None
)
if pause_coordinate_hold:
pos_x, pos_y, pos_z, map_x, map_y, held_yaw, held_heading = self.last_good_position
effective_yaw_rad = held_yaw
effective_heading_deg = held_heading
else:
pos_x, pos_y, pos_z = raw_pos_x, raw_pos_y, raw_pos_z
map_x, map_y = raw_map_x, raw_map_y
self.last_good_position = (
pos_x, pos_y, pos_z, map_x, map_y,
effective_yaw_rad, effective_heading_deg,
)
Как телефон вообще это видит
На ПК одновременно работают две вещи: UDP listener и локальный HTTP-сервер. Телефон открывает страницу по Wi-Fi, например 192.168.x.x:8766. Страница каждые 120 миллисекунд спрашивает /api/state, а сервер отдает уже нормальный JSON: скорость, передачу, обороты, map_x, map_y, heading, статус LIVE/HOLDING/WAITING.
То есть телефон не подключается к игре. Он подключается к моей локальной программе. И это очень приятная архитектура: игра ничего не знает про телефон, телефон ничего не знает про бинарную телеметрию, а посередине сидит Python и делает вид, что так и было задумано.
Локальный API для телефона
# server side
if path == "/api/state":
self.send_json(STATE.get_snapshot())
return
# browser side
async function pollTelemetry(){
try{
const r = await fetch('/api/state?ts=' + Date.now(), {cache:'no-store'});
telemetry = await r.json();
updateHud();
handleMemeLayerTelemetry(telemetry);
} finally {
setTimeout(pollTelemetry, 120);
}
}
Самая важная часть проекта оказалась не в красивой карте. Самая важная часть - заставить виртуальную машину стабильно существовать в координатах моего интерфейса.
После этого уже можно делать все остальное: поиск точек, маршрут, heading-up, dashboard, мемные звуки, мини-карту в приборке и прочий цирк. Но фундамент простой: UDP -> struct -> координаты -> калибровка -> JSON -> телефон.
В следующей серии будет интересно про карту: как обычная картинка превратилась в дорожный граф, почему прямая линия до цели - это позор, и зачем мне понадобились 14561 узел и 18612 ребер.