######## ################## ###### ###### ##### ##### #### #### ## ##### #### #### #### #### #### ##### ##### ## ## #### ## ## ## ### ## #### ## ## ## ##### ######## ## ## ## ##### ## ## ## ## ## ##### ## ## ######## ## ## ## ### ## ## #### ## ## ##### #### #### #### #### ##### #### #### #### #### #### ###### ##### ## ###### ###### Issue #21 ################## Feb 5, 2002 ######## Special Focus on Minigames ............................................................................... "Do not meddle in the affairs of wizards, for they are subtle and quick to anger." -- Ancient Elvish saying ............................................................................... BSOUT (Why yes, I _am_ re-reading Lord Of The Rings, how did you know? Sure, the movie is pretty good, not the same as the book, but probably the best you could do, although why does every Hollywood evil creature essentially come from Night of the Living Dead? But hey, the elves actually looked like elves. Black Hawk Down is really good too, and... oh, sorry!) Hoo-ah! Welcome to another issue of C=Hacking! There's lots of nifty stuff to get to so this will be brief. First, the "Hacking Exchange" is now up at the Official Unofficial C=Hacking Homepage: http://www.ffd2.com/fridge/chacking/ It's just a simple message board, with the idea that C=Hacking types can make comments, ask questions, and otherwise talk to one another. Check it out! Second, if you have any projects you're working on... please contact me, and consider writing them up for C=Hacking! And finally, something I think you'll enjoy: http://groups.google.com/groups?hl=en&th=87c2a2ced7e32ce1&rnum=42 From: duck@clumsy.pembroke.edu (duck@clumsy.pembroke.edu) Subject: C= Hacker's Net-Mag Newsgroups: comp.sys.cbm Date: 1992-02-08 14:52:20 PST Due to the recent influx of "Tech-Know" (People who actually understand / hack te C=64 into doing stuff that was previously unknown) I am going to tentativly start a C= Hackers Net-Mag (Hacking in the 90's since of the word, not the 80's). So - please send me your netmail address if you're interested in receiving it. Or if you are interested in contatcting me concerning an article etc that you'd like to see distributed please also contact me. For the first issue I'm tentatively planning on: - Line Drawing on 8563 VDC (640 x 200 hi-res graphics) - Beginning ML column - Raster Article . . . - Craig Taylor duck@pembvax1.pembroke.edu P.S. - Not sure when the first is gonna go out but hopefully soon. From: duck@clumsy.pembroke.edu (duck@clumsy.pembroke.edu) Subject: Issue 1 - C= Hacking Available Newsgroups: comp.sys.cbm Date: 1992-02-27 17:22:44 PST Issue 1 of C= Hackers is now available via NETMAIL and is a compilation of several articles on the tehnical side of the Commodore 64 and 128. For those of you who missed the first posting, please reply via email and ask to be put on the list. The first issue had programming in ml, documented and undocumented 6502 opcodes, and a line-drawing package in machine language for the C=128 hi-res screen. Issue 2 will be coming out in a month or so. Many thanks for the bandwith. - Craig Taylor duck@pembvax1.pembroke.edu ....... .... .. . C=H 20 ::::::::::::::::::::::::::::::::::: Contents :::::::::::::::::::::::::::::::::: BSOUT o Voluminous ruminations from your unfettered editor. Jiffies o News, rumours, and stuff. Side Hacking o "Pulse Width Modulation, continued" by various. Tying up some loose ends from last issue's digi article. o "Introducing Full-Screen IFLI mode with a SuperCPU", by Todd Elliot (Hey, what's with all this MSN crapola? :) Using a SuperCPU, it is possible to use the first three columns of an (I)FLI picture, and Todd shows how. Main Articles o "VIC-20 Kernel ROM Disassembly Project, part IV", by Richard Cini And now it's time to start on that most frightening of creations: the tape drive code! o "The Art of the Minigame" -- an article in six parts: Introduction, by the editor Part 1: Codebreaker, by David Holz Part 2: TinyPlay, by S. Judd Part 3: MagerTris, by Per Olofsson Part 4: Compressing Tiny Programs, by S. Judd Part 5: TinyRinth, by Mark Seelye Part 6: Tetrattack!, by Stephen Judd .................................. Credits ................................... Editor, The Big Kahuna, The Car'a'carn..... Stephen L. Judd C=Hacking logo by.......................... Mark Lawrence Special thanks to the cbm-hackers for many otherwise unacknowledged contributions. Legal disclaimer: 1) If you screw it up it's your own fault! 2) If you use someone's stuff without permission you're a dork! For information on the mailing list, ftp and web sites, send some email to chacking-info@jbrain.com. ................................... Jiffies .................................. $01 Jochen Adler has made a program that reads the second side of a 1541 disk in a 1571 - without turning the disk over. It reads the blocks from end to beginning. Because of the mechanical bump however, it can only read tracks 5 to 35. If anybody wants this program please e-mail Jochen (NLQ@gmx.de) $02 Soci/Singular has been working on a commented C128 ROM listing. Check out this great effort at http://singularcrew.hu/c128rom/ $03 64net/2 has been updated: For those that do not know: 64net/2 is yet another PC-to-C64/128 User<->LPT parallel cable software. It supports d64/d71/d81/t64/lnx/dhd disk images, raw files and own Internet partition. It is possible to enter disk images like any other directory. Client programs are provided for C64 and C128. The next goal will be to patch ROM instead of loading client. A small BASIC example program is included that is able to send e-mails through 64net/2 host or any other machine if its IP is known. How many e-mail agents are there for C=? How many of them are written in plain BASIC 2.0? :) http://sourceforge.net/projects/c64net/ contains the latest version, and http://venus.wmid.amu.edu.pl/~ytm/64net2win.tar.gz contains a Windows binary. $04 CC65 is up to version 2.7.0, with many new improvements. Check it out at http://www.cc65.org/ $05 Moreover, Ullrich has set up his C64 as a web server at http://c64.cc65.org/ The web server runs on a stock 64, using a Swiftlink for communications, and uses the uIP TCP/IP stack written (with cc65) by Adam Dunkels http://dunkels.com/adam/uip Pretty cool, eh? $06 A new IDE interface has become available: Elysium is proud to announce a new software and hardware solution for your Commodore mass data storage needs. The CIA-IDE is yet another approach to connect an IDE hard drive to C64/128. It differs from previous similar projects in these areas: - it is the simpliest one (only two chips required (or just one in case of C128)) - it is free (documentation and software), - the software is available in source code form under GNU GPL, - there _exists_ a ready to use GEOS 2.0 driver. Documentation, source codes, and binaries are at: ftp://ftp.elysium.pl/groups/Elysium/Projects/ciaide/ $07 Marko Makela has developed a tape drive emulator with an RS-232 port, allowing transfers between any computer with a tape port and any computer with an RS-232 interface (e.g. a PC or Swiftlink). The hardware and software is at http://www.funet.fi/pub/cbm/crossplatform/transfer/C2N232/ $08 GoDot -- the C64 image processing program -- is now public domain. Arndt will still be working on it, but it's now available at http://members.aol.com/howtogodot/godnews.htm $09 The C64 is now listed in the Guiness Book of World Records; a scan of the page (from Robert Bernardo, posted by Frank Michlick) is at www.cupid.de/upload/famous.jpg (love that line about "16K sound"). Also, I highly recommend taking Robert's advice and checking out the infinitely cool "Logo-Matic" on the main www.cupid.de site! $0A JOS just keeps cruising along, with lots of changes. Among the biggest changes, of course, is the announcement that JOS will be merged with Clips, with the new system to be called "Wings" (with some of the letters capitalized and some not; I never remember that stuff). For the latest JOS news, check out http://www.jolz64.cjb.net/ $0B Frank Kontros has made a commented disassembly of the C64 ROM, with the BASIC ROM on the way: http://c64.uz.ua/sources/C64_Kernal_Disassembly.zip $0C And finally, Aleksi Eeben, author of two minigames, has now written a VIC-20 game: http://www.student.oulu.fi/~aeeben/download/dragonwing.zip more screenshots: http://www.student.oulu.fi/~aeeben/screen1.png - screen4.png Aleksi's homepage is at http://www.cncd.fi/aeeben Neat! ................................ Side Hacking ................................ Pulse Width Modulation, continued --------------------------------- from various The digi article in issue #20 of C=Hacking left a few loose ends, and generated some followups. First, Otto Jarvinen (sounddemon) emailed to say that the SID detection routine occasionally reported incorrect results for him, and suggested that a workaround was to do the detect several times. YMMV! Second, a day or two after issue #20 was released, Levente discovered a brilliant way to play 6-bit PWM digis on a stock machine: -- I couldn't resist, and tried something out (see attachment). It works!!! :-) In fact, when I wrote the last letter I didn't know that I found something useable, just had some ideas - I felt that I'm at the right place. When I read C=H 20 this morning and read your comment about the Test bit (from the PRG), I knew that it must work. All I had to do is then to put this idea into code. The whole idea is about starting the pulse by software, and then having the SID turn it back to 0 after a time. Is it possible? ...The keys are the Test bit (the SID wave counter can be reseted anytime), the pulse width register, the wave counter and the SIDs way of generating pulse wave. (Ie. the pulse wave is high, as long as the wave counter is less than the value in the pulse width register). Check this algorithm: - Init: volume at max, voice 1 sustain level max, start attack. Freq is selected well (=$4000), so the wave counter is incremented by 4 every processor clock cycles. Loop: - load next sample value, and put it to the pulse width low register ($d402; ensure that $d403 is 0). - Set test bit, and clear test bit (counter reset). - Increase sample pointer, some delay, then loop. The delay must be 64 clock cycles + the time while the Test bit is kept set (4 cycles if using STA $d404 : STX $d404 immediately with pre-loaded values). What will happen? The 8-bit sample value is put directly to the pulse width register (MSBs of the pulse width register are cleared!...). The wave counter is started (release test bit), and it increases 4 by every CPU cycles (= counts 256 in 64 cycles). After some time, the counter will reach the value in the pulse width register. This happens in exactly after (8-bit sample value / 4) cycles, because of the above. In this cycle (or the next?...) the SID turns its pulse output to 0. Voilá! One must just make sure that the loop length in cycles matches the above conditions, and then it runs like hell... Since it does exactly the same on the SID as the other (bit-banging) way, it just does it with some hardware help, there's also no problem with the 4khz maximum barrier (since the oscillator is reset every loop). With little enhancement, it's possible to write an about 7.5 bits player for a stock C64 by this method. This is what you find in the attachment... The idea is using all the 3 channels simultaneously. A slightly increased sample value is written to the three pulse width registers, so the oscillators will finish the duty cycle one processor cycle later, when there's a carry between bits(0,1) to the MSBs. The replay freq is the CPU clk / 68 (~15khz). 64 cycles (variable duty cycle) + 4 cycles (constant duty cycle because of the reset time - no problems with that, it doesn't change (just gives a small constant DC...)). By similar methods, it should be possible to write a sample player with higher PWM freq (with less resolution of course, but eliminating this still audible whistling). (I tried using the filter to reduce it, but it sounded so bad that I left it out. It clicked like hell. The FETs got saturated.) [Richard Atkinson suggested turning down the sustain volumes to avoid this] See the attachment, and the binary. I think the sample sounds pretty good :-). (The cut is from 'Greece 2000' by Three drives on a vinyl). (Another idea that popped up in my mind: since the TED sound generator can also be reset, I could probably translate this idea to the Plus/4 :-O ). Best regards, Levente -- The binary is available at http://www.ffd2.com/fridge/chacking/ towards the bottom of the page. Third, I received a very interesting email from an Apple-II guy, which I'd like to pass on: -- Hi! I found your page as I was searching for something else 6502-related, and was very interested. Although I have always been aware of the C64, I have never really been a user--I have used Apple II's since 1980. I was particularly interested in the article on playing "digis" on the C64. I became interested in playing digitized sounds on the Apple II in 1993, after hearing a 3-bit, 11.025 KHz PWM player. At 3 bits, you can imagine how noisy speech samples were, but the overall effect for a 1 MHz machine with a 1-bit speaker "toggle" was amazing. It made me wonder how far this PWM technique could be pushed on a stock, 1 MHz Apple II (not the somewhat faster, 65816-based IIgs). The short answer is, much farther than I expected! Robin and Stephen accurately describe the theoretical PWM limit as 6 bit samples at about 16 KHz for a stock 1 MHz machine, but, as they point out, that is not practically realizable for a number of reasons, unless the play loop is completely unrolled! Furthermore, in the Apple II world, sampled sounds have acquired a few standardized sampling rates--mostly as a result of Mac influence, which was in turn influenced by CD's. The most common rate in the Apple II world is 11.025 KHz, or one-fourth of the audio CD sampling rate. This is commonly considered to be "AM radio quality", with a Nyquist bandwidth of about 5.5 KHz and a practical bandwidth of 4+ KHz, given practical anti-aliasing filters (at the sampling end, not the playback end). A frequency of 11.025 KHz is, though high, still painfully audible to people whose ears are not zonked--a piercing "squeal" running through every sound. So even though it is possible to write a practical 6-bit 11.025 KHz PWM player (usually called a SoftDAC in the Apple II world), the resulting listening experience is disappointing. So I went to work on a way to do 2x oversampling, and built a 5-bit 22.050 KHz PWM player. It was sad to lose a bit, but the absence of any audible "carrier" more than compensated for it! If you have access to an 8-bit Apple II (preferably with lower case, like a //e), and also preferably with a way of attaching an external speaker or headphones in place of the miserable 2.75" internal speaker, then you can easily give it a try and judge for yourself. I'm pretty proud of the novel design of the code, which I would characterize as "vectored" unrolled loops, one for every two pulse duty cycles, which I wrote a BASIC program to write for me--much less painful for counting cycles! The package is available on the web at: http://members.aol.com/MJMahon/index.html and is called Sound Editor v2.2, since I had to "dress up" the player into something fun to play with. ;-) An earlier version of Sound Editor was published on SoftDisk in 1994, IIRC, but this one is a little more evolved. It also introduced 2:1 ADPCM compression of 8-bit sampled sounds, to save disk space. It is a lossy compression, but not very noticeably. The editor package also includes those routines, in 6502 assembly code. All of this should be trivially adaptable to the stock, 1 MHz C64, with very good results. By using the filters, you could probably filter out the 11.025 KHz carrier and return to 6-bit accuracy! I should note that in the Apple world, sampled sounds are usually represented as "excess-128" codes, which means that the sign bit is inverted. This actually simplifies things, since the sample value is within a few shifts of being the pulse width in cycles. Let me know what you think! -michael -- (Always great to hear from Atari and Apple ][ folks!) And finally, I have a little mathematical analysis of PWM and how it compares to a "straight" digi. Basically, I found some of the PWM explanations a little unconvincing in issue #20 (even though I wrote them!). For example, the idea of "average voltage" seems a little funny, since every two samples has an "average voltage", as does every four, etc. but that set of average voltages would give a different sounding signal than the original (or more dramatically, there is an average voltage over a full second of digi playback, but that's not what you hear!). So I wanted to know how a PWM signal _really_ compares to a straight digi playback. Another issue is changing the amplitude of a PWM digi, i.e. using two pulse waveforms, with one 1/16 the value of the other, to get higher resolution. If you recall the discussion of digis, the resolution of a PWM digi depends on the number of pulse widths available, not the amplitude. Adding two PWM waveforms together does not change the number of pulse widths available, so I wanted to figure out what changing the amplitude _really_ does to a PWM digi, and if it can really be exploited. And finally, I wanted to know about the carrier wave (that is so piercing at lower playback frequencies) -- and once again, how it compares with a standard digi (which, after all, is stair-stepping the voltages at the playback rate). Since the rest of this article is some Fourier analysis that 99% of people will have zero interest in, I'll put the conclusions here. The first is: PWM digis and standard digis are essentially identical except at higher frequencies (except for a phase shift, which doesn't make any difference to your ear). The second is: changing the amplitude of a PWM changes the resolution. More specifically, the amplitude of the pulse multiplies the digi sample value. If two pulses can be synced close enough, it should indeed be possible to use two pulses to get a higher resolution. Moreover, by modulating the amplitude of a single PWM digi, using the $d418 volume register -- that is, using PWM _and_ $d418 -- it should be possible to get a higher dynamic range, something that should be a little more achievable using SID (but maybe not that useful, so I didn't try it out). And finally, a standard digi has zero amplitude at the carrier frequency. In other words, after a lot of effort I was able to demonstrate what everyone already knows. The analysis doesn't change anything from the previous articles (except possibly the idea for changing the PWM amplitude to get more dynamic range). And now, some Fourier analysis. A standard digi just sets the voltage to the sample value s_j, for a length of time dt (dt = 1/sample rate). The Fourier transform of a single sample s_j (occuring at time t_j) is s_j [e^(-iw dt) - 1] * [e^(-iw t_j) / -iw] where w = angular frequency. Since the above is a little hard to read, I'll say it in words. The first term is the sample value s_j, which scales amplitudes at all frequencies. The second term is due to the finite length of the pulse (evaluating the Fourier integral at the boundaries), and basically changes the phase of the transform. The third term is like sin(w)/w -- a sinusoid with decreasing amplitude as frequency increases. So: the transform goes like sin(w)/w times the sample value, with some phase effects thrown in (we'll get back to these in a moment). A PWM digi sets the duty cycle of a pulse to the sample value s_j, giving a Fourier transform of [e^(-iw s_j dt) - 1] * [e^(-iw t_j) / -iw] Compare this with the earlier expression, and you'll see that the sample value s_j has moved up in to the exponent of the "phase term" but that they're otherwise the same. The first thing to do is to show that both expressions, PWM and standard, reduce to the same thing -- that is, that a PWM and a standard digi sound the same! The expressions both decrease as 1/frequency, due to the sin(w)/w term. This means that at large frequencies the values become negligible. (How large? For example, if the sample frequency is just 1KHz, then sin(w)/w is .001 times smaller near w=1KHz (i.e. the sample frequency, which is twice the Nyquist limit) than it is near w=0). So now consider the phase terms for small w. The Taylor expansion for e^x is 1 + x + x^2/2 + ... We can therefore expand the "phase terms" as regular: e^(-iw dt) - 1 = (1 - iw*dt + w^2 dt^2/2 + ...) - 1 = -iw*dt + O(w^2 dt^2) pwm: e^(-iw s_j dt) - 1 = -iw*s_j*dt + O(w^2 dt^2) where O(w^2 dt^2) is considered very small since w and dt are both small. Substituting the above into the original expressions gives s_j*iw*dt [e^(-iw t_j) / iw] in both cases. That is, we have shown that for "small" frequencies -- more specifically, for frequencies where (w^2*dt^2) is much smaller than (w*dt), which is where w*dt<1, which is frequencies less than the sample frequency, which is all frequencies of interest! -- PWM and standard digis are the same. The explanation lies in the phase terms. Those "phase terms" [e^(iw dt) - 1] (regular) and [e^(iw s_j dt) - 1] (PWM) do more than just change the phase. When they multiply the sin(w)/w signal, they take the sin(w)/w signal, change the phase, and then subtract the sin(w)/w signal again. It's this difference of signals that makes things work out at the frequencies we care about. PWM and standard digis are _not_ the same, but the main differences are at higher frequencies, where the amplitudes are in general much smaller. But... but... what about the PWM carrier frequency? If we take a constant digi, say with sample values = 1/2, the standard digi gives a constant voltage, whereas a PWM digi gives a square wave at the sample frequency. The answer comes from the "phase terms" above. The sample frequency is w = 2*pi/dt. Substituting this into the phase terms gives [e^(i*2*pi) - 1] (regular) and [e^(i s_j 2*pi) - 1] (PWM) The regular expression is exactly zero -- there is _nothing_ at the sample frequency of a regular digi. But that's not the case for the PWM term, because of the s_j up in the exponent. PWM digis have a _finite_ amplitude at the carrier frequency. Note that because of the sin(w)/w term it gets smaller as the sample frequency increases -- but it isn't zero. Finally, the phase term expansions give some insight into what happens when both the pulse width _and_ height are varied. If the pulse width is s_j, and the height is set to h_j, then the Fourier transform becomes h_j*s_j *iw*dt [e^(-iw t_j) / iw] That is, the amplitude multiples the width. For the case of adding two PWM waves together, then, the amplitude really does effectively scale the sample value, and it should be possible to add one PWM value at 1/16 the amplitude of another to get an effective 8-bit value. What about _varying_ the amplitude of a single PWM sequence? For a 6-bit PWM digi, say, the sample values s_j can go from 0 to 63. If this is then multiplied by h_j=2 say, then the values become 0 2 4 ... 126 -- a 7-bit number where the lowest bit is always 0. What use is that? Well, we still have the h_j=1 values of 0..63, which do include the lowest bit. So we can effectively change the dynamic range from 0..63 to 0..126 using just two amplitude values. As a practical matter, then, it might be possible to use all 15 $d018 values available to get a big dynamic range, and hence a better sounding digi, using fewer CPU cycles. Well, ok, we're only _sort of_ changing the dynamic range, so I pretty much doubt the usefulness of it. But maybe someone out there would like to give it a shot. All right, let's hope this closes the book on pulse width modulation for digi playback! ....... .... .. . C=H 20 .............................................................................. Introducing Full-Screen FLI mode for the SuperCPU Copyright (C) 2002 By Todd S. Elliott The 'FLI Bug', where the first three columns of a FLI screen are essentially unusable, can be squashed with the help of a SuperCPU. I won't go into great detail on IFLI, as it has been well-documented elsewhere, but I'll begin with a short summary to get us all up to speed. I refer you to Albert 'Pasi' Ojala's excellent coverage of the FLI mode in C=Hacking #4. Pasi also proofread this article. A Three-Minute Summary of the FLI mode The VIC-II chip asserts a badline when it needs to access the databus and fetch character data or videomatrix data. It was discovered that the VIC-II chip can be manipulated by its vertical scroll register at $d011 (SCROLY) to induce a badline at any given rasterline. By having a badline at every visible rasterline, the program can manipulate $d018 (VMCSB) to point at the right videomatrix to achieve the maximum flexibility of colors given to a multi-color screen. Unfortunately, when a program forces a badline via SCROLY, the BA (Bus Available) line in the computer goes high, and for three cycles the 6510/8510 processor has to finish its write operations or halt its read operations before the BA line is released to the VIC-II chip. The maximum number of successive write operations is three, hence the 3-cycle delay. It is in those three cycles that the VIC-II does not fetch video matrix data to fill in the first three columns and causes the 'FLI Bug'. I wish to stress that in those first three cycles, when the BA line is high, the 6510/8510 processor is still active and can complete write operations. It isn't fully shut down. After the badline retrigger at STA SCROLY, the code following it is fetched on the databus and is ready to be executed by the 6510/8510 processor. When BA is high, the VIC-II will reference the value on the databus as videomatrix data and display it in the first three columns of the screen. The actual instructions that follow the STA SCROLY in the FLI loop constitutes the video matrix data for the first three columns of the screen. Enter the SuperCPU! Normally, a VIC-II chip access is only possible every 4 cycles. The SuperCPU can access the VIC-II chip in 1 cycle (1MHz) intervals, making cycle to cycle changes possible within the VIC-II chip. More importantly, the SuperCPU tristates the 6510/8510 processor inside the host Commodore computer (which is a fancy way of saying that you can disconnect the processor from the system without physically removing it). When a forced badline retrigger occurs with a STA SCROLY in a FLI loop under the SuperCPU, the BA signal inside the host Commodore computer goes high. But, the SuperCPU runs asynchronously and really doesn't have to pay attention to the host Commodore as it runs code after the STA SCROLY. In fact, the SuperCPU will execute code even if the VIC-II badline is in full swing inside the host Commodore computer. I knew that the instruction opcodes left on the databus after the STA SCROLY made up the video matrix data for the VIC-II chip for those first three columns of the screen. But I wondered how this was possible in a SuperCPU configuration because there would be no instruction opcodes left hanging on the databus inside the host Commodore computer. After some discussions with Per Olofsson ("MagerValp"), he suggested that writes/reads to the i/o area will force a value to be put on the databus. This is where the magic begins, when the FLI loop forces the SuperCPU to write to the i/o area of the host Commodore after the forced badline retrigger at STA SCROLY. The SuperCPU will note that the BA signal is still high, so it can still access the databus and stash values there via DMA. This BA high signal will last for 3 cycles, enough for the SuperCPU to stash three values onto the databus. The 6510/8510 is still tristated by the SuperCPU, and there's nothing on the databus after the forced badline retrigger at STA SCROLY. Normally, the 6510/8510 CPU shares the databus with the VIC-II for each machine cycle. With the 6510/8510 CPU out of the equation, the SuperCPU can stash a value onto this shared bus on the CPU half of this machine cycle and the VIC-II chip will see it in its other half of the machine cycle. However, the databus is only eight bits wide. The VIC-II chip fetches video matrix data and color ram data 12 bits at a time. The SuperCPU can force values onto the databus during the first three cycles after the forced badline retrigger, but on each cycle the last four bits belonging to Color RAM would not be fed to the VIC-II chip. Only pixel values of %10 and %01 can be individually selected in multicolor FLI mode, while %11 pixel values cannot be individually set for those first three columns of the screen. The high resolution FLI mode does not suffer from this problem because it does not use color RAM for color attribute information. Full-Screen FLI in practice Let's get down to the nitty gritty. The Write I/O Approach requires three 200-byte tables, corresponding to each column. Each value on those tables correspond to each visible rasterline. For example, the first byte of each table corresponds to rasterline 50, the second byte of each table corresponds to rasterline 51, etc. The first table contains values needed for the first column of the screen, the second table contains values needed for the second column of the screen, and the third table values for the third column. In the FLI display loop prior to the STA SCROLY command, the current rasterline is used as an index to all three tables. The values are then fetched from the tables and inserted into the code that follows the STA SCROLY command using self-modifying code techniques. When the STA SCROLY happens, the code that immediately follows it starts writing the values onto the databus, all three in a row to complete the first three columns of the screen. There is a disadvantage with this approach. It requires that three 200-byte tables be specially constructed and stored somewhere in memory that is not mirrored by the SuperCPU. A routine would have to read in a FLI graphics file, extract information from the first three columns and store it into their respective 200-byte tables. Pasi Ojala came up with a graph depicting the SuperCPU interacting with the VIC-II in action, showing what happens after the forced DMA retrigger at STA SCROLY. The 'LDA #$xx' would have been modified earlier in the FLI routine (before the STA SCROLY) using self-modifying code. Here is the relevant source code which takes up 4 machine cycles inside the host Commodore computer. sta scroly : abcd, d = write Y to SCROLY on 1MHz bus CPU half - Mach. Cycle #1 lda #$00 : ef sta $d022 : ghij, j = write 1 to $D022 on 1MHz bus CPU half - Mach. Cycle #2 lda #$00 : kl sta $d022 : mnop, p = write 2 to $D022 on 1MHz bus CPU half - Mach. Cycle #3 lda #$00 : qr sta $d022 : stuv, v = write 3 to $D022 on 1MHz bus CPU half - Mach. Cycle #4 There are the two shared halves consisting of a machine cycle inside the host Commodore bus, and by stashing values onto the databus, this value is carried over to the VIC-II half and is read as videomatrix data during the first three columns of the FLI screen. ________ = VIC-II half of the 1MHz cycle . = SCPU synchronizes to 1MHz bus Each char position is equivalent to a 1 (20MHz) cycle. Mach. Cycle #1 Mach. Cycle #2 Mach. Cycle #3 Mach. Cycle #4 +-------------------+-------------------+-------------------+------------------- __________YYYYYYYYYY___DMA____1111111111___col0___2222222222___col1___3333333333___col2___ 1MHz abc.......ddddddddddefghi.....jjjjjjjjjjklmno.....ppppppppppqrstu.....vvvvvvvvvv SCPU Values on the databus which is carried over onto the VIC-II half of the databus: DMA: DMA condition detected by VIC-II col0: colors for column 0 read, gets the value 1 put into the bus by SCPU col1: colors for column 1 read, gets the value 2 put into the bus by SCPU col2: colors for column 2 read, gets the value 3 put into the bus by SCPU An alternative approach bites the dust The SuperCPU can also fetch values onto the databus by reading from the I/O region. If a coder were so inclined to use a 'Read I/O Approach', where is a program going to find 600 free bytes in the i/o region at $d000-$dfff? The idea is to force the SuperCPU to do a read on the databus via DMA and this can't be done with mirrored locations similar to the ones used in those VIC optimization modes. When a SuperCPU reads a value from mirrored memory, it does so from its local RAM and not the RAM that is inside the host Commodore computer. However, if the SuperCPU reads from the I/O block at $d000-$dfff, it will read a value from inside the host Commodore computer using DMA. Unfortunately, this approach did not work when the BA line went high inside the host Commodore computer and is unworkable for a full-screen FLI mode. The SuperCPU stops for reads if BA is high, just like its 1MHz 6510/8510 counterpart. Other Considerations. There were some interesting observations while debugging the full-screen FLI routines. The full-screen FLI routines were originally inspired by Robin Harbron's IRQ-based IFLI routines. Because they are driven by an IRQ, the CPU is still available for normal computational tasks. When all three videomatrix values are written to after the STA SCROLY in the line-interruptible FLI routine, the IRQ must then exit quickly with the restoration of the registers. It's a good idea to avoid writing to any mirrored location or read/write to any I/O region ($D000-$DFFF), since the SuperCPU will have to wait for VIC to finish with the data bus. Using a raster IRQ will naturally lead to trouble, since cycle-exact timing is needed, so a CIA timer is used. The timer may be set to synchronize a PAL or NTSC machine. Then in the FLI routine the timer can be checked and indexed into a table of preset timing values so that the forced badline retrigger at STA SCROLY will always happen at the right time on the screen, no matter what the SuperCPU is doing when the VIC-II interrupted it with an raster IRQ. Thanks goes to Ninja/The-Dreams (aka Wolfram Sang) for tips on how to create a stable line-interruptible FLI routine using timers. Source code Without further ado, here is the source code. This source code was used in the Santa Claus FLI Demo for Wheels OS. This code will run in either Commodore 64 or 128 computers and in either PAL or NTSC systems. It did take a lot of tweaking at Points #1, #2, #3, #4, & #5 as I tried to perfect the routines as closely as possible. The full source code for the Santa Claus FLI demo can be supplied via email upon request. It is in Concept+ (geoProgrammer) format. ; Wedges the full-screen FLI interrupt handler in Wheels systems. ; Thanks goes to Robin Harbron for the idea of a line-interruptible FLI routine. InstallFLI: ; Installs the FLI routine jsr ClearMouseMode ; Turn off the mouse. sei lda CPU_DATA ; Save 6510/8510 Location #$01. sta r6510 lda screenMode ; Check computer. bne 2$ ; Take branch in 128 mode. lda #IO_IN .byte $2c 2$: lda #%00110111 ; for 128 mode only sta CPU_DATA lda vmcsb ; Save original video bank info for Wheels. sta oVMBmp lda scroly ; save screen Y axis sta yaxis MoveW $fffe, oldVector ; Saves the old Wheels IRQ vector. lda #fli sta $ffff ; Point #1 lda #$31 ; Trigger the IRQ request sta raster ; At rasterline 49. lda scroly and #$7f ; Clear bit 7 of raster register. sta scroly lda #1 sta vicirq ; Ack raster ints. cli rts RemoveFLI: ; Removes the FLI routine sei MoveW oldVector, $fffe ; Restores the old Wheels IRQ vector. lda #$fb ; Trigger the IRQ request sta raster ; At rasterline 251. lda yaxis ; restore screen Y axis and #$7f ; Clear bit 7 of raster register. sta scroly lda #1 sta vicirq ; Ack raster ints. lda oVMBmp ; Restore original video bank info for Wheels. sta vmcsb lda r6510 sta CPU_DATA ; Restores 6510 Port #$01 cli jmp StartMouseMode ; Start the mouse on. fli: ; The actual FLI interrupt routine lies here. pha .byte $da ;phx .byte $5a ;phy php ; Save processor flags. ; Point #2 ldx #$03 ; #$0f for PAL SuperCPU systems. 3$ dex bpl 3$ lda raster tax ldy colOneClrs,x ; Get colors for the first three columns. sty mark4+1 ldy colTwoClrs,x sty mark5+1 ldy colThreeClrs,x sty mark6+1 ; ldy backgndTable,x ; Get background color for scanline. ; stx $d021 inx ; Point #3 cpx #$f9 ; Have we reached scanline 249? bne 1$ ; Point #1 ldx #$31 ; Restart the IRQ at rasterline 49. 1$: stx raster ; By this time, the raster interrupt register is ; incremented by one, and will re-trigger the ; same fli routine. ; This way, it is fully line-interruptible ; and frees up SuperCPU time. ldy #$01 sty vicirq ; Ack raster ints. and #$07 ; Mask out lower three bits. tax ldy tabd018,x ; Use preset values for vmcsb. lda d011tab,x ; Use preset values for scroly. sty vmcsb ; Select video matrix. sta scroly ; Forces the badline. mark4: lda #$00 ; Stores a video matrix value onto the first column. sta $d022 mark5: lda #$00 ; Stores a video matrix value onto the second column. sta $d022 mark6: lda #$00 ; Stores a video matrix value onto the third column. sta $d022 plp ; Restore processor flags. .byte $7a ;ply .byte $fa ;plx pla ; Do NOT use any memory accesses to the host rti ; Commodore databus in this part because it will ; be blocked by the VIC-II badline. ; Point #4 tabd018: ; Preset video matrix values. .byte $78,$08,$18,$28,$38,$48,$58,$68; NTSC systems ;.byte $08,$18,$28,$38,$48,$58,$68,$78 - PAL systems d011tab: ; Preset VIC DMA retrigger values. .byte $38,$39,$3a,$3b,$3c,$3d,$3e,$3f ChkAbortKey: ; Checks the RUN/STOP keypress. LoadB $dc00, #$7f ; check for the STOP key 3$: asl $dc01 ; Check for the RUN/STOP keypress. ; This also synchronizes the line-interruptible FLI routine. bcs 3$ ; Branch if it isn't pressed. rts prep3Cols: ; Prepares the first three columns of the FLI screen ; Ideally, a FLI file would be loaded in and this ; routine would then be called to set up the three ; 200-byte tables corresponding to each column, ; covering the first three columns of the screen. lda #$40 sta mark1+2 ; Prepare the marks. sta mark2+2 sta mark3+2 ldy #$00 sty mark1+1 iny sty mark2+1 iny sty mark3+1 php sei lda screenMode beq 1$ ; take branch in 64 mode. lda $ff00 pha ; save 128 configuration. lda #%01111110 ; select RAM at $4000 sta $ff00 1$: ldy #$00 ldx #$07 ; Use self-modifying code to create three ; Point #5 mark1: lda $4000 ; 200-byte tables for each column of the sta colOneClrs+49,y ; FLI screen and each value is indexed by mark2: lda $4001 ; the scanline in the FLI routine. sta colTwoClrs+49,y mark3: lda $4002 sta colThreeClrs+49,y ; use +48 for the column offset in PAL clc ; systems. lda mark1+2 adc #$04 sta mark1+2 sta mark2+2 sta mark3+2 iny dex bpl mark1 sec lda mark1+2 sbc #$20 sta mark1+2 clc lda mark1+1 adc #$28 sta mark1+1 tax inx stx mark2+1 inx stx mark3+1 lda mark1+2 adc #$00 sta mark1+2 sta mark2+2 sta mark3+2 cpy #200 bne mark1-2 lda screenMode beq 2$ ; take branch in 64 mode. pla sta $ff00 ; restore 128 configuration. 2$: plp rts .ramsect $1000 ; All column colors are referenced by scanline. colOneClrs: ; Column one colors of the FLI screen. .block 256 colTwoClrs: ; Column two colors of the FLI screen. .block 256 colThreeClrs: ; Column three colors of the FLI screen. .block 256 Hopefully the full-screen FLI possibilities that the SuperCPU can now unlock will bring forth cool software for our SuperCPU's and tons of 'eye candy'. Enjoy. ....... .... .. . C=H 20 :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: Main Articles :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: VIC KERNAL Disassembly Project - Part V Richard Cini February, 2002 Introduction ============ In Part 4 of this series, we discovered that of the 39 ROM routines available to be called by user code, 26 of them related to device I/O. The path to using a device from machine code was to first "open" it. So, we looked at the routines required to open and begin using a logical device. When we concluded the discussion on the OPEN command, we left out seven routines dealing with the tape device. This is some of the most complex code I've ever seen. Unlike the IEEE serial peripherals, the tape deck is a "dumb" device, meaning that the VIC Kernal has to do the work of moving the data to/from the tape. With the serial peripherals, the Kernal just sends a character to the device that then acts on it independently of the VIC. After opening the device, there are nine non-tape routines to deal with getting data into and out of the VIC, including talking and untalking, listening and unlistening, moving the characters in and out, and some secondary address stuff. In this installment, we'll conclude our discussion of the OPEN procedure by looking at the tape routines that are called from OPEN. Later articles will discuss the actual movement of bits to and from the cassette and the remaining IEEE serial routines, as well as the loading and saving of files. Tape Routines ============= In the last installment, we examined the beginnings of opening a device for use by a program. The reader will find that the routines are convoluted and hard to follow, with jumps and branches into apparently unrelated subroutines. Overall it appears to be ugly but highly functional code. Here's a "calling tree" outline to the routines called by OPEN: IOPEN---- |-FIND (analyzed in Part IV of this series) |-SENDSA (ultimately handles IEEE serial stuff) |-SEROPN (handles RS232 stuff) | |(all tape-related from here down) |-GETBFA (get address of tape buffer) |-PLAYMS (prompt user to press PLAY on tape deck) | |-TPSTAT (tape key status) | |-TPSTOP (check for STOP keyboard key during tape ops) | |-SRCHMS (print "Searching" or "Searching for..." messages) |-LOSCPH (locate a tape header with filename matching one in | OPEN) |-LOCTPH (locate first/next header; no filename specified) | |-SETBST (sets tape buffer start/end pointers) | |-PLAYMS (see above) | |-TPCODE (main tape code-moves bits in and out on IRQ) | |-SBIDLE (serial buss idle check) | |-STOIRQ1 (put key tape vectors into table) | |-NCHAR (sets bit counter for char input operations) | |-TPSTOP (see above) | |-IUDTIM (update jiffy clock; previous installment) | |-RECDMS (prompt user to press PLAY & RECORD on tape deck) |-WRTPHD (write a tape header to tape) | |-SETSBT (see above) | |-TPWRIT1 (precedes TPCODE by 12 bytes) When dealing with the tape, it helps to understand that Commodore built the tape protocol with an eye towards readability under adverse conditions, including tape quality and motor speed. This made Commodore tapes probably one of the most reliable data systems when compared to TI, Apple, and Tandy, among others. Data headers and data blocks are repeated on the tape and a comparison is made between the two reads to ensure integrity. Additionally, the recording method is self-clocking so the effects of varying tape speed are minimized. One of the first routines used in opening the tape device is determining the address of the tape buffer and making sure that it's in the $02xx page (or higher) of the system RAM. A test at IOPEN_S5 bails out with an "illegal device" error if the tape buffer isn't just so. F84D ;============================================================= F84D ; GETBFA - Get start of tape buffer F84D ; Returns buffer address in .X (LSB) and .Y (MSB) F84D ; F84D GETBFA F84D A6 B2 LDX TAPE1 F84F A4 B3 LDY TAPE1+1 F851 C0 02 CPY #$02 ;is buffer at $02xx? F853 60 RTS PLAYMS is called by IOPEN at IOPEN_S6. A test there determines if we're trying to read/load or write/save from/to a tape. Read mode results in the "Press Play..." message, the "Searching for..." message and two routines that search for the appropriate tape header. F894 ;=============================================================== F894 ; PLAYMS - Wait for tape key on read F894 ; F894 PLAYMS F894 20 AB F8 JSR TPSTAT ;get tape key status F897 F0 1C BEQ TPSTEX ;$F8B5 pressed? yes, exit. F899 F899 A0 1B LDY #KIM_PLAY ;offset for "Press Play..." message F89B PLAYMS1 F89B 20 E6 F1 JSR MSG ;print it F89E WTPLAY F89E 20 4B F9 JSR TPSTOP ; check for STOP key F8A1 20 AB F8 JSR TPSTAT ;get key status F8A4 D0 F8 BNE WTPLAY ;$F89E wait for PLAY switch F8A6 A0 6A LDY #KIM_OK ;offset for "OK" message F8A8 4C E6 F1 JMP MSG ;print it and return to caller This simple routine prints the "Searching for..." message only if in direct mode, and if appropriate, the filename that is being searched for. If no filename is present, the message is changed to "Searching..." (without the "for") before it's output to the screen. F647 ;============================================================== F647 ; SRCHMS - Print "Searching for [filename]" F647 ; F647 SRCHMS F647 A5 9D LDA CMDMOD ;direct mode? F649 10 1E BPL SRCHEX ;$F669 no (programmed mode), exit F64B F64B A0 0C LDY #KIM_SRCH ;"Searching" message F64D 20 E6 F1 JSR MSG ;output message F650 A5 B7 LDA FNMLEN ;get filename length F652 F0 15 BEQ SRCHEX ;$F669 no filename, skip "for" F654 A0 17 LDY #$17 ;point to "FOR" in "Searching For" F656 20 E6 F1 JSR MSG ;print it F659 ; F659 ; FLNMMS - Print filename F659 ; F659 FLNMMS F659 A4 B7 LDY FNMLEN ;get filename length F65B F0 0C BEQ SRCHEX ;$F669 no filename, exit F65D A0 00 LDY #$00 F65F FLNMLP ; loop to print filename F65F B1 BB LDA (FNPTR),Y ;get character F661 20 D2 FF JSR CHROUT ; and print it F664 C8 INY F665 C4 B7 CPY FNMLEN F667 D0 F6 BNE FLNMLP ;$F65F loop F669 SRCHEX F669 60 RTS ;exit If the user is attempting to open a tape file with a specific filename, the IOPEN code makes a call to LOCSPH in IOPEN_S6 to find the file header associated with the filename. If the specific header is not found, the routine emits the "File not Found" error message. LOCSPH is a loop wrapper around the LOCTPH routine that searches for the "next" file header regardless of name. The secondary address parameter of the OPEN command defines whether the action is to (0) read a tape file and relocate it in memory, (1) read a tape file without relocation (a machine program), or (2) write a tape file and put both EOF an EOT markers after it. Once a header is found, the filename from the header is compared to the filename in the OPEN command to see if there's a match. F867 ;============================================================ F867 ; LOCSPH- Find specific tape header F867 ; F867 LOCSPH F867 20 AF F7 JSR LOCTPH ;search for next header F86A B0 1D BCS LCSPEXC+1 ;$F889 returned EOT? Go to ready F86C A0 05 LDY #$05 ;filename offset in header F86E 84 9F STY TPTR2 ;save offset F870 A0 00 LDY #$00 ;loop counter F872 84 9E STY TPTR1 ;save it F874 LCSPHLP F874 C4 B7 CPY FNMLEN ;compare filename length F876 F0 10 BEQ LCSPEXC ;$F888 counter 0, exit F878 F878 B1 BB LDA (FNPTR),Y ;get filename letter F87A A4 9F LDY TPTR2 ; offset to name in header F87C D1 B2 CMP (TAPE1),Y ;compare to tape header F87E D0 E7 BNE LOCSPH ;f867 different, get next header F880 E6 9E INC TPTR1 ;increment counters F882 E6 9F INC TPTR2 F884 A4 9E LDY TPTR1 F886 D0 EC BNE LCSPHLP ;$F874 compare next character F888 LCSPEXC F888 18 CLC ; exit success F889 60 RTS Tape searches are performed linearly, so the LOCTPH routine is used to search for the next file header on the tape starting from the current tape position. This routine is also called by LOAD through IOPEN if no filename is provided as a parameter to the LOAD (or OPEN) call. Additionally, it's called in a loop by the LOCSPH routine when searching for a specific file. LOCTPH reads a tape block to the tape buffer using TPREAD and examines a few important fields in the file header. The fields examined indicate the file type and the filename. If the file header indicates that the file is anything other than a program file or a data file, the routine looks for another file header until it reaches the end of the tape. If BASIC is in "direct mode", LOCTPH prints the "Found" message in addition to the filename. F7AF ;============================================================ F7AF ; LOCTPH - Read any tape header F7AF ; Header type: 1=BASIC program, 2=data block, 3=machine program, F7AF ; 4=data header, 5=end-of-tape marker F7AF ; F7AF LOCTPH F7AF A5 93 LDA IOFLG2 F7B1 48 PHA ;save load/verify flag F7B2 20 C0 F8 JSR TPREAD ;read tape block to buffer F7B5 68 PLA F7B6 85 93 STA IOFLG2 ;restore flag F7B8 B0 2C BCS LOCTPEX ;F7E6 error, end search F7BA A0 00 LDY #$00 ;index reg F7BC B1 B2 LDA (TAPE1),Y ;get header type F7BE C9 05 CMP #$05 ;EOT? F7C0 F0 24 BEQ LOCTPEX ;$F7E6 yes, exit F7C2 C9 01 CMP #$01 ;BASIC program? F7C4 F0 08 BEQ LOCTP1 ;$F7CE yes, branch F7C6 C9 03 CMP #$03 ;ML program? F7C8 F0 04 BEQ LOCTP1 ;$F7CE yes, branch F7CA C9 04 CMP #$04 ;data header? F7CC D0 E1 BNE LOCTPH ;must be data block-skip it F7CE LOCTP1 ;program or data header comes here F7CE AA TAX F7CF 24 9D BIT CMDMOD ;direct mode? F7D1 10 11 BPL LOCTPEX-2 ;$F7E4 no, continue F7D3 A0 63 LDY #KIM_FOUN ;setup for "Found" msg F7D5 20 E6 F1 JSR MSG ;print it F7D8 A0 05 LDY #$05 ;offset to file name F7DA LOCLOOP ; loop to print filename F7DA B1 B2 LDA (TAPE1),Y ;print loop F7DC 20 D2 FF JSR CHROUT F7DF C8 INY F7E0 C0 15 CPY #$15 ;21 characters max F7E2 D0 F6 BNE LOCLOOP ;$F7DA loop F7E4 18 CLC F7E5 88 DEY F7E6 LOCTPEX F7E6 60 RTS The TPREAD routine is responsible for the setup required to read or verify a block from the tape. It prompts the user to press the PLAY button on the tape deck, disables system interrupts and sets some important parameters before execution falls through to the code responsible for starting the tape IRQ routines. In many Commodore machines, tape operations are performed under the operation of a routine installed as a temporary IRQ handler -- sort of a cheap multitasking so that the system doesn't appear to be hung while tape operations are occurring. Execution ultimately comes to code at $F8EF (TPCODE) which is responsible for installing and starting the tape IRQ routine. All of this and we haven't yet reached the bits on the tape :-) F8C0 ;========================================================== F8C0 ; TPREAD - Read tape block F8C0 ; F8C0 TPREAD F8C0 A9 00 LDA #$00 F8C2 85 90 STA CSTAT ;clear status variable... F8C4 85 93 STA IOFLG2 ;and read/verif flag F8C6 TPREAD1 F8C6 20 54 F8 JSR SETBST ;set tape buffer pointers F8C9 ; F8C9 ; load program F8C9 ; F8C9 TPREAD2 F8C9 20 94 F8 JSR PLAYMS ;wait for Play key F8CC B0 1F BCS TPCODE-2 ;$F8ED (in TPWRIT1) F8CE F8CE 78 SEI ;disable interrupts F8CF A9 00 LDA #$00 ;clear work memory for IRQ routines F8D1 85 AA STA RIDATA F8D3 85 B4 STA BITTS F8D5 85 B0 STA TPCON1 F8D7 85 9E STA TPTR1 F8D9 85 9F STA TPTR2 F8DB 85 9C STA BYTINF F8DD A9 82 LDA #$82 ;Timer H constant F8DF A2 0E LDX #$0E ;number for IRQ vector F8E1 D0 11 BNE TPCODE1 ;$F8F4 (TPCODE1 in TPWRIT below) ; falls through to TPWRIT below) SRCHMS also calls this small routine to determine if a key is pressed on the tape deck. TPSTAT looks at PA6 on VIA1 to determine the key state and sets up the Z flag for a compare to be performed in SRCHMS. F8AB ;============================================================ F8AB ; TPSTAT - Check tape key status F8AB ; F8AB TPSTAT F8AB A9 40 LDA #%01000000 ;$40 F8AD 2C 1F 91 BIT D1ORAH ;switch sense F8B0 D0 03 BNE TPSTEX ;$F8B5 not pressed, exit F8B2 2C 1F 91 BIT D1ORAH ;button pressed. Setup for another F8B5 ;compare later. Z=1 if pressed F8B5 TPSTEX F8B5 18 CLC F8B6 60 RTS ;return clear One of the tests performed in IOPEN (also at IOPEN_S6) is to determine if the tape operation is a read or a write. If we're in the write mode, the code jumps to IOPEN2. At IOPEN2, the Kernal prompts for the user to press play and record on the tape deck and then writes a file header by calling WRTPHD with a control ID of $04 (a data header). Then, IOPEN writes a control byte ID of $02 (block is a data block) to the tape buffer and returns. RECDMS is called by IOPEN to determine if a key is pressed on the tape deck and if not, sets the message flag to the "Press Play & Record" message and prints the message by calling into the PLAYMS routine. PLAYMS prints the message and then checks for the key press. F8B7 ;=========================================================== F8B7 ; RECDMS - Wait for tape key on write F8B7 ; F8B7 RECDMS F8B7 20 AB F8 JSR TPSTAT ;get button status F8BA F0 F9 BEQ TPSTEX ;$F8B5 pressed? Yes, continue F8BC A0 2E LDY #KIM_RECP ;no, remind to "Press Play & Record" F8BE D0 DB BNE PLAYMS1 ;exit through $F89B IOPEN calls WRTPHD at IOPEN2 with $04 as the control byte (following block is a data header) to be written into the header. WRTPHD then writes some critical information into zero-page locations in advance of filling the tape buffer with the same information. At the end of the routine, the data is written to the tape in WRTPH1. F7E7 ;========================================================== F7E7 ; WRTPHD - Write tape header F7E7 ; On entry, .A is the header type: 2=data blk; 4=data hdr F7E7 ; F7E7 WRTPHD F7E7 85 9E STA TPTR1 ;save header type F7E9 20 4D F8 JSR GETBFA ;get tape buffer address F7EC 90 5E BCC WRTPEX ;$F84C exit if not right F7EE A5 C2 LDA STAL+1 ; save some program info F7F0 48 PHA ;save...start H F7F1 A5 C1 LDA STAL F7F3 48 PHA ;...start L F7F4 A5 AF LDA EAL+1 F7F6 48 PHA ;...end H F7F7 A5 AE LDA EAL F7F9 48 PHA ;...end L F7FA A0 BF LDY #$BF ;buffer length-1 (191) F7FC A9 20 LDA #$20 ; {space} F7FE WRTPLP1 ; write program data to tape buffer F7FE 91 B2 STA (TAPE1),Y ;clear buffer F800 88 DEY F801 D0 FB BNE WRTPLP1 ;$F7FE F803 A5 9E LDA TPTR1 ;get header type F805 91 B2 STA (TAPE1),Y ;write it F807 C8 INY F808 A5 C1 LDA STAL ;get start L F80A 91 B2 STA (TAPE1),Y ;write it F80C C8 INY F80D A5 C2 LDA STAL+1 ;get start H F80F 91 B2 STA (TAPE1),Y ;write it F811 C8 INY F812 A5 AE LDA EAL ;get end L F814 91 B2 STA (TAPE1),Y ;write it F816 C8 INY F817 A5 AF LDA EAL+1 ;get end H F819 91 B2 STA (TAPE1),Y ;write it F81B C8 INY F81C 84 9F STY TPTR2 ;save buffer offset F81E A0 00 LDY #$00 ;filename loop counter F820 84 9E STY TPTR1 ;save loop counter F822 WRTPLP2 ; write filename to buffer F822 A4 9E LDY TPTR1 ;get loop counter F824 C4 B7 CPY FNMLEN ;compare filename length F826 F0 0C BEQ WRTPH1 ;$F834 done F828 B1 BB LDA (FNPTR),Y ;get filename char F82A A4 9F LDY TPTR2 ;get tape buffer pointer F82C 91 B2 STA (TAPE1),Y ;write char to buffer F82E E6 9E INC TPTR1 ;increment loop counters F830 E6 9F INC TPTR2 F832 D0 EE BNE WRTPLP2 ;$F822 loop F834 WRTPH1 ; flush buffer to tape F834 20 54 F8 JSR SETBST ;set start and end address pointers F837 A9 69 LDA #$69 ;checksum block ID F839 85 AB STA RIPRTY ;save parity character F83B 20 EA F8 JSR TPWRIT1 ;$F8EA write block F83E A8 TAY ;restore start and end addresses F83F 68 PLA F840 85 AE STA EAL F842 68 PLA F843 85 AF STA EAL+1 F845 68 PLA F846 85 C1 STA STAL F848 68 PLA F849 85 C2 STA STAL+1 F84B 98 TYA F84C F84C WRTPEX F84C 60 RTS ;exit SETBST is a helper routine called by LOCTPH and WRTPHD to setup the tape buffer before a tape operation takes place. It sets the starting address of the buffer to the first address of the assigned buffer range and sets the end of the buffer to start + 192 bytes. F854 ;========================================================== F854 ; SETBST - Set buffer start/end pointers F854 ; F854 SETBST F854 20 4D F8 JSR GETBFA ;get buffer address F857 8A TXA F858 85 C1 STA STAL ;start=start of buffer F85A 18 CLC F85B 69 C0 ADC #$C0 ;end=start+192 F85D 85 AE STA EAL F85F 98 TYA F860 85 C2 STA STAL+1 F862 69 00 ADC #$00 F864 85 AF STA EAL+1 F866 60 RTS TPWRIT performs the nuts and bolts of moving cassette data in and out of the VIC. The VIC moves tape data by using a series of interrupt routines that are swapped into the IRQ vector as needed. The benefit here is that the tape IRQ code is then executed 60 times per second, along with normal processing, until the operation is complete, resulting in a cheap form of multitasking. TPWRIT performs some setup chores before changing the IRQ vector, including clearing interrupts, ensuring that the IEEE serial bus is idle, saving the old vector, assigning the new vector and setting-up the variables used by the tape IRQ routine to actually move bits. Finally, interrupts are enabled at $F92E which starts the whole process. When the tape IRQ routine is finished it restores the IRQ vector to the standard $EABF which is detected by TPWRIT at TPCDLP2 (an I/O loop). When completed, TPWRIT exits through the TPSTOP routine. This loop also updates the jiffy clock. F8E3 ;========================================================== F8E3 ; TPWRIT - Initiate tape write F8E3 ; F8E3 TPWRIT F8E3 20 54 F8 JSR SETBST ;get buffer pointers F8E6 A9 14 LDA #$14 ;checksum F8E8 85 AB STA RIPRTY ;save it F8EA ; F8EA ; write buffer to tape F8EA ; F8EA TPWRIT1 F8EA 20 B7 F8 JSR RECDMS ;wait for Record+Play keys F8ED B0 68 BCS TPSTPX1 ;$F957 exit F8EF ; F8EF ; TPCODE - Common tape code F8EF ; F8EF TPCODE F8EF 78 SEI ;disable interrupts F8F0 A9 A0 LDA #%10100000 ;$A0 Timer H constant F8F2 A2 08 LDX #%00001000 ;$08 offset for tape IRQ vector F8F4 TPCODE1 F8F4 A0 7F LDY #%01111111 ;$7F disable interrupts F8F6 8C 2E 91 STY D2IER ;save to interrupt enable reg F8F9 8D 2E 91 STA D2IER ; on VIAs F8FC 20 60 F1 JSR SBIDLE ;wait for timeout F8FF AD 14 03 LDA IRQVP ;save current IRQ Vector F902 8D 9F 02 STA TAPIRQ F905 AD 15 03 LDA IRQVP+1 F908 8D A0 02 STA TAPIRQ+1 F90B 20 FB FC JSR STOIRQ1 ;$FCFB .X=8 set tape IRQ vectors F90E A9 02 LDA #$02 ;read # of blocks F910 85 BE STA FSBLK F912 20 DB FB JSR NCHAR ;set bit counter for serial shifts F915 AD 1C 91 LDA D1PCR F918 29 FD AND #%11111101 ;$FD CA2 manual low F91A 09 0C ORA #%00001100 ;$0C force bits 2,3 high F91C 8D 1C 91 STA D1PCR ;switch on tape drive F91F 85 C0 STA CAS1 ;flag as on F921 A2 FF LDX #$FF ;delay loop for high (outer) F923 A0 FF LDY #$FF ;inner loop F925 TPCDLP1 F925 88 DEY F926 D0 FD BNE TPCDLP1 ;$F925 F928 CA DEX F929 D0 F8 BNE TPCDLP1-2 ;$F923 outside loop F92B 8D 29 91 STA D2TM2H F92E 58 CLI ;allow tape interrupts F92F ; F92F ; wait for I/O-end F92F ; F92F TPCDLP2 F92F AD A0 02 LDA TAPIRQ+1 ;check IRQ direction F932 CD 15 03 CMP IRQVP+1 ;standard vector? F935 18 CLC F936 F0 1F BEQ TPSTPX1 ;$F957 yes, ready F938 20 4B F9 JSR TPSTOP ;no, check STOP key F93B AD 2D 91 LDA D2IFR ;timer 1 IF clear? F93E 29 40 AND #%01000000 ;$40 F940 F0 ED BEQ TPCDLP2 ;$F92F continue F942 AD 14 91 LDA D1TM1L ; get timer 1 loword F945 20 34 F7 JSR IUDTIM ;update RTC F948 4C 2F F9 JMP TPCDLP2 ;$F92F loop TPSTOP is another helper routine. It scans for the keyboard STOP key, and if detected, restores the standard IRQ vector and returns to the caller's caller (the caller to TPWRIT). F94B ;=========================================================== F94B ; TPSTOP - Check for tape stop F94B ; F94B TPSTOP F94B 20 E1 FF JSR STOP ;scan STOP key F94E 18 CLC F94F D0 0B BNE TPSTPX ;$F95C not pressed, return F951 20 CF FC JSR RESIRQ ;pressed, turn drive off and restore IRQ F954 38 SEC ;set error flag F955 68 PLA ;pop return address F956 68 PLA F957 TPSTPX1 F957 A9 00 LDA #$00 ;flag normal IRQ vector F959 8D A0 02 STA TAPIRQ+1 F95C TPSTPX F95C 60 RTS ; return to caller's caller The SBIDLE routine is used in TPWRIT to detect if the RS-232 serial bus is active and if so, will wait until it's idle before returning to continue the tape code. F160 ;=========================================================== F160 ; SBIDLE - Set timer for serial bus timeout F160 ; F160 SBIDLE F160 48 PHA ;save .A F161 AD 1E 91 LDA D1IER ;get IER F164 F0 0C BEQ SBIDLEX ;$F172 no interrupts pending, exit F166 SBIDLLP F166 AD 1E 91 LDA D1IER ;get IER F169 29 60 AND #%01100000 ;$60 T1 & T2 F16B D0 F9 BNE SBIDLLP ;$F166 F16D F16D A9 10 LDA #%00010000 ;$10 kill CB1 RS232 F16F 8D 1E 91 STA D1IER F172 SBIDLEX F172 68 PLA F173 60 RTS RATS3 is at the tail end of the RAMTAS routine - the subroutine that precedes the tape vectors. FCF6 ;=========================================================== FCF6 ; STOIRQ - Set IRQ vector FCF6 ; usually called with .x=$08 or $0e. $08 points to the first FCF6 ; entry in TAPVEC while $0e points to the last entry FCF6 STOIRQ FCF6 20 CF FC JSR RESIRQ ;restore std IRQs FCF9 F0 97 BEQ TPEOI ;$FC92 FCFB STOIRQ1 FCFB BD E9 FD LDA RATS3,X ;$FDE9,X set vectors from table FCFE 8D 14 03 STA IRQVP FD01 BD EA FD LDA RATS3+1,X ;$FDEA,X FD04 8D 15 03 STA IRQVP+1 FD07 60 RTS These are the actual routines responsible for writing bits to the tape. Various calling routines place these vectors into the IRQ vector that then gets executed 60 times per second. In order, these routines are for writing a leader block to the tape, the routine to write data to the tape, the standard IRQ vector, and the routine to read bits from the tape. FDF1 ;=========================================================== FDF1 ; TAPEVC - Tape IRQ vectors FDF1 ; FDF1 TAPEVC FDF1 ; .dw $FCA8, $FC0B, $EABF, $F98E FDF1 A8FC0BFCBFEA .dw WRLDR2, TWRD7, IRQVEC, RDTPBT FDF7 8EF9 The NCHAR routine resets the internal bit counters to their initial state before a calling routine begins to shift the bits over a serial or tape channel. It sets the bit length to 8 and resets intermediate variables to 0. FBDB ;=========================================================== FBDB ; NCHAR - Set bit counter for new character (serial output) FBDB ; FBDB NCHAR FBDB A9 08 LDA #$08 FBDD 85 A3 STA SBITCF FBDF A9 00 LDA #$00 FBE1 85 A4 STA CYCLE FBE3 85 A8 STA BITCI FBE5 85 9B STA TPRTY FBE7 85 A9 STA RINONE FBE9 60 RTS That's all of the room we have for this part of the series. All of this code and we still haven't moved the bits off the tape. So, next time we'll look at the actual routines responsible for moving bits on and off of the tape and we'll begin to look at the IEEE serial routines. ....... .... .. . C=H 20 :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: ----------------------- The Art Of The Minigame ----------------------- Introduction ------------ In summer 2001, an 8-bit minigame contest was held. Voter turnout may have been low, but author turnout was high, with a total of thirty entries for the C64, Spectrum, Amstrad, and Atari 8-bit. The entries and the results are available at http://demo.raww.net/minigame/, and what follows are a series of articles by several minigame authors. The articles are for enjoyment, to stimulate thought, and, hopefully, to motivate people for next year's contest. (Everyone had a great time, btw.) Minigames -- writing tiny programs in general -- present a set of very unique challenges. Whereas many of us are used to optimizing programs for cycle-efficiency, optimizing programs for byte-efficiency turns out to be very different -- challenging, at times aggrivating, but very rewarding. I think you will find the challenges and solutions ahead to be very interesting. The game authors were pioneers, exploring pretty new territory, and I salute all that entered the contest (especially for making such a nice C64 showing). A special mention should be made of MagerValp ("Skinny Puppy" in Swedish, in case you've been wondering), who motivated a number of lazy people (e.g. myself) to get involved with the contest. Before diving in to the articles ahead, which contain lots of tricks to save memory, I thought it might get us in the mood to go over some general ideas and concerns for saving bytes on a C64. It should be obvious that some balance has to be found between cycle- efficiency and memory-efficiency. Routines should be reasonably fast in most cases, but the major portion of that balance is -- has to be -- memory efficiency. Obviously, everything that can be put into zero page should be put there, since zero page instructions are two bytes instead of three. Equally obvious is that every list-type fetch should use the .X register, since there is no zero-page lda xx,y instruction. Moreover, memory is initialized to various values when the computer is reset. Many zero-page locations have specific values -- by careful code design, these can be taken advantage of to avoid any initialization code. For example, many times the code needs a zero-page pointer with the low byte zero (i.e. sta (point),y). If that variable is chosen as a location which is already zero, the code doesn't have to waste four bytes on lda #00 sta point. One-time counters can also be used in this way. Finally, certain locations can be manipulated to have certain values; for example, the Tetrattack load address forces the end address+1 to be $10f0, because $10 and $f0 are the z-coordinates of two object vertices. There is also a fairly substantial kernal to take advantage of -- routines to clear the screen, perform memory manipulations, and so on. Knowing what is available, and what the routines do, is handy. Self-modifying code comes in handy when large portions of code can be used for similar things with just a few changes. For example, the line routine in Tetrattack uses a single routine for lines in both the x- and y-directions, by just interchanging a few INX/INY instructions. And finally, there are things which Mark Seelye terms "injections", which seems like a good term to me. The idea is to reuse known register values whenever possible. That is, instead of having LDA #00 STA zp in a subroutine, you might put an "STX zp" in an earlier subroutine, where you know the value of .X is zero. In other words, the instruction to clear the zp location is "injected" into a different subroutine to save a couple of bytes. Every program below uses this trick in one form or another. You'll see lots of neat tricks in the articles ahead, but the basic design framework is: put stuff in zero-page, take advantage of default zero-page values, take advantage of the kernal, always know the register values, and reuse as much code as possible. With that, let's check out the programs. ....... .... .. . C=H 20 =====================================Part 1=================================== Postmortem: Codebreaker by White Flame (aka David Holz) http://white-flame.com The most difficult part of making an entry for this compo was deciding what game to do that could fit in 512 bytes. The ubiquitous snake clones and scroll/dodge games abound; finding something different is truly a challenge. I wanted to do a Mastermind game a looong time ago, with a full-on "We're getting attacked, solve to code to save the world!" type of setting with lots of animation, but usually found myself in application and library/utility programming instead. But luckily this idea popped back into my head as something different that would fit in the 2-page limit. The concept is pretty simple (as most 512-byte games are). There's a 4-color secret key, you get 10 guesses at it. Two numbers reflect the score for the current guess: a black number shows how many slots have the right color, and a white number shows how many correct colors are in the guess, but in the wrong slot. Experienced players should be able to deduce the correct answer in 4-6 guesses. Random number generation There are 3 obvious ways to get random numbers easily in the 64: SID oscillator with noise waveform, BASIC prng, or the raster counter. I decided to go for the raster solution. Obviously, you need to wait a certain amount of time between reads of the raster location, or they'd be the same or linear. To fix this, every time I wanted a random number, I'd read the raster and call a loop that many times. When it was done, $d012 should have a "random enough" number in its lower 3 bits (to get a color from 0 to 5). randomDelay: ldx $d012 randomLoop: jsr delay dex bne randomLoop; delay: dey bne delay rts Code space saving What's interesting is that it actually took less bytes of code and data to have custom draw loops for the 3 textual messages than setting a pointer and calling a drawText function. ldy #22 titleloop: lda titletext,y sta screenLoc-48,y dey bpl titleloop: These functions assume that you're not running on an ancient kernal, so color memory is initialized to the cursor color (white). I also had all variables (except the secret key) use screen memory, color memory, or register .Y instead of manipulating off-screen variables and then updating the screen to match: - For entering a guess, color memory is INC'd and DEC'd, with .Y holding the current offset from the fixed screen location. JSR $e8ea is used to scroll the screen, and everything is drawn to screen line 23. - To find out if you've already made 10 guesses, a memory location near the top of the screen was polled to see if it was still a space. As guesses are made, the screen was scrolled, and if the title line ever scrolled into that memory location, it's game over. - The current guess score '0 0' is drawn to the screen first, then those screen values are INC'd or DEC'd as matches are found. - Winning constitutes checking to see if the screen has a '4' in the black score location. Score calculation This was a bit of a pain. At first I trying to calculate the white score by the scoring rules (every matching color that was in the wrong slot), INCing the white score on every hit, then looping for exact matches, INCing the black score on every hit. This code ended up being way too big, so I got the idea to simplify the white loop: INC the white score on every color match, whether in the right spot or not, then on every black match, DEC the white score and INC the black score. This gave the same result, but with much simpler code. Conclusion All in all, it was an interesting challenge for me, and had I not gotten stuck in Phoenix for an extra week, I'd have finished my 2k entry. The code is pretty straightforward, and at 415 bytes total, I'm quite happy to claim having the smallest entry, even if I didn't win (why Pacman didn't beat out a low-res scroll/dodge/single-shot game is a mystery to me). Being a hobbyist C64 programmer, and my career history being more on the research end of things, having a deadline is a nice change in terms of getting a project completed. :) Publisher: Gasman/Raww Arse Number of full-time developers: 1 programmer Number of contractors: 0 Estimated budget: 0 USD (0 EUR) Development time: About 5 hours Release date: August 22, 2001 Platforms: Commodore 64 and compatibles Development hardware: pee sea (1.4GHz T-bird, 512MB RAM, 100GB HD, Win98SE) 3rd party tools: xa, Vice, Notepad In-house tools: none Project size: 290 lines of 6502 assembly code, containing 1 line of tokenized BASIC --- cb.asm --- .word $0801 ;Starting address for loader * = $0801 .word nextLine ;Line link .word $0 ;Line number .byte 158 ;SYS .byte 50,48,54,54 ;"2066" .byte 0 nextLine .byte 0,0 ;end of basic screenGameOver = $0400+(4*40)+16 screenLoc = $0400+(23*40)+16 colorLoc = $d800+(23*40)+16 screen = $fd color = $fb peg = 81 cursor = 87 crsr_up = 145 crsr_down = 17 crsr_left = 157 crsr_right = 29 WaitStart: jsr $ffe4 beq WaitStart Start: ;Change to white lda #$05 jsr $ffd2 ;Clear screen lda #147 jsr $ffd2 ;Set bg colors lda #$00 sta $d020 sta colorLoc sta colorLoc+1 sta colorLoc+2 sta colorLoc+3 sta $ff ;for cursor lda #11 ;dark gray sta $d021 ;Init zp pointers lda #screenLoc sta screen+1 lda #>colorLoc sta color+1 ;Draw text ldy #22 titleloop: lda titletext,y sta screenLoc-48,y dey bpl titleloop: ;Randomize values iny newGuess sty $02 guessAgain jsr randomDelay lda $d012 and #$07 cmp #$06 bpl guessAgain ldy $02 checkLoop: ;See if the color's already in the key dey bmi goodGuess cmp secret,y beq guessAgain bne checkLoop goodGuess: ;Save the color and put a peg on the screen to show progress ldy $02 sta secret,y lda #peg sta (screen),y iny cpy #$04 bne newGuess Game_Loop: ;Get cursor pos ldy $ff ;Draw cursor lda #cursor sta (screen),y Key_Loop: ;Wait for key getin: jsr $ffe4 beq getin ldy $ff ;If up, bump color up cmp #crsr_up bne notUp lda (color),y and #$07 adc #$00 ;carry is always set after the cmp cmp #$06 bne colorNotHigh lda #$00 colorNotHigh: sta (color),y notUp: ;If down, bump color down cmp #crsr_down bne notDown lda (color),y and #$07 bne colorNotLow lda #$06 colorNotLow: sbc #$01 ;carry is always set after the cmp sta (color),y notDown: ;If left, cmp #crsr_left bne notLeft ; Erase cursor lda #peg sta (screen),y ; Bump cursor left dey bpl cursorOKLeft ldy #$03 cursorOKLeft: ; Draw cursor lda #cursor sta (screen),y notLeft: ;If right, cmp #crsr_right bne notRight ; Erase cursor lda #peg sta (screen),y iny cpy #$04 bne cursorOKRight ldy #$00 cursorOKRight: lda #cursor sta (screen),y notRight: ;Store cursor pos sty $ff ;If not enter, goto Key_Loop cmp #13 bne Key_Loop ;------------------------ ;Erase cursor lda #peg sta (screen),y ;Check score ;Draw "0 0" lda #'0 sta screenLoc+5 sta screenLoc+7 lda #0 ;black sta colorLoc+5 ;Calculate white score (any matches between the guess & key) ldy #3 yLoop: ldx #3 xLoop: lda (color),y and #$07 cmp secret,x bne noWhiteMatch inc screenLoc+7 noWhiteMatch dex bpl xLoop dey bpl yLoop ;Calculate black score (exact matches between the guess & key) ldy #3 blackLoop: lda (color),y and #$07 cmp secret,y bne noBlackMatch inc screenLoc+5 dec screenLoc+7 noBlackMatch: dey bpl blackLoop ;Check win (black score = "4") lda #'4 cmp screenLoc+5 beq Win ;Check for Game Over (screen's getting full) lda screenGameOver cmp #32 bne Game_Over ;Scroll screen up 2 rows jsr $e8ea jsr $e8ea ;Copy last hand ldy #3 copyLoop: lda colorLoc-80,y sta (color),y lda #peg sta (screen),y dey bpl copyLoop: jmp Game_Loop Game_Over: jsr $e8ea ldy #8 ;Show "YOU SUCK!" loseloop: lda losetext,y sta (screen),y dey bpl loseloop: jsr $e8ea ldy #3 ;Reveal the secret key losecopy: lda secret,y sta (color),y lda #peg sta (screen),y dey bpl losecopy jmp WaitStart Win: jsr $e8ea ldy #15 ;Show Zero Wing reference winloop: lda wintext,y sta screenLoc-4,y dey bpl winloop rts randomDelay: ldx $d012 ;Loop n times, n = current raster location randomLoop: jsr delay dex bne randomLoop; delay: dey bne delay rts wintext .byte 1,32,23,9,14,14,5,18,32,9,19,32,25,15,21,33 losetext .byte 25,15,21,32,19,21,3,11,33 titletext .byte 58,58,58,32,3,54,52,32,3,15,4,5,2,18,5,1,11,5,18,32,58,58,58 secret = * --- end cb.asm --- ....... .... .. . C=H 20 =====================================Part 2=================================== Tinyplay by SLJ -------- Like many people, I was a serious Ultima guy growing up. Not only did I love the games (and still do), but I absolutely loved the music (and still do). In fact, I think the music from III and IV is some of the finest, most musical composing ever done on the C64, and the best example of how appropriate, atmospheric music can add volumes (so to speak) to a game. It also turns out that I had been thinking about some new ideas for a new music system for a while. So when Robin said he was writing an Ultima-like game -- well! I'm certainly no Kenneth W. Arnold, but a set of tiny tunes sure sounded like a neat and fun thing to do, especially in the Ultima style! Robin removed a lot of neat features and text to add in the music, so I feel pretty honored to have been included. The player and tunes were written on pretty short notice; I think we started a week or so before the deadline. Moreover, I had to go out of town the weekend of the deadline, getting back that Sunday, where many of the optimizations took place in frantic coding sessions! So it shouldn't be surprising that the code is not as optimal as it could be -- if you get around to looking at the source code, you'll see an awful lot of weird, duplicate, and otherwise embarrasing lines of code which anyone with a clear head would never have used. But as is, the player and three tunes take up some 428 bytes (I think), which isn't too bad. (Later on I'll describe a music compression routine which would have worked great, but that I didn't have time to implement.) Broadly speaking, the player is a note-duration based player, using two sawtooth voices for that Ultima sound (originally, before reality kicked in, the thinking was to put some sound fx in the third voice). It uses a single play routine, using the .X register to tell which voice to play, and can play multiple tunes. The original version has several effects included like major and minor arpeggios, but memory constraints forced these to be taken out (along with the original tune, alas, alas). The full source code for the player is at the end of this article, and can be divided into four primary parts: a one-time init routine, a routine to select a tune, a routine to play notes, and the music data. But perhaps the best way to understand the player is to begin by looking at how the data for a tune is stored. Tune encoding ------------- Here is the third, "castle" tune, in its entirety: * Tune 3: Castle t2v1 dfb 97 ;gate dfb 24+$80-1 dfb 70,96,72 dfb 6+$80-1 dfb 96,96,70,72 dfb 12+$80-1 dfb 74,74,96,75 dfb 24+$80-1 dfb 77,75,74,96,72 dfb 6+$80-1 dfb 96,96,70,72 dfb 24+$80-1 dfb 70,96,96,96 dfb 00 t2v2 dfb 97 dfb 24+$80-1 dfb 34,41,46,41 dfb 34,41,46,41 dfb 58,62,53,57 dfb 58,53,46,96 dfb 0 end The first chunk of data (t2v1) is the voice 1 data, and the second chunk the voice 2 data (t2v2). There are 12 notes in an octave, and eight octaves total, for 96 possible notes. In the player, data bytes 0-95 represent these notes -- the player just uses the data as an index into a frequency table. This leaves bytes 96 and above free for other uses; also, since the lower note values are rarely used, values like 0, 1, etc. are also free in principle. Byte values >= 128 are used to specify the note duration. When the player encounters these byte values it simply strips the high bit and stores the result. When a new note is read in, this stored duration is placed into a counter which is decremented on each player call. In the castle tune above, the duration for voice 2 is only set once, at the very beginning -- every note has the same duration. In the voice 1 data it is set several times, to change the duration when appropriate. For some reason the code decrements the duration counter until it becomes negative, instead of decrementing it until it becomes zero. I'm sure it was because of something relevant in an earlier version of the player, I just totally don't remember what. But that is why the durations above are encoded as 24+$80-1 -- the -1 is to make it go negative, instead of to zero. Looks weird. Byte values >95 and <128 are available for 'special' features. The player supports one "effect": the gate toggle. The player can either leave the gate bit on all the time or can turn it off right as a note is about to finish (say, when the duration counter decrements down to 2). A byte value of 97 turns the gate toggle on -- in the castle tune above it is the first byte of data. In other tunes, a value of 100 is used to turn off gate toggling. (Historically speaking, values 98 and 99 were used for major and minor arpeggios, and 100 was for no fx at all.) The byte value 96 is used as a "rest" (or hold) -- the player resets the duration counter but does not reset the gate bit or the note value. When there is no gate toggling this provides a way of holding a note for longer than the basic duration. When there is gate toggling, it starts the release cycle, and lets notes fade away. In the castle tune above, the first two notes in voice 1 are "70 96" -- a note, then a rest, during which the note fades away. Finally, the value 0 is used to indicate the end of data. While 0 is technically a note it never gets used as such. When the player hits a zero it simply resets the data pointer. So with all that in mind, let's take a look at the player. The Player ---------- Before playing a tune, a one-time routine is called which sets up the frequency tables; when the player reads a new note, it looks up the SID frequency settings in this table, along the lines of: ldy note,x lda freqlo,y sta $d400 lda freqhi,y sta $d401 Frequencies in different octaves are related by powers of 2. When you go up an octave, the frequency doubles -- for example, the frequency of C-5 is twice the frequency of C-4. The routine starts with a table of twelve frequency values for the highest octaves, and copies those values into the end of the frequency table (e.g. freqlo+95, freqlo+94, ..., freqlo+84). After copying, it divides each value by 2 and repeats the process -- now the frequencies correspond to the next lower octave -- and this is done until the frequency table is full. Piece of cake. Once the frequency table is set up, the main play routine is called with the tune number in .A. If the tune number is different than the current tune, the player simply resets the music data pointers and note durations, and falls through to the main player routine. And the main player routine simply decrements the duration, and when it becomes negative reads in the next data. Because the process -- decrement duration, read next data, store frequencies in SID -- is the same for each voice, a single routine is used for both voices one and two, using .X as the voice index. That is, instead of dec duration the code can use dec duration,x and instead of sta $d400 the code can use something like ldy SidOffset,x sta $d400,x where .X is 0 or 1. And that's about all there is to it -- the code is pretty straightforward. Compressing Music ----------------- Most music has the property that it doesn't jump around all over the place, but rather notes progress in relatively small intervals (seconds, thirds, fifths, etc.). For example, a major chord (as used in a typical arpeggio) is note note+4 note+7 note So the idea is to use a differential scheme, encoding the note _intervals_ instead of the note _values_. That is, the above could instead be encoded as x 4 3 -7 where each value is added to the current note value. Thus, if intervals are restricted to -8..7 then only four bits are required, cutting the data size in half. Well, not quite of course, because an escape code is needed to specify notes outside of the -8..7 range, not to mention durations and fx settings. Luckily, not all interval values are used, so these can be used for escape codes; code 96 -- the rest or "hold" note -- can also use an otherwise unused value. So the decompression code looks something like read next four bits if escape code then read next byte otherwise add to current note value Pretty simple, and leads to quite substantial savings. Ah, but for a little more time... Another possibility arises if not all note values are used. I set up the frequency tables to contain all 12 notes in eight octaves. If not all twelve notes are actually used -- if none of the tunes contain a D# or whatever -- then a few bytes can be shaved off of the frequency table. But this means a lot of renumbering and such... it all depends on just how much you want those extra bytes! And that's about all there is to it. Source code ----------- * * Tiny music player, for the * minigame contest. * * slj 9/23/01 * sjudd@ffd2.com * ; org $2800-491 org $2800-425 ARPDEL = 2 SR = $c9 GATEDEL = 2 curtune = $fe curdur = $fc tunepos = $b0 ;position in note sequence seqpos = $b2 ;position in seq list dur = $b4 ;2 bytes each notefx = $b6 curnote = $b8 newnote = $ba arpdur = $bc arpoff = $bd noteptr = $fa temp = $fa freqtab = $c000 * * Code begins here * start *------------------------------- do 0 jmp InitFreq jmp Play jmp Reset fin *------------------------------- * * InitFreq -- set up note table * InitFreq ldy #192 :l2 ldx #24 :loop lda freq-1,x sta freqtab-1,y dex dey lda freq-1,x sta freqtab-1,y lsr freq,x ror freq-1,x dey beq :xit dex bne :loop beq :l2 :xit rts * * Play routine * tune in .A * Reset ; Force a tune reset sec ror curtune ; ldx #255 ; stx curtune Play ldx #1 cmp curtune beq PlayTune sta curtune ldy #00 sty tunepos ; sty tunepos+1 sty dur sty dur+1 lda #15 sta $d418 lda #SR sta $d406 sta $d406+7 * * Main routine * * voice in .X (0, 1) * SetTune ldy curtune txa beq :v1 :v2 lda v2tunepos,y bne :sta :v1 lda v1tunepos,y :sta sta tunepos,x PlayTune Get lda #00 sta newnote,x dec dur,x bpl DoFx :next ldy tunepos,x inc tunepos,x lda tunes,y beq SetTune bpl :c1 and #$7f ;$80+dur sta curdur,x bne :next :c1 cmp #96 bcc :setfreq beq :hold sta notefx,x bne :next :note ;sta curnote,x ; sta newnote,x ; jsr setfreq :SetFreq asl tay lda Freqtab,y pha lda Freqtab+1,y ldy SidOffset,x sta $d401,y pla sta $d400,y :gate lda #$21 ldy SidOffset,x sta $d404,y :hold lda curdur,x sta dur,x DoFx ldy notefx,x cpy #97 bne AllDone ldy dur,x cpy #GATEDEL bcs :c1 lda #$20 ldy SidOffset,x sta $d404,y :c1 AllDone dex bpl PlayTune RTS rts * * Frequencies * freq da 34334 da 36377 da 38539 da 40831 da 43258 da 45831 da 48557 da 51444 da 54502 da 57743 da 61177 da 64815 ;arptab dfb 0,4,7 * * Tune positions (offset into sequence * list) * v1tunepos dfb t0v1-tunes dfb t1v1-tunes dfb t2v1-tunes v2tunepos dfb t0v2-tunes dfb t1v2-tunes dfb t2v2-tunes tunes ; dfb 00 ;dummy byte * * Sid offset table * SidOffset dfb 00 dfb 07 t0v1 dfb 97 dfb 10+$80-1 ;dur=10 dfb 42,47,51 dfb 42,47,51 dfb 42,47,47,47 dfb 51,51,51 dfb 54,52,51 dfb 52,51,49 dfb 51,51,51 dfb 49,49,49 dfb 51,49,47 dfb 49,47,46 dfb 44,44,44 dfb 46,46,46 dfb 47,51,47 dfb 46,42,46 dfb 47,96,96 dfb 96,96 dfb 00 t0v2 ;v2 dfb 97 dfb 10+$80-1 dfb 35,35,96 dfb 35,35,96 dfb 35,35,96 dfb 35,39,96 dfb 39,42,96 dfb 42,30,96 dfb 30,35,96 dfb 35,34,96 dfb 34,32,96 dfb 32,32,96 dfb 32,32,96 dfb 32,34,96 dfb 34,35,96 dfb 35,30,96 dfb 35,35,96 dfb 35,35,96 dfb 00 * Tune 2: Combat t1v1 dfb 100 ;No gate dfb 11+$80-1 ;dur=11 dfb 51,54,58,59,96 dfb 58,57,54,51,54,58,63,96 dfb 62,61,60,59,56,53 dfb 58,54,51,56,53 dfb 46,50,53,56 dfb 59,58,57,54 dfb 0 t1v2 ;v2 dfb 100 dfb 11+$80-1 dfb 15,27,15,39 dfb 15,27,15,39 dfb 15,27,15,39 dfb 15,27,15,39 dfb 47,44,41,51,46,42,47,44 dfb 22,34 dfb 22,34 dfb 22,34 dfb 22,34 dfb 0 * Tune 3: Castle t2v1 dfb 97 ;gate dfb 24+$80-1 dfb 70,96,72 dfb 6+$80-1 dfb 96,96,70,72 dfb 12+$80-1 dfb 74,74,96,75 dfb 24+$80-1 dfb 77,75,74,96,72 dfb 6+$80-1 dfb 96,96,70,72 dfb 24+$80-1 dfb 70,96,96,96 dfb 00 t2v2 dfb 97 dfb 24+$80-1 dfb 34,41,46,41 dfb 34,41,46,41 dfb 58,62,53,57 dfb 58,53,46,96 dfb 0 end ....... .... .. . C=H 20 =====================================Part 3=================================== MagerTris -- by Per Olofsson Way back in 1990 or thereabouts I wrote a Tetris clone in basic on the Amiga. When the compo was announced I scratched my head for a bit and figured that I should have a good chance of fitting Tetris in 512 bytes. If I couldn't I would at least have a good engine for the 2K compo. The Game This puzzle game was invented in the 80s by the Russian programmer Alexey Pazhitnov. The game has a vertical field where one of seven puzzle pieces appears at the top and falls towards the bottom. The player can rotate the piece 90 degrees or move it left or right. When it reaches the bottom the game field is checked for filled lines that are removed. The player is awarded points for removing lines (with a bonus for clearing more than one in one go) and the game is over when the screen fills up so that new pieces can't enter the game field. Drawing the Screen The screen is filled with inverted space and the game pieces and the border is drawn into the color ram. There aren't any real tricks used here, the only one is that the color of the border is a side effect of the last character printed, ascii 13 (light green). Random Numbers We need a decent source of random numbers to generate a random Tetris piece. The SID chip's noise waveform is probably the best one available on the C64, and fortunately the code to access it is really short. To initialize I used: initrnd subroutine lda #$81 ; enable noise sta $d412 ; voice 3 control register sta $d40f ; $81xx as the frequency and to get an 8-bit random number all you have to do is lda $d41b. The Tetris Pieces Tetris has 7 puzzle pieces that you have to store in a table. As every piece consists of four boxes we need a total of 7*4 = 28 entries. The smallest tetris table is probably the one where each piece is represented by 8 bits, like this: 0010 0000 0011 0110 0100 0010 0110 0111 1111 0110 0011 0111 1110 0110 The table is very compact, a mere 7 bytes, but then you need code to rotate every piece and accessing the table is somewhat clunky on the 6502. I've seen an x86 version that did this though, with a total size of less then 256 bytes. However, on the 6502 I needed four bytes for each piece for the code to be reasonably compact and I also kept all the rotated pieces in the table, for a total of 112 bytes. Fortunately, as every piece has a center box we only need to store 3 which trims the table to 84 bytes. But what do we actually store? As the screen is painted to color ram I used a pointer to the current position. In the table I simply stored the offset in color ram from the center box. With indirect indexed addressing the routine is nice and short. Failure The rest of the program is fairly straight forward. User input is just a cmp loop, there are dec zp timers for most events, and a raster wait to time everything. This is also where I failed to fit everything inside the 512 byte limit, as everything put together took about 600 bytes, even after some serious optimizations. There are two very similar routines: the one that draws a piece on the screen and the one that checks for collisions. Both iterate through the four boxes in a piece, but because of the way I used some zeropage pointers I couldn't merge the two routines into one without rewriting most of the code. Success As rewriting everything meant too much work, I just decided to go for plan B: write a 2K entry. With a 600 byte engine there was plenty of space to add features, and at around 700 bytes or so compressíon starts to make sense -- pucrunch breaks even around there somewhere, and the final binary is actually 6500 bytes uncompressed. I could easily fit a title screen complete with custom graphics, a nice tile set for the tetris pieces, and even a hiscore list that saves to disk. The only trick I used here was that the custom character set was EOR:d with the ROM font, and as most characters are the same or very similar it compresses much better that way. And that's the story of the 2K MagerTris. ....... .... .. . C=H 20 =====================================Part 4=================================== Tuff -- Compressing tiny programs -----------------------------------> Stephen L. Judd Most compression schemes address the problem of taking a large program or data file and compressing it down. But have you given much thought to the problem of trying to compress a _small_ program, even a 512-byte program? I spent some time trying to come up with a compression scheme for tiny programs -- saving even 2 or 3 bytes in tetrattack would have been helpful. The effort was basically a failure, as the best I thought I could do was to break even (on paper, for tetrattack). I never actually implemented the code either -- just worked it out on paper. It is fairly interesting though, and maybe these preliminary efforts will give other people ideas (especially for compressing 2k programs). And after writing this article, I now think it might have really worked, and saved a handful of bytes -- maybe next year? Tiny Compression Basics ----------------------- If you remember your C=Hacking #16, there are two basic approaches to compression: taking a fixed-length input while giving a variable-length output, or taking a variable-length input and producing a fixed-length output. An example of the latter is LZ compression -- looking for repeated strings of length 2, 3, etc. and replacing those with a single byte (or two bytes, or whatever). An example of the former is a Huffman tree, which replaces each byte with a string of bits, using fewer bits for the most common bytes. To decompress the compressed data, of course, a decompression routine is needed. For tiny compression, this routine obviously needs to be as tiny as possible. Most C64 decompression programs copy themselves somewhere before decompressing the data stream to wherever it is supposed to go. Obviously, if we put a few restrictions on the data to be decompressed the decompression routine can be made smaller, and more specific to the task. Finally, it is worth mentioning that a tiny program in general does not use all possible byte values. Now, first consider Huffman encoding. In a typical Huffman decoding scheme, the decoder reads a bit at a time, traversing the Huffman tree with each bit, until a byte is hit. This implies a fairly complicated decoder, and worse the tree must be stored. Finally, the tree can be fairly large, if the number of possible symbols is large. So Huffman doesn't seem like a good approach. So, consider LZ-style encoding: replacing n-byte sequences with a shorter code. In LZ, the n-byte code is replaced with a reference to the previously decompressed bytes -- a code which says how far back to go, and how many bytes to copy. What a drag -- an escape code, the distance backwards, and the number of bytes to copy. For a byte-aligned decompressor, this implies at least two bytes (since the decompression routine must be small, a byte- aligned decompressor is of greater interest). So far so bad. But, since a tiny program doesn't use all possible byte values, perhaps a simple substitution method is possible -- that is, replacing various 2-, 3-, 4- etc. byte sequences with one of those bytes. Alas, the numbers don't work out very well: in the above scheme, let's say an n-byte sequence appears m times, for a total of n*m bytes. We replace each of those sequences with a single byte, giving a savings of n*m - m bytes. But we also need to store the original sequence in a table somewhere, plus the byte corresponding to that sequence. This means another n+1 bytes, so the total savings is n*m - (m+n+1) So, for example, if we have a 2-byte sequence repeated 3 times, the savings is 2*3 - (2+3+1) = 0 bytes! The issue is that replacing a 2-byte sequence with a 1-byte sequence saves one byte, and this must happen at least three times to overcome the three bytes of table storage. With 4 repetitions, one byte is saved. And so on. The net result is that you have to have a _lot_ of sequences repeated a lot of times to get any savings, and somehow this savings needs to be greater than the decompression code. Note that, in general, if there are only a few sequences to replace then some custom code can be shorter, but as the number of sequences increases then generic code, with sequences stored in tables, quickly becomes shorter. The final dagger in the heart of these schemes is the program itself. I wrote a simple program to find all the repeated strings in tetrattack. There were numerous 2-byte sequences, but rarely repeated more than three times. There were a few 3-byte sequences, and even one 4-byte sequence. But overall, there just aren't enough sequences, especially long sequences, to make any sequence-shortening scheme worthwhile. There are, however, lots of repeated bytes. For example, there are an awful lot of STA instructions and zp variables, and usually a lot of zeros. This suggests taking another look at a Huffman-style scheme to replace those bytes with a smaller number of bits. One major problem identified earlier is storing the Huffman tree -- it's just too big. So, the first thing to consider is to just compress the most common bytes -- maybe the top five or eight bytes or whatever -- to make the tree smaller. But that still leaves a substantial decompression code to traverse the tree, and a tree with several empty nodes in it. Once again, if there are only a few nodes then custom code might be shorter, but beyond a few nodes it's more practical to store the tree and have some generic traversal code. But consider the following alternative to a Huffman tree: what if we had a tree which looked like 0/\1 / \ lit 0/\1 / \ b1 0/\1 / \ b2 ... where "lit" is a literal byte and b1, b2, etc. are encoded bytes. This would encode the most common byte with two bits (10), the next-most common byte with three bits (110), and so on, and encode literal bytes with nine bits (0byte). To see if this is a viable scheme, check out the numbers. Let's say that we encoded just the top three most common bytes in a program; in an early version of tetratack those bytes were 00 $2F occurances 03 $1A occurances (ZP variable) 8D $18 occurances (STA opcode) In a real implementation, I'd choose zp variables to be common opcodes like $8D, which would double the number of occurances of that byte, but let's just consider the above numbers in a 512-byte program. Those top three bytes total $5A bytes, almost 1/8 of the total bytes (the top seven bytes take a little less than 1/3 of the total, by contrast). The number of bits required is 9*($200-$5A) + 2*$2F + 3*$1A + 4*$18 = $FE2 bits = $1FD bytes. so, a whopping 3 byte savings -- just enough to store the three "tree" values. For the top seven bytes, a similar calculation suggests $01D8 bytes of compressed data, again for code not optimized for compression. For a more optimized code, with zp variables the same as common opcodes and such, the total bytes could conceivably be on the order of $1C0 bytes -- maybe 64 bytes of savings (but again, I never actually tried coding a more optimized version so I don't know for sure). Of course, we haven't talked about decompression yet. One important feature of this tree is that the "treeness" of it is unimportant -- it's really just a substitution code. Remember that the data is encoded as 0+8 bits literal byte 10 byte 1 110 byte 2 1110 byte 3 and so on. A decompression algorithm simply reads bits until a 0 is found, and then looks up the relevant value in a table. The exception is if the first bit read is 0, in which case the code needs to read the next eight bits and output the byte. There is no tree traversal, in the sense of looking up nodes and moving left/right, to speak of. Reading bits is trickier than reading bytes. After every eight bits read some sort of memory pointer has to be incremented to the next eight bits. Moreover, when reading codes like 110 or 1110 the number of 1's read has to be counted too, to be used as an index into the lookup table. And if there's a literal byte then the next eight bits need to be read, regardless of their value. When reading the bitstream, after every eight bits the pointer into that bitstream must be incremented. To count eight "global" bits at a time, I use a zp variable "count" and just rotate a bit through the different bit positions. Every time it turns zero, the data stream pointer is incremented. The advantage of this method is that it frees up a register (and winds up saving a byte overall). To count the number of "local" bits read, I use the .Y register. Initially it is set to zero, and increments as 1s are read. For a literal byte, I set .Y to -8, and put a little check in the bit reader for negative .Y; that way, it simply increments to .Y=0. When bit=0 is finally found, the code simply does an lda data,y to get the coded data. Since .Y counts the number of bits read, .Y will be at least 1 for any coded data, but .Y=0 for a literal byte as described above. Therefore, for a literal byte, the code can simply rotate bits into the location "data", and use the same lda data,y instruction to fetch the literal byte. With all that in mind, here's the code I finally came up with (on paper!): Tuff+1 [data lookup table] Source [compressed data bitstream] :lit ldy #-8 :getbit lsr count ;Increment pointer every eight bits bne :skip ror count inx bne :skip inc :src+2 :skip :src asl Source,x ;Get next bit bcs :next tya ;.y=0 means first bit is 0 beq :lit ;so read next 8 bits bpl :found ;If .y<0 then literal :next rol Tuff ;Save bit iny bne :getbit ;until .y=0 :found lda Tuff,y ;Fetch code/literal byte ldy #00 sta (zp),y ;Output data inc zp bne :getbit inc zp+1 bpl :getbit [Uncompressed code -- (zp) points here!] for a total of 45 bytes, plus 7 bytes for the "Tuff" byte table -- total of 52 bytes, compared with a possible 64 byte savings (maybe I should have tried to implement this after all!). Hopefully someone can improve on this further. (And "Tuff", btw, is a contraction of "TinyHuff"). Now, there may be a few raised eyebrows here which I hope I'll lower. This code makes several assumptions. It can either be entered in at ":src", or at ":getbit" depending on the value of "count", the global bit counter. I assume count is already initialized (e.g. default kernal/restart value), either to 1 (in which case :getbit is the entry point) or $80, in which case :src is the entry point, with the "Source" stream modified accordingly in each case. I further assume that .axy have their default values of 0 when the routine is called from BASIC. I also assume "zp" is initialized to the correct value already. One easy way to do this is to use $ae/$af, the end load address+1, but other options are certainly available. Finally, you may have noticed that the thing just keeps outputting data until zp=$8000 -- correct. Unless you really really care about garbage in memory above the program, there's no reason to waste bytes on an "end of data" check. Another variant --------------- I spent a little time looking at a variant of this scheme: using a fixed length bit sequence, for example, assigning four bits to each substitution byte, i.e. 0+ literal byte 1000 byte 1 1001 byte 2 1010 byte 3 ... 1111 byte 8 The numbers work out pretty well, compression-wise, but at the time I felt the decompression code was more complicated and that it wouldn't break even. Looking at it now, though, I'm starting to think otherwise -- that loading three bits at a time isn't any more complicated that fetching eight bits so code can be reused, and there isn't any issue about checking for a 1 bit, so the decompression code ought to be even simpler. So this may actually be the better scheme. Sigh... I don't know about you, but every time I revisit a problem I wind up feeling much stupider. Final thoughts -------------- So, that's it: assign shorter bit codes to the most common bytes, and use a highly optimized decompression routine. This method is very specialized, obviously, but might, just might, make it possible to save a few bytes on a tiny program that has been optimized for compression -- using zp locations the same value as common opcodes, reusing vars as often as possible, and so forth. The reason this works is that, in general, repeated long strings in a small program just don't happen, whereas repeated bytes are common and make up a substantial fraction of the total. Pelle (magervalp@cling.gu.se) has suggested the possibility using the first eight bytes of the compressed program for the 'Tuffman' table -- to structure the code to make these common bytes, and thereby save eight bytes by not having to store the 'tree'. Might save a few more bytes with some programs. Finally, while writing this article, I started to wonder about byte sequences with "holes", for example, for things like STA zp1 STA zp2. I wonder if there's some way of taking advantage of these repeated "mask" patterns. But I leave that thought for another person, for another day. ....... .... .. . C=H 20 =====================================Part 5=================================== Tinyrinth by Mark Seelye (aka Burning Horizon) mseelye@yahoo.com http://www,burninghorizon.com Introduction When the mini-game competition was first announced I was originally not interested because of time considerations -- I spend most of my time at work, or watching the kids. But once I saw that nearly everyone on EFNet #c-64 was doing one, I thought it would be fun if I could just keep it simple. I had no idea what game to do. I had a few other ideas but I settled on a maze game because I thought it'd be an interesting challenge doing a maze algorithm and have game code in under 512 bytes. As it turned out, I ended up getting the code down to like 475 bytes, but the version I turned in was 509 bytes. (I ended up shaving an additional 34 bytes just before the due date.) The other reason for the maze game is I have this weird thing I do where in each language I know I code this little maze generation algorithm that I made a long time ago. I had used the algorithm in ASP, VB, C, and a few others but for some reason I had never done it in 6502! Imagine that! It was going to be a bit of a challenge because I had never done it in so few instructions, and never in ASM. It would have to lose some weight and fluff - but I knew I could do it. So obviously there are MANY better ways to generate a maze, but in light of trying to make it small this routine ended up being such a strange mutation of my idea that I'm surprised it worked at all. Setup Before I do anything I must setup some things. This is also used when a player dies OR enters a new level. The only difference between a reset of the game and moving to the next level is the ldx #$00: sta $53. The $53 is also used to set the color of the maze so it rotates from a level one of white, to a level 16 of BLACK (which is evil because then you can't see the maze except for the small hint of the corners on a turn.) ; Initialization setup = * gameover = * ;Setup game variables ldx #$00 stx $53 ; Start at level 1 (to be inc'd) init = * ;Setup level inc $53 ; next level ldx $53 stx $54 ;Counter for drawing Keys (next level) stx $0286 ;Character Color to use on clear (e544) ;Set Render Cursor Start Pos / Player Color lda #$05 sta EBCM1 ;Set ebcm color PLAYER to GREEN ($d022) sta $45 ; Cursor/Player Position X (0-39) sta $46 ; Cursor/Player Position Y (0-24) ;Clear Screen jsr $E544 ;clear screen set to char color ($0286) lda #$5b sta $d011 ;turn on EBCM lda #$18 sta $d018 ;Activate Cset As you can see that I mention extended color background mode, I'll talk about that later. You also might notice I use a character set, but that I will talk about a little later as well, first I want to jump right into the maze generation. Maze Algorithm The easiest way to describe how the algorithm works is to compare it to a worm. The worm eats through an area until it gets blocked by one of its former meals. While eating the worm is counting how many meals he has, once he gets to a certain number he knows he can eat no more. With that said, I'm sure you're completely confused and probably think (know?) I'm crazy. So now for the technical explanation. Starting with a 2-dimensional grid of some size, initialized with 0's, set a start point for the formation of the maze within the grid somewhere. After that has been taken care of the rest follows simple logic: Have we reached every part of the grid? if so: done; if not: Can we grow the maze into an adjacent area from the current position? if so: Grow in one of those directions at random, count it, continue; if not: Find a place in the maze that can grow, continue I had to change the order of the logic around to get it to better fit into a smaller number of bytes. So I moved the "find a place to grow from" routine, and the "Have we reached every part of the grid" check after the check if we can grow in any direction from the current position. To check in all directions from the current position I used a subroutine. The subroutine has 5 entry points which translate to: cangrow up/right/down/ left, and the 5th entry point is actually the first entry point - it checks to see what is in .a and checks the direction that value represents. To check the direction I use a kernal routine to set some zp so I can easily check the value on the screen to the left, right, below, or above the current position. This is also where the boundaries are set: 40 columns wide and 25 characters high. popgridloop = * ldy $45 ;xpos ldx $46 ;ypos cangrowxy = * ;Can grow in any direction? jsr cgup ;check up beq _cgxy ;if 0 then we can grow inx ;offset up check jsr cgright ;check right beq _cgxy ;if 0 then we can grow dey ;offset right check jsr cgdown ;check down beq _cgxy ;if 0 then we can grow dex ;offset down check jsr cgleft ;check left _cgxy beq growloop ;if 0 then we can grow ....The subroutine!... There is a weird "injection" in the bottom of this routine to save a couple bytes.. it is explained in the comments to the code. ;Check if a cell can grow a direction ;1-up 2-right 3-down 4-left ; (y xpos, x ypos, a=dir) x/y switched for indirect lda($xx),y below ; return: a == 0 : true (can move) ; a <> 0 : false (cannot move) cangrow = * cmp #$01 beq cgup cmp #$02 beq cgright cmp #$03 beq cgdown ;cmp #$04 ;beq cgleft *** not needed falling through cgleft = * dey ;set xpos - 1 cpy #$ff ;check xpos beq cgno bne cgloadcell cgright = * iny ;set xpos + 1 cpy #$28 ;check xpos (<40) beq cgno bne cgloadcell cgup = * dex ;set ypos - 1 cpx #$ff ;check xpos beq cgno bne cgloadcell cgdown = * inx ;set ypos + 1 cpx #$19 ;check ypos (<25) beq cgno ;*** fallthrough, bne cgloadcell not needed cgloadcell = * lda #$1f loadcell = * ;x = ypos, y = xpos, a = and value sta $50 jsr $e9f0 ; * sets $d1 $d2 lda ($d1),y ;load byte (x pos in y reg!) cgno = * and $50 ;#$1f = use only low 5 bits! ;rts see below! ; This is mixed with the rts because the first byte would ; be wasted growfrom = * rts .byte 1 ; this is part growfrom part growto 48 is used for both ; again first byte would be wasted so we overlap with the previous growinto = * .byte 2, 4, 8, 1, 2 ;explanation of above ; 0 1 2 4 8 ; 0 4 8 1 2 ;rts 1 2 4 8 ; 4 8 1 2 *From Mapping: ;59888 $E9F0 ;Set Pointer to Screen Address of Start of Line ;This subroutine puts the address of the first byte of the ;screen line designated by the .X register into locations ;209-210 ($D1-$D2). Well, if the code at "cangrowxy" discovers that it cannot grow in any direction from the current position, it has to find a place to grow from. To do this it falls into the "findgrow" routine; the findgrow routine is interesting because it is a "stateful" routine, meaning that upon reentry it resumes what it was doing before. The reason for this is I didn't want it restarting at the top of the maze each time it entered this find routine - I wanted it to continue to search from where it left off. This routine works by "walking" the current x and y positions through the grid. To walk it through it keeps track of where it left off and it just keeps setting the x and y pointers to the next place and "returns" to the start of the generation portion of the code. The other advantage to doing it this way is I can reset this routine to place a "key" at a dead end in the maze. This reset is done elsewhere in the maze by setting a zp value to 0. When the reset is done the findgrow routine runs a small piece of code to reset itself, but before it does this it places a key if it has not exceeded the max number of keys. Here is the find grow routine, each line of code is documented to hint at what it is doing: ; *** fall into findgrow findgrow = * lda $4b ; Check byte 0 != resume findgrow bne _fgresume sta $49 ;Reset Findgrow Xpos sta $4a ;Reset Findgrow Ypos inc $4b ;Set findgrow flag to resume (<>0) ;Place keys in corners (injected here for ease of placement, d1/d2 is ;pointed at a dead end) iny ; offset left check beq _fgresume ;Do not try when column is 0, it freaks out lda $54 beq _fgresume ;if 0 then keys are done dec $54 ;dec # of keys left to place inc $51 ;actual num keys left lda ($d1),y ;load byte ora #$c0 ;EBCM value for key! sta ($d1),y ;store new value ;(end of injection) _fgresume = * _fgx ldx $4a ;Findgrow ypos _fgy ldy $49 ;Findgrow xpos inc $49 ;Next xpos (next round) cpy #$28 ; < 40 beq _fgcr ; next line if >= 40 jsr cgloadcell ; load cell byte beq _fgy ; if 0 then get next xpos/byte sty $45 ;Set Current xpos stx $46 ;Set Current ypos jmp cangrowxy ;Check if this can grow _fgcr lda #$00 ;Reset Findgrow xpos sta $49 ; 0->xpos inc $4a ;Next Findgrow ypos lda $4a cmp #$19 ;check ypos (<25) bne _fgx ;If we're at x40y25 we are ready to play! beq gameinit ;Start game logic That code also checks to see if we have completed generating the maze, at which point it enters the game logic. But before we jump into that I have to explain what happens when the "cangrowxy" routine discovers that it CAN GROW, instead of falling into the "findgrow" routine. In the "cangrowxy" routine described a bit above there was the final line of: _cgxy beq growloop ;if 0 then we can grow This "growloop" routine is the place where the actual "growing" or "eating" occurs. This routine works by choosing a direction at random, and attempting to grow in that direction. It reuses the same "cgup" etc. routines that the "cangrowxy" uses because although we know we can grow in a direction, we don't know which direction. In a larger version of this algorithm I usually link all of this stuff together, but this is how it worked out in tinyrinth. growloop = * randdir = * ;jsr getrand; not a func, not reused yet getrand = * lda #$80 sta $028a ; Key Repeat; (injected here for #$80) just using #$80 for ;smaller code sta $d412 ;sta $d404 d412 is V3, d404 is V1!! sta $d40f ;set v3 random # gen bits lda $d41b ; read random number from V3 and #$03 ; Force Random number to 0-3 clc adc #$01 ; Add 1 to get 1-4 sta $4c ; store rand direction ldy $45 ; Current Xpos ldx $46 ; Current Ypos jsr cangrow ; Check if we can grow in that direction bne randdir ; if <> 0 then Try again sta $4b ; reset findgrow flag (injected here for .a==0) grow = * ldx $4c ;Get saved rand direction lda growinto,x ; 1-4 (4, 8, 1, 2) Get bit set for new cell sta ($d1),y ; write new location lda growfrom,x ; 1-4 (1, 2, 4, 8) Get bit to set for old sta $4d ; Save growfrom bit ldy $45 ; Reload Current xpos ldx $46 ; Reload Current ypos jsr cgloadcell ; Load base cell again ora $4d ; Combine with growfrom bit sta ($d1),y ;Modify old cell to connect to new loc ;Change current position lda $4c ; Get saved rand direction jsr cangrow ; Get new x y again - (this will only perform next x/y ;adj, returns <>0) sty $45 ; xpos set to new location stx $46 ; ypos jmp popgridloop ; Return to populate grid loop As you can see above I use the random number generator from V3 to get the random direction. A HUGE bonus here is I can use the value returned to directly call that main entry point in "cangrow" so it will figure out which direction to check by itself. If the direction it checks is bad it just loops and tries again. In a larger version of the program I'd make this much more efficient, but efficiency costs bytes. Many coders would be afraid of using a loop like this but since I pre-checked that I can grow before entering this loop, unless my random number generator never returns that direction, I should be fine! Once we have a good direction I use the already set $d1 zp to read and update the screen data. Nice bonus of using the routine that sets it! The code that updates the data must update the cell you are growing to, but ALSO it must update the cell you are growing from, which is why I have to reload the original data up there. When it is finished doing the growing it moves the x/y positions ($45, $46) to the place it grew to; and resumes the maze generation from there. As I said before, the findgrow routine will jump into the game logic once it knows it is finished, but before we talk about that I should describe another problem that I needed to solve. In a 2 dimensional maze there are 16 possible pieces that can be formed. Starting with completely closed ending with completely open. I represent these pieces with numbers 0-15; each bit represents a side of a cell that is open/closed. So when I finish with the maze generation I have a grid of numbers 0-15. (Actually 1-14; in this game I never end up with 0's or 15's as all pieces are used.) In Tinyrinth the "grid" I used was the screen memory (40x25), so I ended up with a screen full of characters A-N. Navigating a maze of characters A-N would be rather difficult so I had to decide whether to use the built in character set and transform what was on the screen or somehow use a character set. Well, another hitch was I wanted this to be a game and losing the simplicity of 0-15 for detecting which way a player could move would be bad - so I opted for a character set of 16 characters. Hmmm 8 bytes per character time 16 characters.. 128 bytes! Yikes! Character Set Generation So I was going to need a 128 byte character set, but I didn't want to have to lose 128 bytes. So what did I do? I coded about 70 bytes of code to generate the character set. This too pained me because I was sure I could make it smaller somehow, but once I got it working and a decent percentage smaller than the size of what it produced I stopped worrying about it. Here is how it works: we know we want to represent the 16 pieces with 0-15 so the bits must give some hint to the shape of the piece. What I did was create a loop to check the current counter value and either set or skip the top, sides, bottom of the piece. Here is how it works, I have commented each line of code to describe what it is doing. ; Generate Cset! lda #$20 ; write hi sta $48 ; use zp lda #$00 ; write lo ;Initialize Screen, variables (injected here to save bytes - using ;lda #$00) sta $51 ; Clear actual num keys placed counter (see findgrow) sta $d020 sta EBCM0; Set BG Color ($d021) ;(end of injection) tax ; counter = 0 _again sta $47 ; use zp ldy #$00 ; index txa ; counter to a and #$01 ; check for top beq _ytop ; yes top eor #%01111111 ; 00000001 -> 011111110 -> 10000001 _ytop eor #%11111111 ; 00000000 -> 111111111 _6sides sta ($47),y ; store top/sides to cset iny ; next mem location txa ; counter to a and #$02 ; check for right eor #%00000010 ; flip lsr ; 00000010 -> 00000001 || 0->0 sta $49 ; store for right side txa ; counter to a and #$08 ; check for left side bne _noleft ; no left eor #%10001000 ; 00000000 -> 10001000 -> 10000000 _noleft eor #%00001000 ; 00001000 -> 00000000 ora $49 ; merge with right cpy #$07 ; 7->15->23->... bne _6sides ; total of 6 side pieces txa ; counter to a and #$04 ; check for bottom beq _ybot ; no bottom eor #%01111010 ; 00000100 -> 01111110 -> 10000001 _ybot eor #%11111111 ; 00000000 -> 11111111 sta ($47),y ; store bottom to cset inx ; next counter clc ; clear carry lda $47 ; inc zp adc #$08 ; by 8 bne _again ; do it again inc $48 ldy $48 cpy #$28 ;repeat through cset 2000-2800 bne _again sty EBCM2 ;Set Death color ($d023) (using result of cset gen for ;color value!) I still think I could have made that smaller; either way though it was fun making a little routine to generate the cest. With that all said, we now know how the maze is generated. But now I needed a way to play it, as suggesting players use dry erase markers on their screens was not a great idea. Game Loop Ok, once "findgrow" reaches the end of its maximum size it branches to "gameinit". Game init sets up some quick things and gets going on the game play. The game loop loops until the player either gets all the keys or dies. When the player gets all the keys the level number ($53) is increased which is used to increase the maximum number of keys and the color of the maze! Here is the game loop, I have commented much of the code and separated it into sections: *** Flashes the keys, sets speed of "minitaur" based on the level ; Game Initialization and Game Loop gameinit = * gameloop=* inc EBCM3 ; Flash Keys ($d024) inc $58 ; Increase Speed counter #1 (0-255) bne moveplayer ; Skip move inc $57 ; Increase Speed counter #2 ($57|#$f0 - 255) bne moveplayer ; Skip Move ;set death speed lda $53 ;Use level for Speed value cmp #%11111000 ;If more than this use default speed bmi _dsp lda #%11111000 ;Default speed _dsp ora #%11110000 ;Set high nibble so counter counts up to 255 sta $57 ; Set Speed counter #2 *** Moves the minitaur to the next position if it is time. (Checks for player *** hits) ;move death movedeath = * ldy $55 ;Baddy Xpos ldx $56 ;Baddy Ypos jsr cgloadcell ; load the cell/point the zps (ANDs by #$1f) sta ($d1),y ;store cleared value _newy iny ;increase xpos cpy #$28 ;less than 40? bmi _go ;don't reset ldx $46 ;ypos of player stx $56 ;ypos of death ldy #$00 ;clear xpos counter _go sty $55 ;Set baddy xpos lda #$ff ;Get all bits! (see loadcell) jsr loadcell ;load the cell/point the zps sta $59 ;Save cell value (withh all possible bits) and #%11000000 ;and by EBCM bits cmp #%11000000 ;Check for KEY - (so it can skip over) beq _newy ;Jump ahead 1 more to skip key position cmp #%01000000 ;Check for player hit bne _nodie ;Player is not dead jmp gameover ;Game Over! _nodie lda $59 ;Reload stored value ora #$80 ;EBCM for Death sta ($d1),y ;store value ; *** fall through to Move Player *** Checks the keyboard for input, moves player if possible ;Move Player moveplayer=* _ffe4 jsr $ffe4 ;Get keypress beq gameloop ;no key - goto gameloop and #%00000011 ;.a == 0-7 at this point tax lda keytodir,x ;Loads from keytodir ;Move entity in game ; .a=direction 1-up 2-right 3-down 4-left glmove tax stx $4e ; store direction lda growinto,x ; get check bit sta $4f ; store check bit ldy $45 ; current xpos ldx $46 ; current ypos jsr cgloadcell ; load the cell (and with #$1f) sta ($d1),y ; store the data (clear the EBCM) lda #$00 ; Bottom "fall out" fix sta $50 ; clear and of cangrow Bottom "fall out" fix lda $4e ; load direction jsr cangrow ; call cangrow to move xpos/ypos and $4f ; check bit bne glmyes ; if we have a bit then we can move! ldy $45 ; reload xpos - do not move ldx $46 ; reload ypos - do not move glmyes lda #$ff ; bits to obtain from loadcell jsr loadcell ; load the cell/point the zps pha ; temp store value for later checks and #$1f ; clear other EBCM bits ora #$40 ; EBCM ORA Player/Baddy sta ($d1),y ; store new data sty $45 ; store xpos of new position stx $46 ; store ypos of new position *** Checks for key hits, minitaur hits, Check for end-level: (jumps to next level if it is the end of the level, loops to the beginning of the game loop if not.) ;Hit checks pla ; load previous value and #$c0 ; check for hits "11xxxxxx" cmp #$c0 ; check for key hit bne _back ;_notkey ; to next check dec $51 ; dec number of keys left in level bne _back ; if 0 then we should go to the next level jmp init ; gen maze again ;_notkey cmp #$80 ; check for death hit! ; bne _back ; jmp gameover ; game over _back jmp gameloop ;more checks here? keytodir=* .byte 2,1,4,3 To make this all work, I reused the "cangrow" routine again! Because basically it is the exact same logic. One thing that I found unfortunate is I never coded a way for the minitaurs to have any intelligence, there was an attempt that worked - but it was FAR too many bytes (over by like 20-30). So I just axed it. I also at one time made the movement routines all one subroutine that took parameters that would move any "entity". This also took too many bytes to be useful for this version of the game. Perhaps some day I'll write a kick butt 1k or 2k version with all those features coded in. Various Space Saving Techniques Used If you look throughout the code, you'll notice I comment a lot on "injections". These are little bits of code that are conveniently put in places to take advantage of the state of a register, or a memory location. Here is an example: in the "getrand" routine I injected a line to set the key repeat, using the value that I wanted to also put into $d412 for the random number generation. lda #$80 sta $028a; Ket Repeat; (injected here for #$80) just using #$80 for smaller code sta $d412 ;sta $d404 d412 is V3, d404 is V1!! Another example, here I mixed some static data together to save some bytes and just labeled it so some would be reused. To save additional bytes I also positioned the label in front of the "rts". (This was because the code accessing the data never used an index of 0.) This is mixed with the rts because the first byte would be wasted growfrom = * rts .byte 1 ; this is part growfrom part growto 48 is used for both ; again first byte would be wasted so we over lap with the previous growinto = * .byte 2, 4, 8, 1, 2 ;explanation of above ; 0 1 2 4 8 ; 0 4 8 1 2 ;rts 1 2 4 8 ; 4 8 1 2 I also reused the "cangrow" routine as much as possible, and on top of that I made it more usable by making multiple entry points so it could be used in different way. Finally the best thing I did was use kernal routines, like $E544 to clear the screen to the set char color at $0286. This allowed me to change the color of the maze each level just by changing the value at $0286. I also used $e9f0, Mapping the c64 says, "Set Pointer to Screen Address of Start of Line. This subroutine puts the address of the first byte of the screen line designated by the .X register into locations 209-210 ($D1-$D2)." This was handy because I could use this to read from and write to the screen. Extended Color Background Mode I could not use sprites. This made me sad, but there was just no room for it. So I just decided to allow the cset to repeat itself through the whole cset area ($2000-$2800) and use ECBM! This allowed me to just set a bit and make a maze piece a different color, what more when I read this value I could easily tell what the piece represented! That made collision detection very easy, and also allowed me to have cool flashing keys. Conclusion I had a LOT of fun coding this and look forward to another competition. I was surprised and amazed by the entries! This even inspired me to make a better version of Tinyrinth, of which I have not completed. I did add to it, I made a version in which the minitaurs move through the maze. I made another version that would also increase the number of minitaurs in the maze. I made yet another version that would change the size of the maze, and thought about changing the shape too. All of these ideas were easy to implement once I had the base game working. Oh by the way, I know how to spell Minotaur, the Minitaur thing is just a bad joke. :) Final Code Stats Total source compiled: 445 lines in 1 file(s) Symbol table: 29 global and 1 local constants, 0 global variables, 2 global and 15 local labels, 0 source labels. Data size: 10 bytes Code size: 475 bytes in 231 instructions Memory block affected: $1000 - $11E4 (total size: 485 bytes) Source Note: This source is not the source I released for the event. It is a bit smaller, I cannot locate my original source for some reason. Also, some of it may differ a tiny bit from the code documented above. I had written the article using the wrong copy of the source, I have attempted to go back and update it though. (Note to self: keep better records!) :) Another note: This source compiles with c64asm by Balint Toth. :) -- tinyrinth.asm -- ; Tinyrinth ; entry for a 512b game contest ; Burning Horizon/FTA ; Mark Seelye ; mseelye@yahoo.com ; =================================================================== * = $1000 ;name loc desc color ecbm bits EBCM0 = $d021 ; untouched black $00 00 EBCM1 = $d022 ; cursor red $40 01 EBCM2 = $d023 ; touched green $80 10 EBCM3 = $d024 ; keys yellow $c0 11 ;ZPs used: (Consolidation Possible if needed) ;43/44 - Not Used ;45/46 - Current X/Y Position, Maze Generation & Game ;47/48 - CSet Location, CSet Generation ;49 - Temp Storage, CSet Generation ;49/4a - Xpos/Ypos Findgrow, Maze Generation ;4b - Flag, Findgrow, Maze Generation ;4c - Temp Storage, randdir, Maze Generation ;4d - Temp Storage, grow, Maze Generation ;4e/4f - Temp Storage, glmove, Game ;50 - Temp Storage, loadcell, Maze Generation & Game ;51 - NumKeys Left in level (affected by: destroyed & found keys) ;52 - not used ;53 - Current Level ;54 - # of Keys to try and place, gameinit, Game ;55/56 - X/Y Pos of Death ;57/58 - speed counter for death ;Collect all keys ; gen crummb path for visited areas? ; once all keys collected next maze is gen'd ; Initialization setup = * gameover = * ;Setup game variables ldx #$00 stx $53 ; Start at level 1 (to be inc'd) init = * ;Setup level inc $53 ; next level ldx $53 stx $54 ;Counter for drawing Keys (next level) stx $0286 ;Character Color to use on clear (e544) ;Set Render Cursor Start Pos / Player Color lda #$05 sta EBCM1 ;Set ebcm color PLAYER to GREEN ($d022) sta $45 ; Cursor/Player Position X (0-39) sta $46 ; Cursor/Player Position Y (0-24) ;Clear Screen jsr $E544 ;clear screen set to char color ($0286) lda #$5b sta $d011 ;turn on EBCM lda #$18 sta $d018 ;Activate Cset ; Generate Cset! lda #$20 ; write hi sta $48 ; use zp lda #$00 ; write lo ;Initialize Screen, variables (injected here to save bytes - using lda #$00) sta $51 ; Clear actual num keys placed counter (see findgrow) sta $d020 sta EBCM0; Set BG Color ($d021) ;(end of injection) tax ; counter = 0 _again sta $47 ; use zp ldy #$00 ; index txa ; counter to a and #$01 ; check for top beq _ytop ; yes top eor #%01111111 ; 00000001 -> 011111110 -> 10000001 _ytop eor #%11111111 ; 00000000 -> 111111111 _6sides sta ($47),y ; store top/sides to cset iny ; next mem location txa ; counter to a and #$02 ; check for right eor #%00000010 ; flip lsr ; 00000010 -> 00000001 || 0->0 sta $49 ; store for right side txa ; counter to a and #$08 ; check for left side bne _noleft ; no left eor #%10001000 ; 00000000 -> 10001000 -> 10000000 _noleft eor #%00001000 ; 00001000 -> 00000000 ora $49 ; merge with right cpy #$07 ; 7->15->23->... bne _6sides ; total of 6 side pieces txa ; counter to a and #$04 ; check for bottom beq _ybot ; no bottom eor #%01111010 ; 00000100 -> 01111110 -> 10000001 _ybot eor #%11111111 ; 00000000 -> 11111111 sta ($47),y ; store bottom to cset inx ; next counter clc ; clear carry lda $47 ; inc zp adc #$08 ; by 8 bne _again ; do it again inc $48 ldy $48 cpy #$28 ;repeat through cset 2000-2800 bne _again sty EBCM2 ;Set Death color ($d023) (using result of cset gen for color value!) popgridloop = * ;can grow from current? ldy $45 ;xpos ldx $46 ;ypos ;Can grow in any direction? cangrowxy = * jsr cgup ;check up beq _cgxy ;if 0 then we can grow inx ;offset up check jsr cgright ;check right beq _cgxy ;if 0 then we can grow dey ;offset right check jsr cgdown ;check down beq _cgxy ;if 0 then we can grow dex ;offset down check jsr cgleft ;check left _cgxy beq growloop ;if 0 then we can grow ; *** fall into findgrow findgrow = * lda $4b ; Check byte 0 != resume findgrow bne _fgresume sta $49 ;Reset Findgrow Xpos sta $4a ;Reset Findgrow Ypos inc $4b ;Set findgrow flag to resume (<>0) ;Place keys in corners (injected here for ease of placement, d1/d2 is pointed at a dead end) iny ; offset left check beq _fgresume ;Do not try when column is 0, it freaks out lda $54 beq _fgresume ;if 0 then keys are done dec $54 ;dec # of keys left to place inc $51 ;actual num keys left lda ($d1),y ;load byte ora #$c0 ;EBCM value for key! sta ($d1),y ;store new value ;(end of injection) _fgresume = * _fgx ldx $4a ;Findgrow ypos _fgy ldy $49 ;Findgrow xpos inc $49 ;Next xpos (next round) cpy #$28 ; < 40 beq _fgcr ; next line if >= 40 jsr cgloadcell ; load cell byte beq _fgy ; if 0 then get next xpos/byte sty $45 ;Set Current xpos stx $46 ;Set Current ypos jmp cangrowxy ;Check if this can grow _fgcr lda #$00 ;Reset Findgrow xpos sta $49 ; 0->xpos inc $4a ;Next Findgrow ypos lda $4a cmp #$19 ;check ypos (<25) bne _fgx ;If we're at x40y25 we are ready to play! beq gameinit ;Start game logic growloop = * randdir = * ;jsr getrand; not a func, not reused yet getrand = * lda #$80 sta $028a; Ket Repeat; (injected here for #$80) just using #$80 for smaller code sta $d412 ;sta $d404 d412 is V3, d404 is V1!! sta $d40f ;set v3 random # gen bits lda $d41b ; read random number from V3 and #$03 ; Force Random number to 0-3 clc adc #$01 ; Add 1 to get 1-4 sta $4c ; store rand direction ldy $45 ; Current Xpos ldx $46 ; Current Ypos jsr cangrow ; Check if we can grow in that direction bne randdir ; if <> 0 then Try again sta $4b ; reset findgrow flag (injected here for .a==0) grow = * ldx $4c ;Get saved rand direction lda growinto,x ; 1-4 (4, 8, 1, 2) Get bit set for new cell sta ($d1),y ; write new location lda growfrom,x ; 1-4 (1, 2, 4, 8) Get bit to set for old sta $4d ; Save growfrom bit ldy $45 ; Reload Current xpos ldx $46 ; Reload Current ypos jsr cgloadcell ; Load base cell again ora $4d ; Combine with growfrom bit sta ($d1),y ;Modify old cell to connect to new loc ;Change current position lda $4c ; Get saved rand direction jsr cangrow ; Get new x y again - (this will only perform next x/y adj, returns <>0) sty $45 ; xpos set to new location stx $46 ; ypos jmp popgridloop ; Return to populate grid loop ; Game Initialization and Game Loop gameinit = * gameloop=* inc EBCM3 ; Flash Keys ($d024) inc $58 ; Increase Speed counter #1 (0-255) bne moveplayer ; Skip move inc $57 ; Increase Speed counter #2 ($57|#$f0 - 255) bne moveplayer ; Skip Move ;set death speed lda $53 ;Use level for Speed value cmp #%11111000 ;If more than this use default speed bmi _dsp lda #%11111000 ;Default speed _dsp ora #%11110000 ;Set high nibble so counter counts up to 255 sta $57 ; Set Speed counter #2 ;move death movedeath = * ldy $55 ;Baddy Xpos ldx $56 ;Baddy Ypos jsr cgloadcell ; load the cell/point the zps (ANDs by #$1f) sta ($d1),y ;store cleared value _newy iny ;increase xpos cpy #$28 ;less than 40? bmi _go ;don't reset ldx $46 ;ypos of player stx $56 ;ypos of death ldy #$00 ;clear xpos counter _go sty $55 ;Set baddy xpos lda #$ff ;Get all bits! (see loadcell) jsr loadcell ;load the cell/point the zps sta $59 ;Save cell value (withh all possible bits) and #%11000000 ;and by EBCM bits cmp #%11000000 ;Check for KEY - (so it can skip over) beq _newy ;Jump ahead 1 more to skip key position cmp #%01000000 ;Check for player hit bne _nodie ;Player is not dead jmp gameover ;Game Over! _nodie lda $59 ;Reload stored value ora #$80 ;EBCM for Death sta ($d1),y ;store value ; *** fall through to Move Player ;Move Player moveplayer=* _ffe4 jsr $ffe4 ;Get keypress beq gameloop ;no key - goto gameloop and #%00000011 ;.a == 0-7 at this point tax lda keytodir,x ;Loads from keytodir ;Move entity in game ; .a=direction 1-up 2-right 3-down 4-left glmove tax stx $4e ; store direction lda growinto,x ; get check bit sta $4f ; store check bit ldy $45 ; current xpos ldx $46 ; current ypos jsr cgloadcell ; load the cell (and with #$1f) sta ($d1),y ; store the data (clear the EBCM) lda #$00 ; Bottom "fall out" fix sta $50 ; clear and of cangrow Bottom "fall out" fix lda $4e ; load direction jsr cangrow ; call cangrow to move xpos/ypos and $4f ; check bit bne glmyes ; if we have a bit then we can move! ldy $45 ; reload xpos - do not move ldx $46 ; reload ypos - do not move glmyes lda #$ff ; bits to obtain from loadcell jsr loadcell ; load the cell/point the zps pha ; temp store value for later checks and #$1f ; clear other EBCM bits ora #$40 ; EBCM ORA Player/Baddy sta ($d1),y ; store new data sty $45 ; store xpos of new position stx $46 ; store ypos of new position ;Hit checks pla ; load previous value and #$c0 ; check for hits "11xxxxxx" cmp #$c0 ; check for key hit bne _back ;_notkey ; to next check dec $51 ; dec number of keys left in level bne _back ; if 0 then we should go to the next level jmp init ; gen maze again ;_notkey cmp #$80 ; check for death hit! ; bne _back ; jmp gameover ; game over _back jmp gameloop ;more checks here? keytodir=* .byte 2,1,4,3 ;Check if a cell can grow a direction ;1-up 2-right 3-down 4-left ; (y xpos, x ypos, a=dir) x/y switched for indirect lda($xx),y below ; return: a == 0 : true (can move) ; a <> 0 : false (can not move) cangrow = * cmp #$01 beq cgup cmp #$02 beq cgright cmp #$03 beq cgdown ;cmp #$04 ;beq cgleft *** not needed falling through cgleft = * dey ;set xpos - 1 cpy #$ff ;check xpos beq cgno bne cgloadcell cgright = * iny ;set xpos + 1 cpy #$28 ;check xpos (<40) beq cgno bne cgloadcell cgup = * dex ;set ypos - 1 cpx #$ff ;check xpos beq cgno bne cgloadcell cgdown = * inx ;set ypos + 1 cpx #$19 ;check ypos (<25) beq cgno ;*** fallthrough, bne cgloadcell not needed cgloadcell = * lda #$1f loadcell = * ;x = ypos, y = xpos, a = and value sta $50 jsr $e9f0 ; sets $d1 $d2 ;59888 $E9F0 ;Set Pointer to Screen Address of Start of Line ;This subroutine puts the address of the first byte of the screen line ;designated by the .X register into locations 209-210 ($D1-$D2). lda ($d1),y ;load byte (x pos in y reg!) cgno = * and $50 ;#$1f = use only low 5 bits! ;rts see below! ; This is mixed with the rts because the first byte would ; be wasted growfrom = * rts .byte 1 ; this is part growfrom part growto 48 is used for both ; again first byte would be wasted so we over lap with the previous growinto = * .byte 2, 4, 8, 1, 2 ;explanation of above ; 0 1 2 4 8 ; 0 4 8 1 2 ;rts 1 2 4 8 ; 4 8 1 2 ;Notes ; 1 ;8 2 ; 4 ; ; ;*** * * *** * * ;*0* *1* *2 *3 @ A B C ;*** *** *** *** ; ;*** * * *** * * ;*4* *5* *6 *7 D E F G ;* * * * * * * * ; ;*** * * *** * * ; 8* 9* a b H I J K ;*** *** *** *** ; ;*** * * *** * * ; c* d* e f L M N O ;* * * * * * * * -- end: tinyrinth.asm -- ....... .... .. . C=H 20 =====================================Part 6=================================== Tetrattack! by Stephen L. Judd Introduction ------------ The main point of Tetrattack!, of course, was to try and write a 3D engine in 512 bytes. I had a few other routine ideas but couldn't think of anything remotely fun to do with them, but I finally thought of a neat 3D game idea and decided to go for it. Alas, as code kept getting bigger that idea had to be discarded, and the eventual result was tetrattack, the simplest/smallest 3D game that wasn't (totally) stupid. But it is a 3D engine in 512 bytes! At first blush a 512-byte 3D engine might seem ridiculous but think about it: a basic 3d program doesn't need to store any graphics definitions or things like that -- it draws its own as it goes. In fact, all that is needed is a line routine, a rotation/projection routine, and some routines to keep track of angles and positions and such. What could possibly be hard about that, right? Well, it seemed reasonable at the time, anyways. At exactly 512 bytes, the code really had to sweat out the bytes, and every single byte saved was important; meanwhile, the routines still had to be reasonably fast. So this article will discuss the line routine, the rotation/projection routine, and the object and game manipulation routines, and the various optimizations used. I must say that writing this program really was fun; I'm sure that everyone else had the same problems I had, trying to shift from a "cycle-optimization" mindset to a "byte-optimization" mindset. Finding those extra few bytes here and there was a mighty challenge, sometimes taking hours or even days just to save one or two bytes. That part was frustrating, of course, as is the fact that the 'game' aspect of the program is thoroughly lame. But it was a neat challenge, and there was never an issue of adding too many features to the program and never finishing it! Quick Summary ------------- A full-on 3D program uses matrix multiplications to compute rotations, but 3D calculations get a whole lot easier in a Battlezone or driving-type situation, when you only turn left or right. There are no longer rotation matrices to keep track of -- just a single angle. This means that rotations are done with a simple INC/DEC, and there are no matrix multiplies to deal with. Rotations are done with some simple table lookups. The line drawing routine is pretty standard. Self-modifying code is used to manipulate a single routine to draw different lines, by swapping INX/INY instructions (which turns out to be less efficient, memory-wise -- sigh, oh well). Lines are plotted to a double-buffered 128x128 charmap, and once again setting up the screen took extra bytes. The "game code" is the simplest possible -- tetrahedrons are moved by just incrementing their z-coordinate, and rotated by incrementing their angle. Their locations are determined by whatever zero page is initialized to -- the program never sets explicit locations. Instead, I just chose a portion of zero page that had a reasonable range of default values (default zero-page values are used extensively in the code) In addition to default zero-page values the code uses several kernal routines (including a few unusual ones, for example the shift-C= routine for swapping charset buffers), self-modifying code, lots of zero-page instructions, and a whole bunch of tightly optimized code. The code was written over a few weeks -- a good week of planning and writing out possible routines, and a good week or two of coding. A couple of times I froze the program (using AR) and the freeze almost always happened in the same place: the buffer clearing routines. So the rotation and line drawing routines turned out to be fast enough, even with all the byte savings. The code was developed using the Sirius assembler on a 128D+SCPU -- and btw, the Jammon debugging mode has turned out to be awfully cool, with being able to visually single-step through code while simultaneously viewing memory and registers. Action Replay was downright clunky afterwards (but could freeze!). Yep, just a plug, with a bit of amazement that it actually all worked. I also tested it with VICE, to make sure it worked OK. VICE initializes zero page to different values than my 128, unfortunately, so some tweaking had to be done. And that's about it. Now on to more detail. ---------- Setting up ---------- The setup routines initialize VIC, initialize tables used by the line and rotation routines, and set up the screen. I note that when I started planning this program I computed the sizes of line and rotation routines without considering the code needed to set up tables and such for those routines. Oops! The default initial values for .X, .Y, and .A are 0 after a SYS, so the code made a little use of this. Line routine setup ------------------ The line routine uses tables to look up pixel addresses and bit locations for each x,y coordinate (the line routine stores the x and y coordinates directly in the .X and .Y registers, so looking up pixel addresses amounts to LDA Bitp,X kinds of instructions). The "bitmap" is a 128x128 charmap, so the calculations are pretty easy. The bit patterns which correspond to different x-coordinates (10000000 01000000 etc.) are computed using cmp #$80 rol to cyclically rotate a single bit. Rotation routine setup ---------------------- Another little piece of code is piggybacked into this loop, which extends a sine table from 0..pi. In this program, "angles" can go from 0..63, corresponding to 0..2*pi; the most memory-efficient way to store the sine table is to store the values for 0..pi/2, and then extend the table to pi/2..pi. That is, angle=16 (angles go from 0..63, remember) corresponds to pi/2, so the job of extending the table amounts to copying table values 15..0 to values 17..32 (value 16 is not copied because it is pi/2 -- the values are 'mirrored' through pi/2. To put it another way: where would it be copied to?). This amounts to starting one index register at 15 and decrementing it, while starting the other register at 17 and incrementing: ldy #15 ldx #17 lda table,y sta table,x inx dey The problem here is piggybacking into a loop where .Y is initialized to ldy #$7F instead of ldy #15. The solution is to initialize .X carefully [ldy #$7f] ldx #$90 and use the following: lda sin0,y sta sin0+17,x inx [dey] The idea is that when .Y=15, .X will equal 0 and start incrementing. This copies a lot of junk into the table that is never used but doesn't overwrite the existing 0..16 table values, and saves several bytes. The rotation tables are tables of r*sin(theta), one table for every allowed value of theta=0..64 and r=-128..127. The idea is that every 256-byte page contains r*sin(theta) for one value of theta, so that a table lookup amounts to lda theta ora #>high byte of sin tables sta zp+1 ldy r lda (zp),y The table values are computed using a standard shift-and-add multiply routine, with a little extra code to handle negative values of r. What about negative values of sin(theta), though? Recall that theta=0..63 corresponds to 0..2*pi, but the init code only extends the sine table from 0..pi (i.e. theta=0..32). The table values for pi..2*pi are computed simply by taking the negative of the values for 0..pi: sta (p4000),y eor #$ff clc adc #1 STA (p6000),y (The clc adc #1 code was painful, byte-wise, but necessary). The pointers p4000 and p6000 are initialized to $4000 and $6000 by default -- or at least are supposed to be. Unfortunately, VICE and my 128D had different ideas about this, so a little extra code was necessary to initialize them. A big reason for putting a table at $6000 is that it ends at $8000, to provide an easy exit condition for the loop: inc p6000+1 bpl :mult8 ;to $8000 Screen setup ------------ A couple of things need to be done here: clear the screen, set the color RAM to the background color, and put a 16x16 block of characters in the middle of the screen (and do so in as few bytes as possible!). Clearing the screen is a piece of cake: sta $d021 sta $0286 ;clear color jsr $e544 ;clr scr The $0286 STA is to clear the color RAM to black, and work with different kernals. (With both color RAM and background the same, pixels won't appear where they aren't wanted). Storing a block of chars isn't quite so straightforward; rather, the straightforward methods use up a fair number of bytes, especially since both screen and color RAM have to be set up. Here's the method I finally came up with: :l2a jsr $e8ea ;scroll up ;.A = ($AC) ;.X=00 clc ;necessary? :l2 ;set one line sta $0720+12,x inc $db20+12,x inx adc #16 bcc :l2 ; adc #$00 inc $ac cmp #15 bne :l2a Kernal routine $e8ea is the routine to scroll the entire screen up one line, and disassembly shows that when it exits .A is equal to the contents of $AC and .X is 0. So the routine stores one line of chars to the middle of the screen, increments the corresponding color RAM (to white), and moves the screen up (chars and color RAM). By incrementing $AC before moving the screen up ($AC starts at zero normally), .A is re-initialized to its previous value+1, so this prints the next line of chars to the screen (that is, the previous line of chars + 1). By doing this 16 times a 16x16 block of chars is produced, with color RAM set to white inside the block, and set to black everywhere else. And that brings us to the main program loop. --------- Main Loop --------- The main loop is pretty straightforward: clear buffer, get input, update positions and angles, render the buffer, and swap buffers. The only thing special in this process is swapping buffers via the kernal routine at $eb59 -- this is the routine that is called when C= and shift are held down. There is also a lame dot that pulses to the screen when you hold the fire button down (how cool some laser lines would have been). Alas... But that's pretty much it. So now let's talk a little about some of the optimizations that can be made for a 3D program, followed by a discussion of the line routine and the rotation routine. -------- 3D Stuff -------- For detailed information on how to "do" 3D worlds I suggest reading issue #16 of C=Hacking, but the basics of rendering a 3D world are: 1) figuring out where an object is, relative to you, 2) if the object is visible, computing what it looks like from your perspective, and 3) rendering the object. To define and operate on an "object", we need to know the vertices of the object, and how to connect them together with lines, i.e. to draw the edges. The simplest object of all is a tetrahedron -- four points, and every point is connected to every other point. This turns out to be pretty important, as a lot of memory is saved by using only tetrahedrons. To see why, first consider what the code to draw edges from a list of vertices must look like: rotate points ldx #number_of_edges :loop stx temp ldy edge_vertex1,x ;index into some list of vertices lda edge_vertex2,x tax jsr drawline ldx temp dex bpl :loop Now consider a cube; in my original code idea, I was going to use cubes and tetrahedrons (pyramids). A cube has eight vertices and 12 edges. Each vertex has an x, y, and z coordinate, and from the code above each edge needs two bytes, one for each vertex of the edge. 48 bytes, just to store a cube -- almost 1/10 of the entire 512-byte code! (Using code to generate the vertices is possible, but still takes a fair number of bytes.) Compare this to the humble tetrahedron, which only requires twelve bytes: three for each vertex. What about the edges? Remember that every point is connected to every other point in a tetrahedron. This means that we do not have to store the edge connections, but can compute them manually, and draw the whole thing using the following code: [count = 3] ; Plot :ploop ldy count :l2 ldx count dey tya pha jsr DrawLine pla tay bne :l2 dec count bne :ploop This code simply draws lines between all vertices: 3-2, 3-1, 3-0, and so on. It uses pretty much the same number of bytes as the earlier code, but doesn't require any storage of the edge connections (12 bytes to store the connections). Next consider the vertices of a tetrahedron. It turns out that by carefully selecting the vertices, default zero-page values can be used, something that isn't possible with e.g. a cube. Consider the following tetrahedron vertices: Px = 0, -4, 16, -4 Py = 16, 0, 8, 0 Pz = 0, 16, 0, -16 (just the vertices of a more or less regular tetrahedron which I drew on a piece of paper). The trick is to arrange these coordinates in the right way. Location $ae/$af is used by the load routine (among others) to store to memory while loading; at the end of a load, this location contains the end address+1. And, it is surrounded by zeros: >C:00ab 00 00 00 00 00 00 00 3c 03 00 00 00 00 00 00 00 .......<........ which means that by engineering the end address right we can store two coordinates here. In this case, I chose the end address+1 to be $10f0, which stores $f0 and $10 in $ae/$af -- -16 and 16, two of the Pz values, the other two being zero. This was my original plan anyways, until I re-discovered that the screen setup routine altered the value of $ac (the kernal routine to move the screen up exits with .A=$ac, so incrementing it re-initializes .A in every loop iteration.) On the other hand, it increments it 16 times, so that after the load and the init the zp values are >C:00ab 00 10 00 f0 10 .. The values 00 10 00 f0 are exactly the Pz values above: 0, 16, 0, -16. Careful study of default zero-page values then shows >C:0040 00 00 08 00 00 00 00 24 00 00 00 00 00 00 00 00 .......$........ This is pretty much Py, except for the first value -- so all the code has to do is store a 16 at location $40, and it turns out the screen init loop ends with .X=$10. So, one STX does the job, saving yet more bytes. Moreover, by locating the vertices in zero page, zero-page instructions can be used to access the point lists, saving even more bytes. Now, the above are the vertices, but what about the _centers_, i.e. the object locations? Once again, to save space I just used some default zero-page values, with the x-coordinates at $14 and the z-coordinates at $7d. The reason these were chosen is pretty straightforwards. Since objects just move 'downstream' with increasing z-coordinate, the z-coords should be somewhat spread out; the values at $7d are: >C:007d 3a b0 0a c9 20 f0 ef 38 e9 30 38 e9 d0 60 80 4f which are somewhat spread-out. The x-coords, on the other hand, need to be clustered around 0. The camera (you) is located at x=0, and if objects have a large value for the center x-coordinate they are way off to the side (you've probably noticed a few objects like this). Location $14 has some zeros and some nonzero numbers that are near zero, i.e. that cluster around zero: >C:0014 00 00 19 16 00 0a 76 a3 00 00 00 00 00 00 76 a3 Not great values, but good enough -- beggars can't be choosers. Of course, poking different values into locations 20 and above will put the tetrahedrons in different places. ----------------------- Rotation and Projection ----------------------- Rotations are done very simply, using tables. As outlined earlier, there is a complete set of tables of r*sin(theta). At first blush, the 'natural' way of setting up these tables would be to have a table of 1*sin(theta) in one page, a table of 2*sin(theta) in the next page, and so on, maybe fitting a table of cos(theta) in the same page. The location of r*cos(theta) would then be given by page = offset+256*r (e.g. $40+r for tables starting at $4000) value = page+theta It makes a lot more sense, though, to instead let _theta_ be the page index, and let each page contain r*sin(theta) for different values of _r_; the lookup is then page = offset+256*theta (e.g. $5300 for theta=$13) value = page+r For one thing, the pointer setup is very easy -- there's only one value of theta to set up, instead of different values of r (for different x,y coordinates). For another, the entire range r=-128..127 can be used, which means the same tables can be used to rotate object _centers_ in addition to object _vertices_. (If you recall programs like lib3d, the object centers are 16-bit signed values and have their own rotation routine, whereas the object vertices are limited to -96..95 and have a separate, optimized rotation routine; in this case, both the vertices and centers are eight-bit signed numbers.) This all means that rotations (e.g. y*cos(theta) - x*sin(theta)) amount to LDY Py,X LDA (cos),Y LDY Px,X SEC SBC (sin),Y to rotate a point. Now, the usual 3D world calculations look like: rotate object centers rotate vertices, add to rotated center, and project Consider rotating the z-coordinate; the code will look like LDA (cos),Y LDY Px,X SEC SBC (sin),Y CLC RotM1 ADC CZ that is: rotate vertex (z*cos(theta) - x*sin(theta)) and add to center (ADC CZ). Here's the great part: if we instead want to rotate the _center_ of the object, we want to _store_ the rotated point in CZ; the code would look like LDA (cos),Y LDY Px,X SEC SBC (sin),Y STA CZ This is exactly the same code as above, except the ADC is changed to a STA. Therefore only one routine is needed, using self-modifying code to decide whether to STA the center coordinate or ADC it. In the code, the appropriate instruction is passed into the routine in the .Y register. There's one other thing to be mentioned about the rotation routine. The rotation tables are of r*sin(theta), and r*cos(theta) is of course done by adding pi/2 to the theta-coordinate (theta+16 in the program). There are two ways to do this -- add 16 to the theta coordinate and correct any overflow (AND #63, for example), or extend the tables by an extra pi/2. Byte-wise, the first method is the best, but check this out: instead of adding pi/2, we can also subtract 3*pi/2. The setup code then becomes: RotProj AND #$3F ORA #>SINTAB STA sin+1 SBC #$2F ;-3*pi/4 ; ADC #$10 ; AND #$3F ORA #>SINTAB STA cos+1 The trick works here because of the ORA #>SINTAB, which is $4000 -- this allows negative angles to work, by borrowing higher bits. Consider: .A ORA #>SINTAB SBC #$2F ORA #>SINTAB -- ------------ -------- ------------ $12 $52 - sin tab at $5200 $22 $62 - cos tab at $6200 $22 $62 - sin tab at $6200 $32 $72 - cos tab at $7200 $32 $72 - sin tab at $7200 $42 $42 - cos tab at $4200 (C is clear in the SBC above). By using an SBC, angles automatically wrap around from $8000 to $4000. This saves two bytes over the ADC #$10 AND #$3F method (commented out above). Every byte counts!!! Projections ----------- The easiest way to do projections, once again, is via tables of f(x,z)=x/z, much like the tables of f(r,theta) = r*sin(theta). Alas, the setup code to generate these tables took up too much space, so the projects are computed 'manually', using a shift-and-add division routine. Now, it turns out that the routine really computes 64*x/z -- the 64 is the magnification factor. The nice thing about a shift-and-add routine is that multiplying by 64 is possible just by changing the number of shifts. So the routine adds an extra 6 shifts into the division loop -- a little inefficient cycle-wise, but pretty thrifty byte-wise. ------------- Drawing lines ------------- Obviously, to render an object we need a line drawing routine. The line routine in the program is pretty standard, and described in several earlier issues of C=Hacking, so I won't go into too much detail. Most of the paragraphs below are just random tidbits, so feel free to skip any (there won't be a test). The simplest and most efficient routines draw to a charmap -- you can read about this in detail in C=Hacking #8 and beyond -- since the .Y register maps directly to the y-coordinate, and the column-offsets are easy to compute. The biggest charmap available is 128x128, so that's what I used. Overall, the line routine is pretty small, while being reasonably fast. The plot routine is 'dumb', and recalculated for every pixel, but the recalculation itself is fairly efficient, with a few table lookups. It draws into a 128x128 buffer and can handle off-screen coordinates, and the buffer can be anywhere (that it, it supports a double-buffer display). So, I think it turned out OK, all things considered. With that background, here's the main line drawing loop: plot pha tya bmi calcline lda bitphi,x ora base sta point+1 lda bitplo,x sta point lda bitp,x beq calcline ora (point),y sta (point),y calcline pla mod2 sbc #dx bcs mod5 mod3 adc #dy mod4 inx mod5 iny dec temp bne plot The basic line routine iteration goes like plot x,y y=y+1 a = a - dx if a<0 then a=a+dy, x=x+1 for slope>1. The code for slope<1 is exactly the same, except for swapping x and y, and dx and dy. Therefore, I just used one routine and self-modifying code to set INX and INY etc. in the right places (mod2, mod3, mod4, and mod5 above). In retrospect this was not smart, and I think it would have saved 5 or 6 bytes to use two routines, and jsr to the plot routine -- the routines to set up the self-modifying lines take more space than the basic iteration. The x and y coordinates are stored directly in the .x and .y registers. This makes the plotting really easy and relatively fast, and works well with the 128x128 charmap. Off-screen points are a must, so the allowed coordinates are -64..192, giving 64 pixels to either side of the 128x128 visible area. Checking for these is easy, since the high bit will be set for coords <0 and >127. These are checked in the plot routine. The y-coordinate is checked directly, using tya; the x-coordinate is checked for range by use of the bitp table -- this table contains the bit pattern for the x-coordinate (%1000000 %01000000 etc.), and all of the values for x>127 are set to zero. Once again, in retrospect it would have been more memory-efficient to do this with txa (two more bytes), but darn it all, SOMETHING has to be at least a little bit cycle efficient here! Old habits die hard! The line routine always draws from left to right, which means the initialization routine calculates dx = x2-x1, and if dx<0 then it swaps the two endpoints. This creates a problem when coordinates are -64..192 though; after subtracting x2 and x1 do you check the negative bit, or the carry bit? Cases like x1=-1, x2=1 should work, so the carry bit is useless; on the other hand, long lines, say x1=1, x2=130, should also work, so the high bit is also useless. The solution I used was to offset coordinates by +64 (and once again, in retrospect...). The point coordinates are stored in two lists, one of x-coords and one of y-coords. This means that the endpoints are simply indexes into those point lists, i.e.: xlist contains list of x-coords; ylist list of y-coords xlist,x and ylist,x contain left endpoint xlist,y and ylist,y contain right endpoint That simplified a lot of things -- the lists can be in zero-page, swapping coordinates is very simple, and so on. The routine is double-buffered -- flickery single-buffers are just too painful. Since $2000 and $2800 are the locations of the two charset buffers, the current buffer is stored in zero-page variable $ce ("base" in the plot code above), which starts out initialized to #$20 (location $81 would also work) -- one more variable to not initialize. So, in summary: a pretty standard line routine, with a few memory optimizations thrown in. ------- Wrap-up ------- Well, that's about it. As you can see, the code really runs on the edge -- zero-page has to be initialized correctly, little bits and pieces are missing from certain routines, things have to line up just right... but it works! There are undoubtably other places where bytes can be saved, of course, but I think it does a pretty good job of shaving bytes overall. See you at next year's contest! * * tetrattack! * * Mini-3D rendering package * * - Motion in the x-z plane => rotations about one axis only * - Plots to charmap * * SLJ 9/01 * ; org $1000 org $0ef0 NUMOBJS = 9 temp = $02 dx = $03 dy = $04 point = $05 AUX = $03 ACC = $04 CX = $07 CZ = $08 rottemp = $09 ;0 initially zpoint = $0a ;low byte already set to zero zpoint2 = $0c count = $0e angle = $0f cenx = $14 Py = $40 XCoord = $50 YCoord = $60 theta = $70 cenz = $7d ; $ac taken by screen setup routine ; final value = $10 Pz = $ab base = $ce ;$20 initially ;alternate: ($81) p4000 = $8f ;$4000 initially p6000 = $89 ;$60xx initially p8000 = $9c ;$8000 tagged = $e0 sin = $f7 ;0 low byte cos = $f9 curobj = $ff bitp = $1c00 bitplo = $1d00 bitphi = $1e00 bitphi2 = $1f00 SINTAB = $4000 PROJTAB = $8000 DEY = $88 INY = $c8 INX = $e8 ADC = $65 STA = $85 start sta $8f ;set up p4000 * * Set up VIC * sta $d021 sta $0286 ;clear color jsr $e544 ;clr scr lda #$18 sta $d018 * * Line drawing tables * ldx #$90 ldy #$7f lda #$01 :l1 sta bitp,y cmp #$80 rol pha tya lsr lsr lsr lsr sta bitphi,y lda #00 sta bitp+128,y ;0 = dont draw ror sta bitplo,y * * Extend sin table to 0..31 * lda sin0,y sta sin0+17,x ;sta sin0+32,x inx pla dey bpl :l1 ;.y=$ff ;.x=$10 * * Set up sin tables * InitRot iny sty p6000 ;rats :( ; ldy #00 ;rats :( ; ; sty temp ;:l3 ldx rottemp ; lda sin0,x ; sta aux ;sin(theta) ; ;:l4 ; ; clc ; jsr MULT8 ;.Y * AUX ; ; AUX*.Y -> ACC,.A (low,hi) 16-bit result ; AUX, .Y unaffected ; .Y can be negative ; :Mult8 sty temp ;dumb multiply ; sty acc ; lda #00 ; tay ;:mloop clc ; adc sin0-$10,x ; bcc :skip ; iny ;:skip dec acc ; bne :mloop ; tya sty acc lda #00 ldy #9 clc :mloop ror ror acc bcc :mul2 ; clc ;dec sin0 table by 1 instead adc sin0-$10,x :mul2 dey bne :mloop ldy temp bpl :pos sec sbc sin0-$10,x :pos sta (p4000),y eor #$ff clc adc #1 STA (p6000),y iny bne :mult8 inx inc p4000+1 inc p6000+1 bpl :mult8 ;to $8000 ;.x=$20 ;.y=0 * * Set up Screen * ; lda #00 ;bah! ; tya :l2a ; pha jsr $e8ea ;scroll up ; pla ;.A = ($AC) ;.X=00 clc ;necessary? :l2 ;set one line sta $0720+12,x inc $db20+12,x inx adc #16 bcc :l2 ; adc #$00 inc $ac cmp #15 bne :l2a :done ;.X=$10 stx $40 ;rats :( ;init Py * * Main program loop: * - get input * - update positions/angles * - render * - swap buffers * MainLoop ; Clear buffer and swap lda base eor #$08 sta base sta zpoint+1 ldy #00 tya ldx #8 :l0 sta (zpoint),y iny bne :l0 inc zpoint+1 dex bne :l0 ; Get Input ; .X=0 lda $dc00 lsr lsr lsr bcs :c1 inc theta :c1 lsr bcs :c2 dec theta :c2 and #$01 sta tagged eor #$01 ora $23c0+6 sta $23c0+6 ;shot fired ; lda $cb ; and #3 ; beq :skip ; lsr ; bcc :inc ; dec theta ; lsr ; bcc :skip ; jsr Forwards ;:inc inc theta ;:skip ObjLoop ; Compute relative center inx stx curobj dec cenz,x ;Update pos lda cenx,x ; sec ; sbc cenx sta Px+4 lda cenz,x ; sbc cenz sta Pz+4 ; Rotate Rot lda theta,x adc theta sta angle lda theta ldx #4 ldy #STA jsr RotProj lda cz sbc #14 ; bmi :skip cmp #125 bcs NoDraw cmp cx bmi NoDraw adc cx bmi NoDraw ; If in view, rotate project & plot dex stx count :loop lda angle ldy #ADC jsr RotProj sta YCoord,X dex bpl :loop ; Plot :ploop ldy count :l2 ldx count dey tya pha jsr DrawLine pla tay bne :l2 dec count bne :ploop ;.y=0 NoDraw ldx curobj ; Update lda tagged,x beq :zip lda cx ora tagged sta tagged,x dfb $2c :zip inc theta,x cpx #NUMOBJS bcc ObjLoop jsr $eb59 ;Swap buffer ;.Y=1 .A=$7F jmp MainLoop * * Line routine * - Draws from L to R * - Forces dx>0 (x1 < x2) * - Numbers are +128 offset, to allow * - DX and DY to be 0..255 for long lines * - Uses same core routine for stepinx * and stepiny by changing variables * * On input: * .X = index into point list, point 1 * .Y = index of point 2 * Pswap sty temp txa tay ldx temp DrawLine lda xcoord,y sec sbc xcoord,x bcc Pswap ;x2 >= x1 sta dx lda ycoord,y sbc ycoord,x ;dy = y2-y1 bcs :posy ldy #DEY eor #$ff adc #1 dfb $2c :posy ldy #INY sta dy cmp dx lda #INX ; bcs stepiny bcs :noswap tya ldy #INX :noswap sty mod5 sta mod4 stepinx ; sta mod5 ; sty mod4 ;INY/DEY lda dy ldy dx bcc mod stepiny ; sta mod4 ; sty mod5 ;iny/dey lda dx ldy dy mod ; sty mod1+1 sty temp sty mod3+1 sta mod2+1 Draw lda ycoord,x sec sbc #64 tay lda xcoord,x sec sbc #64 tax ;mod1 lda #dy lda temp beq done ;no steps! ; sta temp lsr plot pha tya bmi calcline lda bitphi,x ora base sta point+1 lda bitplo,x sta point lda bitp,x beq calcline ora (point),y sta (point),y calcline pla mod2 sbc #dx bcs mod5 mod3 adc #dy mod4 inx mod5 iny dec temp bne plot done rts * * Rotate and project point * * On entry: * .X = index into point list * .A = rotation angle * .Y = ADC/STA for points/centers * CX,CZ = location of object * * On exit: * Points stored in xcoord,x ycoord,x * RotProj AND #$3F ORA #>SINTAB STA sin+1 SBC #$2F ;-3*pi/4 ; ADC #$10 ; AND #$3F ORA #>SINTAB STA cos+1 STY RotM1 STY RotM2 RotProj2 LDY Pz,X LDA (sin),Y PHA LDA (cos),Y LDY Px,X SEC SBC (sin),Y CLC RotM1 ADC CZ STA aux ; LDY Px,X ; LDA (cos),Y ; LDY Pz,X ; CLC ; ADC (sin),Y PLA CLC ADC (cos),Y CLC RotM2 ADC CX JSR Div8 STA XCoord,X LDA Py,X ; JSR Div8 ; STA YCoord,X ; RTS * * Signed division routine * * 64*.A/AUX -> .A * Only valid for .Y/AUX < 1 * .A pos or neg, AUX > 0 * AUX, .X unaffected * DIV8 ; STY ACC ; tya php bpl :pos ;.A just loaded eor #$ff :pos sta acc LDA #0 LDY #14 :DLOOP ASL ACC ROL CMP AUX BCC :DIV2 SBC AUX INC ACC :DIV2 DEY BNE :DLOOP ;RTS lda acc plp bpl :pos2 eor #$ff :pos2 eor #$80 ;+128 offset rts * * Point list * Last point is for relative cx,cz * ;Pz dfb 0,16,0,-16,0 ;Py dfb 16,0,8,0 Px dfb 0,-4,16,-4 ;note: px+4 intrudes on table below * * 128*sin(t), t=0..15 * * Table must be at end of code! * ;sin0 dfb 0,13,25,37,49,60,71,81 ; dfb 91,99,106,113 ; dfb 118,122,126,127,128 ;sin0 dfb 0,25,50,74,98,120,142,162 ; dfb 180,197,212,225,236,244,250,254,255 sin0 dfb 0,24,49,73,97,119,141,161 dfb 179,196,211,224,235,243,249,253,255 end ....... .... .. . - fin -