Saltar al contenido
Nelkano Emulador multisistema

SDK de Nelkano

Modificar un juego

Usa host.gameMod para leer datos de una partida y cambiar su comportamiento. Tu módulo debe conocer el juego: qué representan sus datos, dónde están y cuándo es seguro modificarlos.

GBA admite lectura, escritura, valores numéricos, observaciones y reglas en Android y Windows/Linux. Studio ofrece memoria de prueba. En cualquier motor, consulta primero las capacidades de la sesión.

Comprobar permisos y capacidades

Declara en capabilities los permisos de las operaciones que uses. Los ejemplos de este capítulo utilizan:

"capabilities": [ "emulation.game-mod", "emulation.read", "emulation.memory.read", "emulation.memory.write", "runtime.events" ]

emulation.game-mod identifica este contrato; un host antiguo rechaza el paquete. Cada operación necesita además sus permisos:

  • Consultar capacidades: emulation.read.
  • Leer memoria o listar reglas: emulation.memory.read.
  • Escribir memoria o retirar reglas: emulation.memory.write.
  • Instalar reglas: lectura y escritura.
const active = await host.emulation.snapshot(); if (active) { const support = await host.gameMod.capabilities(active.session); console.log(support.systemId, support.spaces, support.frameRules); }

Revisa estos campos de la respuesta antes de operar:

  • session, gameId, systemId y simulated: partida, juego, sistema y si estás en una simulación.
  • memoryRead, memoryWrite, atomicWrite, frameRules y memoryObserve: operaciones disponibles.
  • spaces: espacios con id, addressBits, byteOrder y regions. Cada región indica id, start, length, readable y writable.
  • maxRanges, maxBytes, maxRules y maxRuleBytes: límites anunciados por el motor.

Un core sin acceso a memoria devuelve espacios vacíos, funciones de memoria en false y límites en cero. Comprueba input, saveRequirements y newGame por separado: pueden tener otra disponibilidad.

Elegir una dirección de GBA

En GBA, el espacio cpu utiliza direcciones absolutas de 32 bits y orden little endian:

  • EWRAM: 0x02000000, 256 KiB, lectura y escritura.
  • IWRAM: 0x03000000, 32 KiB, lectura y escritura.
  • IO, paleta, VRAM, OAM y bus de ROM: solo lectura.

Cada operación debe quedar dentro de una región anunciada.

Las direcciones del bus ROM incluyen sus espejos de memoria; no son offsets del archivo. Para leer el archivo original utiliza host.gameRuntime.rom. La inspección conserva el estado del cartucho y de la comunicación serie.

cpu no permite escribir en IO ni en su bus ROM. Para cambiar el cartucho cargado, utiliza el espacio rom descrito más abajo.

Leer y escribir por lotes

El ejemplo usa una ROM propia que reserva 0x0203FFF0 como byte de prueba. En otro juego debes localizar una dirección adecuada antes de escribir.

async function changeScratchByte(host, session) { const support = await host.gameMod.capabilities(session); if (!support.memoryRead || !support.atomicWrite) { throw new Error("Este core no permite esta modificación."); } const address = 0x0203FFF0; const current = await host.gameMod.read(session, { ranges: [{space: "cpu", address, length: 1}] }); return host.gameMod.write(session, { writes: [{space: "cpu", address, expected: current.ranges[0].data, data: "AQ=="}] }); }
  • read devuelve ranges con space, address y data en Base64.
  • write recibe data y expected: Base64 canónico de igual longitud.
  • AQ== representa un byte con valor 1.
  • Las escrituras de un lote no pueden solaparse.

Antes de escribir, el host compara todos los expected. Si alguno ha cambiado, devuelve {applied:false, conflictIndex} con el primer conflicto, sin aplicar el lote. Si coinciden, devuelve {applied:true}.

Ante un conflicto, decide si debes volver a leer. Repetir la escritura sin revisar los datos puede sobrescribir un cambio del juego.

Trabajar directamente con números

readValues admite u8, i8, u16, i16, u32, i32, f32 y f64. Cada entrada lleva space, address y type; endian puede ser little o big y usa por defecto el orden del espacio.

La respuesta incluye value, data Base64 y el endian efectivo. Un float no finito devuelve value:null y conserva data. Utiliza read/write de bytes para comparar bits exactos, NaN, infinito o cero con signo.

const current = await host.gameMod.readValues(session, { values: [{space: "cpu", address: 0x0203FFF0, type: "u16"}] }); const previous = current.values[0].value; const result = await host.gameMod.writeValues(session, { writes: [{space: "cpu", address: 0x0203FFF0, type: "u16", expected: previous, value: (previous + 1) & 65535}] });

