Desmontando SimCity 2000

SimCity 2000 (Maxis, 1993) es uno de mis juegos superfavoritos de toda la vida. Llevo jugándolo como 20 años y es parcialmente responsable de mis horrorosas notas en el instituto. Modificar juegos siempre me ha gustado, pero hasta ahora no me había puesto en serio a decodificar los datos de este simulador de ciudades. ¡Y he hallado varias cosas interesantes!

Existieron versiones en un mogollón de plataformas, desde el Macintosh (la original) hasta GameBoy Advance, pero mi favorita es la de MS-DOS y es sobre la que trata este artículo. Hay dos ficheros interesantes: el ejecutable (SC2000.EXE) y el fichero de datos (SC2000.DAT). Lamentablemente, la versión de Windows no salió en español, y la versión Network Edition que permitía juego en red funciona fatal (y también está sólo en inglés).

SC2000.EXE

El ejecutable del juego no parece contener muchos recursos, pero sí que tiene algunos textos de la interfaz. En el editor hexadecimal se pueden ver algunas etiquetas de ancho fijo.

También hay algunos de ancho variable que corresponden a más etiquetas de la interfaz. También parece haber algunos ficheros empotrados, en los que por ejemplo se describen los escenarios del juego.

Por ahora desemsamblar un ejecutable de hace cerca de 25 años no está entre mis especialidades, por lo que no he averiguado gran cosa. Los punteros no son evidentes en el ejecutable, por lo que dejado ahí. Pero la chicha está en el fichero de datos.

SC2000.dat

Este es el principal fichero de datos del juego. No tiene cabecera, pero un vistacillo en el editor hexadecimal arroja bastantes pistas sobre su estructura.

Resulta que desde el byte 0, lo primero que nos encontramos son bloques de 16 bytes que describen los ficheros contenidos en el paquete. El primer campo es evidente: son 12 bytes con el nombre del archivo (8 + 3 caracteres de los ficheros de MS-DOS más el punto). Si sobran caracteres, el resto de bytes son 00h.

Los cuatro restantes no tanto, pero resulta que el juego es original de Macintosh, que en aquella época usaba procesadores Motorola. Estos procesadores, a diferencia de los Intel, son Little-Endian (siendo Big-Endian los de Intel). Esto significa que los números de más de un byte se guardan ordenados del byte menos significativo al más significativo, en lugar del ordenamiento «natural». Se trata, pues, de un entero de 32 bits sin signo en formato Little-Endian en el que se codifica el offset (desplazamiento) del fichero cuyo nombre está justo antes.

La idea feliz sobre el offset se la debo a Brett Lajzer, un ingeniero de software de Albany que empezó antes que yo a investigar este fichero. Le escribí para intercambiar información y me advirtió sobre este punto, que no llegó a describir en su artículo.

El desempaquetamiento y empaquetamiento, sabido esto, resulta relativamente sencillo. He escrito un pequeño programa en Java que realiza esta operación:

package sce2000;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;

import org.apache.commons.io.FileUtils;

public class Sce2000 {
	
	public static final String SC2000DAT = "G:\\dos\\sce2000\\sc2000.dat";
	public static final int NUMFILES = 399;
	
	public static class Filestrut implements Serializable {
		private static final long serialVersionUID = 1L;
		public String filename;
		public int offset;
		public int targetOffset;
	}

	public static void main(String[] args) throws IOException, ClassNotFoundException {
		
		if (args.length == 0) {
			info();
		} else if ("x".equals(args[0])) {
			extract();
		} else if ("c".equals(args[0])) {
			create();
		} else {
			info();
		}

	}
	
	public static void info() {
		System.out.println("Usage: x to eXtract or c to Create (after eXtract) + sc2000.dat file");
	}

