Tutorial de optimización de cintas y ultracargas

Cursos, reparaciones, fichas, tutoriales, etc.

Moderador: Fundadores

Avatar de Usuario
antoniovillena
Demonio segundo orden
Demonio segundo orden
Mensajes: 1596
Registrado: 02 Abr 2013, 19:06
Been thanked: 1 time

Re: Tutorial de optimización de cintas y ultracargas

Mensaje por antoniovillena »

Lección 4

Primero quiero mostraros un gráfico para que entendáis en qué punto estamos y hacia donde queremos llegar. Es una representación en la que cada columna equivale a una lección. Ojo, los tiempos no están a escala.

Imagen

La primera columna es la carga original del juego que mostramos en la lección 1, es decir son 6 bloques físicos (3 lógicos) con carga estándar.

La segunda columna es cuando en la lección 2 conseguimos reducir las cabeceras de los 2 últimos bloques lógicos (pantalla y juego), pero manteniendo la carga estándar.

En la tercera columna (tercera lección) quitamos las pausas entre bloques, recortamos los tiempos de tono guía y ligeramente los pulsos que codifican los ceros y los unos, pero manteniéndonos en carga estándar.

En esta cuarta lección lo que pretendemos es modificar el cargador de la ROM (LD-BYTES) para acelerar la carga (y de paso mostrar otros colores de borde), evidentemente sólo en los 2 últimos bloques que es cuando podemos tener disponible el nuevo cargador.

Como no podemos modificar la ROM, lo que hacemos es ubicar el nuevo cargador (LD-BYTES) en RAM. Desgraciadamente no nos vale cualquier parte de RAM, tiene que ser memoria no contenida o memoria alta (rango $8000-$FFFF), porque de lo contrario se producirían retardos en las instrucciones que producirían errores de carga.

Antes de nada os voy a mostrar el cargador de la ROM:

Código: Seleccionar todo

;; LD-BYTES
L0556:  INC     D               ; reset the zero flag without disturbing carry.
        EX      AF,AF'          ; preserve entry flags.
        DEC     D               ; restore high byte of length.

        DI                      ; disable interrupts

        LD      A,$0F           ; make the border white and mic off.
        OUT     ($FE),A         ; output to port.

        LD      HL,L053F        ; Address: SA/LD-RET
        PUSH    HL              ; is saved on stack as terminating routine.

;   the reading of the EAR bit (D6) will always be preceded by a test of the 
;   space key (D0), so store the initial post-test state.

        IN      A,($FE)         ; read the ear state - bit 6.
        RRA                     ; rotate to bit 5.
        AND     $20             ; isolate this bit.
        OR      $02             ; combine with red border colour.
        LD      C,A             ; and store initial state long-term in C.
        CP      A               ; set the zero flag.

; 

;; LD-BREAK
L056B:  RET     NZ              ; return if at any time space is pressed.

;; LD-START
L056C:  CALL    L05E7           ; routine LD-EDGE-1
        JR      NC,L056B        ; back to LD-BREAK with time out and no
                                ; edge present on tape.

;   but continue when a transition is found on tape.

        LD      HL,$0415        ; set up 16-bit outer loop counter for 
                                ; approx 1 second delay.

;; LD-WAIT
L0574:  DJNZ    L0574           ; self loop to LD-WAIT (for 256 times)

        DEC     HL              ; decrease outer loop counter.
        LD      A,H             ; test for
        OR      L               ; zero.
        JR      NZ,L0574        ; back to LD-WAIT, if not zero, with zero in B.

;   continue after delay with H holding zero and B also.
;   sample 256 edges to check that we are in the middle of a lead-in section. 

        CALL    L05E3           ; routine LD-EDGE-2
        JR      NC,L056B        ; back to LD-BREAK
                                ; if no edges at all.

;; LD-LEADER
L0580:  LD      B,$9C           ; set timing value.
        CALL    L05E3           ; routine LD-EDGE-2
        JR      NC,L056B        ; back to LD-BREAK if time-out

        LD      A,$C6           ; two edges must be spaced apart.
        CP      B               ; compare
        JR      NC,L056C        ; back to LD-START if too close together for a 
                                ; lead-in.

        INC     H               ; proceed to test 256 edged sample.
        JR      NZ,L0580        ; back to LD-LEADER while more to do.

;   sample indicates we are in the middle of a two or five second lead-in.
;   Now test every edge looking for the terminal sync signal.

;; LD-SYNC
L058F:  LD      B,$C9           ; initial timing value in B.
        CALL    L05E7           ; routine LD-EDGE-1
        JR      NC,L056B        ; back to LD-BREAK with time-out.

        LD      A,B             ; fetch augmented timing value from B.
        CP      $D4             ; compare 
        JR      NC,L058F        ; back to LD-SYNC if gap too big, that is,
                                ; a normal lead-in edge gap.

;   but a short gap will be the sync pulse.
;   in which case another edge should appear before B rises to $FF

        CALL    L05E7           ; routine LD-EDGE-1
        RET     NC              ; return with time-out.

; proceed when the sync at the end of the lead-in is found.
; We are about to load data so change the border colours.

        LD      A,C             ; fetch long-term mask from C
        XOR     $03             ; and make blue/yellow.

        LD      C,A             ; store the new long-term byte.

        LD      H,$00           ; set up parity byte as zero.
        LD      B,$B0           ; timing.
        JR      L05C8           ; forward to LD-MARKER 
                                ; the loop mid entry point with the alternate 
                                ; zero flag reset to indicate first byte 
                                ; is discarded.

; --------------
;   the loading loop loads each byte and is entered at the mid point.

;; LD-LOOP
L05A9:  EX      AF,AF'          ; restore entry flags and type in A.
        JR      NZ,L05B3        ; forward to LD-FLAG if awaiting initial flag
                                ; which is to be discarded.

        JR      NC,L05BD        ; forward to LD-VERIFY if not to be loaded.

        LD      (IX+$00),L      ; place loaded byte at memory location.
        JR      L05C2           ; forward to LD-NEXT

; ---

;; LD-FLAG
L05B3:  RL      C               ; preserve carry (verify) flag in long-term
                                ; state byte. Bit 7 can be lost.

        XOR     L               ; compare type in A with first byte in L.
        RET     NZ              ; return if no match e.g. CODE vs. DATA.

;   continue when data type matches.

        LD      A,C             ; fetch byte with stored carry
        RRA                     ; rotate it to carry flag again
        LD      C,A             ; restore long-term port state.

        INC     DE              ; increment length ??
        JR      L05C4           ; forward to LD-DEC.
                                ; but why not to location after ?

; ---
;   for verification the byte read from tape is compared with that in memory.

;; LD-VERIFY
L05BD:  LD      A,(IX+$00)      ; fetch byte from memory.
        XOR     L               ; compare with that on tape
        RET     NZ              ; return if not zero. 

;; LD-NEXT
L05C2:  INC     IX              ; increment byte pointer.

;; LD-DEC
L05C4:  DEC     DE              ; decrement length.
        EX      AF,AF'          ; store the flags.
        LD      B,$B2           ; timing.

;   when starting to read 8 bits the receiving byte is marked with bit at right.
;   when this is rotated out again then 8 bits have been read.

;; LD-MARKER
L05C8:  LD      L,$01           ; initialize as %00000001

;; LD-8-BITS
L05CA:  CALL    L05E3           ; routine LD-EDGE-2 increments B relative to
                                ; gap between 2 edges.
        RET     NC              ; return with time-out.

        LD      A,$CB           ; the comparison byte.
        CP      B               ; compare to incremented value of B.
                                ; if B is higher then bit on tape was set.
                                ; if <= then bit on tape is reset. 

        RL      L               ; rotate the carry bit into L.

        LD      B,$B0           ; reset the B timer byte.
        JP      NC,L05CA        ; JUMP back to LD-8-BITS

;   when carry set then marker bit has been passed out and byte is complete.

        LD      A,H             ; fetch the running parity byte.
        XOR     L               ; include the new byte.
        LD      H,A             ; and store back in parity register.

        LD      A,D             ; check length of
        OR      E               ; expected bytes.
        JR      NZ,L05A9        ; back to LD-LOOP 
                                ; while there are more.

;   when all bytes loaded then parity byte should be zero.

        LD      A,H             ; fetch parity byte.
        CP      $01             ; set carry if zero.
        RET                     ; return
                                ; in no carry then error as checksum disagrees.

; -------------------------
; Check signal being loaded
; -------------------------
;   An edge is a transition from one mic state to another.
;   More specifically a change in bit 6 of value input from port $FE.
;   Graphically it is a change of border colour, say, blue to yellow.
;   The first entry point looks for two adjacent edges. The second entry point
;   is used to find a single edge.
;   The B register holds a count, up to 256, within which the edge (or edges) 
;   must be found. The gap between two edges will be more for a '1' than a '0'
;   so the value of B denotes the state of the bit (two edges) read from tape.

; ->

;; LD-EDGE-2
L05E3:  CALL    L05E7           ; call routine LD-EDGE-1 below.
        RET     NC              ; return if space pressed or time-out.
                                ; else continue and look for another adjacent 
                                ; edge which together represent a bit on the 
                                ; tape.

; -> 
;   this entry point is used to find a single edge from above but also 
;   when detecting a read-in signal on the tape.

;; LD-EDGE-1
L05E7:  LD      A,$16           ; a delay value of twenty two.

;; LD-DELAY
L05E9:  DEC     A               ; decrement counter
        JR      NZ,L05E9        ; loop back to LD-DELAY 22 times.

        AND      A              ; clear carry.

;; LD-SAMPLE
L05ED:  INC     B               ; increment the time-out counter.
        RET     Z               ; return with failure when $FF passed.

        LD      A,$7F           ; prepare to read keyboard and EAR port
        IN      A,($FE)         ; row $7FFE. bit 6 is EAR, bit 0 is SPACE key.
        RRA                     ; test outer key the space. (bit 6 moves to 5)
        RET     NC              ; return if space pressed.  >>>

        XOR     C               ; compare with initial long-term state.
        AND     $20             ; isolate bit 5
        JR      Z,L05ED         ; back to LD-SAMPLE if no edge.

;   but an edge, a transition of the EAR bit, has been found so switch the
;   long-term comparison byte containing both border colour and EAR bit. 

        LD      A,C             ; fetch comparison value.
        CPL                     ; switch the bits
        LD      C,A             ; and put back in C for long-term.

        AND     $07             ; isolate new colour bits.
        OR      $08             ; set bit 3 - MIC off.
        OUT     ($FE),A         ; send to port to effect the change of colour. 

        SCF                     ; set carry flag signaling edge found within
                                ; time allowed.
        RET                     ; return.
No me voy a poner a explicar cómo funciona, supongo que tenéis unos conocimientos mínimos de ensamblador y de inglés para leer los comentarios. Entender dicho código es recomendable, aunque sólo es necesario conocerlo si lo que queréis es escribir vuestro propio cargador.

En lugar de ir a la solución exacta como en otras lecciones, voy a hacerlo equivocándome varias veces a conciencia para que sea más instructivo. Pues bien, lo primero que se me ocurre es copiar el código del cargador en loader.asm a ver qué pasa:

Código: Seleccionar todo

        org     xxxx
ini     ld      sp, $7530
        di
        db      $de, $c0, $37, $0e, $8f, $39, $96
        ld      hl, yyyy
        ld      de, zzzz
        ld      bc, fin-L0556
        ldir
        ld      hl, $5800
        ld      de, $5801
        ld      bc, $2ff
        ld      (hl), l
        ldir
        scf
        sbc     a, a
        ld      ix, $4000
        ld      de, $1b00
        call    L0556
        scf
        sbc     a, a
        ld      ix, $8000
        ld      de, $8000
        call    L0556
        jp      $8400

;; LD-BYTES
L0556:  INC     D               ; reset the zero flag without disturbing carry.
        EX      AF,AF'          ; preserve entry flags.
        DEC     D               ; restore high byte of length.
        ...
fin
He puesto sólo las 3 primeras instrucciones de la rutina de carga en ROM (LD-BYTES) para no ser muy repetitivo. Tenemos el mismo "loader.asm" de antes pero con este añadido:

Código: Seleccionar todo

        ld      hl, yyyy
        ld      de, zzzz
        ld      bc, fin-L0556
        ldir
Que se encargaría de mover a memoria alta el cargador (LD-BYTES). También he cambiado los valores de los CALLs para llamar a nuestra rutina y no a de la ROM original. De momento no sé qué poner en los valores xxxx, yyyy, zzzz. Es complicado porque sé que la etiqueta "ini" comienza en $5CCB, pero si pongo un "ORG $5CCB" como antes tenía, voy a tener mal calculadas las direcciones a partir de la etiqueta L0556. Al hacer una prueba de ensamblado con valor 0, compruebo que la longitud del cargador (desde L0556 hasta fin) es de $AF bytes. Si quiero ubicarlo en la zona más alta de memoria, tendría que ser en $FF51 ($10000-$AF).

Siempre es mala idea poner valores absolutos, ya que si modifico la longitud del cargador tengo que recalcularlo todo de nuevo. Por eso hay que pensarse muy bien estos valores. Como en el código que hay desde ini hasta L0556 no hay ninguna etiqueta, prefiero fijar el ORG a un valor que favorezca lo que hay a continuación de L0556. Los valores adecuados son:

Código: Seleccionar todo

        org     $10000-fin+ini
ini     ld      sp, $7530
        di
        db      $de, $c0, $37, $0e, $8f, $39, $96
        ld      hl, $5ccb+L0556-ini
        ld      de, L0556
        ld      bc, fin-L0556
        ldir
        ld      hl, $5800
        ld      de, $5801
        ...
Pues bien, ensamblo y pruebo el tzx. Tras cargar la pantalla se produce un cuelgue. Activo el depurador de mi emulador (Spectaculator) para ver qué es lo que pasa, y detecto esta instrucción errónea en FF59:

Código: Seleccionar todo

FF57    D3 FE       OUT     ($FE),A
FF59    21 00 00    LD      HL,$0000
FF5C    E5          PUSH    HL
Me doy cuenta de que el fallo está en que la etiqueta L053F no existe. Normal, he extraído la rutina de un código fuente que contiene toda la ROM y ese código no lo hemos copiado. Tampoco hace falta, es la rutina de retorno que no tenemos intención de modificar, tan sólo necesitamos saber cuál es su dirección. Por suerte las etiquetas están compuestas por la letra L seguida de la dirección hexadecimal que le corresponde, con lo que cambiando la L por el signo $ se soluciona el problema:

Código: Seleccionar todo

        LD      HL,$053F        ; Address: SA/LD-RET
Ejecutamos make.bat de nuevo y cargamos el TZX. Aparentemente carga bien el juego. Pero hay algo que no me concuerda. Si el cargador estaba en $FF51 y el último bloque empieza en $8000 y acaba en $FFFF, en algún momento se ha debido de machacar código que estaba en ejecución. Lo que suele suceder en estos casos es un cuelgue o un reseteo. Pero en pocas ocasiones (como es el caso) desafortunadamente no ocurre nada, por lo que el fallo seguramente se produzca al llegar al último nivel del juego. Por eso nunca está de más hacer un dump de memoria bajo emulador para comprobar que el juego se ha cargado correctamente.

Comprobemos qué ocurre en Spectaculator. Primero ponemos un punto de ruptura en $5CCB, luego cargamos el TZX y escribimos LOAD"" pulsando Enter. Tras la carga del bloque Basic salta el depurador en $5CCB. Nos vamos un poco más abajo y ponemos otro punto de ruptura donde está el asterisco:

Código: Seleccionar todo

  5CF9    37          SCF
  5CFA    9F          SBC     A,A
  5CFB    DD 21 00 80 LD      IX,$8000
  5CFF    11 00 80    LD      DE,$8000
  5D02    CD 51 FF    CALL    $FF51
* 5D05    C3 00 84    JP      $8400
Vemos que los registros DE y IX valen $005B y $FFA5 respectivamente. Estos deberían ser 0 en ambos casos (DE siempre vale cero tras la rutina de carga, puesto que en ella se va decrementando hasta alcanzar dicho valor). Pero no, la carga se ha cortado antes. Veamos qúe pasa en $FFA5:

Código: Seleccionar todo

FFA2  07      RLCA
FFA3  80      ADD     A,B
FFA4  07      RLCA
FFA5  20 07   JR      NZ,$FFAE
FFA7  30 0F   JR      NC,$FFB8
Lo que me temía, los datos del juego (concretamente del último nivel) han machacado el código del cargador hasta $FFA4, y por alguna razón se ha retornado la carga sin errores. No me detengo a analizar la traza exacta, pero evidentemente algo ha ido mal.

El problema es que el juego ocupa precisamente toda la memoria alta, que es donde obligatoriamente tiene que ir el cargador. La solución es cargar el juego un poco más abajo y moverlo a posteriori. Nada que no podamos hacer con la socorrida instrucción LDIR.

Código: Seleccionar todo

        scf
        sbc     a, a
        ld      ix, $8000-fin+L0556
        ld      de, $8000-fin+L0556
        call    L0556
        ld      hl, $8000-fin+L0556
        ld      de, $8000
        ld      bc, $8000
        ldir
        jp      $8400
Generamos de nuevo manic.tzx y cargamos. Ups, un reseteo. Como siempre depuramos a ver qué ha pasado. Vemos que toda la memoria alta ($8000-$FFFF) está a $00. Típico error de principiante: hemos movido un bloque con LDIR cuando teníamos que haber empleado LDDR. Cuando el registro destino (DE) tiene un valor mayor que el origen (HL) y hay solapamiento, el bloque no se mueve bien cuando lo hacemos con LDIR sino que se machaca a sí mismo repitiendo el mismo patrón cada DE-HL bytes.

Corregimos el error. Mucho cuidado que los límites de LDDR suelen confundir bastante.

Código: Seleccionar todo

        scf
        sbc     a, a
        ld      ix, $8000-fin+L0556
        ld      de, $8000-fin+L0556
        call    L0556
        ld      hl, $ffff-fin+L0556
        ld      de, $ffff
        ld      bc, $8000
        lddr
        jp      $8400
Probamos de nuevo y vemos que ahora sí funciona todo correctamente. Llegados a este punto hemos conseguido hacer lo mismo que hacía el cargador de la ROM pero con nuestro propio código, básicamente el mismo pero ensamblado en otro lugar. Antes de ponernos a tunear la velocidad veamos que ocurre si comentamos esta línea:

Código: Seleccionar todo

        IN      A,($FE)         ; read the ear state - bit 6.
        RRA                     ; rotate to bit 5.
        AND     $20             ; isolate this bit.
