#!/usr/bin/env python3 """Независимый решатель задачи 4 «Он немного изменился» (CREF + CTM4). Вход: участнический PNG. Метаданные намеренно НЕ раскрывают параметры кодирования — решатель восстанавливает всё из данных. Логика: 1. ручной разбор PNG-чанков (struct), проверка CRC каждого чанка; 2. поиск приватного чанка caTs, разбор конверта CREF (magic, version=1, length, sha256), извлечение чистого оригинала; 3. размер плитки — из выравнивания diff эталона и результата: для кандидатов 4/8/16 каждая изменённая плитка обязана совпасть ровно с одним из 4 циклических сдвигов эталонной плитки на 1 пиксель; размер, прошедший проверку для ВСЕХ изменённых плиток, — рабочий; 4. сетка плиток от (0,0), обход по строкам; пригодность считается ТОЛЬКО по оригиналу (4 сдвига попарно различны, ни один не равен исходнику, max |дельта| по каналам <= 4); 5. для каждой пригодной плитки изменённое изображение сравнивается с 4 кандидатами (up/right/down/left): ровно одно совпадение, иначе отказ; 6. карта направлений — из калибровки: первые 4 пригодные плитки дают up,right,down,left, то есть 00=up 01=right 10=down 11=left; 7. далее пары бит (старшая пара первой) -> конверт CTM4, узнаваемый по magic в самих байтах (version=1, payload_length u32 LE, payload, crc32 u32 LE); 8. печать payload (флаг + шутка). Зависимости: stdlib + Pillow + numpy. Генератор не импортируется, файлы организатора не читаются. """ import binascii import hashlib import struct import sys import numpy as np from PIL import Image TILE_CANDIDATES = (4, 8, 16) MAXDIFF = 4 MAX_PAYLOAD = 1 << 20 # 1 МиБ PNG_SIG = b"\x89PNG\r\n\x1a\n" # up=00, right=01, down=10, left=11 DIRECTIONS = ("up", "right", "down", "left") class SolveError(Exception): pass def parse_chunks(raw): """Ручной разбор PNG-чанков с проверкой CRC. Без Pillow.""" if raw[:8] != PNG_SIG: raise SolveError("неверная сигнатура PNG") chunks = [] off = 8 while True: if off + 12 > len(raw): raise SolveError("усечённый PNG (нет IEND)") (length,) = struct.unpack(">I", raw[off:off + 4]) if length > len(raw) - off - 12: raise SolveError("длина чанка выходит за границы файла") ctype = raw[off + 4:off + 8] data = raw[off + 8:off + 8 + length] (crc_stored,) = struct.unpack(">I", raw[off + 8 + length:off + 12 + length]) crc_real = binascii.crc32(ctype + data) & 0xFFFFFFFF if crc_real != crc_stored: raise SolveError(f"неверная CRC чанка {ctype!r}") chunks.append((ctype, data)) off += 12 + length if ctype == b"IEND": break if off != len(raw): raise SolveError("лишние байты после IEND") return chunks def extract_original(chunks): """Находит caTs, разбирает CREF, проверяет length и sha256.""" cats = [d for t, d in chunks if t == b"caTs"] if len(cats) != 1: raise SolveError(f"ожидался ровно один чанк caTs, найдено {len(cats)}") env = cats[0] if len(env) < 4 + 1 + 4 + 32: raise SolveError("CREF: короткий конверт") if env[:4] != b"CREF": raise SolveError("CREF: неверный magic") if env[4] != 1: raise SolveError(f"CREF: version {env[4]} != 1") (length,) = struct.unpack(" MAXDIFF: return False return True def eligible_tiles(orig, tile): h, w = orig.shape[:2] out = [] for gy in range(h // tile): for gx in range(w // tile): t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile] if tile_eligible(t): out.append((gx, gy)) return out def changed_tiles(orig, mod, tile): """Индексы (gx, gy) плиток, содержащих хотя бы один изменённый пиксель.""" h, w = orig.shape[:2] th, tw = h // tile, w // tile diff = np.any(orig[:th * tile, :tw * tile] != mod[:th * tile, :tw * tile], axis=2) hit = diff.reshape(th, tile, tw, tile).any(axis=(1, 3)) return [(gx, gy) for gy in range(th) for gx in range(tw) if hit[gy, gx]] def check_size_candidate(orig, mod, tile): """Быстрый отсев кандидата по выравниванию diff: каждая изменённая плитка обязана быть ровно одним из 4 циклических сдвигов эталонной.""" for gx, gy in changed_tiles(orig, mod, tile): t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile] m = mod[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile] hits = [d for d in range(4) if np.array_equal(shift_tile(t, d), m)] if len(hits) != 1: raise SolveError( f"размер {tile}x{tile} отклонён: плитка (gx={gx} gy={gy}): " f"совпадений с кандидатами {len(hits)}, ожидалось ровно 1") def decode_with_tile(orig, mod, tile): """Полное декодирование при заданном размере плитки: пригодность, калибровка, пары бит, конверт CTM4. Возвращает (payload, n_elig, n_used).""" elig = eligible_tiles(orig, tile) def read_dir(idx): gx, gy = elig[idx] t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile] m = mod[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile] hits = [d for d in range(4) if np.array_equal(shift_tile(t, d), m)] if len(hits) != 1: raise SolveError( f"плитка #{idx} (gx={gx} gy={gy}): совпадений с кандидатами " f"{len(hits)}, ожидалось ровно 1") return hits[0] # Калибровка: первые 4 пригодные плитки — up, right, down, left. for i in range(4): if len(elig) <= i: raise SolveError("слишком мало пригодных плиток для калибровки") d = read_dir(i) if d != i: raise SolveError( f"калибровка не сошлась: плитка #{i} = {DIRECTIONS[d]}, " f"ожидалось {DIRECTIONS[i]}") # Пары бит: в байте сначала старшая пара. 4 плитки = 1 байт. pairs = [] total = None # полная длина конверта в байтах, станет известна после заголовка idx = 4 header_len = 4 + 1 + 4 while True: have = len(pairs) // 4 if total is not None and have >= total: break if total is None and have >= header_len: hdr = bytes() for k in range(header_len): hdr += bytes([pairs[4 * k] << 6 | pairs[4 * k + 1] << 4 | pairs[4 * k + 2] << 2 | pairs[4 * k + 3]]) if hdr[:4] != b"CTM4": raise SolveError("CTM4: неверный magic") if hdr[4] != 1: raise SolveError(f"CTM4: version {hdr[4]} != 1") (plen,) = struct.unpack(" MAX_PAYLOAD: raise SolveError(f"CTM4: payload_length {plen} превышает лимит") total = header_len + plen + 4 continue if idx >= len(elig): raise SolveError("конверт CTM4 не поместился в пригодные плитки") pairs.append(read_dir(idx)) idx += 1 data = bytearray() for k in range(total): data.append(pairs[4 * k] << 6 | pairs[4 * k + 1] << 4 | pairs[4 * k + 2] << 2 | pairs[4 * k + 3]) (plen,) = struct.unpack("", file=sys.stderr) return 2 try: payload, n_elig, n_used = solve(argv[1]) except (SolveError, OSError) as e: print(f"ошибка: {e}", file=sys.stderr) return 1 text = payload.decode("utf-8", errors="strict") print(f"пригодных плиток: {n_elig}, использовано: {n_used}", file=sys.stderr) print(text) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))