Este ejemplo necesita dos bytes reservados por tu ROM. writeValues valida y convierte el lote completo antes de escribir:

  • Conserva expected, applied/conflictIndex, propiedad y permisos de write.
  • Rechaza enteros fuera de rango, fracciones en tipos enteros y desbordamientos de float.
  • Admite hasta 32 valores.

En hosts anteriores, comprueba que existan readValues y writeValues.

Recibir avisos cuando cambien los datos

observe necesita emulation.game-mod, emulation.memory.read y runtime.events. Comprueba memoryObserve, maxWatches, maxWatchBytes y maxWatchRanges.

El adaptador toma muestras y el host agrupa los avisos; tu JavaScript no se ejecuta en cada frame.

await host.gameMod.observe(session, { id: "scratch-value", everyFrames: 4, ranges: [{space: "cpu", address: 0x0203FFF0, length: 2}] }); // Dentro de onEvent(event): if (event.type === "game-memory-changed") { console.log(event.watchId, event.changes, event.ranges); } // Para dejar de observar: await host.gameMod.unobserve(session, "scratch-value");

La primera muestra se toma al registrar. everyFrames admite 1–600, con valor inicial 1. Las siguientes muestras llegan después de avanzar el juego; pausar o capturar una imagen no cuenta.

Reiniciar o cargar dentro de la misma sesión conserva la observación. La comparación continúa cuando el juego vuelve a avanzar.

game-memory-changed entrega session, watchId, sequence, frame, changes y ranges. Cada rango contiene space, address, before y data en Base64.

Solo hay un aviso pendiente por observación: conserva el primer before, el último data y el número de muestras con cambios. Si un valor cambia y vuelve al inicial, changes puede ser mayor que cero aunque before y data coincidan. Los cambios entre muestras pueden pasar inadvertidos.

  • El host consulta avisos aproximadamente cada 100 ms en primer plano; el intervalo de entrega puede variar.
  • frame es el contador del adaptador. En Studio cuenta pasos sintéticos.
  • Cada módulo recibe solo sus observaciones.
  • Repetir un id propio sustituye la observación y descarta el aviso pendiente.
  • unobserve descarta también ese aviso y devuelve true si existía la observación, o false si no.
  • Cerrar o cambiar de sesión elimina las observaciones.

En GBA, todos los módulos de una sesión comparten 16 observaciones, 32 rangos y 16 KiB observados. Observar no reserva zonas de escritura. Retirar un módulo que solo observa mantiene abierta la partida.

Buscar valores que cambian

Solicita emulation.game-mod y emulation.memory.read. Comprueba memorySearch y los límites maxSearches, maxScanBytes, maxSearchBytes y maxSearchResults.

GBA y Studio admiten por sesión hasta cuatro búsquedas, 256 KiB por búsqueda y 1 MiB de capturas en total. Cada página devuelve hasta 128 resultados. Otros motores, incluidos los V2 con un puerto propio, pueden anunciar límites distintos.

const search = await host.gameMod.searchStart(session, { space: "cpu", address: 0x02000000, length: 262144, type: "u16", mode: "unknown" }); // Después de cambiar algo en el juego: const refined = await host.gameMod.searchRefine(session, { id: search.id, mode: "increased" }); const page = await host.gameMod.searchRead(session, { id: search.id, offset: 0, limit: 32 }); await host.gameMod.searchClose(session, {id: search.id});

Configura searchStart con:

  • equal y value para buscar un número conocido, o unknown sin valor.
  • Uno de los ocho tipos numéricos.
  • endian little/big; por defecto se usa el orden del espacio.
  • alignment 1, 2, 4 u 8: paso entre candidatos desde la dirección inicial. Por defecto coincide con el tamaño del tipo.

El rango completo debe caber en una región legible.

searchRefine conserva los candidatos que cumplen la comparación y actualiza la captura de referencia al terminar:

  • equal/notEqual: comparan con value.
  • changed/unchanged: comparan los bits con la captura anterior, sin value.
  • increased/decreased: comparan números finitos con la captura anterior, sin value.

equal compara números y redondea f32 a su representación. Los valores no finitos se entregan como null y no coinciden con uno finito.

Las respuestas incluyen id, matchCount, scannedBytes, revision, space, address, length, type, endian, alignment y paged. searchRead añade offset, results y hasMore.

Cada resultado contiene dirección, tipo, orden, value y data Base64 de la última captura. Consultar otra página no vuelve a leer el juego ni pierde los candidatos restantes.

Las capturas usan tramos de hasta 32 KiB. Si necesitas un instante coherente entre tramos, pausa el juego antes de capturar.

Las búsquedas pertenecen al módulo y autor que las creó. searchClose devuelve false si no encuentra una búsqueda propia. Retirar un módulo que solo busca conserva abierta la partida.

Cerrar o cambiar la sesión descarta las búsquedas. Reiniciar o cargar dentro de la misma sesión conserva la captura hasta la siguiente refinación.

