diff --git a/.gitignore b/.gitignore index 1d724f9c..c1fa4a06 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ build-win/ tools/chatgui/build/ tools/chatgui/ffmpeg/ffmpeg_build/ +tools/lightsout/build/ # Build outputs *.o diff --git a/BUGS_proxy.txt b/BUGS_proxy.txt new file mode 100644 index 00000000..cbc8f318 --- /dev/null +++ b/BUGS_proxy.txt @@ -0,0 +1,33 @@ +Ниже — подтверждённые по коду проблемы; отдельно указал, что воспроизведено запуском. + +[P1] Exit смешивает TCP-потоки разных клиентов. Поиск соединения использует только stream_id, хотя каждый клиент начинает нумерацию заново. После подключения второго клиента с таким же ID данные первого могут уйти в соединение второго; FIN/CLOSE также закрывают чужой поток. Нужен ключ (src_node_id, stream_id) во всех обработчиках, включая проверку повторного CONNECT. src/proxy/tcp_proxy_server.c:342 + +[P1] Клиент не проверяет отправителя ответов. DATA/FIN/CLOSE принимаются по одному stream_id, без сравнения источника с via_node_id. Обработка CLOSE_ALL читает peer_id, но затем уничтожает вообще все клиентские соединения. Пакеты другого узла, доставленные этому сервису, могут повредить или закрыть существующие потоки. src/proxy/tcp_proxy_client.c:668 + +[P1, воспроизведено] UDP-ответы в TUN имеют неправильную checksum. Обнуляется только IP-заголовок; байты UDP checksum остаются заполненными аллокатором значением 0xAAAA. Проверка сформированного пакета дала сумму 0xC1E2 вместо 0xFFFF. Такие ответы будут отбрасываться принимающим стеком. Нужно рассчитывать checksum либо явно устанавливать ноль для IPv4. src/proxy/udp_proxy.c:188 + +[P1, воспроизведено] ICMP с нечётной длиной payload получает неверную checksum. Цикл читает последнее 16-битное слово целиком, захватывая байт за пределами выделенного пользовательского буфера. Ошибка есть и на exit, и при восстановлении ответа клиенту. Для payload длиной 2 проверочная сумма получилась FFFF, длиной 3 — FF10. Отправка (src/proxy/icmp_proxy.c:80), ответ в TUN (src/proxy/icmp_proxy.c:285) + +[P1] UDP/ICMP-контексты глобальные, а ядро поддерживает несколько instances. Следующий init() перезаписывает g_udp_ctx/g_icmp_ctx; обработчики старого экземпляра используют новый контекст, а destroy(inst) освобождает его без проверки владельца. Дополнительно UDP различает запрос и ответ только по глобальному is_exit: узел, одновременно работающий клиентом и exit, трактует входящий ответ как новый исходящий запрос. src/proxy/udp_proxy.c:137, инициализация (src/proxy/udp_proxy.c:237), src/proxy/icmp_proxy.c:321 + +[P1] Нет сквозного ограничения потока при медленном получателе. Принятые ETCP-данные без ограничения добавляются в write_queue сокета или to_lwip. Заполненность этих очередей не останавливает доставку и подтверждение данных маршрутизатором. Медленный destination или локальный клиент приводит к росту памяти; при отказе выделения TCP-данные просто теряются. src/proxy/tcp_proxy_server.c:424, src/proxy/tcp_proxy_client.c:584 + +[P1] TUN-клиент может уничтожить ещё не отправленные данные при FIN. Если локальный FIN уже получен, но tx_queue остаётся заблокированной, входящий FIN от exit вызывает conn_finish() без проверки этой очереди. Она освобождается вместе с данными. Завершать поток нужно после опустошения очередей обоих направлений. src/proxy/tcp_proxy_client.c:642 + +[P1] Exit может отправить CLOSE раньше остатка ответа. В on_fin_cb() проверка tc->fin_local имеет приоритет над pend_r. Если клиент уже закрыл свою половину, а ответ destination остался в tx_buf из-за backpressure, сервер отправляет CLOSE и освобождает остаток. Ветка on_flushed_cb() также не проверяет ожидающие отправки данные ответа. src/proxy/tcp_proxy_server.c:108 + +[P1] HTTP CONNECT зависит от границ TCP-чтений. Парсер запускает туннель после первой \r\n, не дожидаясь \r\n\r\n. Если заголовки приходят следующим чтением, они пересылаются destination как содержимое туннеля — например, перед TLS ClientHello. Если вместе с заголовками уже пришли данные туннеля, они уничтожаются обнулением buf_len. src/proxy/socks_proxy.c:195 + +[P1] HTTP POST во время DNS может потерять весь накопленный запрос. dns_pending накапливается до 65535 байт, затем отправляется одним DATA. Exit принимает максимум 8192 байта в одном сообщении и отбрасывает превышение. При переполнении самого dns_pending очередные данные также теряются без прекращения потока. Нужны ограниченная очередь и отправка частями. src/proxy/socks_proxy.c:339, накопление (src/proxy/socks_proxy.c:430), ограничение exit (src/proxy/tcp_proxy_server.c:438) + +[P2] FIN теряется в двух штатных сценариях. SOCKS/HTTP при непустой очереди ответа вызывает tcp_conn_set_flushed(tc, NULL) — продолжение, которое должно переслать FIN, отсутствует. Exit молча игнорирует FIN, пришедший до завершения TCP connect. Протоколы, ожидающие EOF перед ответом, могут зависнуть. src/proxy/socks_proxy.c:538, src/proxy/tcp_proxy_server.c:492 + +[P2] SOCKS5-парсер некорректно обрабатывает запросы. После greeting/CONNECT сбрасывается весь буфер, включая следующие байты; всегда выбирается метод NO AUTH, даже если клиент его не предлагал. IPv6 вместо отказа превращается в IPv4 из неправильного смещения buf + 12. Доменный CONNECT длиной менее 10 байт бесконечно ожидает продолжения. src/proxy/socks_proxy.c:132 + +[P2] SOCKS/HTTP сообщают об успешном соединении до подключения exit. Ответ SOCKS success или HTTP 200 формируется даже до send_connect(). Подтверждения успешного TCP connect от exit в протоколе нет. При отказе подключения клиент сначала получает успех, затем закрытие вместо корректной ошибки. src/proxy/socks_proxy.c:316 + +[P2] После RST TUN-соединение остаётся в списке. tcp_proxy_client_err_cb() обнуляет pcb и выставляет error, но не освобождает pc. Очистка предусмотрена в poll callback уже уничтоженного PCB, который больше не вызовется. src/proxy/tcp_proxy_client.c:446 + +[P2] Неправильно разбираются IP options и фрагменты в TUN. UDP/ICMP используют фиксированные смещения от 20-байтового IPv4-заголовка, игнорируя IHL и fragment offset. Пакет с options или последующий IP-фрагмент превращается в запрос с неверными портами/данными. src/proxy/tcp_proxy_client.c:158 + +[P2] ICMP-ответы разных клиентов могут перепутаться. Exit сохраняет исходные echo_id/echo_seq и ищет ответ только по этой паре, без уникального преобразования ID и проверки адреса отправителя. Совпадающие ping-запросы разных клиентов получают чужие ответы. src/proxy/icmp_proxy.c:56 diff --git a/tools/lightsout-android/.gitignore b/tools/lightsout-android/.gitignore new file mode 100644 index 00000000..9a0fe6e9 --- /dev/null +++ b/tools/lightsout-android/.gitignore @@ -0,0 +1,7 @@ +build/ +.gradle/ +*.apk +*.iml +.idea/ +local.properties +.cxx/ diff --git a/tools/lightsout-android/app/build.gradle.kts b/tools/lightsout-android/app/build.gradle.kts new file mode 100644 index 00000000..be69304d --- /dev/null +++ b/tools/lightsout-android/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.utun.lightsout" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + defaultConfig { + applicationId = "com.utun.lightsout" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.5" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } +} + +dependencies { + implementation(platform("androidx.compose:compose-bom:2024.02.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.activity:activity-compose:1.8.2") +} diff --git a/tools/lightsout-android/app/src/main/AndroidManifest.xml b/tools/lightsout-android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..ac0c0773 --- /dev/null +++ b/tools/lightsout-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/tools/lightsout-android/app/src/main/java/com/utun/lightsout/LightsOutGame.kt b/tools/lightsout-android/app/src/main/java/com/utun/lightsout/LightsOutGame.kt new file mode 100644 index 00000000..dbdc7df9 --- /dev/null +++ b/tools/lightsout-android/app/src/main/java/com/utun/lightsout/LightsOutGame.kt @@ -0,0 +1,77 @@ +package com.utun.lightsout + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.setValue +import kotlin.random.Random + +// Логика игры «Выключить свет»: хранит истинные состояния клеток (real, 0/1), +// отображаемое значение каждой клетки — XOR её самой с четырьмя соседями. +// Поле хранится как Compose-наблюдаемый список, чтобы клики перерисовывали доску. +class LightsOutGame(initialSize: Int = 5) { + var size: Int = initialSize + private set + + val real = mutableStateListOf() + + var moves by mutableIntStateOf(0) + private set + + init { + newGame(initialSize) + } + + // Новая партия: сброс поля и случайная решаемая позиция. + fun newGame(size: Int) { + this.size = size + real.clear() + repeat(size * size) { real.add(0) } + moves = 0 + scramble() + } + + // Переключает клетку, инкрементирует счётчик, возвращает true, если решено. + fun toggle(row: Int, col: Int): Boolean { + val idx = row * size + col + real[idx] = real[idx] xor 1 + moves++ + return isSolved() + } + + // Отображаемое значение: XOR клетки с четырьмя соседями (с учётом границ). + fun display(row: Int, col: Int): Int { + var v = real[row * size + col] + if (row > 0) v = v xor real[(row - 1) * size + col] + if (row < size - 1) v = v xor real[(row + 1) * size + col] + if (col > 0) v = v xor real[row * size + (col - 1)] + if (col < size - 1) v = v xor real[row * size + (col + 1)] + return v + } + + // Истинное состояние клетки (для подсказки). + fun isReallyOff(row: Int, col: Int): Boolean = real[row * size + col] == 0 + + // Победа — все отображаемые клетки погашены. + fun isSolved(): Boolean { + for (r in 0 until size) { + for (c in 0 until size) { + if (display(r, c) != 0) return false + } + } + return true + } + + // Позиция строится случайными одиночными переключениями из нулевого состояния — + // обратная последовательность ходов гарантированно решает её. + private fun scramble() { + do { + for (i in real.indices) real[i] = 0 + val scrambles = size * size * 3 + repeat(scrambles) { + val idx = Random.nextInt(size * size) + real[idx] = real[idx] xor 1 + } + } while (isSolved()) + } +} diff --git a/tools/lightsout-android/app/src/main/java/com/utun/lightsout/MainActivity.kt b/tools/lightsout-android/app/src/main/java/com/utun/lightsout/MainActivity.kt new file mode 100644 index 00000000..311e6608 --- /dev/null +++ b/tools/lightsout-android/app/src/main/java/com/utun/lightsout/MainActivity.kt @@ -0,0 +1,206 @@ +package com.utun.lightsout + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +private val OnColor = Color(0xFFFFD65C) // «включена» — тёплый жёлтый +private val OffColor = Color(0xFF2C3038) // «выключена» — тёмно-серый +private val HintColor = Color(0xFF78DC78) // подсказка — зелёная точка + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + LightsOutApp() + } + } + } +} + +@Composable +fun LightsOutApp() { + var game by remember { mutableStateOf(null) } + var hintEnabled by remember { mutableStateOf(false) } + var showDialog by remember { mutableStateOf(true) } + var solved by remember { mutableStateOf(false) } + + // Диалог выбора размера: при старте и по кнопке «Новая игра». + if (showDialog || game == null) { + val current = game?.size ?: 5 + var selected by remember(current) { mutableIntStateOf(current) } + val isInitial = game == null + AlertDialog( + onDismissRequest = { if (!isInitial) showDialog = false }, + title = { Text(if (isInitial) "Выключить свет" else "Новая игра") }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "$selected × $selected", + style = MaterialTheme.typography.headlineMedium + ) + Slider( + value = selected.toFloat(), + onValueChange = { selected = it.roundToInt() }, + valueRange = 3f..8f, + steps = 4 + ) + Text( + "Размер поля", + style = MaterialTheme.typography.bodyMedium + ) + } + }, + confirmButton = { + TextButton(onClick = { + game = LightsOutGame(selected) + hintEnabled = false + solved = false + showDialog = false + }) { + Text(if (isInitial) "Играть" else "Начать") + } + }, + dismissButton = { + if (!isInitial) { + TextButton(onClick = { showDialog = false }) { Text("Отмена") } + } + } + ) + } + + game?.let { g -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Ходы: ${g.moves}", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(16.dp)) + + Board( + game = g, + hintEnabled = hintEnabled, + onCellTap = { r, c -> + if (g.toggle(r, c)) { + solved = true + } + } + ) + + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton(onClick = { hintEnabled = !hintEnabled }) { + Text(if (hintEnabled) "Скрыть подсказку" else "Подсказка") + } + Button(onClick = { showDialog = true }) { + Text("Новая игра") + } + } + } + } + + if (solved) { + val g = game ?: return@LightsOutApp + AlertDialog( + onDismissRequest = { solved = false }, + title = { Text("Победа!") }, + text = { Text("Свет выключен за ${g.moves} ходов.") }, + confirmButton = { + Button(onClick = { showDialog = true }) { Text("Новая игра") } + }, + dismissButton = { + TextButton(onClick = { solved = false }) { Text("Ок") } + } + ) + } +} + +@Composable +private fun Board( + game: LightsOutGame, + hintEnabled: Boolean, + onCellTap: (Int, Int) -> Unit +) { + val n = game.size + Column( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + for (r in 0 until n) { + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + for (c in 0 until n) { + val on = game.display(r, c) != 0 + val hint = hintEnabled && game.isReallyOff(r, c) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(RoundedCornerShape(6.dp)) + .background(if (on) OnColor else OffColor) + .border(1.dp, Color(0xFF14161A), RoundedCornerShape(6.dp)) + .clickable { onCellTap(r, c) }, + contentAlignment = Alignment.Center + ) { + if (hint) { + Box( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(HintColor) + ) + } + } + } + } + } + } +} diff --git a/tools/lightsout-android/app/src/main/res/values/strings.xml b/tools/lightsout-android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..21ce5f48 --- /dev/null +++ b/tools/lightsout-android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Выключить свет + diff --git a/tools/lightsout-android/app/src/main/res/values/themes.xml b/tools/lightsout-android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..e3cd9bd5 --- /dev/null +++ b/tools/lightsout-android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +