@@ -186,11 +186,13 @@ agemki/
186186│ │ └── __snapshots__/ snapshots vitest (preview hex de cabeceras)
187187│ │
188188│ └── engine_host/ ← Phase 3a — runner C compilado con clang en host
189- ├── lib/crc32.c copia byte-exact de _sfx_crc32 del motor
190- ├── include/ag_test.h header compartido del runner
191- ├── runner.c entrypoint con dispatcher de tests
192- ├── build.mjs compila con clang (cross-platform mac/win)
193- └── runner / runner.exe binario generado (gitignored)
189+ ├── lib/
190+ │ ├── crc32.c copia byte-exact de _sfx_crc32 (sub 2.1)
191+ │ └── pcx_decode.c copia byte-exact de _pcx_decode RLE (sub 2.2)
192+ ├── include/ag_test.h header compartido del runner
193+ ├── runner.c entrypoint con dispatcher de tests
194+ ├── build.mjs compila con clang (cross-platform mac/win)
195+ └── runner / runner.exe binario generado (gitignored)
194196│
195197├── goldens/ ← outputs esperados (entran al repo, !goldens/** en .gitignore)
196198│ ├── dat/
@@ -200,7 +202,8 @@ agemki/
200202│ │ ├── AUDIO.DAT
201203│ │ ├── FONTS.DAT
202204│ │ └── manifest.json sha256 + size + numBlocks por DAT
203- │ ├── engine/ (futuro Phase 3a) outputs binarios de lógica pura
205+ │ ├── engine/ outputs binarios de lógica pura del motor host
206+ │ │ └── pcx/ SHA-256 de cada PCX decodificado (sub 2.2)
204207│ └── runtime/ (futuro Phase 3c) BMP frames del motor en DOSBox-X
205208│ capturados en puntos deterministas del game loop
206209│
@@ -235,11 +238,11 @@ Smoke de los helpers de testing:
235238- ` decodeDat ` parsea cabecera + index entry de un fichero AGMK construido a mano.
236239- Constantes (` MAGIC ` , ` HEADER_SIZE ` , ` INDEX_ENTRY_SIZE ` ) coherentes con la spec.
237240
238- ### ` tests/golden/engine-host.test.js ` (22 tests, Phase 3a)
241+ ### ` tests/golden/engine-host.test.js ` (22 tests, Phase 3a sub 2.1 )
239242
240243Tests del ** subset portable del motor C** compilado con clang en host.
241- Primer test : CRC32 (` _sfx_crc32 ` del motor) — algoritmo crítico porque
242- el motor usa este hash para binary search en el TOC de SFX.DAT. Si
244+ Primer módulo : CRC32 (` _sfx_crc32 ` del motor) — algoritmo crítico
245+ porque el motor lo usa para binary search en el TOC de SFX.DAT. Si
243246difiere del CRC32 del codegen JS (sfxGenerator.js / datGenerator.js),
244247los chunks no se encuentran en runtime.
245248
@@ -258,6 +261,40 @@ linux), el bloque de tests dependientes se **skipea** automáticamente
258261y el suite sigue verde. El drift test SÍ se ejecuta siempre (no
259262requiere clang).
260263
264+ ### ` tests/golden/engine-host-pcx.test.js ` (16 tests, Phase 3a sub 2.2)
265+
266+ Tests del decoder PCX del motor: la sección RLE de
267+ ` _pcx_decode ` en ` resources/engine/agemki_engine.c:995-1034 ` . La
268+ parte RLE (decompresión) es 100% portable, lo que toca registros VGA
269+ (` outp(0x3C8/0x3C9) ` para cargar la paleta) queda fuera del subset
270+ host por ser HW-only.
271+
272+ Cubre:
273+ - ** Drift detection** (sin clang): la copia en
274+ ` tests/engine_host/lib/pcx_decode.c ` sigue byte-exact a la sección
275+ RLE de ` _pcx_decode ` del motor. Cualquier edit en el motor que altere
276+ esos bytes se caza con un test rojo y un mensaje claro.
277+ - ** Bit-exact motor C ↔ JS** : para los 6 PCX del fixture minimal (BG,
278+ sprite, objeto, 3 fuentes), el buffer decodificado por el runner C
279+ coincide al byte con el de ` jsPcxDecode ` (implementación de
280+ referencia en JS puro).
281+ - ** Goldens binarios** : el SHA-256 del buffer decodificado de cada PCX
282+ se persiste en ` goldens/engine/pcx/<name>.sha256.txt ` . Cualquier
283+ cambio en bytes (por edit del motor, del fixture, o de cualquier
284+ intermedio) deja el SHA distinto y el test rojo identifica qué PCX
285+ cambió.
286+ - ** Sanity sobre dimensiones** : cada fixture decodifica al ` WxH `
287+ esperado.
288+
289+ Por qué no testear ` apply_pal ` : usa ` outp() ` (escritura a registros
290+ DAC de VGA) y ` g_pal_raw ` (global del motor). En host no tiene sentido,
291+ sería testear el stub.
292+
293+ Por qué hex en stdout y no bytes raw: en Windows, stdout tiene
294+ LF→CRLF translation por defecto que corrompe bytes 0x0A. El runner
295+ C emite el buffer como hex (2 chars por byte) para evitar el issue
296+ sin parches específicos de OS.
297+
261298### ` tests/golden/dat.test.js ` (26 tests)
262299
263300Para cada fixture (` minimal ` ):
@@ -404,6 +441,142 @@ con cache buster para reiniciar `loadRecent()`.
404441
405442---
406443
444+ ## Cómo funciona el motor host (Phase 3a)
445+
446+ Los tests ` engine-host*.test.js ` validan que módulos puros del motor
447+ C (CRC32, decoder PCX, A* , lightmap, ...) producen los mismos bytes
448+ ejecutándose en host con clang que ejecutándose en DOS con Watcom.
449+ La idea es ** cazar regresiones del motor sin necesidad de DOSBox-X
450+ ni de bootear el juego** .
451+
452+ ### Las 4 piezas que cooperan
453+
454+ ```
455+ ┌─────────────────────────────────────────────────────────────────────────┐
456+ │ │
457+ │ resources/engine/<file>.c tests/engine_host/lib/<file>.c │
458+ │ ┌──────────────────────────┐ ┌────────────────────────────────┐ │
459+ │ │ función real del motor │ ←══→ │ copia byte-exact de la sección │ │
460+ │ │ (puede tener HW outp/ │ drift │ portable (sin HW) │ │
461+ │ │ globals/etc.) │ test │ │ │
462+ │ └──────────────────────────┘ └────────────────────────────────┘ │
463+ │ ↑ ↓ clang -std=c89 │
464+ │ │ ┌─────────────────────┐ │
465+ │ │ │ ./runner <test> │ │
466+ │ │ │ emite output hex │ │
467+ │ │ └─────────────────────┘ │
468+ │ │ ↓ stdout │
469+ │ │ ┌─────────────────────┐ │
470+ │ │ función JS de referencia → │ tests/golden/ │ │
471+ │ └─────────────────────────────→ │ engine-host-*.test │ │
472+ │ │ .js │ │
473+ │ └─────────────────────┘ │
474+ │ ↓ │
475+ │ SHA-256 → goldens/engine/<m>/*.txt │
476+ └─────────────────────────────────────────────────────────────────────────┘
477+ ```
478+
479+ | Pieza | Función | Dónde |
480+ | ---| ---| ---|
481+ | ** Motor real** | El código que ejecuta el juego en DOS | ` resources/engine/*.c ` |
482+ | ** Copia portable** | Subset del motor compilable con clang en host | ` tests/engine_host/lib/*.c ` |
483+ | ** Runner C** | Binario host que invoca cada función bajo test y emite output | ` tests/engine_host/runner.c ` → ` runner ` (gitignored) |
484+ | ** JS reference impl** | Misma lógica reescrita en JS puro, validador independiente | ` tests/helpers/engine-host.js ` |
485+
486+ ### Las 3 garantías (cada una caza un tipo de regresión)
487+
488+ 1 . ** Drift test** — la copia en ` lib/ ` sigue byte-exact al motor real.
489+ Si Javi edita el motor, te avisa para sincronizar la copia.
490+ * No requiere clang* — solo lee los ` .c ` con ` fs.readFileSync ` y los
491+ compara con regex extractors.
492+
493+ 2 . ** Bit-exact JS ↔ C** — el output del motor coincide con el de la
494+ implementación JS de referencia. Si el motor cambia su lógica
495+ (no solo su sintaxis), te avisa.
496+
497+ 3 . ** Goldens binarios** — el SHA-256 de los outputs reales (`buffer
498+ decodificado` , ` lista de waypoints` , ` bitmap lightmap`, ...) se
499+ persiste en ` goldens/engine/<modulo>/ ` . Cualquier cambio que
500+ altere bytes deja el SHA distinto y el test rojo identifica qué
501+ fixture cambió.
502+
503+ ### Workflow cuando edites el motor
504+
505+ Si tocas ` resources/engine/agemki_audio.c ` o ` agemki_engine.c ` :
506+
507+ 1 . ** Hook automático** dispara ` tests/golden/engine-host*.test.js ` .
508+ 2 . Si tu cambio NO toca lógica cubierta → todo verde, sigues.
509+ 3 . Si tu cambio toca una función cubierta → drift FAIL con mensaje
510+ tipo "lib/pcx_decode.c y agemki_engine.c divergen". Dos opciones:
511+ - Cambio intencional: copia el bloque actualizado del motor a
512+ ` tests/engine_host/lib/<file>.c ` . Ejecuta ` npm run goldens:update `
513+ si los bytes producidos también cambiaron.
514+ - Cambio no intencional: revierte.
515+ 4 . Si tu cambio en el motor produce ** bytes distintos** (no solo
516+ estilo/comments): el drift queda verde tras sincronizar la copia,
517+ pero los SHA-256 de los goldens fallan. Ahí decides si el cambio
518+ es legítimo (regenera goldens) o es un bug (revierte).
519+
520+ ### Cómo añadir un nuevo módulo (el patrón)
521+
522+ Cuando arranques una sub-etapa nueva (ej: A* en sub 2.3), el patrón:
523+
524+ 1 . ** Localizar la función pura** en el motor. Identifica qué deps HW
525+ tiene (` outp/inp/int86 ` , globals, etc.). Si el módulo está
526+ acoplado a HW de forma que no se puede aislar trivialmente,
527+ replantea — quizás necesite refactor.
528+ 2 . ** Copiar byte-exact** la sección portable a
529+ ` tests/engine_host/lib/<modulo>.c ` . Renombra la función con
530+ prefijo ` ag_test_ ` para exponerla.
531+ 3 . ** Añadir typedef** si la copia usa tipos del motor (` u8 ` , ` s16 ` ,
532+ ` Point ` , ...). Mapear a ` <stdint.h> ` o equivalentes en host.
533+ 4 . ** Extender el runner** (` runner.c ` ) con un dispatcher nuevo
534+ (` ./runner <comando> <args> ` ). Output siempre en formato
535+ line-based + hex (no binario raw, ver "por qué hex" abajo).
536+ 5 . ** Helper JS en ` tests/helpers/engine-host.js ` ** con:
537+ - Función ` runner<Modulo>(...) ` que invoca el binario y parsea
538+ stdout.
539+ - Función ` js<Modulo>(...) ` que reimplementa el algoritmo en JS
540+ puro, sirve de referencia.
541+ - Función ` detect<Modulo>Drift() ` con la regex que extrae el
542+ bloque del motor real y lo compara con la copia local.
543+ 6 . ** Test vitest** en ` tests/golden/engine-host-<modulo>.test.js `
544+ con tres bloques:
545+ - ` describe('drift detection (sin clang)', ...) ` — independiente.
546+ - ` describe.skipIf(noClang)('bit-exact con JS', ...) ` — el grueso.
547+ - ` describe.skipIf(noClang)('goldens', ...) ` — SHA-256 vs
548+ ` goldens/engine/<modulo>/ ` .
549+ 7 . ** Hook** : el matcher actual ya cubre ` resources/engine/*.c|*.h ` ,
550+ no hace falta tocar nada.
551+ 8 . ** ` tests/README.md ` ** : añadir sección sobre el nuevo test.
552+
553+ ### Por qué hex y no bytes raw en el stdout del runner
554+
555+ Windows hace LF→CRLF translation por defecto en stdout. Cualquier
556+ byte 0x0A en el output binario se convierte a 0x0D 0x0A al salir,
557+ corrompiendo el SHA-256 y rompiendo el test cross-platform.
558+
559+ Solución elegida: el runner emite el buffer como cadena hex (2 chars
560+ por byte). Cero bytes 0x0A en el output, cero translation issue.
561+ Coste: ~ 2x bytes en stdout, despreciable para fixtures pequeños.
562+
563+ Alternativa con ` _setmode(_fileno(stdout), _O_BINARY) ` también
564+ funcionaría, pero requiere ` #ifdef _WIN32 ` y headers ` <io.h> ` en C
565+ — el hex es más simple y portable.
566+
567+ ### Skip elegante si clang no está
568+
569+ ` tests/engine_host/build.mjs ` invoca ` clang --version ` al arrancar.
570+ Si no está disponible (mac sin Xcode CLT, win sin LLVM, linux sin
571+ apt), el build sale con exit 2 y el helper ` ensureRunnerBuilt() `
572+ devuelve ` { status: 'no-clang' } ` .
573+
574+ ` describe.skipIf(noClang) ` skipea automáticamente los bloques que
575+ necesitan el binario, manteniendo el suite verde. ** El drift test
576+ NO se skipea** — solo lee ` .c ` files con ` fs ` , no requiere clang.
577+
578+ ---
579+
407580## Cómo funciona el hook ` PostToolUse `
408581
409582` .claude/settings.json ` registra un hook que se dispara cada vez que
@@ -437,7 +610,7 @@ El hook `.claude/hooks/run-tests-on-edit.mjs`:
437610| ` src/main/index.js ` | ` tests/golden/ ` |
438611| ` src/renderer/src/store/*.js ` | ` tests/unit/stores/ ` |
439612| ` tests/fixtures/* ` o ` tests/helpers/* ` | ` tests/ ` (toda la suite) |
440- | ` resources/engine/*.c ` o ` *.h ` | ` tests/engine_host/ ` (Phase 3a ) |
613+ | ` resources/engine/*.c ` o ` *.h ` | ` tests/golden/engine-host*.test.js ` (drift + tests del módulo si clang está disponible ) |
441614| Cualquier otro path | noop, sale 0 |
442615
443616Reglas del hook:
0 commit comments