;        OR      $02             ; combine with red border colour.
        LD      C,A             ; and store initial state long-term in C.
        CP      A               ; set the zero flag.
Por arte de magia los colores de carga ahora son otros. No me voy a detener en detalles de cómo cambiarlos, sólo decir que si queréis usar una gama distinta a los pares complementarios (rojo-cian, amarillo-azul, verde-magenta y blanco-negro) tenéis que currároslo un poquito.

¿Os acordáis lo que os conté hace poco de que era mala idea poner valores absolutos? Pues echadle un vistazo al código máquina: ahora el cargador comienza en $FF53 y tiene longitud $AD. Como hemos hecho las cosas "bien", no tenemos que hacer ningún cambio de valores. Repasamos lo que hemos hecho hasta ahora, copiamos el make.bat en make2.bat (el loader.asm en loader2.asm) y procedemos con la segunda parte de la lección.

En esta segunda parte trataremos de acelerar la carga de los 2 últimos bloques. Para ello tengo que explicar un poco la parte de ensamblador que convierte cambios del puerto EAR en bits que luego se convierten en bytes y se guardan en memoria. La parte clave está en este trozo de código:

Código: Seleccionar todo

;; LD-MARKER
L05C8:  LD      L,$01           ; initialize as %00000001

;; LD-8-BITS
L05CA:  CALL    L05E3           ; routine LD-EDGE-2 increments B relative to
                                ; gap between 2 edges.
        RET     NC              ; return with time-out.

        LD      A,$CB           ; the comparison byte.
        CP      B               ; compare to incremented value of B.
                                ; if B is higher then bit on tape was set.
                                ; if <= then bit on tape is reset.

        RL      L               ; rotate the carry bit into L.

        LD      B,$B0           ; reset the B timer byte.
        JP      NC,L05CA        ; JUMP back to LD-8-BITS
Se trata de un bucle cerrado de 7 instrucciones que comienza en L05CA. En dicho bucle se van metiendo bits por la derecha del registro L hasta completar 8, en cuyo caso se ejecuta otro código que no he mostrado aquí pero que básicamente mueve L a (IX), incrementa IX, decrementa DE y xorea el byte leído (registro L) con el registro H para llevar la cuenta del checksum. Al final de todo esto volvemos a L05C8 donde se inicializa L a $01, que en binario es %00000001. El bit que está a 1 se llama bit marcador, y sirve para que cuando rotemos 8 veces con la instrucción RL L, nos avise por el flag carry de que hemos leído 8 bits, de esta forma no necesitamos ningún contador adicional.

¿Cómo se lee un bit del puerto EAR? Pues la primera instrucción es un salto a L05E3, también llamada rutina LD-EDGE-2 que muestro a continuación. Observa que el valor de B antes de llamar a esta rutina es $B0.

Código: Seleccionar todo

;; LD-EDGE-2
L05E3:  CALL    L05E7           ; call routine LD-EDGE-1 below.
        RET     NC              ; return if space pressed or time-out.
                                ; else continue and look for another adjacent 
                                ; edge which together represent a bit on the 
                                ; tape.

; -> 
;   this entry point is used to find a single edge from above but also 
;   when detecting a read-in signal on the tape.

;; LD-EDGE-1
L05E7:  LD      A,$16           ; a delay value of twenty two.

;; LD-DELAY
L05E9:  DEC     A               ; decrement counter
        JR      NZ,L05E9        ; loop back to LD-DELAY 22 times.

        AND      A              ; clear carry.

;; LD-SAMPLE
L05ED:  INC     B               ; increment the time-out counter.
        RET     Z               ; return with failure when $FF passed.

        LD      A,$7F           ; prepare to read keyboard and EAR port
        IN      A,($FE)         ; row $7FFE. bit 6 is EAR, bit 0 is SPACE key.
        RRA                     ; test outer key the space. (bit 6 moves to 5)
        RET     NC              ; return if space pressed.  >>>

        XOR     C               ; compare with initial long-term state.
        AND     $20             ; isolate bit 5
        JR      Z,L05ED         ; back to LD-SAMPLE if no edge.

;   but an edge, a transition of the EAR bit, has been found so switch the
;   long-term comparison byte containing both border colour and EAR bit. 

        LD      A,C             ; fetch comparison value.
        CPL                     ; switch the bits
        LD      C,A             ; and put back in C for long-term.

        AND     $07             ; isolate new colour bits.
        OR      $08             ; set bit 3 - MIC off.
        OUT     ($FE),A         ; send to port to effect the change of colour. 

        SCF                     ; set carry flag signaling edge found within
                                ; time allowed.
        RET                     ; return.
LD-EDGE-2 (L05E3) llama 2 veces a la rutina LD-EDGE-1 (L05E7), que a su vez detecta un cambio de nivel (detección de flanco, de ahí el nombre) en el puerto EAR (cambio de cero a uno o de uno a cero, da igual). Antes de leer del puerto se introduce un pequeño retardo de 22*16 ciclos (LD-DELAY) y luego se va sampleando el puerto EAR en iteraciones de 59 ciclos cada una, en las que incrementamos B y detectamos si se ha pulsado la tecla Espacio. Tanto si pulsamos espacio como si el contador B llega a $00 (estamos incrementando, harían falta $100-$B0= $50, en decimal 80 iteraciones) interrumpimos la carga con el correspondiente error.

En resumen, B tendrá un valor entre $B2 y $FF (si todo ha ido bien) tras la llamada a LD-EDGE-2 dependiendo de si los dos pulsos codifican un "0" o un "1". Por eso lo que hay después es una comparación con un valor umbral ($CB), y el resultado de dicha comparación es lo que va a parar al flag carry, que se meterá por la derecha del registro L (en RL L).

Ahora vamos a hacer una serie de cálculos. Primero vamos a ver cuántos ciclos son necesarios para que se desborde el registro B (llegue a $00) en la llamada LD-EDGE-2. El conteo es tedioso, aunque no muy complicado, si quieres seguirlo aquí tienes el desglose, siendo un total de 5598 ciclos. Recordemos que el "1" se codificaba con dos pulsos de 1710 ciclos cada uno, en total 3420 ciclos. Si leemos un pulso de más de 5598 ciclos (el 64% más del tiempo esperado) se produce un error.

Por otro lado y debido al pequeño retardo que hay al comienzo, el valor mínimo de B (que es $B2) no se corresponde con 0 ciclos, sino que lo hace con un conteo de 996 ciclos. Teniendo en cuenta que un "0" dura 855*2= 1710 ciclos, esto sería el 58% del tiempo esperado, aunque en este caso no se produce error directamente si hay menos de 996 ciclos, simplemente se añaden los ciclos que falten para completar los 996 al siguiente pulso.

Pues bien, si tenemos que con $B2 son 996 ciclos y con $00 son 5598, la fórmula que me devuelve el número de ciclos que duran los 2 pulsos leídos es:

Código: Seleccionar todo

Ciclos= 996+(regB-178)*59
Si tenemos en cuenta la variabilidad del registro A (en rutina estándar vale 22) que introduce el retardo, la fórmula más genérica sería:

Código: Seleccionar todo

Ciclos= 292+32*regA+(regB-178)*59
Lo siguiente a calcular es a cuántos ciclos equivale el valor umbral $CB (203 en decimal) usando dicha fórmula:

Código: Seleccionar todo

Ciclos= 292+32*22+(203-178)*59= 2471.
Dos pulsos que duren menos de 2471 ciclos son un "0", si duran más son un "1". Si normalizamos a 2471 tenemos 0.692 (1710/2471) y 1.384 (3420/2471) respectivamente, con lo que comprobamos que el umbral escogido por la rutina de carga es más o menos simétrico.

Bueno pues ahora queremos acelerar la carga a un valor de entre 1500bps y 3000bps (más sería arriesgado). Cojamos números sencillos y así agilizamos las cuentas: 1000 ciclos para el doble pulso del cero y 2000 ciclos para el del uno, que serían 2565bps. Para hacerlo todo simétrico y no pillarnos los dedos mantenemos el 58% del "0" como retardo mínimo. En la teoría basta con comprobar que 996<1000 sin cambiar el retardo; en la práctica los pulsos pueden ser muy asimétricos y tendríamos problemas. Retardo mínimo= 0.58*1000= 580 ciclos. En esta ecuación:

Código: Seleccionar todo

580= 292+32*regA
Despejamos regA y nos sale 9. Ahora veamos el umbral a aplicar:

Código: Seleccionar todo

0.692= 1000/Umbral
Despejando me salen 1445 ciclos. Pasamos estos ciclos a byte a comparar de B despejando de aquí:

Código: Seleccionar todo

292+32*9+(regB-178)*59= 1445
Nos salen 193, que en hexadecimal es $C1. Pues ya está, hacemos los siguientes cambios en el código:

Para el retardo:

Código: Seleccionar todo

;; LD-EDGE-1
L05E7:  LD      A,$09           ; a delay value of nine.
Para el umbral:

Código: Seleccionar todo

        RET     NC              ; return with time-out.
        LD      A,$C1           ; the comparison byte.
        CP      B               ; compare to incremented value of B.
Y por último cambiamos el make2.bat con los nuevos parámetros que hemos introducido en los dos últimos bloques:

Código: Seleccionar todo

SjAsmPlus loader2.asm
rem SjAsmPlus manic.asm
FlagCheck header2.bin 0
FlagCheck loader2.bin
FlagCheck manic.scr
FlagCheck manic.bin
GenTape                     manic2.tzx      ^
    turbo 2168   667   735                  ^
      600 1600  1500     0  header2.bin.fck ^
    turbo 2168   667   735                  ^
      600 1600  1500     0  loader2.bin.fck ^
    turbo 2168   667   735                  ^
      500 1000  1500     0  manic.scr.fck   ^
    turbo 2168   667   735                  ^
      500 1000  1500     0  manic.bin.fck
Ya casi hemos acabado. Lo siento, sé que esta lección es muy pesada y queréis acabar ya. El último cambio que voy a hacer en realidad es una optimización, vamos que es un complemento de la lección totalmente superfluo pero me parece interesante mostrarlo y no quiero hacer una lección 4 y media.

¿Os acordáis cuando movíamos todo el bloque del juego (32768 bytes) a su sitio porque era necesario ubicar el cargador en memoria alta? Pues bien, mover tal cantidad de bytes con un LDDR es muy lento, son 32768*21= 688128 ciclos, casi 200ms. Pensaréis que 200ms no es nada pero creedme, es un montón. Tal vez en esta situación sea plausible dejarlo, pero en cualquier otra circustancia puede ralentizar el desarrollo del juego, así que os diré como reducirlo a casi cero.

Tenemos 32768 bytes que parten de un archivo binario, que hay que desplazar 173 bytes hacia adelante. ¿Por qué no transformamos el binario de tal forma que haya que mover la mínima cantidad de bytes? Os dejo que penséis un poco y en breve muestro la solución.

...

...

...

Pues sí, se trata de poner los 173 últimos bytes del fichero al principio (o los 32595 bytes primeros al final). De esta forma bastaría con machacar el cargador con los primeros 173 bytes del archivo transformado para tener el juego correctamente cargado en memoria. Pues bien, el fichero se puede "transformar" haciendo un sencillo copy/paste con el editor hexadecimal. Pero como hemos dicho muchas veces, este tipo de soluciones a la larga no nos gustan porque suponen un trabajo extra cada vez que cambiemos la longitud del cargador. Por suerte tengo la utilidad hecha desde antes, no la he tenido que hacer ad-hoc como FlagCheck.c. Se llama fcut e incluyo sólo el ejecutable, el código fuente y el funcionamiento lo podéis ver aquí.

Vayamos al lío. Primero renombramos make2.bat y loader2.asm a make3.bat y loader3.asm respectivamente y hacemos los cambios allí.

En loader3.asm:

Código: Seleccionar todo

        scf
        sbc     a, a
        ld      ix, $8000-fin+L0556
        ld      de, $8000-fin+L0556
        call    L0556
        ld      hl, $8000-fin+L0556
        ld      de, $10000-fin+L0556
        ld      bc, fin-L0556
        ldir
        jp      $8400
En make3.bat:

Código: Seleccionar todo

SjAsmPlus loader3.asm
rem SjAsmPlus manic.asm
fcut manic.bin -AD  AD  manic.cut1
fcut manic.bin  00 -AD  manic.cut2
copy /b   manic.cut1  ^
        + manic.cut2  ^
      manic.new
FlagCheck header3.bin 0
FlagCheck loader3.bin
FlagCheck manic.scr
FlagCheck manic.new
GenTape                     manic3.tzx      ^
    turbo 2168   667   735                  ^
      600 1600  1500     0  header3.bin.fck ^
    turbo 2168   667   735                  ^
      600 1600  1500     0  loader3.bin.fck ^
    turbo 2168   667   735                  ^
      500 1000  1500     0  manic.scr.fck   ^
    turbo 2168   667   735                  ^
      500 1000  1500     0  manic.new.fck
Ya sí, ya hemos acabado. En la próxima lección explicaremos cómo funciona la compresión y cómo aplicarla para que nuestros bloques ocupen menos.

Pincha aquí para bajar el archivo de la lección
Avatar de Usuario
antoniovillena
Demonio segundo orden
Demonio segundo orden
Mensajes: 1596
Registrado: 02 Abr 2013, 19:06
Been thanked: 1 time

Re: Tutorial de optimización de cintas y ultracargas

Mensaje por antoniovillena »

Lección 5

Bienvenidos a la lección 5. Antes de meterme de lleno en la compresión quiero arreglar un poco el código fuente de la última lección. El último make (make3.bat) no está automatizado del todo, hay unos parámetros en la llamada a fcut que requieren ser corregidos manualmente cada vez que cambie la longitud del cargador. Primero separo la rutina LD-BYTES en un archivo aparte (ldbytes.asm), luego creo ldbytes_size.asm para que me genere un binario con sólo la rutina LD-BYTES con la única intención de calcular su longitud. Estas líneas del archivo make.bat leen la longitud del archivo y la pasan a hexadecimal para no tener que modificar a mano los parámetros de fcut:

Código: Seleccionar todo

for %%A in (ldbytes.bin) do set _fileSize=%%~zA
set /a "_fshex1=%_fileSize%>>4&15"
set /a "_fshex2=%_fileSize%&15"
set _map=0123456789ABCDEF
set _res=!_map:~%_fshex1%,1!!_map:~%_fshex2%,1!
También he optimizado código en ldbytes.asm, puesto que no necesitamos la parte de verificación (sólo cargamos) y el byte flag siempre vale $FF. Quitando el retardo de un segundo tras la detección del tono guía puedo reducir de 1500ms a 500ms la duración de dichos tonos en los dos últimos bloques, acortando en 2 segundos la duración total.

Debido a un bug en SjAsmPlus aparecen unos mensajes de error "Bytes lost" tras el ensamblado de los archivos de la lección anterior. En realidad ensambla bien pero dificulta la lectura de errores reales. Para evitar dichos mensajes trunco a 16 bits los valores de las líneas que dan error con este define al comienzo:

Código: Seleccionar todo

        define  tr $ffff &
Empleado en las líneas que dan error:

Código: Seleccionar todo

        ld      de, tr loader
        call    tr loader
L056C:  CALL    tr L05E7        ; routine LD-EDGE-1
        CALL    tr L05E3        ; routine LD-EDGE-2
Por último y para no liarnos con tanto archivo, al final del make.bat borro todos los archivos que han sobrado.

Código: Seleccionar todo

del *.fck *.cut header.bin loader.bin ldbytes.bin manic.new
Ya está, ya tenemos un make.bat limpio de donde partir, ahora renombramos a make2.bat (y loader2.asm) y comenzamos la lección.

Nuestro objetivo es reducir el tamaño de los bloques usando compresión para que éstos tarden menos en cargar, y una vez estén cargados los descomprimimos en memoria, generando el mismo mapa de memoria que habríamos tenido de haber cargado los bloques en crudo. Compresores hay muchos y que usan diferentes algoritmos. Los únicos viables para máquinas antiguas son los basados en Lempel Ziv, en concreto LZ77, debido a que son los más rápidos en descomprimir y no necesitan RAM adicional.

A grandes rasgos lo que hace el compresor es quitar la redundancia de los archivos, codificando los bloques repetidos por referencias. Por ejemplo si tenemos un archivo de texto de una sola línea con el contenido "hola hola", el primer "hola" lo codificará con 4 bytes; sin embargo para el segundo "hola" ya no hace falta, basta con que especifiquemos "copiar los 4 bytes que hay desplazándonos hacia atrás 5 bytes".

Pues bien, para ZX Spectrum hay muchos compresores basados en LZ77, nosotros vamos a usar dos: zx7/zx7b y Exomizer/Exoopt. La primera opción descomprime muy rápido, tiene un descompresor muy pequeño y un ratio de descompresión normal. La segunda opción es más lenta, el descompresor es más grande (incluso necesita espacio auxiliar para una tabla) pero tiene la ventaja de que comprime mejor.

De forma genérica el funcionamiento es el siguiente. Primero comprimimos un archivo que se corresponda con un bloque de carga, por ejemplo manic.scr. Para ello usamos el compresor, que es un archivo ejecutable (por ejemplo compress.exe) al que llamamos con una serie de parámetros, entre ellos el archivo de entrada.

Código: Seleccionar todo

C:/miruta> compress manic.scr manic.scr.comp
Tras la ejecución obtenemos el archivo comprimido manic.scr.comp, que evidentemente tendrá una longitud menor a la de su homólogo sin comprimir (manic.scr).

El siguiente paso sería descomprimir. Hay 2 formas de hacerlo: hacia adelante o hacia atrás, según el orden en que se van leyendo los datos del stream comprimido y se van escribiendo los datos descomprimidos. Descomprimir es tan sencillo como hacer una llamada a una rutina, llamémosla descomp, con 2 parámetros de entrada: en uno le indicamos dónde está el stream comprimido, en el otro le decimos dónde queremos descomprimir. Normalmente se usan los registros HL y DE como parámetros para estos menesteres. El "dónde" sería apuntar al primer byte del bloque si comprimimos hacia adelante, o el último byte del mismo si lo hacemos hacia atrás.

Imagínate que ya hemos cargado en $8000 el archivo manic.scr.comp, y que ocupa $1000 bytes. Si queremos descomprimir en memoria de video, la descompresión hacia adelante sería así:

Código: Seleccionar todo

        ld      hl, $8000
        ld      de, $4000
        call    descomp
Y si es hacia atrás lo hacemos de la siguiente forma:

Código: Seleccionar todo

        ld      hl, $8FFF
        ld      de, $5AFF
        call    descomp
Ahora voy a explicar un concepto importante, que es el posible solapamiento entre el bloque comprimido y descomprido. Y es que podemos ir machacando el bloque comprimido a medida que descomprimamos, siempre que al acabar la descompresión dejemos unos pocos bytes sin machacar. A estos pocos bytes es a lo que llamamos "safety offset" o desplazamiento de seguridad. Si dejamos menos bytes de margen de los que nos indica el "safety offset" con toda seguridad se va a producir un cuelgue. Por experiencia este valor suele ser de 2 ó 3 bytes, así que para no preocuparos podéis probar distintos valores hasta el cuelgue, o bien poner por ejemplo 4 bytes que seguro que no se va a colgar. En algunos compresores como exomizer te indican este valor tras la compresión.

Vamos a ver cómo sería descomprimir solapando al máximo los bloques para ocupar la mínima cantidad de RAM. Supongamos que el bloque descomprimido va desde $8000 hasta $bfff (en total 16K) y el bloque comprimido tiene un ratio de 0.5, vamos que ocupa la mitad ($2000 ú 8K). Por otro lado vamos a usar un safety offset de 4 bytes para no complicarnos.

Si comprimimos hacia adelante haríamos los siguientes cálculos. El bloque descomprimido acaba en $BFFF, por lo que el comprimido podría acabar 4 bytes después, serían los bytes que no se machaquen tras la descompresión. El bloque comprimido acabaría en $C003, y como tiene una longitud de $2000, empezaría en $A004. Ojo, estoy incluyendo los bytes de los límites, por eso al hacer diferencia hay que sumar uno para que coincida con la longitud.

Código: Seleccionar todo

        ld      hl, $A004
        ld      de, $8000
        call    descomp
Hacemos lo mismo si la descompresión fuese hacia atrás. El bloque acabaría (de descomprimirse) en $8000, por tanto el comprimido puede acabar en $7FFC dejando 4 bytes de safety offset. Si acaba en $7FFC debe empezar en $1FFF+$7FFC= $9FFB.

Código: Seleccionar todo

        ld      hl, $9FFB
        ld      de, $BFFF
        call    descomp
En resumidas cuentas, en nuestro juego vamos a aplicar descompresión dos veces, una en la pantalla de carga donde no queremos solapamiento (de lo contrario se vería basura en pantalla) y otra en el bloque del juego donde solaparemos al máximo. Prácticamente en todos los juegos ocurre que si sumamos la longitud del juego comprimido con la del descomprimido nos sale una cifra mayor de 48K, por lo que sin solapamiento sería imposible.

El primer algoritmo que voy a mostrar es el más sencillo, el ZX7 de Einar Saukas. Para no ralentizar la descompresión es necesario ubicar el descompresor en una zona no contenida, así que lo pondré justo debajo de ldbytes.asm. Ahora en ldbytes_descom.asm (donde cuento los bytes que necesito rotar el binario) añado a lo anterior la rutina descompresora y los 4 bytes del safety offset.

ldbytes_descom.asm

Código: Seleccionar todo

        define  tr $ffff &
        output  ldbytes_descom.bin
        defs    4               ; safety offset=4
        include dzx7_standard.asm
        include ldbytes.asm
En el make2.bat genero el manic.new modificado:

Código: Seleccionar todo

for %%A in (ldbytes_descom.bin) do set _fileSize=%%~zA
set /a "_fshex1=%_fileSize%>>4&15"
set /a "_fshex2=%_fileSize%&15"
set _map=0123456789ABCDEF
set _res=!_map:~%_fshex1%,1!!_map:~%_fshex2%,1!

fcut manic.bin -%_res% %_res% manic1.cut
fcut manic.bin  0     -%_res% manic2.cut
copy /b   manic1.cut  ^
        + manic2.cut  ^
      manic.new
Y procedo a comprimir los binarios y a generar los archivos con el flag y el checksum añadidos:

Código: Seleccionar todo

FlagCheck header.bin 0
FlagCheck loader2.bin
zx7 manic.scr
zx7 manic.new
FlagCheck manic.scr.zx7
FlagCheck manic.new.zx7
Evidentemente en la llamada a GenTape indico los archivos comprimidos para los 2 últimos bloques:

Código: Seleccionar todo

GenTape                     manic2.tzx        ^
    turbo 2168   667   735                    ^
      600 1600  1500     0  header.bin.fck    ^
    turbo 2168   667   735                    ^
      600 1600  1500     0  loader2.bin.fck   ^
    turbo 2168   667   735                    ^
      500 1000   500     0  manic.scr.zx7.fck ^
    turbo 2168   667   735                    ^
      500 1000   500     0  manic.new.zx7.fck
Esto sería el make2.bat. Ahora mostremos cómo modificar el loader2.asm. Lo primero de todo es hacer una primera ejecución del make2.bat para ver cuánto ocupan los comprimidos y hacer una tabla como ésta, en la que muestro la longitud de los bloques comprimidos y descomprimidos (tanto en decimal como en hexadecimal):

Código: Seleccionar todo

         | Descomprimido      Comprimido
---------|------------------------------
Pantalla |  6912 - $1B00     274 - $0112
Juego    | 32768 - $8000   13263 - $33CF
El primer bloque (pantalla) es muy sencillo, puesto que no hay solapamiento:

Código: Seleccionar todo

        ld      ix, $8000
        ld      de, $0112
        call    tr loader
        di
        ld      hl, $8000
        ld      de, $4000
        call    tr dzx7b
Nótese que he añadido un "di" después de la llamada al cargador que no había antes, pues me acabo de percatar que la rutina de la ROM con la que acaba nuestro cargador SA/LD-RET (L053F) introduce un EI al final. No nos interesa tener habilitadas las interrupciones durante todo el cargador, sobre todo descomprimiendo ya que es un poco más lento y podría provocarnos un cuelgue.

Prosigo. Aquí viene la parte complicada, que es la carga y la descompresión del bloque Juego:

Código: Seleccionar todo

        ld      ix, $10000-fin+dzx7b-$33cf
        ld      de, $33cf
        call    tr loader
        di
        ld      hl, $10000-fin+dzx7b-$33cf
        ld      de, $8000-fin+dzx7b-4
        call    tr dzx7b
¿Por qué es complicada? Porque al haber solapamiento, todos los cálculos se hacen en funcion de la longitud del archivo comprimido ($33cf) y de los 4 bytes del safety offset. Cargo el bloque de tal forma que éste acabe justo antes de donde comienza el código del descompresor ($FF31) para luego descomprimir acabando 4 bytes antes ($FF2D).

La última parte del loader2.asm es esta:

Código: Seleccionar todo

        ld      hl, $8000-fin+dzx7-4
        ld      de, $10000-fin+dzx7-4
        ld      bc, fin-dzx7+4
        ldir
        jp      $8400

dzx7    include dzx7_standard.asm
loader  include ldbytes.asm
fin
Donde machaco tanto el descompresor como el cargador (ya no lo vamos a utilizar más) con la parte de juego que corresponda. Como he mencionado antes, incluyo el fuente del descompresor justo antes del cargador.

¿Os ha resultado difícil de seguir? No os preocupéis, es normal. La aritmética de las etiquetas es así de compleja, más aún si añadimos la longitud del bloque comprimido. Todavía nos falta automatizar el proceso para no tener que hacer nada en caso de que cambien las longitudes. Pero antes quiero mostraros en make3.bat y loader3.asm cómo sería si empleamos la variante hacia atrás de zx7, es una que modifiqué partiendo del mismo algoritmo, llamada zx7b.

Tabla de longitudes:

Código: Seleccionar todo

         | Descomprimido      Comprimido
---------|------------------------------
Pantalla |  6912 - $1B00     287 - $011F
Juego    | 32768 - $8000   13292 - $33EC
make3.bat:

Código: Seleccionar todo

SETLOCAL EnableDelayedExpansion
SjAsmPlus loader3.asm
SjAsmPlus ldbytes_descom3.asm
rem SjAsmPlus manic.asm

for %%A in (ldbytes_descom3.bin) do set _fileSize=%%~zA
set /a "_fshex1=%_fileSize%>>4&15"
set /a "_fshex2=%_fileSize%&15"
set _map=0123456789ABCDEF
set _res=!_map:~%_fshex1%,1!!_map:~%_fshex2%,1!

fcut manic.bin -%_res% %_res% manic1.cut
fcut manic.bin  0     -%_res% manic2.cut
copy /b   manic1.cut  ^
        + manic2.cut  ^
      manic3.new

FlagCheck header.bin 0
FlagCheck loader3.bin
zx7b manic.scr manic.scr.zx7b
zx7b manic3.new manic3.new.zx7b
FlagCheck manic.scr.zx7b
FlagCheck manic3.new.zx7b

GenTape                     manic3.tzx          ^
    turbo 2168   667   735                      ^
      600 1600  1500     0  header.bin.fck      ^
    turbo 2168   667   735                      ^
      600 1600  1500     0  loader3.bin.fck     ^
    turbo 2168   667   735                      ^
      500 1000   500     0  manic.scr.zx7b.fck  ^
    turbo 2168   667   735                      ^
      500 1000   500     0  manic3.new.zx7b.fck

del *.fck *.cut header.bin loader3.bin ldbytes_descom.bin manic3.new manic.scr.zx7b manic3.new.zx7b
ENDLOCAL
loader3.asm

Código: Seleccionar todo

        define  tr $ffff &

; Bloque cabecera
        output  header.bin
        db      0               ; tipo: 0=cabecera, 1=array numérico
                                ; 2=array alfanumérico, 3=código máquina
        db      'ManicMiner'    ; Nombre del archivo (hasta 10 letras)
        block   11-$, 32        ; Relleno el resto con espacios
        dw      fin-ini         ; Longitud del bloque basic
        dw      10              ; Autoejecución en línea 10
        dw      fin-ini         ; Longitud del bloque basic

; Bloque datos (Basic con código máquina incrustado)
        output  loader3.bin
        org     $10000-fin+ini
ini     ld      sp, $7530
        di
        db      $de, $c0, $37, $0e, $8f, $39, $96
        ld      hl, $5ccb+dzx7b-ini
        ld      de, tr dzx7b
        ld      bc, fin-dzx7b
        ldir

        ld      ix, $8000
        ld      de, $011f
        call    tr loader
        di
        ld      hl, $811e
        ld      de, $5aff
        call    tr dzx7b

        ld      ix, $8000-fin+dzx7b-4
        ld      de, $33ec
        call    tr loader
        di
        ld      hl, $8000-fin+dzx7b+$33ec-4-1
        ld      de, $10000-fin+dzx7b-1
        call    tr dzx7b

        ld      hl, $8000-fin+dzx7b
        ld      de, $10000-fin+dzx7b
        ld      bc, fin-dzx7b
        ldir
        jp      $8400

dzx7b   include dzx7b_fast.asm
loader  include ldbytes.asm
fin
La descompresión hacia atrás tiene una ligera ventaja en cuanto a velocidad sobre la compresión hacia adelante, os recomiendo usarla siempre que podáis. Como veis, el cambio ha sido sencillo.

Lo que vamos a hacer ahora es automatizar el cálculo de longitudes, así ya no necesitaremos la tabla de longitudes y podremos modificar la pantalla y el juego tranquilamente. Lo de siempre, copiamos a make4.bat y loader4.asm, y hacemos los siguientes cambios:

Tras comprimir el archivo, calculo su longitud y lo meto en una variable. Luego introduzco esa variable en una línea de un archivo llamado define.asm redirigiendo la salida estándar. Hago lo mismo en los 2 archivos a comprimir:

Código: Seleccionar todo

zx7b manic.scr manic.scr.zx7b
for %%A in (manic.scr.zx7b) do set _fileSize=%%~zA
echo  define  scrsize %_fileSize% >  define.asm

zx7b manic4.new manic4.new.zx7b
for %%A in (manic4.new.zx7b) do set _fileSize=%%~zA
echo  define  binsize %_fileSize% >> define.asm
De esta forma el archivo define.asm que genero tendrá este aspecto:

Código: Seleccionar todo

 define  scrsize 287 
 define  binsize 13292 
Finalmente incluyo el define.asm al comienzo de loader4.asm y sustituyo los valores numéricos de las longitudes por estas constantes:

Código: Seleccionar todo

        include define.asm
        ...

        ld      ix, $8000
        ld      de, scrsize
        call    tr loader
        di
        ld      hl, $8000+scrsize-1
        ld      de, $5aff
        call    tr dzx7b

        ld      ix, $8000-fin+dzx7b-4
        ld      de, binsize
        call    tr loader
        di
        ld      hl, $8000-fin+dzx7b+binsize-4-1
        ld      de, $10000-fin+dzx7b-1
        call    tr dzx7b
Ya está, ya hemos acabado con los compresores sencillos (zx7 y zx7b). Ahora vamos a dar un paso más. Copiamos a make5.bat y loader5.asm y peleémonos con Exomizer/Exoopt.

La compresión se hace en 2 etapas, primero comprimimos por separado los archivos con exomizer, y luego procesamos conjuntamente los archivos intermedios con exoopt para generar los archivos definitivos. En exoopt decidimos el sentido y la velocidad de la descompresión, nosotros nos hemos decantado por descompresión hacia atrás y velocidad 1, con lo que el código a introducir es b1. Si fuese compresión hacia adelante y velocidad 3 sería f3.

Código: Seleccionar todo

set /a _offset= 0x10000-%_fileSize%
call :dec2hex %_offset%
exomizer raw manic.scr -b -r -c -o manic.scr.exo
exomizer raw manic5.new -b -r -c -o manic5.new.exo
exoopt b1 %_res% manic.scr.exo manic5.new.exo
Calculamos la dirección de la tabla que va a usar el descompresor, un buffer de 156 bytes que hemos ubicado justo debajo del compresor. Luego pasamos esa dirección (_offset) a hexadecimal (_res). Lo siguiente es comprimir los 2 bloques, usando los comandos "-b -r" que indican que la compresión es hacia atrás y "-c" que evita que aparezcan literales (son una característica que mejora la compresión en algunas ocasiones). Por último hacemos la llamada a exoopt, que toma como entrada los archivos intermedios *.exo y genera los archivos finales *.exo.opt. Además de estos archivos también genera el descompresor en d.asm:

Código: Seleccionar todo

; b1 [f0..87] nolit= 148 bytes
        ld      iy, 65136
        ld      a, 128
        ld      b, 52
        push    de
        cp      a
exinit: ld      c, 16
        ...
En la primera línea vemos un comentario que nos dice el tipo de descompresor (b1), el rango que usa el byte bajo de la dirección de la tabla de entre 2 posibles ($46 se encuentra entre $f0 y $87), el hecho de que no procesa literales y la longitud del descompresor (148 bytes).

Como en el caso anterior (make4.bat), calculamos las longitudes de los comprimidos y las metemos en el archivo define.asm:

Código: Seleccionar todo

for %%A in (manic.scr.exo.opt) do set _fileSize=%%~zA
echo  define  scrsize %_fileSize% >  define.asm
for %%A in (manic5.new.exo.opt) do set _fileSize=%%~zA
echo  define  binsize %_fileSize% >> define.asm
Y ya todo lo demás es igual que antes, sólo que teniendo en cuenta la tabla de 156 bytes. En el archivo ldbytes_descom5.asm se hace así:

Código: Seleccionar todo

        define  tr $ffff &
        output  ldbytes_descom5.bin
        defs    156             ; exomizer table
        include d.asm
        include ldbytes.asm
Y en el archivo loader5.asm lo hacemos de la siguiente manera:

Código: Seleccionar todo

        ld      ix, $8000-fin+deexo-156-4
        ld      de, binsize
        call    tr loader
        di
        ld      hl, $8000-fin+deexo-156+binsize-4-1
        ld      de, $10000-fin+deexo-156-1
        call    tr deexo

        ld      hl, $8000-fin+deexo-156
        ld      de, $10000-fin+deexo-156
        ld      bc, fin-deexo+156
        ldir
        jp      $8400

deexo   include d.asm
loader  include ldbytes.asm
fin
El paso de decimal a hexadecimal que calcula el archivo .bat ahora (también lo hicimos en make4.bat) lo tenemos implementado como una especie de procedimiento:

Código: Seleccionar todo

:dec2hex
set /a "_fsh1=%1>>12&15"
set /a "_fsh2=%1>>8&15"
set /a "_fsh3=%1>>4&15"
set /a "_fsh4=%1&15"
set _map=0123456789ABCDEF
set _res=!_map:~%_fsh1%,1!!_map:~%_fsh2%,1!!_map:~%_fsh3%,1!!_map:~%_fsh4%,1!
goto :eof
En el cual el valor que pasemos como parámetro lo pasará a hexadecimal en la variable _res.

Ahora sí que sí, ya hemos acabado la lección. A modo de resumencillo y para que no os hagáis un lío con los archivos, tenemos:
  • make.bat. No usamos compresión, es el mismo ejemplo de la última lección pero con algunas mejoras.
  • make2.bat. Compresión ZX7, calculando longitudes manualmente.
  • make3.bat. Compresión ZX7B, calculando longitudes manualmente.
  • make4.bat. Compresión ZX7B, proceso automatizado.
  • make5.bat. Compresión Exomizer/Exoopt, proceso automatizado.
La siguiente lección será más bien a modo de repaso, explicaré para qué sirve el filtro RCS y aplicaremos compresión en otras situaciones: en carga estándar, aplicándolo a un cargador Basic y cómo convertir a cartucho (16K) un juego de 48K.

Pincha aquí para bajar el archivo de la lección
Avatar de Usuario
antoniovillena
Demonio segundo orden
Demonio segundo orden
Mensajes: 1596
Registrado: 02 Abr 2013, 19:06
Been thanked: 1 time

Re: Tutorial de optimización de cintas y ultracargas

Mensaje por antoniovillena »

Lección 7

Aviso de antemano, esta lección es muy avanzada. No recomiendo seguirla a no ser que tengáis un buen nivel de ensamblador del Z80 para comprenderla.

Un poco de historia.

Las cargas turbo aparecieron relativamente pronto, sobre 1984. Usan la misma modulación (FSK) que la carga estándar, pero modificando los tiempos para conseguir una carga al doble de velocidad (3000 bps). El objetivo principal era dificultar la piratería de dos formas: encriptando los datos para que no los pueda leer ningún copiador, y poniendo al límite el ancho de banda de las cintas, evitando así su copiado en cassettes de doble pletina. Las cargas turbo más populares fueron Alkatraz y Speedlock.

