diff options
| -rw-r--r-- | .github/ISSUE_TEMPLATE/custom.md | 17 | ||||
| -rw-r--r-- | .github/PULL_REQUEST_TEMPLATE.md | 10 | ||||
| -rw-r--r-- | .github/workflows/ci.yml | 22 | ||||
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | CHANGELOG.md | 6 | ||||
| -rw-r--r-- | CONTRIBUTING.md | 24 | ||||
| -rw-r--r-- | LICENSE | 165 | ||||
| -rw-r--r-- | Makefile | 43 | ||||
| -rw-r--r-- | README.md | 202 | ||||
| -rw-r--r-- | examples/mul.s | 81 | ||||
| -rw-r--r-- | list.s | 179 | ||||
| -rw-r--r-- | test/common.s | 83 | ||||
| -rw-r--r-- | test/run.sh | 56 | ||||
| -rw-r--r-- | test/suite.lua | 66 | ||||
| -rw-r--r-- | test/suite.s | 186 | ||||
| -rw-r--r-- | test/utils.lua | 80 |
16 files changed, 1223 insertions, 0 deletions
diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..4a0af7e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,17 @@ +--- +name: Custom issue template +about: General issues, both bugs and features. +title: '' +labels: '' +assignees: mssola +--- + +### Description + +1. First I did this... +2. Then that... +3. And this happened! +4. Versions: git commit sha, toolchain version, version of the emulator/system, etc. + +- **Expected behavior**: I expected this to happen! +- **Actual behavior**: But this happened... diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..13ff63b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +Provide a general description of the changes in your pull request. If this pull +request fixes a known issue, please tag it as well (e.g.: `Fixes #1`). In any +case: + +- [ ] Write a [good commit message](https://chris.beams.io/posts/git-commit/). +- [ ] You are sure that `make` continues to work. +- [ ] The pull request has *only* one subject and a clear title. You are not + submitting a pull request with tons of different unrelated commits. + +Thanks! diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c590235 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Continuous Integration + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install dependencies from Ubuntu sources + run: sudo apt-get install cc65 fceux ruby + + - name: Main task + run: make diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1dbaac --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +tmp/ +*.o +**/*.nes diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d433fdc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +## 0.1 + +Initial version with a `list.s` file which implements the `List` scope. This +scope defines two 16-bit pointers from which you can manipulate a list, but they +are more useful when used together with the macros `LIST_INIT`, `LIST_IT_FROM` +and `LIST_NEXT`; and the functions `set`, `push` and `get`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0baab7f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +## Why? + +Do you want to fix an error you have found? Do you know a way to improve my +6502-fu or do you know a technique on NES development that might help here? I am +open for discussion and welcome any help! + +## How? + +There are many ways to help me out. One way might be to open an issue on +[Github's tracker](https://github.com/mssola/list.nes/issues) and start a +discussion. For this, mind the following: + +- Check that the issue has not already been reported or fixed in `main`. +- Try to be concise and precise in your description. +- If you have found a problem, provide a step by step guide on how to reproduce it. +- Provide the version you are using (git commit SHA), as well as the version of + the toolchain and the emulator/system being used. + +Another way is to simply submit a pull request. For this, also mind these: + +- Write a [good commit message](https://chris.beams.io/posts/git-commit/). +- You are sure that `make` continues to work. +- The pull request has *only* one subject and a clear title. You are not + submitting a pull request with tons of different unrelated commits. @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ef3cb0e --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +CA65 ?= ca65 +CC65 ?= cl65 +CCOPTS ?= --target nes +ifeq "$(DEBUG)" "1" +CCOPTS += -g -Ln tmp/labels.txt +endif + + +.PHONY: test +test: prepare build example unit + +.PHONY: prepare +prepare: clean deps + +.PHONY: clean +clean: + @sed -i '/RUN_TESTS = 1/d' test/suite.s + @rm -rf tmp + @mkdir tmp + @find . -type f -name "*.o" -delete + @find . -type f -name "*.nes" -delete + +.PHONY: deps +deps: + @which $(CC65) >/dev/null 2>/dev/null || (echo "ERROR: $(CC65) not found." && false) + @which fceux >/dev/null 2>/dev/null || (echo "ERROR: fceux not found." && false) + +.PHONY: build +build: list.o + +%.o: %.s + $(CA65) $< -o $@ + +test/%.nes: test/%.s + $(CC65) $(CCOPTS) $< -o $@ + +.PHONY: unit +unit: + @bash test/run.sh + +.PHONY: example +example: + $(CC65) $(CCOPTS) examples/mul.s -o examples/mul.nes diff --git a/README.md b/README.md new file mode 100644 index 0000000..982c91a --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +## list.nes + +This is a small library that allows NES developers to maintain and +manipulate big lists on the system. + +The use case for this is indeed rare for NES games, where you usually have an +already defined table or an array with a fixed small size (or at least not +huge). That being said, I encountered this problem while solving [day 4 of +Advent of Code 2023](https://adventofcode.com/2023/day/4) (see my repo +[mssola/aoc2023.nes](https://github.com/mssola/aoc2023.nes)), in which I needed +to manipulate a list of around 600 bytes. This was a bit tricky to get right and +it added a bunch of code that was unrelated to the challenge itself. Hence, I +extracted the logic into this library and tuned things down from the lessons I +learnt along the way. + +It's all pretty simple but being assembly code it comes with some gotchas that +you need to be aware of. + +### Installing + +You need to copy the code into some vendor directory, or add this repository as +a git submodule. After doing that, simply `.include` it in your code and it's +ready to be used. + +As for the memory mapping, please do note that this library needs 4 bytes in +order to store two 16-bit pointers that keep track of the list. By default these +4 bytes are located at \$60-\$63. If that clashes with something from your code, +simply change these variables, named `List::ptr` and `List::last`. Each of these +pointers are 16-bit long (hence 2 bytes long), and they don't need to be in +continguous memory locations. + +### Creating a list + +In order to create a list you don't need to reserve a whole memory location in a +fixed way. Rather, what you do is to set the `List::ptr` and the `List::last` +pointers to the beginning of the memory from which your list will grow. Thus, +it's up to you to set this to a valid memory address which is free for as many +bytes as you'll need. This library will not try to avoid possible clashes and +will simply grow mindlessly as long as you keep pushing data to it. + +And so, what are these pointers? `List::ptr` stores the memory location in +little endian format in which the current read/write will happen, while +`List::last` stores the last memory address in which a push happened. That is, +`List::last` resolves to how big your list is, while `List::ptr` is simply an +iterator over this list. Hence, in order to "create" a list you have to set both +these pointers to the same memory address, or simply use the provided +`LIST_INIT` macro, which asks for a full 16-bit address: + +``` assembly +LIST_INIT $0400 +;; now you have a list that starts at memory address $0400. +``` + +### Pushing data + +The most basic operation from this library is `List::push`, which will push a +new value to the list and grow it by one byte. Example: + +``` assembly +LIST_INIT $0400 + +;; The value to be pushed has to be stored into the `a` register. +lda #2 +jsr List::push +;; now your list is: [$02] ($0400: $02). + +lda #$F0 +jsr List::push +;; now your list is: [$02, $F0] ($0400: $02, $0401: $F0). + +;; and so on... +``` + +Note that both the `a` and the `y` registers will be affected after calling +`List::push`, and that the carry flag might also be set. + +### Getting data + +Now that you have pushed data, it would be cool to actually get it back! For +that you have two ways. First of all, you can perform a load with indirect +addressing by using the `List::ptr` pointer. Hence, the following allows you to +fetch the item at memory address `$0401`: + +``` assembly +lda #$01 +sta List::ptr +lda #$04 +sta List::ptr + 1 + +ldy #0 +lda (List::ptr), y +``` + +Note that there is a macro which resets the iterator pointer called +`LIST_IT_FROM`. Hence, the previous code could have been written like so: + +``` assembly +LIST_IT_FROM $0401 + +ldy #0 +lda (List::ptr), y +``` + +This is convenient for random access, but when you are iterating over the list +it might be tedious to move the pointer over and over. This is why there is also +the `LIST_NEXT` macro, which will move the `List::ptr` pointer for you: + +``` assembly +LIST_IT_FROM $0401 + +ldy #0 +lda (List::ptr), y + +LIST_NEXT ;; so `List::ptr` points to $0402. +``` + +But doing things like this there is the gotcha that you don't know when to stop +if you are just iterating over the whole list. In order to solve this problem +there is another way of fetching data: the `List::get` function. This function +works like this: + +1. Try to fetch the value on `List::ptr`. +2. If `List::ptr` is already at the end of the list, it will set `a` to `0` and + `y` to `$FF`. +3. If not, then the value will be copied to `a` and `y` will be set to `0`. +4. Move `List::ptr` to the next element if possible. + +This way you can come up with a code like this: + +``` assembly +;; Imagine we have a list already initialized at $0400. Let's reset the `List::ptr` +;; so to start iterating from there. +LIST_IT_FROM $0400 + +@loop: + jsr List::get + cpy #$FF + beq @done + ;; do whatever with the value on `a`. + jmp @loop +@done: + ;; move on... +``` + +This way you don't have to care about pointers or anything: just set where you +want to start iterating your list, and call `List::get` until `y` has an `$FF` +value. + +### Setting values without growing the list + +As we have seen with `List::push`, this subroutine will also move the +`List::last` pointer, so our list grew on each push. Now let's say that we have +already initialized a list at `$0400` and we want to set some values without +growing the list any further. One way would be to, again, just use the +`List::ptr` variable: + +``` assembly +LIST_IT_FROM $0402 + +lda #1 +ldy #0 +sta (List::ptr), y +``` + +But again, when doing it while iterating this can be a bit tedious. That's why +there is the `List::set` function. Here you'd have: + +``` assembly +LIST_IT_FROM $0402 +lda #1 +jsr List::set +;; `List::ptr` has been moved to point to $0403 so you can use it on the next iteration. +``` + +And again, this `List::set` function works in the same way as `List::get`, in +which the `y` register contains whether the operation could be performed or not. +That is, if we were at the end of the list, then `y` is set to `$FF`, otherwise +to `0`. This again is pretty convenient when performing a loop: + +``` assembly +;; Imagine we have a list at $0400. Let's reset the `List::ptr` so to start +;; iterating from there. +LIST_IT_FROM $0400 + +@loop: + lda #2 + jsr List::set + cpy #$FF + bne @loop + ;; move on... +``` + +### Putting it all together + +You can check out an example in the [examples/mul.s](./examples/mul.s) file. +Otherwise you can also see it being used in real life on my solution for [day 4 +of Advent of Code](https://github.com/mssola/aoc2023.nes/blob/main/src/4.s). + +## License + +Released under the [LGPLv3+](http://www.gnu.org/licenses/lgpl-3.0.txt), +Copyright (C) 2023-<i>Ω</i> Miquel Sabaté Solà. diff --git a/examples/mul.s b/examples/mul.s new file mode 100644 index 0000000..81e12f4 --- /dev/null +++ b/examples/mul.s @@ -0,0 +1,81 @@ +;;; +;; A silly example that tries to demonstrate how to use list.nes. +;; +;; It will initialize a list of 256 16-bit items with a value of 0..255 +;; respectively. After doing that, it will multiply each value by 2 and store it +;; again. As a final touch, the second item will be forced to have the same +;; value for the high and the low bytes. +;; +;; You can check that this works by running `make example` and then running the +;; resulting ROM file into an emulator with a RAM inspector like FCEUX or Mesen. +;; Then go from memory address $0400 onwards and check that the results are as +;; described. + +.include "../test/common.s" + +main: + LIST_INIT $0400 + + ;; We will store 256 16-bit items and $90 will keep track of it. + lda #0 + sta $90 +@fill_loop: + ;; The 16-bit item will have the value of the current index as stored in + ;; $90. The list will be initialized through the `List::push` function, so + ;; the size is properly set at the end. + lda $90 + jsr List::push + lda #0 + jsr List::push + + ;; If we have set all the items we wanted, move into the next thing, + ;; otherwise increase $90 and go back into the loop. + lda $90 + cmp #$FF + beq @set + inc $90 + jmp @fill_loop + +@set: + ;; Reset the pointer to the start of the list since we want to iterate it + ;; over. + LIST_IT_FROM $0400 + +@set_loop: + ;; There are two values to keep track: `List::ptr` and `List::ptr + 1`. For + ;; the low byte we need to shift left once, and then the high byte needs to + ;; add the possible carry to itself. In order to do this and call + ;; `List::set` properly we need to be do some stuff with the stack (low + ;; byte) or the `x` register (high byte). + ldy #0 + lda (List::ptr), y + asl + pha + + lda #0 + iny + adc (List::ptr), y + tax + + ;; Pull the value that corresponds to the low byte and set it. After doing + ;; that, though, did we get a $FF value? If so then it means that we are + ;; done with it, otherwise proceed to grab the high byte stored on the `x` + ;; registerm set it with `List::set` and loop again. + pla + jsr List::set + cpy #$FF + beq @final_get + txa + jsr List::set + jmp @set_loop + +@final_get: + ;; We are done! Now for a final touch let's copy the value from the low byte + ;; into the high byte of the second element. + LIST_IT_FROM $0402 + + jsr List::get + jsr List::set + +done: + jmp done @@ -0,0 +1,179 @@ +;;; +;; list.nes - a small library to maintain big lists on the NES. +;; +;; Copyright (C) Miquel Sabaté Solà <mikisabate@gmail.com> +;; +;; This library is free software: you can redistribute it and/or modify +;; it under the terms of the GNU Lesser General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. +;; +;; This library is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU Lesser General Public License for more details. +;; +;; You should have received a copy of the GNU Lesser General Public License +;; along with this library. If not, see <https://www.gnu.org/licenses/>. + +.p02 + +;; Initialize a new list which starts at the given 16-bit address. +.macro LIST_INIT address + lda #.lobyte(address) + sta List::ptr + sta List::last + lda #.hibyte(address) + sta List::ptr + 1 + sta List::last + 1 +.endmacro + +;; For an already initialized list, reset the `List::ptr` variable so the list +;; can be iterated with functions like `List::get`. +.macro LIST_IT_FROM address + lda #.lobyte(address) + sta List::ptr + lda #.hibyte(address) + sta List::ptr + 1 +.endmacro + +;; Move the `List::ptr` variable so to point to the next element. +.macro LIST_NEXT + clc + lda #1 + adc List::ptr + sta List::ptr + lda #0 + adc List::ptr + 1 + sta List::ptr + 1 +.endmacro + +;;; +;; List provides the scope for the variables and subroutines that have been +;; defined in order to maintain and manipulate big lists on the NES. +;; +;; This library can also work for smaller list but there are other more +;; performant ways to achieve the same thing. Thus, use this library only if you +;; need to have a list that may store more than 255 bytes and indexing in the +;; usual ways might be a problem otherwise. +;; +;; For more information on the usage and the API check the documentation +;; (README.md file on the https://github.com/mssola/list.nes repository). +.scope List + ;; NOTE (important): this library needs 4 bytes ($60-$63) to store + ;; information of this list. These are two 16-bit pointers which are used + ;; for the subroutines being defined here. If you have a clash with these + ;; memory addresses, do feel free to change them, but remember that the code + ;; assumes that both pointers are 16-bit (they don't need to be contiguous). + ptr = $60 + last = $62 + + ;; Set the value of `a` into the list and advance one position without + ;; growing the list. + ;; + ;; Use this function instead of `List::push` if you have already set + ;; `List::ptr` as desired (e.g. with `LIST_IT_FROM`) and you just want to + ;; set a specific value to this address. That is, the list has already been + ;; defined somewhere else and you are just modifying some position. + ;; + ;; If the list pointer is already at the end and the operation is not + ;; possible, then `y` is set to $FF, otherwise `y` will be set to 0. + ;; + ;; NOTE: registers modified: `a` and `y`. + .proc set + tay + + ;; Check if `List::ptr` >= `List::last` (16-bit comparison). If this is + ;; the case, then we are actually done, otherwise we can proceed to set + ;; the value as desired. + lda List::ptr + 1 + cmp List::last + 1 + bcc @do + bne @done + lda List::ptr + cmp List::last + bcc @do + @done: + ;; We were actually done: set `y` to `$FF` to denote "end of list". + ldy #$FF + lda #0 + rts + @do: + tya + ldy #0 + sta (List::ptr), y + + LIST_NEXT + + rts + .endproc + + ;; Push the value of `a` after the last position of the list and grow one + ;; more byte. + ;; + ;; Note that this subroutine assumes that we are already at the last item of + ;; the list. Hence, if you reset `List::ptr` (e.g. with `LIST_IT_FROM`) and + ;; then call this subroutine, you will also move the `List::last` pointer + ;; accordingly and thus you might have (accidentally) shrinked the list. If + ;; this is not a behavior that you want (you just want to set a value at a + ;; specific location), then take a look at `List::set` instead. + ;; + ;; NOTE: registers modified: `a` and `y`. + ;; NOTE: this subroutine might set the carry flag. + .proc push + ldy #0 + sta (List::ptr), y + + clc + lda #1 + adc List::ptr + sta List::ptr + sta List::last + lda #0 + adc List::ptr + 1 + sta List::ptr + 1 + sta List::last + 1 + rts + .endproc + + ;; Get the contents of the current position of the list pointer and advance + ;; it. + ;; + ;; The byte will be stored into the 'a' register. If the list pointer is + ;; already at the end and won't fetch relevant data, then `y` is set to $FF, + ;; otherwise `y` will be set to 0. + ;; + ;; NOTE: registers modified: 'a' and 'y'. + ;; NOTE: this subroutine might set the carry flag. + .proc get + ;; Check if `List::ptr` >= `List::last` (16-bit comparison). If this is + ;; the case, then we are actually done, otherwise we can proceed to + ;; fetch the value as desired. + lda List::ptr + 1 + cmp List::last + 1 + bcc @do + bne @done + lda List::ptr + cmp List::last + bcc @do + @done: + ;; We were actually done: set `y` to `$FF` to denote "end of list" and + ;; zero out the returned value. + ldy #$FF + lda #0 + rts + @do: + ;; Fetch the value and push it into the stack since `LIST_NEXT` will + ;; actually mess with the `a` register. + ldy #0 + lda (List::ptr), y + pha + + LIST_NEXT + + ;; Get the value back from the stack so to set the proper return value. + pla + + rts + .endproc +.endscope diff --git a/test/common.s b/test/common.s new file mode 100644 index 0000000..f0e9fb9 --- /dev/null +++ b/test/common.s @@ -0,0 +1,83 @@ +.segment "HEADER" + .byte 'N', 'E', 'S', $1A + + .byte $02 + .byte $01 + + .byte $00 + .byte $00 + +.segment "VECTORS" + .addr nmi, reset, irq + +.segment "CHARS" +.segment "STARTUP" +.segment "CODE" + +.include "../list.s" + +reset: + sei + cld + + ldx #$40 + stx $4017 + + ldx #$ff + txs + + inx + stx $2000 + stx $2001 + stx $4010 + +@vblankwait1: + bit $2002 + bpl @vblankwait1 + + ldx #0 + lda #0 +@ram_reset_loop: + sta $000, x + sta $100, x + sta $300, x + sta $400, x + sta $500, x + sta $600, x + sta $700, x + inx + bne @ram_reset_loop + + lda #$ef +@sprite_reset_loop: + sta $200, x + inx + bne @sprite_reset_loop + + lda #$00 + sta $2003 + lda #$02 + sta $4014 + +@vblankwait2: + bit $2002 + bpl @vblankwait2 + + lda #$3F + sta $2006 + lda #$00 + sta $2006 + + lda #$0F + ldx #$20 +@palettes_reset_loop: + sta $2007 + dex + bne @palettes_reset_loop + + jmp main + +;; Unused +nmi: +irq: + rti diff --git a/test/run.sh b/test/run.sh new file mode 100644 index 0000000..6caef32 --- /dev/null +++ b/test/run.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -e + +ROOT="$( cd "$( dirname "$0" )/.." && pwd )" +cd "$ROOT" + +# Clean previous builds, prepare the test environment and call build again with +# a test setup. +sed -i 's/.ifdef RUN_TESTS/RUN_TESTS = 1\n.ifdef RUN_TESTS/g' "$ROOT/test/suite.s" +DEBUG=1 make test/suite.nes + +rm -f "$ROOT/tmp/test-results.txt" + +# Github Actions do not allow GUI programs to be run. This means that the code +# below will always fail (fceux won't be able to run). There is a way to emulate +# an X server with tools like xvfb-run or xvncserver, but so far I've had no +# luck on this front. +if [ ! -z "${GITHUB_ACTION}" ]; then + exit 0 +fi + +# Run all the tests that we have on Lua. +for file in test/*.lua; do + if [ -n "$(echo $file | grep -v utils.lua)" ]; then + echo $file + fceux --loadlua $file "$ROOT/test/suite.nes" + fi +done + +# +# Show the results. + +if [ ! -f "$ROOT/tmp/test-results.txt" ]; then + echo "Something went wrong: test results were not printed out!" + exit 1 +fi + +cat "$ROOT/tmp/test-results.txt" + +n=$(cat "$ROOT/tmp/test-results.txt" | grep FAIL | wc -l) +echo "" +case $n in + 0) + echo "All tests passed!" + exit 0 + ;; + 1) + echo "1 test failed!" + exit 1 + ;; + *) + echo "$n tests failed!" + exit 1 + ;; +esac diff --git a/test/suite.lua b/test/suite.lua new file mode 100644 index 0000000..4a5578c --- /dev/null +++ b/test/suite.lua @@ -0,0 +1,66 @@ +utils = require "utils" + +utils.StartRun("Unit tests") + +--- +-- We push three elements and we sum them up. We expect a proper sum value +-- stored at $90, the items on $040{0, 1, 2} and the List pointers at the last +-- position. + +utils.MemTest("@test_list_sum", { + {0x60, "03"}, {0x62, "03"}, -- List::{ptr, last} should be at the very end (one past the last written). + {0x90, "07"}, -- We pushed three elements: 2, 4, 1; and the sum is left in 0x90. + {0x400, "02"}, {0x401, "04"}, {0x402, "01"} -- The three elements pushed by this test. +}) + +--- +-- We initialize an empty list and we try to call `get` with no elements. The +-- pointers should not move and the test function should set a `1` on $90 if +-- `get` set $FF to `y` (which is what we want). + +utils.MemTest("@test_list_empty_get", { + {0x60, "00"}, {0x62, "00"}, -- List::{ptr, last} did not advance. + {0x90, "01"} -- $FF was simply returned. +}) + +--- +-- We initialize a large list ($200-sized) with the 8-bit index as a value +-- (hence 0-$FF twice). In $90-$91 we leave the sum of all the values. + +utils.MemTest("@test_large_list", { + {0x90, "00"}, {0x91, "FF"} -- (0..255) * 2 = 65280, which is 0xFF00 in hexadecimal. +}) + +--- +-- We run the same code of `@test_list_sum` so to get a new three-sized array, +-- but then we overwrite the contents. This test makes sure that perform the sum +-- again gives us a new value and, hence, the previous values were actually +-- overwritten by `List::set`. + +utils.MemTest("@test_list_set", { + {0x60, "03"}, {0x62, "03"}, -- List::{ptr, last} should be at the very end (one past the last written). + {0x90, "0A"}, -- We overwrote the three elements: 3, 5, 2; and the sum is left in 0x90. + {0x400, "03"}, {0x401, "05"}, {0x402, "02"} -- The three elements pushed by this test. +}) + +--- +-- We initialize an empty list and we try to call `set` with no elements. The +-- pointers should not move and the test function should set a `1` on $90 if +-- `get` set $FF to `y` (which is what we want). + +utils.MemTest("@test_list_empty_set", { + {0x60, "00"}, {0x62, "00"}, -- List::{ptr, last} did not advance. + {0x90, "01"} -- $FF was simply returned. +}) + +--- +-- We initialize a list with two elements, and then we try to call `List::set` +-- three times. The first two writes work, the third not. + +utils.MemTest("@test_list_overflow", { + {0x60, "02"}, {0x62, "02"}, -- List::{ptr, last} should be at the very end (one past the last written). + {0x90, "00"}, {0x91, "00"}, {0x92, "FF"}, -- We overwrote the three elements: 3, 5, 2; and the sum is left in 0x90. + {0x400, "02"}, {0x401, "04"}, {0x402, "00"} -- The three elements pushed by this test. +}) + +utils.EndRun() diff --git a/test/suite.s b/test/suite.s new file mode 100644 index 0000000..c302e0a --- /dev/null +++ b/test/suite.s @@ -0,0 +1,186 @@ +;;; +;; The definition of `RUN_TESTS` will be inserted automatically when running +;; tests. Do not insert the definition manually. + +.include "common.s" + + +main: +.ifdef RUN_TESTS + jsr suite +.endif +halt: + jmp halt + +;;; +;; Test suite + +suite: + jsr list_sum + jsr list_empty_get + jsr large_list + jsr list_set + jsr list_empty_set + jsr list_set_overflow + + rts + +;; This is re-used in both `list_sum` and `list_set`. +.macro LIST_SUM_AUX + lda #0 + sta $90 + + LIST_INIT $0400 + + lda #2 + jsr List::push + lda #4 + jsr List::push + lda #1 + jsr List::push + + LIST_IT_FROM $0400 +: + jsr List::get + cpy #$FF + beq :+ + clc + adc $90 + sta $90 + jmp :- +: + nop +.endmacro + +list_sum: + LIST_SUM_AUX +@test_list_sum: + rts + +list_empty_get: + LIST_INIT $0400 + jsr List::get + cpy #$FF + beq :+ + lda #0 + sta $90 + jmp @test_list_empty_get +: + lda #1 + sta $90 +@test_list_empty_get: + rts + +large_list: + lda #0 + sta $90 + sta $91 + sta $92 + + LIST_INIT $0400 + + ldx #0 +: + txa + jsr List::push + + ;; Are we about to overflow the `x` register? If so, check if this was the + ;; first time or not. If so, then we let it overflow and loop again $FF + ;; times. Otherwise we will stop the loop, since we want to have a + ;; $200-sized list. + cpx #$FF + bne :+ + inc $92 + lda #2 + cmp $92 + beq :++ +: + inx + jmp :-- +: + ;; At this point we have stored this big list, let's add things up. + lda #0 + sta $90 + sta $91 + LIST_IT_FROM $0400 +: + jsr List::get + cpy #$FF + beq @test_large_list + clc + adc $90 + sta $90 + lda #0 + adc $91 + sta $91 + jmp :- + +@test_large_list: + rts + +list_set: + LIST_SUM_AUX + + lda #0 + sta $90 + + LIST_IT_FROM $0400 + + lda #3 + jsr List::set + lda #5 + jsr List::set + lda #2 + jsr List::set + + LIST_IT_FROM $0400 +: + jsr List::get + cpy #$FF + beq @test_list_set + clc + adc $90 + sta $90 + jmp :- +@test_list_set: + rts + +list_empty_set: + LIST_INIT $0400 + jsr List::set + cpy #$FF + beq :+ + lda #0 + sta $90 + jmp @test_list_empty_set +: + lda #1 + sta $90 +@test_list_empty_set: + rts + +list_set_overflow: + ;; $0402 should not be set by this function, and this is to be checked; so + ;; zero it out before doing anything. + lda #0 + ldy #0 + sta $0402 + + LIST_INIT $0400 + lda #1 + jsr List::push + lda #2 + jsr List::push + + LIST_IT_FROM $0400 + lda #2 + jsr List::set + sty $90 + lda #4 + jsr List::set + sty $91 + lda #6 + jsr List::set + sty $92 +@test_list_overflow: + rts diff --git a/test/utils.lua b/test/utils.lua new file mode 100644 index 0000000..9405c8b --- /dev/null +++ b/test/utils.lua @@ -0,0 +1,80 @@ +utils = {} + +-- Returns the root path for the project. +function utils.RootPath() + local fullpath = debug.getinfo(1,"S").source:sub(2) + fullpath = io.popen("realpath '"..fullpath.."'", 'r'):read() + fullpath = fullpath:gsub('[\n\r]*$','') + + local dirname, filename = fullpath:match('^(.*/)([^/]-)$') + dirname = dirname or '' + if dirname == '' then + return '' + end + + return io.popen("realpath '"..dirname.."/..'", 'r'):read() +end + +-- At a `label` that exists on the assembly code grab the values for the given +-- addresses and write it all into the `test-results.txt` file. The `addresses` +-- array is made up of two-sized arrays, where the first element contains the +-- memory you are trying to test, and the second element is the value that we +-- are expecting. +function utils.MemTest(label, addresses) + local cmd = "cat ".. utils.RootPath() .. "/tmp/labels.txt | grep .".. label .." | awk '{ print $2; }' | cut -c3-" + local file = assert(io.popen(cmd, 'r')) + local result = file:read("*a") + + -- Double check that the address that we grabbed has at least a good format. + if string.len(result) ~= 5 then + error("Error on '" .. label .. "': got a bad address! (".. result ..")") + end + + -- Register a function to execute on the given test address. The function will + -- simply iterate over the given `addresses` and compare them with the + -- expected result. Everything will be saved into the `test-results.txt` file. + memory.registerexecute(tonumber(result, 16), function() + local expected = "" + local got = "" + + for _, vals in ipairs(addresses) do + expected = expected .. "$" .. string.format("%04X", vals[1]) .. " -> " .. vals[2] .. "; " + got = got .. "$" .. string.format("%04X", vals[1]) .. " -> " .. string.format("%02X", memory.readbyte(vals[1])) .. "; " + end + + file = io.open(utils.RootPath() .. "/tmp/test-results.txt", "a") + io.output(file) + + if expected == got then + io.write("-> Test '".. label .."': OK\n") + else + io.write("\n-> Test '".. label .."': FAIL\n") + io.write("Expected: ".. expected .. "\n") + io.write("Got: ".. got .. "\n") + io.write("\n") + end + io.close(file) + end) +end + +function utils.StartRun(title) + file = io.open(utils.RootPath() .. "/tmp/test-results.txt", "a") + io.output(file) + io.write("\n== " .. title .. " ==\n") + io.close(file) +end + +-- Ends the given test run. That is, it will exit from the emulator so we can +-- turn back to the runner. +function utils.EndRun() + -- I'm not entirely sure why this is needed, but if we don't advance for + -- several frames fceux won't exit. Thus, let's advance for some frames and + -- then quit. + for i = 0, 10, 1 do + emu.frameadvance(); + end + + emu.exit() +end + +return utils |
