Zum Inhalt springen
Michael Malura
273 Github Stars 10060 npm Downloads 29 Projekte

Secret of Evermore: Der Junge kommt jetzt komplett aus dem ROM

Im ersten Teil hab ich das Spiel seine Maps rendern lassen und musste bei den Sprites des Jungen passen. Stehen und Gehen gingen per Pixelabgleich gegen ein Referenz-Sheet, aber Rennen, Angriff und Zauber kamen nur auf 0.4 bis 0.8 Treffer und sahen aus wie Tile-Salat. Meine Erklärung damals: die dynamischen Posen liegen nicht als fertige Bilder im ROM, sondern werden zur Laufzeit aus einzelnen Hardware-Sprites zusammengebaut. Das stimmte. Was fehlte, war der Weg dorthin.

Der ist jetzt gefunden. Alle Frames des gesteuerten Jungen kommen aus dem ROM, mit dem echten Timing des Spiels, und sind gegen den Emulator pixelgenau geprüft.

Kontaktbogen: alle 60 Frames des Jungen aus dem ROM, Stehen, Gehen, Rennen, Angriff in vier Richtungen
Kontaktbogen: alle 60 Frames des Jungen aus dem ROM, Stehen, Gehen, Rennen, Angriff in vier Richtungen

Der falsche Lead

Die Session davor hatte eine Übergabe hinterlassen, die ziemlich sicher klang: der Sprite-Composer im ROM habe mehrere Einstiegspunkte, und der laufende Junge werde über einen davon gerendert, den der bisherige Trace nie gesehen hatte. Dazu drei konkrete Adressen mit ihren Aufrufern.

Zwei der drei Adressen waren Unsinn. Sie lagen mitten in Instruktionen, eine auf einem TXA, eine auf einem REP. Die angeblichen Aufrufer standen in einer Bank, die sich unter keiner Flag-Kombination als Code lesen ließ, also Daten, die zufällig wie ein Sprungbefehl aussahen. Ein Byte-Scan nach Opcodes findet so etwas ständig, und genau deshalb war die Regel für diesen Durchgang: nichts glauben, was nicht disassembliert oder gemessen ist.

Die dritte Adresse war echt, und sie hat alles aufgelöst. Es ist kein neuer Einstieg, sondern ein Wiedereinstieg in dieselbe Routine, ein paar Bytes hinter dem Anfang. Er wird von einer Schleife aufgerufen, die jeden Frame eine verkettete Liste abläuft: nach Bildschirmhöhe sortierte Knoten, einer pro sichtbarem Sprite, jeder mit Position, Palette und einem Zeiger auf sein Metasprite. Der alte Trace saß auf dem Routinenanfang und hat deshalb alle Knoten dieser Liste verpasst, den Jungen eingeschlossen. Ein paar Bytes daneben, eine ganze Session verloren.

Die Kette

Von da an ging es Glied für Glied rückwärts. Woher kommt der Zeiger im Listenknoten? Aus zwei Feldern der Spielfigur, eines pro Layer. Wer schreibt die Felder? Eine kleine Bytecode-Maschine in Bank $D0, die pro Figur ein Animations-Script abarbeitet. Und die Scripts liegen in Bank $C8, direkt lesbar, sobald man die Opcodes kennt:

52                Script-Start, setzt Felder zurück
4D 04 00          Geschwindigkeit 4 (Rennen: 12)
22 4B 11  08      Metasprite $CB114B, dann 8 Frames warten
C2 06             einen Schritt bewegen
22 65 11  08      nächstes Metasprite, 8 Frames
...
D3                zurück zum Start

Das Byte nach dem Metasprite ist die Anzeigedauer in Frames. Der Emulator hat für das Gehen nach oben 8, 8, 7, 8, 8, 6 Frames gemessen, und genau diese Zahlen stehen im Script. Der letzte Frame dauert im Spiel einen Tick länger, weil der Rücksprung selbst einen Frame kostet. Beides ist jetzt im Manifest.

Welches Script gerade läuft, entscheidet eine Tabelle in Bank $C4: 16 Byte pro Aktion, vier Richtungen zu je vier Byte. Gruppe 5 ist Stehen, 6 Gehen, 7 Rennen, 10 Angriff. Die Reihenfolge der Richtungen ist oben, rechts, unten, links.

Gehzyklus in vier Richtungen mit dem Timing aus dem Animations-Script
Gehzyklus in vier Richtungen mit dem Timing aus dem Animations-Script

Warum die Bilder erst Müll waren

Mit den Scripts hatte ich die Metasprites, aber die Blöcke darin ergaben beim Rendern Unsinn. Die Blocknummern waren viel zu klein für die bekannte Grafiktabelle. Ich hab erst geraten, ob ein Offset dazukommt. Kam keiner. Der Composer hat schlicht zwei Tabellen: ein Flag-Bit im Chunk entscheidet, ob ein Block ein 16×16-Stück aus der einen oder ein 8×8-Stück aus der anderen Tabelle ist. Der Junge mischt beides, Torso in 16×16, Hände und Füße in 8×8. Wer nur die eine Tabelle kennt, sieht die halbe Figur und hält den Rest für kaputt.

