Tutorial SDL RWops

Aus DGL Wiki
Version vom 28. Februar 2007, 17:08 Uhr von Lord Horazont (Diskussion | Beiträge)

(Unterschied) ← Nächstältere Version | Aktuelle Version (Unterschied) | Nächstjüngere Version → (Unterschied)
Wechseln zu: Navigation, Suche

Einleitung

Aye und willkommen an Bord zu meinem ersten Tutorial. Dies beschäftigt sich im Gegensatz zu den wohl überwiegenden Erwartungen in dieser Community nicht mit OpenGL sondern mit SDL. Diejenigen, die nicht wissen, was SDL ist, sollten sich erst darüber informieren und das ein oder andere damit ausprobieren. Einige Grundkenntnisse in SDL sollten schon vorhanden sein. Aber jetzt los.


RWops?

Wer schon einmal mit SDL und Dateien gearbeitet hat, der kennt bestimmt den SDL_RWops-Record. In ihm ist alles, was man zum Lesen und Schreiben der Datei braucht gespeichert. Hier noch einmal die Deklarationen aus der SDL.pas:

type 
  TStdio = record
    autoclose: Integer;
    fp: Pointer;
  end;

  TMem = record
    base: PUInt8;
    here: PUInt8;
    stop: PUInt8;
  end;

  TUnknown = record
    data1: Pointer;
  end;

  TSDL_RWops = record
    seek: TSeek;
    read: TRead;
    write: TWrite;
    close: TClose;
    type_: UInt32;
    case Integer of
      0: (stdio: TStdio);
      1: (mem: TMem);
      2: (unknown: TUnknown);
    end;
  end;

Der Record besteht also aus vier Pointern auf jeweils eine Prozedur, aus einem Integer der für die Typenangabe wichtig ist und einem dynamischen Teil, der später Pointer auf die Daten enthält (dazu später mehr).


Handling

Die Daten im RWops kann man eigentlich ganz einfach manipulieren und auslesen. Ähnlich wie bei den normalen Delphi-Streams hat man hier Funktionen zum Lesen und Schreiben sowie zum Setzen der Position innerhalb der Datenquelle zur Verfügung. Dadurch, dass die Funktionen als Variablen innerhalb des RWops liegen, kann man so ziemlich alles mit ihnen machen. SDL bietet von Haus aus zwei Möglichkeiten, RWops zu erstellen.


RWops aus Datei

function SDL_RWFromFile(filename, mode: PChar): PSDL_RWops;

Die erste und wohl am meisten benutzte Methode ist das erstellen eines RWops aus einer Datei. Hierbei wird die Funktion SDL_RWFromFile verwendet, die mit zwei relativ simplen Parametern auskommt. Der erste ist (wie könnte es anders sein) der Dateiname als PChar (Man kann einfach einen normalen String nehmen und diesen mit PChar() auf PChar casten oder einen konstanten Wert verwenden). Hinweis: Ich empfehle absolute Pfade zu verwenden, (also mit Laufwerk und allem) da es sonst erstens unter einigen Betriebssystemen zu Problemen kommen könnte und zweitens kann sich der aktuelle Pfad, also der von dem aus die Relativen berechnet werden sich jeder Zeit ändern, wodurch dann die Dateien nicht mehr gefunden werden würden. Der zweite Parameter enthält den Zugriffsmodus auf die Datei. Das kennt man ja eigentlich schon von den TFileStreams, die ja auch wissen müssen, was man mit der Datei machen will. Hier gibt es sechs Möglichkeiten:

  1. r Die Datei wird einfach nur zum Lesen geöffnet und sollte existieren.
  2. w Die Datei wird für den Schreibzugriff geöffnet und muss existieren.
  3. a Die Datei wird zum Schreiben geöffnet aber die Daten werden an das Ende der Datei angehängt. Die Datei muss existieren.
  4. r+ Die Datei wird zum Lesen und Schreiben geöffnet, sie muss existieren.
  5. w+ Es wird eine neue Datei erstellt und zum Lesen und Schreiben geöffnet. Sollte eine Datei mit gleichem Namen existieren, wird die gnadenlos überschrieben.
  6. a+ Die Datei wird zum Lesen und Schreiben geöffnet, aber die Daten werden wie bei a ans Ende angehängt. Die Datei muss auch hier existieren.

Simpel, oder? Was man jetzt noch machen kann (und sollte) ist festzulegen, wie die Datei geöffnet wird. Es gibt hier noch einmal zwei Möglichkeiten. Die Erste ist der Textmodus. Das Lesen aus der Datei wird abgebrochen, sobald das Steuerzeichen ^Z erreicht ist. Der Textmodus ist Standard, kann aber durch das Anhängen eines 't' an den Modus explizit festgelegt werden. Das Zweite ist der Binärmodus. Das Lesen wird beendet, sobald das „logische“ Ende der Datei erreicht ist und wird durch ein 'b' am Ende des Modus festgelegt.


RWops aus Pointern

function SDL_RWFromMem(mem: Pointer; size: Integer): PSDL_RWops;

Die zweite Möglichkeit, direkt über SDL RWops zu erstellen ist, der Funktion SDL_RWFromMem einen Pointer zu übergeben. Das erlaubt dann den Zugriff auf den Speicherbereich über die komfortablen SDL_RWops. Das spart das ganze Rumgerechne mit Pointern, was durchaus gerne mal zu Fehlern führt. Auch diese Funktion erwartet zwei Parameter. Als erstes übergibt man einen Pointer auf den Speicherbereich, auf den man später mit dem SDL_RWops zugreifen möchte. Der zweite Parameter muss die Größe des Speicherbereiches in Bytes enthalten (da kann auch die Funktion SizeOf() helfen, die die Größe einer Variable oder eines Typs in Bytes zurückgibt – aber Achtung, bei Strings und dynamischen Arrays muss man Length() verwenden und ggf. noch mit der Größe der einzelnen Array-Elemente multiplizieren)


Lesezugriff

Aus den RWops muss man nicht direkt über die im dynamischen Teil enthaltenen Pointer zugreifen. Dafür gibt es (wer hätte das gedacht) die Funktion auf die in der Read-Eigenschaft gezeigt wird. Die kann man eigentlich genauso wie die Delphi Stream-Methoden verwenden mit dem kleinen Unterschied, dass man noch einen Zeiger auf den RWops übergeben muss. Das ist nötig, weil es sich ja um einen Record handelt. Wäre TSDL_RWops eine Klasse, so könnten die Methoden über ein Self auf die Pointer zugreifen, so muss man den Kontext mit übergeben. Als zweiten Parameter wird ein Pointer auf den Speicherbereich erwartet, in den später die gelesenen Daten geschrieben werden sollen. Für die, die nicht gerne mit Pointern arbeiten: Man kann auch einfach ein Array oder sonst eine normale Zielvariable verwenden und auf die dann mit einem @ zeigen, indem man es einfach vor den Variablennamen setzt. Der dritte Parameter (size) sollte die Größe eines Datenblocks enthalten. Ein Datenblock wäre für das byteweise Auslesen immer 1. Wenn man aber immer einen Integer, der ja 4 Byte groß ist, erhalten will, dann sollte man dort eine 4 übergeben. Die Anzahl an Bytes, die bei size übergeben wird, wird immer versucht zu lesen. Wenn also eine 15 Byte große Datei gelesen wird, dann werden die letzten 3 Byte nicht mit eingelesen, da man mit ihnen keinen Block voll bekommt. (Auch hier kann man wieder gut SizeOf() bzw. Length() verwenden) Zu guter Letzt: maxnum. Maxnum legt fest, wie viele Blöcke maximal gelesen werden können. Will man also (um beim obigen Beispiel zu bleiben) zwei Integer einlesen, so wird man bei size die Größe des Integers, also 4 und bei maxnum die Anzahl der Integer, also 2 angeben. Man könnte natürlich genausogut bei size 1 und bei maxnum 8 übergeben, aber so kann man sicher sein, dass immer ein kompletter Integer eingelesen wird, nicht nur ein halber. Dies kann später bei eigenen RWops (etwa für Netzwerkübertragung) sehr hilfreich sein. Die Funktion gibt die Anzahl der gelesenen Blocks zurück, nicht die Anzahl der Bytes! Will man also die Anzahl der gelesenen Bytes erhalten, muss man den Rückgabewert noch mit size multiplizieren. Zum Schluss noch ein Beispiel zum Einlesen einer Zahl aus einer binären Datei:

var
  F: PSDL_RWops;
  Number: Integer;
begin
  F := SDL_RWFromFile(PChar('datei.bin'), 'rb'); // datei.bin wird im binären Lesemodus geöffnet
  F^.read(F, @Number, 4, 1); // Lesen von 1 Integer (4 Bytes) in die Variable Number aus F
  SDL_RWClose(F); // Freigeben des Speichers für den RWops
  
  WriteLn(Number); // Ausgabe in die Konsole (natürlich nur bei einer Konsolenanwendung möglich
end;

Was übrigens auch geht ist, die Funktion SDL_Read zu verwenden. Dieser muss man dann die gleichen Parameter übergeben wie der Funktion im SDL_RWops, aber man spart sich das dereferenzieren des RWops. Diese sogenannten Makros gibt es auch für SDL_Write, SDL_Seek und SDL_Close.

Schreibzugriff

Das Schreiben in RWops verläuft ähnlich wie das Lesen und wiederum ähnlich wie bei den Delphi Streams. Die Funktion erwartet vier Parameter. Der erste ist wie schon bei der Lesefunktion der Pointer auf den RWops. Der zweite ist der Speicherbereich, von dem aus die Daten in den RWops geschrieben werden. Auch hier könnte man wieder einen @-Pointer, der auf eine lokale oder globale Variable zeigt, verwenden. Size ist hier wie schon beim Lesen die Blockgröße und num die Anzahl der Blocks, die geschrieben werden sollen. Die Funktion gibt die Anzahl der geschrieben Blocks zurück, wiederum nicht die Anzahl der Bytes (außer natürlich, size ist 1, dann würde ein Block einem Byte entsprechen). Es kann passieren, dass die Funktion einen Wert zurückgibt, der kleiner als num ist. In diesem Falle ist irgendein Fehler aufgetreten (z.B. ein Out of Memory). Hier wieder ein kleines Beispiel wie man in eine Datei schreiben würde (diesmal ein String):

var
  F: PSDL_RWops;
  S: String;
begin
  F := SDL_RWFromFile(PChar('datei.txt'), 'wb'); // datei.txt wird im binären Schreibmodus geöffnet, obwohl Text geschrieben wird. Bei RWops ist das imho besser
  S := 'Hallo Welt!';
  F^.write(F, @S[1], 1, Length(S)); // Schreiben des Strings in die Datei. Wichtig: Strings beginnen an der 1. Position, daher muss auch der Pointer auf diese zeigen
  SDL_RWClose(F); // Schließen der Datei
end;

Wie schon in den Kommentaren erwähnt, sollte man die Datei trotz der Absicht, Text zu schreiben im Binärmodus öffnen. Und nicht vergessen, dass Strings immer an der Array-Position 1 beginnen, nicht wie so ziehmlich alles andere bei 0. Dies kann böse Access Violation-Fehler verursachen.

Position ändern

Für das, was man in normalen Streams mit Stream.Position := x; macht, braucht man bei RWops den Befehl seek, der wie die anderen im RWops vorhanden ist. Der erste Parameter ist, wie bei allen anderen auch, der RWops selber. Mit dem dritten Parameter legt man fest, wie der zweite behandelt wird. Dafür gibt es drei Werte (man muss sie selbst deklarieren, da sie aus der stdio.h von C kommen):

const
  SEEK_SET = 0;
  SEEK_CUR = 1;
  SEEK_END = 2;

Wenn jetzt der dritte Parameter gleich SEEK_SET ist, dann ist Offset im Prinzip die Position vom Anfang des RWops an. SEEK_CUR ändert die Position relativ zur aktuellen, SEEK_END relativ zum Ende. Offset kann auch negative Werte annehmen, sodass man in Verbindung mit SEEK_CUR innerhalb des RWops zurück gehen könnte.

Info DGL.png Mindestens unter Windows scheint nur SEEK_SET bei whence, dem dritten Parameter, bei Dateien zu funktionieren. Bei den beiden anderen kommt es zwar zu keiner Fehlermeldung aber sie werden behandelt wie SEEK_SET.

Erweiterungen

„Das ist ja alles schön und gut. Aber man könnte ja auch normale Streams verwenden, um Dateien einzulesen.“ werden jetzt einige Leser einwenden. Aber wer z.B. Bilder mit SDL laden will, kann dies nicht über normale Streams tun. Man kann zwar direkt aus der Datei laden, aber was will man machen, wenn man ein eigenes VFS schreibt und die Daten im Speicher hält? Es wäre da alles andere als effektiv, die einmal mühselig aus dem Archiv geholten Daten erst auf die Festplatte in eine Datei zu schreiben um sie dann von SDL auslesen zu lassen. Statt dessen könnte man SDL einfach einen RWops auf den Speicherbereich erstellen lassen (siehe SDL_RWFromMem) und dann über beispielsweise IMG_LoadRW ein Bild laden lassen. Aber ehrlich gesagt habe ich schlechte Erfahrungen mit dem Auslesen von Daten aus Dateien mit RWops gemacht, irgendwie war mir das ganze zu spartanisch und zu Anti-OOP. Dazu kam das oben genannte Problem, dass man nicht anständig in der Datei navigieren kann und dass man die aktuelle Position nicht auslesen kann. Also habe ich das VFS für mein Projekt unüberlegter Weise mit Streams geschrieben. Jetzt stand ich vor dem Problem mit SDL-Funktionen Bilder zu laden (ja, ich bin faul, ich hätte mir auch einen Loader schreiben können). Nach kurzem Überlegen kam mir in den Sinn dass RWops ja durch die Möglichkeit, eigene Funktionen einzusetzen, sehr flexibel sind. Jetzt kommen wir zu dem dynamischen Teil des Records. Dynamischer Record bedeutet, dass alle Teile, die in einer solchen Case-Anweisung liegen, den gleichen Speicherbereich belegen. Also kann man immer nur eines der drei Teile, die im TSDL_RWops deklariert sind verwenden.

  1. TStdio
    TStdio stammt von C ab. Eigentlich enthält der Pointer eine FILE-Variable. Die sind in C das, was in Pascal ein File of Byte ist, also der direkte Dateizugriff. Dieser Typ wird eigentlich nur von SDL selbst benutzt, zumindest fällt mir nichts anderes ein.
  2. TMem
    TMem enthält informationen wenn man einen RWops aus einem Speicherbereich erstellt (siehe SDL_RWFromMem) und wird meiner Meinung nach auch nur intern benutzt.
  3. TUnknown
    Auf den ersten Blick langweilig. Er enthält nur einen Pointer. Aber man denke drüber nach, was man mit diesem Pointer alles anstellen kann.

Um mein kleines Stream zu RWops Problem zu lösen habe ich mir vier Funktionen geschrieben, die allesamt den Unknown-Teil des Records benutzen. Diese Funktionen casten sich aus dem Pointer einen TStream und führen dort die jeweiligen Operationen aus. Read führt die Read-Methode des Streams aus, Write die Write-Methode. Seek setzt die Position korrekt anhand der Parameter. Close gibt den Speicher des Streams frei und setzt den Pointer in Unknown auf nil. Dann habe ich mir noch zwei kleine Procedures geschrieben, die mir aus einem Stream einen RWops machen und aus einem RWops einen Stream. Bei ersterer wurden nur die gerade erwähnten Standardfunktionen zu einer frischen Rwops-Variable zugewiesen und der Unknown-Pointer durch den Stream ersetzt. Die andere Funktion war etwas komplizierter, da man ja nicht unbedingt weiss, ob einem ein „eigener“ RWops übergeben wurde oder einer, der von SDL erstellt wurde. Also habe ich innerhalb dieser Funktion alle Daten des RWops über dessen Read-Funktion in einen in den Parametern übergebenen Stream kopiert. Das stellt außerdem sicher, dass selbst nach dem Freigeben des RWops die Daten im erstellten Stream erhalten bleiben. Hier ein paar Codes:

function StreamRWSeek( context: PSDL_RWops; offset: Integer; whence: Integer ): Integer; {$IFNDEF ___GPC___}cdecl;{$ENDIF}
var
  Stream: TStream;
begin
  Stream := Tstream(context^.unknown.data1); // Den Stream aus dem RWops holen
  if Stream = nil then
  begin
    Result := -1;
    Exit;
  end;
  case whence of
    SEEK_SET: Stream.Position := offset; // Anhand des Whence-Parameters die neue Streamposition setzen
    SEEK_CUR: Stream.Position := Stream.Position + offset;
    SEEK_END: Stream.Position := Stream.Size + offset;
  end;
  Result := Stream.Position; // Die neue Position zurückgeben
end;

function StreamRWRead( context: PSDL_RWops; Ptr: Pointer; size: Integer; maxnum : Integer ): Integer; {$IFNDEF ___GPC___}cdecl;{$ENDIF}
var
  Stream: TStream;
