Themabewertung:
  • 1 Bewertung(en) - 5 im Durchschnitt
  • 1
  • 2
  • 3
  • 4
  • 5
Reverse Engineering der NLT II
Ich finde die Schrift im ersten Bild am angenehmsten zu lesen.

Ist das Zufall oder gewollt, dass im Nachbau die Zeilen mit dem jeweils gleichen Wort des Originals enden?
"Alrik war durstig und hat getrunken."
Zitieren
Ich denke, das ist technisch bedingt - genau das würde sich mit einem "eigenen" Zeilenumbruch ja ändern, womit man die Textbox effizienter und sinnvoller befüllen könnte.
Zitieren
Danke für eure Ideen, ich bin ganz eurer Meinung. Es wäre zwar möglich, den Text dem Original sehr ähnlich zu gestalten, doch würde das an Lesbarkeit kosten. Also besser wir nehmen Schriftschnitt Medium, normale Leerzeichen, und etwas kleineren Text, bzw. größeren Zeilenabstand - im Original können zwei Buchstaben 'verschmelzen', wenn bspw. ein 'g' über einem 'i' steht. Um gewisse Zeilenabstände zu gewährleisten, müsste man die Textboxen aber selbst malen, auch um einen eigenen Textfluss zu ermöglichen:

(03.11.2023, 11:17)aeyol schrieb: Ich denke, das ist technisch bedingt - genau das würde sich mit einem "eigenen" Zeilenumbruch ja ändern, womit man die Textbox effizienter und sinnvoller befüllen könnte.

Genau, aktuell habe ich nur den Befehl für das Zeichnen von Textketten ersetzt. Der nächste Schritt wäre, den Befehl zum Malen der Textboxen anzupassen. So könnte man längere Texte selbstständig umbrechen und die Größe der Textboxen entsprechend anpassen. Das sollte subtil genug sein, um nicht aufzufallen – ob eine Box nun 5 oder 7 Zeilen hat, dürfte kaum jemanden stören. Hauptsache, die Texte bleiben gut lesbar und die Box sieht nicht fehl am Platz aus. Ein zusätzlicher, bisher nicht genannter Vorteil wäre, dass ich die Textboxgrafiken nicht strecken müsste, wie man es im ersten Beispiel meines letzten Posts passiert ist.



Was PT Mono betrifft, werde ich das später ausprobieren, aber ich bezweifle, dass die Originalschriftart eine Monospace-Font ist. Besonders bei Buchstaben wie 'i' oder 't' sieht man die schmalere Breite – und umgekehrt beim Leerzeichen, wie weit es ist. Also scheint es, dass jeder Buchstabe seine eigene fixe Breite hat – aber noch nicht ganz so ausgefeilt wie bei modernen Schriftarten, die Abstände abhängig von den nebeneinanderstehenden Buchstaben anpassen (Stichwort 'Kerning').
Zitieren
Ich hatte mich kürzlich mal mit dem BOB-Dateiformat auseinander gesetzt, die offenen Fragen des Wikis klären können, und den Dateiaufbau zusammen gefasst. Zu Dokuzwecken poste ich es mal hier:

Ich habe mal ein Screenshot des ungewöhnlichen .BOB von Adran, das ich im Spiel nicht triggern konnte, beigefügt:

   
Zitieren
Mhh, ich weiß nicht wie ich das übersehen konnte, aber ich habe wohl etwas mühselig gezeigt, dass das "Neue Format", einfach das "Alte Format" mit einem vorangestellten Pre-Header ist. Ab dem 'Headerbeginn' sind beide Formate identisch.
Der Sinn dahinter erschließt sich mir noch nicht ganz. Vielleicht hat der Pre-Header das Abstimmen der Animationen etwas erleichtert?

@Shihan, falls du mich zum Wiki / Repo hinzufügen magst, kann ich die Artikel zu BoB, 3DM und einigen anderen Formate gerne ergänzen. Da kam jetzt über das letzte Jahr doch so einiges zusammen.

Ich glaube, wir haben dann schon fast alle Formate in Riva geknackt! Was fehlt, sind noch .MOV/.MOF für die Autobewegungen, was sich mit eigenen 'Aufnahmen' in der 1.62 Demo wohl leicht entziffern lässt, sowie 5 Formate von dem MODULEAUTOMAP (.ANN .APA .LST .MSK .MST), die aber übersichtlich aussehen. Ah und dann noch die Dialogformate .LXT und .XDF . Und zuletzt fehlt noch der Komprimierungsmodus 0x02 in AIF, den man noch ausmachen muss.

Ergänzung:

Ich hatte zuletzt noch Spaß mit dem Komprimierungsmodus 0x02 in AIF, der in Riva zwar nur von einem Bild benutzt wird, aber dann doch nachgegeben hat:
Es ist eine Form von Lauflängenkodierung (RLE).
Im Endeffekt muss man immer nur 1 Byte b lesen und entscheiden, ob dieses < 128 ist:
Fall I) b < 128 leitet eine Folge von b + 1 Bytes ein, b selbst nicht mitgezählt, die 1:1 in das Ergebnis kopiert werden.
Fall II) b >= 128 steht immer vor einem Farbcode c. c wird nun 257 - b mal in das Ergebnis geschrieben.

Anders ausgedrückt: Das komprimierte Bild ist also in Pakete b_0, p_0, b_1, p_1, b_2, p_2, b_3, p_3, ... unterteilt,
wobei jedes b_n die Länge von p_n diktiert, und besagt, ob man p_n entweder ein- oder mehrfach in das Ergebnis schreibt.

Auf Fall I) folgt wohl immer Fall II), außer eine Folge ist zu lang, aber dies kommt in dem einen Rivabild nicht vor.
Dass die Fallgrenze bei < 128 liegt, habe ich auch nur geraten, und könnte man nur verifizieren, falls dieser Komprimierungsmodus z.B. auch in Sternenschweif oder der Schicksalsklinge benutzt wird.
Zitieren
Hi cmfrydos,

bin nach wie vor absolut überwältigt, wie weit Du hier gekommen bist, nachdem ich den Reverse-Engineer-Hut an den Nagel gehängt habe. Grandios, chapeau dafür!
Ich habe Dich mal als Collaborator für das Wiki eingeladen. Sag Bescheid, wenn das klappt oder auch nicht klappt.

Bin lange weg gewesen, weil mein RL mich gewaltig beschäftigt hat. Bis auf ein paar Arbeiten an einem Factorio-Projekt (YAFC CE) habe ich auch wenig hobbymäßiges Entwickeln gemacht. Alles ein wenig eingeschlafen.

Aber wenn Du im Wiki ein paar Dinge nachpflegst, schau ich gerne drüber und gebe meinen Senf ab, wenn gewünscht.
Auch Feedback und / oder Hilfe bei Deinem RivaHDViewer kann ich gerne geben, wenn es was konkretes gibt, wo Du Input brauchst. Kann nicht garantieren, dass ich innerhalb weniger Stunden antworte, aber ich habe mir vorgenommen, hier wieder öfter reinzusehen.
Zitieren
Hi Shihan,

ich bin momentan auch sehr vom echten Leben eingenommen, aber ich hoffe, bald mal wieder Zeit fürs Reverse-Engineering und Modden an der Nordlandtrilogie zu finden. Ich freue mich darauf, die Dinge ins Wiki nachzutragen - danke, das mit der Einladung hat geklappt!

Ich bin bezüglich der Repositories zum HDViewer und zum Probenlogger etwas im Rückstand und sollte auch dort mal einen Push vorbereiten. Auf meinem Rechner hatte ich zuletzt viel experimentiert.

Zuletzt, das war noch im Frühjahr, hatte ich einen kleinen Eureka-Moment, als ich merkte, dass es beim Aufbau eines Open-Source-Klons gar nicht notwendig wäre, bottom-up zu arbeiten, also erst die Funktionen zu implementieren, die keine anderen Funktionen aufrufen, dann die, die nur die schon implementierten aufrufen, und so weiter. Man kann auch mit dem Main-Loop anfangen, der bei BrightEyes erst ganz zum Schluss fertiggestellt werden konnte. Um das zu bewerkstelligen, startet man quasi eine DOS-Emulation an genau der Stelle, an der die noch nicht implementierte Unterfunktion im Speicher startet. Das klappt auch praktisch ganz gut. Zuletzt hing ich nur daran, dass, wenn ich zu viele der originalen Funktionen durch meine ersetze, manchmal Endlosschleifen auftreten, da Interrupts nicht aufgerufen werden. Hier rätsel ich zurzeit daran, wie ich diese Interrupt-Mechanismen am besten ersetze. Oft ist es nur ein: f1 liest in Endlosschleife die globale Variable a aus, bis ein Zeit-Interrupt a auf false setzt - sollte eigentlich machbar sein, ich stehe nur auf dem Schlauch.

Code:
bool a;

void f1(){
    a = true;
    while(a){
        // do nothing
    }
}

void interrupt(){ // called by CPU interrupt
    a = false;
}
Zitieren
Schön, dass die Einladung geklappt hat. Dann freue ich mich auf Deine Infos :)

Der Ansatz, den Du da beschreibst, ist durchaus vielversprechend! KeeperFX (ein Klon von Dungeon Keeper) hat das auch so gemacht, bis alles ersetzt war.
Im Rahmen der Recherche nach diesem Ansatz bin ich mal auf dieses Tool gestoßen: https://github.com/OpenRakis/Spice86
Leider, leider, leider funktioniert das nur mit Real Mode, was bei Riva schlecht ist, da Riva ja Protected Mode nutzt.

Deshalb meine Frage: Wie hast Du das hinbekommen, dass Du Funktionen ersetzen kannst? In welcher Umgebung läuft das Ding dann, Dosbox?
Zitieren
Ich fange in der DosBox die Call-Befehle ab, schaue, ob ich eine C-Funktion dazu habe, und rufe dann diese stattdessen auf. Ich vermute, dass das recht ähnlich läuft wie bei BrightEyes. Mit dem Protected Mode hatte ich bisher keine großen Schwierigkeiten, man muss nur aufpassen, da je nach Modus die Operanden- und Adressweiten verschieden sind. Lustigerweise mache ich genau das, was Spice macht, nur bisher nur im Protected-Mode und mit einer C-Ausgabe. Ich jage den Dosbox-Disassembler über den Startpunkt einer Funktion, dieser zerstückelt mir den Byte-Code in die einzelnen Befehle, und diese schiebe ich durch ein Python-Skript, das mir äquivalenten DosBox-C-Code ausgibt.
Ich habe, glaube ich, etwa 99,9% des verwendeten Befehlssatzes implementiert, und damit > 90% des gesamten Riva-Bytecode.

Raus kommt dann so etwas wie das:
(Habe den Ausschnitt nur schnell kopiert, um den aktuellen Stand darzustellen. Die Funktion an sich ist vielleicht nicht so interessant, und generierter Code ist nie schön, aber vielleicht ein Startpunkt ;))

Code:
void f_1DB2B0()
{
    CPU_Push32(reg_ebx);
    CPU_Push32(reg_ecx);
    CPU_Push32(reg_esi);
    CPU_Push32(reg_edi);
    CPU_Push32(reg_ebp);
    reg_ecx = reg_eax;                            // 32,32,mov ecx,eax
    reg_esi = reg_edx;                            // 32,32,mov esi,edx
    reg_edx = mem_readd_inline(reg_eax + 0x003D); // 32,32,mov edx,[eax+003D]
    test_32(reg_edx, reg_edx);
    if (FLG_NE) {
        goto l1DB2C8;
    }
    reg_ebx = mem_readd_inline(reg_eax + 0x0031); // 32,32,mov ebx,[eax+0031]
    reg_edi = mem_readd_inline(reg_eax + 0x0039); // 32,32,mov edi,[eax+0039]
    goto l1DB2CD;
l1DB2C8:
    reg_edi = reg_edx;                            // 32,32,mov edi,edx
    reg_ebx = mem_readd_inline(reg_eax + 0x0035); // 32,32,mov ebx,[eax+0035]
l1DB2CD:
    reg_eax = mem_readd_inline(reg_ecx + 0x0004); // 32,32,mov eax,[ecx+0004]
    mem_writed_inline(reg_ecx + 0x0029, reg_esi); // 32,32,mov [ecx+0029],esi
    reg_ebp = mem_readd_inline(reg_eax);          // 32,32,mov ebp,[eax]
    test_xor_32(reg_edx, reg_edx);
    reg_edx = reg_edx ^ reg_edx;
    decide_call(mem_readd_inline(reg_ebp + 0x0044), std::nullopt); // call near dword [ebp+0044]
                                                                   // ABS
    reg_eax = mem_readd_inline(reg_ecx + 0x0004);                  // 32,32,mov eax,[ecx+0004]
    reg_ebp = mem_readd_inline(reg_eax);                           // 32,32,mov ebp,[eax]
    test_xor_32(reg_edx, reg_edx);
    reg_edx = reg_edx ^ reg_edx;
    decide_call(mem_readd_inline(reg_ebp + 0x0010), std::nullopt); // call near dword [ebp+0010]
                                                                   // ABS
    reg_eax = mem_readd_inline(reg_ecx + 0x0004);                  // 32,32,mov eax,[ecx+0004]
    reg_ebp = mem_readd_inline(reg_eax);                           // 32,32,mov ebp,[eax]
    reg_edx = reg_edi;                                             // 32,32,mov edx,edi
    decide_call(mem_readd_inline(reg_ebp + 0x0024), std::nullopt); // call near dword [ebp+0024]
                                                                   // ABS
    reg_eax = mem_readd_inline(reg_ecx + 0x0004);                  // 32,32,mov eax,[ecx+0004]
    test_xor_32(reg_ebx, reg_ebx);
    reg_ebx = reg_ebx ^ reg_ebx;
    test_xor_32(reg_edx, reg_edx);
    reg_edx = reg_edx ^ reg_edx;
    reg_edi = mem_readd_inline(reg_eax);                           // 32,32,mov edi,[eax]
    reg_bx  = mem_readw_inline(reg_esi + 0x0012);                  // 16,32,mov bx,[esi+0012]
    reg_dx  = mem_readw_inline(reg_esi + 0x0014);                  // 16,32,mov dx,[esi+0014]
    decide_call(mem_readd_inline(reg_edi + 0x0058), std::nullopt); // call near dword [edi+0058]
                                                                   // ABS
    reg_eax = mem_readd_inline(reg_ecx + 0x0004);                  // 32,32,mov eax,[ecx+0004]
    reg_edx = mem_readd_inline(reg_eax);                           // 32,32,mov edx,[eax]
    decide_call(mem_readd_inline(reg_edx + 0x0028), std::nullopt); // call near dword [edx+0028]
                                                                   // ABS
    reg_ebx = mem_readd_inline(reg_ecx + 0x003D);                  // 32,32,mov ebx,[ecx+003D]
    test_32(reg_ebx, reg_ebx);
    if (FLG_E) {
        goto l1DB31F;
    }
    reg_eax = reg_ebx; // 32,32,mov eax,ebx
    CPU_Push32(-1);
    f_1CA710();
    CPU_Pop32();                                     // CALL // call 001CA710 ($-10c08) REL
    mem_writed_inline(reg_ecx + 0x003D, 0x00000000); // 32,32,mov dword [ecx+003D],00000000
l1DB31F:
    reg_eax = mem_readd_inline(reg_ecx + 0x0039); // 32,32,mov eax,[ecx+0039]
    CPU_Push32(-1);
    f_1CA710();
    CPU_Pop32();                                     // CALL // call 001CA710 ($-10c17) REL
    mem_writed_inline(reg_ecx + 0x0039, 0x00000000); // 32,32,mov dword [ecx+0039],00000000
    mem_writed_inline(reg_ecx + 0x0035, 0x00000000); // 32,32,mov dword [ecx+0035],00000000
    reg_eax = mem_readd_inline(reg_ecx + 0x0035);    // 32,32,mov eax,[ecx+0035]
    mem_writed_inline(reg_ecx + 0x0031, reg_eax);    // 32,32,mov [ecx+0031],eax
    reg_ebp = CPU_Pop32();
    reg_edi = CPU_Pop32();
    reg_esi = CPU_Pop32();
    reg_ecx = CPU_Pop32();
    reg_ebx = CPU_Pop32();
    return; // ret
}

Als nächstes wollte ich per Graphenanalyse die `goto`s durch `if`s, `while`s und `switch`-Statements ersetzen und mit ähnlicher Analyse eventuell die globalen Register durch lokale Variablen. Das war bisher nur eine Spielerei, und ich weiß gar nicht, was das Ziel ist. Die Performance im DosBox dynamic-cpu Mode (JIT-Übersetzung von DOS-ASM in x64) ist fantastisch, und der Source-Code von Riva scheint ja noch in den Händen von Attic zu sein und nicht verloren, wie bei der Schicksalsklinge. Für stellenweise Analysen, unter anderem, da ich so Breakpoints von Visual Studio aus setzen und interessante Stellen kommentieren kann, ist es aber praktisch.
Zitieren
Klingt richtig gut!

Vielleicht will man nicht unbedingt den ganzen Source so ersetzen. Aber falls man bei einer Neuimplementierung nicht genau weiß, wie etwas abläuft, oder Fehler suchen und fixen will, da könnte sowas schon echt hilfreich sein. Vor allem, wenn man dann größere Teile von Riva mit dem VS-Debugger durchlaufen kann. Das wäre ein Traum!

Könntest Du sowas hier gebrauchen:
   

