2. Śnieżenie

Jako drugi program napiszemy graficzny efekt - śnieżenie ekranu. Oto jego pełny kod źródłowy w NoASM:

; 02 Śnieżenie

; ======== MAKRA ========

define width  120
define height 90
define pixels 10800 ; width*height
define size   43200 ; pixels*4u

; ======== KOD ========

; Organizujemy stos
mov4i &stos SR

; Tryb ekranu
mov1u 5 A1      ; Urządzenie: ekran
mov1u 2 A2      ; Komunikat: zmiana trybu
mov1u 2 A3      ; Tryb: graficzny
mov4i width  BX ; Szerokość
mov4i height BY ; Wysokość
out

; Pętla główna
Loop:
  call Frame
  jmp Loop

; ======== FUNKCJE ========

; Funkcja główna - rysuje klatkę
Frame:
  call Generuj
  call Zapisz
  call Zaprezentuj
  ret

; Wygenerowanie klatki amimacji do pamięci Screen
Generuj:
  namespace          ; Anonimowa strefa nazw

    ; Inicjalizacja
    zero4 BX         ; BX będzie licznikiem pikseli
    mov4i &Screen BY ; BY będzie przechowywał adres bieżącego piksela

    ; Pętla
    MyLoop:

      ; Warunek
      eq4u BX pixels A1 ; Jeśli licznik pikseli = liczba pikseli
      retnz1 A1         ; to koniec funkcji

      ; Zapisanie piksela
      rand1u 0x00 0xFF A1 ; Wylosowanie jasności
      mov1u A1 *BY
      inc4i BY
      mov1u A1 *BY
      inc4i BY
      mov1u A1 *BY
      inc4i BY
      inc4i BY

      ; Zwiększenie liczników (BY już został zwiększony powyżej)
      inc4u BX

      ; Zapętlenie
      jmp MyLoop
  end

; Wysłanie obrazu do ekranu
Zapisz:
  mov1u 5 A1       ; Urządzenie: ekran
  mov1u 7 A2       ; Komunikat: wysłanie obrazu
  mov4i &Screen AY ; Adres pamięci z obrazem
  out
  ret

; Zaprezentowanie zmian na ekranie
Zaprezentuj:
  mov1u 5 A1 ; Urządzenie: ekran
  mov1u 4 A2 ; Komunikat: zaprezentowanie zmian
  out
  ret

; ======== PAMIĘĆ ========

; Rezerwacja pamięci na ekran
Screen:
  reserve size

; To znaczy, że tutaj po końcu programu będzie się rozciągał obszar
; pamięci do wykorzystania jako stos
stos:

Jak widać, kod jest trochę dłuższy od poprzedniego i zawiera wiele nowych rzeczy, które będziemy musieli omówić. Za pomocą odpowiednich komentarzy podzielony został na logiczne części.

Makra

Najpierw przyjrzyjmy się pierwszego fragmentowi:

; ======== MAKRA ========

define width  120
define height 90
define pixels 10800 ; width*height
define size   43200 ; pixels*4u

Define nie jest instrukcją. Oznacza to, że z takiej linijki nie powstanie instrukcja do pliku docelowego z dyskiem. Jest to tzw. dyrektywa preprocesora, która definiuje makro. Jej pierwszym parametrem jest nazwa, a drugim treść. Począwszy od takiej deklaracji w całym kodzie słowo width zostanie zamienione na liczbę 120, słowo height na liczbę 90 itd. Jest to tak jakby zdefiniowanie stałej.

Wartości dwóch ostatnich makr powstają z obliczenia na podstawie poprzednich. Niestety w NoASM nie można stosować wyrażeń matematycznych i dlatego trzeba było policzyć je ręcznie, wpisać, a w komentarzu obok dopisać skąd to się wzięło.

