Реверс-инжиниринг ресурсов игры LHX. Часть 3
Graphics3D в Mathematica
В предыдущих сериях
В прошлом посте я расписал то, как я нашел в экзешнике LHX.exe сначала всего лишь один байт, значение которого искал — и как я был этим горд. Ну и еще про то, как я изящно почти разобрался со структурой байт, которые окружают строки с названием моделек в игре.
Вот она, эта структура (еще раз):
какой‑то 1 байт
1 байт, содержащий количество точек лоу‑поли
ссылка на адрес старта списка лоу‑поли точек
непонятная куча каких‑то байтов номер 1
1 байт, содержащий количество точек хай‑поли
ссылка на адрес старта списка хай‑поли точек
непонятная куча каких‑то байтов номер 2
null‑terminated строка с названием объекта
список лоу‑поли точек
список хай‑поли точек
И еще я написал про то, что не все было радужно, и я столкнулся с несколькими проблемами при попытке полностью понять эту самую структуру.
Первая проблема за раз
Начать я решил с кучи странных байтов номер 1, вполне ожидаемо ожидая, что это вторая из двух (точки и полигоны) составных частей описания модели — список полигонов. Вроде бы тут ей самое место — название есть, точки есть, почему бы уже и полигоны не описать тут же?
А как вообще описать полигоны, имея явный список точек? Очевидно, упоминая эти точки. Упоминая либо:
по значению (то есть, именно значению координаты) — что бессмысленно, ведь у нас уже есть список этих значений,
по ссылке — и самой дешевой и простой ссылкой будет просто номер точки в списке точек.
Номер вряд ли будет отрицательным, так что это беззнаковое число. Вряд ли точек в модели будет больше 256 — не те времена, не те бюджеты полигонов. Да ведь и в самой струтуре размер списка точек (что эквивалентно максимальному номеру места в списке) указывается тоже одним байтом. Значит, ссылка на трех‑координатную точку — это беззнаковый байт, указывающий на ее номер в списке из максимум 256 точек.
Держа вышесказанное в голове, я скопировал непонятную кучу байтов номер 1 из сегмента, описывающего модель зенитки SA-8 (это которая 9К33, но игру сделали американцы), и представил в Mathematica как список байт — для наглядности и анализа.
И там подозрительно часто начало встречаться значение 255. Явно, что это не номер точки — не тот размер списка точек. Это или разделитель, или еще что‑то, но это явно намекает на какую‑то структуру. Ну или по крайней мере хотя бы на что‑то не про точки. При этом последовательности невысоких значений типа »1, 2, 5, 6» (я же искал номера точек) также встречались.
Кусок кучи байт с выделенными 255 и некоторыми подчеркнутыми последовательностями, похожими на списки точек
Отбив в тексте энтером несколько последовательностей, начинающихся с 255, я получил двоякую картину. Вроде какая‑то структура проявляется — нолик этот, всегда стоящий за 255. И за ноликом цифра редко меняется… Но порой были выплески, которые ломали всю картину. Возможно, 255 — это не начало структуры?
Но при этом за ноликом почти всегда стояла 4, а за ней — последовательность невысоких величин. Предположив, что эти величины — это все же номера точек, а это последовательность — это список этих номеров, я заметил, что 4 — это то количество предполагаемых номеров, за которым всегда стоит значение 16. И если перед списком номеров стояла не 4 — закономерность сохранялась — список сравнительно невысоких значений этой длины, а потом — 16. Местами логика ломалась — если за 255 стоял не нолик.
За 255 — почти всегда нолик, за ноликом — почти всегда 4, за 4 — всегда последовательности невысоких величин.
Тогда я предположил, что можно начать структуру не 255, а с 16, которая всегда стояла в конце списка точек. Картинка не сильно поменялась.
Картинка немного улучшилась, но не намного.
Но, выискивая в тексте глазами 16-ки, чтобы отбить последовательности, я заметил, что 16 всегда стоит за 4 байта до 255. Предположив, что само значение »16» — не совсем уж константа, а вот ее позиция — да, я отбил последовательности так, чтоб 255 шла пятой по счету с начала записи. Структура более‑менее (но не полностью) сохранилась, к 16 в начале добавились варианты — 8 и 1. Опираясь на визуальную структуру, я добил рассматриваемый кусок до чего‑то более‑менее хоть как‑то объяснимого (хоть и не всегда понятного).
Картинка проясняется еще немножко
Видите последовательность со множеством нулей? Это множество заставило меня вспомнить про Ida, про XREF, про то, что возле этого XREF указано количество точек, предположить, что количество полигонов тоже может быть указано — и так и оказалось. Число »17» на 5-й строке сверху на последней картинке = количеству записей с 16, 8 или 0 в начале -, а именно их я и подозревал в описании полигонов. Так я нашел счетчик полигонов.
Двойка, которая стоит за 17, вроде показывает, сколько записей с 1 в начале, но позднее оказалось, что это просто совпадение. Что же тогда описывают записи с числом 1 в начале? А там нету счетчика списка вершин, а самих номеров вершин всегда два. Что всегда (потому что нет счетчика) опирается только на 2 вершины? Правильно, линия. Значит, структура с единицей в начале описывает линию. И, значит, 1 — это номер элемента «линия». А 16, 8, 0 — это все же что‑то, связанное с полигонами — ведь количество вершин там переменное от раза к разу.
Но возвращаясь к линии — зачем ей внутри ещё 4 байта данных? Например,»182, 85, 15, 255» у первой линии с последней картинки — она же вроде полностью определена?
Если посмотреть на эту картинку — то видно, что у структур, начинающихся на »3», упоминаются пары байт. Их всегда четное количество. И эти пары байт встречаются собственно у элементов геометрии — как, например, значение »182, 85». Может, это номер элемента и структура с тройкой на них зачем‑то ссылается? Причем этот номер сквозной для всех моделек — иначе зачем там аж 2 байта? Отметив этот факт в Kaitai, я присмотрелся к оставшимся двум цифрам у первой линии — »15, 255».
255, как я уже сказал, упоминался с очень завидным постоянством и постоянно лез в глаза. И это было очень похоже на неизменную константу, такой столб посреди поля. И я бы так и решил, но червячок «кругом все в счетчиках и номерках, а тут целый байт всегда везде одинаковый — к чему бы это?» все же грыз меня.
Неразобранным также осталось значение 15. Сравнив его со значением в других элементах, мне ничего в голову не пришло, так что я благополучно эту позицию в структуре пометил как неопределенную.
После перебора дампов других моделек и похожего анализа, а так же их порченья и рассматривания результатов в игре под разными углами я нашел еще несколько новых типов элементов и даже более‑менее разобрался в их структуре. И некоторые моменты дали ответы на пару вопросов, которые у меня лежали в голове все эти годы.
Например, как это так процессор 8088 так бодро рисует столько таких идеальных сфер в реальном времени и не полыхает как сверхновая при этом?
Черные сферы (видные в основном как точки, потому что они далеко) дают мозгу какую-то пространственную опору (я изобразил ее красными линиями), помогая понять скорость и направление полета по пустыне, которая в 1990-м году изображалась тупо желтой плоскостью. Так вот каждое это черное пятнышко — это сфера. И их много.
Все те же сферы ориентации + голова ракетчика. Оцените степень их детализации, особенно в сравнении с руками, ногами и, собственно, всего лишь «цилиндрической» (, а не полностью круглой) ПЗРК (по лору — это Стрела-2) у ракетчика. Причем, такое приближение к голове — ну очень редкая и нестандартная ситуация, но при этом голова идеально круглая. В отличие от руки, у которая могла бы быть прямоугольной, но даже тут зажали одну точку и поэтому даже рукав нормальный не сделали.
Так вот — модель в формате, используемом EA в игре LHX, содержит далеко не только полигоны, которые просто честные многоугольники, видимые только с одной стороны. Еще она содержит двусторонние полигоны (очевидная оптимизация объема данных для тех же крыльев самолета, которые широкие, но практически плоские), и полигоны с обратным порядком обхода точек (наверное, для экономии времени дизайнера при копипасте, чтоб полигон был тот же, но нормаль в противоположную сторону шла), и даже (зачем‑то) скрытые полигоны!
Но даже на этом список не кончается. Ещё есть, как уже известно, линии (самостоятельные или принадлежащие полигону — потому что они всегда лежат на полигоне и никогда не висят в воздухе), есть просто тупо точки и, собственно, есть те самые сферы. И вот у сферы, как и у линии, тоже всего 2 значения без всяких счетчиков — это номер точки где лежит центр сферы, а также ее радиус (в аж 2 байта размером, то есть можно нарисовать сферу в 256 раз больше любой модельки, и я рисовал, и это стремно). Так что это не геометрический примитив, а команда. По которой система просто рисует на честном трехмерном рендере закрашенный круг заданного радиуса. Ларчик открывался просто.
То, что казалось сквозным номером, оказалось абсолютно неуникальным значением, и в некоторых модельках повторялось у нескольких элементов.
А еще я разобрался что такое 255. Почти случайно. Я заметил, что у полигонов, которые являются лопастями, в этом месте стоит значение ниже, чем у других полигонов модельки вертолета. В чем же разница между лопастями и остальными элементами? Да вот же:
Полупрозрачные лопасти
Это оказалось значение прозрачности. И вот благодаря тому, что в игре практически все — непрозрачное, я и смог уцепиться за это самое лезущее в глаза значение 255 и размотать этот клубок. Зная, что 255 — это прозрачность, последовательность »15, 255» (где на месте 15 — это вообще максимальное встретившееся за все время анализа значение) стала очевидной. 15 — это индекс цвета. Про цвет мне очень долго не приходило в голову просто потому, что в детстве я играл не в ту игру, которую я показывал на картинках выше.
Я играл в такую:
LHX на мониторе CGA. Тут больше паттернов, чем цветов.
И цвет в ней был довольно большой условностью, хотя надо признать, что разработчикам в принципе удалось более‑менее сгладить этот момент.
Так или иначе, я разобрался практически со всей геометрией. Осталась парочка таинственных участков, но у меня получилось их отсечь от уже понятных. Финальное (для меня) описание геометрии — под катом.
Kaitai-описание структуры «полигонов»
Наблюдательный глаз увидит, что явно доступен только блок с элементами модельки низкой детализации. А блок с моделькой «высокой» детализации — нет. Это потому, что я не нашел ни ссылки, ни счетчика, который явно сказал бы — «этот блок здесь». Так что пришлось его опознавать по косвенным признакам среди других chunks.
А полигоны в кавычках — потому что там не только полигоны.
meta:
id: lhx_elements
file-extension: lhx_elements
endian: le
seq:
- id: low_model_chunk
type: low_model_connection
- id: chunks
type: chunk
repeat: eos
types:
chunk:
seq:
- id: lead_byte
type: u1
- id: data
type:
switch-on: lead_byte
cases:
3 : group
7 : strange_garb_2
0 : alias
16 : alias
24 : alias
_ : med_model_connection
low_model_connection:
seq:
- id: num_of_elements
type: u1
- id: num_of_points
type: u1
- id: points_address_prefix
doc: usually 00 00 00 00
#contents: [0x00, 0x00, 0x00, 0x00]
type: u4
- id: pointcloud_address
doc: if zeroes - points are in the separate file
type: address
- id: strange1
type: u2
- id: num_of_polygons
type: u1
- id: strange2
type: u1
- id: elements
type: element
repeat: expr
repeat-expr: num_of_elements
med_model_connection:
seq:
- id: num_of_points
type: u1
- id: points_address_prefix
doc: usually 00 00 00 00
#contents: [0x00, 0x00, 0x00, 0x00]
type: u4
- id: pointcloud_address
doc: if zeroes - points are in the separate file
type: address
- id: strange1
type: u2
- id: num_of_polygons
type: u1
- id: strange2
type: u1
- id: elements
type: element
repeat: expr
repeat-expr: _parent.lead_byte
address:
seq:
- id: segment
type: u2
- id: offset
type: u2
element:
seq:
- id: element_type
type: u1
enum: type_of_element
- id: some_plane_element_id_1
type: u2
- id: element_color_code
type: u1
- id: opaqueness
doc: usually 255
type: u1
- id: geometry
type:
switch-on: element_type
cases:
'type_of_element::separate_line' : line
'type_of_element::on_polygon_line': line
'type_of_element::double_sided_polygon': polygon
'type_of_element::hidden_polygon': polygon
'type_of_element::single_sided_polygon': polygon
'type_of_element::reversed_normals_polygon': polygon
'type_of_element::point': point
'type_of_element::sphere': sphere
polygon:
seq:
- id: strange_3
type: u1
- id: num_of_points
type: u1
- id: points
type: u1
repeat: expr
repeat-expr: num_of_points
line:
seq:
- id: points
type: u1
repeat: expr
repeat-expr: 2
point:
seq:
- id: point_num_of_point
type: u1
sphere:
seq:
- id: radius
type: u2
- id: point_of_center
type: u1
group:
seq:
- id: num_of_grouped_ids
type: u1
- id: grouped_id
type: u2
repeat: expr
repeat-expr: num_of_grouped_ids
alias:
seq:
- id: real_id
type: u2
- id: alias_id_1
type: u2
- id: alias_id_2
type: u2
strange_garb_1:
seq:
- id: two_zeroes
type: u2
- id: ids
type: u1
repeat: expr
repeat-expr: 5
strange_garb_2:
seq:
- id: num_of_ids
type: u1
- id: ids
type: u2
repeat: expr
repeat-expr: num_of_ids
- id: zeroes
type: u1
repeat: expr
repeat-expr: 6
- id: strange_numbers
type: u1
repeat: expr
repeat-expr: 3
enums:
type_of_element:
0x00: double_sided_polygon
0x01: separate_line
0x02: point
0x03: sphere
0x08: hidden_polygon
0x10: single_sided_polygon
0x11: on_polygon_line
0x18: reversed_normals_polygon
Результат анализа информации о геометрии, пример применения.
Так или иначе, списки точек есть, списки элементов (это все же не просто описание полигонов) для них есть и более‑менее понятны — я все ближе к цели!
Это я в математике накрутил визуализатор для проверок гипотез. Отображается лоу‑поли моделька SA-8 (ниже — референс из игры) — колес нет, ракетницы обозначены просто линиями (на них нарисованные мной красные стрелочки показывают, это не глюк визуализатора). А треугольник, находящийся прямо под фиолетовым прямоугольником сверху и опирающийся на упомянутые линии, в игре не виден — это тот самый невидимый полигон.
Лоу-поли модельки SA-8 в игре
Осталось всего ничего — достать наружу собственно модельки, т.е. конкретные данные по точкам и элементам. Так как это все вшито в экзешнике, а я сознательно саботировал любое обучение DOS, то оставалось одно — выковыривание списков точек и элементов руками. Какие-то попытки автоматизации я делал, но они были весьма условны.
Вторая проблема за раз
Как я уже говорил в прошлой статье, в процессе ковыряния в экзешнике я не раз сталкивался с тем, что у некоторых объектов указан список элементов, но нет списка точек (т.е. описание модельки обрывается на названии объекта, и после него сразу начинается список элементов следующей модельки). Но откуда-то же они должны браться! Логика подсказывала, что они или во внешних файлах ресурсов или все же почему-то архивированы внутри экзешника. Второй вариант был сознательно отброшен — помним про нежелание лезть в архитектуру. И я начал изучать внешние файлы — те, которые были распакованы утилиткой, написанной на Nim.
И пожалел, что не сделал этого раньше. Оказалось, для каждого объекта из игры есть ресурсный файлик формата PNT. Я их, естественно, видел ранее, но я думал, что это paint — типа как красить, текстуры какие-то. Даже искал графический редактор тех времен под DOS, который поддерживает такое расширение.
Много файлов .PNT
Но ведь в описании элемента уже указывается цвет (и даже прозрачность, да)! Так что это явно не текстуры. И, скорее всего, это не paint, а points, мда.
И так и оказалось. Каждый файл pnt — это именно список точек (или два, если у объекта есть лоу-поли и мид-поли модель) объекта. Для каждого объекта в игре, в том числе и тех, для которых список точек уже приведен в экзешнике. Тут, признаться, я немного не понял стратегии EA — зачем? Вроде ж как должны свободное место бороться… Но потом решил, что это решение было предвестником наступающих (лет через 30) игр размером в десятки и сотни гигабайт.
Так или иначе, pnt-файлы содержали описание точек в немного в другом формате:
каждая координата длиной уже не в байт, а в два. В kaitai это решается заменой в одном месте строки «s1» на «s2».
после списка точек модели идет какой-то набор байт… В общем, это был не первый и далеко не последний раз, когда я разобрался с тем, что эти байты есть и сколько их, но не разобрался, зачем они. У меня была гипотеза про BSP -, но ровно так же я мог иметь гипотезу про XYZ или ЭЮЯ. По итогу я этот набор отслеживал и потом скипал. В названии упоминается BSP -, но 98% что это не оно.
в начале файла есть заголовок, содержащий только адреса блоков с точками и вот этими, как я решил, bsp — элементами.
Kaitai-описание структуры точек (и одновременно файла формата .PNT)
Как я уже написал, в самом файле помимо точек, содержатся еще какие‑то данные, которые я так и не расшифровал. При этом возникали они по довольно логичной, но не совсем уж очевидной схеме — ее останки разбросаны по комментам и док‑строчкам. Я не стал их убирать ради историчности и отображения собственно того, как делать комменты и док‑строчки.
meta:
id: lhx_points
file-extension: pnt
endian: le
seq:
- id: header
type: header_info
#s1
- id: dotcloud_low
type: dotcloud
size: header.set1_end_offset - header.set1_start_offset
# bsp 1_1
- id: dotcloud_low_bsp_1
doc: bsp 1 till the end of file -> s110s200
if: header.set1_bsp_end_offset == 0 and header.set2_start_offset == 0
type: u1
repeat: eos
# bsp 1_2
- id: dotcloud_low_bsp_2
doc: bsp 1 between set 1 and addon 1 -> s111s200 s111s210 s111s210
if: header.set1_bsp_end_offset != 0
type: u1
repeat: expr
repeat-expr: header.set1_bsp_end_offset - header.set1_end_offset
# bsp 1_3
- id: dotcloud_low_bsp_3
doc: bsp 1 between s1 and s2 -> s110s210 s110s211
if: header.set1_bsp_end_offset == 0 and header.set2_start_offset != 0
type: u1
repeat: expr
repeat-expr: header.set2_start_offset - header.set1_end_offset
# a 1_1 == not exist -> s110s200 s110s210 s110s111
# a 1_2
- id: dotcloud_low_addon_2
doc: addon 1 till end of file -> s111s200
if: header.set1_bsp_end_offset != 0 and header.set2_start_offset == 0
type: u1
repeat: eos
# a 1_3
- id: dotcloud_low_addon_3
doc: addon between bsp 1 and set 2 -> s111s210 s111s211
if: header.set1_bsp_end_offset != 0 and header.set2_start_offset != 0
type: u1
repeat: expr
repeat-expr: header.set2_start_offset - header.set1_bsp_end_offset
#s2
- id: dotcloud_med
type: dotcloud
if: header.set2_start_offset != 0
size: header.set2_end_offset - header.set2_start_offset
# bsp 2_1 == not exist -> s110s200 s111s200
# bsp 2_2
- id: dotcloud_med_bsp_2
doc: bsp 2 till end of file -> s110s210 s111s210
if: header.set2_start_offset != 0 and header.set2_bsp_end_offset == 0
type: u1
repeat: eos
# bsp 2_3
- id: dotcloud_med_bsp_3
doc: bsp 2 between set 2 and addon 2
if: header.set2_start_offset != 0 and header.set2_bsp_end_offset != 0
type: u1
repeat: expr
repeat-expr: header.set2_bsp_end_offset - header.set2_end_offset
# a 2_1 == not exist -> s110s200 s111s200 s110s210 s111s210
- id: dotcloud_med_addon_2
doc: addon 2 till end of file -> s110s211 s111s211
if: header.set2_start_offset != 0 and header.set2_bsp_end_offset != 0
type: u1
repeat: eos
types:
header_info:
seq:
- id: zerostart
type: u2
- id: set1_start_offset
type: u2
doc: Number of bytes to skip from the beginning of file.
- id: set2_start_offset
type: u2
doc: Number of bytes to skip from the beginning of file. Equals zero if there is no set 2
- id: set1_end_offset
type: u2
doc: Number of bytes to skip from the beginning of file.
- id: set2_end_offset
type: u2
doc: Number of bytes to skip from the beginning of file. Equals zero if there is no set 2
- id: set1_bsp_end_offset
type: u2
- id: set2_bsp_end_offset
type: u2
- id: strange3
type: u2
- id: strange4
type: u2
dotcloud:
seq:
- id: dots
type: dot
repeat: eos
dot:
seq:
- id: x
type: s2
- id: z
type: s2
- id: y
type: s2
Таким образом, набор точек для каждого объекта я нашел. А наборы элементов для каждого объекта были вшиты в экзешнике. Я их путем тупого гринда выковырял и сохранил. Для каждой модельки получилось 2 файла ‑.pnt (просто скопированный из игровых файлов) и .bin (вручную скопированный из экзешника дамп с элементами. В нем даже XREF не вырезан).
Формально, цель достигнута!
Звучат фанфары, дофамин растекается по мозгу, солнце светит на 24% ярче. Было — ничего, стало — исходники моделек. Которые лежат вот тут.
Вынесенные уроки
Философия
Если реверсишь просто данные — то надо бы понимать доменную область того, что реверсишь. Потому что на руках у тебя, фактически, заполненная анкета, у которой удалили вопросы, но оставили ответы (причем в довольно туманном формате). И весь реверс данных по сути своей состоит именно в угадывании исходных метаданных (вопросов анкеты). И если не знаешь, что ищешь — то искомое может лежать прямо перед глазами, но ты просто не поймешь, что это именно оно.
Бекграунд
Чтобы заниматься всеми означенными фокусами — очень неплохо бы знать нюансы, связанные с битами, байтами, словами, индианскими порядками, дополнительными кодами и остальной битовщиной.
Общие моменты
Если значения данных меняются, но всегда находятся на одинаковых позициях — это структурированный блок данных. Надо постараться формализовать эту структуру — какой длины данные на каких местах стоят. А после этого — разбираться с каждым элементом структуры по очереди. При этом можно оказаться в ситуации, когда:
Вся структура формализована, но непонятно, за что отвечает какие‑то из ее элементов.
Вся структура формализована, но непонятно, за что отвечают все ее элементы.
Структура непонятна совсем, можно только знать ее общий размер.
Если определенный блок данных предполагает вариабельность — то есть если это список/вектор, а не массив, то в данных будет содержаться способ показать размер этого блока. Это может быть:
Счетчик перед списком. Но необязательно вплотную.
Указание смещения относительно какой‑то точки. Точка обычно одна — это начало файла, нулевой элемент в массиве с содержимым файла. Поэтому смещения скорее всего будут также указаны в начале файла — в заголовке.
Заголовки файлов
Если возишься с файлами — и первые цифры не очень различаются между файлами — возможно, это не данные, а заголовок. Обычно заголовок — это какие‑то конфигурационные вещи и адреса блоков в файле (по крайней мере, первых из экземпляров). Пара нюансов разбирания заголовков:
Постараться найти маленькие файлы, плюс в идеале — какой‑нибудь вырожденный случай (типа файла со списком точек, в котором только одна точка).
Брать значения из начала (особенно повторяющиеся) — и отсчитывать расстояния от начала файла. Если совпадет с чем‑нибудь очевидным — то это смещение.
Небольшой пример
Иллюстративные 6 скринов с содержимым файлов точек
Верхняя строчка на всех картинках смотрится более-менее одинаково, особенно первые 4 байта — 00 00 12 00. Только в одном случае (CITY.PNT) там проскакивает 08, но закроем на это глаза. Если предположить, что 0×12(18 в десятичной) — это расстояние до чего-то и посчитать эти 19 байт (zero-based index) с начала файла пальчиком — то видно, что как раз 18 по счету значение — меняется от файла к файлу. А вот 17 и 18 — всегда нули, что еще больше укрепляет нас в этой мысли.
Но кроме 12 в первой строке встречаются и другие числа — с разным значением, но в одной позиции. И если присмотреться — то они почти всегда возрастают от левого к правому, и нет почти ни одного меньше 12. Похоже на то, что это тоже расстояния, причем чем дальше положение в заголовке — тем выше значение. Что логично, файл-то линейный, и данные находятся все дальше и дальше.
Но все же! Встречаются же »01»,»02»! А больше 02 и нету. А может это не самостоятельные байты? А может, это старшие разряды идущих перед ними байтов? В IBM PC порядок байт — задом наперед же, сначала младшие байты, потом старшие. То есть, 91 02 читается как 02 91 = 0×291. Что явно больше 12 00 = 0×12. То есть, во-первых и предыдущая догадка про смещения подкрепляется, и тип данных, получается, не байт, а слово (которое 2 байта).
Так что надо считать пальчиками и угадывать назначение блоков…
Cliffhanger
Но… как же похвастаться этими модельками? Как посмотреть полгода спустя, когда все нюансы уже забыты? А может, захочется их распечатать на 3D‑принтере? Поэтически выражаясь, я выучил новый язык и понял сказки на нем —, но как рассказать их тем, кто этого языка не знает?