Aparte de las protecciones comerciales también han habido intentos homebrew de acelerar la carga de juegos hasta 2005, pero siempre basándose en la misma modulación y el mismo código que la carga estándar. Como ejemplo de utilidad que permitía esto tenemos el Taper (ojo es para MS-DOS) y una página conocida que empleaba dicha utilidad era la de Digiexp. Estamos hablando del cuádruple de velocidad estándar (6000 bps) y tiempos de carga de minuto y pico.

¿Qué ocurrió a partir de 2005? Pues por un lado se emplearon métodos más rápidos para muestrear el puerto EAR y por otro lado aparecieron mejores modulaciones. La primera utilidad fue Sna2Wav, escrita por un servidor, y la velocidad alcanzada era de 8 veces la velocidad estándar (12000 bps). Se puede descargar aquí. Básicamente es la misma modulación que la carga estándar (FSK) pero empleando símbolos de 3 y 5 muestras para el 0 y el 1 (en carga estándar se codifican con 23 y 47 muestras respectivamente).

Luego apareció el famoso k7zx de Francisco Villa (decicoder en los foros), mejorando tanto las rutinas de muestreo como desarrollando nuevas modulaciones. Esta utilidad evolucionó en el proyecto OTLA, en el que se portan las ultracargas a otras plataformas (ZX81, Amstrad CPC y MSX). Dichas utilidades explotaron al máximo todo lo que podían dar de sí las ultracargas. Se consiguió la máxima velocidad en un spectrum real (21333 bps) y se llegó a la conclusión de que el método más rápido (que a la vez ofrece fiabilidad) es el llamada "Shawings Raudo 2.25", que explicaré más adelante.

El único escollo que quedaba por resolver era convertir fácilmente un archivo TAP a ultracarga. Con los snapshots no había problema, pero tienen sus inconvenientes: requieren cargar 48K de datos por lo que siempre tardan más, es necesario generar un snapshot ya que los juegos normalmente se distribuyen en formato cinta (TAP ó TZX) y por último no siempre es imposible mostrar la pantalla de carga. Por esa razón desarrollé (con la ayuda de decicoder) CargandoLeches, un proyecto que mediante reemplazo de ROM se pueden pasar juegos en formato TAP a ultracarga, empleando las mismas modulaciones "Shawings Raudo" y "Shawings Slow" del k7zx/OTLA. Tienen la ventaja de ser las más rápidas (en torno a 10-15 segundos para un juego de 48K) porque no es necesario que los primeros bloques tengan carga estándar. Aunque claro, hay que reemplazar la ROM (o disponer de un +2A) si quieres disfrutar de ellas.

Por último tenemos la utilidad GenTape, que permite todo tipo de ultracargas mediante plugins. Tan sólo hay que desarrollar un ejecutable que genere un bloque (en formato TZX y WAV) partiendo de un binario dado, que GenTape llama a dicho ejecutable varias veces y concatena los segmentos de audio al archivo principal. En esta lección mostraré cómo hacer esto, e introduciré un nuevo formato de ultracarga (basado en la modulación más rápida de CargandoLeches) que he escrito exclusivamente para este tutorial.

Antes de nada, ajusta el volumen.

Para que las ultracargas funcionen es necesario que la señal esté lo más balanceada posible. Es decir, si le meto una onda cuadrada, los pulsos altos deben durar lo mismo que los pulsos bajos. Antiguamente esto se hacía variando el azimuth, alineando el cabezal lector con la cinta mediante el ajuste de un tornillo. Evidentemente no vamos a usar cintas, tienen un ancho de banda muy limitado que no permite superar la barrera de los 10 Kbps, pero el concepto es el mismo.

El Spectrum siempre ve una onda cuadrada por el puerto EAR, pero la señal de audio es muy parecida a la función seno. Para estar perfectamente balanceados necesitamos que el pulso de la señal cuadrada resultante (lo que ve el Spectrum) cambie cada vez que nuestro "seno" pase por cero, leyendo un "0" cuando la señal es negativa y un "1" cuando la misma es positiva. Se permite cierto margen de asimetría, pero en las ultracargas este margen es muy pequeño. En teoría nuestra rutina temporiza ciclos enteros (un ciclo son dos pulsos) por lo que daría igual que las 10 muestras de un hipotético ciclo duren 5/5, 4/6 ó 3/7. En la práctica el código se pasa una parte del tiempo muestreando y otra haciendo otras cosas, mientras más grande sea la proporción de "haciendo otras cosas", más sensible será nuestra rutina a fallar cuando la señal muestre asimetría. Esto es lo que pasa con las ultracargas, que seguro funcionan a 5/5, es posible que también a 4/6, pero a 3/7 dejan de funcionar, mientras que la carga estándar se lo traga todo (ojo estas cifras no son reales sino ejemplos ilustrativos).

Vamos a utilizar la utilidad LoadVerify (hay un TZX y un WAV en el fichero de la lección) para hacer el ajuste del volumen. Es muy importante usar el mismo reproductor tanto en el calibrado como en la carga final, no nos vale de nada cargar el LoadVerify.tzx con Tapir y luego reproducir el WAV con VLC. Se entiende que vamos a cargar el juego al mismo volumen con el que hemos conseguido el mejor calibrado, y el mejor calibrado es aquel en el cual el cuadradito rosa esté lo más centrado posible. La guía verde de arriba es orientativa, nada nos asegura que vaya a cargar si estamos dentro de la guía o que no vaya a hacerlo si estamos fuera. Evidentemente a mayor velocidad de carga, mejor calibración necesitaremos, más centrado debe estar el cuadradito rosa. En el ejemplo de esta lección cargaremos a 21333bps (la máxima conseguida en hardware real sin modificaciones) por lo que hay que centrar todo lo que podamos.

Muestro un pantallazo de lo que sería ideal. Nótese que una señal asimétrica cuando es pronunciada también se aprecia en las bandas del borde. Si las bandas de un color son notablemente más anchas que las de otro es porque la señal que lee el spectrum es muy asimétrica. Esto en tiempos del Spectrum era un síntoma claro de que hacía falta un ajuste de azimuth, y afortunadamente se podía calibrar sin ninguna utilidad específica, tan sólo observando las bandas rojas/cyan del tono guía en cualquier carga estándar.

Imagen

Un poco de código antes de empezar.

Hay 2 formas para muestrear el puerto EAR lo más rápidamente posible. La primera es la que se me ocurrió a mí para el Sna2Wav:

Código: Seleccionar todo

        in      l, (c)
        jp      (hl)
Tarda 16 ciclos, serían 12 de la instrucción IN, más 4 del JP (HL). El secreto está en ubicar distintos fragmentos en direcciones que acaben en $BF y $FF, que son los valores posibles que podemos leer del puerto EAR. Por ejemplo en $80BF tengo el bucle que me lee el nivel cero y en $80FF tengo el que me lee el nivel uno.

¿Por qué es importante que el bucle dure pocos ciclos? Pues porque mientras menos dure, más veces podemos muestrear la señal EAR y más exactos serán los valores a comparar. Para que te hagas una idea, una muestra a 48000 Hz dura unos 73 ciclos de CPU. En carga estándar el bucle que incrementa el registro B (lo vimos en la lección 4) tarda 59 ciclos en muestrear. Dependiendo de cuándo toca muestrear (esto no lo podemos controlar) podemos tener el mismo número de lecturas, por ejemplo 2, tanto en un pulso de una muestra (73 ciclos) como en uno de dos (146 ciclos).

Veamos ahora la segunda forma de muestrear, que descubrió decicoder y lo usó por primera vez en su k7zx:

Código: Seleccionar todo

bucle   in      f, (c)
        jp      pe, bucle
Este bucle tarda 22 ciclos en completarse, 12 en el IN, más 10 en el JP. En este caso la ventaja es evidente, podemos ubicar la rutina donde queramos a costa de un muestreo 6 ciclos más lento. Lo primero es una instrucción no documentada (el registro F no existe), lo único que hace es actualizar los flags, el byte leído no se almacena en ningún registro. Ojo que hay otra instrucción, IN A,(N), de 11 ciclos que no actualiza los flags, y por tanto no nos vale. De hecho el único flag que nos interesa es P/V (paridad/overflow), que en este caso usamos como paridad. La paridad es el resultado de la función XOR de los 8 bits que componen el byte, y es equivalente a contar el número de unos y ver si la cuenta es par o impar. Ojo que en la última instrucción he puesto un JP PE (parity even o par), pero también podría ser un JP PO (parity odd o impar).

Si hacemos cuentas, con la primera rutina podemos detectar el nivel de una muestra a 48000 bps (73 ciclos) entre 4 y 5 veces (73/16= 4.56), mientras que con la segunda rutina sería entre 3 y 4 veces (73/22= 3.31). Evidentemente mientras más veces muestremos un pulso mejor, así podemos distinguirlo mejor de otro pulso de distinta duración.

Hasta ahora he indicado cómo se muestrea pero no como se contabiliza el número de muestras leídas. Este paso es imprescindible si queremos distinguir pulsos de distinta duración. Bueno pues también existen dos formas de contabilizar, una que emplea 4 ciclos adicionales y otra que emplea... 0 ciclos. ¿Cómorrrr? Sí, lo que estás oyendo, en breve explicaré lo que a simple vista parece una magufada. Primero la de 4 ciclos:

En método 1

Código: Seleccionar todo

        inc     a
        in      l, (c)
        jp      (hl)
En método 2

Código: Seleccionar todo

bucle   inc     a
        in      f, (c)
        jp      pe, bucle
Esto evidentemente empeora el muestreo a 20 ciclos (método 1) y a 26 ciclos (método 2) respectivamente. Ahora toca explicar la magufada. ¿Cómo podemos saber cuántas veces se ha ejecutado el bucle sin meter ninguna instrucción que incremente un contador? Pues muy fácil, mediante el registro R del Z80. Es un registro exclusivamente pensado para simplificar la circuitería en las memorias dinámicas. El Z80 incrementa el registro R (los 7 bits menos significativos) tras cada ciclo de fetch, de tal forma que pueda hacer una falsa lectura (con el objetivo de refrescar) a cada una de las 128 filas que componen la matriz. De no hacer estas lecturas periódicas la RAM se corrompería.

Pues bien, en ambos casos lo que hay que hacer es leer el registro R antes y después y hacer la diferencia, muestro el método 2:

Código: Seleccionar todo

        ld      a, r
        ld      b, a

bucle   in      f, (c)
        jp      pe, bucle

        ld      a, r
        sub     b
En el bucle hay 2 intrucciones pero la primera es compuesta (el opcode ED tiene su propio ciclo de fetch), por lo que en cada pasada el registro R se incrementa en 3. Así que en este código, dependiendo del número de pasadas del bucle (lo llamaremos N) tendremos un incremento del registro R de N*3+2.

Lo que acabo de contar es el corazón de toda ultracarga, al fin y al cabo de lo que se trata es de leer el puerto EAR lo más rápidamente posible e interpretar los símbolos dependiendo de la duración de los pulsos. Todo lo demás es dependiente de la modulación: rellenar el byte de bits, escribirlo en memoria una vez esté relleno, incrementar puntero de memoria, calcular checksum, comprobar si hemos acabado la ultracarga, etc...

La modulación que vamos a emplear.

Es la misma que "Shawings Raudo 2.25" en su k7zx, es una FSK con 2 bits por símbolo. Es decir que tenemos 4 símbolos distintos que codificar (00, 01, 10 y 11) a diferencia de la rutina de carga estándar donde teníamos sólo dos (0 y 1). El numerito que viene después indica la velocidad de carga. Viene a decir cúantas muestras hacen falta de media para codificar un bit (no un símbolo). A partir de este numerito podemos calcular la velocidad de carga haciendo una simple división. Así, a 44100 Hz tendremos 19600 bps, mientras que a 48000 Hz serían 21333 bps. Los valores reales, al igual que en la carga estándar aunque en menor medida, son ligeramente mejores. Esto se debe a que los ceros son más frecuentes que los unos (los 00s también) y al ser también los símbolos más cortos hacen que la media baje. O sea que normalmente una carga estándar puede ir a 1600/1700 bps y esta ultracarga por tanto entre 22000/23000 bps. Teniendo en cuenta ésto y la duración de los silencios y los tonos guía ya podemos calcular de forma aproximada cuánto va a tardar en cargar un juego.

Por poner un ejemplo, tenemos un juego con 7K de pantalla de carga y 30K de binario. Serían 4 bloques físicos (3 lógicos), y el cargador pongamos que ocupa 300 bytes.

Tonos guía: 5 segundos del primer bloque, y 2 segundos de cada uno de los otros tres suman 11.
Pausas: 1 segundo tras el primer bloque y 2 en cada uno de los otros 3 (descontando 2 del silencio final) nos dan un total de 5 segundos.
Datos: 300bytes * 8 bits-por-byte / 1600 bps hacen un total de 1.5 segundos para el cargador, haciendo las mismas cuentas en pantalla de carga (34.5s) y juego (150s) sale un total de 186 segundos.

En carga estándar tendríamos un juego de 202 segundos (3 minutos y 22 segundos).

Hagamos las mismas cuentas para una ultracarga, reduciendo pausas y tonos guía a la mitad aunque en realidad se reducen aún más:

Tonos guía: 5.5s
Pausas: 2.5s
Datos: 1.5s (cargador) + 2.4s (pantalla de carga) + 10.4s (juego)= 14.3

Total: 22.3s

La duración de la carga es más o menos 9 veces menor en la ultracarga. Si sólo cargáramos datos debería haber salido 21333/1500= 14.22. Por eso es muy importante optimizar también el tiempo de los silencios y tonos guías. En el ejemplo que estamos siguiendo en el tutorial partíamos de 3:25 y lo dejaremos en 9 segundos, vamos que la mejora es de casi 23. Ojo que en esto último hemos metido compresión de por medio, sólo quiero indicaros lo que se puede conseguir.

Ahora veamos el aspecto que tiene una señal en carga estándar. Son capturas del Audacity con leyendas sobreescritas.

Imagen

Como podemos observar, tenemos un tren de pulsos equiespaciados a la izquierda (sería el tono guía) seguido de dos pulsos cortos de sincronismo (sync) y después tenemos los datos. En este ejemplo vemos 2 bytes con sus correspondientes bits, donde podemos distinguir los ciclos del símbolo 0 (dos pulsos cortos) de los ciclos del 1 (dos pulsos largos). Sabemos que es un bloque de datos porque el primer byte (de flag) es $FF, y salvo éste byte y el último (checksum) todos los demás se escriben en memoria.

Ahora le toca a una señal con modulación "Shawings Raudo 2.25". He omitido el tono guía y el checksum (en esta modulación se codifican de forma diferente y no existe byte de flag), yéndonos directamente a la zona de datos:

Imagen

Vemos que los símbolos son de 2 bits (cada símbolo) y que cada byte se codifica con 4 símbolos. La duración de cada símbolo va desde 3 muestras (00) hasta 6 muestras (11) y al igual que en carga estándar cada símbolo está compuesto por 2 pulsos (pulso negativo+pulso positivo). En esta modulación en concreto el primer pulso es siempre más corto o igual que el segundo, esto es así porque la rama de código del primer pulso también es más corta, aunque hay otras modulaciones como CargandoLeches donde es justo al revés. Así las codificaciones exactas son: 00 (1+2=3), 01 (2+2=4), 10 (2+3=5) y 11 (3+3=6).

Ahora con estos 4 símbolos calculamos la media, sería: (3+4+5+6)/4 = 4.5 muestras. Es decir, cada símbolo (2 bits) dura de media 4.5 muestras, con lo que cada bit se codifica en una media de 2.25 muestras. Es por eso que la codificación se llama "Shawings Raudo 2.25". Existen otras variantes Shawings llamadas Slow en las que la distancia entre símbolos es de 2 muestras. Evidentemente esto es más lento pero a la vez es más inmune a errores de carga (decimos que la carga es más fiable). Por ejemplo la Shawings Slow más rápida es la que usa símbolos de 2,4,6 y 8 muestras respectivamente, también llamada "Shawings Slow 2.5". El conjunto de modulaciones que acepta CargandoLeches para "Shawings Raudo" es 1.75, 2.25, 2.75 y 3.25 (a 1.75 no funciona en máquinas reales) y para "Shawings Slow" es 2.5, 3, 3.5 y 4.

En nuestro ultracargador partimos del código "Shawings Raudo 2.25", al cual le haremos una serie de modificaciones para hacerlo más simple. El cambio más importante es el de la rutina muestreadora, usaremos una más lenta pero más sencilla de ubicar (algo parecido al método 2).

make.bat. Todo metido en el cargador, rutina en dirección fija

Como siempre, lo mejor es empezar haciendo algo lo más sencillo posible y que funcione, luego habrá tiempo de mejorarlo. No lo he dicho antes pero GenTape funciona con dos velocidades de muestreo (44100Hz y 48000Hz), y por defecto se trabaja a 44100. Y si queremos hacer las cosas bien tenemos que tener en cuenta ambas frecuencias a la hora de codificar el WAV, y que nuestra rutina cargadora se adapte a estos pequeños cambios (44100Hz serían muestras de 79 ciclos, a diferencia de los 73 a 48000Hz). También podemos trabajar sólo a una en concreto y mostrar error si se intenta usar la otra, aunque no es lo recomendable.

Veamos el archivo make.bat:

Código: Seleccionar todo

set _freq=44100

fcut manic.bin 200 -200 manic.cut
zx7b manic.scr manic.scr.zx7b
zx7b manic.cut manic.cut.zx7b
for %%A in (manic.scr.zx7b) do set _fileSize=%%~zA
echo  define  scrsize %_fileSize% >  define.asm
for %%A in (manic.cut.zx7b) do set _fileSize=%%~zA
echo  define  binsize %_fileSize% >> define.asm
set /a _sf48=%_freq%/48000
echo  define  sf48    %_sf48%     >> define.asm

SjAsmPlus loader.asm
FlagCheck header.bin 0
FlagCheck loader.bin

GenTape %_freq%                   manic.wav           ^
          turbo 2168   667   735                      ^
            600 1600  1500     0  header.bin.fck      ^
          turbo 2168   667   735                      ^
            600 1600  1500     0  loader.bin.fck      ^
   plug-ultra-3        100   500  manic.scr.zx7b      ^
   plug-ultra-3        100   500  manic.cut.zx7b
En la variable _freq decidimos cual es la frecuencia de muestreo a la que vamos a trabajar. Las 7 líneas siguientes ya las hemos visto antes, son para recortar, comprimir y escribir el tamaño de los comprimidos en define.asm. Luego tenemos otras 2 líneas adicionales que introducen una constante nueva sf48 en define.asm, que estará a "0" si trabajamos a 44100, o a "1" si lo hacemos a 48000 Hz. Esto es necesario porque en un momento dado necesitamos indicarle al ensamblador a qué frecuencia trabajamos y es más sencillo hacerlo con una variable booleana que con una numérica.

El siguiente bloque de 3 líneas también lo hemos visto antes, ensamblamos loader.asm (que genera header.bin y loader.bin) y calculamos los flags/checksums para usar bloques "turbo" en lugar de "data" (en los que podemos ajustar los tiempos a nuestro antojo).

Por último tenemos la llamada a GenTape. Como véis le hemos pasado la variable _freq para decirle a qué frecuencia de muestreo queremos que nos genere el WAV. En este caso generamos un WAV pero podríamos haber generado un TZX. Para ultracargas de este estilo recomiendo los WAVs, son más fiables. Los dos primeros bloques son tipo "turbo", donde tenemos carga estándar con parámetros ligeramente modificados. La novedad está en los 2 últimos bloques, donde se usa un tipo que no hemos visto antes llamado "plug-ultra-3".

Todo tipo que empieza por "plug-" lo que hace es invocar un plugin, que es un ejecutable externo cuyo nombre es lo que viene a continuación. Es decir por cada línea "plug-ultra-3" se hace una llamada al ejecutable "ultra.exe" con una serie de parámetros, algunos fijos y otros que introducimos en la propia línea. Como el número de parámetros puede variar para cada plugin, se lo indicamos con el "-3", así GenTape sabrá cuando acaba la línea (GenTape recibe todos los parámetros en una línea, los ^ no los recibe, son para mejorar la legibilidad). Los 3 parámetros que le enviamos son "100 500 manic.scr.zx7b" para la pantalla y "100 500 manic.cut.zx7b" para el juego.

Como curiosidad, escribamos "ultra" para invocar a "ultra.exe" en la línea de comandos:

Código: Seleccionar todo

ultra v0.03, an ultra load block generator by Antonio Villena, 31 May 2014

  ultra <srate> <channel_type> <ofile> <pilot_ms> <pause_ms> <ifile>

  <srate>         Sample rate, 44100 or 48000. Default is 44100
  <channel_type>  Possible values are: mono (default), stereo or stereoinv
  <ofile>         Output file, between TZX or WAV file
  <pilot_ms>      Duration of pilot in milliseconds
  <pause_ms>      Duration of pause after block in milliseconds
  <ifile>         Hexadecimal string or filename as data origin of that block
Tenemos esta ayuda en pantalla que nos lo explica todo. Es más, seríamos capaces de poder generar la ultracarga sin la herramienta GenTape con otras herramientas y a base de editar WAVs. Como véis el ejecutable tiene 6 parámetros, mientras que la llamada al plugin sólo 3 (en concreto los 3 últimos). Internamente GenTape rellena los 3 primeros parámetros con lo que le hayamos indicado, o lo que tome por defecto si no le indicamos nada (mono y 44100), y como fichero de salida un archivo temporal que luego concatenará con el fichero que esté generando, para luego borrarlo.

Pues bien, como podemos deducir de todo esto, en ambos bloques de ultracarga queremos un tono guía de 100 milisegundos y una pausa de 500 milisegundos después de cada bloque.

Recomiendo que le echéis un vistazo al código fuente de ultra.exe (ultra.c), está en C y es más o menos sencillo de asimilar. No sé si lo he dicho pero si desarrolláis vuestra propia modulación es conveniente poder generar la ultracarga tanto en formato TZX como en WAV. Para ello tendréis que conocer las especificaciones de dichos formatos, aunque siguiendo el código de ultra.c no debería ser muy difícil.

Ahora veamos el código Z80 del cargador, loader.asm:

Código: Seleccionar todo

        define  tr $ffff &
        include define.asm

; Bloque cabecera
        output  header.bin
        db      0               ; tipo: 0=cabecera, 1=array numérico
                                ; 2=array alfanumérico, 3=código máquina
        db      'ManicMiner'    ; Nombre del archivo (hasta 10 letras)
        block   11-$, 32        ; Relleno el resto con espacios
        dw      fin-ini         ; Longitud del bloque basic
        dw      10              ; Autoejecución en línea 10
        dw      fin-ini         ; Longitud del bloque basic

; Bloque datos (Basic con código máquina incrustado)
        output  loader.bin
        org     $8000-dzx7b+ini
ini     ld      sp, $8200-4
        di
        db      $de, $c0, $37, $0e, $8f, $39, $96
        ld      hl, $5ccb+dzx7b-ini
        ld      de, $8000
        ld      bc, fin-dzx7b
        ldir

        ld      hl, $8200
        ld      de, scrsize
        call    tr loader
        ld      hl, $8200+scrsize-1
        ld      de, $5aff
        call    tr dzx7b

        ld      hl, $8200-4
        ld      de, binsize
        call    tr loader
        ld      hl, $8200+binsize-4-1
        ld      de, $ffff
        call    tr dzx7b

        jp      $8400

dzx7b   include dzx7b_fast.asm
loader  include ldbytes.asm
fin
Como tenéis experiencia de lecciones anteriores no hace falta que explique lo que hace línea por línea. Tanto el descompresor (dzx7b_fast.asm) como la rutina cargadora (ldbytes.asm) van incluídos en el cargador (en el primer bloque, el bloque Basic) y por tanto son cargadas a velocidad estándar. En concreto el descompresor va desde $8000 hasta $8042 y la rutina cargadora desde $8043 hasta $8159. Si hacemos la suma de cargador+descompresor+rutina cargadora nos sale un total de 407 bytes. Puede parecer poco pero supone bastante tiempo de carga si lo hacemos a 1500 bps en lugar de a 21333.

Por otro lado no nos hemos preocupado de que la rutina cargadora se pueda reubicar, o sea funciona sólo si la colocamos en $8043 (o en cualquier dirección que acabe en $43). Estos pequeños inconvenientes los resolveremos en los proximos makeX.bat.

Por último voy a mostrar el contenido de ldbytes.asm:

Código: Seleccionar todo

ultra   exx                     ; salvo de, en caso de volver al cargador estandar
        ld      c, 0
ultra1  defb    $26
ultra2  jp      nz, $053f       ; return if at any time space is pressed.
ultra3  ld      b, 0
        call    $05ed           ; leo la duracion de un pulso (positivo o negativo)
        jr      nc, ultra2      ; si el pulso es muy largo retorno a bucle
        ld      a, b
        add     a, -16          ; si el contador esta entre 10 y 16 es el tono guia
        rr      h               ; de las ultracargas, si los ultimos 8 pulsos
        jr      z, ultra1
        add     a, 6            ; son de tono guia h debe valer ff
        jr      c, ultra3
        ld      a, $d8          ; a' tiene que valer esto para entrar en raudo
        ex      af, af'
        dec     h
        jr      nz, ultra1      ; si detecto sincronismo sin 8 pulsos de tono guia retorno a bucle
        call    $05ed           ; leo pulso negativo de sincronismo
        inc     h
ultra4  ld      b, 0            ; 16 bytes
        call    $05ed           ; esta rutina lee 2 pulsos e inicializa el contador de pulsos
        call    $05ed
        ld      a, b
        cp      12
        rl      h
        jr      nc, ultra4
        ld      a, h
        exx
        ld      c, a            ; guardo checksum en c'
        push    hl              ; pongo direccion de comienzo en pila
        exx
        pop     de              ; recupero en de la direccion de comienzo del bloque
        inc     c               ; pongo en flag z el signo del pulso
        ld      bc, $effe       ; este valor es el que necesita b para entrar en raudo
        ld      hl, leehi
        jr      z, ultra6
        ld      (lowh1+1), hl
ultra5  in      f, (c)
        jp      pe, ultra5
        jr      ultra8          ; salto a raudo segun el signo del pulso en flag z
ultra6  ld      (lowh0+1), hl
ultra7  in      f, (c)
        jp      po, ultra7
        add     hl, hl
ultra8  ld      h, table>>8
        jr      lowhi           ; salto a raudo

lowh0   call    leelo           ;17       61
        ex      af, af'         ;4
        ld      a, r            ;9
        ld      l, a            ;4
        ld      b, (hl)         ;7
lowhi   ld      a, $0d+3*sf48   ;7
        ld      r, a            ;9
        ex      af, af'         ;4

lowh1   call    leelo           ;17       65/65
        jr      nc, lowh2       ;7/12
        xor     b               ;4
        xor     $9c             ;7
        ld      (de), a         ;7
        inc     de              ;6
        ld      a, $dc          ;7
        jp      lowh0           ;10
lowh2   xor     b               ;4
        add     a, a            ;4
        add     a, a            ;4
        out     (c), b          ;12
        jr      lowh0           ;12

leehi   .14     defb    $ed, $70, $e8
        jr      ultra9

leelo   .14     defb    $ed, $70, $e0

ultra9  pop     hl
        exx                     ; ya se ha acabado la ultracarga (raudo)
        dec     de
        ld      b, e
        inc     b
        inc     d
ultraa  xor     (hl)
        inc     hl
        djnz    ultraa
        dec     d
        jr      nz, ultraa      ; con JP ahorro algunos ciclos
        xor     c
        ret     z               ; si no coincide el checksum salgo con carry desactivado
        ei
        rst     $08             ; error-1
        defb    $1a             ; error report: tape loading error

table   .15     defb    $ec
        .12     defb    $ed
        .12     defb    $ee
        .13     defb    $ef
Empiezo por lo más importante, la rutina muestreadora:

Código: Seleccionar todo

leehi   .14     defb    $ed, $70, $e8
        jr      ultra9

leelo   .14     defb    $ed, $70, $e0

ultra9  pop     hl
        exx
Está así de mal porque no he encontrado la forma de hacer macros de 2 líneas, pero en realidad sería equivalente a esto:

Código: Seleccionar todo

leehi   in      f, (c)
        ret     pe
        in      f, (c)
        ret     pe
        ... (14 veces en total)
        in      f, (c)
        ret     pe
        jr      ultra9

leelo   in      f, (c)
        ret     po
        in      f, (c)
        ret     po
        ... (14 veces en total)
        in      f, (c)
        ret     po

ultra9  pop     hl
        exx
Sería un bucle desenrollado que lee el puerto EAR cada 17 ciclos. Desgraciadamente al no ser múltiplo de 8 se ve mas afectada por la contención, y si nos pilla en mitad de una línea las lecturas de puerto tienden a 20 ciclos. Esto con una rutina de 16 ciclos no pasaba, si hay contención solo le afecta a la primera lectura (entre 0 y 6 ciclos de penalización), las demás se libran.

Otra ventaja de este método (lo llamaré método 3) es que es muy sencillo detectar el fin de la carga, como en ningún símbolo legal llegamos a hacer 14 lecturas (en uno de los pulsos) podemos indicar el fin de la carga con un símbolo especial más largo, y por tanto salimos por ultra9. El pop hl es para equilibrar la pila, ya que a leelo o leehi hemos entrado con un call.

Entendiendo esta rutina todo lo demás es coser y cantar. Desde ultra a ultra8 tenemos código de inicialización, que incluye detección de tono guía y lectura de byte de checksum. Lo que hay entre lowh0 y el final de lowh2 es el bucle principal, se estará ejecutando todo el rato que dure la ultracarga. Consta de 2 segmentos. Desde lowh0 hasta lowh1 leemos el primer pulso, contamos el número de lecturas a puerto extrayéndolo del registro R y miramos en una tabla a qué símbolo se corresponde tal número de lecturas (en el registro B). Finalmente inicializamos el registro R a un valor en el cual concuerden el número de las lecturas con el contenido de la tabla. En este caso dicho valor es $0d+3*sf48, lo que quiere decir que vale 13 a 44100Hz y 16 a 48000Hz. Al ser un valor fijo significa que la rutina sólo funciona en dicha ubicación: $8043.

Veamos ahora el segundo segmento del bucle principal, que va desde lowh1 hasta el último "jr lowh0". Este segmento se bifurca en 2 ramas: en la "no carry" introducimos el símbolo que acabamos de leer (está en los 2 bits menos significativos de B) en el registro A y luego rotamos dos bits a la izquierda, preparándolo para el siguiente símbolo. El último out (c), b muestra el borde, que será uno de 4 colores (de 4 a 7) dependiendo del símbolo que acabamos de leer. La otra rama, la rama "carry", ocurre cuando hemos leído los 4 símbolos del byte y procedemos a cargar el byte en memoria, incrementar el puntero DE e inicializar el registro A para el siguiente símbolo.

En total tenemos 61 ciclos del primer segmento y 65 en el segundo segmento (las 2 ramas están balanceadas), por esa razón procuramos que si los dos pulsos tienen distinta duración, que el segundo sea el más largo.

El resto del código (desde ultra9 hasta "defb $1a") lo que hace es comprobar que el checksum indicado al comienzo coincide con el del bloque que acabamos de leer y muestra error en caso de que no coincidan. Esto lo hacemos después, en diferido, porque nuestra rutina es muy crítica en tiempos. En realidad habría sido más sencillo llevar la cuenta del checksum en un registro (en la rutina estándar es el registro H) y xorearlo con cada byte que acabáramos de leer.

Ya sólo me queda por explicar la tabla. Los valores son $ec, $ed, $ee y $ef en lugar de algo más lógico como $00, $01, $02 y $03. Lo hacemos así porque este mismo byte es el que escribimos en el puerto $FE (por eso cambia el borde). Las restricciones vienen impuestas porque el bit 3 debe valer 0 y el bit 4, 1 para que las posteriores lecturas se hagan correctamente, digamos que el valor debe ser así %ABC01DXX. Los valores XX no dependen de nosotros sino del símbolo que acabamos de leer. Los otros (A,B,C y D) pueden valer lo que queramos, pero si lo ponemos a 1 mejor, así evitamos conflictos con el teclado.

Otra restricción que tiene la tabla es que no debe pasar el límite de media página (128 bytes) ya que el siguiente byte volvería al principio. Un ejemplo, tenemos una tabla que empieza en $8070 y acaba en $81A3. Muy mal, porque el registro R pasaría de $7F a $00 (el bit alto no cambia) y por tanto después de $807F se leería $8000. Esto segmentaría la tabla dejando un hueco inútil. En nuestro caso no hay problema porque estamos en una dirección fija, y la tabla se encuentra entre $8126 y $8159. Por último decir que sólo 1/3 de los valores de la tabla son los que realmente se usan porque en cada lectura incrementamos R en 3, y 3 es un número muy chungo de tratar para un procesador que no puede multiplicar ni dividir directamente, así que preferimos desperdiciar unos pocos bytes (en concreto 34 de los 52) y listo.

Bueno ya he explicado lo más gordo. A partir de ahora todo será mejorar la rutina que acabamos de crear, ya sea para hacerla más flexible o para optimizar el tiempo de carga. Ah bueno, se me olvidaba, hay una parte del código que es automodificable, en concreto de los dos "call leelo" uno de ellos se convierte en "call leehi" en función del nivel con el que trabajemos. A priori toda señal de audio es neutral, vamos que da igual el valor que le pongas a una muestra porque a la salida la puedes tener invertida o no (dependiendo de si el número de veces que la circuitería ha invertido la polaridad es par o impar). Así que nosotros debemos de tratar los dos casos posibles. Como todo ciclo está compuesto por dos pulsos, tendremos un primer caso en que el primer pulso tengamos nivel negativo (leemos un 0 del puerto EAR) y el segundo positivo (leemos 1), y un segundo caso con el comportamiento contrario (primero positivo y segundo negativo). Esto lo detectamos en el primer pulso de sincronismo. Lo mejor es generar un WAV con la señal invertida a uno dado y comprobar que los dos WAVs carguen correctamente.
Última edición por antoniovillena el 01 Jun 2014, 00:47, editado 1 vez en total.
Avatar de Usuario
Sinclair
Hermano de Lucifer
Hermano de Lucifer
Mensajes: 3101
Registrado: 04 Jul 2013, 23:42

Re: Tutorial de optimización de cintas y ultracargas

Mensaje por Sinclair »

No pensaba yo que esto de las ultracargas tuviera tanta miga... Y yo que creía que con saber manejar el programa K7zx ya lo sabía todo.
Imagen
Avatar de Usuario
antoniovillena
Demonio segundo orden
Demonio segundo orden
Mensajes: 1596
Registrado: 02 Abr 2013, 19:06
Been thanked: 1 time

Re: Tutorial de optimización de cintas y ultracargas

Mensaje por antoniovillena »

Sinclair escribió:No pensaba yo que esto de las ultracargas tuviera tanta miga... Y yo que creía que con saber manejar el programa K7zx ya lo sabía todo.
Pues una de las modulaciones de K7ZX de Francisco Villa será la que veremos en el siguiente capítulo. Tienen miga porque no es algo automático que se pueda aplicar a todos los juegos, a no ser que lo que cargues sean snapshots o modifiques la ROM (como en CargandoLeches). Lo que muestro en el tutorial es cómo crear cintas de manera óptima, con cargadores en código máquina y en Basic y recortando todo lo que se pueda recortar para que tarde menos en cargar.

El objetivo es que nuevos desarrolladores aprendan estas técnicas, y que saquen además del TAP básico una versión optimizada y comprimida en formato TZX, que tarde la mitad o menos en cargar en un spectrum real.
Avatar de Usuario
antoniovillena
Demonio segundo orden
Demonio segundo orden
Mensajes: 1596
Registrado: 02 Abr 2013, 19:06
Been thanked: 1 time

Re: Tutorial de optimización de cintas y ultracargas

Mensaje por antoniovillena »

Lección 7

Aviso de antemano, esta lección es muy avanzada. No recomiendo seguirla a no ser que tengáis un buen nivel de ensamblador del Z80 para comprenderla.

Un poco de historia.

Las cargas turbo aparecieron relativamente pronto, sobre 1984. Usan la misma modulación (FSK) que la carga estándar, pero modificando los tiempos para conseguir una carga al doble de velocidad (3000 bps). El objetivo principal era dificultar la piratería de dos formas: encriptando los datos para que no los pueda leer ningún copiador, y poniendo al límite el ancho de banda de las cintas, evitando así su copiado en cassettes de doble pletina. Las cargas turbo más populares fueron Alkatraz y Speedlock.

Aparte de las protecciones comerciales también han habido intentos homebrew de acelerar la carga de juegos hasta 2005, pero siempre basándose en la misma modulación y el mismo código que la carga estándar. Como ejemplo de utilidad que permitía esto tenemos el Taper (ojo es para MS-DOS) y una página conocida que empleaba dicha utilidad era la de Digiexp. Estamos hablando del cuádruple de velocidad estándar (6000 bps) y tiempos de carga de minuto y pico.

¿Qué ocurrió a partir de 2005? Pues por un lado se emplearon métodos más rápidos para muestrear el puerto EAR y por otro lado aparecieron mejores modulaciones. La primera utilidad fue Sna2Wav, escrita por un servidor, y la velocidad alcanzada era de 8 veces la velocidad estándar (12000 bps). Se puede descargar aquí. Básicamente es la misma modulación que la carga estándar (FSK) pero empleando símbolos de 3 y 5 muestras para el 0 y el 1 (en carga estándar se codifican con 23 y 47 muestras respectivamente).

Luego apareció el famoso k7zx de Francisco Villa (decicoder en los foros), mejorando tanto las rutinas de muestreo como desarrollando nuevas modulaciones. Esta utilidad evolucionó en el proyecto OTLA, en el que se portan las ultracargas a otras plataformas (ZX81, Amstrad CPC y MSX). Dichas utilidades explotaron al máximo todo lo que podían dar de sí las ultracargas. Se consiguió la máxima velocidad en un spectrum real (21333 bps) y se llegó a la conclusión de que el método más rápido (que a la vez ofrece fiabilidad) es el llamada "Shawings Raudo 2.25", que explicaré más adelante.

El único escollo que quedaba por resolver era convertir fácilmente un archivo TAP a ultracarga. Con los snapshots no había problema, pero tienen sus inconvenientes: requieren cargar 48K de datos por lo que siempre tardan más, es necesario generar un snapshot ya que los juegos normalmente se distribuyen en formato cinta (TAP ó TZX) y por último no siempre es imposible mostrar la pantalla de carga. Por esa razón desarrollé (con la ayuda de decicoder) CargandoLeches, un proyecto que mediante reemplazo de ROM se pueden pasar juegos en formato TAP a ultracarga, empleando las mismas modulaciones "Shawings Raudo" y "Shawings Slow" del k7zx/OTLA. Tienen la ventaja de ser las más rápidas (en torno a 10-15 segundos para un juego de 48K) porque no es necesario que los primeros bloques tengan carga estándar. Aunque claro, hay que reemplazar la ROM (o disponer de un +2A) si quieres disfrutar de ellas.

Por último tenemos la utilidad GenTape, que permite todo tipo de ultracargas mediante plugins. Tan sólo hay que desarrollar un ejecutable que genere un bloque (en formato TZX y WAV) partiendo de un binario dado, que GenTape llama a dicho ejecutable varias veces y concatena los segmentos de audio al archivo principal. En esta lección mostraré cómo hacer esto, e introduciré un nuevo formato de ultracarga (basado en la modulación más rápida de CargandoLeches) que he escrito exclusivamente para este tutorial.

Antes de nada, ajusta el volumen.

Para que las ultracargas funcionen es necesario que la señal esté lo más balanceada posible. Es decir, si le meto una onda cuadrada, los pulsos altos deben durar lo mismo que los pulsos bajos. Antiguamente esto se hacía variando el azimuth, alineando el cabezal lector con la cinta mediante el ajuste de un tornillo. Evidentemente no vamos a usar cintas, tienen un ancho de banda muy limitado que no permite superar la barrera de los 10 Kbps, pero el concepto es el mismo.

El Spectrum siempre ve una onda cuadrada por el puerto EAR, pero la señal de audio es muy parecida a la función seno. Para estar perfectamente balanceados necesitamos que el pulso de la señal cuadrada resultante (lo que ve el Spectrum) cambie cada vez que nuestro "seno" pase por cero, leyendo un "0" cuando la señal es negativa y un "1" cuando la misma es positiva. Se permite cierto margen de asimetría, pero en las ultracargas este margen es muy pequeño. En teoría nuestra rutina temporiza ciclos enteros (un ciclo son dos pulsos) por lo que daría igual que las 10 muestras de un hipotético ciclo duren 5/5, 4/6 ó 3/7. En la práctica el código se pasa una parte del tiempo muestreando y otra haciendo otras cosas, mientras más grande sea la proporción de "haciendo otras cosas", más sensible será nuestra rutina a fallar cuando la señal muestre asimetría. Esto es lo que pasa con las ultracargas, que seguro funcionan a 5/5, es posible que también a 4/6, pero a 3/7 dejan de funcionar, mientras que la carga estándar se lo traga todo (ojo estas cifras no son reales sino ejemplos ilustrativos).

Vamos a utilizar la utilidad LoadVerify (hay un TZX y un WAV en el fichero de la lección) para hacer el ajuste del volumen. Es muy importante usar el mismo reproductor tanto en el calibrado como en la carga final, no nos vale de nada cargar el LoadVerify.tzx con Tapir y luego reproducir el WAV con VLC. Se entiende que vamos a cargar el juego al mismo volumen con el que hemos conseguido el mejor calibrado, y el mejor calibrado es aquel en el cual el cuadradito rosa esté lo más centrado posible. La guía verde de arriba es orientativa, nada nos asegura que vaya a cargar si estamos dentro de la guía o que no vaya a hacerlo si estamos fuera. Evidentemente a mayor velocidad de carga, mejor calibración necesitaremos, más centrado debe estar el cuadradito rosa. En el ejemplo de esta lección cargaremos a 21333bps (la máxima conseguida en hardware real sin modificaciones) por lo que hay que centrar todo lo que podamos.

Muestro un pantallazo de lo que sería ideal. Nótese que una señal asimétrica cuando es pronunciada también se aprecia en las bandas del borde. Si las bandas de un color son notablemente más anchas que las de otro es porque la señal que lee el spectrum es muy asimétrica. Esto en tiempos del Spectrum era un síntoma claro de que hacía falta un ajuste de azimuth, y afortunadamente se podía calibrar sin ninguna utilidad específica, tan sólo observando las bandas rojas/cyan del tono guía en cualquier carga estándar.

Imagen

Un poco de código antes de empezar.

Hay 2 formas para muestrear el puerto EAR lo más rápidamente posible. La primera es la que se me ocurrió a mí para el Sna2Wav:

Código: Seleccionar todo

        in      l, (c)
        jp      (hl)
Tarda 16 ciclos, serían 12 de la instrucción IN, más 4 del JP (HL). El secreto está en ubicar distintos fragmentos en direcciones que acaben en $BF y $FF, que son los valores posibles que podemos leer del puerto EAR. Por ejemplo en $80BF tengo el bucle que me lee el nivel cero y en $80FF tengo el que me lee el nivel uno.

¿Por qué es importante que el bucle dure pocos ciclos? Pues porque mientras menos dure, más veces podemos muestrear la señal EAR y más exactos serán los valores a comparar. Para que te hagas una idea, una muestra a 48000 Hz dura unos 73 ciclos de CPU. En carga estándar el bucle que incrementa el registro B (lo vimos en la lección 4) tarda 59 ciclos en muestrear. Dependiendo de cuándo toca muestrear (esto no lo podemos controlar) podemos tener el mismo número de lecturas, por ejemplo 2, tanto en un pulso de una muestra (73 ciclos) como en uno de dos (146 ciclos).

Veamos ahora la segunda forma de muestrear, que descubrió decicoder y lo usó por primera vez en su k7zx:

Código: Seleccionar todo

bucle   in      f, (c)
        jp      pe, bucle
Este bucle tarda 22 ciclos en completarse, 12 en el IN, más 10 en el JP. En este caso la ventaja es evidente, podemos ubicar la rutina donde queramos a costa de un muestreo 6 ciclos más lento. Lo primero es una instrucción no documentada (el registro F no existe), lo único que hace es actualizar los flags, el byte leído no se almacena en ningún registro. Ojo que hay otra instrucción, IN A,(N), de 11 ciclos que no actualiza los flags, y por tanto no nos vale. De hecho el único flag que nos interesa es P/V (paridad/overflow), que en este caso usamos como paridad. La paridad es el resultado de la función XOR de los 8 bits que componen el byte, y es equivalente a contar el número de unos y ver si la cuenta es par o impar. Ojo que en la última instrucción he puesto un JP PE (parity even o par), pero también podría ser un JP PO (parity odd o impar).

Si hacemos cuentas, con la primera rutina podemos detectar el nivel de una muestra a 48000 bps (73 ciclos) entre 4 y 5 veces (73/16= 4.56), mientras que con la segunda rutina sería entre 3 y 4 veces (73/22= 3.31). Evidentemente mientras más veces muestremos un pulso mejor, así podemos distinguirlo mejor de otro pulso de distinta duración.

Hasta ahora he indicado cómo se muestrea pero no como se contabiliza el número de muestras leídas. Este paso es imprescindible si queremos distinguir pulsos de distinta duración. Bueno pues también existen dos formas de contabilizar, una que emplea 4 ciclos adicionales y otra que emplea... 0 ciclos. ¿Cómorrrr? Sí, lo que estás oyendo, en breve explicaré lo que a simple vista parece una magufada. Primero la de 4 ciclos:

En método 1

Código: Seleccionar todo

        inc     a
        in      l, (c)
        jp      (hl)
En método 2

Código: Seleccionar todo

bucle   inc     a
        in      f, (c)
        jp      pe, bucle
Esto evidentemente empeora el muestreo a 20 ciclos (método 1) y a 26 ciclos (método 2) respectivamente. Ahora toca explicar la magufada. ¿Cómo podemos saber cuántas veces se ha ejecutado el bucle sin meter ninguna instrucción que incremente un contador? Pues muy fácil, mediante el registro R del Z80. Es un registro exclusivamente pensado para simplificar la circuitería en las memorias dinámicas. El Z80 incrementa el registro R (los 7 bits menos significativos) tras cada ciclo de fetch, de tal forma que pueda hacer una falsa lectura (con el objetivo de refrescar) a cada una de las 128 filas que componen la matriz. De no hacer estas lecturas periódicas la RAM se corrompería.

Pues bien, en ambos casos lo que hay que hacer es leer el registro R antes y después y hacer la diferencia, muestro el método 2:

Código: Seleccionar todo

        ld      a, r
        ld      b, a

bucle   in      f, (c)
        jp      pe, bucle

        ld      a, r
        sub     b
En el bucle hay 2 intrucciones pero la primera es compuesta (el opcode ED tiene su propio ciclo de fetch), por lo que en cada pasada el registro R se incrementa en 3. Así que en este código, dependiendo del número de pasadas del bucle (lo llamaremos N) tendremos un incremento del registro R de N*3+2.

Lo que acabo de contar es el corazón de toda ultracarga, al fin y al cabo de lo que se trata es de leer el puerto EAR lo más rápidamente posible e interpretar los símbolos dependiendo de la duración de los pulsos. Todo lo demás es dependiente de la modulación: rellenar el byte de bits, escribirlo en memoria una vez esté relleno, incrementar puntero de memoria, calcular checksum, comprobar si hemos acabado la ultracarga, etc...

La modulación que vamos a emplear.

Es la misma que "Shawings Raudo 2.25" en su k7zx, es una FSK con 2 bits por símbolo. Es decir que tenemos 4 símbolos distintos que codificar (00, 01, 10 y 11) a diferencia de la rutina de carga estándar donde teníamos sólo dos (0 y 1). El numerito que viene después indica la velocidad de carga. Viene a decir cúantas muestras hacen falta de media para codificar un bit (no un símbolo). A partir de este numerito podemos calcular la velocidad de carga haciendo una simple división. Así, a 44100 Hz tendremos 19600 bps, mientras que a 48000 Hz serían 21333 bps. Los valores reales, al igual que en la carga estándar aunque en menor medida, son ligeramente mejores. Esto se debe a que los ceros son más frecuentes que los unos (los 00s también) y al ser también los símbolos más cortos hacen que la media baje. O sea que normalmente una carga estándar puede ir a 1600/1700 bps y esta ultracarga por tanto entre 22000/23000 bps. Teniendo en cuenta ésto y la duración de los silencios y los tonos guía ya podemos calcular de forma aproximada cuánto va a tardar en cargar un juego.

Por poner un ejemplo, tenemos un juego con 7K de pantalla de carga y 30K de binario. Serían 4 bloques físicos (3 lógicos), y el cargador pongamos que ocupa 300 bytes.

Tonos guía: 5 segundos del primer bloque, y 2 segundos de cada uno de los otros tres suman 11.
Pausas: 1 segundo tras el primer bloque y 2 en cada uno de los otros 3 (descontando 2 del silencio final) nos dan un total de 5 segundos.
Datos: 300bytes * 8 bits-por-byte / 1600 bps hacen un total de 1.5 segundos para el cargador, haciendo las mismas cuentas en pantalla de carga (34.5s) y juego (150s) sale un total de 186 segundos.

En carga estándar tendríamos un juego de 202 segundos (3 minutos y 22 segundos).

Hagamos las mismas cuentas para una ultracarga, reduciendo pausas y tonos guía a la mitad aunque en realidad se reducen aún más:

Tonos guía: 5.5s
Pausas: 2.5s
Datos: 1.5s (cargador) + 2.4s (pantalla de carga) + 10.4s (juego)= 14.3

Total: 22.3s

La duración de la carga es más o menos 9 veces menor en la ultracarga. Si sólo cargáramos datos debería haber salido 21333/1500= 14.22. Por eso es muy importante optimizar también el tiempo de los silencios y tonos guías. En el ejemplo que estamos siguiendo en el tutorial partíamos de 3:25 y lo dejaremos en 9 segundos, vamos que la mejora es de casi 23. Ojo que en esto último hemos metido compresión de por medio, sólo quiero indicaros lo que se puede conseguir.

Ahora veamos el aspecto que tiene una señal en carga estándar. Son capturas del Audacity con leyendas sobreescritas.

Imagen

Como podemos observar, tenemos un tren de pulsos equiespaciados a la izquierda (sería el tono guía) seguido de dos pulsos cortos de sincronismo (sync) y después tenemos los datos. En este ejemplo vemos 2 bytes con sus correspondientes bits, donde podemos distinguir los ciclos del símbolo 0 (dos pulsos cortos) de los ciclos del 1 (dos pulsos largos). Sabemos que es un bloque de datos porque el primer byte (de flag) es $FF, y salvo éste byte y el último (checksum) todos los demás se escriben en memoria.

Ahora le toca a una señal con modulación "Shawings Raudo 2.25". He omitido el tono guía y el checksum (en esta modulación se codifican de forma diferente y no existe byte de flag), yéndonos directamente a la zona de datos:

Imagen

Vemos que los símbolos son de 2 bits (cada símbolo) y que cada byte se codifica con 4 símbolos. La duración de cada símbolo va desde 3 muestras (00) hasta 6 muestras (11) y al igual que en carga estándar cada símbolo está compuesto por 2 pulsos (pulso negativo+pulso positivo). En esta modulación en concreto el primer pulso es siempre más corto o igual que el segundo, esto es así porque la rama de código del primer pulso también es más corta, aunque hay otras modulaciones como CargandoLeches donde es justo al revés. Así las codificaciones exactas son: 00 (1+2=3), 01 (2+2=4), 10 (2+3=5) y 11 (3+3=6).

Ahora con estos 4 símbolos calculamos la media, sería: (3+4+5+6)/4 = 4.5 muestras. Es decir, cada símbolo (2 bits) dura de media 4.5 muestras, con lo que cada bit se codifica en una media de 2.25 muestras. Es por eso que la codificación se llama "Shawings Raudo 2.25". Existen otras variantes Shawings llamadas Slow en las que la distancia entre símbolos es de 2 muestras. Evidentemente esto es más lento pero a la vez es más inmune a errores de carga (decimos que la carga es más fiable). Por ejemplo la Shawings Slow más rápida es la que usa símbolos de 2,4,6 y 8 muestras respectivamente, también llamada "Shawings Slow 2.5". El conjunto de modulaciones que acepta CargandoLeches para "Shawings Raudo" es 1.75, 2.25, 2.75 y 3.25 (a 1.75 no funciona en máquinas reales) y para "Shawings Slow" es 2.5, 3, 3.5 y 4.

En nuestro ultracargador partimos del código "Shawings Raudo 2.25", al cual le haremos una serie de modificaciones para hacerlo más simple. El cambio más importante es el de la rutina muestreadora, usaremos una más lenta pero más sencilla de ubicar (algo parecido al método 2).

make.bat. Todo metido en el cargador, rutina en dirección fija

Como siempre, lo mejor es empezar haciendo algo lo más sencillo posible y que funcione, luego habrá tiempo de mejorarlo. No lo he dicho antes pero GenTape funciona con dos velocidades de muestreo (44100Hz y 48000Hz), y por defecto se trabaja a 44100. Y si queremos hacer las cosas bien tenemos que tener en cuenta ambas frecuencias a la hora de codificar el WAV, y que nuestra rutina cargadora se adapte a estos pequeños cambios (44100Hz serían muestras de 79 ciclos, a diferencia de los 73 a 48000Hz). También podemos trabajar sólo a una en concreto y mostrar error si se intenta usar la otra, aunque no es lo recomendable.

Veamos el archivo make.bat:

Código: Seleccionar todo

set _freq=44100

fcut manic.bin 200 -200 manic.cut
zx7b manic.scr manic.scr.zx7b
zx7b manic.cut manic.cut.zx7b
for %%A in (manic.scr.zx7b) do set _fileSize=%%~zA
echo  define  scrsize %_fileSize% >  define.asm
for %%A in (manic.cut.zx7b) do set _fileSize=%%~zA
echo  define  binsize %_fileSize% >> define.asm
set /a _sf48=%_freq%/48000
echo  define  sf48    %_sf48%     >> define.asm

SjAsmPlus loader.asm
FlagCheck header.bin 0
FlagCheck loader.bin

GenTape %_freq%                   manic.wav           ^
          turbo 2168   667   735                      ^
            600 1600  1500     0  header.bin.fck      ^
          turbo 2168   667   735                      ^
            600 1600  1500     0  loader.bin.fck      ^
   plug-ultra-3        100   500  manic.scr.zx7b      ^
   plug-ultra-3        100   500  manic.cut.zx7b
En la variable _freq decidimos cual es la frecuencia de muestreo a la que vamos a trabajar. Las 7 líneas siguientes ya las hemos visto antes, son para recortar, comprimir y escribir el tamaño de los comprimidos en define.asm. Luego tenemos otras 2 líneas adicionales que introducen una constante nueva sf48 en define.asm, que estará a "0" si trabajamos a 44100, o a "1" si lo hacemos a 48000 Hz. Esto es necesario porque en un momento dado necesitamos indicarle al ensamblador a qué frecuencia trabajamos y es más sencillo hacerlo con una variable booleana que con una numérica.

El siguiente bloque de 3 líneas también lo hemos visto antes, ensamblamos loader.asm (que genera header.bin y loader.bin) y calculamos los flags/checksums para usar bloques "turbo" en lugar de "data" (en los que podemos ajustar los tiempos a nuestro antojo).

Por último tenemos la llamada a GenTape. Como véis le hemos pasado la variable _freq para decirle a qué frecuencia de muestreo queremos que nos genere el WAV. En este caso generamos un WAV pero podríamos haber generado un TZX. Para ultracargas de este estilo recomiendo los WAVs, son más fiables. Los dos primeros bloques son tipo "turbo", donde tenemos carga estándar con parámetros ligeramente modificados. La novedad está en los 2 últimos bloques, donde se usa un tipo que no hemos visto antes llamado "plug-ultra-3".

Todo tipo que empieza por "plug-" lo que hace es invocar un plugin, que es un ejecutable externo cuyo nombre es lo que viene a continuación. Es decir por cada línea "plug-ultra-3" se hace una llamada al ejecutable "ultra.exe" con una serie de parámetros, algunos fijos y otros que introducimos en la propia línea. Como el número de parámetros puede variar para cada plugin, se lo indicamos con el "-3", así GenTape sabrá cuando acaba la línea (GenTape recibe todos los parámetros en una línea, los ^ no los recibe, son para mejorar la legibilidad). Los 3 parámetros que le enviamos son "100 500 manic.scr.zx7b" para la pantalla y "100 500 manic.cut.zx7b" para el juego.

Como curiosidad, escribamos "ultra" para invocar a "ultra.exe" en la línea de comandos:

Código: Seleccionar todo

ultra v0.03, an ultra load block generator by Antonio Villena, 31 May 2014

  ultra <srate> <channel_type> <ofile> <pilot_ms> <pause_ms> <ifile>

  <srate>         Sample rate, 44100 or 48000. Default is 44100
  <channel_type>  Possible values are: mono (default), stereo or stereoinv
  <ofile>         Output file, between TZX or WAV file
  <pilot_ms>      Duration of pilot in milliseconds
  <pause_ms>      Duration of pause after block in milliseconds
  <ifile>         Hexadecimal string or filename as data origin of that block
Tenemos esta ayuda en pantalla que nos lo explica todo. Es más, seríamos capaces de poder generar la ultracarga sin la herramienta GenTape con otras herramientas y a base de editar WAVs. Como véis el ejecutable tiene 6 parámetros, mientras que la llamada al plugin sólo 3 (en concreto los 3 últimos). Internamente GenTape rellena los 3 primeros parámetros con lo que le hayamos indicado, o lo que tome por defecto si no le indicamos nada (mono y 44100), y como fichero de salida un archivo temporal que luego concatenará con el fichero que esté generando, para luego borrarlo.

Pues bien, como podemos deducir de todo esto, en ambos bloques de ultracarga queremos un tono guía de 100 milisegundos y una pausa de 500 milisegundos después de cada bloque.

Recomiendo que le echéis un vistazo al código fuente de ultra.exe (ultra.c), está en C y es más o menos sencillo de asimilar. No sé si lo he dicho pero si desarrolláis vuestra propia modulación es conveniente poder generar la ultracarga tanto en formato TZX como en WAV. Para ello tendréis que conocer las especificaciones de dichos formatos, aunque siguiendo el código de ultra.c no debería ser muy difícil.

Ahora veamos el código Z80 del cargador, loader.asm:

Código: Seleccionar todo

        define  tr $ffff &
        include define.asm

; Bloque cabecera
        output  header.bin
        db      0               ; tipo: 0=cabecera, 1=array numérico
                                ; 2=array alfanumérico, 3=código máquina
        db      'ManicMiner'    ; Nombre del archivo (hasta 10 letras)
        block   11-$, 32        ; Relleno el resto con espacios
        dw      fin-ini         ; Longitud del bloque basic
        dw      10              ; Autoejecución en línea 10
        dw      fin-ini         ; Longitud del bloque basic

; Bloque datos (Basic con código máquina incrustado)
        output  loader.bin
        org     $8000-dzx7b+ini
ini     ld      sp, $8200-4
        di
        db      $de, $c0, $37, $0e, $8f, $39, $96
        ld      hl, $5ccb+dzx7b-ini
        ld      de, $8000
        ld      bc, fin-dzx7b
        ldir

        ld      hl, $8200
        ld      de, scrsize
        call    tr loader
        ld      hl, $8200+scrsize-1
        ld      de, $5aff
        call    tr dzx7b

        ld      hl, $8200-4
        ld      de, binsize
        call    tr loader
        ld      hl, $8200+binsize-4-1
        ld      de, $ffff
        call    tr dzx7b

        jp      $8400

dzx7b   include dzx7b_fast.asm
loader  include ldbytes.asm
fin
Como tenéis experiencia de lecciones anteriores no hace falta que explique lo que hace línea por línea. Tanto el descompresor (dzx7b_fast.asm) como la rutina cargadora (ldbytes.asm) van incluídos en el cargador (en el primer bloque, el bloque Basic) y por tanto son cargadas a velocidad estándar. En concreto el descompresor va desde $8000 hasta $8042 y la rutina cargadora desde $8043 hasta $8159. Si hacemos la suma de cargador+descompresor+rutina cargadora nos sale un total de 407 bytes. Puede parecer poco pero supone bastante tiempo de carga si lo hacemos a 1500 bps en lugar de a 21333.

Por otro lado no nos hemos preocupado de que la rutina cargadora se pueda reubicar, o sea funciona sólo si la colocamos en $8043 (o en cualquier dirección que acabe en $43). Estos pequeños inconvenientes los resolveremos en los proximos makeX.bat.

Por último voy a mostrar el contenido de ldbytes.asm:

Código: Seleccionar todo

ultra   exx                     ; salvo de, en caso de volver al cargador estandar
        ld      c, 0
ultra1  defb    $26
ultra2  jp      nz, $053f       ; return if at any time space is pressed.
ultra3  ld      b, 0
        call    $05ed           ; leo la duracion de un pulso (positivo o negativo)
        jr      nc, ultra2      ; si el pulso es muy largo retorno a bucle
        ld      a, b
        add     a, -16          ; si el contador esta entre 10 y 16 es el tono guia
        rr      h               ; de las ultracargas, si los ultimos 8 pulsos
        jr      z, ultra1
        add     a, 6            ; son de tono guia h debe valer ff
        jr      c, ultra3
        ld      a, $d8          ; a' tiene que valer esto para entrar en raudo
        ex      af, af'
        dec     h
        jr      nz, ultra1      ; si detecto sincronismo sin 8 pulsos de tono guia retorno a bucle
        call    $05ed           ; leo pulso negativo de sincronismo
        inc     h
ultra4  ld      b, 0            ; 16 bytes
        call    $05ed           ; esta rutina lee 2 pulsos e inicializa el contador de pulsos
        call    $05ed
        ld      a, b
        cp      12
        rl      h
        jr      nc, ultra4
        ld      a, h
        exx
        ld      c, a            ; guardo checksum en c'
        push    hl              ; pongo direccion de comienzo en pila
        exx
        pop     de              ; recupero en de la direccion de comienzo del bloque
        inc     c               ; pongo en flag z el signo del pulso
        ld      bc, $effe       ; este valor es el que necesita b para entrar en raudo
        ld      hl, leehi
        jr      z, ultra6
        ld      (lowh1+1), hl
ultra5  in      f, (c)
        jp      pe, ultra5
        jr      ultra8          ; salto a raudo segun el signo del pulso en flag z
ultra6  ld      (lowh0+1), hl
ultra7  in      f, (c)
        jp      po, ultra7
        add     hl, hl
ultra8  ld      h, table>>8
        jr      lowhi           ; salto a raudo

lowh0   call    leelo           ;17       61
        ex      af, af'         ;4
        ld      a, r            ;9
        ld      l, a            ;4
        ld      b, (hl)         ;7
lowhi   ld      a, $0d+3*sf48   ;7
        ld      r, a            ;9
        ex      af, af'         ;4

lowh1   call    leelo           ;17       65/65
        jr      nc, lowh2       ;7/12
        xor     b               ;4
        xor     $9c             ;7
        ld      (de), a         ;7
        inc     de              ;6
        ld      a, $dc          ;7
        jp      lowh0           ;10
lowh2   xor     b               ;4
        add     a, a            ;4
        add     a, a            ;4
        out     (c), b          ;12
        jr      lowh0           ;12

leehi   .14     defb    $ed, $70, $e8
        jr      ultra9

leelo   .14     defb    $ed, $70, $e0

ultra9  pop     hl
        exx                     ; ya se ha acabado la ultracarga (raudo)
        dec     de
        ld      b, e
        inc     b
        inc     d
ultraa  xor     (hl)
        inc     hl
        djnz    ultraa
        dec     d
        jr      nz, ultraa      ; con JP ahorro algunos ciclos
        xor     c
        ret     z               ; si no coincide el checksum salgo con carry desactivado
        ei
        rst     $08             ; error-1
        defb    $1a             ; error report: tape loading error

table   .15     defb    $ec
        .12     defb    $ed
        .12     defb    $ee
        .13     defb    $ef
Empiezo por lo más importante, la rutina muestreadora:

Código: Seleccionar todo

leehi   .14     defb    $ed, $70, $e8
        jr      ultra9

leelo   .14     defb    $ed, $70, $e0

ultra9  pop     hl
        exx
Está así de mal porque no he encontrado la forma de hacer macros de 2 líneas, pero en realidad sería equivalente a esto:

Código: Seleccionar todo

leehi   in      f, (c)
        ret     pe
        in      f, (c)
        ret     pe
        ... (14 veces en total)
        in      f, (c)
        ret     pe
        jr      ultra9

leelo   in      f, (c)
        ret     po
        in      f, (c)
        ret     po
        ... (14 veces en total)
        in      f, (c)
        ret     po

ultra9  pop     hl
        exx
Sería un bucle desenrollado que lee el puerto EAR cada 17 ciclos. Desgraciadamente al no ser múltiplo de 8 se ve mas afectada por la contención, y si nos pilla en mitad de una línea las lecturas de puerto tienden a 20 ciclos. Esto con una rutina de 16 ciclos no pasaba, si hay contención solo le afecta a la primera lectura (entre 0 y 6 ciclos de penalización), las demás se libran.

Otra ventaja de este método (lo llamaré método 3) es que es muy sencillo detectar el fin de la carga, como en ningún símbolo legal llegamos a hacer 14 lecturas (en uno de los pulsos) podemos indicar el fin de la carga con un símbolo especial más largo, y por tanto salimos por ultra9. El pop hl es para equilibrar la pila, ya que a leelo o leehi hemos entrado con un call.

Entendiendo esta rutina todo lo demás es coser y cantar. Desde ultra a ultra8 tenemos código de inicialización, que incluye detección de tono guía y lectura de byte de checksum. Lo que hay entre lowh0 y el final de lowh2 es el bucle principal, se estará ejecutando todo el rato que dure la ultracarga. Consta de 2 segmentos. Desde lowh0 hasta lowh1 leemos el primer pulso, contamos el número de lecturas a puerto extrayéndolo del registro R y miramos en una tabla a qué símbolo se corresponde tal número de lecturas (en el registro B). Finalmente inicializamos el registro R a un valor en el cual concuerden el número de las lecturas con el contenido de la tabla. En este caso dicho valor es $0d+3*sf48, lo que quiere decir que vale 13 a 44100Hz y 16 a 48000Hz. Al ser un valor fijo significa que la rutina sólo funciona en dicha ubicación: $8043.

Veamos ahora el segundo segmento del bucle principal, que va desde lowh1 hasta el último "jr lowh0". Este segmento se bifurca en 2 ramas: en la "no carry" introducimos el símbolo que acabamos de leer (está en los 2 bits menos significativos de B) en el registro A y luego rotamos dos bits a la izquierda, preparándolo para el siguiente símbolo. El último out (c), b muestra el borde, que será uno de 4 colores (de 4 a 7) dependiendo del símbolo que acabamos de leer. La otra rama, la rama "carry", ocurre cuando hemos leído los 4 símbolos del byte y procedemos a cargar el byte en memoria, incrementar el puntero DE e inicializar el registro A para el siguiente símbolo.

En total tenemos 61 ciclos del primer segmento y 65 en el segundo segmento (las 2 ramas están balanceadas), por esa razón procuramos que si los dos pulsos tienen distinta duración, que el segundo sea el más largo.

El resto del código (desde ultra9 hasta "defb $1a") lo que hace es comprobar que el checksum indicado al comienzo coincide con el del bloque que acabamos de leer y muestra error en caso de que no coincidan. Esto lo hacemos después, en diferido, porque nuestra rutina es muy crítica en tiempos. En realidad habría sido más sencillo llevar la cuenta del checksum en un registro (en la rutina estándar es el registro H) y xorearlo con cada byte que acabáramos de leer.

Ya sólo me queda por explicar la tabla. Los valores son $ec, $ed, $ee y $ef en lugar de algo más lógico como $00, $01, $02 y $03. Lo hacemos así porque este mismo byte es el que escribimos en el puerto $FE (por eso cambia el borde). Las restricciones vienen impuestas porque el bit 3 debe valer 0 y el bit 4, 1 para que las posteriores lecturas se hagan correctamente, digamos que el valor debe ser así %ABC01DXX. Los valores XX no dependen de nosotros sino del símbolo que acabamos de leer. Los otros (A,B,C y D) pueden valer lo que queramos, pero si lo ponemos a 1 mejor, así evitamos conflictos con el teclado.

Otra restricción que tiene la tabla es que no debe pasar el límite de media página (128 bytes) ya que el siguiente byte volvería al principio. Un ejemplo, tenemos una tabla que empieza en $8070 y acaba en $81A3. Muy mal, porque el registro R pasaría de $7F a $00 (el bit alto no cambia) y por tanto después de $807F se leería $8000. Esto segmentaría la tabla dejando un hueco inútil. En nuestro caso no hay problema porque estamos en una dirección fija, y la tabla se encuentra entre $8126 y $8159. Por último decir que sólo 1/3 de los valores de la tabla son los que realmente se usan porque en cada lectura incrementamos R en 3, y 3 es un número muy chungo de tratar para un procesador que no puede multiplicar ni dividir directamente, así que preferimos desperdiciar unos pocos bytes (en concreto 34 de los 52) y listo.

Bueno ya he explicado lo más gordo. A partir de ahora todo será mejorar la rutina que acabamos de crear, ya sea para hacerla más flexible o para optimizar el tiempo de carga. Ah bueno, se me olvidaba, hay una parte del código que es automodificable, en concreto de los dos "call leelo" uno de ellos se convierte en "call leehi" en función del nivel con el que trabajemos. A priori toda señal de audio es neutral, vamos que da igual el valor que le pongas a una muestra porque a la salida la puedes tener invertida o no (dependiendo de si el número de veces que la circuitería ha invertido la polaridad es par o impar). Así que nosotros debemos de tratar los dos casos posibles. Como todo ciclo está compuesto por dos pulsos, tendremos un primer caso en que el primer pulso tengamos nivel negativo (leemos un 0 del puerto EAR) y el segundo positivo (leemos 1), y un segundo caso con el comportamiento contrario (primero positivo y segundo negativo). Esto lo detectamos en el primer pulso de sincronismo. Lo mejor es generar un WAV con la señal invertida a uno dado y comprobar que los dos WAVs carguen correctamente.
Avatar de Usuario
antoniovillena
Demonio segundo orden
Demonio segundo orden
Mensajes: 1596
Registrado: 02 Abr 2013, 19:06
Been thanked: 1 time

Re: Tutorial de optimización de cintas y ultracargas

Mensaje por antoniovillena »

Lección 7 (continuación)

make2.bat. Sacamos el descompresor del cargador, rutina en dirección fija

Tenemos 3 bloques lógicos, el primero (2 bloques físicos) carga a 1500 bps y los otros dos a 21333 bps. ¿Cómo aceleramos la carga? Pues moviendo todo lo que podamos del primer bloque al segundo. ¿Y qué es lo único que no necesitamos del primer bloque en ese momento? Pues el descompresor, ya que lo necesitaremos por primera vez tras la carga del segundo bloque, para descomprimir la pantalla de carga.

Hay 2 formas de hacer esto:
  • Ensamblamos sólo el descompresor en un archivo aparte (dzx7b_81b9.asm) como hicimos en el make3.bat de la lección anterior, y luego concatenamos el archivo resultante (usando "copy /b") con la pantalla comprimida para el segundo bloque.
  • Creamos un tercer archivo binario de salida en loader2.asm, concretamente dzx7screen2.bin, que genere exactamente el mismo contenido que el punto anterior.
Ninguna solución es buena, pero la segunda me parece menos mala. Yo prefiero la convención de que cada archivo .asm genere un único archivo .bin con el mismo nombre, aunque en este caso merece la pena hacer una excepción, al menos desde mi punto de vista. Sería tan fácil como añadir estas líneas al final del archivo loader2.asm:

Código: Seleccionar todo

; Bloque datos (descompresor y pantalla de carga)
        output  dzx7screen2.bin
        org     $8200-67-4
dzx7b   include dzx7b_fast.asm
        defb    0, 0, 0, 0
        incbin  manic.scr.zx7b
scrend
Los 4 bytes que dejo a cero son el hueco que necesitamos (safety offset) para descomprimir ya que nuestro descompresor es hacia atrás y no queremos mover datos con LDIR después. El resto del código en loader2.asm apenas ha cambiado, hemos quitado el include dzx7b_fast.asm y movido el destino del primer bloque desde $8000 a $8043, de lo contrario no funcionaría el ultracargador, recordemos que es de ubicación fija.

El make2.bat se ha simplificado, estas 2 líneas ya no las necesitamos:

Código: Seleccionar todo

for %%A in (manic.scr.zx7b) do set _fileSize=%%~zA
echo  define  scrsize %_fileSize% >  define.asm
Puesto que la longitud del comprimido se puede calcular fácilmente haciendo una resta entre etiquetas.

Como veis este cambio no tiene mucha chicha. Hemos adelgazado la carga estándar en 67 bytes, que en tiempo serían 67*8/1500= 357 ms. La mejora total al moverlo sería esta cifra menos lo que hemos engordado el segundo bloque 67*8/21333= 25 ms. La diferencia es de 332 ms, es decir, un tercio de segundo. Que sí, que es poco, pero lo importante es que vamos en buen camino, prosigamos con más optimizaciones.