Dann die Farben. Die Palette aus dem Charakter-Record hatte an zwei Stellen ein grelles Grün, und Haare und Kleidung stimmten nicht mit dem Spiel überein. Die Lösung kam aus dem Emulator: die Palette, die zur Laufzeit im Farbspeicher liegt, steht wortgleich im ROM, nur drei Paletten weiter. Es gibt acht Varianten des Jungen, und die zwei grünen Slots sind die Waffenfarben. Palette 0 ist der Platzhalter ohne Waffe, Palette 3 der Junge mit Knochenkeule aus dem Dschungel.

Zuletzt die Reihenfolge. Die Standposen lagen bei 0.87, alle Abweichungen an der Naht zwischen Hüfte und Oberkörper. Auf dem SNES liegt ein Sprite mit niedrigerer OAM-Nummer vorne, und der Composer schreibt den ersten Chunk in den niedrigsten Slot. Mein Renderer hatte den ersten Chunk zuerst gezeichnet und damit nach hinten gelegt. Einmal die Schleife umgedreht, alle Standposen auf 1.0.

Pixelvergleich: Atlas-Frame, Rekonstruktion aus dem Emulator und Differenz, Score 1.0
Pixelvergleich: Atlas-Frame, Rekonstruktion aus dem Emulator und Differenz, Score 1.0

Rennen braucht den Ring

Die Gehframes ließen sich messen, weil ich dem Emulator per Lua einen Savestate lade und dem Jungen Richtungstasten schicke. Rennen ging nicht. Erst dachte ich, B sei die Taste, aber B ist Angriff, dafür hatte ich dann eben die Angriffsframes. Rennen ist A, und nur mit dem Jaguar-Ring, den mein Savestate nicht hatte.

Den Script-Cursor direkt auf das Renn-Script zu setzen hat nichts gebracht, das Spiel setzt ihn im nächsten Frame zurück. Also andersrum: die Charm-Flags stehen laut RAM-Map in drei Bytes. Alle drei auf FF, A halten, und der Junge rennt, exakt mit den Frames aus dem Script. Danach Bisektion, erst byteweise, dann bitweise: Bit 1 in $7E2262 ist der Jaguar-Ring.

Rennzyklus in vier Richtungen, im Emulator erst mit Jaguar-Ring erreichbar
Rennzyklus in vier Richtungen, im Emulator erst mit Jaguar-Ring erreichbar
Angriff mit der Knochenkeule in vier Richtungen
Angriff mit der Knochenkeule in vier Richtungen

Prüfen statt behaupten

Der Prüfweg ist derselbe wie beim letzten Mal, nur strenger. Ein Lua-Skript zeichnet pro Frame OAM, Video- und Farbspeicher auf, während der Junge läuft. Daraus wird der Junge rekonstruiert, so wie ihn die Konsole gezeichnet hat, und gegen den Frame aus meinem Atlas gelegt, der dasselbe Metasprite trägt. Der Score ist der Anteil exakt gleicher Pixel.

Von 60 Frames haben 46 ein Gegenstück in den Aufnahmen. 44 davon liegen bei genau 1.0. Die zwei anderen sind isolierte Einzelframes im Capture, in denen die Konsole gerade mehr Sprites zeigt als die Pose hat, vermutlich der Schlag-Effekt oder das Blinken an einer Wand. Die stehen als offen im Manifest, statt weggeglättet zu werden.

Wie das lief

Der Ablauf war diesmal anders als beim ersten Teil. Ich hab die Richtung vorgegeben und die Ergebnisse angeschaut, Claude hat die Arbeit über parallele Agenten verteilt: einer hat einen eigenen kleinen Disassembler geschrieben, weil Ghidra die Registerbreiten in diesem Code ständig falsch rät, einer hat Mesen mit Savestates gefahren, einer hat Pixel verglichen. Die Berichte kamen mit Bytes und Adressen, und jede Behauptung wurde mit einer Messung gegengeprüft, bevor sie in die Doku durfte.

Mein Anteil war der Blick von außen. Ich hab im Emulator-Fenster gesehen, dass eine Pflanze den Jungen trifft, während der Trace lief. Ohne den Hinweis wäre die Sequenz mit Treffer-Frames drin ausgewertet worden. Und die Ansage, dass für Kompilieren, Tracen und Testen kleinere Modelle in vielen parallelen Agenten reichen, hat das Ganze in einem Abend durchlaufen lassen, statt sich in einer einzigen langen Session festzufahren.

Was fehlt: die restlichen Aktionsgruppen der Tabelle, also Zauber, Schaden, Tod, und der Hund, der nach demselben Muster in einer Nachbarbank liegt. Der Parser dafür ist derselbe. Wie beim letzten Mal landet nichts von Square im Repo, der Atlas wird aus der eigenen ROM neu gebaut.

15.09.2026 aktualisiert 15.09.2026
Teilen X Reddit Hacker News LinkedIn E-Mail
Neue Beiträge abonnieren RSS JSON