A Saga de programar um jogo no ZX Spectrum continua.
Como sabem, resolvi reeditar o antigo jogo Treinador de Futebol, perpetuado no World of Spectrum e editado pela Softimar, abreviatura de software Tiago e Mário (o meu primo e eu), em 1989. Jogo. Aliás que teve direito a referência recente no livro jovens programadores Portugueses.
A minha ambição levou-me a melhorias enormes face ao jogo original, mas rapidamente, e apesar de toda uma optimização de código que não existiu no jogo original, atingi os limites da máquina. E ao programar num Spectrum 128K pela facilidade de digitação (a digitação é feita letra a letra como nas máquinas modernas, e não com combinações de teclas como no 48K), sem me aperceber rapidamente alcancei os 64 KB de RAM, eliminando assim a possibilidade de suporte ao modelo 48K.
Como referi na primeira parte deste artigo, não é fácil nos dias de hoje programar com tão pouca RAM, tendo de se ter o cuidado com cada byte usado. Mas o certo é que acredito que em 1989 não nos apercebemos do quão perto o treinador de futebol ficou no limite de esgotar os recursos do 48Kb.
A realidade foi que, perante as melhorias, precisei de 64Kb, mas infelizmente atingi o limite da RAM, antes de acabar o jogo.
Dado que o que falta é muito pouco decidi explorar formas de obter mais memória. A primeira foi otimizar o código ao extremo, o que me deu a possibilidade de acabar o mercado de transferências que estava em falta. Mas os finais de época, as descidas e subidas de divisão, e o ecrã de vitória do campeonato, ficaram de fora. Precisava de mais RAM.
Ora dado que o Spectrum 48k estava excluído, porque não usar toda a RAM do 128K?
Infelizmente a coisa não é assim tão simples. O Z80 apenas endereça no máximo 64KB, pelo que aceder a mais RAM é problemático e requer paginação de memória. Uma situação complexa e avançada que é um problema de ser feita com o Basic.
Mas mesmo assim, tentei. E consegui! Mas infelizmente o método não servia. Apesar de só ter copiado para a RAM estendida um bloco de 7 KB recuperar o mesmo para a RAM vídeo era um processo de mais de 1 minuto.
Basicamente tinha de aceder à RAM adicional, ler todos os bytes um a um, e copiar de volta. Um ciclo For Next que no Basic é lento. Super lento.
Apesar de tudo consegui aumentar a capacidade de leitura para 256 bytes de cada vez, reduzindo o tempo de leitura de forma significativa, mas mesmo assim… demasiadamente lento.
A solução que me restava era só uma. Assembler.
Ora o Assembler, vocês já ouviram falar dele. É o código máquina, a linguagem de mais baixo nível que existe. E também a mais rápida que existe. Uma linguagem sobre a qual os meus conhecimentos são muito parcos.
Mas entre parcos e zero ainda vai uma grande diferença, e eu sabia que neste caso apenas precisava de dominar uma pequena parte do assembler: A leitura e cópia de dados. E nesse sentido, dediquei-me a estudar o mesmo, e após alguns dias tinha conseguido copiar uma imagem de uma RAM para a outra. O tempo? Bem, varia dependendo do banco, pois alguns são mais lentos que outros, mas mesmo assim, o caso pior, é uma ridicularia: 41 ms.
O problema com a RAM parecia ultrapassado.
Mas infelizmente não, pois eu precisava de meter na memória extra mais do que um bloco. A ideia era meter os dados para um menu, a criação do estádio onde vemos o jogo, e o ecra de celebração de vitória no campeonato. Um total de 28 KB que não seriam um problema para os restantes 64Kb. Ou seriam?
O certo é que o que funcionava com uma imagem… Não funcionava com as restantes. E descobrir os motivos, foi um berbicacho. Até porque estava perante vários problemas e não apenas um, o que demorou a perceber.
O que eu descobri foi que a paginação de memória não me troca dos 64KB originais para os outros. Dado que o Spectrum está no limite, uma mudança dessas implicava perder os valores todos das variáveis, e mesmo parte do código presente na RAM.
O que o Spectrum permite, para resolver isso, é abrir uma ranhurazinha na “parede da sala” de 64KB, para se ver a “sala do lado”, e passar dados por ela.
Mas ranhurinha ou não, com o uso de assembler, o problema estava resolvido pois a transferência era rápida, e 41ms era aceitável. Agora porque motivo não conseguia meter mais do que um bloco de dados?
O problema é que os restantes 64KB não existem. Se nos primeiros 64KB olhamos para eles como um todo pois tudo é invisível ao utilizador, os restantes 64 Kb são na realidade 4x16KB, pelo que não basta abrir a ranhura na parede. É preciso definir onde a fazemos para que se veja o compartimento que se pretende aceder.
Ou seja, a paginação é mais complexa ainda do que inicialmente parecia pois não se acede a mais 64 KB, mas sim a 4x 16 Kb.
Mas essa parte eu também acabei por conseguir superar, e consegui definir qual dos bancos de ram de 16 KB quero ver.
Mas o Spectrum com 128KB tem 8 bancos de 16 KB. A dúvida agora era saber quais são os que já estão usados e os que estão livres.
Poderiam ser de se esperar que a coisa fosse linear, mas não é. E como linear era que o Basic usasse os 4 primeiros e os restantes 4 estivessem livres (gênero, banco 0, 1, 2 e 3 usados, e 4, 5, 6 e 7 livres). Mas não! E descobrir em quais eu podia escrever sem causar danos ao meu programa foi um problema. Mas depois de descobrir, passei a usar dois bancos adicionais, o 1 e o 6. Dado que cada um tem 16 KB, e cada bloco de dados ocupa perto de 7KB, cada um pode conter dois blocos, e precisando de 4 deles, esta memória chegaria.
Mas depois tive outro problema. Dado que os bancos não são sequenciais, quais os endereços de memória de cada um?
E esta parte ainda hoje me mata. Porque há duas formas de lhes aceder, sendo que basta um pequeno erro de código, e ambas misturam-se, com resultados desagradáveis.
A primeira forma é usando os endereços reais. Basicamente os primeiros 64KB usam os endereços do zero até ao 65535, e os restantes bancos vão usar do 65536 até ao 131070. A questão aqui é que banco contém que parte desses endereços?
Ora isso é possível saber-se, mas para usar endereços reais eu preciso que o Z80 lhes aceda, e como já referi, isso obriga a largar parte da RAM base, com consequências desagradáveis para a estabilidade do programa (na maior parte dos testes, parte do meu código que estava na RAM do Basic, ficou corrompido ou desapareceu). A alternativa era usar endereços lógicos.
O que isso quer dizer? Basicamente tratar cada um dos bancos como se fosse único, com endereços iguais em todos. E isso trabalha bem melhor com a janelinha de abertura pois não causa problemas com a ram base.
Ora os endereços lógicos funcionam muito bem, mas tem um inconveniente. Algo que me demorou duas semanas a perceber. A questão é que quando eu acedo a um banco de memória, mesmo que mude para outro, o anterior mantem-se acessível, algo que eu não sabia, e que as IAs que eu consultava para auxilio me garantiam que não acontecia. Ora o que acontecia é que eu carregava dados para o banco 1, por exemplo nos endereços 40000 e 46912. Tudo corria bem, e elas estavam lá. Mas quando copiava para o banco 6, para os mesmos endereços, a janelinha para o banco 1 mantinha-se aberta, e o comando ia também para o banco 1, copiando os dados para ambos, e escrevendo por cima do que já existia no banco 1. O resultado é que só obtinha metade dos dados, e eu não estava a compreender porque. A resposta é que o que estava nos dois bancos era igual pois eu estava a escrever por cima.
Infelizmente, dado que o que pretendo passar para cada banco são dois blocos de dados que ocupam 7 KB cada um (total de 14 KB), e os bancos são de 16 KB cada um, eu enchia basicamente os bancos, pelo que não tinha possibilidade de usar endereços que não se sobrepusessem. E ao manterem-se os dois bancos ativos, a copia para o segundo banco, escreve por cima do primeiro.
Perceber a causa do problema foi um berbicacho. As IAs são uma vergonha. O Deepseek não percebe a ponta de um chavelho de ZX Spectrum, e insiste comigo que o meu código está todo mal e que não funciona. Para lhe explicar as coisas perco horas, sendo que ele acaba por reconhecer que estou correto, mas passado um bocado, volta à carga insistindo que está tudo mal.
O Gemini é sem dúvida o que mais entende de Spectrum, mas é impressionante como ele está tão atrás dos outros, cometendo sucessivamente erros básicos.
O ChatGPT é o mais avançado, mas insiste nos erros e também os comete, não tendo um conhecimento da arquitetura do Spectrum ao nível do Gemini.
O que me tem valido são duas janelas, uma com o Chat, outra com o Gemini, onde os confronto com as afirmações um do outro, até chegarem a um consenso. Mas mesmo assim há muita ignorâncias nestas IAs que falam como se tivessem certeza do que dizem.
Como auxiliares para código são os dois miseráveis, mas para me explicarem a arquitetura do Spectrum, tem sido uma ajuda preciosa, apesar de os ter de confrontar regularmente com as incongruências. Mas mesmo elas não foram capazes de me dizer o que estava a acontecer. E apenas quando eu formulei de forma insistente a hipótese de estar a escrever em simultâneo para os dois bancos é que elas reconheceram a situação. Apesar de andarem à duas semanas a negaram que isso pudesse estar a acontecer.
A realidade é que, devido a isto, até ao momento ainda não consegui um código funcional que me recupere 4 blocos de dados. Recorri a fóruns de programação do Spectrum, mas em vez de ajuda tudo o que me disseram foi que me meti num caminho que poucos conseguiram trilhar com sucesso.
E infelizmente, devido a isso decidi que vou ter de dar uma pausa ao jogo. Afastar a cabeça por uns dias a ver se vem ideias frescas. Porque estou à beira de conseguir o que pretendo, mas devo estar a cair em algum erro básico que me está a entupir e entretanto estou perto de um esgotamento.
Acréscimo de ultima hora.
Basicamente o que aconteceu foi que desisti do jogo. Estava de tal forma cansado que resolvi mandar tudo às urtigas.
O facto é que perante isso, sem stresses resolvi meter-se a ler sobre a arquitetura interna do Zx Spectrum. Basicamente deixar de acreditar em IAs e ir eu mesmo à busca da informação.
O resultado foi que ao fim de 10 minutos de leitura, estava entusiasmado com o que lia e voltei a programar. E à primeira tentativa com um novo método, estava com sucesso. Acedi a três bancos, guardei 6 blocos de código, e retornei os mesmos com sucesso.
Por outras palavras, o jogo não está morto, e o problema resolvido tendo agora acesso a toda a RAM onde leio e escrevo à vontade. Irei agora, com calma, acabar o que falta, e resolver um ou outro problema que me parece menor que ainda existe, acreditando que dentro em breve o jogo estará publicado. Dado que agora acedo à totalidade dos 128 KB, talvez tire partido disso e melhore o grafismo ainda mais um bocadinho.
Só lamento ter matado a cabeça por duas semanas à procura de uma solução recomendada pelas IAs, que se revelou um beco sem saída. Tudo devido à Estupidez artificial das mesmas.
IMAGENS DO JOGO
Que aventura!!
Está a ser muito divertido e interessante de acompanhar, Mário!
Acabei de inserir a ultima linha de código :D… Falta montar as partes de forma sequencial para o loading do jogo.
Dou-o oficialmente como completo :D, faltando apenas eventuais alterações devido a bugs que possa apanhar.
Com isto aprendi Assembler ao um nível que eu nunca tinha explorado antes, aprendi a arquitetura do ZX Spectrum, e melhorei os meus conhecimentos de programação a um nível que nunca pensei explorar [programação ao mais baixo nível possível)
Não é um grande jogo… e nem sequer é dos melhores do gênero. Mas é feito por uma única pessoa, nos tempos livres, e a o conceito por detrás dele foi re-editar o jogo original, com todas as suas falhas e virtudes, atualizando-o, e melhorando-o.
A minha ideia era compilar isto e passar tudo a código máquina, mas o problema é que não há compiladores atuais para Spectrum. A maior parte deles não suporta 128K, e os que suportam bão suportam as metodologias que estou a usar. Vou ter de mandar o jogo em Basic. Vou proteger o código e bloquear o acesso ao mesmo.
Pouco importa se é um grande jogo ou não, o projeto e o caminho é que estão a ser muito interessantes!
E acredito que muito satisfatório para si!
Atualizei o artigo com imagens do jogo.
Meus parabéns. Realmente você entrou fundo na programação do spectrum.
Ficou muito bem feito o jogo.
A ideia do jogo é a gestão da equipa. Não és treinador e daí a mudança de nome, pois não treinas verdadeiramente nada (apesar que mudar o esquema tático tem implicações). A ideia é gerires a equipa escolhendo os melhores jogadores, e gerires o mercado com compras e vendas para melhorares a equipa e permitires que a mesma seja campea (daí o novo nome Manager de futebol).
Qualquer que seja a equipa que escolhas ela vai ser uma equipa de meio da tabela, pelo que não há diferentes níveis de dificuldade.
Perdes se a equipa apresentar saldos negativos, ou se desceres de divisão e ganhas, em uma ou mais épocas, quando fores campeão.
Ah sim, é provável que nesta fase ainda hajam bugs que eu não detetei.
Bacana demais, Mário! Parabéns!
Já tenho revistas de Spectrum e youtubers a pedirem acesso a uma copia do jogo… 🙂
Eles analisaram e gostaram da versão de 1989.
É impressionante o que vc conseguiu usando bem menos do que um equivalente de uma jpeg de 200x200plxs de memória! Ótimo resultado e o jogo me remete a um grande saudosismo de uma época em que eu ainda gostava de jogar games de futebol, como no atari.
Inspirador! Os programadores de nova geração ainda teriam muito a aprender contigo em matéria de optimização de código! O velhinho speecy assim o obrigava! Conhecendo as limitações da máquina e os jogos que eram feitos, sem dúvida que os programadores de spectrum são verdadeiros magos!
Parabéns pelo projeto!
Isto foi muito cansativo para mim. Na era atual os bytes não contam. Tudo funciona em megas. Aqui tudo conta. O tamanho da string, os parâmetros dos comandos, tudo gasta bytes. E bytes são algo fundamental.
Por exemplo, imagina que queres colocar uma linha cheia de *. Nos dias que correm não te preocupes com o método a utilizar, mas no Spectrum um ciclo for Next que imprima um caráter de cada vez gasta mais 14 bytes do que eu mandar imprimir uma string com 31 asteriscos.
Mas isto nem sempre é verdade. E há casos onde o que se passa é o contrário. Se os bytes contam, isto tem de ser tudo analisado. É lixado…
Por exemplo, se quiseres escrever três linhas destas, um ciclo for Next de 1 a 3 para cada linha, misturado com a string total é o que gasta menos RAM.
Depois tens também a possibilidade de não usar RAM com variáveis, usando um pouco mais de armazenamento mas passando a carga para o CPU que calcula as coisas conforme são precisas. É todo um jogo de equilíbrio.
Parabéns! Alegro-me que tenhas conseguido superar todos os obstáculos, e que não tenhas desistido de terminar o jogo.
Tem sido muito interessante seguir esta história!
Se não for muito incomodo, seria possível saber qual a documentação que usaste?
Tenho andado a ver alguns tutoriais de programação em assembler para o spectrum, mas às vezes é um pouco difícil entender algumas coisas. Obrigado.
Isto é simples e complicado ao mesmo tempo.
Agora o curioso é que o Spectrum tem interpretador assembler embutido, pelo que não precisas de programar mesmo em assembler.
Eu precisava de copiar dados de uma RAM para a outra pelo que precisava do seguinte código em assembler:
LD HL,n define o endereço de origem da imagem na RAM
LD DE,n define o endereço de destino da imagem na RAM
LD BC,n define a dimensão dos dados a copiar a partir do endereço de origem.
LDIR executa a cópia do endereço de origem para o endereço de destino com a dimensão definida
Preciso depois de mais dois comandos, pois quando entro em assembler tenho de retornar ao Basic
RET retorna ao Basic
E finalmente preciso de executar o código em assembler no basic
Ora ha duas formas de fazer isto. Criar o código assembler e carregar fora da RAM usada pelo Basic, ou executar tudo dentro do Basic.
Como referi, dado que o Spectrum possui embutido o interpretador de assembler nós podemos chamá-lo dentro do Basic.
Para isso temos de definir um endereço de RAM onde guardar a rotina assembler.
Imagina o endereço 65000. Vamos defini-lo no Basic.
Let addr=65000
Agora vamos aceder ao assembler. Para isso vamos guardar cada um dos comandos nos bytes sequenciais ao endereço escolhido.
Assim vamos fazer um POKE addr,33. Addr é o endereço que definimos, e 33 é 0x21em hexadecimal que é o opcode no Spectrum para o primeiro comando assembler, o LD HL. Temos agora que definir o parâmetro n que é o endereço de origem.
Se a imagem estiver guardada em 54344, por exemplo, temos de definir esse endereço nos bytes seguintes do registo. E assim vamos ter os seguintes pokes:
Poke addr+1,0: poke addr+2,224.
Porque 0 e 224? Aqui o Spectrum trabalha de forma curiosa. Vamos passar 54344 a hexadecimal e temos 0xE000. Aqui vamos ignorar o 0x e pegar no E000. E0 em decimal é 224, e 00 é 0. Inverte, metendo o 0 primeiro e o 224 depois, e tens os pokes explicados.
Agora vamos ao comando seguinte:
O opcode de LD DE é 0x11, ou 17 em decimal, pelo que usamos um POKE addr+3,17. Se o endereço de destino for o, por exemplo, 32768, vamos passar o valor a hexadecimal que é 0x8000. 80 em decimal é 128 e 00 é 0. Invertes e tens POKE addr+4,0: POKE addr+5,128.
Comando seguinte, LD BC, opcode 0x01 ou 1 em decimal. Logo POKE addr+6,1.
Como eu copiava imagens e a RAM video ocupa 6912 bytes, esse valor em hexa é 0x1B00, ou 27 e 0. Logo, invertendo, POKE addr+7,0: POKE addr+8,27
Vamos mandar copiar com o LDIR, opcode 0xED e 0xB0. Precisamos dos dois. Pelo que são dois pokes. Poke addr+9,237:Poke addr+10,176.
E a cópia está feita… Rápido e eficaz.
Agora retornar ao Basic, com o comando RET, opcode 0xC9 ou 201 em Decimal. POKE addr+11,201
Estás agora no basic, pelo que precisando da rotina só tens de fazer:
Let a=usr(addr)
E ele executa.
Não sei se compliquei. A lista de opcodes encontras na NET, o resto aprendi. 😉
Muito obrigado pela extensa explicação 🙂
Achei curioso que tenhas de colocar os POKEs para todas as linhas de assembler, teria suposto que o código seria executado em sequência até encontrar o RET, mas pelos vistos não é assim, muito interessante.
Também não sabia que o spectrum tinha interpretador de assembler. Eu tenho usado o compilador sjasmplus que cria um ficheiro SNA (creio que também suporta outros formatos como o TAP), tem sido bastante fácil de usar, só ainda não sei se haverá alguma forma de fazer debug ou algo.
Mais uma vez, muito obrigado pela explicação!
Este é o código de transferência numa mesma memória. Mudando os bancos tens de ter comandos extra mas vais entrar num caminho penoso. Seja como for, o mesmo código no Basic é lentíssimo pois terias de fazer um ciclo
FOR I=0 TO 6911 para ler os 6912 bytes, o que demora uma eternidade. E dentro dele terias de ter um poke endereço+I, peek(Endereço2+I), onde escrevias e lias os dados byte a byte.
Isto era desesperante a nível de tempo. E se metesse na memória vídeo diretamente para ver a transferência via os pixels a aparecerem um a um.
ARRRGH. Até arrancava cabelos. 😀
Quanto ao código, ele só poderia ser executado em sequência se estivesse escrito e na RAM. O meu problema é que eu usava a RAM toda e só deixava livre acima do Basic o suficiente para meter o audio e o assembler necessário para o executar (3 canais usando o beeper é impossível em BASIC pela lentidão). Dessa forma não quis roubar nada ao Basic para carregar o assembler e optei por o inserir dentro do Basic (a tal “simulação” de assembler em Basic que eu referi ir fazer, e que chamei simulação pois não estou a escrever diretamente código assembler, mas sim apenas comandos Basic, mas estou a usá-lo na mesma).
Uma coisa curiosa, caso alguém um dia veja o meu código, é que me vão chamar de maluco. Isto porque as primeiras 20 linhas do Basic estão escritas todas em uma só.
Porquê? A certa altura andava à rasca de RAM, e não tinha solução para isso. Dai que andava à caça a byte.
E não tendo muito mais por onde fugir, resolvi poupar os bytes de indexação das linhas. Infelizmente poupava pouco e não valia a pena, mas compilei 20 linhas numa só, e depois não estive para repor como estava. :D.
O código do jogo de 1989 impresso ocupa 8 páginas A4 de um lado e de outro. O atual ocupa 16. E isto sem contar com o trabalho gráfico que agora houve e antes não havia.
8 páginas! Para entender o código deve ter sido uma aventura por si só 🙂
Muito interessante ver todo o processo que fizeste para poupar uns bytes aqui e ali. Um teste que fiz para comparar a velocidade a preencher o ecrã com pixels, a versão do basic era mesmo penosa hehe.
Mais uma vez, obrigado pela explicação. Imagino que depois desta odisseia, não vais voltar a fazer mais nenhum jogo no spectrum hehe.
Boas . Li e gostei é possível jogar esse jogo ? Fiquei curioso .
Ainda não. Mas depois sim.
Entretanto o jogo vai receber uma vídeo review num canal youtube Ingles dedicado ao Spectrum.