Both sides previous revisionPrevious revisionNext revision | Previous revision |
javascript_terminal_v3 [2023/11/29 07:31] – appledog | javascript_terminal_v3 [2023/11/30 02:37] (current) – appledog |
---|
When I began to write NetWhack, I decided that the game would start out as a terminal emulator, and then the game would rely on the terminal emulator side of the software as if it were a game engine. I liked this 'old school' environment because it was nostalgic for me. It is how I learned to code, way back in the day, so I thought it would be a fun little project to write a interface that would look like a Commodore or a PC or some kind of monochrome terminal. | When I began to write NetWhack, I decided that the game would start out as a terminal emulator, and then the game would rely on the terminal emulator side of the software as if it were a game engine. I liked this 'old school' environment because it was nostalgic for me. It is how I learned to code, way back in the day, so I thought it would be a fun little project to write a interface that would look like a Commodore or a PC or some kind of monochrome terminal. |
| |
The main problem I encountered was that modern, event driven systems like Android, Javascript and iOS don't work well as imperative systems, where you need to do blocking input -- and perhaps because of the rush in which these systems were developed, the languages and environments are of very poor quality and do not support many features (such as multithreading). A great example of this is Web Assembly, which cannot access the DOM, and cannot even do simulated multitasking. What were they thinking? | The main problem I encountered was that modern, event driven systems like Android, Javascript and iOS don't allow you to do blocking string input. Trying to do getkey() or input() is impossible. Eventually I found a way to simulate it within the environment of the terminal simulator by sending the line that the cursor was on to a "command processor", which would interpret the line like a command. This was close to a shell running on an OS, but there were no clear definitions of what was the OS, what was the shell, what was the hardware. Then I wrote an actual input() function that allowed me to prompt the user and set it to a keyword which could be evaluated by the game engine later: |
| |
I tried many ways to get around this and eventually I found a way to simulate blocking string input in a single threaded environment. The system would be in different 'modes', and in input mode it would simulate string input by sending the line on which the user pressed ENTER to the event queue -- like a console. Later I refined it, marking where we began the input. In Javascript it looked like this: | |
| |
<Code:JavaScript> | <Code:JavaScript> |
When the user pressed ENTER, a console command "SET_NAME <value>" would be sent to the event queue (the game engine) for processing like a console command. | When the user pressed ENTER, a console command "SET_NAME <value>" would be sent to the event queue (the game engine) for processing like a console command. |
| |
This was great but it still did not solve the problem of context; I was not able to store and use the value of the input locally ex. "a = input()". Every time I accepted string input I would have to lose program context, then respond to the SET_NAME event later on. This was simulated string input, but not simulated blocking string input. | I then began experimenting with a kind of in-terminal scripting, like BASIC. The concept was simple, I would see if the first part of the string sent to the command processor was a number. If it was, it sent it to an array indexed by that number. This became a program listing which I could "RUN" with an interpreter. |
| |
| This would finally solve the context problem; normally you cnanot store and use the value of the input locally ex. "a = input()", because JavaScript (as does LibGDX, PyGame, and many others) runs its game engine code on the UI thread. So every time I accepted string input I would have to lose program context, then respond to the SET_NAME event later on. This was simulated string input, but not simulated blocking string input. |
| |
| == Tradeoffs |
| This is really the best you can do. If you try and expand the scripting aspect you will eventually find yourself rewriting the entire game in the virtual scripting language. It might not be worth it, if you can break up your code just a little. With that in mind I set out to make V3 the best damn terminal simulator it could be, but with a structure that would allow someone to turn it into a state machine very easily, if desired. |
| |
=== Refactoring gameState | === Refactoring gameState |
Now the question is over the queue. Do we use one monolithic atomic queue or try to use multiple queues? | Now the question is over the queue. Do we use one monolithic atomic queue or try to use multiple queues? |
| |
=== The Same Queue | === Monolithic Atomic Queue |
If all events are atomic they will eventually be on the same queue. Like a message queue. This has benefits. A running program would be the addition of a 'RUN' command to the atomic queue, or 'ISC' (instruction set cycle). This would pull an instruction, add it to the queue, and it would execute. IO commands on the same queue would be executed in-sequence alongside "CPU" or "PROGRAM" commands. The execution model for a 'RUN' could be that the instruction is pulled from memory, added to the queue, and then another RUN (or ISC) is added after it in sequence, causing a loop but also maintaining the control of the "main" command queue. | If all events are atomic they are effectively on the same queue so a single queue for all events would be used. Like a message queue. This has a lot of benefits, primarily keeping things simple. The big downside is that there is a massive jump table required, but this can be partially solved by using keywords for various processing modes. such as an ISC command which pulls opcodes, an ENTER command which deals with the user pressing ENTER in terminal "shell" mode, a LINE command which causes program lines to be put into the program listing array, and so forth. These could be separate functions in separate files to keep things neat and tidy. |
| |
The downside is that it will be very difficult to maintain a very large switch statement, a lot of IFs, or a jump table. Hopefully it can be refactored later. | |
| |
=== Multiple Queues | === Multiple Queues |
Say, a 'system' queue (i/o), an 'os' queue, a 'program' queue, a 'terminal' queue, a 'cpu' queue, and so forth. The problem with this is scheduling, Ideally you would have a priority queue for things like cpu register value changes. Or would you? If you enter a write command on a priority queue followed by a read on a nonpriority queue, its fine. But if you enter a priority read followed by a priority write there could be a problem. Ultimately it's a pipeline problem because we are in a single threaded environment; i.e. we cannot determine (at speed) whether or not one command is necessarily dependant on another. We do know however that if there are 100 commands on the execution stack that a value change must be inserted before the next command. This insinuates a priority queue for things that must be done in direct support of the current command. For example, setting a zero flag in the case where multiple instructions are in the queue. We cannot append it, if a subsequent command relies on the zero flag. | Multiple queues won't work. If you need a value set it will be set by the host (in JavaScript). If you need to process an opcode as a subroutine of other opcodes (ex. ADD is a construct of other opcodes and runs on the CPU and not in JavaScript) you can just replace AND by inserting the replacement code directly. If you need to set a value immediately that can be inserted before the next command, too. Multiple queues will be a distraction and are unlikely to speed up emulation in a single threaded environment. |
| |
The very idea of a program stored in memory is already a queue. We do not need to modify it while it is actually running. So we would only ever be running one command (ISC) at a time. | Note that if we are in a multi-threaded environment we can always have the game engine running in an off-UI thread. Or the implementation can launch copies in their own threads vs, manual task and context swithching which you would need to simulate in an (ex. JavaScript) implementation. But it would be transparent to the code written for the virtual machine. |
| |
The question is then what if we implement an opcode (for example) as a series of 'other' opcodes? In such a case there is a CPU state which must be modified immediately no matter what. Ultimately, the idea of a priority queue is inferior to the idea of a single threaded system supporting a monolithic atomic queue. | == It's really a CPU |
| A monolithic atomic queue is like a CPU. Since we don't have a ROM, we write high level commands in JavaScript. Slowly, we can implement opcodes and start working more with the state machine. |
| |
However, not all events are equal. A user typing 'HELP' should never reach the queue if we are in some kind of ISC or RUN mode and we did, for example, A = INPUT(). So we can turn off console mode during run and so forth. | === BASIC Interpreter |
| <Code:BASIC> |
| 10 PRINT A |
| 20 GOTO 40 |
| 30 PRINT B |
| 40 PRINT C |
| 50 PRINT D |
| LIST |
| RUN |
| A |
| C |
| D |
| |
This is a mid-way simulation, and it only points in one way. | </Code> |
| |
== It's really a CPU | Instead of refactoring into a state machine, I threw together a quick BASIC class which processed commands in a program[] array. It handles print, and goto. Since it is a quick design it can not handle infinite loops or very long programs, since there is no time for the game loop to process updates and render or for the UI to make those changes to canvas. |
Since we don't have a ROM, we write high level commands in JavaScript, which do things directly. Slowly we impleent a few opcodes, and then we can start moving towards that end of things. But for now, in fact for most purposes, we need a very high level language which goes to JavaScript and doesn't try to be what it really is, a CPU and System. This will be the value of V3, it's a terminal, which can be controlled by a Game class, and it will have a minimum viable scripting language which you could extend for your own projects -- or just turn off and manipulate the terminal directly. | |
| |
This concept of an easily controllable terminal, but which can also be expanded by adding scripting commands, is kind of like writing a shell. The terminal mode is the shell. Your actual game can be written in javascript and you can run it there, and turn off terminal mode, or you can remain in terminal mode and try to use the scripting functionality there. But we won't go too deep on it, just create the structure to use as a codebase for terminal-style games. | However, for the point of theory, yes, it is possible to write a scripting language (or a CPU simulator) and have it run in the terminal. |
| |
In most cases, the input method of v2 is going to be good enough and easy enough to work around that I can write NetWhack using it. V3 is kind of like a high-end overly expensive car. You can use it if you want but in daily life you will not use more than 10% of it's capabilities. This will make V3 likely the last standalone "JavaScript Terminal Technical Demo" that will be produced (barring a refactoring or some minor updates). Any further development will take it away from it's goal to be a terminal simulator engine for your programs. | ==== Jump Table in JavaScript |
| <Code:JavaScript> |
| // Define functions |
| function function1() { |
| console.log("Function 1"); |
| } |
| |
| function function2() { |
| console.log("Function 2"); |
| } |
| |
| function function3() { |
| console.log("Function 3"); |
| } |
| |
== Final Thoughts | // Create a jump table |
Today's computers are many millions of times faster than a C64, but even a C65 running at 3.5mhz can simulate a C64. The point is that today's processors are in many cases over a million times faster than a C64 or 8086 style processor. Even if I wrote a full system simulator with clock-accurate instruction timings (for fun?) it would run fast enough to write modern user-land programs. There's no worries here regarding speed. The main thing is, once we write that, and a ROM/BIOS, we need to write an OS, a shell, basic tools, and so on, It's too big. It would be fun but it's just an endless amount of work. | const jumpTable = { |
| "func1": function1, |
| "func2": function2, |
| "func3": function3 |
| }; |
| |
An 80486 had five pipelines and ran at 100mhz. Today's computers could easily simulate even a handful of those. We are aiming for something simple so we can escape the bounds of JavaScript in terms of blocking input and multitasking. A scripting language and mini-state machine is going to be good enough, and will likely run on anything (even an early 2020s Apple watch). | // Example of calling a function dynamically based on a string parameter |
| function callFunctionByName(name) { |
| const selectedFunction = jumpTable[name]; |
| |
| if (selectedFunction && typeof selectedFunction === 'function') { |
| selectedFunction(); |
| } else { |
| console.error("Function not found"); |
| } |
| } |
| </Code> |
| |
== Why / More Final Thoughts | This example is how to support a large number of opcodes/commands quickly. |
I don't think anything similar to this really exists. There are almost ten different C64 emulators written in JavaScript here: https://github.com/fcambus/jsemu and this list includes many more for other systems (more than twenty Nintendo emulators for example). That page also lists many CPU emulators. So while there might be nothing really like this, there are very many similar things and we know the concept is tried and true. | |
| == Refactoring Needed |
| V3 is V2 but with changes made in how terminal is controlled by the main program. The code has also become somewhat spaghetti. I partially addressed this by removing the Tile class from V3 and adding BASIC command processing into its own class. But much more work needs to be done on this. The BASIC class should maintain the copy of the program code, and the Terminal class might need to offload it's draw funcion to a Screen class. We might even move event checking into an OS or CPU class. Or both. But the OS class would eventually be replaced by programs running on the CPU (a ROM). |
| |
| In a real VM, the code would not be stored in a program[] array but in memory[] and read from there by the interpreter. There are lots of little design clashes between the idea of a VM, the idea of a Game (NetWhack) and the idea of s hybrid scripting language written in Javascript. They are not really compatable. So for a V4 the goal will be to pare down and really isolate the functionality of Terminal, and to clean up main by making a class Screen, and also perhaps a class Keyboard, class Mouse, etc. |
| |
| == Bugs |
| Well, there are still a few 'bugs' but maybe they are just quality of life fixes. I'd like the terminal to retain it's cursor y position, if possible, during a resize -- or, to clear the screen of existing text. Frankly it isn't important enough for me to worry about right now, and it probably isn't really a true bug, so I will leave it for now. |
| |
| == Final Thoughts |
| Today's computers are many millions of times faster than (ex. a C64). Even a C65 running at 3.5mhz can 'software' simulate a C64. People have written Sega Master System (Z80) system simulators on 386 quality systems. The point is that today's processors are in many cases over a million times faster than a C64 or 8086 style processor and are overkill for this. Therefore if we move in the direction of a full cpu and system simulator the main problem will not be processing speed, but writing the software. We don't have a ROM/BIOS and we don't have an OS, and those two are both rabbitholes greater than or equal to a 'JavaScript NetWhack'. It's just a little too big to deal with before we write the game. |
| |
Of particular interest will probably be ZZT.js and Parchment, as well as others -- which are game engines written in JavaScript. In it's current state, Terminal V2 (using gameState) is already good enough to serve as a platform for interactive games, since enter-on-a-line is good enough. You could also use V2 as a MUD platform. So why V3? It's something I'd like to do, something which should be done, but perhaps not worth it if I am just going to write a couple of games in JavaScript. | == The Future of JTTD |
| V4 will be whatever we do to support NetWhack, and then a pared down version will be presented as a V5 "final". It will represent a programmable terminal emulator slash game engine for character mode programs. Future goals would be for some interactive fiction games, or other interesting games, and possibly, as a platform for teaching JavaScript in a familiar environment (using the terminal as a codebase, and running everything in a Game class. Like modern Android design for example.) For now though, V3 is a sort of stop-gap kludge, and the rewrite will be coming after JavaScript Netwhack. |