Das ist noch aus meiner Analyse mit Ghidra. Die Offsets sind zwar verschoben, weil ich nur den LE-Teil der Exe analysiert habe, aber das sollte kein großes Problem darstellen.
Wenn Du damit was anfangen könntest, will ich mal sehen, wie ich die Daten exportiert bekomme.


Hast Du denn Pläne, die nachgebauten C-Funktionen zu teilen?
Zitieren
Das ist tatsächlich interessant, da ich noch nicht herausfinden konnte, wo die ALF-Extraktion stattfindet. Besonders interessant wäre es, die Funktion zu finden, die die Adressen zu den im RAM entpackten Dateien zurückgibt. Damit könnte man vielleicht auch die Funktionen zum Importieren der Spieldateien (für Bilder, Videos oder andere Dateien) finden, um hier noch offene Fragen zu klären. Wenn ich deine Grafik richtig lese, liegen die am Anfang des Programmes? Ich frage mich, wie Ghidra das den Quelldateinamen zuordnet, ich habe zwar textuelle Verweise im Hex gesehen, aber bei meinem Disasemblierverfahren sind die verloren gegangen.. :think: 

Groß händisch leicht lesbar nachgebaut habe ich bisher noch nichts. Am ehesten noch die Zufallsfunktionen, aber auch die noch nicht vollständig. Wenn da etwas fertig wird, teile ich das gerne. Bei den anderen ~350.000 Zeilen rein maschinell generierten Codes fühle ich mich nicht wohl, diese bereitzustellen, da es Attics Rechte tangieren könnte und weil da die schöpferische Eigenleistung, oder wie man das auch nennen mag, noch fehlt. Ich wäre aber offen, meine Skripte und DosBox zu teilen, damit könnte man sich das selbst generieren.
Zitieren
Wie gesagt, da ich den DOS/4GW-Stub am Anfang entfernt habe, um das Ding überhaupt laden zu können, stimmen die Offsets sicherlich hinten und vorne nicht. Aber ich könnte sicher die ersten N Bytes einer Funktion mit ausgeben, damit Du sie darüber zuordnen kannst. Mal sehen, der Python-Dialekt in Ghidra ist etwas eigenartig.

Die Namen der Dateien kommen von Debug-Ausgaben im Code. Viele, leider nicht alle, Funktionen/Methoden machen eine solche Debug-Ausgabe. Die läuft zwar anscheinend ins Leere, aber dabei gibt es immer wieder Verweise auf CPP-Dateien. Und mit einem anderen Python-Skript bin ich alle erkannten Funktionen durchgelaufen und habe geprüft, ob da eine solche Debug-Ausgabe existiert und dann, wenn dem so ist, den Dateinamen vor den Funktionsnamen gesetzt.


Wenn Dir nicht wohl ist, die Sachen zu teilen, ist das ok. Kann ich gut verstehen. Aber wenn es ein paar kleine Teaser gibt... da würde ich nicht nein sagen ;)
Zitieren
(29.07.2024, 10:01)Shihan schrieb: Aber ich könnte sicher die ersten N Bytes einer Funktion mit ausgeben, damit Du sie darüber zuordnen kannst.

Das wäre super! Dann könnte ich den Offset zu dir berechnen. Am besten von mehreren Stellen, denn wenn du eine absolute Adresse erwischst, könnte das bei mir nicht vorkommen, da diese beim Laden neu ausgerichtet werden. Mein Code wurde während der Runtime generiert (ich habe nur Funktionen, die während einer "Recording"-Session aufgerufen wurden), während dein Code statisch in der EXE liegt, so wie ich es verstanden habe. Da gibt es immer wieder Differenzen; in der EXE ist es, glaube ich, enger gepackt. Aber vielleicht kann Ghidra das umschalten?

Die Debug-Ausgaben, also auch Verweise auf die Dateinamen, liegen am Ende der EXE hintereinander. Ich konnte nur leider noch keine Stelle finden, die darauf per Offset zugreift und damit das Zugriffsmuster verstehen.
Zitieren
Bin noch nicht dazu gekommen, sorry. Chef und halbes Team ist im Urlaub und Kunde will Reklamationen durchboxen... Letzte Woche gab es fast 20 Überstunden...

Aber diese Woche ist wieder etwas Ruhe im Spiel. Mal sehen, ob ich das Export-Skrypt fertig bekomme.
Zitieren




Benutzer, die gerade dieses Thema anschauen: 4 Gast/Gäste