	public static void create() throws IOException, ClassNotFoundException {
		
		File sc2000dat = new File(SC2000DAT);
		File metafile = new File(SC2000DAT + "!/meta");
		ObjectInputStream ois = new ObjectInputStream(new FileInputStream(metafile));
		List<Filestrut> files = (List<Filestrut>) ois.readObject();
		
		List<Byte> targetFile = new ArrayList<Byte>();

		for (Filestrut file : files) {
			byte[] fileNameBytes = file.filename.getBytes(StandardCharsets.US_ASCII);
			for (Byte myByte : fileNameBytes) {
				targetFile.add(myByte);
			}
			for (int i = 0; i < 16 - fileNameBytes.length; i++) {
				targetFile.add((byte) 0);
			}
			//sourceFile.length();
		}
		
		int i = 0;
		for (Filestrut file : files) {
			byte[] binary = Files.readAllBytes(Paths.get(SC2000DAT + "!/" + file.filename));
			int fileOffset = targetFile.size();
			for (Byte myByte : binary) {
				targetFile.add(myByte);
			}
			int filePointer = 12 + (16 * i);
			
			byte[] offsetBytes = fromInt(fileOffset);
			targetFile.set(filePointer, offsetBytes[0]);
			targetFile.set(filePointer + 1, offsetBytes[1]);
			targetFile.set(filePointer + 2, offsetBytes[2]);
			targetFile.set(filePointer + 3, offsetBytes[3]);
			
			i++;
		}
		
		byte[] binaryFile = new byte[targetFile.size()];
		for (i = 0; i < targetFile.size(); i++) {
			binaryFile[i] = targetFile.get(i);
		}
		
		FileUtils.writeByteArrayToFile(sc2000dat, binaryFile);

		
		System.out.println();
		
	}
		

	public static void extract() throws IOException {
		
		Path sc2000dat = Paths.get(SC2000DAT);
		byte[] data = Files.readAllBytes(sc2000dat);
		
		int vector = 0;
		List<Filestrut> files = new ArrayList<Filestrut>();
		for (int i = 0; i < NUMFILES; i++) {
			
			Filestrut currfile = new Filestrut();
			
			currfile.filename =  convertFilename(Arrays.copyOfRange(data, vector, vector + 12));
			vector += 12;
			
			currfile.offset = fromByteArray(Arrays.copyOfRange(data, vector, vector + 4));
			vector += 4;
			
			System.out.println("Found file " + currfile.filename + " at " + currfile.offset);
			
			files.add(currfile);
			
		}
		
		File dir = new File(SC2000DAT + "!");
		if (dir.exists()) {
			FileUtils.deleteDirectory(dir);
		}
		
		dir.mkdir();
		
		Iterator<Filestrut> fileIt = files.iterator();
		
		Filestrut file = fileIt.next();
		Filestrut fileNext = null;
		
		boolean stop = false;
		while (!stop) {
			
			File extracted = new File(SC2000DAT + "!/" + file.filename);
			
			int init = file.offset;
			int end = -1;
			if (fileIt.hasNext()) {
				fileNext = fileIt.next();
				end = fileNext.offset;
			} else {
				end = data.length;
				stop = true;
			}

			System.out.println("Writing: " + extracted.getAbsolutePath());
			FileUtils.writeByteArrayToFile(extracted, Arrays.copyOfRange(data, init, end));
			
			file = fileNext;
			
		}
		
		File metafile = new File(SC2000DAT + "!/meta");
		
		ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(metafile));
		oos.writeObject(files);
		oos.close();
		
		System.out.println("OK!");
	}
	
	public static byte[] fromInt(int number) {
		byte[] bytes = ByteBuffer.allocate(4).putInt(number).array();
		swapEndianess(bytes);
		return bytes;
	}
	
	public static void swapEndianess(byte [] bytes) {
		byte temp1 = bytes[0];
		byte temp2 = bytes[1];
		bytes[0] = bytes[3];
		bytes[1] = bytes[2];
		bytes[2] = temp2;
		bytes[3] = temp1;
	}
	
	public static int fromByteArray(byte[] bytes) {
		// Change endianness
		swapEndianess(bytes);
		return ByteBuffer.wrap(bytes).getInt();
	}
	
	public static String convertFilename(byte[] data) {
	    StringBuilder sb = new StringBuilder(data.length);
	    for (int i = 0; i < data.length; ++ i) {
	        if (data[i] < 0) { 
	        	throw new IllegalArgumentException();
	        }
	        if (data[i] == 0) {
	        	break;
	        }
	        sb.append((char) data[i]);
	    }
	    return sb.toString();
	}


}

