MacPaint, program graficzny na pierwszą wersję komputerów Macintosh, stanowił kamień milowy w historii grafiki komputerowej. Chociaż jego funkcje były rewolucyjne, były również ograniczone przez sprzęt, na którym działał. Najbardziej oczywistym ograniczeniem był brak koloru. Dokumenty MacPaint były wyłącznie czarno-białe, a same pliki miały stały rozmiar 576 pikseli szerokości i 720 pikseli wysokości. Dodatkowo, oryginalny Macintosh nie posiadał dysku twardego, a całość operacji opierała się na dyskietkach, które miały bardzo ograniczoną pojemność.
Ze względu na tę ograniczoną przestrzeń, MacPaint musiał wykorzystać schemat kompresji zwany kodowaniem długości biegu (run-length encoding), aby zoptymalizować wykorzystanie pamięci. Współczesny użytkownik może patrzeć na ten format z dystansem, ale w kontekście tamtych czasów, był to niesamowicie efektywny sposób przechowywania danych. W tej części omówimy, jak działa format pliku MacPaint i jak przekształcać dane obrazu do tego unikalnego formatu.
Podstawy formatu MacPaint
Plik MacPaint składa się z dwóch głównych części: nagłówka oraz danych pikseli. Nagłówek ma 512 bajtów, a dane pikseli są kompresowane za pomocą kodowania długości biegu. Każdy piksel jest przechowywany jako pojedynczy bit: 1 oznacza kolor czarny, a 0 kolor biały. Taki sposób reprezentacji pozwala na znaczne zaoszczędzenie miejsca, ponieważ dla każdego piksela potrzebujemy tylko 1 bit, zamiast pełnego bajtu, jak w wielu współczesnych formatach graficznych.
Ważnym aspektem, który może być niezrozumiany przez współczesnych programistów, jest sposób przechowywania plików w systemie klasycznego Mac OS. Zamiast standardowego jednego pliku, jak w większości systemów operacyjnych, Mac OS używał dwóch "widoków" pliku, znanych jako "forki". Pierwszy fork zawierał dane pliku, a drugi – metadane. Aby plik MacPaint mógł zostać przeniesiony do innych systemów operacyjnych (np. Windows), musiał być zakodowany w specjalnym formacie MacBinary. Tylko wtedy dane i metadane byłyby poprawnie zachowane podczas transferu.
Konwersja obrazów do formatu MacPaint
Aby zamienić dane pikseli na format MacPaint, potrzebujemy kilku etapów. Pierwszym krokiem jest konwersja danych obrazu do postaci bitów. W programie MacPaint każdy bajt reprezentuje 8 pikseli. Zatem nasza tablica bajtów (gdzie każdy bajt odpowiada wartości 0 lub 255, czyli czarnemu lub białemu pikselowi) musi zostać przekonwertowana na tablicę bitów, gdzie każdy bit reprezentuje pojedynczy piksel. Po dokonaniu tej konwersji, konieczne jest dodanie "pustych" pikseli, jeśli obraz nie wypełnia pełnej szerokości 576 pikseli lub pełnej wysokości 720 pikseli.
Po przeprowadzeniu konwersji bitów, obraz może być zapisany jako plik MacPaint, ale w tej postaci nie będzie jeszcze odpowiednio zoptymalizowany pod kątem przestrzeni dyskowej. Aby rozwiązać ten problem, stosuje się technikę kompresji znaną jako kodowanie długości biegu.
Kodowanie długości biegu (Run-Length Encoding)
Kodowanie długości biegu jest prostą, ale skuteczną metodą kompresji, polegającą na zapisaniu powtórzeń tego samego elementu zamiast samego elementu. Na przykład, zamiast zapisywać serię identycznych pikseli (np. pięciu białych pikseli z rzędu), zapisujemy liczbę ich powtórzeń i sam kolor. Załóżmy, że mamy następujący ciąg: 00000011110000. Zamiast zapisywać wszystkie 16 bitów, zapisujemy po prostu 6 zer, 4 jedynki i 6 zer. Dzięki temu ciąg ten będzie zajmował tylko 3 bajty zamiast 16.
Przykład z rzeczywistego obrazu: jeśli macierz pikseli w jednym wierszu zawiera wiele powtórzeń jednego koloru, kompresja pozwala na zapisanie tych powtórzeń w postaci liczby i koloru, a nie samego ciągu pikseli. Dla systemów takich jak oryginalny Macintosh, które miały bardzo ograniczoną pojemność pamięci i dysków, taka kompresja była kluczowa, aby zmieścić więcej danych w ograniczonej przestrzeni.
Dzięki kompresji kodowania długości biegu, plik MacPaint, który bez kompresji zajmowałby 52,352 bajty, może zostać znacznie zmniejszony, co umożliwia zapisanie większej liczby obrazów na ograniczonej przestrzeni dyskowej.
Wyzwania związane z kodowaniem
Choć na pierwszy rzut oka format MacPaint i jego metoda kompresji mogą wydawać się prostymi technikami, w praktyce wprowadzenie tych mechanizmów do kodu wiąże się z licznymi wyzwaniami. Programowanie na poziomie bitów wymaga precyzyjnego manipulowania danymi, co może być skomplikowane, zwłaszcza jeśli nie ma się doświadczenia w operacjach bitowych.
Zrozumienie, jak działa kodowanie długości biegu, wymaga nie tylko znajomości algorytmów, ale także dokładnego przemyślenia, jak najlepiej przechowywać dane w pamięci. W końcu komputer musi nie tylko przechować dane w formacie, który będzie efektywny pod względem miejsca, ale także zapewnić ich późniejsze odczytanie bez błędów, co może być wyzwaniem, gdy operujemy na niskim poziomie.
MacPaint w kontekście współczesnych technologii
Choć technologia MacPaint jest dzisiaj przestarzała, warto zrozumieć, dlaczego była tak innowacyjna w swoim czasie. Działała na komputerach, które miały minimalne zasoby i zmuszały twórców oprogramowania do optymalizacji na każdym etapie – od algorytmów po formaty plików. Dzisiejsze technologie komputerowe są znacznie bardziej zaawansowane, ale lekcje płynące z takich formatów, jak MacPaint, są nadal cenne dla programistów i architektów systemów komputerowych.
MacPaint może być również interesującym przypadkiem w historii grafiki komputerowej, pokazując, jak ograniczenia sprzętowe wpłynęły na twórczość oprogramowania. To doskonały przykład, jak z ograniczonych zasobów można wycisnąć maksimum, tworząc narzędzia, które mimo swojej prostoty miały ogromny wpływ na rozwój cyfrowych obrazów.
Jak działa zarządzanie pamięcią w NES?
W systemie NES zarządzanie pamięcią jest skomplikowane, a zrozumienie różnych trybów adresowania i podziału pamięci jest kluczowe dla każdego, kto chce stworzyć emulator lub zrozumieć, jak działa ta kultowa konsola. Pamięć NES jest podzielona na kilka regionów, z których każdy pełni inną funkcję – od RAM-u po rejestry PPU (Picture Processing Unit), a także różne urządzenia wejściowe/wyjściowe i przestrzeń kartuszy. Przeanalizujmy, jak te różnice w strukturze pamięci wpływają na sposób odczytu i zapisu danych.
Począwszy od podstawowych trybów adresowania, zauważmy, że w architekturze 6502 (używanej w NES) mamy kilka sposobów dostępu do pamięci. Istnieją tryby takie jak INDIRECT_INDEXED, w którym adres w pamięci RAM jest dodawany do rejestru Y, tworząc ostateczny adres; RELATIVE, gdzie do rejestru PC (Program Counter) dodawane jest dane tworzące adres; oraz tryby ZEROPAGE i jego warianty, takie jak ZEROPAGE_X i ZEROPAGE_Y. Choć te tryby mogą wydawać się zbędnymi powtórzeniami w stosunku do trybów ABSOLUTE, w rzeczywistości oferują one istotną optymalizację.
Zaletą trybu ZEROPAGE jest to, że pierwsze 256 bajtów pamięci w NES (tzw. zero page) są dostępne szybciej, ponieważ do określenia adresu w tym obszarze wystarczy tylko jeden bajt danych. Z tego powodu instrukcje operujące na pamięci zero-page są znacznie szybsze niż te operujące na innych obszarach pamięci, co wynika z oszczędności w czasie CPU – nie ma potrzeby pobierania drugiego bajtu adresu. W praktyce, programiści 6502 często traktowali te 256 bajtów w zero-page jak dodatkowe rejestry, co częściowo kompensowało ograniczoną liczbę rzeczywistych rejestrów w tej architekturze.
Kiedy już zrozumiemy podstawy formowania adresów pamięci, musimy przejść do analizy struktury pamięci w NES. Ważne jest, aby wiedzieć, które adresy są mapowane na RAM, a które na PPU, i jak te regiony pamięci się różnią. NES stosuje tzw. mirroring, co oznacza, że wiele regionów pamięci jest powielanych, co pozwala zaoszczędzić na kosztach sprzętowych.
Na przykład, pierwsze 2 KB pamięci (do adresu 0x800 w systemie szesnastkowym) są mapowane na RAM CPU, ale każdy adres poniżej 0x2000 odnosi się do tej samej pamięci RAM. Oznacza to, że adres 0x801 jest równy adresowi 0x001, podobnie jak adresy 0x1001 i 0x1801. To rozwiązanie wynikało z oszczędności kosztów sprzętowych – nie wszystkie linie sprzętowe 6502 były wykorzystywane do adresowania tych 2 KB RAM, więc część z nich została zignorowana.
Kiedy programujesz na NES, musisz zdawać sobie sprawę z faktu, że system pamięci jest niejednolity. Na przykład, system pamięci kartuszy jest zależny od mappera i może się różnić w zależności od gry. Zrozumienie, jak różne obszary pamięci współdzielą swoje adresy, ma kluczowe znaczenie dla prawidłowego zarządzania danymi i rozwiązywania problemów, które mogą wystąpić podczas implementacji emulatora.
NES posiada także dwie istotne podsekcje w swojej pamięci RAM: szybki obszar zero-page, od adresu 0x0000 do 0x00FF, oraz przestrzeń wykorzystywaną przez stos, od 0x0100 do 0x01FF. Zero-page jest używana nie tylko do przechowywania zmiennych, ale także pełni rolę buforów dla danych, które muszą być odczytywane lub zapisywane w bardzo krótkim czasie.
W kontekście odczytu i zapisu danych do pamięci w systemie NES, ważne jest, aby rozumieć, jak funkcjonują metody dostępu do pamięci, takie jak read_memory() oraz write_memory(). Te funkcje operują na różnych trybach pamięci, zależnie od używanego adresowania. W przypadku odczytu, jeśli tryb pamięci jest IMMEDIATE, oznacza to, że odczytujemy bezpośrednio dane związane z instrukcją, bez potrzeby szukania adresu. W innych przypadkach, funkcja address_for_mode() przekształca lokalizację w rzeczywisty adres w pamięci, a następnie, w zależności od regionu pamięci, wybierany jest odpowiedni sposób odczytu – od RAM po rejestry PPU i urządzenia wejściowe.
Przykładowo, jeśli adres w pamięci znajduje się w obszarze PPU (od 0x2000 do 0x3FFF), funkcja odczytuje dane z odpowiednich rejestrów PPU, wykorzystując mechanizm powielania rejestrów co 8 bajtów. Z kolei w przypadku odczytu danych z urządzenia wejściowego, np. przy odczycie statusu joypada, system NES wymaga ośmiu odczytów, by uzyskać pełny status wszystkich przycisków.
Pisząc dane do pamięci, podobnie jak w przypadku odczytu, system decyduje, w którym regionie pamięci ma nastąpić zapis, bazując na określonym trybie adresowania. Instrukcje zapisujące dane do pamięci, zwłaszcza w kontekście PPU, również uwzględniają powielanie rejestrów oraz mechanizmy używane do interakcji z zewnętrznymi urządzeniami, jak DMA transfery czy zapisy do statusów urządzeń.
Podsumowując, zrozumienie struktury pamięci i sposobów zarządzania nią w NES jest fundamentalne dla prawidłowego programowania na tej platformie. Znajomość zasad działania pamięci RAM, PPU, jak również zrozumienie koncepcji powielania adresów i różnorodnych trybów adresowania, pozwala na bardziej efektywne i zoptymalizowane zarządzanie danymi.
Jak działają sprite'y w NES i jak je rysować
Rysowanie sprite'ów w NES ma pewne podobieństwa do rysowania kafelków tła. Zamiast jednak odczytywać dane z tablicy nazw, sprite'y odczytywane są z pamięci OAM (Object Attribute Memory). Każdy wpis sprita w pamięci to 4 bajty, które zawierają informacje o pozycji Y, indeksie w tabeli wzorców, atrybutach i pozycji X (patrz tabela 6-5). W OAM może znajdować się maksymalnie 64 wpisy sprite'ów. Jeśli pozycja Y wynosi 0xFF, oznacza to, że dany wpis nie jest używany. Proces rysowania sprite'ów rozpoczyna się od przetwarzania danych OAM, sprawdzając każdy wpis po 4 bajty.
Z każdego prawidłowego wpisu pobieramy pozycję Y oraz X sprite'a. Sprawdzamy również piątą bitową pozycję w atrybucie, aby określić, czy jest to sprite tła. Sprite'y tła rysowane są tylko wtedy, gdy tło jest przezroczyste. Warto zwrócić uwagę, że przetwarzamy pamięć sprite'ów w odwrotnej kolejności, ponieważ zerowy sprite ma specjalne znaczenie.
Podobnie jak przy rysowaniu kafelków tła, sprite'y rysujemy piksel po pikselu:
W tym przypadku, zmienne x i y są analogiczne do fine_x i fine_y w metodzie draw_background(). Musimy upewnić się, że nie rysujemy pikseli, które znajdują się poza ekranem. Kolejną właściwością sprite'ów jest możliwość ich odwrócenia w pionie (flip_y), co określane jest przez siódmy bit w bajcie atrybutów sprite'a:
Jeśli sprite jest odwrócony w pionie, odczytujemy jego piksele w odwrotnej kolejności. Liczba 7 pojawia się tutaj, ponieważ każdy sprite ma rozmiar 8×8 pikseli. Odczyt rzeczywistych bitów pikseli z tabeli wzorców wygląda podobnie do procesu rysowania tła:
Wartość bitów 0 i 1 z bajtu atrybutów przechowywane są w zmiennej bit3and2 dla finalnej koloru. Możliwość odwrócenia sprite'a w poziomie, zależna od bitu 6 w bajcie atrybutu, wygląda tak:
Następnie łączymy dwa plany bitowe i pomijamy piksele przezroczyste:
Ważnym aspektem pracy z sprite'ami jest wykrywanie kolizji, a dokładniej tzw. "sprite-zero hit", czyli wykrycie kolizji zerowego sprite'a z pikselami tła. PPU śledzi, czy zerowy sprite (pierwszy wpis w OAM) koliduje z pikselami tła, które nie są przezroczyste. Implementujemy to w sposób bardzo prosty:
Jeśli wykryta zostanie kolizja, odpowiedni bit w rejestrze statusu zostaje ustawiony. Ponadto, w tej sekcji kodu pomijamy rysowanie sprite'ów tła, jeśli tło nie jest przezroczyste. Istnieją flagi w PPU, które mogą wykluczyć rysowanie lewej części tła lub sprite'a, a te flagi są sprawdzane, by zapobiec nieprawidłowym wykryciom kolizji.
Na koniec odczytujemy kolor każdego piksela, łącząc odpowiednie bity:
Kolor jest pobierany z pamięci palety, a wynikowy piksel wyświetlany na ekranie. Zamiast bezpośrednio odczytywać paletę, używamy metody read_memory(), ponieważ wymaga to uwzględnienia odwzorowania adresów.
Rysowanie sprite'ów to istotny proces, który wiąże się z wieloma technicznymi szczegółami. Konieczne jest dbanie o prawidłowe odczytywanie atrybutów, obsługę kolizji oraz prawidłowe rysowanie pikseli, by zachować integralność wizualną na ekranie NES-a.
Warto również pamiętać, że sprite'y mają swoje ograniczenia. Na przykład, procesor PPU może jednocześnie obsługiwać tylko 64 sprite'y w pamięci OAM, co oznacza, że w przypadku ich nadmiaru, mogą wystąpić zjawiska, takie jak zjawisko "sprite overflow", które wymaga odpowiednich działań w kodzie. Dodatkowo, ważnym aspektem jest sprawdzanie wydajności renderowania sprite'ów, zwłaszcza w kontekście ograniczonych zasobów, jakimi dysponuje NES. Optymalizacja rysowania sprite'ów i tła, minimalizacja niepotrzebnych odczytów pamięci i kontrolowanie liczby sprite'ów wyświetlanych na ekranie to kluczowe elementy w procesie tworzenia efektywnej grafiki na tym systemie.
Jak znaleźć pary nawiasów w interpreterze Brainfucka?
Rozwiązanie problemu znajdowania par nawiasów w języku Brainfuck można podejść na różne sposoby. Jednym z nich jest zastosowanie podejścia opartego na zmiennej śledzącej liczbę nawiasów w środku (in_between_brackets). Każdorazowo, gdy napotykamy koniec pary nawiasów, zmniejszamy wartość zmiennej in_between_brackets, chyba że jej wartość wynosi 0, co oznacza, że zakończyliśmy przetwarzanie wszystkich nawiasów i znaleźliśmy nawias docelowy. To rozwiązanie jest proste i skuteczne, ale wiąże się z koniecznością wielokrotnego przeszukiwania kodu w poszukiwaniu nawiasów.
Alternatywą jest zastosowanie stosu. Kiedy napotykamy nawias otwierający, zapisujemy jego lokalizację na stosie. Kiedy napotykamy nawias zamykający, zdejmujemy element ze stosu, co daje nam parę nawiasów (lokalizacje nawiasu zamykającego oraz zdjętego nawiasu otwierającego). Dzięki temu rozwiązaniu możemy przetwarzać cały kod źródłowy w jednym przebiegu, co pozwala na łatwe znalezienie wszystkich par nawiasów. Lokacje par nawiasów mogą być przechowywane w pamięci podręcznej, co poprawia wydajność interpretera. Zamiast przeprowadzać liniowe wyszukiwanie za każdym razem, kiedy potrzebujemy „skoczyć” do drugiego nawiasu, wystarczy, że dokonamy prostego odczytu z pamięci podręcznej, co jest znacznie szybsze.
Innym pomocniczym mechanizmem, który jest niezbędny do poprawnego działania interpreterów Brainfucka, jest funkcja clamp0_255_wraparound(). Jej zadaniem jest symulowanie oryginalnego zachowania języka Brainfuck, w którym wartości komórek ograniczone są do zakresu 8-bitowych liczb całkowitych bez znaku, czyli od 0 do 255. W Pythonie, typ int jest o dowolnej precyzji, co oznacza, że liczby mogą rosnąć do dowolnie dużych rozmiarów, bez ryzyka przepełnienia (zamiast tego zajmowane są kolejne bajty). Prawdziwy 8-bitowy unsigned int przekroczenie wartości 255 powodowałoby przepełnienie, a w momencie przekroczenia 255 wartość wracałaby do 0, a przy odwrotnym przypadku (wartość 0 i próba dekrementacji) – do 255. Funkcja clamp0_255_wraparound() odtwarza takie zachowanie, stosując odpowiednie warunki:
Z tą funkcją interpreter Brainfucka jest w pełni funkcjonalny i gotowy do użycia. Co ciekawe, implementacja języka Turing-complete nie wymaga zaawansowanych struktur danych ani skomplikowanych algorytmów. Wystarczy zrozumieć mechanizm działania interpretera i zastosować odpowiednie narzędzia do obsługi podstawowych operacji języka.
Aby przetestować interpreter Brainfucka, warto uruchomić kilka programów napisanych w tym języku. W repozytorium książki znajdują się przykłady, takie jak program generujący ciąg Fibonacciego lub klasyczny program „Hello, World!”. Uruchomienie tych programów pozwala zweryfikować, czy interpreter działa prawidłowo:
oraz
Podczas uruchamiania tych programów należy pamiętać, że Python wymaga użycia opcji -m, aby rozpoznać Brainfucka jako moduł. Zwróćmy również uwagę, że sposób wywoływania Pythona w zależności od systemu operacyjnego może się różnić, np. w systemach Linux używa się python3, a w systemach Windows może być to python.
Aby zapewnić, że interpreter działa prawidłowo, warto napisać testy. Zamiast tworzyć testy jednostkowe dla każdej komendy, lepiej skupić się na testach integracyjnych, które sprawdzają, czy całe programy Brainfuck działają zgodnie z oczekiwaniami. Testy takie są bardziej zwięzłe, a jednocześnie sprawdzają szerszy zakres funkcji interpretera.
Testy integracyjne wykonują programy w języku Brainfuck, przechwytują ich wyjście i porównują je z oczekiwanym rezultatem. W repozytorium znajduje się folder tests, w którym przechowywane są wszystkie testy. Poniżej znajduje się przykład implementacji testów:
Uruchomienie tych testów daje pewność, że interpreter działa poprawnie. Testowanie nie tylko pozwala sprawdzić poprawność wykonania programów, ale także pomaga wykryć ewentualne błędy w kodzie, które mogą wystąpić przy dalszym rozwoju interpretera.
Interpreter Brainfucka to doskonały przykład na to, jak w prostocie tkwi potęga. Choć język ten wydaje się bardzo ograniczony, jego implementacja może nauczyć nas wielu fundamentalnych zasad programowania i interpretacji języków. Dzięki takiemu podejściu uczymy się, jak w prostych algorytmach można zawrzeć mechanizmy, które pozwalają na realizację skomplikowanych zadań.
Hogyan fejleszthetők a napelemes, víz- és levegőhibrid autómotorok a jövő fenntartható közlekedése érdekében?
Hogyan használjuk a németet napi 15 percben 12 hét alatt?
Hogyan vásárolj a spanyol piacon: Kifejezések és kulturális tippek
Hogyan Történik A Küzdelem A Zárak És Titkok Mögött?
Mi az arab szó a "nagy" és "kicsi" kifejezésekre?

Deutsch
Francais
Nederlands
Svenska
Norsk
Dansk
Suomi
Espanol
Italiano
Portugues
Magyar
Polski
Cestina
Русский