make3.bat. Hacemos el ultracargador reubicable

El siguiente cambio es quizás el más interesante. Queremos reutilizar el mismo ultracargador en otros juegos y muy probablemente necesitemos ubicarlo en otra dirección, por ejemplo la parte más alta de RAM. El código tal cual lo tenemos no nos sirve.

Por supuesto el make3.bat y el loader3.asm es casi idéntico a los anteriores, por eso no los muestro. Lo único que en loader3.asm voy a ubicar el ultracargador en $8000 en lugar de en $8043, así de paso comprobamos que es reubicable.

Muestro aquí el contenido del nuevo ldbytes3.asm:

Código: Seleccionar todo

    IF  ($ & $7f) < $4d
        define  a4d     1
table   .15     defb    $ec
        .12     defb    $ed
        .12     defb    $ee
        .13     defb    $ef

leelo   .14     defb    $ed, $70, $e0

ultra9  pop     hl
        exx                     ; ya se ha acabado la ultracarga (raudo)
        dec     de
        ld      b, e
        inc     b
        inc     d
ultraa  xor     (hl)
        inc     hl
        djnz    ultraa
        dec     d
        jr      nz, ultraa      ; con JP ahorro algunos ciclos
        xor     c
        ret     z               ; si no coincide el checksum salgo con carry desactivado
        ei
        rst     $08             ; error-1
        defb    $1a             ; error report: tape loading error
    ELSE
        define  a4d     0
leelo   .14     defb    $ed, $70, $e0

ultra9  pop     hl
        exx                     ; ya se ha acabado la ultracarga (raudo)
        dec     de
        ld      b, e
        inc     b
        inc     d
ultraa  xor     (hl)
        inc     hl
        djnz    ultraa
        dec     d
        jr      nz, ultraa      ; con JP ahorro algunos ciclos
        xor     c
        ret     z               ; si no coincide el checksum salgo con carry desactivado
        ei
        rst     $08             ; error-1
        defb    $1a             ; error report: tape loading error

table   .15     defb    $ec
        .12     defb    $ed
        .12     defb    $ee
        .13     defb    $ef
    ENDIF

leehi   .14     defb    $ed, $70, $e8
        jr      ultra9

ultra   exx                     ; salvo de, en caso de volver al cargador estandar...
        ld      c, 0
ultra1  defb    $26
ultra2  jp      nz, $053f       ; return if at any time space is pressed.
ultra3  ld      b, 0
        call    $05ed           ; leo la duracion de un pulso (positivo o negativo)
        jr      nc, ultra2      ; si el pulso es muy largo retorno a bucle
        ld      a, b
        add     a, -16          ; si el contador esta entre 10 y 16 es el tono guia
        rr      h               ; de las ultracargas, si los ultimos 8 pulsos
        jr      z, ultra1
        add     a, 6            ; son de tono guia h debe valer ff
        jr      c, ultra3
        ld      a, $d8          ; a' tiene que valer esto para entrar en raudo
        ex      af, af'
        dec     h
        jr      nz, ultra1      ; si detecto sincronismo sin 8 pulsos de tono guia retorno a bucle
        call    $05ed           ; leo pulso negativo de sincronismo
        inc     h
ultra4  ld      b, 0            ; 16 bytes
        call    $05ed           ; esta rutina lee 2 pulsos e inicializa el contador de pulsos
        call    $05ed
        ld      a, b
        cp      12
        rl      h
        jr      nc, ultra4
        ld      a, h
        exx
        ld      c, a            ; guardo checksum en c'
        push    hl              ; pongo direccion de comienzo en pila
        exx
        pop     de              ; recupero en de la direccion de comienzo del bloque
        inc     c               ; pongo en flag z el signo del pulso
        ld      bc, $effe       ; este valor es el que necesita b para entrar en raudo
        ld      hl, leehi
        jr      z, ultra6
        ld      (lowh1+1), hl
ultra5  in      f, (c)
        jp      pe, ultra5
        jr      ultra8          ; salto a raudo segun el signo del pulso en flag z
ultra6  ld      (lowh0+1), hl
ultra7  in      f, (c)
        jp      po, ultra7
        add     hl, hl
ultra8  ld      h, table>>8
        jr      lowhi           ; salto a raudo

lowh0   call    leelo           ;17       61
        ex      af, af'         ;4
        ld      a, r            ;9
        ld      l, a            ;4
        ld      b, (hl)         ;7
lowhi   ld      a, $-$b7-$36*a4d & $80|$67+3*sf48+table&$7f
        ld      r, a            ;9
        ex      af, af'         ;4

lowh1   call    leelo           ;17       65/65
        jr      nc, lowh2       ;7/12
        xor     b               ;4
        xor     $9c             ;7
        ld      (de), a         ;7
        inc     de              ;6
        ld      a, $dc          ;7
        jp      lowh0           ;10
lowh2   xor     b               ;4
        add     a, a            ;4
        add     a, a            ;4
        out     (c), b          ;12
        jr      lowh0           ;12
Se trata del mismo código que antes pero cambiando el orden de las rutinas, tablas... y con una directiva de ensamblado condicional. Veamos cómo funciona. Lo que hay dentro de los dos casos en el IF es lo mismo pero en distinto orden. En el primer caso tenemos table/leelo/ultra9 y en el segundo tenemos leelo/ultra9/table. Lo que hacemos es usar una ordenación u otra dependiendo del rango de ($ & $7f). $ es una variable (o directiva o como quieras llamarlo) que nuestro ensamblador reemplazará por la dirección que toca ser ensamblada justo en el punto donde se invoca. Como está al comienzo de ldbytes3.asm y hemos dicho que queríamos ubicar el ultracargador en la dirección $8000, pues esta variable $ se sustituye por $8000. Y como $8000 & $007f es igual a $00, y $00 es menor de $4d pues se ensamblaría la primera parte del IF.

¿Por qué hacemos todo este lío? Pues por la sencilla razón de que no podemos partir la tabla en dos, los 9 bits más significativos de la dirección de cada elemento de la tabla deben permanecer inalterables. Si nos ubicamos en $8000, la tabla comienza en $8000 y acaba en $8033. Podemos ir desplándonos byte a byte $8001/$8034, $8002/$8035 hasta llegar a un punto en el que no podemos desplazar más ya que se partiría la tabla. Ese punto es $804d, si la tabla comienza aquí, acabaría hipotéticamente en $8080, pero es que el registro R nunca valdrá $80, pasará de $7f a $00, partiendo el ultimo byte en $8000. Tendríamos un segmento de 1 byte en $8000 y otro de 51 bytes en $804d-$807f. Esto no es aceptable.

Por eso al intentar ensamblar en $804d se toma la otra rama del IF. En la otra rama la tabla no está al comienzo, sino desplazada por leelo y ultra9 (60 bytes) y por tanto la tabla comenzaría en $8089 (acaba en $80bc). Para el resto del rango, $4d-$7f, no hay problemas de que se segmente la tabla, por tanto tan sólo necesitamos 2 casos en nuestro ensamblado condicional. Si por ejemplo queremos ubicar nuestro ultracargador en $807f (último byte del rango) vemos cómo la tabla comenzaría en $80bb y acabaría en $80ee, sin llegar al límite $8100 que es donde estaría el problema.

¿Con esto hemos acabado? Casi, todavía me falta por explicar una instrucción:

Código: Seleccionar todo

lowhi   ld      a, $-$b7-$36*a4d & $80|$67+3*sf48+table&$7f
Este chorizaco de fórmula para inicializar A se debe a que hay que tener en cuenta distintos casos. Anteriormente sólo contábamos con sf48 porque table tenía una dirección fija y no había que lidiar con ensamblado condicional. Seguramente se pueda simplificar un poco la fórmula pero no quiero tocarla porque funciona en todas las posibles ubicaciones. Evidentemente estas cosas no salen así porque sí, vas depurando, añadiendo cosas y comprobando que funciona en todos los casos. Si te pones a pensarlo igual sale algo más sencillo, pero, ¿para qué perder el tiempo pensando si con el método de ensayo/error acabas antes?

make4.bat. Comprimiendo el ultracargador y moviendo al bloque 2 algunas cosillas más

El ultracargador actualmente ocupa 279 bytes, que junto a los 61 bytes del código restante hacen 340 bytes de carga estándar. El objetivo es comprimir esos 279 para que se queden en menos y mover parte de los 61 bytes restantes al bloque 2. ¿Cómo comprimimos algo que de por sí es muy pequeño?

Pues a mano, con un descompresor escrito ad-hoc, sólo nos preocupamos por comprimir las partes más redundantes, que son los 2 bloques de IN/RET PE e IN/RET PO, y por supuesto la tabla. El resto lo copiamos sin comprimir, al ser tan poco código cualquier intento de hacerlo sería contraproducente (el descompresor ocuparía más que los bytes que se compriman).

Veamos cómo quedaría el código.

Código: Seleccionar todo

ini     ld      de, location+fin-ultrac+154-1
        di
        db      $de, $c0, $37, $0e, $8f, $39, $96
        ld      hl, fin-1    +$5ccb-ini
        ld      bc, fin-ultrac
        ld      a, $e8
ini2    lddr
ini3    ex      de, hl
        ld      bc, $0e12
ini4    ld      (hl), a
        dec     hl
        ld      (hl), $70
        dec     hl
        ld      (hl), $ed
        dec     hl
        djnz    ini4
        ex      de, hl
      IF  (location & $7f) < $4d
        define  a4d     1
        define  leelo   ultrac-102
        define  table   ultrac-154
        xor     $08
        jp      po, ini2    +$5ccb-ini
        ld      h, d
        ld      l, e
        dec     de
        ld      (hl), $ef
ini5    ld      c, $0d
ini6    lddr
        dec     (hl)
        add     a, a
        jr      c, ini5
        jp      ini7
      ELSE
        define  a4d     0
        define  leelo   ultrac-154
        define  table   ultrac-94
        xor     $07
        jp      pe, ini7
ini5    ld      b, 13
ini6    ld      (de), a
        dec     e
        djnz    ini6
        dec     a
        cp      $eb
        jr      nz, ini5
        ld      a, $e0
        jr      ini2
      ENDIF
Como veis, tampoco es para tanto, todo es ponerse. Lo único a destacar aquí es que las dos ramas condicionales las he optimizado por separado, y el descompresor de tabla resultante es bastante distinto (uno rellena la tabla con LDIR y el otro con LD (DE),A y DJNZ) pese a que ambos hacen lo mismo. Haciendo cuentas el descompresor ocupa 56 bytes y el stream (realmente está sin comprimir, sólo lo movemos) 143 bytes, dándonos un total de 199 bytes.

Por otro lado tenemos las cadenas de llamadas al ultracargador/compresor. En loader3.asm tenía este aspecto:

Código: Seleccionar todo

        ld      hl, dzx7b
        ld      de, scrend-dzx7b
        call    tr ultra
;.................................
        ld      hl, scrend-1
        ld      de, $5aff
        call    tr dzx7b

        ld      hl, $8200-4
        ld      de, binsize
        call    tr ultra
        ld      hl, $8200+binsize-4-1
        ld      de, $ffff
        call    tr dzx7b

        jp      $8400
¿Realmente necesitamos tener todo esto en el primer bloque? Pues no, para cargar el primer bloque sólo hacen falta las 3 primeras instrucciones (hasta la línea de puntos). El resto lo podemos cargar posteriormente. ¿Y esto cómo se hace? Ubicando las 3 primeras instrucciones al final, justo antes de donde vamos a cargar el siguiente bloque. El código que hay debajo del "call tr ultra" se cargará en la propia llamada. Veamos el código:

Código: Seleccionar todo

        include ldbytes4.asm
ini7    ld      hl, fin
        ld      de, dzxend-fin
        call    tr ultra
fin

; Bloque datos (descompresor y pantalla de carga)
        output  dzx7screen4.bin
        org     location+154-ultrac+fin
        ld      hl, dzxend-1
        ld      de, $5aff
        call    tr dzx7b

        ld      hl, $8200-4
        ld      sp, hl
        ld      de, binsize
        call    tr ultra
        ld      hl, $8200+binsize-4-1
        ld      de, $ffff
        call    tr dzx7b

        jp      $8400

dzx7b   include dzx7b_fast.asm
        incbin  manic.scr.zx7b
dzxend
Como véis, hemos movido 31 bytes del bloque 1 (carga estándar) al bloque 2 (ultracarga). Tras este pequeño fragmento de código, el bloque 2 contiene el descompresor y la pantalla de carga comprimida. No muestro el make4.bat porque es idéntico al make3.bat. Al ldbytes4.asm le podéis echar un vistazo, es el mismo código qu ldbytes3.asm pero sin las partes comprimidas table/leelo/leehi y sin ensamblado condicional.

Ya sólo me falta hacer cuentas de lo que hemos ganado. Antes teníamos 279+61= 340 bytes de carga estándar. Ahora tenemos 299+9= 208 bytes (un byte más en la otra rama condicional), con lo que la mejora total es de 132 bytes. Por otro lado el siguiente bloque (archivo dzx7screen3/4.bin) ha pasado de 358 bytes a 385, engordando 27 bytes.

La mejora de tiempo sería de: 132*8/1500-27*8/21333= 0.694s. Unas 7 décimas de segundo, no está nada mal.

make5.bat. Reducciones drásticas finales.

Voy a acabar la lección reduciendo aún más el tiempo de carga, de 9.67s a 8.61s. Por dos frentes, recortando al máximo las pausas (silencios) y los tonos guía y dándole una última vuelta de tuerca a la cantida de bytes de carga estándar.

El make5.bat tiene este aspecto:

Código: Seleccionar todo

freverse  fast5.bin fast5.bin.rev

GenTape %_freq%                   manic5.wav          ^
          turbo 2168   667   735                      ^
            600 1600  1500     0  header.bin.fck      ^
          turbo 2168   667   735                      ^
            600 1600  1500     0  loader5.bin.fck     ^
   plug-ultra-3         -1     0  fast5.bin.rev       ^
   plug-ultra-3         50     0  dzx7screen5.bin     ^
   plug-ultra-3        100     0  manic.cut.zx7b
Hay una llamada a freverse de un bloque nuevo (fast5.bin.rev) que parece ser que lo transforma de alguna manera (ya lo explicaremos) y en la llamada a GenTape podemos ver ese nuevo bloque de ultracarga. Las reducciones en los bloques existentes son quitar los silencios de 500ms y acortar el tono guía del primer bloque ultracarga de 100 a 50ms. Esto daría una mejora de 1050ms, pero como no estamos contabilizando el silencio del final, nos quedamos en 550ms.

Hacemos un "dir loader5.bin" y vemos que ocupa 71 bytes. ¿Cómorrrr? ¿Qué hemos pasado de 208 a 71 bytes de carga estándar? ¿Dónde está el truco?

El truco está en introducir un bloque ultracargador más. No será tan rápido como el anterior (21333 bps) pero será bastante más rápido que la carga estándar. En concreto vamos a usar "Shawings Slow 3.5", que tiene una velocidad de 13714 bps. La idea es un poco retorcida: cargar un cargador grande y rápido usando otro cargador más lento y pequeño (pero bastante más rápido que la carga estándar).

Tendremos en loader5.bin un cargador "Shawings Slow 3.5" lo más sencillo y optimizado posible, en concreto se ha quedado en 71 bytes (es una mejora de una antigua versión de 74 bytes que usé en CargandoLeches). Luego tendremos fast5.bin, que es el código de nuestro cargador de antes, es decir hemos movido aquí lo que antes estaba en el bloque anterior de carga estándar. La reducción de 208 a 197 bytes se debe exclusivamente a que hemos eliminado el snippet Basic de Paolo Ferraris. Y todo lo demás está igual que antes.

No me voy a poner a explicar el código de este ultracargador, al estar tan optimizado es bantante críptico para leer. De todas formas dejo aquí el código, pero aviso que sin un depurador y altas dosis de paciencia es difícil de entender:

Código: Seleccionar todo

        org     $8edd+load-load2-fast6+fast
load    ld      de, load1-167
        di
        db      $d6, $c0, $31, $0e, $8f, $39, $96
        ld      h, b
        ld      bc, $e2
        ld      a, c
        ldir
        jp      $8edd+load1-load2-fast6+fast
load1   dec     c
        dec     c
load2   ld      h, b
        ld      (load8), a
        xor     8
        ld      (load4), a
        ld      (loada), a
load3   inc     h
        in      f, (c)
load4   db      0
        dw      load3
        add     hl, hl
        jr      nc, load2
        ld      de, $000d+sf48*2
load5   add     hl, hl          ; 11  11
        jr      c, load6        ;  7  12
        push    hl              ; 11
        inc     sp              ;  6
        ld      h, c            ;  4
load6   ld      l, $c0          ;  7   7
load7   add     hl, de
        in      f, (c)
load8   db      0
        defw    load7
load9   add     hl, de
        in      f, (c)
loada   db      0
        defw    load9
        dec     h               ;  4   4
        add     hl, hl          ; 11  11
        jr      c, load5        ; 12  12
loadb                           ; 73  57
A modo de apunte, este cargador va rellenando la memoria hacia atrás (usa la pila para rellenar), por esa razón necesito una utilidad que me invierta el fichero (freverse). Por otro lado no me he querido complicar mucho haciendo otro generador de ultracargas (ultra.exe) distinto que me cree el bloque "Shawings Slow 3.5". Lo que hago es parchear el mismo de tal forma que si recibe un -1 en el parámetro "duración del tono guía" cambiamos la tabla de símbolos, generando "Shawings Slow 3.5" en lugar de "Shawings Raudo 2.25".

Ya sólo nos falta hacer las cuentas. La reducción de carga estándar es de 138 bytes (208-70). Por otro lado ahora cargamos 197 bytes a 13714 bps que antes no estaban ahí. Entonces tenemos: 138*8/1500-197*8/13714= 736ms-115ms= 621ms.

Con esto y un bizcocho, nos vemos en la lección 8. Por cierto, será la última lección de este tutorial y trataré el tema de la desprotección de juegos.

Pincha aquí para bajar el archivo de la lección
Avatar de Usuario
Mikes España - Com. Valenciana
Moderador
Moderador
Mensajes: 3388
Registrado: 08 Nov 2013, 14:15
Ubicación: Benaguasil (València)
Has thanked: 2 times
Been thanked: 140 times

Re: Tutorial de optimización de cintas y ultracargas

Mensaje por Mikes »

Muy interesante, como todo el 'curso' ;)
"Mikes solo hay uno"
Mi 'Paraeta'
Responder

Volver a “Cursos y Tutoriales”