Este programa está hecho rápidamente y no está precisamente optimizado. He podido permitirme cargar o gestionar los ficheros enteros en memoria ya que el tamaño del DAT completo es de pocos megas. Además, tiene hardcodeado (incrustado en el código) el número de ficheros y la ruta al DAT. Mejorarlo lo dejo de deberes para el lector.

¿Por qué en Java? No es un lenguaje muy indicado para manejar binarios, y desde luego el no disponer de tipos sin signo no ayuda. Sencillamente lo hice en Java y no en C porque es el lenguaje con el que me gano la vida y con el que tengo más soltura, y llevo casi 8 años sin programar nada en C.

Usando el código anterior (o cualquier otro que el intrépido lector codifique) podemos ver los siguientes tipos de fichero:

  • RAW, con imágenes sin cabecera.
  • PAL, con la paleta de colores que se usa en el juego.
  • Ficheros de texto, algunos TXT* sin extensión y otros con extensión RAW.
  • XMI, con la música.
  • VOC, con los efectos de sonido.
  • FNT, con las fuentes del juego.
  • Etc, etc…

Hablemos un poco de cada uno.

Ficheros VOC

Los efectos sonoros del juego se almacenan en ficheros VOC. Bueno, ésta es fácil, ya que se trata de un formato no común pero ampliamente soportado por muchos editores de audio. Es un formato de Creative Labs que almacena principalmente audio codificado en PCM y ADPCM, aunque de forma puntual ha servido para otras codificaciones.

Se puede abrir y guardar con Adobe Audition perfectamente. La frecuencia de muestreo varía de un fichero a otro, pero la reconoce sin problemas.

Ficheros de texto

Existen tres tipos de ficheros de texto. Los más sencillos son los ficheros in extensión TXT*, siendo * un número. Se pueden abrir con el Notepad++ y editar sin problemas.

Bueno, no del todo. La codificación no es ASCII, ni ANSI, ni tampoco UNICODE porque aún no era común. ¿Cuál entonces? Pues, dado que el juego se programó para Macintosh, la codificación es Macintosh Roman. Esto es un problema porque Notepad++ no da soporte a esta codificación. Está pedido, pero no parece prioritario, por lo que si quieres editar cómodamente hay que convertirlo antes.

Otros ficheros de texto son los STR*.RAW y *.RAW, siendo * un número. Su formato no es tan bonito.

Al parecer el primer byte es siempre 00h, y el segundo parece almacenar el número de cadenas que hay en el texto. Las cadenas no son como las de C: en lugar de ser cadenas terminadas en 00h son cadenas precedidas de su longitud, codificada en 1 byte. Lo ideal para editar estos ficheros sería construir una utilidad al efecto, pero parece más que viable.

El tercer tipo de ficheros corresponde únicamente al fichero PPDT1003.RAW, que contiene una de las características más divertidas de SimCity 2000: los periódicos.

Lamentablemente este formato es una pesadilla. Comienza con un diccionario de términos separados por 00h, y luego tiene una ristra de textos para generar los artículos. Esta codificación personalizada es de ancho variable (por ejemplo, 5C96h representa la letra ñ) y usa los términos definidos anteriormente, y naturalmente contiene marcadores de posición para los términos del artículo. Éstos se generaban proceduralmente, y un artículo que denunciaba la desaparición de un animal tenía como protagonista un gato o un rinoceronte, de la señora Dwight o del señor Martínez.

Antiguamente existían editores, como Thingy, que daban soporte a ficheros TBL. En ellos se podía especificar un diccionario de términos (o toda una codificación) y editarlos «cómodamente». De todas formas sólo nos servirá si mantenemos el ancho de las cadenas, así que habría que descifrar el número que las precede (y su codificación) para poder editarlas como es debido. Parece bastante complicado, por lo que lo he dejado para mejor ocasión.

Editado: Al parecer, los punteros para los textos de los periódicos están en el fichero PPDT1004.RAW. Consisten en conjuntos de 4 bytes en Big-Endian. Menudo lío.

Ficheros de imagen