begin
  Stream := Tstream(context^.unknown.data1); // Den Stream aus dem RWops holen
  if Stream = nil then
  begin
    Result := -1;
    Exit;
  end;
  Result := Stream.Read(Ptr^, Size*Maxnum) div Size; // Daten auslesen und die Anzahl der Blocks zurückgeben
end;

function StreamRWWrite( context: PSDL_RWops; Ptr: Pointer; size: Integer; num: Integer ): Integer; {$IFNDEF ___GPC___}cdecl;{$ENDIF}
var
  Stream: TStream;
begin 
  Stream := Tstream(context^.unknown.data1); // Den Stream aus dem RWops holen
  if Stream = nil then
  begin
    Result := -1;
    Exit;
  end;
  Result := Stream.Write(Ptr^, Size*num) div Size; // Die Daten schreiben und die Anzahl der geschriebenen Blocks zurückgeben
end;

function StreamRWClose( context: PSDL_RWops ): Integer; {$IFNDEF ___GPC___}cdecl;{$ENDIF}
var
  Stream: TStream;
begin
  Stream := Tstream(context^.unknown.data1); // Den Stream aus dem RWops holen
  if Stream = nil then
  begin
    Result := -1;
    Exit;
  end;
  Stream.Destroy; // Speicher des Streams freigeben
  context^.unknown.data1 := nil; // Im RWops den Pointer leeren
  Result := 0; // 0 zurückgeben
end;

function StreamToRWops(Stream: TStream): TSDL_RWops;
begin
  Result.type_ := 2; // Mir ist die Bedeutung der type_-Variable nicht ganz klar, aber ich setze sie auf 2, wie im dynamischen Teil für Unknown      
  Result.unknown.data1 := Stream; // Pointer auf den Stream setzen
  Result.seek := @StreamRWSeek; // Und noch die Funktionen zuweisen
  Result.read := @StreamRWRead;
  Result.write := @StreamRWWrite;
  Result.close := @StreamRWClose;
end;     

procedure RWopsToStream(RW: PSDL_RWops; Stream: Tstream);
var
  Buf: Pointer; // Pufferspeicher zum Lesen
  Read: Integer; // Anzahl der gelesenen Bytes
begin
  if Stream = nil then Exit; // Sicherheitsbedingungen
  if RW = nil then Exit;

  GetMem(Buf, 1024); // Speicher für Puffer reservieren
  try
    Read := RW^.read(RW, Buf, 1, 1024); // Ersten Datenblock auslesen
    while Read > 0 do // Solange Auslesen, bis keine Daten mehr gelesen wurden
    begin
      Stream.Write(Buf^, Read); // Ausgelesene Daten in Stream schreiben
      Read := RW^.read(RW, Buf, 1, 1024); // Neue Daten auslesen
    end;
  finally
    FreeMem(Buf); // Puffer wieder freigeben.
  end;
end;
Info DGL.png Das @ vor den Funktionen ist nur im FPC nötig, in Delphi kann man sie sich glaube ich sparen. Auch die cdecl-Direktive hinter den Funktionen für die RWops ist für den FPC wichtig und sollte bei ihm übernommen werden, da es sonst Typenfehler bei den Zuweisungen gibt.

Mit den obigen Funktionen kann man eigentlich alles mit RWops machen, was man auch mit Streams machen kann. Es ist dabei völlig gleich, ob ein TMemoryStream oder ein TFileStream übergeben wird, solange es von TStream abstammt. Was man jetzt auch noch machen könnte, wäre eine Nachfolger-Klasse zu TStream, mit der man auf RWops zugreifen kann. Aber wer dann auf die Idee kommt, die in einen RWops zu packen, den... naja egal ;)

Das Ende naht

So, das war es erstmal. Fünf (korrigiere: sechs) Seiten reichen ja auch für's erste. Vielleicht fällt mir später noch mehr zu dem Thema ein, sodass es für noch ein Tutorial reicht (aber ich glaube, ich habe jetzt die RWops eigentlich ausgereizt obwohl man auch TCP-Verbindungen darüber laufen lassen könnte, aber dafür sollte man sich dann doch besser ne eigene Klasse machen).

Bei Kommentaren, Kritik, Hinweisen, Morddrohungen oder Informationen einfach an mich im Forum wenden.

Lord Horazont