Explorador de memoria en Studio

  1. Abre una sesión GBA de prueba en el visor.
  2. Entra en Ajustes → Explorar memoria y elige dirección, tipo y rango.
  3. Busca un valor conocido o empieza con valor desconocido. Refina por cambios, aumentos o disminuciones.
  4. Selecciona un resultado y genera una lectura, escritura comprobada u observación en JavaScript o TypeScript.
  5. Pulsa Copiar código e incorpora las funciones a tu módulo con sus permisos.

La escritura generada lee expected y comprueba el resultado. La observación requiere también runtime.events.

Cambiar valor de prueba modifica RAM sintética y respeta las zonas reservadas por módulos. Cerrar el explorador libera sus búsquedas; los valores se conservan hasta cerrar o restablecer la sesión.

El explorador no se conecta a partidas reales ni edita tus archivos automáticamente.

Explorador de memoria con una sesión GBA simulada.
Explorador de memoria con una sesión GBA simulada.

Aplicar una regla en cada frame

installRule registra una regla de la sesión. Antes de cada frame, el core comprueba sus condiciones y todos los expected; aplica el lote solo si coinciden. La regla se ejecuta junto al core, sin llamadas a JavaScript ni esperas de red, y se detiene mientras el juego está pausado.

En esta ROM propia, 0x0203FFF1 es una señal. Cuando vale 1 y el byte anterior vale 0, la regla escribe 2 en ese byte anterior:

await host.gameMod.installRule(session, { id: "scratch-rule", conditions: [{space: "cpu", address: 0x0203FFF1, expected: "AQ=="}], writes: [{space: "cpu", address: 0x0203FFF0, expected: "AA==", data: "Ag=="}] });
  • conditions es opcional y puede leer también IO o bus ROM.
  • Las escrituras acceden a RAM o al espacio rom de GBA, no a IO.
  • Después de escribir 2, expected deja de coincidir. La regla solo vuelve a actuar si el juego restablece los valores esperados.
  • Reinstalar un id propio sustituye la regla y devuelve {id}.
const myRules = await host.gameMod.listRules(session); const removed = await host.gameMod.removeRule(session, "scratch-rule");

listRules devuelve tus reglas con id, conditions y writes. removeRule devuelve true si retira una regla propia y false si no existía. Un módulo no puede listar ni retirar las de otro, aunque compartan autor.

Límites y conflictos

  • GBA: hasta 32 rangos y 32 KiB por operación.
  • Cada regla: hasta 32 condiciones y escrituras en total.
  • Por sesión, compartido entre módulos: 16 reglas y 32 KiB de condiciones y escrituras.

Utiliza los límites anunciados por el host de destino.

Escribir en una zona reservada por otro módulo devuelve modification-conflict. Retirar una regla detiene su ejecución, pero no libera las zonas reservadas hasta cerrar la sesión: todavía pueden conservar sus efectos.

El control de solapamientos incluye las escrituras de host.gameRuntime.patch. Los módulos que solo usan ese puerto anterior conservan su comportamiento al retirarse; el cierre de sesión descrito aquí se aplica a las nuevas escrituras o reglas de gameMod.

Retirar el módulo y conservar partidas

Cerrar la sesión elimina reglas y modificaciones activas; el archivo ROM original permanece intacto. Reiniciar o cargar un estado dentro de la misma sesión conserva las reglas y vuelve a comprobar conditions y expected.

Retirar una regla no restaura los bytes anteriores. Si un módulo ha modificado la sesión, desactivarlo, actualizarlo o perder su proceso cierra esa sesión, aunque ya hubiera retirado las reglas. Las referencias antiguas de sesión dejan de ser válidas.

Los efectos pueden quedar en los guardados habituales. Informa al usuario antes de modificar su progreso. Si cargar la partida exige tu módulo o empezar de cero, declara los requisitos de guardado explicados más abajo.

Probar el módulo

En Studio, simulated:true permite probar el código y el contrato. Revisa permisos, expected incorrectos, rangos no disponibles, conflictos entre módulos, carga/reinicio, desactivación y cambio de sesión. Después prueba el paquete con una ROM propia en la app de destino.

Construye la interfaz con páginas y formularios del SDK. Ofrece las operaciones de memoria o ROM temporal que anuncie cada motor.

Introducir botones desde un módulo

Con emulation.game-mod y emulation.input, llama a gameMod.input(session,{buttons:[{code,pressed}]}). Las pulsaciones del módulo se combinan con las del usuario y otros módulos; al desactivarlo se liberan solo las suyas.

Consulta capabilities y usa los códigos admitidos por el core. Studio simula las pulsaciones; este soporte se consulta por separado del acceso a memoria.

Cambiar la ROM cargada sin alterar el archivo