Jeśli jeszcze tego nie zrobiłeś, skompiluj i uruchom ten program. Będzie to potrzebne do zrozumienia, co znaczą te stałe. Width oznacza ilość punktów na szerokość. Height to ilość punktów na wysokość. Iloczyn szerokości i wysokości to pixels, czyli całkowita ilość punktów. Z kolei size to rozmiar pamięci potrzebnej na zapamiętanie obrazu. Każdy punkt składa się z koloru, a kolor, jak pamiętamy, zapisywany jest na 4 bajtach.

Zauważyłeś zapewne, że po odpowiednim rozszerzeniu okna wirtualnej maszyny poszczególne piksele (punkty) obrazu na wirtualnym ekranie mają postać dużych kwadratów, a nie są pojedynczymi pikselami ekranu rzeczywistego monitora. Mówiąc o punktach lub pikselach będziemy mieli na myśli te wirtualne - na ekranie NoSense - niezależnie od tego, jak są wyświetlane przez wirtualną maszynę.

Stos

Poznamy teraz rzecz, której od tej pory będziemy już zawsze używali we wszystkich programach. Stos jest bardzo ważną strukturą danych. Nie powstaje jednak w NoSense automatycznie, ale trzeba go sobie samemu zorganizować. Na szczęście potem już można używać instrukcji, które go wspierają.

Stos potrzebuje miejsca w pamięci. To miejsce robimy deklarując na samym końcu programu etykietę:

; To znaczy, że tutaj po końcu programu będzie się rozciągał obszar
; pamięci do wykorzystania jako stos
stos:

Podczas uruchamiania programu maszyna ładuje go do pamięci. Nasz kod, skompilowany do postaci kodu maszynowego NoCode, zajmuje ileś tam bajtów. Dalej rozciąga się pamięć, której również można używać. Właśnie początkowi tej pamięci nadajemy nazwę definiując powyższą etykietę na końcu naszego programu.

Do utworzenia stosu potrzebne jest wpisanie adresu pamięci, od którego się zaczyna, do specjalnie do tego przeznaczonego rejestru SR. To jest pierwsza instrukcja w naszym programie:

; ======== KOD ========

; Organizujemy stos
mov4i &stos SR

Każda komórka (bajt) pamięci ma swój adres. Adresy są 4-bajtowymi liczbami całkowitymi ze znakiem (typu [4i]).

W znanej nam już instrukcji mov4i widzimy użyty dodatkowy znaczek &. Oznacza on, że do rejestru SR chcemy wpisać adres etykiety stos. Gdyby go nie było, wpisalibyśmy do niego to, co znajduje się w pamięci oznaczonej tą etykietą.

Funkcje

Właściwy kod rozpoczyna się wysłaniem do ekranu komunikatu wyjściowego zmieniającego tryb wyświetlania na tryb graficzny o żądanej szerokości i wysokości:

; Tryb ekranu
mov1u 5 A1      ; Urządzenie: ekran
mov1u 2 A2      ; Komunikat: zmiana trybu
mov1u 2 A3      ; Tryb: graficzny
mov4i width  BX ; Szerokość
mov4i height BY ; Wysokość
out

Opis tego komunikatu znajdziesz w dokumentacji w rozdziale Devices > Screen > Output messages > Set mode. Wykonane tutaj operacje powinny być ci już znane. Zwróć jedynie uwagę, że podając szerokość i wysokość używamy zdefiniowanych wcześniej makr - width i height. Działają one tak samo, jakgdyby w tym miejscu bezpośrednio wpisać ich wartości.

Jednak główną częścią programu jest ta oto niepozorna pętla:

; Pętla główna
Loop:
  call Frame
  jmp Loop

Widzimy tutaj etykietę i instrukcję skoku do niej. Można się więc domyślić, że działanie pętli polega na nieustannym wykonywaniu instrukcji call. Tylko co to za instrukcja?

Instrukcja call oznacza wywołanie. Jest podobna do instrukcji skoku jmp, ale zanim wykona skok, odkłada na stos adres aktualnie wykonywanej instrukji (konkretnie to instrukcji następnej za tą, czyli u nas jmp), aby potem móc powrócić w to miejsce.

Odłożenie na stos polega na zapisaniu danej wartości (w tym przypadku adresu miejsca w kodzie) pod adresem przechowywanym przez rejestr SR oraz zwiększeniu tego rejestru o rozmiar tej informacji. W ten sposób można odkładać na stos coraz to nowe wartości, a potem w odwrotnej kolejności zdejmować je (odczytywać). Właśnie tak działa stos. Jednak w instrukcji call to wszystko odbywa się automatycznie i nie musimy się tym zajmować.

Skoro call działa podobnie do instrukcji skoku, podana do niej nazwa Frame powinna być nazwą etykiety. Okazuje się, że faktycznie tak jest. Widzimy przy okazji, że etykiety wolno używać nawet jeszcze wcześniej, nim zostanie zdefiniowana. Zdefiniowany pod nią kod wygląda natomiast tak:

; ======== FUNKCJE ========

; Funkcja główna - rysuje klatkę
Frame:
  call Generuj
  call Zapisz
  call Zaprezentuj
  ret

Po przeskoczeniu do tej etykiety rozpoczyna się wykonywanie kolejnych instrukcji call. Robią one kolejno trzy rzeczy potrzebne do utworzenia każdej klatki animacji śnieżenia, a całość jest jakby funkcją (podprogramem) rysującym pojedynczą klatkę.

Obecna na końcu bezparametrowa instrukcja ret oznacza powrót (ang. "return") i jest jakby odwrotnością call. Zdejmuje ze stosu zapamiętany adres i skacze do niego wracając w miejsce, z którego nastąpiło wywołanie i kontynuując wykonywanie kodu znajdującego się pod tym wywołaniem.

Generowanie obrazu

Żeby wygenerować nową klatkę animacji, potrzeba trzech czynności. Ich omawianie zaczniemy od końca:

; Zaprezentowanie zmian na ekranie
Zaprezentuj:
  mov1u 5 A1 ; Urządzenie: ekran
  mov1u 4 A2 ; Komunikat: zaprezentowanie zmian
  out
  ret

Ta funkcja po wywołaniu wysyła do ekranu znany nam już komunikat zaprezentowania dokonanych zmian. Okazuje się bowiem, że takiego samego zaprezentowania trzeba dokonać również w trybie graficznym, a nie tylko w trybie tekstowym, żeby zmiany pokazały się na ekranie.

Zanim omówimy środkową funkcję, spójrz na deklarację pod koniec programu:

; ======== PAMIĘĆ ========

; Rezerwacja pamięci na ekran
Screen:
  reserve size

Reserve nie jest instrukcją, ale poleceniem kompilatora. Oznacza ono zarezerwowanie określonej ilości bajtów pamięci. Jak pamiętamy, size jest makrem zdefiniowanym jako liczba 43200. Normalnie taka spora ilość bajtów zapisana zostałaby do dysku zajmując w nim miejsce. Jeśli jednak pod poleceniem reserve nie ma już w programie żadnej instrukcji (jedynie inne takie polecenia oraz etykiety - tak jak u nas), kompilator nie zaśmieca niepotrzebnie dysku, a wszystko i tak działa poprawnie.

Podsumowując, pod etykietą Screen kryje się obszar 43200 bajtów pamięci wypełnionych byle czym. Będziemy go używali do przechowywania tablicy z kolorami pikseli do wyświetlenia na ekranie. Wyświetlenia takiego dokonuje funkcja:

; Wysłanie obrazu do ekranu
Zapisz:
  mov1u 5 A1       ; Urządzenie: ekran
  mov1u 7 A2       ; Komunikat: wysłanie obrazu
  mov4i &Screen AY ; Adres pamięci z obrazem
  out
  ret

Jest to wysłanie komunikatu wyjściowego opisanego w dokumentacji w rozdziale Devices > Screen > Output messages > Set image. W trybie graficznym nie rysuje się po pikselu (tak jak w trybie tekstowym ustawiało się kolejne znaki), ale trzeba na raz wpisać do ekranu cały obraz z obszaru pamięci przechowującego kolory kolejnych pikseli.

Do rejestru AY wpisujemy adres obszaru pamięci oznaczonego etykietą Screen - stąd poznany już wcześniej znaczek &. Przypomnę, że gdyby go nie było, wpisalibyśmy to, co znajduje się w pamięci pod tą etykietą.

Wreszcie czas na omówienie najważniejszej i najtrudniejszej funkcji. Wypełnia ona obszar pamięci oznaczony pod Screen kolorami punktów. Do naszego efektu potrzebne jest, aby każdy punkt w każdej klatce otrzymał nowy, losowy kolor w odcieniach szarości (czarny, biały lub jakiś szary).

; Wygenerowanie klatki amimacji do pamięci Screen
Generuj:
  namespace          ; Anonimowa strefa nazw

    ; Inicjalizacja
    zero4 BX         ; BX będzie licznikiem pikseli
    mov4i &Screen BY ; BY będzie przechowywał adres bieżącego piksela

    ; Pętla
    MyLoop:

      ; Warunek
      eq4u BX pixels A1 ; Jeśli licznik pikseli = liczba pikseli
      retnz1 A1         ; to koniec funkcji

      ; Zapisanie piksela
      rand1u 0x00 0xFF A1 ; Wylosowanie jasności
      mov1u A1 *BY
      inc4i BY
      mov1u A1 *BY
      inc4i BY
      mov1u A1 *BY
      inc4i BY
      inc4i BY

      ; Zwiększenie liczników (BY już został zwiększony powyżej)
      inc4u BX

      ; Zapętlenie
      jmp MyLoop
  end

Fragment rozpoczynający się poleceniem namespace, a zakończony odpowiadającym mu poleceniem end wyznacza tzw. strefę nazw. Wszystkie etykiety zdefiniowane w tej strefie są lokalne dla niej. Z każdego miejsca w kodzie można się odwołać do każdej z etykiet, ale w miejscu na zewnątrz tej strefy wymagałoby to podania pełnej ścieżki w postaci nazwa_strefy.etykieta.

Ponieważ po słowie namespace nie ma podanej nazwy dla strefy, powstaje tzw. strefa anonimowa. Jej nazwa zostanie przez kompilator wygenerowana automatycznie z jakiś losowych znaków, ale to nie ma znaczenia. Nam chodzi tutaj o odizolowanie etykiet zdefiniowanych i używanych wewnętrznie w funkcji Generuj od reszty kodu, żeby przypadkiem nie powstała kolizja nazw.

Musimy teraz zaplanować, w jaki sposób napisać samo to wypełnianie tablicy kolorami kolejnych pikseli. Trzeba będzie zorganizować pętlę, która przejdzie przez komórki pamięci odpowiadające wszystkim pikselom i wypełni je nowymi danymi. Pętla będzie musiała się zatrzymać po dojściu do końca, co wymaga sprawdzenia warunku.

Wybrane tu rozwiązanie może nie jest najlepsze ani najszybsze, ale działa nienajgorzej. Będziemy używali dwóch liczb, na które przeznaczymy dwa rejestry:

    ; Inicjalizacja
    zero4 BX         ; BX będzie licznikiem pikseli
    mov4i &Screen BY ; BY będzie przechowywał adres bieżącego piksela

BX będzie zliczał przetwarzane piksele. Zaczyna od zera, z każdym nowym będzie zwiększany o jeden, a kiedy osiągnie liczbe równą ilości pikseli (zdefiniowaną jako makro pixels), będzie to warunkiem zakończenia pętli.

Trzeba będzie zapisywać obliczane wartości do pamięci rozpoczynającej się etykietą Screen. W tym celu użyjemy rejestru BY, który będzie przechowywał adres bieżącego piksela. Pod ten adres będziemy zapisywali. Na początek ustawiamy go na adres etykiety Screen, co odpowiada pierwszemu pikselowi, jaki się tam znajdzie.

Realizacja pętli przebiegającej po wszystkich pikselach wygląda tak:

    ; Pętla
    MyLoop:

      ; Warunek
      eq4u BX pixels A1 ; Jeśli licznik pikseli = liczba pikseli
      retnz1 A1         ; to koniec funkcji

      ; ...

      ; Zapętlenie
      jmp MyLoop

Na końcu znajduje się skok do początku, ale wcześniej, jeszcze przed właściwą treścią pętli zastąpioną tutaj wielokropkiem znajduje się warunek. Instrukcja eq4u porównuje dwie wartości. Jeśli wartość znajdująca się w rejestrze BX jest równa (ang. "equal") liczbie kryjącej się pod makrem pixels, do rejestru A1 wpisana zostaje logiczna wartość prawdy (czyli jednobajtowa liczba różna od zera). W przeciwnym wypadku wpisany zostaje fałsz, czyli zero.

Retnz1 to powrót warunkowy. Dokonuje powrotu z funkcji, tak jak ret, ale tylko jeśli jednobajtowa wartość znajdująca się w rejestrze A1 jest różna od zera (ang. "not zero"). Innymi słowy - jeśli wykonane instrukcję wcześniej porównanie dało wynik prawdziwy.

Na koniec pozostało samo wygenerowanie koloru piksela:

      ; Zapisanie piksela
      rand1u 0x00 0xFF A1 ; Wylosowanie jasności
      mov1u A1 *BY
      inc4i BY
      mov1u A1 *BY
      inc4i BY
      mov1u A1 *BY
      inc4i BY
      inc4i BY

      ; Zwiększenie liczników (BY już został zwiększony powyżej)
      inc4u BX

Instrukcja rand1u losuje liczbę z zakresu od 0 (pierwszy parametr) do 255 (drugi parametr - zapisany szesnastkowo) i wynik losowania umieszcza w rejestrze A1 (trzeci parametr). Ta wartość powinna się stać jasnością wszystkich trzech składowych (czerwonej, zielonej i niebieskiej) danego piksela. Tylko wtedy, kiedy są one sobie równe, piksel ma kolor czarny, szary albo biały, a nie barwny.

W tym celu przypominamy sobie, że BY przechowuje adres bieżącego piksela. Musimy potraktować ten adres jako liczbę typu [4u]. Jednakże będziemy zapisywali jej poszczególne bajty. Musimy do tego wiedzieć, że bajty przechowywane są w odwrotnej kolejności, niżby się to intuicyjnie wydawało - nie XRGB, ale BGRX.

Najpierw kopiujemy więc bajt z A1 do pierwszej składowej. Pojawia się tu nowy operator - gwiazdka *. Gdyby jej nie było, skopiowana zostałaby liczba z rejestru A1 do rejestru BY (dokładnie do jej pierwszego bajta). Nam jednak chodzi o to, żeby ta liczba wpisana została w miejsce w pamięci, którego adres przechowuje (na które pokazuje) rejestr BY - właśnie do tego służy gwiazdka. W językach wysokiego poziomu nazywa się to wskaźnikiem.

Wpisujemy więc liczbę pod adres przechowywany przez BY i zwiększamy ten adres przesuwając nasz wskaźnik o jeden bajt do przodu. W ten sposób wypełniamy składowe koloru piksela czwarty bajt pozostawiając niezmieniony (BY zwiększamy dwa razy).

Po tym całym procesie BY przechowuje już adres do początku następnego piksela, który zostanie wpisany w następnym cyklu pętli. Pozostaje już tylko zwiększyć licznik pikseli BX i to wszystko.

2004-07-02
Adam Sawicki "Regedit"
WWW: NoSense, HomePage, ProgrameX