Con la excepción de algunos ficheros usados para texto (descritos en el apartado anterior), los RAW son las imágenes usadas en la interfaz del juego. Los edificios y otros elementos están en unos ficheros DAT que quedan fuera del ámbito de este documento ya que se pueden editar mucho más fácilmente con el SimCity Urban Renewal Kit (SCURK), una herramienta que se incluía con versiones posteriores para editar ciudades «a manubrio» y modificar el aspecto de los edificios.

En lo que nos ocupa: estos ficheros RAW son mapas de bits con 8 bits por pixel (256 colores) con una cabecera de 4 bytes. No parece haber información sobre la resolución o los colores, pero para esto último existe un fichero, MINE.PAL, que especifica la paleta que usa el juego. No es un fichero de Microsoft Palette, como sugiere su extensión, sino una paleta en bruto tipo ACT. Casualmente, es el formato que usa preferentemente Adobe Photoshop.

En cuanto a la resolución, no parece haber forma fácil de averiguarla simplemente mirando el archivo. Lo más sencillo es factorizar el número (menos 4 bytes) en dos múltiplos, cosa en la que el propio Photoshop nos puede asistir al abrir el archivo, al menos en versiones modernas. Por el nombre del fichero podemos más o menos discernir para qué sirve si no tenemos una idea, y si hemos jugado al juego (¡deberías!) podemos conocer más o menos su relación altura/anchura. Algunas son fáciles: la pantalla de título (TITLE.RAW) es una imagen de 640×480 pixeles. Todas las imágenes usan la misma paleta, dado que el juego maneja 256 colores en todo momento.

Para editar los ficheros con color indexado en 8 bits siempre uso la misma técnica: las convierto a color de 24 bits + 8 de canal alfa y las edito cómodamente. Después, antes de salvarlas, las cambio a color indexado cargando la paleta del juego nuevamente. Sorprendentemente, salvando en formato Adobe RAW el juego las lee bien, dado que Photoshop permite conservar los 4 bytes de la cabecera. ¡Qué alivio!

Nota: ni que decir tiene que este formato RAW no tiene nada que ver con los formatos de datos en bruto de las cámaras digitales.

Ficheros XMI

En los ficheros XMI se almacena cada una de las músicas del juego. El formato XMI soporta varias canciones por fichero, pero no parece ser el caso. La estructura es diferente a los MIDI, pero hacen prácticamenente lo mismo: almacenar notas y eventos musicales. Se pueden reproducir en Windows cómodamente con Foobar2000, y es posible convertir ficheros MIDI a XMI (y viceversa). MIDIPLEX parece hacerlo desde Windows, pero no se ofrecen binarios compilados y lo hice con herramientas más antiguas para MS-DOS, usando DOSBox. De todas formas, el propio SimCity 2000 necesitará DOSBox para funcionar en sistemas modernos.

Ficheros FNT

Presumiblemente las tipografías del juego, al juzgar por los nombres, los números que los acompañan y la extensión. No parece que sea mismo formato de fuentes en mapa de bits FNT para Windows, por lo que no he podido abrirlo con ningún editor.

Otros

Otros ficheros presentes en el paquete son fuentes General Midi para chipsets OPL, diversos índices y cabeceras para los conjuntos de gráficos de edificios… nada demasiado interesante para modificar el juego. Todo lo demás parece estar incrustado en el propio ejecutable, algo que está fuera de mi alcance.

En Windows

Como apunte, la versión de Windows es mucho más fácil de modificar. Los recursos de imagen y sonido están presentes en formato WAV y BMP, que son mucho menos oscuros que los descritos anteriormente. El resto de recursos se encuentra incrustado en el ejecutable del juego, pero con un editor de recursos se pueden extraer y modificar sin problemas, y en formatos más accesibles que los de MS-DOS. (Yo uso Resource Hacker, que es gratis y funciona bastante bien)

Los periódicos, sin embargo, parecen conservar el calamitoso formato de la versión de MS-DOS. Una pena porque hubiera sido interesante traducir esta versión al español.

Pues hasta aquí. Espero que haya sido muy educativo todo, aunque tras tanto tiempo no parece que el juego suscite mucho interés para ser modificado de ninguna forma. Siempre he querido tocar el juego y hacer mi propia versión, SimCity efecto 2000, un poco más gamberra, pero otra vez será.

Un comentario en “Desmontando SimCity 2000”

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.