GBA ofrece el espacio rom desde el offset cero hasta la longitud real del cartucho. Admite lecturas, escrituras comprobadas, valores numéricos, búsquedas, observaciones y reglas. Un lote mixto RAM/ROM se aplica entero o no cambia nada.

Los cambios afectan al código o recursos de esa sesión. Al volver a abrir se lee el original. El bus ROM del espacio cpu sigue siendo de solo lectura.

const original = await host.gameMod.read(session, {ranges:[{space:"rom",address:offset,length:1}]}); await host.gameMod.write(session, {writes:[{space:"rom",address:offset,expected:original.ranges[0].data,data:nuevoByteBase64}]});

Solicita emulation.game-mod y emulation.memory.read/write. Ten en cuenta:

  • No se amplía la ROM ni se vuelve a detectar el tipo de guardado al cambiar cabeceras.
  • Los estados no incluyen la ROM modificada. Declara la dependencia del módulo y prepara sus cambios al cargar.
  • Studio utiliza una ROM sintética.
  • Los recursos y scripts con nombre de RPG Maker utilizan gameRuntime y sus límites; el resto de cores no recibe un motor de scripts general.

Declarar que una partida necesita el módulo

await host.gameMod.configureSave({gameId,newGame:true});

Llama a configureSave antes de abrir el juego y solicita emulation.game-mod. La llamada registra el módulo y su autor como requisitos del guardado. newGame es opcional y exige empezar una partida nueva.

Los datos se guardan en slots, autoguardado y batería habituales. Si falta el módulo o cambia su autor, la app rechaza la carga y conserva el archivo. Una partida anterior sin marca de nueva no cumple newGame, aunque tenga otra dependencia.

  • Los guardados sin requisitos conservan sus bytes. Las cabeceras de requisitos están versionadas y admiten la versión anterior.
  • Una sesión con requisitos empieza pausada para preparar el módulo antes de continuar.
  • Retirar un módulo que solo exige presencia pausa la partida y permite guardar antes de salir; no permite continuar sin él.
  • Si el módulo escribió memoria, ROM o reglas, se mantiene el cierre de sesión de gameMod.

Para exigir una partida nueva consulta capabilities(session).newGame. Los cores incluidos que ofrecen ese contrato lo aplican; un motor sin soporte rechaza la apertura en vez de reutilizar progreso. Studio simula los metadatos, no una batería real.

Crear un pack de archivos

Un pack sustituye archivos al abrir el juego y conserva el contenido original. Para prepararlo:

  1. Solicita resources.packs y consulta host.resourcePacks.capabilities({gameId}).
  2. Crea un pack con create({gameId,id,label}). Usa list({gameId?}) para consultar packId, revisión, estado y archivos.
  3. Abre la carga de cada archivo con beginFile({packId,path,size,sha256}), envía bloques con write({id,offset,dataBase64}) y termina con commitFile({id}).

readFile({packId,path,offset,length}) lee por bloques. Para quitar un archivo de un pack desactivado, usa removeFile({packId,path}).

enable({packId,enabled,expectedRevision}) y remove({packId,expectedRevision}) requieren la sesión cerrada y la revisión vigente. Para eliminar el pack, desactívalo primero.

  • Por identidad: hasta 32 packs.
  • Por pack: hasta 1024 archivos y 1 GiB en la app; Studio limita cada pack a 32 MiB.
  • Bloques de transferencia: 64 KiB.
  • Caducidad de las cargas: diez minutos.
  • Índice agregado por identidad: 95 KiB, para caber en el límite IPC.

El host comprueba rutas, tamaños, hashes y contenido base antes de aplicar las capas.

Al abrir el juego, el host crea una copia privada con las capas habilitadas y conserva el ID y el original. Las ordena por identidad del propietario y después por ID del pack.

Al revocar un módulo, sus capas dejan de aplicarse en la siguiente apertura. La sesión abierta conserva la copia con la que arrancó.

Los packs admiten sustituir una ROM completa o archivos de contenedores de discos de solo lectura. Debes preparar los recursos o la ROM reconstruida y comprobar las capacidades del formato.

RPG Maker no está admitido: sus guardados nativos no deben copiarse junto con los recursos.

Elegir entre packs y texturas

Elige el servicio según lo que quieras cambiar:

  • host.resourcePacks: capas de archivos preparadas al abrir el juego. Cambia su selección con la sesión cerrada.
  • host.graphics: opciones y texturas del core. graphics.textures reemplaza PNG por hash y graphics.options selecciona la resolución.

GameCube admite raster host 1×/2×/4×. PS1, PSP y GameCube admiten reemplazos de texturas en Android/Desktop. Son cambios limitados a la sesión y se retiran al revocar el módulo; un pack de archivos no habilita texturas HD en cualquier core.

Consulta Emulación para formatos, límites y correspondencia XFB/VI.