├── docs ├── CNAME ├── _data │ └── authors.yml ├── images │ ├── 2gbs_1pc.jpg │ ├── gb_spi.png │ ├── bgb_link_test.png │ ├── breakout_boards.jpg │ ├── pokered_mock_trade.png │ ├── serial_bridge_link.jpg │ ├── tetris_link_nodejs.jpg │ ├── dmg_link_cable_port.jpg │ ├── dmg_link_port_pinout.png │ ├── multi_game_analysis.jpg │ ├── pokered_trade_center.png │ ├── gb_arduino_connection.jpg │ ├── pokered_trade_finished.png │ ├── pokered_role_negotiation.png │ ├── games │ │ ├── tetris │ │ │ ├── tetris_boxart.png │ │ │ ├── tetris_gameplay.png │ │ │ ├── tetris_link_round_end.png │ │ │ ├── tetris_link_main_gameplay.png │ │ │ ├── tetris_link_music_selection.png │ │ │ ├── tetris_link_role_negotiation.png │ │ │ ├── tetris_link_difficulty_selection.png │ │ │ └── tetris_link_pre_game_initialization.png │ │ ├── f1-race │ │ │ └── f1-race_gameplay.png │ │ ├── street-fighter-2 │ │ │ ├── sf2_boxart.png │ │ │ ├── sf2_gameplay.png │ │ │ ├── sf2_link_fighting.png │ │ │ ├── sf2_link_role_negotiation.png │ │ │ ├── sf2_link_stage_selection.png │ │ │ └── sf2_link_fighter_selection.png │ │ └── wave-race │ │ │ └── wave-race_gameplay.png │ ├── pokered_arduino_connection.jpg │ ├── pokered_arduino_sanity_check.jpg │ ├── pokered_link_type_selection.png │ ├── pokered_trade_confirmation.png │ └── pokered_trainer_data_exchange.png ├── Gemfile ├── index.md ├── css │ ├── header.css │ └── post.css ├── _includes │ ├── youtube.html │ ├── image.html │ ├── footer.html │ ├── head.html │ ├── header.html │ ├── social.html │ └── anchor_headings.html ├── 404.html ├── game-protocols.md ├── _config.yml ├── _layouts │ ├── protocol-doc.html │ ├── game-protocols.html │ ├── home.html │ └── post.html ├── _game-protocols │ ├── Street-Fighter-2.md │ └── Tetris.md ├── Gemfile.lock └── _posts │ └── 2021-05-10-An-8-Bit-Idea_The-Internet-of-Game-Boys.md ├── tools ├── requirements.txt ├── tcp-serial-bridge │ ├── game_protocols │ │ ├── pokemon_gen1.py │ │ └── tetris.py │ ├── tcp_serial_bridge.py │ └── gb_tcp.py ├── bgb-serial-link │ └── bgb_serial_link.py ├── README.md ├── common │ ├── serial_link_cable.py │ └── bgb_link_cable_server.py └── pokered-mock-trade │ ├── pokemon_data_structures.py │ └── trader.py ├── esp └── GBPlay │ ├── README.md │ ├── main │ ├── commands.h │ ├── http.h │ ├── tasks │ │ ├── socket_manager.h │ │ ├── status_indicator.h │ │ ├── status_indicator.c │ │ ├── network_manager.h │ │ ├── socket_manager.c │ │ └── network_manager.c │ ├── hardware │ │ ├── led.h │ │ ├── led.c │ │ ├── spi.h │ │ ├── storage.h │ │ ├── spi.c │ │ ├── storage.c │ │ ├── wifi.h │ │ └── wifi.c │ ├── CMakeLists.txt │ ├── http.c │ ├── socket.h │ ├── GBPlay.c │ ├── socket.c │ └── commands.c │ └── CMakeLists.txt ├── .gitignore ├── server ├── src │ ├── util.ts │ ├── server.ts │ ├── game-session.ts │ ├── client.ts │ └── games │ │ └── tetris.ts ├── tsconfig.json └── package.json ├── .github └── workflows │ └── build-and-test.yml ├── arduino └── gb_to_serial │ └── gb_to_serial.ino ├── README.md └── compatibility.csv /docs/CNAME: -------------------------------------------------------------------------------- 1 | blog.gbplay.io -------------------------------------------------------------------------------- /tools/requirements.txt: -------------------------------------------------------------------------------- 1 | pyserial -------------------------------------------------------------------------------- /docs/_data/authors.yml: -------------------------------------------------------------------------------- 1 | matt: 2 | name: Matt Penny 3 | github: mwpenny 4 | -------------------------------------------------------------------------------- /docs/images/2gbs_1pc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/2gbs_1pc.jpg -------------------------------------------------------------------------------- /docs/images/gb_spi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/gb_spi.png -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "github-pages", "~> 226", group: :jekyll_plugins 4 | -------------------------------------------------------------------------------- /docs/images/bgb_link_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/bgb_link_test.png -------------------------------------------------------------------------------- /docs/images/breakout_boards.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/breakout_boards.jpg -------------------------------------------------------------------------------- /docs/images/pokered_mock_trade.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/pokered_mock_trade.png -------------------------------------------------------------------------------- /docs/images/serial_bridge_link.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/serial_bridge_link.jpg -------------------------------------------------------------------------------- /docs/images/tetris_link_nodejs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/tetris_link_nodejs.jpg -------------------------------------------------------------------------------- /docs/images/dmg_link_cable_port.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/dmg_link_cable_port.jpg -------------------------------------------------------------------------------- /docs/images/dmg_link_port_pinout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/dmg_link_port_pinout.png -------------------------------------------------------------------------------- /docs/images/multi_game_analysis.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/multi_game_analysis.jpg -------------------------------------------------------------------------------- /docs/images/pokered_trade_center.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/pokered_trade_center.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Content in this file will render before the post list on the main page 3 | layout: home 4 | --- 5 | -------------------------------------------------------------------------------- /esp/GBPlay/README.md: -------------------------------------------------------------------------------- 1 | # GBPlay hardware code 2 | 3 | This code is meant to be compiled using the ESP idf SDK v5.2.x 4 | -------------------------------------------------------------------------------- /esp/GBPlay/main/commands.h: -------------------------------------------------------------------------------- 1 | #ifndef _COMMANDS_H 2 | #define _COMMANDS_H 3 | 4 | void cmds_register(); 5 | 6 | #endif 7 | -------------------------------------------------------------------------------- /docs/images/gb_arduino_connection.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/gb_arduino_connection.jpg -------------------------------------------------------------------------------- /docs/images/pokered_trade_finished.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/pokered_trade_finished.png -------------------------------------------------------------------------------- /docs/css/header.css: -------------------------------------------------------------------------------- 1 | /* Remove left space in collapsible site menu */ 2 | .site-nav .page-link { 3 | margin-left: 0; 4 | } 5 | -------------------------------------------------------------------------------- /docs/images/pokered_role_negotiation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/pokered_role_negotiation.png -------------------------------------------------------------------------------- /docs/images/games/tetris/tetris_boxart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/tetris/tetris_boxart.png -------------------------------------------------------------------------------- /docs/images/games/tetris/tetris_gameplay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/tetris/tetris_gameplay.png -------------------------------------------------------------------------------- /docs/images/pokered_arduino_connection.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/pokered_arduino_connection.jpg -------------------------------------------------------------------------------- /docs/images/pokered_arduino_sanity_check.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/pokered_arduino_sanity_check.jpg -------------------------------------------------------------------------------- /docs/images/pokered_link_type_selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/pokered_link_type_selection.png -------------------------------------------------------------------------------- /docs/images/pokered_trade_confirmation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/pokered_trade_confirmation.png -------------------------------------------------------------------------------- /docs/images/games/f1-race/f1-race_gameplay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/f1-race/f1-race_gameplay.png -------------------------------------------------------------------------------- /docs/images/pokered_trainer_data_exchange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/pokered_trainer_data_exchange.png -------------------------------------------------------------------------------- /esp/GBPlay/main/http.h: -------------------------------------------------------------------------------- 1 | #ifndef _HTTP_H 2 | #define _HTTP_H 3 | 4 | int http_get(const char* url, char* out, int out_len); 5 | 6 | #endif 7 | -------------------------------------------------------------------------------- /docs/images/games/street-fighter-2/sf2_boxart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/street-fighter-2/sf2_boxart.png -------------------------------------------------------------------------------- /esp/GBPlay/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.5) 2 | 3 | include($ENV{IDF_PATH}/tools/cmake/project.cmake) 4 | project(GBPlay) 5 | -------------------------------------------------------------------------------- /docs/images/games/street-fighter-2/sf2_gameplay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/street-fighter-2/sf2_gameplay.png -------------------------------------------------------------------------------- /docs/images/games/tetris/tetris_link_round_end.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/tetris/tetris_link_round_end.png -------------------------------------------------------------------------------- /docs/images/games/wave-race/wave-race_gameplay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/wave-race/wave-race_gameplay.png -------------------------------------------------------------------------------- /docs/images/games/tetris/tetris_link_main_gameplay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/tetris/tetris_link_main_gameplay.png -------------------------------------------------------------------------------- /docs/images/games/street-fighter-2/sf2_link_fighting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/street-fighter-2/sf2_link_fighting.png -------------------------------------------------------------------------------- /docs/images/games/tetris/tetris_link_music_selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/tetris/tetris_link_music_selection.png -------------------------------------------------------------------------------- /docs/images/games/tetris/tetris_link_role_negotiation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/tetris/tetris_link_role_negotiation.png -------------------------------------------------------------------------------- /docs/images/games/tetris/tetris_link_difficulty_selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/tetris/tetris_link_difficulty_selection.png -------------------------------------------------------------------------------- /docs/images/games/street-fighter-2/sf2_link_role_negotiation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/street-fighter-2/sf2_link_role_negotiation.png -------------------------------------------------------------------------------- /docs/images/games/street-fighter-2/sf2_link_stage_selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/street-fighter-2/sf2_link_stage_selection.png -------------------------------------------------------------------------------- /docs/images/games/tetris/tetris_link_pre_game_initialization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/tetris/tetris_link_pre_game_initialization.png -------------------------------------------------------------------------------- /docs/images/games/street-fighter-2/sf2_link_fighter_selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwpenny/gbplay/HEAD/docs/images/games/street-fighter-2/sf2_link_fighter_selection.png -------------------------------------------------------------------------------- /esp/GBPlay/main/tasks/socket_manager.h: -------------------------------------------------------------------------------- 1 | #ifndef _SOCKET_MANAGER_H 2 | #define _SOCKET_MANAGER_H 3 | 4 | /* 5 | Maintains a socket connection to the backend server. 6 | */ 7 | void task_socket_manager_start(int core, int priority); 8 | 9 | #endif 10 | -------------------------------------------------------------------------------- /esp/GBPlay/main/tasks/status_indicator.h: -------------------------------------------------------------------------------- 1 | #ifndef _STATUS_INDICATOR_H 2 | #define _STATUS_INDICATOR_H 3 | 4 | /* 5 | Updates the status LED based on device state. 6 | 7 | Solid if connected to Wi-Fi, otherwise blinking. 8 | */ 9 | void task_status_indicator_start(int core, int priority); 10 | 11 | #endif 12 | -------------------------------------------------------------------------------- /esp/GBPlay/main/hardware/led.h: -------------------------------------------------------------------------------- 1 | #ifndef _LED_H 2 | #define _LED_H 3 | 4 | /* Configures the status LED for use. */ 5 | void led_initialize(); 6 | 7 | /* 8 | Sets the state of the status LED. 9 | 10 | @param is_on Whether the LED should be on or off 11 | */ 12 | void led_set_state(bool is_on); 13 | 14 | #endif 15 | -------------------------------------------------------------------------------- /esp/GBPlay/main/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # TODO: split up into separate components 2 | idf_component_register( 3 | SRCS "GBPlay.c" "commands.c" "http.c" "socket.c" "hardware/led.c" "hardware/spi.c" "hardware/storage.c" "hardware/wifi.c" "tasks/network_manager.c" "tasks/socket_manager.c" "tasks/status_indicator.c" 4 | INCLUDE_DIRS "." 5 | ) 6 | -------------------------------------------------------------------------------- /esp/GBPlay/main/hardware/led.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "led.h" 4 | 5 | #define LED_GPIO 2 6 | 7 | void led_initialize() 8 | { 9 | gpio_reset_pin(LED_GPIO); 10 | gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT); 11 | led_set_state(false); 12 | } 13 | 14 | void led_set_state(bool is_on) 15 | { 16 | gpio_set_level(LED_GPIO, is_on); 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | __pycache__/ 3 | docs/.bundle 4 | docs/_site 5 | docs/vendor 6 | esp/GBPlay/CMakeFiles/ 7 | esp/GBPlay/config 8 | esp/GBPlay/build/ 9 | esp/GBPlay/build.bat 10 | esp/GBPlay/build.sh 11 | esp/GBPlay/bootloader 12 | esp/GBPlay/bootloader-prefix 13 | esp/GBPlay/esp-cmd.bat 14 | esp/GBPlay/esp-idf/ 15 | server/dist/ 16 | server/node_modules/ 17 | test-roms/ 18 | -------------------------------------------------------------------------------- /server/src/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a promise that resolves after the specified amount of time. 3 | * @param ms Time in milliseconds until the returned promise resolves 4 | * @returns `Promise` that will resolve after at least `ms` milliseconds 5 | */ 6 | export function sleep(ms: number): Promise { 7 | return new Promise(resolve => { 8 | setTimeout(resolve, ms); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /docs/_includes/youtube.html: -------------------------------------------------------------------------------- 1 |
2 | 9 | 10 | {% if include.caption %} 11 |
{{ include.caption | markdownify }}
12 | {% endif %} 13 |
14 | -------------------------------------------------------------------------------- /esp/GBPlay/main/hardware/spi.h: -------------------------------------------------------------------------------- 1 | #ifndef _SPI_H 2 | #define _SPI_H 3 | 4 | /* Configures the SPI interface for use. */ 5 | void spi_initialize(); 6 | 7 | /* Disables the SPI interface. */ 8 | void spi_deinitialize(); 9 | 10 | /* 11 | Exchanges a byte with the SPI slave device. 12 | 13 | @param tx The byte to send 14 | 15 | @returns The received byte, or 0xFF if there was no response 16 | */ 17 | uint8_t spi_exchange_byte(uint8_t tx); 18 | 19 | #endif 20 | -------------------------------------------------------------------------------- /docs/_includes/image.html: -------------------------------------------------------------------------------- 1 |
2 | {%- assign srcs = include.src | split: ' ' -%} 3 | {% for src in srcs %} 4 | 5 | 6 | 7 | {% endfor %} 8 | 9 | {% if include.caption %} 10 |
{{ include.caption | markdownify }}
11 | {% endif %} 12 |
13 | -------------------------------------------------------------------------------- /docs/css/post.css: -------------------------------------------------------------------------------- 1 | .anchor { 2 | vertical-align: middle; 3 | font-size: 0.7em; 4 | } 5 | 6 | .anchor:hover, 7 | h1:hover .anchor, 8 | h2:hover .anchor, 9 | h3:hover .anchor, 10 | h4:hover .anchor, 11 | h5:hover .anchor, 12 | h6:hover .anchor { 13 | display: inline; 14 | text-decoration: none; 15 | } 16 | 17 | figure.centered { 18 | text-align: center; 19 | } 20 | 21 | figure > iframe { 22 | max-width: 100%; 23 | } 24 | 25 | figure > a { 26 | display: inline-block; 27 | } 28 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- 1 | --- 2 | permalink: /404.html 3 | layout: default 4 | --- 5 | 6 | 19 | 20 |
21 |

404

22 | 23 |

Page not found :(

24 |

The requested page could not be found.

25 |
26 | -------------------------------------------------------------------------------- /docs/game-protocols.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Content in this file will render before the page list on the game protocols page 3 | layout: game-protocols 4 | title: Game Protocols 5 | permalink: /game-protocols/ 6 | --- 7 | 8 | Adding support for a game to GBPlay (and determining whether it can work at all) 9 | requires understanding its link cable protocol. This can be a time-consuming 10 | process since only a small handful have been analyzed and documented online. 11 | We saw this as an opportunity to close the gap. 12 | 13 | Listed below are those that we have reverse-engineered -- for reference, future 14 | projects, and the curious. 15 | -------------------------------------------------------------------------------- /docs/_includes/footer.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | 15 |
16 |
17 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic options */ 4 | "target": "esnext", 5 | "module": "commonjs", 6 | "declaration": false, 7 | "outDir": "dist", 8 | "rootDir": ".", 9 | "noEmitOnError": true, 10 | "incremental": true, 11 | "sourceMap": true, 12 | 13 | /* Type-checking options */ 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | 20 | /* Language options */ 21 | "experimentalDecorators": true 22 | }, 23 | "include": ["src/**/*.ts"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /docs/_includes/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {%- seo -%} 6 | 7 | 8 | 9 | 10 | 11 | {%- feed_meta -%} 12 | {%- if jekyll.environment == 'production' and site.google_analytics -%} 13 | {%- include google-analytics.html -%} 14 | {%- endif -%} 15 | 16 | -------------------------------------------------------------------------------- /tools/tcp-serial-bridge/game_protocols/pokemon_gen1.py: -------------------------------------------------------------------------------- 1 | # Games supported: Pokemon R/G/B/Y and G/S/C when using the time capsule 2 | class PokemonGen1LinkInitializer: 3 | MASTER_MAGIC = 0x01 4 | SLAVE_MAGIC = 0x02 5 | 6 | def __init__(self): 7 | self.last_byte_received = None 8 | 9 | def get_send_delay_ms(self): 10 | return 0 11 | 12 | def data_handler(self, data): 13 | self.last_byte_received = data 14 | 15 | if data == self.SLAVE_MAGIC: 16 | return None 17 | return self.MASTER_MAGIC 18 | 19 | def get_link_initializer(): 20 | return PokemonGen1LinkInitializer() 21 | 22 | def get_start_sequence(): 23 | return [] 24 | 25 | def get_default_send_delay_ms(): 26 | return 0 -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # Deployment config 2 | baseurl: "" 3 | url: "https://blog.gbplay.io" 4 | 5 | # Metadata 6 | title: GBPlay Blog 7 | tagline: Internet-enabled Game Boy multiplayer using original hardware 8 | description: >- 9 | GBPlay aims to enable Game Boy and Game Boy Color multiplayer over the 10 | internet using original hardware. It is currently in an early/exploratory 11 | stage. Stay tuned for more updates as the project progresses. 12 | 13 | collections: 14 | game-protocols: 15 | output: true 16 | 17 | relative_links: 18 | enabled: true 19 | collections: true 20 | 21 | # Social links 22 | rss: RSS Feed 23 | 24 | # Theming 25 | theme: minima 26 | plugins: 27 | - jekyll-feed 28 | - jekyll-github-metadata 29 | - jekyll-relative-links 30 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gbplay-server", 3 | "version": "0.0.1", 4 | "description": "Backend server for internet-enabled GB/GBC multiplayer", 5 | "main": "dist/server.js", 6 | "files": [ 7 | "/dist" 8 | ], 9 | "devDependencies": { 10 | "@types/node": "^16.4.2", 11 | "rimraf": "^3.0.2", 12 | "tsc-watch": "^4.4.0", 13 | "typescript": "^4.3.5" 14 | }, 15 | "scripts": { 16 | "build": "tsc", 17 | "start": "node dist/src/server.js", 18 | "watch": "tsc-watch --onSuccess \"npm run start\"", 19 | "clean": "rimraf dist" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git://github.com/mwpenny/gbplay.git" 24 | }, 25 | "license": "AGPL-3.0", 26 | "bugs": { 27 | "url": "https://github.com/mwpenny/gbplay/issues" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /docs/_layouts/protocol-doc.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 |
5 | 6 |
7 |

{{ page.title | escape }}

8 | 9 | 21 |
22 | 23 |
24 | {% 25 | include anchor_headings.html 26 | html=content 27 | anchorBody="#" 28 | anchorAttrs="hidden" 29 | anchorClass="anchor" 30 | %} 31 |
32 | 33 |
34 | -------------------------------------------------------------------------------- /docs/_layouts/game-protocols.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 |
6 | {%- if page.title -%} 7 |

{{ page.title }}

8 | {%- endif -%} 9 | 10 | {{ content }} 11 | 12 | {%- if site.game-protocols.size > 0 -%} 13 |

{{ page.list_title | default: "Documented Games" }}

14 |
    15 | {%- for page in site.game-protocols -%} 16 |
  • 17 | 18 |

    19 | 20 | {{ page.game | escape }} 21 | 22 |

    23 | {%- if site.show_excerpts -%} 24 | {{ page.excerpt }} 25 | {%- endif -%} 26 |
  • 27 | {%- endfor -%} 28 |
29 | {%- endif -%} 30 |
31 | -------------------------------------------------------------------------------- /docs/_layouts/home.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 |
6 | {%- if page.title -%} 7 |

{{ page.title }}

8 | {%- endif -%} 9 | 10 | {{ content }} 11 | 12 | {%- if site.posts.size > 0 -%} 13 |

{{ page.list_title | default: "Posts" }}

14 |
    15 | {%- for post in site.posts -%} 16 |
  • 17 | {%- assign date_format = site.minima.date_format | default: "%b %-d, %Y" -%} 18 | 19 |

    20 | 21 | {{ post.title | escape }} 22 | 23 |

    24 | {%- if site.show_excerpts -%} 25 | {{ post.excerpt }} 26 | {%- endif -%} 27 |
  • 28 | {%- endfor -%} 29 |
30 | {%- endif -%} 31 |
32 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: CI build 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | push: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build_firmware: 11 | name: Build firmware 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | 18 | - name: ESP-IDF build 19 | uses: espressif/esp-idf-ci-action@v1 20 | with: 21 | esp_idf_version: v5.2 22 | target: esp32 23 | path: esp/GBPlay 24 | 25 | build_server: 26 | name: Build server 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Setup Node.js 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: 16.x 37 | 38 | - name: Build server 39 | working-directory: ./server 40 | # TODO: run tests (after creating tests) 41 | run: | 42 | npm ci 43 | npm run build 44 | -------------------------------------------------------------------------------- /esp/GBPlay/main/tasks/status_indicator.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include 4 | #include 5 | 6 | #include "../hardware/led.h" 7 | #include "../hardware/wifi.h" 8 | 9 | #define TASK_NAME "status-indicator" 10 | 11 | static void task_status_indicator(void* data) 12 | { 13 | bool led_on = false; 14 | 15 | while (true) 16 | { 17 | // Blink if not connected to wifi, otherwise solid 18 | led_on = wifi_is_connected() || !led_on; 19 | led_set_state(led_on); 20 | 21 | sleep(1); 22 | } 23 | } 24 | 25 | void task_status_indicator_start(int core, int priority) 26 | { 27 | xTaskCreatePinnedToCore( 28 | &task_status_indicator, 29 | TASK_NAME, 30 | configMINIMAL_STACK_SIZE, // Stack size 31 | NULL, // Arguments 32 | priority, // Priority 33 | NULL, // Task handle (output parameter) 34 | core // CPU core ID 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /esp/GBPlay/main/tasks/network_manager.h: -------------------------------------------------------------------------------- 1 | #ifndef _NETWORK_MANAGER_H 2 | #define _NETWORK_MANAGER_H 3 | 4 | /* 5 | The network manager tries to ensure a Wi-Fi connection. 6 | When the device is not connected to a network, the manager will: 7 | 8 | 1. Try to reconnect to the previous network, if it was saved 9 | 2. Try to reconnect to in-range saved networks, prioritized by RSSI 10 | 11 | Whenever the manager fails to connect, the network in question will be 12 | blocked for 5 minutes. This also happens when the user initiates the 13 | disconnect, and in that case the manager will not try to reconnect to any 14 | network (since the decision to disconnect was intentional). Similarly, 15 | user-initiated connections override the decisions of the manager. 16 | 17 | When a connection is established, the list of blocked networks is 18 | cleared and the manager will not try to find another network. 19 | 20 | The task is suspended when not trying to reconnect. 21 | */ 22 | void task_network_manager_start(int core, int priority); 23 | 24 | #endif 25 | -------------------------------------------------------------------------------- /tools/bgb-serial-link/bgb_serial_link.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import argparse 3 | import os 4 | import sys 5 | 6 | # Ugliness to do relative imports without a headache 7 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) 8 | 9 | from common.bgb_link_cable_server import BGBLinkCableServer 10 | from common.serial_link_cable import SerialLinkCableClient 11 | 12 | arg_parser = argparse.ArgumentParser(description='Provides BGB <-> serial link cable communication.') 13 | arg_parser.add_argument('--bgb-port', type=int, help='port to listen on for BGB data') 14 | arg_parser.add_argument('--trace', default=False, action='store_true', help='enable communication logging') 15 | arg_parser.add_argument('serial_port', type=str, help='serial port to connect to') 16 | 17 | args = arg_parser.parse_args() 18 | 19 | kwargs = { 'port': args.bgb_port } if args.bgb_port is not None else {} 20 | bgb_server = BGBLinkCableServer(**kwargs) 21 | 22 | def data_handler(data): 23 | response = serial_link.send(data) 24 | if args.trace: 25 | print(f'{data:02X},{response:02X}') 26 | return response 27 | 28 | with SerialLinkCableClient(args.serial_port) as serial_link: 29 | bgb_server.run(data_handler) 30 | -------------------------------------------------------------------------------- /tools/README.md: -------------------------------------------------------------------------------- 1 | # GBPlay tools 2 | 3 | This directory contains scripts for development and debugging. 4 | 5 | ## Requirements 6 | 7 | * [Python 3](https://www.python.org/downloads/) 8 | * [BGB](https://bgb.bircd.org/) Game Boy emulator (for relevant tools/modes) 9 | * Run `pip install -r requirements.txt` 10 | 11 | Tools which communicate with a real Game Boy over serial require a serial to 12 | Game Boy adapter which will wait for a byte to be written by the host (PC), send 13 | it to the GB, and send the byte receieved from the GB back. 14 | 15 | An Arduino-based implementation of such an adapter can be found 16 | [in this repository](../arduino/gb_to_serial). 17 | 18 | ## Directory index 19 | 20 | | Directory | Description | 21 | | ----------- | ------------------------------------------------- | 22 | | `bgb-serial-link/` | Provides BGB <-> serial link cable communication | 23 | | `common/` | Code shared by multiple tools | 24 | | `pokered-mock-trade/` | Sends fake Pokemon trade data to a GB or emulator | 25 | | `tcp-serial-bridge/` | Links GBs and/or emulators in slave mode via TCP | 26 | 27 | All tools support the `--help` argument. 28 | -------------------------------------------------------------------------------- /esp/GBPlay/main/http.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "http.h" 5 | 6 | int http_get(const char* url, char* out, int out_len) 7 | { 8 | esp_http_client_config_t config = { 9 | .url = url 10 | }; 11 | 12 | esp_http_client_handle_t client = esp_http_client_init(&config); 13 | esp_http_client_set_method(client, HTTP_METHOD_GET); 14 | 15 | esp_err_t err = esp_http_client_open(client, 0 /* write_len */); 16 | if (err != ESP_OK) 17 | { 18 | ESP_LOGE(__func__, "Failed to open HTTP connection: %s", esp_err_to_name(err)); 19 | return -1; 20 | } 21 | 22 | int content_len = esp_http_client_fetch_headers(client); 23 | if (content_len < 0) 24 | { 25 | ESP_LOGE(__func__, "Failed to fetch HTTP headers"); 26 | return -1; 27 | } 28 | 29 | // Truncate to fit in buffer 30 | int data_read = esp_http_client_read_response(client, out, out_len); 31 | if (data_read < 0) 32 | { 33 | ESP_LOGE(__func__, "Failed to read HTTP response"); 34 | return -1; 35 | } 36 | 37 | // Success 38 | ESP_LOGI(__func__, "HTTP GET %s Status = %d, content_length = %d", 39 | url, 40 | esp_http_client_get_status_code(client), 41 | content_len 42 | ); 43 | 44 | ESP_ERROR_CHECK(esp_http_client_cleanup(client)); 45 | 46 | return data_read; 47 | } 48 | -------------------------------------------------------------------------------- /esp/GBPlay/main/socket.h: -------------------------------------------------------------------------------- 1 | #ifndef _SOCKET_H 2 | #define _SOCKET_H 3 | 4 | /* 5 | Attempts to open a socket to the specified location. 6 | 7 | @param address Address to connect to 8 | @param port Port number to connect to 9 | @param timeout_ms Number of milliseconds to wait before timing out 10 | 11 | @returns The socket file descriptor, or -1 on error. 12 | */ 13 | int socket_connect(const char* address, uint16_t port, int timeout_ms); 14 | 15 | /* 16 | Reads data from a socket. Returns once enough data has been read to 17 | completely fill the specified buffer, or an error has occurred. 18 | 19 | @param sock File descriptor of socket to read from 20 | @param out_buf [output] Buffer to store receieved data in 21 | @param buf_len Length of receive buffer, out_buf 22 | 23 | @returns Whether or not all of the data could be read from the socket. 24 | */ 25 | bool socket_read(int sock, uint8_t* out_buf, size_t buf_len); 26 | 27 | /* 28 | Writes data to a socket. Returns once all of the data in the specified 29 | buffer has been written, or an error has occurred. 30 | 31 | @param sock File descriptor of socket to write to 32 | @param buf Buffer to write to the socket 33 | @param buf_len Length of send buffer, buf 34 | 35 | @returns Whether or not all of the data could be written to the socket. 36 | */ 37 | bool socket_write(int sock, const uint8_t* buf, size_t buf_len); 38 | 39 | #endif 40 | -------------------------------------------------------------------------------- /esp/GBPlay/main/hardware/storage.h: -------------------------------------------------------------------------------- 1 | #ifndef _STORAGE_H 2 | #define _STORAGE_H 3 | 4 | /* Configures and opens non-volatile storage. */ 5 | void storage_initialize(); 6 | 7 | /* Closes non-volatile storage. */ 8 | void storage_deinitialize(); 9 | 10 | /* 11 | Retrieves a buffer from non-volatile storage. 12 | 13 | @param key Identifier of data 14 | 15 | @returns Dynamically allocated copy of buffer. Caller must free. 16 | NULL if no data was found for key. 17 | */ 18 | void* storage_get_blob(const char* key); 19 | 20 | /* 21 | Stores a buffer in non-volatile storage. 22 | 23 | @param key Identifier of data 24 | @param value Buffer to store 25 | @param length Size of buffer 26 | */ 27 | void storage_set_blob(const char* key, const void* value, size_t length); 28 | 29 | /* 30 | Retrieves a string from non-volatile storage. 31 | 32 | @param key Identifier of string 33 | 34 | @returns Dynamically allocated copy of string. Caller must free. 35 | NULL if no data was found for key. 36 | */ 37 | char* storage_get_string(const char* key); 38 | 39 | /* 40 | Stores a string in non-volatile storage. 41 | 42 | @param key Identifier of string 43 | @param value String to store 44 | */ 45 | void storage_set_string(const char* key, const char* value); 46 | 47 | /* 48 | Removes a value from non-volatile storage. 49 | 50 | @param key Identifier of the value 51 | */ 52 | void storage_delete(const char* key); 53 | 54 | #endif 55 | -------------------------------------------------------------------------------- /arduino/gb_to_serial/gb_to_serial.ino: -------------------------------------------------------------------------------- 1 | const int BAUD = 28800; // Fastest that I found to be stable 2 | const int PIN_CLK = 2; 3 | const int PIN_SO = 3; 4 | const int PIN_SI = 4; 5 | 6 | void setup() 7 | { 8 | Serial.begin(BAUD, SERIAL_8N1); 9 | pinMode(PIN_CLK, OUTPUT); 10 | pinMode(PIN_SO, OUTPUT); 11 | pinMode(PIN_SI, INPUT_PULLUP); 12 | 13 | // Signal to the PC that we are ready 14 | Serial.write(0); 15 | } 16 | 17 | byte transfer_byte(byte tx) 18 | { 19 | byte rx = 0; 20 | 21 | for (int i = 0; i < 8; ++i) 22 | { 23 | digitalWrite(PIN_SO, (tx & 0x80) ? HIGH : LOW); 24 | tx <<= 1; 25 | 26 | // http://www.devrs.com/gb/files/gblpof.gif 27 | // 120 us/bit 28 | digitalWrite(PIN_CLK, LOW); 29 | delayMicroseconds(60); 30 | 31 | byte rx_bit = (digitalRead(PIN_SI) == HIGH) ? 1 : 0; 32 | rx = (rx << 1) | rx_bit; 33 | 34 | digitalWrite(PIN_CLK, HIGH); 35 | delayMicroseconds(60); 36 | } 37 | 38 | return rx; 39 | } 40 | 41 | void loop() 42 | { 43 | if (!Serial.available()) 44 | { 45 | return; 46 | } 47 | 48 | byte tx = Serial.read(); 49 | byte rx = transfer_byte(tx); 50 | 51 | Serial.write(rx); 52 | 53 | // Give the Game Boy "enough" time to prepare the next byte 54 | // This value is purely anecdotal may need to be adjusted 55 | // 56 | // For the final hardware, it should be implemented on the 57 | // server side (latency will likely be high enough anyway) 58 | delay(5); 59 | } 60 | -------------------------------------------------------------------------------- /server/src/server.ts: -------------------------------------------------------------------------------- 1 | import { Socket, Server } from "net"; 2 | import { GameBoyClient } from "./client"; 3 | import { TetrisGameSession } from "./games/tetris"; 4 | 5 | function generateSessionId(): string { 6 | // 26^4 = 456976 possibilities 7 | const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 8 | 9 | let id = ""; 10 | for (let i = 0; i < 4; ++i) { 11 | id += alphabet[Math.floor(Math.random() * alphabet.length)]; 12 | } 13 | 14 | return id; 15 | } 16 | 17 | const SERVER_PORT = 1989; 18 | 19 | // Just Tetris, for now 20 | const sessions = new Map(); 21 | 22 | const server = new Server(async (socket: Socket) => { 23 | // Reduce latency 24 | socket.setNoDelay(true); 25 | 26 | const client = new GameBoyClient(socket); 27 | 28 | // Try to join an existing session first, then fall back to a new one 29 | let session = [...sessions.values()].find(s => s.isJoinable()); 30 | if (!session) { 31 | let id: string; 32 | do { 33 | id = generateSessionId(); 34 | } while (sessions.has(id)); 35 | 36 | const newSession = new TetrisGameSession(id); 37 | newSession.on("end", () => { 38 | console.info(`Session '${newSession.id}' ended.`); 39 | sessions.delete(newSession.id); 40 | }); 41 | 42 | sessions.set(id, newSession); 43 | newSession.run(); 44 | 45 | session = newSession; 46 | } 47 | 48 | await session.addClient(client); 49 | }); 50 | 51 | server.listen(SERVER_PORT, "0.0.0.0"); 52 | console.info(`Listening on port ${SERVER_PORT}...`); 53 | -------------------------------------------------------------------------------- /esp/GBPlay/main/hardware/spi.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "spi.h" 5 | 6 | #define GB_SPI_MODE 3 7 | #define GB_SPI_CLOCK_SPEED_HZ 8000 8 | 9 | #define GB_SPI_HOST SPI2_HOST 10 | #define GB_PIN_MOSI SPI2_IOMUX_PIN_NUM_MOSI // Pin 13 11 | #define GB_PIN_MISO SPI2_IOMUX_PIN_NUM_MISO // Pin 12 12 | #define GB_PIN_SCLK SPI2_IOMUX_PIN_NUM_CLK // Pin 14 13 | 14 | static spi_device_handle_t _spi_slave_handle = NULL; 15 | 16 | void spi_initialize() 17 | { 18 | spi_bus_config_t bus_config = { 19 | .mosi_io_num = GB_PIN_MOSI, 20 | .miso_io_num = GB_PIN_MISO, 21 | .sclk_io_num = GB_PIN_SCLK, 22 | .quadwp_io_num = -1, 23 | .quadhd_io_num = -1, 24 | .max_transfer_sz = 1, 25 | .flags = SPICOMMON_BUSFLAG_MASTER, 26 | .intr_flags = 0 27 | }; 28 | ESP_ERROR_CHECK(spi_bus_initialize(GB_SPI_HOST, &bus_config, SPI_DMA_DISABLED)); 29 | 30 | spi_device_interface_config_t dev_config = {0}; 31 | dev_config.mode = GB_SPI_MODE; 32 | dev_config.clock_speed_hz = GB_SPI_CLOCK_SPEED_HZ; 33 | dev_config.spics_io_num = -1; 34 | dev_config.queue_size = 1; 35 | ESP_ERROR_CHECK(spi_bus_add_device(GB_SPI_HOST, &dev_config, &_spi_slave_handle)); 36 | } 37 | 38 | void spi_deinitialize() 39 | { 40 | spi_bus_remove_device(_spi_slave_handle); 41 | spi_bus_free(GB_SPI_HOST); 42 | } 43 | 44 | uint8_t spi_exchange_byte(uint8_t tx) 45 | { 46 | uint8_t rx = 0xFF; 47 | 48 | spi_transaction_t txn = {0}; 49 | txn.length = 8; // In bits 50 | txn.tx_buffer = &tx; 51 | txn.rx_buffer = ℞ 52 | 53 | ESP_ERROR_CHECK(spi_device_transmit(_spi_slave_handle, &txn)); 54 | return rx; 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GBPlay: Online Game Boy Multiplayer 2 | 3 | GBPlay aims to enable Game Boy and Game Boy Color multiplayer over the internet 4 | using original hardware. This [has](http://pepijndevos.nl/TCPoke/) been done 5 | [before](https://www.youtube.com/watch?v=KtHu693wE9o), but existing projects are 6 | limited to one game, require a hard-wired connection to a PC, and are generally 7 | inaccessible to users without a technical background. 8 | 9 | Our goal is to create a solution that supports as much of the Game Boy library 10 | as possible and is plug and play, such that everything can be done entirely from 11 | the Game Boy. This will take the form of a small dongle with a link cable 12 | connector and Wi-Fi connectivity. Configuration will be done via a custom Game 13 | Boy cartridge, with a mobile-friendly web page as a backup option. 14 | 15 | Through this, we endeavor to learn more about hardware design, embedded systems, 16 | and retro devices as we find out just how far old technology can be pushed 17 | beyond what it was ever intended to do. 18 | 19 | The project is currently in an early/exploratory stage. 20 | Read more at [blog.gbplay.io](https://blog.gbplay.io). 21 | 22 | ## Repository structure 23 | 24 | | Directory | Description | 25 | | ----------- | --------------------------------------------------------- | 26 | | `arduino/` | Code for Arduino-based USB to Game Boy link cable adapter | 27 | | `captures/` | Annotated link cable communication logs for various games | 28 | | `docs/` | Contents of [blog.gbplay.io](https://blog.gbplay.io) | 29 | | `esp/` | Code for firmware of ESP32-based Game Boy Wi-Fi interface | 30 | | `server/` | Code for backend server | 31 | | `tools/` | Test scripts for development and debugging | 32 | -------------------------------------------------------------------------------- /docs/_includes/header.html: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /docs/_layouts/post.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 |
6 |
7 |

{{ page.title | escape }}

8 | 31 |
32 | 33 |
34 | {% 35 | include anchor_headings.html 36 | html=content 37 | anchorBody="#" 38 | anchorAttrs="hidden" 39 | anchorClass="anchor" 40 | %} 41 |
42 | 43 | {%- if site.disqus.shortname -%} 44 | {%- include disqus_comments.html -%} 45 | {%- endif -%} 46 | 47 | 48 |
49 | -------------------------------------------------------------------------------- /esp/GBPlay/main/GBPlay.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | #include "commands.h" 8 | #include "hardware/led.h" 9 | #include "hardware/spi.h" 10 | #include "hardware/storage.h" 11 | #include "hardware/wifi.h" 12 | 13 | #include "tasks/network_manager.h" 14 | #include "tasks/socket_manager.h" 15 | #include "tasks/status_indicator.h" 16 | 17 | #define CONFIG_CONSOLE_MAX_COMMAND_LINE_LENGTH 1024 18 | 19 | void init_console() 20 | { 21 | esp_console_repl_t* repl = NULL; 22 | esp_console_dev_uart_config_t uart_config = ESP_CONSOLE_DEV_UART_CONFIG_DEFAULT(); 23 | 24 | esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT(); 25 | repl_config.max_cmdline_length = CONFIG_CONSOLE_MAX_COMMAND_LINE_LENGTH; 26 | 27 | // Prompt to be printed before each line. 28 | // This can be customized, made dynamic, etc. 29 | repl_config.prompt = "GBLink >"; 30 | 31 | cmds_register(); 32 | 33 | ESP_ERROR_CHECK(esp_console_new_repl_uart(&uart_config, &repl_config, &repl)); 34 | ESP_ERROR_CHECK(esp_console_start_repl(repl)); 35 | } 36 | 37 | void start_tasks() 38 | { 39 | task_network_manager_start(0 /* core */, 2 /* priority */); 40 | task_status_indicator_start(0 /* core */, 1 /* priority */); 41 | 42 | task_socket_manager_start(1 /* core */, 1 /* priority */); 43 | } 44 | 45 | void app_main() 46 | { 47 | WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0); // Disable brownout detector 48 | 49 | esp_event_loop_create_default(); 50 | 51 | // Initialize hardware 52 | led_initialize(); 53 | spi_initialize(); 54 | storage_initialize(); 55 | wifi_initialize(); 56 | 57 | // Initialize REPL 58 | init_console(); 59 | 60 | // Let's-a-go 61 | start_tasks(); 62 | 63 | while (true) 64 | { 65 | sleep(1); 66 | } 67 | 68 | wifi_deinitialize(); 69 | storage_deinitialize(); 70 | spi_deinitialize(); 71 | 72 | esp_event_loop_delete_default(); 73 | } 74 | -------------------------------------------------------------------------------- /tools/common/serial_link_cable.py: -------------------------------------------------------------------------------- 1 | from io import DEFAULT_BUFFER_SIZE 2 | import serial 3 | 4 | # Enables link cable communication with a Game Boy over serial. Requires a 5 | # serial <-> Game Boy adapter which will wait for a byte to be written by the 6 | # host (PC), send it to the GB, and send the byte receieved from the GB back. 7 | # 8 | # An Arduino-based implementation of such an adapter can be found at 9 | # gbplay/arduino/gb_to_serial. 10 | 11 | DEFAULT_BAUD_RATE = 28800 12 | BASE_SERIAL_CONFIG = { 13 | 'stopbits': serial.STOPBITS_ONE, 14 | 'parity': serial.PARITY_NONE, 15 | } 16 | 17 | class SerialLinkCableServer: 18 | def __init__(self, serial_port, baudrate=DEFAULT_BAUD_RATE): 19 | self._serial_config = BASE_SERIAL_CONFIG | { 20 | 'port': serial_port, 21 | 'baudrate': baudrate 22 | } 23 | 24 | def run(self, data_handler): 25 | self._client_data_handler = data_handler 26 | 27 | with serial.Serial(**self._serial_config) as link: 28 | # Wait for boot 29 | link.read() 30 | print(f'Serial link connected on {self._serial_config["port"]}') 31 | 32 | response = None 33 | while True: 34 | to_send = self._client_data_handler(response) 35 | link.write(bytearray([to_send])) 36 | response = link.read()[0] 37 | 38 | 39 | class SerialLinkCableClient: 40 | def __init__(self, serial_port, baudrate=DEFAULT_BAUD_RATE): 41 | serial_config = BASE_SERIAL_CONFIG | { 42 | 'port': serial_port, 43 | 'baudrate': baudrate 44 | } 45 | self._link = serial.Serial(**serial_config) 46 | 47 | # Wait for boot 48 | self._link.read() 49 | print(f'Serial link connected on {serial_config["port"]}') 50 | 51 | def __enter__(self): 52 | return self 53 | 54 | def __exit__(self, _type, _value, _traceback): 55 | if self._link.isOpen(): 56 | self._link.close() 57 | 58 | def send(self, data): 59 | self._link.write(bytearray([data])) 60 | return self._link.read()[0] 61 | -------------------------------------------------------------------------------- /esp/GBPlay/main/hardware/storage.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "storage.h" 5 | 6 | #define NVS_NAMESPACE "storage" 7 | 8 | static nvs_handle s_storage_handle; 9 | 10 | void storage_initialize() 11 | { 12 | // Initialize NVS 13 | esp_err_t err = nvs_flash_init(); 14 | if (err == ESP_ERR_NVS_NO_FREE_PAGES) { 15 | // NVS partition was truncated and needs to be erased 16 | ESP_ERROR_CHECK(nvs_flash_erase()); 17 | err = nvs_flash_init(); 18 | } 19 | ESP_ERROR_CHECK(err); 20 | ESP_ERROR_CHECK(nvs_open(NVS_NAMESPACE, NVS_READWRITE, &s_storage_handle)); 21 | } 22 | 23 | void storage_deinitialize() 24 | { 25 | nvs_close(s_storage_handle); 26 | } 27 | 28 | void* storage_get_blob(const char* key) 29 | { 30 | size_t size = 0; 31 | if (nvs_get_blob(s_storage_handle, key, NULL, &size) != ESP_OK) 32 | { 33 | // Does not exist 34 | return NULL; 35 | } 36 | 37 | void* blob = malloc(size); 38 | ESP_ERROR_CHECK(nvs_get_blob(s_storage_handle, key, blob, &size)); 39 | return blob; 40 | } 41 | 42 | void storage_set_blob(const char* key, const void* value, size_t length) 43 | { 44 | ESP_ERROR_CHECK(nvs_set_blob(s_storage_handle, key, value, length)); 45 | ESP_ERROR_CHECK(nvs_commit(s_storage_handle)); 46 | 47 | ESP_LOGI(__func__, "Wrote blob '%s' to storage", key); 48 | } 49 | 50 | char* storage_get_string(const char* key) 51 | { 52 | size_t size = 0; 53 | if (nvs_get_str(s_storage_handle, key, NULL, &size) != ESP_OK) 54 | { 55 | // Does not exist 56 | return NULL; 57 | } 58 | 59 | char* str = malloc(size); 60 | ESP_ERROR_CHECK(nvs_get_str(s_storage_handle, key, str, &size)); 61 | return str; 62 | } 63 | 64 | void storage_set_string(const char* key, const char* value) 65 | { 66 | ESP_ERROR_CHECK(nvs_set_str(s_storage_handle, key, value)); 67 | ESP_ERROR_CHECK(nvs_commit(s_storage_handle)); 68 | 69 | ESP_LOGI(__func__, "Wrote string '%s' to storage", key); 70 | } 71 | 72 | void storage_delete(const char* key) 73 | { 74 | esp_err_t err = nvs_erase_key(s_storage_handle, key); 75 | if (err == ESP_ERR_NVS_NOT_FOUND) 76 | { 77 | ESP_LOGI(__func__, "Key '%s' does not exist in storage. Nothing to do.", key); 78 | } 79 | else 80 | { 81 | ESP_ERROR_CHECK(err); 82 | ESP_LOGI(__func__, "Deleted '%s' from storage", key); 83 | } 84 | 85 | ESP_ERROR_CHECK(nvs_commit(s_storage_handle)); 86 | } 87 | -------------------------------------------------------------------------------- /tools/tcp-serial-bridge/tcp_serial_bridge.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import argparse 3 | import threading 4 | from gb_tcp import GBSerialTCPServer, GBSerialTCPClient, BGBProxyTCPClient 5 | 6 | arg_parser = argparse.ArgumentParser(description='Links 2 Game Boys via a serial <-> TCP bridge.') 7 | arg_subparsers = arg_parser.add_subparsers(dest='mode', required=True, help='Operation modes') 8 | 9 | server_parser = arg_subparsers.add_parser('server', help='Run TCP server') 10 | server_parser.add_argument('--protocol', required=True, type=str, help='name of game protocol (see game_protocols folder)') 11 | server_parser.add_argument('--trace', default=False, action='store_true', help='enable communication logging') 12 | server_parser.add_argument('--host', type=str, help='host to listen on') 13 | server_parser.add_argument('--port', type=int, help='port to listen on') 14 | 15 | client_parser = arg_subparsers.add_parser('client', help='Run TCP client using a Game Boy over serial') 16 | client_parser.add_argument('--server-host', type=str, help='server host to connect to') 17 | client_parser.add_argument('--server-port', type=int, help='server port to connect to') 18 | client_parser.add_argument('serial_port', type=str, help='serial port of the Game Boy') 19 | 20 | client_parser = arg_subparsers.add_parser('bgb-proxy', help='Run proxy server which forwards BGB link cable data over TCP') 21 | client_parser.add_argument('--server-host', type=str, help='server host to connect to') 22 | client_parser.add_argument('--server-port', type=int, help='server port to connect to') 23 | client_parser.add_argument('--listen-port', type=int, default=8765, help='port for BGB proxy server to listen on') 24 | 25 | local_parser = arg_subparsers.add_parser('local', help='Run TCP server and a client for each Game Boy') 26 | local_parser.add_argument('--protocol', required=True, type=str, help='name of game protocol (see game_protocols folder)') 27 | local_parser.add_argument('--trace', default=False, action='store_true', help='enable communication logging') 28 | local_parser.add_argument('gb1_port', type=str, help='serial port of the first Game Boy') 29 | local_parser.add_argument('gb2_port', type=str, help='serial port of the second Game Boy') 30 | 31 | args = arg_parser.parse_args() 32 | 33 | if args.mode == 'server': 34 | kwargs = { 'protocol': args.protocol, 'trace': args.trace } | { 35 | k: getattr(args, k) for k in ['host', 'port'] 36 | if getattr(args, k) is not None 37 | } 38 | GBSerialTCPServer(**kwargs).run() 39 | elif args.mode == 'client': 40 | kwargs = { 'serial_port': args.serial_port } | { 41 | k: getattr(args, k) for k in ['server_host', 'server_port'] 42 | if getattr(args, k) is not None 43 | } 44 | GBSerialTCPClient(**kwargs).connect() 45 | elif args.mode == 'bgb-proxy': 46 | kwargs = { 47 | k: getattr(args, k) for k in ['server_host', 'server_port', 'listen_port'] 48 | if getattr(args, k) is not None 49 | } 50 | BGBProxyTCPClient(**kwargs).connect() 51 | elif args.mode == 'local': 52 | server = GBSerialTCPServer(args.protocol, trace=args.trace) 53 | gb1_client = GBSerialTCPClient(args.gb1_port) 54 | gb2_client = GBSerialTCPClient(args.gb2_port) 55 | 56 | threading.Thread(target=lambda: server.run()).start() 57 | threading.Thread(target=lambda: gb1_client.connect()).start() 58 | threading.Thread(target=lambda: gb2_client.connect()).start() 59 | else: 60 | raise Exception(f'Unknown mode:', args.connection_type) -------------------------------------------------------------------------------- /esp/GBPlay/main/hardware/wifi.h: -------------------------------------------------------------------------------- 1 | #ifndef _WIFI_H 2 | #define _WIFI_H 3 | 4 | #include 5 | #include 6 | 7 | // Per 802.11 spec 8 | #define WIFI_MAX_SSID_LENGTH 32 9 | #define WIFI_MAX_PASS_LENGTH 64 10 | 11 | #define WIFI_MINIMUM_RSSI -80 12 | #define WIFI_WEAK_RSSI_THRESHOLD -70 13 | #define WIFI_MED_RSSI_THRESHOLD -60 14 | #define WIFI_STRONG_RSSI_THRESHOLD -50 15 | 16 | #define WIFI_MAX_SAVED_NETWORKS 5 17 | 18 | ESP_EVENT_DECLARE_BASE(NETWORK_EVENT); 19 | 20 | typedef enum { 21 | NETWORK_EVENT_DROPPED = 1, // Network connection lost unexpectedly 22 | NETWORK_EVENT_LEFT = 2, // Network connection intentionally closed 23 | NETWORK_EVENT_CONNECTED = 4 // Network connection established 24 | } network_event; 25 | 26 | typedef struct { 27 | char ssid[WIFI_MAX_SSID_LENGTH + 1]; 28 | } network_event_connected; 29 | 30 | typedef struct { 31 | char ssid[WIFI_MAX_SSID_LENGTH + 1]; 32 | int8_t channel; 33 | int8_t rssi; 34 | bool requires_password; 35 | } wifi_ap_info; 36 | 37 | typedef struct { 38 | char ssid[WIFI_MAX_SSID_LENGTH + 1]; 39 | char pass[WIFI_MAX_PASS_LENGTH + 1]; 40 | } wifi_network_credentials; 41 | 42 | /* Enables the Wi-Fi module and configures it for use. */ 43 | void wifi_initialize(); 44 | 45 | /* Disables the Wi-Fi module. */ 46 | void wifi_deinitialize(); 47 | 48 | /* 49 | Scans for available Wi-Fi networks. 50 | 51 | @param out_ap_list [output] List of found Wi-Fi networks 52 | @param ap_count As input, maximum number of networks to return. 53 | As output, number of networks actually returned. 54 | */ 55 | void wifi_scan(wifi_ap_info* out_ap_list, uint16_t* ap_count); 56 | 57 | /* 58 | Attempts to connect to a Wi-Fi network. 59 | 60 | @param ssid SSID of network to connect to 61 | @param password Password of network to connect to 62 | @param force Whether to connect even if already connected to a network 63 | 64 | @returns Whether or not the connection succeeded 65 | */ 66 | bool wifi_connect(const char* ssid, const char* password, bool force); 67 | 68 | /* If connected, disconnects from the current Wi-Fi network */ 69 | void wifi_disconnect(); 70 | 71 | /* 72 | Checks whether connected to a network. 73 | 74 | @returns Whether the device is connected to a Wi-Fi network 75 | */ 76 | bool wifi_is_connected(); 77 | 78 | /* 79 | Retrieves the credentials of a saved Wi-Fi network from memory. 80 | 81 | @param ssid The SSID of the network to retrieve 82 | @param out_network [output] The saved network credentials, if found 83 | 84 | @returns Whether or not saved credentials were found 85 | */ 86 | bool wifi_get_saved_network(const char* ssid, wifi_network_credentials* out_network); 87 | 88 | /* 89 | Retrieves the credentials of all saved Wi-Fi networks. 90 | 91 | @param out_networks [output] List of saved credentials. Must be large enough 92 | to contain WIFI_MAX_SAVED_NETWORKS entries. 93 | 94 | @returns The number of saved credentials returned 95 | */ 96 | int wifi_get_all_saved_networks(wifi_network_credentials* out_networks); 97 | 98 | /* 99 | Saves the credentials of a Wi-Fi network. 100 | 101 | @param ssid SSID of network to save 102 | @param password Password of network to save 103 | 104 | @returns Whether the network was successfully saved. 105 | Can fail if WIFI_MAX_SAVED_NETWORKS credentials are already saved. 106 | */ 107 | bool wifi_save_network(const char* ssid, const char* password); 108 | 109 | /* 110 | Removes saved the credentials for a Wi-Fi network, if present. 111 | 112 | @param ssid SSID of network to forget 113 | */ 114 | void wifi_forget_network(const char* ssid); 115 | 116 | #endif 117 | -------------------------------------------------------------------------------- /tools/pokered-mock-trade/pokemon_data_structures.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | # Offset from 'A' (0x41). This conversion isn't perfect, but letters match 4 | # See https://bulbapedia.bulbagarden.net/wiki/Character_encoding_(Generation_I) 5 | POKE_TEXT_OFS = 0x3F 6 | POKE_TEXT_TERMINATOR = 0x50 7 | POKE_TEXT_MAX_LEN = 10 8 | POKE_LIST_TERMINATOR = 0xFF 9 | 10 | MAX_PARTY_POKEMON = 6 11 | PREAMBLE_VALUE = 0xFD 12 | 13 | def text_to_pokestr(s): 14 | if len(s) > POKE_TEXT_MAX_LEN: 15 | raise Exception(f'Pokestrs have a maximum length of {POKE_TEXT_MAX_LEN}') 16 | 17 | ps = [0] * (POKE_TEXT_MAX_LEN + 1) 18 | for i, c in enumerate(s): 19 | ps[i] = ord(c) + POKE_TEXT_OFS 20 | ps[len(s)] = POKE_TEXT_TERMINATOR 21 | 22 | return bytes(ps) 23 | 24 | class Pokemon: 25 | SERIALIZED_LEN = 44 26 | 27 | def __init__(self, id, name): 28 | self.id = id 29 | self.hp = 0 30 | self.level_pc = 0 31 | self.status = 0 32 | self.type1 = 0 33 | self.type2 = 0 34 | self.catch_rate = 0 35 | self.move1 = 0 36 | self.move2 = 0 37 | self.move3 = 0 38 | self.move4 = 0 39 | self.trainer_id = 0 40 | self.xp = 0 41 | self.hp_ev = 0 42 | self.atk_ev = 0 43 | self.def_ev = 0 44 | self.spd_ev = 0 45 | self.spc_ev = 0 46 | self.iv = 0 47 | self.move1_pp = 0 48 | self.move2_pp = 0 49 | self.move3_pp = 0 50 | self.move4_pp = 0 51 | self.level = 0 52 | self.max_hp = 0 53 | self.atk = 0 54 | self.defence = 0 55 | self.spd = 0 56 | self.spc = 0 57 | 58 | self.name = name 59 | 60 | def serialize(self): 61 | # See https://bulbapedia.bulbagarden.net/wiki/Pok%C3%A9mon_data_structure_in_Generation_I 62 | return struct.pack( 63 | ">BH9BH3B6H5B5H", 64 | self.id, self.hp, self.level_pc, self.status, self.type1, self.type2, 65 | self.catch_rate, self.move1, self.move2, self.move3, self.move4, 66 | self.trainer_id, self.xp & 0xFF, (self.xp >> 8) & 0xFF, 67 | (self.xp >> 16) & 0xFF, self.hp_ev, self.atk_ev, self.def_ev, 68 | self.spd_ev, self.spc_ev, self.iv, self.move1_pp, self.move2_pp, 69 | self.move3_pp, self.move4_pp, self.level, self.max_hp, self.atk, 70 | self.defence, self.spd, self.spc 71 | ) 72 | 73 | class Trainer: 74 | def __init__(self, name): 75 | self.party_pokemon = [] 76 | self.name = name 77 | 78 | def add_party_pokemon(self, pokemon): 79 | if len(self.party_pokemon) > MAX_PARTY_POKEMON: 80 | raise Exception('Too many party pokemon') 81 | self.party_pokemon.append(pokemon) 82 | 83 | def serialize(self): 84 | serialized = bytes() 85 | 86 | # See https://github.com/pret/pokered/blob/82f31b05c12c803d78f9b99b078198ed24cccdb1/wram.asm#L2197 87 | serialized += bytes([PREAMBLE_VALUE] * 7) 88 | serialized += text_to_pokestr(self.name) 89 | serialized += bytes([len(self.party_pokemon)]) 90 | serialized += bytes( 91 | [mon.id for mon in self.party_pokemon] + \ 92 | [POKE_LIST_TERMINATOR] * (MAX_PARTY_POKEMON - len(self.party_pokemon) + 1) 93 | ) 94 | serialized += b''.join(mon.serialize() for mon in self.party_pokemon) + \ 95 | (b'\0' * Pokemon.SERIALIZED_LEN) * (MAX_PARTY_POKEMON - len(self.party_pokemon)) 96 | serialized += b''.join(text_to_pokestr(self.name) for _ in self.party_pokemon) + \ 97 | (text_to_pokestr('') * (MAX_PARTY_POKEMON - len(self.party_pokemon))) 98 | serialized += b''.join(text_to_pokestr(mon.name) for mon in self.party_pokemon) + \ 99 | (text_to_pokestr('') * (MAX_PARTY_POKEMON - len(self.party_pokemon))) 100 | 101 | return serialized 102 | -------------------------------------------------------------------------------- /docs/_includes/social.html: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /esp/GBPlay/main/tasks/socket_manager.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #include "../hardware/spi.h" 11 | #include "../hardware/storage.h" 12 | #include "../hardware/wifi.h" 13 | #include "socket.h" 14 | 15 | #define TASK_NAME "socket-manager" 16 | 17 | #define CONNECTION_TIMEOUT_MS 10000 18 | 19 | #define SERVER_HOST_STORAGE_KEY "server_host" 20 | #define SERVER_PORT_STORAGE_KEY "server_port" 21 | 22 | #define DEFAULT_SERVER_HOST "192.168.0.115" 23 | #define DEFAULT_SERVER_PORT 1989 24 | 25 | static TaskHandle_t s_socket_manager_task; 26 | 27 | static void _on_network_connect(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) 28 | { 29 | // Wake up the task 30 | xTaskNotify(s_socket_manager_task, 0, eNoAction); 31 | } 32 | 33 | static int _connect_to_server() 34 | { 35 | bool should_free_host = true; 36 | 37 | char* server_host = storage_get_string(SERVER_HOST_STORAGE_KEY); 38 | if (server_host == NULL) 39 | { 40 | server_host = DEFAULT_SERVER_HOST; 41 | should_free_host = false; 42 | } 43 | 44 | uint16_t server_port = DEFAULT_SERVER_PORT; 45 | char* server_port_str = storage_get_string(SERVER_PORT_STORAGE_KEY); 46 | if (server_port_str != NULL) 47 | { 48 | long ret = strtol(server_port_str, NULL, 10 /* base */); 49 | if (ret == 0 || errno == ERANGE || ret > UINT16_MAX) 50 | { 51 | ESP_LOGW( 52 | TASK_NAME, 53 | "Configured server port %s is invalid. Using default port of %d.", 54 | server_port_str, 55 | server_port 56 | ); 57 | } 58 | else 59 | { 60 | server_port = ret; 61 | } 62 | 63 | free(server_port_str); 64 | } 65 | 66 | ESP_LOGI(TASK_NAME, "Connecting to backend server at %s:%d", server_host, server_port); 67 | 68 | int sock = socket_connect(server_host, server_port, CONNECTION_TIMEOUT_MS); 69 | 70 | if (should_free_host) 71 | { 72 | free(server_host); 73 | } 74 | 75 | return sock; 76 | } 77 | 78 | static void _handle_data_until_error(int sock) 79 | { 80 | // TODO: abstract this to use a generic client, rather than socket 81 | // Will making it easier to handle different types of packets 82 | while (true) 83 | { 84 | uint8_t rx = 0; 85 | if (!socket_read(sock, &rx, sizeof(rx))) 86 | { 87 | break; 88 | } 89 | 90 | uint8_t tx = spi_exchange_byte(rx); 91 | if (!socket_write(sock, &tx, sizeof(tx))) 92 | { 93 | break; 94 | } 95 | } 96 | } 97 | 98 | static void task_socket_manager(void *data) 99 | { 100 | while (true) 101 | { 102 | if (!wifi_is_connected()) 103 | { 104 | xTaskNotifyWait( 105 | 0, // ulBitsToClearOnEntry 106 | 0, // ulBitsToClearOnExit 107 | NULL, // pulNotificationValue 108 | portMAX_DELAY // xTicksToWait 109 | ); 110 | 111 | ESP_LOGI(TASK_NAME, "Network connection established. Opening socket..."); 112 | } 113 | else 114 | { 115 | ESP_LOGI(TASK_NAME, "Retrying socket connection..."); 116 | } 117 | 118 | int sock = _connect_to_server(); 119 | if (sock < 0) 120 | { 121 | ESP_LOGE(TASK_NAME, "Failed to connect to backend server"); 122 | } 123 | else 124 | { 125 | ESP_LOGI(TASK_NAME, "Successfully connected to backend server"); 126 | 127 | _handle_data_until_error(sock); 128 | 129 | ESP_LOGI(TASK_NAME, "Closing socket"); 130 | close(sock); 131 | } 132 | } 133 | } 134 | 135 | void task_socket_manager_start(int core, int priority) 136 | { 137 | ESP_ERROR_CHECK(esp_event_handler_instance_register( 138 | NETWORK_EVENT, NETWORK_EVENT_CONNECTED, &_on_network_connect, NULL, NULL 139 | )); 140 | 141 | xTaskCreatePinnedToCore( 142 | &task_socket_manager, 143 | TASK_NAME, 144 | 4096, // Stack size 145 | NULL, // Arguments 146 | priority, // Priority 147 | &s_socket_manager_task, // Task handle (output parameter) 148 | core // CPU core ID 149 | ); 150 | } 151 | -------------------------------------------------------------------------------- /tools/tcp-serial-bridge/game_protocols/tetris.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | class TetrisGameState: 4 | CONFIRMING_ROLES1 = 0x00 5 | SELECTING_MUSIC = 0x01 6 | SELECTING_DIFFICULTY = 0x02 7 | CONFIRMING_ROLES2 = 0x04 8 | SENDING_INITIAL_GARBAGE = 0x08 9 | CONFIRMING_ROLES3 = 0x10 10 | SENDING_PIECES = 0x20 11 | 12 | # Limitations: music and difficulty level are hard-coded 13 | class TetrisLinkInitializer: 14 | MASTER_MAGIC = 0x29 15 | SLAVE_MAGIC = 0x55 16 | MUSIC_A_MAGIC = 0x1C 17 | WAITING_FOR_MUSIC_MAGIC = 0x39 18 | CONFIRM_MUSIC_SELECTION_MAGIC = 0x50 19 | CONFIRM_DIFFICULTY_SELECTION_MAGIC = 0x60 20 | SOLID_TILE_FLAG = 0x80 21 | EMPTY_TILE_MAGIC = 0x2F 22 | GAME_START_MAGIC = [0x30, 0x00, 0x02, 0x02, 0x20] 23 | 24 | def __init__(self): 25 | self._state = TetrisGameState.CONFIRMING_ROLES1 26 | self._transfer_counter = 0 27 | self.last_byte_received = None 28 | 29 | # Same algorithm as the original game. 50/50 chance of an empty space 30 | # versus a filled one. Filled spaces use 1 of 8 different tiles. 31 | # See https://github.com/alexsteb/tetris_disassembly/blob/master/main.asm#L4604 32 | self._initial_garbage = [ 33 | (random.randint(0, 7) | self.SOLID_TILE_FLAG) if random.random() >= 0.5 else self.EMPTY_TILE_MAGIC 34 | for _ in range(100) 35 | ] 36 | 37 | # Same algorithm as the original game. 38 | # See https://harddrop.com/wiki/Tetris_(Game_Boy)#Randomizer and 39 | # https://github.com/alexsteb/tetris_disassembly/blob/master/main.asm#L1780 40 | self._pieces = [] 41 | prev_piece1, prev_piece2 = 0, 0 42 | for _ in range(256): 43 | next_piece = 0 44 | 45 | # Don't try forever 46 | for _ in range(3): 47 | # 7 choices of pieces, each is a multiple of 4 starting from 0 48 | next_piece = (random.randint(0, 6) * 4) 49 | 50 | # Try to avoid repeats 51 | if ((next_piece | prev_piece1 | prev_piece2) & 0xFC) != prev_piece2: 52 | break 53 | 54 | self._pieces.append(next_piece) 55 | prev_piece1, prev_piece2 = next_piece, prev_piece1 56 | 57 | def get_send_delay_ms(self): 58 | if self._state < TetrisGameState.SENDING_INITIAL_GARBAGE: 59 | return get_default_send_delay_ms() 60 | return 0 61 | 62 | def data_handler(self, data): 63 | self.last_byte_received = data 64 | 65 | if self._state == TetrisGameState.CONFIRMING_ROLES1: 66 | if data == self.SLAVE_MAGIC: 67 | self._state = TetrisGameState.SELECTING_MUSIC 68 | else: 69 | return self.MASTER_MAGIC 70 | 71 | if self._state == TetrisGameState.SELECTING_MUSIC: 72 | if data == self.WAITING_FOR_MUSIC_MAGIC: 73 | self._state = TetrisGameState.SELECTING_DIFFICULTY 74 | return self.CONFIRM_MUSIC_SELECTION_MAGIC 75 | return self.MUSIC_A_MAGIC 76 | 77 | elif self._state == TetrisGameState.SELECTING_DIFFICULTY: 78 | # Wait for opponent to send difficulty 79 | if data == 0: 80 | self._state = TetrisGameState.CONFIRMING_ROLES2 81 | return self.CONFIRM_DIFFICULTY_SELECTION_MAGIC 82 | 83 | # "Master" is not an actual player and so difficulty doesn't matter 84 | # TODO: use difficulty of other player so the UI is correct 85 | return 0 86 | 87 | elif self._state == TetrisGameState.CONFIRMING_ROLES2: 88 | if data == self.SLAVE_MAGIC: 89 | self._state = TetrisGameState.SENDING_INITIAL_GARBAGE 90 | self._transfer_counter = 1 91 | return self._initial_garbage[0] 92 | else: 93 | return self.MASTER_MAGIC 94 | 95 | elif self._state == TetrisGameState.SENDING_INITIAL_GARBAGE: 96 | to_send = self._initial_garbage[self._transfer_counter] 97 | self._transfer_counter += 1 98 | 99 | if self._transfer_counter == len(self._initial_garbage): 100 | self._state = TetrisGameState.CONFIRMING_ROLES3 101 | return to_send 102 | 103 | elif self._state == TetrisGameState.CONFIRMING_ROLES3: 104 | if data == self.SLAVE_MAGIC: 105 | self._state = TetrisGameState.SENDING_PIECES 106 | self._transfer_counter = 1 107 | return self._pieces[0] 108 | return self.MASTER_MAGIC 109 | 110 | elif self._state == TetrisGameState.SENDING_PIECES: 111 | if self._transfer_counter == len(self._pieces): 112 | # In game 113 | return None 114 | 115 | to_send = self._pieces[self._transfer_counter] 116 | self._transfer_counter += 1 117 | return to_send 118 | 119 | def get_link_initializer(): 120 | return TetrisLinkInitializer() 121 | 122 | def get_start_sequence(): 123 | return TetrisLinkInitializer.GAME_START_MAGIC 124 | 125 | def get_default_send_delay_ms(): 126 | return 30 127 | -------------------------------------------------------------------------------- /docs/_game-protocols/Street-Fighter-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: protocol-doc 3 | 4 | game: Street Fighter II 5 | serial: DMG-ASFE-USA-1 6 | year: 1995 7 | max_players: 2 8 | data_capture_name: street-fighter-2.csv 9 | 10 | title: Street Fighter II Link Cable Protocol Documentation 11 | image: /images/games/street-fighter-2/sf2_boxart.png 12 | excerpt: >- 13 | A deep dive into Street Fighter II's Game Boy link cable protocol. 14 | --- 15 | 16 | {% 17 | include image.html 18 | src="/images/games/street-fighter-2/sf2_boxart.png" 19 | caption="Not the best way to play Street Fighter, but look at that art!" 20 | %} 21 | 22 | ## Description 23 | 24 | It's not surprising that a Game Boy port of Street Fighter II exists given how 25 | how many platforms the game has been released on over the years and how many 26 | versions there are -- both licensed and 27 | [unlicensed](https://bootleggames.fandom.com/wiki/Street_Fighter_II:_The_World_Warrior). 28 | While not the best way to relive the arcade hit, it is an impressive-looking 29 | port nonetheless. 30 | 31 | The gameplay is simple: fight your way through each opponent in best-of-three 32 | match-ups to become the champion. Each round lasts until one fighter loses all 33 | of their HP or the timer runs out. 34 | 35 | ## Multiplayer gameplay 36 | 37 | The core gameplay of Street Fighter II's multiplayer mode is the same as its 38 | single-player mode. Players fight one-on-one in a best-of-three match. At the 39 | end, the characters and stage can be changed. It is possible to play this mode 40 | using either two Game Boys and a link cable, or a SNES with two controllers and 41 | a Super Game Boy. 42 | 43 | ## Link cable protocol 44 | 45 | ### Role negotiation 46 | 47 | {% 48 | include image.html 49 | src="/images/games/street-fighter-2/sf2_link_role_negotiation.png" 50 | caption="Initiating a connection" 51 | %} 52 | 53 | The first game to select "versus" from the main menu becomes the master and 54 | takes on the responsibility of initiating all subsequent data transfers. It 55 | indicates this by sending the byte `0x75`. The connected game responds with 56 | `0x54`. The required delay between transfers is ~10 ms. 57 | 58 | ### Fighter selection 59 | 60 | {% 61 | include image.html 62 | src="/images/games/street-fighter-2/sf2_link_fighter_selection.png" 63 | caption="Selecting characters. Player 2 (right) has just confirmed their selection by pressing `A` (bit 0)." 64 | %} 65 | 66 | On the fighter selection screen, each player chooses their character. When 67 | entering this screen, two synchronization transfers take place before input is 68 | allowed: `0xE9`/`0xEA` and `0xF0`/`0xFA`. After that, the joypad state is 69 | constantly exchanged between both games to keep the UIs updated -- it is as if 70 | each joypad is also connected to the linked Game Boy, similar to a multiplayer 71 | console game. Each bit represents one of the 8 buttons, and they are in the same 72 | order as they would be if read directly from the 73 | [hardware](https://gbdev.io/pandocs/Joypad_Input.html). The required delay 74 | between transfers is ~10 ms. 75 | 76 | |Bit|Button| 77 | |---|------| 78 | |0 |A | 79 | |1 |B | 80 | |2 |Select| 81 | |3 |Start | 82 | |4 |Right | 83 | |5 |Left | 84 | |6 |Up | 85 | |7 |Down | 86 | 87 | Once each player selects a fighter using either the `A` or `Start` button, both 88 | games move to the stage selection screen. There is no data sent that indicates 89 | this directly. Each of the two games is able to determine it is time because 90 | they are both synchronized. 91 | 92 | ### Stage selection 93 | 94 | {% 95 | include image.html 96 | src="/images/games/street-fighter-2/sf2_link_stage_selection.png" 97 | caption="Selecting characters. Player 1 (left) has just moved the cursor to the left (bit 5)." 98 | %} 99 | 100 | After fighter selection, the location to fight is chosen. Joypad input bytes are 101 | again repeatedly exchanged between both games allowing either player to move the 102 | cursor and confirm. As soon as one has made a selection using either `A` or 103 | `Start`, both games begin the first round of the fight. The required delay 104 | between transfers is ~10 ms. 105 | 106 | ### Fighting 107 | 108 | {% 109 | include image.html 110 | src="/images/games/street-fighter-2/sf2_link_fighting.png" 111 | caption="Two synchronization transfers take place before a round can start" 112 | %} 113 | 114 | Each round has a short introduction, after which both games will pause until 115 | two synchronization transfers take place: `0xE9`/`0xEA` and `0xF0`/`0xFA`. After 116 | this, joypad input is constantly exchanged as in the menu-related states. The 117 | required delay between transfers is ~5 ms. 118 | 119 | The link cable protocol has no mechanism to report where fighters are or which 120 | moves are used. Instead, both games feed the received joypad data to the 121 | opponent character and update the game state in the same way as when the local 122 | player presses a button. This works and keeps the two in sync because the game 123 | engine is deterministic. 124 | 125 | Both games detect the end of the round on their own and play a short animation. 126 | After this happens, the next round starts using the same logic (beginning with 127 | the two synchronization transfers). After a player has won two rounds, both 128 | return to fighter selection. 129 | 130 | ## Summary and notes 131 | 132 | Street Fighter II's protocol is not complicated at all. The game is real-time 133 | and the protocol takes advantage of that by treating the second player as a 134 | second controller. Avoiding special cases for multiplayer allows much of the 135 | single-player code to be re-used and keeps data transfers as simple as they can 136 | be. 137 | -------------------------------------------------------------------------------- /server/src/game-session.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { GameBoyClient } from "./client"; 3 | 4 | /** 5 | * Returns a decorator which registers a `GameSession` member function as the 6 | * handler for the specified state. Once registered, the handler will 7 | * automatically be called on each tick while the session is in the state. 8 | * @param state State to associate with the decorated function 9 | * @returns Decorator to register a `GameSession` member function as the 10 | * handler for `state` 11 | */ 12 | export function stateHandler(state: number) { 13 | return function (target: GameSession, _propertyKey: string, descriptor: PropertyDescriptor) { 14 | // Each GameSession subclass gets its own static state handler map 15 | if (!target.constructor.stateHandlers) { 16 | target.constructor.stateHandlers = new Map(); 17 | } 18 | target.constructor.stateHandlers.set(state, descriptor.value); 19 | }; 20 | } 21 | 22 | /** 23 | * Base class for server-side game logic. 24 | */ 25 | export abstract class GameSession { 26 | // Allows accessing static properties from a class instance. This is needed 27 | // so each subclass can have its own static state handler lookup map. 28 | declare ["constructor"]: typeof GameSession; 29 | 30 | // Initialized when the first handler is added by the decorator 31 | declare static stateHandlers: Map; 32 | 33 | /** Clients connected to the session */ 34 | protected clients: GameBoyClient[] = []; 35 | 36 | /** The current game state */ 37 | protected state: number = 0; 38 | 39 | private eventEmitter: EventEmitter = new EventEmitter({ captureRejections: true }); 40 | private ended: boolean = false; 41 | private requiredClientCount: number; 42 | 43 | constructor(public readonly id: string, requiredClientCount: number = 2) { 44 | this.requiredClientCount = requiredClientCount; 45 | 46 | this.eventEmitter.on("error", (error: Error) => { 47 | console.error(`Unhandled error in ${this.constructor.name} event handler: ${error.message}`); 48 | }); 49 | 50 | console.info(`Created new ${this.constructor.name} with ID '${this.id}'.`); 51 | } 52 | 53 | private async handleState(state: number): Promise { 54 | const handler = this.constructor.stateHandlers.get(state); 55 | if (!handler) { 56 | // TODO: actual enum value name in error message 57 | throw new Error(`${this.constructor.name} has no handler for state '${state}'.`); 58 | } 59 | return Promise.resolve(handler.apply(this)); 60 | } 61 | 62 | private end(): void { 63 | if (!this.ended) { 64 | console.info(`Ending session '${this.id}'.`); 65 | 66 | this.ended = true; 67 | this.clients.forEach(c => c.disconnect()); 68 | this.clients = []; 69 | 70 | this.eventEmitter.emit("end"); 71 | } 72 | } 73 | 74 | /** 75 | * Returns whether or not enough clients to start the game have joined. 76 | */ 77 | protected requiredClientsHaveJoined(): boolean { 78 | return this.clients.length >= this.requiredClientCount; 79 | } 80 | 81 | /** 82 | * Exchanges a byte between session clients, as if the two devices were 83 | * physically connected. 84 | * @param onTransfer Optional callback to intercept the transferred values 85 | */ 86 | protected forwardClientBytes(onTransfer?: (b1: number, b2: number) => void): Promise { 87 | if (this.clients.length !== this.requiredClientCount) { 88 | throw new Error( 89 | `Cannot forward bytes with ${this.clients.length} clients. ` + 90 | `Expected ${this.requiredClientCount}.` 91 | ); 92 | } 93 | return this.clients[0].forwardByte(this.clients[1], onTransfer); 94 | } 95 | 96 | /** 97 | * Adds a listener for the specified event. 98 | * @param event Name of event 99 | * @param listener Event listener 100 | */ 101 | on(event: "end", listener: (client: GameBoyClient) => void): void { 102 | this.eventEmitter.on(event, listener); 103 | } 104 | 105 | /** 106 | * Adds a client to the game. 107 | * @param client The client to add 108 | */ 109 | async addClient(client: GameBoyClient): Promise { 110 | console.info(`Client '${client.id}' joined session '${this.id}'.`); 111 | 112 | client.on("disconnect", async () => { 113 | console.info(`Client '${client.id}' left session '${this.id}'.`); 114 | 115 | // Game Boy games can't handle players re-joining. Bail. 116 | this.end(); 117 | }); 118 | 119 | this.clients.push(client); 120 | } 121 | 122 | /** 123 | * Returns whether or not clients are allowed to join the session. 124 | */ 125 | isJoinable(): boolean { 126 | return !this.ended && !this.requiredClientsHaveJoined(); 127 | } 128 | 129 | /** 130 | * Performs the same action for each client and waits until it has 131 | * completed for all of them. 132 | * @param func Callback to run for each client. The `GameBoyClient` 133 | * instance is passed as an argument. 134 | * @returns Array of results from each invocation of `func` 135 | */ 136 | forAllClients(func: (client: GameBoyClient) => Promise): Promise { 137 | return Promise.all(this.clients.map(c => func(c))); 138 | } 139 | 140 | /** 141 | * Runs the state machine for the game session. 142 | */ 143 | run(): void { 144 | this.handleState(this.state).then(() => { 145 | setImmediate(() => this.run()); 146 | }).catch((error: Error) => { 147 | // Socket errors will occur naturally when the game ends 148 | if (!this.ended) { 149 | // TODO: actual enum value name in error message 150 | console.error(`Error handling state '${this.state}' in session '${this.id}': ${error.message}`); 151 | this.end(); 152 | } 153 | }); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /server/src/client.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { Socket } from "net"; 3 | import { sleep } from "./util"; 4 | 5 | /** 6 | * Represents a Game Boy connected via a networked link cable. 7 | */ 8 | export class GameBoyClient { 9 | public readonly id: string; 10 | 11 | private static readonly dataTimeoutMs = 10000; 12 | 13 | private lastReceivedByte: number = 0; 14 | private lastSendTime: number = Date.now(); 15 | private eventEmitter: EventEmitter = new EventEmitter({ captureRejections: true }); 16 | 17 | constructor(private readonly socket: Socket, private sendDelayMs: number = 5) { 18 | this.id = `${socket.remoteAddress}:${socket.remotePort}`; 19 | this.onConnect(); 20 | } 21 | 22 | private onConnect(): void { 23 | console.info(`Client '${this.id}' connected.`); 24 | 25 | this.socket.on("error", (err: Error) => { 26 | console.error(`Error on client '${this.id}' socket: ${err.message}`); 27 | }); 28 | 29 | this.socket.on("close", async () => { 30 | console.info(`Client '${this.id}' socket closed.`); 31 | this.eventEmitter.emit("disconnect"); 32 | }); 33 | 34 | this.eventEmitter.on("error", (error: Error) => { 35 | console.error(`Unhandled error in client event handler: ${error.message}`); 36 | }); 37 | } 38 | 39 | private waitSendDelay(): Promise { 40 | // Account for connection latency in delay time 41 | const sendDelta = Date.now() - this.lastSendTime; 42 | const msToSleep = this.sendDelayMs - sendDelta; 43 | 44 | if (msToSleep > 0) { 45 | return sleep(msToSleep); 46 | } 47 | return Promise.resolve(); 48 | } 49 | 50 | /** 51 | * Adds a listener for the specified event. 52 | * @param event Name of event 53 | * @param listener Event listener 54 | */ 55 | on(event: "disconnect", listener: () => void): void { 56 | this.eventEmitter.on(event, listener); 57 | } 58 | 59 | /** 60 | * Sets the amount of time to wait before sending each byte. 61 | * @param sendDelayMs Amount of time in milliseconds to wait before sending 62 | */ 63 | setSendDelayMs(sendDelayMs: number): void { 64 | this.sendDelayMs = sendDelayMs; 65 | } 66 | 67 | /** 68 | * Sends a byte to the Game Boy and returns the byte the Game Boy sent. 69 | * @param tx The value to send (only the least significant byte will be used) 70 | * @returns The byte received from the connected Game Boy 71 | */ 72 | async exchangeByte(tx: number): Promise { 73 | await this.waitSendDelay(); 74 | 75 | return new Promise((resolve, reject) => { 76 | let sentByte = false; 77 | 78 | // Don't wait forever 79 | const timeout = setTimeout(() => { 80 | console.warn(`Client '${this.id}' did not respond within ${GameBoyClient.dataTimeoutMs} ms. Disconnecting.`) 81 | this.disconnect(); 82 | }, GameBoyClient.dataTimeoutMs); 83 | 84 | const cleanup = () => { 85 | clearTimeout(timeout); 86 | this.socket.removeListener("close", closeListener); 87 | this.socket.removeListener("data", dataListener); 88 | }; 89 | 90 | const closeListener = () => { 91 | cleanup(); 92 | reject(new Error(`Client '${this.id}' disconnected before responding.`)); 93 | }; 94 | 95 | const dataListener = (data: Buffer) => { 96 | if (sentByte) { 97 | cleanup(); 98 | this.lastReceivedByte = data.readUInt8(0); 99 | resolve(this.lastReceivedByte); 100 | } else { 101 | console.warn(`Client '${this.id}' sent data before receiving any. Discarding.`); 102 | } 103 | }; 104 | 105 | this.socket.once("close", closeListener); 106 | this.socket.on("data", dataListener); 107 | 108 | this.socket.write(new Uint8Array([ tx & 0xFF ]), (err?: Error) => { 109 | if (err) { 110 | cleanup(); 111 | reject(err); 112 | } else { 113 | sentByte = true; 114 | this.lastSendTime = Date.now(); 115 | } 116 | }); 117 | }); 118 | } 119 | 120 | /** 121 | * Exchanges the most recently received byte from this Game Boy with the 122 | * specified Game Boy, `other`, as if the two devices were physically 123 | * connected. 124 | * @param other The second Game Boy to communicate with 125 | * @param onTransfer Optional callback to intercept the transferred values 126 | */ 127 | async forwardByte(other: GameBoyClient, onTransfer?: (b1: number, b2: number) => void): Promise { 128 | await other.exchangeByte(this.lastReceivedByte); 129 | 130 | // TODO: easy byte injection (e.g., Pokemon random seed) 131 | if (onTransfer) { 132 | onTransfer(this.lastReceivedByte, other.lastReceivedByte); 133 | } 134 | 135 | await this.exchangeByte(other.lastReceivedByte); 136 | } 137 | 138 | /** 139 | * Repeatedly polls the Game Boy until it responds with the specified value. 140 | * @param pollValue The value to send in order to poll the Game Boy 141 | * @param waitValue The value to wait for from the Game Boy 142 | */ 143 | async waitForByte(pollValue: number, waitValue: number): Promise { 144 | while ((await this.exchangeByte(pollValue)) !== waitValue); 145 | } 146 | 147 | /** 148 | * Sends the contents of a buffer to the Game Boy. 149 | * @param buf The values to send to the Game Boy (only the least 150 | * significant byte of each value will be sent) 151 | * @returns The last value received from the Game Boy 152 | */ 153 | async sendBuffer(buf: number[]): Promise { 154 | let rx = 0; 155 | for (const b of buf) { 156 | rx = await this.exchangeByte(b); 157 | } 158 | return rx; 159 | } 160 | 161 | /** 162 | * Closes the connection to the client. 163 | */ 164 | disconnect(): void { 165 | this.socket.destroy(); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /tools/pokered-mock-trade/trader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import argparse 3 | import os 4 | import sys 5 | 6 | # Ugliness to do relative imports without a headache 7 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) 8 | 9 | from common.bgb_link_cable_server import BGBLinkCableServer 10 | from common.serial_link_cable import SerialLinkCableServer 11 | from pokemon_data_structures import Trainer, Pokemon 12 | 13 | class TradeState: 14 | NOT_CONNECTED = 0x00 15 | WAITING_FOR_LINK_TYPE = 0x01 16 | SELECTED_TRADE = 0x02 17 | WAITING_FOR_RANDOM_SEED = 0x04 18 | SENDING_RANDOM_SEED = 0x08 19 | SENDING_TRAINER_DATA = 0x10 20 | WAITING_FOR_TRADE = 0x20 21 | TRADE_INITIATED = 0x40 22 | TRADE_CONFIRMATION = 0x80 23 | TRADE_CANCELLED = 0x100 24 | 25 | class PokeTrader: 26 | # Control bytes 27 | MASTER_MAGIC = 0x01 28 | SLAVE_MAGIC = 0x02 29 | CONNECTED_MAGIC = 0x60 30 | SELECT_TRADE_MAGIC = 0xD4 31 | SELECT_BATTLE_MAGIC = 0xD5 32 | SELECT_CANCEL_MAGIC = 0xD6 33 | TERMINATOR_MAGIC = 0xFD 34 | TRADE_MENU_CLOSED_MAGIC = 0x6F 35 | FIRST_POKEMON_MAGIC = 0x60 36 | LAST_POKEMON_MAGIC = 0x65 37 | TRADE_CANCELLED_MAGIC = 0x61 38 | TRADE_CONFIRMED_MAGIC = 0x62 39 | 40 | def __init__(self, server, trainer_data, is_master=False): 41 | self._trade_state = TradeState.NOT_CONNECTED 42 | self._transfer_counter = 0 43 | self._server = server 44 | self._serialized_trainer_data = trainer_data.serialize() 45 | self._is_master = is_master 46 | 47 | def run(self): 48 | self._server.run(self.on_client_data) 49 | 50 | # See http://www.adanscotney.com/2014/01/spoofing-pokemon-trades-with-stellaris.html 51 | def on_client_data(self, data): 52 | to_send = data or 0 53 | 54 | if self._trade_state == TradeState.NOT_CONNECTED: 55 | if data == self.CONNECTED_MAGIC: 56 | self._trade_state = TradeState.WAITING_FOR_LINK_TYPE 57 | to_send = self.CONNECTED_MAGIC 58 | print('Pokemon link initiated') 59 | elif self._is_master: 60 | to_send = self.MASTER_MAGIC 61 | elif data == self.MASTER_MAGIC: 62 | to_send = self.SLAVE_MAGIC 63 | 64 | elif self._trade_state == TradeState.WAITING_FOR_LINK_TYPE: 65 | if data == self.CONNECTED_MAGIC: 66 | to_send = self.CONNECTED_MAGIC 67 | elif data == self.SELECT_TRADE_MAGIC: 68 | self._trade_state = TradeState.SELECTED_TRADE 69 | print('Selected trade center') 70 | elif data == self.SELECT_BATTLE_MAGIC: 71 | raise Exception('Battles are not supported') 72 | elif data == self.SELECT_CANCEL_MAGIC or data == self.MASTER_MAGIC: 73 | raise Exception('Link cancelled by client') 74 | 75 | elif self._trade_state == TradeState.SELECTED_TRADE: 76 | if data == self.TERMINATOR_MAGIC: 77 | self._trade_state = TradeState.WAITING_FOR_RANDOM_SEED 78 | print('Waiting for random seed') 79 | 80 | elif self._trade_state == TradeState.WAITING_FOR_RANDOM_SEED: 81 | if data != self.TERMINATOR_MAGIC: 82 | self._trade_state = TradeState.SENDING_RANDOM_SEED 83 | print('Sending random seed') 84 | 85 | elif self._trade_state == TradeState.SENDING_RANDOM_SEED: 86 | if data == self.TERMINATOR_MAGIC: 87 | self._trade_state = TradeState.SENDING_TRAINER_DATA 88 | self._transfer_counter = 0 89 | print('Sending trainer data') 90 | 91 | elif self._trade_state == TradeState.SENDING_TRAINER_DATA: 92 | if self._transfer_counter < len(self._serialized_trainer_data): 93 | to_send = self._serialized_trainer_data[self._transfer_counter] 94 | self._transfer_counter += 1 95 | else: 96 | self._trade_state = TradeState.WAITING_FOR_TRADE 97 | self._transfer_counter = 0 98 | print('Waiting for trade') 99 | 100 | elif self._trade_state == TradeState.WAITING_FOR_TRADE: 101 | if data == self.TRADE_MENU_CLOSED_MAGIC: 102 | self._trade_state = TradeState.SELECTED_TRADE 103 | print('Trade menu closed') 104 | elif self.FIRST_POKEMON_MAGIC <= data <= self.LAST_POKEMON_MAGIC: 105 | self._trade_state = TradeState.TRADE_INITIATED 106 | print('Trade initiated') 107 | 108 | elif self._trade_state == TradeState.TRADE_INITIATED: 109 | if data != 0: 110 | # Always trade the first 111 | to_send = self.FIRST_POKEMON_MAGIC 112 | else: 113 | self._trade_state = TradeState.TRADE_CONFIRMATION 114 | print('Waiting for trade confirmation') 115 | 116 | elif self._trade_state == TradeState.TRADE_CONFIRMATION: 117 | if data == self.TRADE_CANCELLED_MAGIC: 118 | self._trade_state = TradeState.TRADE_CANCELLED 119 | print('Trade cancelled') 120 | elif data == self.TRADE_CONFIRMED_MAGIC: 121 | self._trade_state = TradeState.SELECTED_TRADE 122 | print('Trade confirmed') 123 | 124 | elif self._trade_state == TradeState.TRADE_CANCELLED: 125 | if data == 0: 126 | self._trade_state = TradeState.WAITING_FOR_TRADE 127 | 128 | return to_send 129 | 130 | 131 | arg_parser = argparse.ArgumentParser(description='Mocks a Pokemon trade.') 132 | arg_subparsers = arg_parser.add_subparsers(dest='connection_type', required=True, help='Types of connections') 133 | 134 | bgb_parser = arg_subparsers.add_parser('bgb', help='Connect to BGB emulator') 135 | bgb_parser.add_argument('--port', type=int, help='port to listen on for BGB data') 136 | 137 | serial_parser = arg_subparsers.add_parser('serial', help='Connect to Game Boy over serial') 138 | serial_parser.add_argument('port', type=str, help='serial port to connect to') 139 | 140 | args = arg_parser.parse_args() 141 | is_master = False 142 | 143 | if args.connection_type == 'bgb': 144 | kwargs = { 'port': args.port } if args.port is not None else {} 145 | server = BGBLinkCableServer(**kwargs) 146 | elif args.connection_type == 'serial': 147 | server = SerialLinkCableServer(args.port) 148 | is_master = True 149 | else: 150 | raise Exception(f'Unknown connection type:', args.connection_type) 151 | 152 | trainer_data = Trainer('Matt') 153 | trainer_data.add_party_pokemon(Pokemon(0x15, 'MEW')) 154 | PokeTrader(server, trainer_data, is_master).run() 155 | -------------------------------------------------------------------------------- /tools/common/bgb_link_cable_server.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import struct 3 | import threading 4 | 5 | # Implements the BGB link cable protocol 6 | # See https://bgb.bircd.org/bgblink.html 7 | class BGBLinkCableServer: 8 | PACKET_FORMAT = '<4BI' 9 | PACKET_SIZE_BYTES = 8 10 | 11 | def __init__(self, verbose=False, host='', port=8765): 12 | self._handlers = { 13 | 1: self._handle_version, 14 | 101: self._handle_joypad_update, 15 | 104: self._handle_sync1, 16 | 105: self._handle_sync2, 17 | 106: self._handle_sync3, 18 | 108: self._handle_status, 19 | 109: self._handle_want_disconnect 20 | } 21 | self._last_received_timestamp = 0 22 | self._is_running = False 23 | self._connection_lock = threading.Lock() 24 | 25 | self.verbose = verbose 26 | self.host = host 27 | self.port = port 28 | 29 | def run(self, master_data_handler=None, slave_data_handler=None): 30 | self._master_data_handler = master_data_handler 31 | self._slave_data_handler = slave_data_handler 32 | 33 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: 34 | # Reduce latency 35 | server.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 36 | 37 | server.bind((self.host, self.port)) 38 | server.listen(0) # One Game Boy to rule them all 39 | print(f'Listening on {self.host}:{self.port}...') 40 | 41 | connection, client_addr = server.accept() 42 | print(f'Received connection from {client_addr[0]}:{client_addr[1]}') 43 | 44 | with self._connection_lock: 45 | self._connection = connection 46 | self._is_running = True 47 | 48 | with connection: 49 | try: 50 | # Initial handshake - send protocol version number 51 | self._send_packet( 52 | 1, # Version packet 53 | 1, # Major 54 | 4, # Minor 55 | 0 # Patch 56 | ) 57 | 58 | while self.is_running(): 59 | data = connection.recv(self.PACKET_SIZE_BYTES) 60 | if not data: 61 | print('Connection dropped') 62 | break 63 | 64 | b1, b2, b3, b4, timestamp = struct.unpack(self.PACKET_FORMAT, data) 65 | 66 | # Cheat, and say we are exactly in sync with the client 67 | if timestamp > self._last_received_timestamp: 68 | self._last_received_timestamp = timestamp 69 | 70 | handler = self._handlers[b1] 71 | handler(b2, b3, b4) 72 | except Exception as e: 73 | print('Socket error:', str(e)) 74 | 75 | def is_running(self): 76 | with self._connection_lock: 77 | return self._is_running 78 | 79 | def stop(self): 80 | with self._connection_lock: 81 | self._is_running = False 82 | 83 | def send_master_byte(self, data): 84 | self._send_packet( 85 | 104, # Master data packet 86 | data, # Data value 87 | 0x81 # Control value 88 | ) 89 | 90 | def _handle_version(self, major, minor, patch): 91 | if self.verbose: 92 | print(f'Received version packet: {major}.{minor}.{patch}') 93 | 94 | if (major, minor, patch) != (1, 4, 0): 95 | raise Exception(f'Unsupported protocol version {major}.{minor}.{patch}') 96 | 97 | self._send_status_packet() 98 | 99 | def _handle_joypad_update(self, _b2, _b3, _b4): 100 | # Do nothing. This is intended to control an emulator remotely. 101 | pass 102 | 103 | def _handle_sync1(self, data, _control, _b4): 104 | # Data received from master 105 | handler = self._master_data_handler 106 | 107 | if handler: 108 | response = handler(data) 109 | if response is not None: 110 | self._send_packet( 111 | 105, # Slave data packet 112 | response, # Data value 113 | 0x80 # Control value 114 | ) 115 | else: 116 | # Indicates no response from the GB 117 | self._send_packet( 118 | 106, # Sync3 packet 119 | 1 120 | ) 121 | 122 | def _handle_sync2(self, data, _control, _b4): 123 | # Data received from slave 124 | handler = self._slave_data_handler 125 | 126 | if handler: 127 | response = handler(data) 128 | if response: 129 | self.send_master_byte(response) 130 | 131 | def _handle_sync3(self, b2, b3, b4): 132 | if self.verbose: 133 | print('Received sync3 packet') 134 | 135 | # Ack/echo 136 | self._send_packet( 137 | 106, # Sync3 packet 138 | b2, 139 | b3, 140 | b4 141 | ) 142 | 143 | def _handle_status(self, b2, _b3, _b4): 144 | # TODO: stop logic when client is paused 145 | if self.verbose: 146 | print('Received status packet:') 147 | print('\tRunning:', (b2 & 1) == 1) 148 | print('\tPaused:', (b2 & 2) == 2) 149 | print('\tSupports reconnect:', (b2 & 4) == 4) 150 | 151 | # The docs say not to respond to status with status, but not doing this 152 | # causes link instability. An alternative is to send sync3 packets 153 | # periodically, but this way is easier. 154 | self._send_status_packet() 155 | 156 | def _handle_want_disconnect(self, _b2, _b3, _b4): 157 | print('Client has initiated disconnect') 158 | 159 | def _send_status_packet(self): 160 | self._send_packet( 161 | 108, # Status packet 162 | 1 # State=running 163 | ) 164 | 165 | def _send_packet(self, type, b2=0, b3=0, b4=0, i1=None): 166 | if i1 is None: 167 | i1 = self._last_received_timestamp 168 | 169 | with self._connection_lock: 170 | try: 171 | if not self._is_running: 172 | raise Exception('Server is not running') 173 | 174 | self._connection.send(struct.pack( 175 | self.PACKET_FORMAT, 176 | type, b2, b3, b4, i1 177 | )) 178 | except Exception as e: 179 | print('Socket error:', str(e)) 180 | self._running = False 181 | -------------------------------------------------------------------------------- /tools/tcp-serial-bridge/gb_tcp.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | import socket 4 | import sys 5 | import threading 6 | import time 7 | 8 | # Ugliness to do relative imports without a headache 9 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) 10 | 11 | from common.serial_link_cable import SerialLinkCableClient 12 | from common.bgb_link_cable_server import BGBLinkCableServer 13 | 14 | DEFAULT_SERVER_PORT = 1989 15 | 16 | # Accepts 2 client connections - one for each Game Boy. Once both have connected, 17 | # the server will put both into slave mode (by sending game-specific data) to 18 | # enable high-latency communication and then act as a bridge between them. 19 | class GBSerialTCPServer: 20 | def __init__(self, protocol, host='0.0.0.0', port=DEFAULT_SERVER_PORT, trace=False): 21 | self._protocol = importlib.import_module(f'game_protocols.{protocol}') 22 | self._host = host 23 | self._port = port 24 | self._trace = trace 25 | 26 | def run(self): 27 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: 28 | # Reduce latency 29 | server.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 30 | 31 | server.bind((self._host, self._port)) 32 | server.listen(1) # Two Game Boys 33 | print(f'Listening on {self._host}:{self._port}...') 34 | print(f'Protocol: {os.path.splitext(os.path.basename(self._protocol.__file__))[0]}') 35 | 36 | client1, client1_addr = server.accept() 37 | print(f'Received connection 1 from {client1_addr[0]}:{client1_addr[1]}') 38 | 39 | try: 40 | with client1: 41 | client2, client2_addr = server.accept() 42 | print(f'Received connection 2 from {client2_addr[0]}:{client2_addr[1]}') 43 | 44 | with client2: 45 | gb1_byte = self._enter_slave_mode(client1) 46 | print('Game Boy 1 entered slave mode') 47 | 48 | self._enter_slave_mode(client2) 49 | print('Game Boy 2 entered slave mode') 50 | 51 | # Trigger game start, if needed 52 | start_sequence = self._protocol.get_start_sequence() 53 | if start_sequence: 54 | for b in start_sequence: 55 | gb1_byte = self._exchange_byte(client1, b) 56 | self._exchange_byte(client2, b) 57 | 58 | # Start the ping-ponging a la Newton's cradle 59 | while True: 60 | gb2_byte = self._exchange_byte(client2, gb1_byte) 61 | 62 | if self._trace: 63 | print(f'{gb1_byte:02X},{gb2_byte:02X}') 64 | 65 | gb1_byte = self._exchange_byte(client1, gb2_byte) 66 | except Exception as e: 67 | print('Socket error:', str(e)) 68 | 69 | def _exchange_byte(self, client, byte, delay_ms=None): 70 | if delay_ms is None: 71 | delay_ms = self._protocol.get_default_send_delay_ms() 72 | 73 | client.sendall(bytearray([byte])) 74 | result = client.recv(1)[0] 75 | 76 | # Different games need different amounts of time to prepare the next byte 77 | time.sleep(delay_ms / 1000) 78 | 79 | return result 80 | 81 | def _enter_slave_mode(self, client): 82 | link_initializer = self._protocol.get_link_initializer() 83 | 84 | # Initiate link cable connection such that game will use external clock 85 | response = None 86 | while True: 87 | to_send = link_initializer.data_handler(response) 88 | if to_send is None: 89 | # Initialized 90 | return link_initializer.last_byte_received 91 | 92 | response = self._exchange_byte(client, to_send, link_initializer.get_send_delay_ms()) 93 | 94 | if self._trace: 95 | print(f'{to_send:02X},{response:02X}') 96 | 97 | 98 | # Connects to a running GBSerialTCPServer and forwards received data to the 99 | # serial-connected Game Boy. The Game Boy's response is sent back over TCP. 100 | class GBSerialTCPClient: 101 | def __init__(self, serial_port, server_host='localhost', server_port=DEFAULT_SERVER_PORT): 102 | self._serial_port = serial_port 103 | self._server_host = server_host 104 | self._server_port = server_port 105 | 106 | def connect(self): 107 | with SerialLinkCableClient(self._serial_port) as gb_link: 108 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as tcp_link: 109 | # Reduce latency 110 | tcp_link.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 111 | 112 | tcp_link.connect((self._server_host, self._server_port)) 113 | print(f'Connected to {self._server_host}:{self._server_port}...') 114 | 115 | while True: 116 | rx = tcp_link.recv(1) 117 | if not rx: 118 | print('Connection closed') 119 | return 120 | 121 | gb_byte = gb_link.send(rx[0]) 122 | tcp_link.sendall(bytearray([gb_byte])) 123 | 124 | 125 | # Forwards link cable data between BGB and a GBSerialTCPServer 126 | class BGBProxyTCPClient: 127 | def __init__(self, server_host='localhost', server_port=DEFAULT_SERVER_PORT, listen_port=8765): 128 | self._server_host = server_host 129 | self._server_port = server_port 130 | self._listen_port = listen_port 131 | self._connected = False 132 | 133 | def connect(self): 134 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as tcp_link: 135 | # Reduce latency 136 | tcp_link.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 137 | 138 | server = BGBLinkCableServer(port=self._listen_port) 139 | 140 | def wait_for_master_data(): 141 | while True: 142 | rx = tcp_link.recv(1) 143 | if not rx: 144 | print('Connection closed by server') 145 | server.stop() 146 | return 147 | 148 | # Give the emulated Game Boy "enough" time to prepare the next 149 | # byte. This value is purely anecdotal may need to be adjusted 150 | time.sleep(0.005) 151 | server.send_master_byte(rx[0]) 152 | 153 | def on_slave_data(data): 154 | if not self._connected: 155 | # Connect lazily on the first transfer so that we won't 156 | # connect to the TCP server before the BGB server has 157 | # received a connection 158 | tcp_link.connect((self._server_host, self._server_port)) 159 | print(f'Connected to {self._server_host}:{self._server_port}...') 160 | threading.Thread(target=wait_for_master_data).start() 161 | self._connected = True 162 | 163 | tcp_link.sendall(bytearray([data])) 164 | 165 | server.run(slave_data_handler=on_slave_data) 166 | -------------------------------------------------------------------------------- /esp/GBPlay/main/socket.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | #include 7 | 8 | #include "socket.h" 9 | 10 | static bool _set_socket_is_blocking(int sock, bool is_blocking) 11 | { 12 | int flags = fcntl(sock, F_GETFL); 13 | if (flags < 0) 14 | { 15 | ESP_LOGE(__func__, "Unable to get socket flags: errno %d", errno); 16 | return false; 17 | } 18 | 19 | int new_flags = is_blocking ? (flags & ~O_NONBLOCK) : (flags | O_NONBLOCK); 20 | if (fcntl(sock, F_SETFL, new_flags) < 0) 21 | { 22 | ESP_LOGE(__func__, "Unable to set socket flags: errno %d", errno); 23 | return false; 24 | } 25 | 26 | return true; 27 | } 28 | 29 | static int _get_socket_error(int sock) 30 | { 31 | int sock_error = 0; 32 | socklen_t sock_error_len = sizeof(sock_error); 33 | 34 | if (getsockopt(sock, SOL_SOCKET, SO_ERROR, &sock_error, &sock_error_len) < 0) 35 | { 36 | ESP_LOGE(__func__, "Failed to get socket error code: errno %d", errno); 37 | sock_error = errno; 38 | } 39 | 40 | return sock_error; 41 | } 42 | 43 | static bool _wait_for_socket_connect(int sock, int timeout_ms) 44 | { 45 | int64_t cutoff_time = esp_timer_get_time() + (timeout_ms * 1000); 46 | bool success = true; 47 | 48 | while (success) 49 | { 50 | int ms_to_wait = (cutoff_time - esp_timer_get_time()) / 1000; 51 | if (ms_to_wait <= 0) 52 | { 53 | errno = ETIMEDOUT; 54 | success = false; 55 | break; 56 | } 57 | 58 | struct pollfd fds[] = {{ 59 | .fd = sock, 60 | .events = POLLOUT // Writable 61 | }}; 62 | int rc = poll(fds, 1, timeout_ms); 63 | 64 | if (rc == 0) 65 | { 66 | // poll() timed out 67 | errno = ETIMEDOUT; 68 | success = false; 69 | } 70 | else if (rc < 0 && errno != EINTR) 71 | { 72 | // poll() failed 73 | ESP_LOGE( 74 | __func__, 75 | "Failed waiting for socket to connect: errno %d", 76 | errno 77 | ); 78 | success = false; 79 | } 80 | else if (rc > 0) 81 | { 82 | // poll() signaled socket as writable. Check for success. 83 | int sock_error = _get_socket_error(sock); 84 | if (sock_error == 0) 85 | { 86 | // Connected 87 | break; 88 | } 89 | 90 | ESP_LOGE(__func__, "Socket unable to connect: errno %d", sock_error); 91 | success = false; 92 | } 93 | } 94 | 95 | if (errno == ETIMEDOUT) 96 | { 97 | ESP_LOGE( 98 | __func__, 99 | "Timed out waiting for socket connection after %d ms", 100 | timeout_ms 101 | ); 102 | } 103 | 104 | return success; 105 | } 106 | 107 | static bool _connect_socket(int sock, struct addrinfo* address, int timeout_ms) 108 | { 109 | // Temporarily switch to non-blocking so we can control the timeout 110 | if (!_set_socket_is_blocking(sock, false)) 111 | { 112 | ESP_LOGE(__func__, "Unable to set socket to non-blocking"); 113 | return false; 114 | } 115 | 116 | bool success = true; 117 | if (connect(sock, address->ai_addr, address->ai_addrlen) < 0) 118 | { 119 | if (errno != EINPROGRESS) 120 | { 121 | ESP_LOGE(__func__, "Socket unable to connect: errno %d", errno); 122 | success = false; 123 | } 124 | else 125 | { 126 | success = _wait_for_socket_connect(sock, timeout_ms); 127 | } 128 | } 129 | 130 | if (!_set_socket_is_blocking(sock, true)) 131 | { 132 | ESP_LOGE(__func__, "Unable to set socket to blocking"); 133 | success = false; 134 | } 135 | 136 | return success; 137 | } 138 | 139 | static int _create_socket(struct addrinfo* address, int timeout_ms) 140 | { 141 | assert(address->ai_protocol == IPPROTO_TCP); 142 | 143 | int sock = socket(address->ai_family, address->ai_socktype, address->ai_protocol); 144 | if (sock < 0) 145 | { 146 | ESP_LOGE(__func__, "Unable to create socket: errno %d", errno); 147 | return -1; 148 | } 149 | 150 | // Reduce latency 151 | int nodelay_value = 1; 152 | if (setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &nodelay_value, sizeof(nodelay_value)) != 0) 153 | { 154 | ESP_LOGE(__func__, "Unable to set socket options: errno %d", errno); 155 | close(sock); 156 | return -1; 157 | } 158 | 159 | if (!_connect_socket(sock, address, timeout_ms)) 160 | { 161 | close(sock); 162 | return -1; 163 | } 164 | 165 | return sock; 166 | } 167 | 168 | int socket_connect(const char* address, uint16_t port, int timeout_ms) 169 | { 170 | struct addrinfo hints = {0}; 171 | hints.ai_family = AF_INET; 172 | hints.ai_socktype = SOCK_STREAM; 173 | hints.ai_protocol = IPPROTO_TCP; 174 | 175 | char service[NI_MAXSERV] = {0}; 176 | snprintf(service, sizeof(service), "%d", port); 177 | service[NI_MAXSERV - 1] = '\0'; 178 | 179 | struct addrinfo* address_info = NULL; 180 | if (getaddrinfo(address, service, &hints, &address_info) != 0) 181 | { 182 | ESP_LOGE(__func__, "Could not get address info for %s:%d", address, port); 183 | return -1; 184 | } 185 | else 186 | { 187 | int sock = -1; 188 | for (struct addrinfo* a = address_info; a != NULL; a = a->ai_next) 189 | { 190 | sock = _create_socket(a, timeout_ms); 191 | if (sock >= 0) 192 | { 193 | break; 194 | } 195 | } 196 | 197 | freeaddrinfo(address_info); 198 | return sock; 199 | } 200 | } 201 | 202 | // TODO: detect unclean disconnect (read timeout) 203 | bool socket_read(int sock, uint8_t* out_buf, size_t buf_len) 204 | { 205 | ssize_t bytes_read = 0; 206 | 207 | while (bytes_read < buf_len) 208 | { 209 | ssize_t ret = recv(sock, out_buf + bytes_read, buf_len - bytes_read, 0); 210 | if (ret == 0) 211 | { 212 | ESP_LOGE(__func__, "Socket closed when reading: errno %d", errno); 213 | return false; 214 | } 215 | else if (ret == -1 && errno != EINTR) 216 | { 217 | ESP_LOGE(__func__, "Error reading socket data: errno %d", errno); 218 | return false; 219 | } 220 | else if (ret > 0) 221 | { 222 | bytes_read += ret; 223 | } 224 | } 225 | 226 | return true; 227 | } 228 | 229 | // TODO: detect unclean disconnect (write timeout) 230 | bool socket_write(int sock, const uint8_t* buf, size_t buf_len) 231 | { 232 | ssize_t bytes_written = 0; 233 | 234 | while (bytes_written < buf_len) 235 | { 236 | ssize_t ret = send(sock, buf + bytes_written, buf_len - bytes_written, 0); 237 | if (ret == 0) 238 | { 239 | ESP_LOGE(__func__, "Socket closed when writing: errno %d", errno); 240 | return false; 241 | } 242 | else if (ret == -1 && errno != EINTR) 243 | { 244 | ESP_LOGE(__func__, "Error writing socket data: errno %d", errno); 245 | return false; 246 | } 247 | else if (ret > 0) 248 | { 249 | bytes_written += ret; 250 | } 251 | } 252 | 253 | return true; 254 | } 255 | -------------------------------------------------------------------------------- /esp/GBPlay/main/tasks/network_manager.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | #include 10 | #include 11 | 12 | #include "../hardware/wifi.h" 13 | 14 | #define TASK_NAME "network-manager" 15 | 16 | #define IDLE_SCAN_PERIOD_SECONDS 30 17 | #define SCAN_LIST_SIZE 10 18 | #define NETWORK_HISTORY_SIZE WIFI_MAX_SAVED_NETWORKS 19 | #define MINUTE_MICROSECONDS (60 * 1000 * 1000) 20 | #define NETWORK_BLOCK_MINUTES 5 21 | 22 | typedef struct { 23 | char ssid[WIFI_MAX_SSID_LENGTH + 1]; 24 | int64_t blocked_until; // For blacklisting networks on disconnect 25 | } network_info; 26 | 27 | // Ring buffer of metadata for previously-used networks 28 | typedef struct { 29 | network_info used_networks[NETWORK_HISTORY_SIZE]; 30 | char prev_ssid[WIFI_MAX_SSID_LENGTH + 1]; 31 | int next_index; 32 | } network_history; 33 | 34 | static EventGroupHandle_t s_connection_event_group; 35 | static network_history s_network_history = {0}; 36 | 37 | static void _on_disconnect(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) 38 | { 39 | xEventGroupSetBits(s_connection_event_group, NETWORK_EVENT_DROPPED); 40 | } 41 | 42 | static void _on_leave(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) 43 | { 44 | xEventGroupSetBits(s_connection_event_group, NETWORK_EVENT_LEFT); 45 | } 46 | 47 | static void _on_connect(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) 48 | { 49 | network_event_connected* event = (network_event_connected*)event_data; 50 | 51 | strncpy(s_network_history.prev_ssid, (char*)event->ssid, sizeof(s_network_history.prev_ssid)); 52 | s_network_history.prev_ssid[sizeof(s_network_history.prev_ssid) - 1] = '\0'; 53 | 54 | xEventGroupSetBits(s_connection_event_group, NETWORK_EVENT_CONNECTED); 55 | } 56 | 57 | static network_info* _ensure_network_info(const char* ssid) 58 | { 59 | for (int i = 0; i < NETWORK_HISTORY_SIZE; ++i) 60 | { 61 | network_info* entry = &s_network_history.used_networks[i]; 62 | if (strcmp(entry->ssid, ssid) == 0) 63 | { 64 | return entry; 65 | } 66 | } 67 | 68 | // Insert into ring buffer, overwriting oldest if necessary 69 | int index = s_network_history.next_index; 70 | s_network_history.next_index = \ 71 | (s_network_history.next_index + 1) % NETWORK_HISTORY_SIZE; 72 | 73 | network_info* entry = &s_network_history.used_networks[index]; 74 | memset(entry, 0, sizeof(*entry)); 75 | strncpy(entry->ssid, ssid, WIFI_MAX_SSID_LENGTH); 76 | entry->ssid[WIFI_MAX_SSID_LENGTH] = '\0'; 77 | return entry; 78 | } 79 | 80 | static void _clear_network_history() 81 | { 82 | memset(&s_network_history.used_networks, 0, sizeof(s_network_history.used_networks)); 83 | s_network_history.next_index = 0; 84 | } 85 | 86 | static void _block_network(network_info* info) 87 | { 88 | ESP_LOGI( 89 | TASK_NAME, 90 | "Blocking network '%s' for %d minutes", 91 | info->ssid, 92 | NETWORK_BLOCK_MINUTES 93 | ); 94 | info->blocked_until = esp_timer_get_time() + (NETWORK_BLOCK_MINUTES * MINUTE_MICROSECONDS); 95 | } 96 | 97 | static bool _try_connect(wifi_network_credentials* creds) 98 | { 99 | network_info* info = _ensure_network_info(creds->ssid); 100 | if (info->blocked_until > esp_timer_get_time()) 101 | { 102 | ESP_LOGD(TASK_NAME, "Skipped network '%s' due to temporary block", creds->ssid); 103 | return false; 104 | } 105 | 106 | ESP_LOGI(TASK_NAME, "Trying network '%s'...", creds->ssid); 107 | if (wifi_connect(creds->ssid, creds->pass, false /* force */)) 108 | { 109 | ESP_LOGI(TASK_NAME, "Successfully connected to network '%s'", creds->ssid); 110 | return true; 111 | } 112 | 113 | ESP_LOGI(TASK_NAME, "Failed to connect to network '%s'", creds->ssid); 114 | _block_network(info); 115 | return false; 116 | } 117 | 118 | static bool _try_connect_prev() 119 | { 120 | wifi_network_credentials creds = {0}; 121 | if (!wifi_get_saved_network(s_network_history.prev_ssid, &creds)) 122 | { 123 | // Can't connect if not saved 124 | return false; 125 | } 126 | 127 | return _try_connect(&creds); 128 | } 129 | 130 | static bool _try_autoconnect() 131 | { 132 | ESP_LOGI(TASK_NAME, "Trying to auto-connect to a network..."); 133 | 134 | // Results will be sorted by RSSI in descending order 135 | wifi_ap_info available_aps[SCAN_LIST_SIZE] = {0}; 136 | uint16_t ap_count = SCAN_LIST_SIZE; 137 | wifi_scan(available_aps, &ap_count); 138 | 139 | ESP_LOGI(TASK_NAME, "Found %d networks", ap_count); 140 | 141 | for (int i = 0; i < ap_count; ++i) 142 | { 143 | wifi_ap_info* ap = &available_aps[i]; 144 | 145 | wifi_network_credentials creds = {0}; 146 | if (wifi_get_saved_network(ap->ssid, &creds) && _try_connect(&creds)) 147 | { 148 | return true; 149 | } 150 | } 151 | 152 | return false; 153 | } 154 | 155 | static void task_network_manager(void* data) 156 | { 157 | // Future enhancements, probably overkill: 158 | // * Detect and avoid networks with rapidly changing RSSIs 159 | // * Prioritize based on wifi technology (n > g > b) 160 | // * Detect internet connection (resolve DNS; dns_gethostbyname) 161 | // Likely good enough to just check this on configuration and let our application layer handle it 162 | // Technically gbplay will still work with no internet connection if hosted locally 163 | // * Prefer networks that previously had internet access over ones that didn't 164 | // New member of network_info 165 | // * Exponential backoff (for blacklisting and time between scans) 166 | 167 | while (true) 168 | { 169 | EventBits_t bits = xEventGroupWaitBits( 170 | s_connection_event_group, 171 | 0xFF, // Bits to wait for (any bits) 172 | pdTRUE, // xClearOnExit 173 | pdFALSE, // xWaitForAllBits 174 | portMAX_DELAY 175 | ); 176 | 177 | if (bits & NETWORK_EVENT_DROPPED) 178 | { 179 | // Could have been a false alarm (disconnect + reconnect by us) 180 | if (!wifi_is_connected()) 181 | { 182 | ESP_LOGI(TASK_NAME, "Connection dropped"); 183 | 184 | // Try to reconnect 185 | while (!wifi_is_connected() && !_try_connect_prev() && !_try_autoconnect()) 186 | { 187 | sleep(IDLE_SCAN_PERIOD_SECONDS); 188 | } 189 | } 190 | } 191 | else if (bits & NETWORK_EVENT_LEFT) 192 | { 193 | ESP_LOGI(TASK_NAME, "Left network voluntarily. Not attempting to reconnect."); 194 | 195 | // Deprioritize network the user chose to leave 196 | network_info* info = _ensure_network_info(s_network_history.prev_ssid); 197 | _block_network(info); 198 | } 199 | else if (bits & NETWORK_EVENT_CONNECTED) 200 | { 201 | ESP_LOGI(TASK_NAME, "Connected to network '%s'", s_network_history.prev_ssid); 202 | 203 | _clear_network_history(); 204 | } 205 | } 206 | } 207 | 208 | void task_network_manager_start(int core, int priority) 209 | { 210 | s_connection_event_group = xEventGroupCreate(); 211 | 212 | ESP_ERROR_CHECK(esp_event_handler_instance_register( 213 | NETWORK_EVENT, NETWORK_EVENT_DROPPED, &_on_disconnect, NULL, NULL 214 | )); 215 | ESP_ERROR_CHECK(esp_event_handler_instance_register( 216 | NETWORK_EVENT, NETWORK_EVENT_LEFT, &_on_leave, NULL, NULL 217 | )); 218 | ESP_ERROR_CHECK(esp_event_handler_instance_register( 219 | NETWORK_EVENT, NETWORK_EVENT_CONNECTED, &_on_connect, NULL, NULL 220 | )); 221 | 222 | xTaskCreatePinnedToCore( 223 | &task_network_manager, 224 | TASK_NAME, 225 | 4096, // Stack size 226 | NULL, // Arguments 227 | priority, // Priority 228 | NULL, // Task handle (output parameter) 229 | core // CPU core ID 230 | ); 231 | 232 | // Wake the task up and start looking for networks 233 | xEventGroupSetBits(s_connection_event_group, NETWORK_EVENT_DROPPED); 234 | } 235 | -------------------------------------------------------------------------------- /docs/_includes/anchor_headings.html: -------------------------------------------------------------------------------- 1 | {% capture headingsWorkspace %} 2 | {% comment %} 3 | Copyright (c) 2018 Vladimir "allejo" Jimenez 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the "Software"), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | {% endcomment %} 26 | {% comment %} 27 | Version 1.0.11 28 | https://github.com/allejo/jekyll-anchor-headings 29 | 30 | "Be the pull request you wish to see in the world." ~Ben Balter 31 | 32 | Usage: 33 | {% include anchor_headings.html html=content anchorBody="#" %} 34 | 35 | Parameters: 36 | * html (string) - the HTML of compiled markdown generated by kramdown in Jekyll 37 | 38 | Optional Parameters: 39 | * beforeHeading (bool) : false - Set to true if the anchor should be placed _before_ the heading's content 40 | * headerAttrs (string) : '' - Any custom HTML attributes that will be added to the heading tag; you may NOT use `id`; 41 | the `%heading%` and `%html_id%` placeholders are available 42 | * anchorAttrs (string) : '' - Any custom HTML attributes that will be added to the `` tag; you may NOT use `href`, `class` or `title`; 43 | the `%heading%` and `%html_id%` placeholders are available 44 | * anchorBody (string) : '' - The content that will be placed inside the anchor; the `%heading%` placeholder is available 45 | * anchorClass (string) : '' - The class(es) that will be used for each anchor. Separate multiple classes with a space 46 | * anchorTitle (string) : '' - The `title` attribute that will be used for anchors 47 | * h_min (int) : 1 - The minimum header level to build an anchor for; any header lower than this value will be ignored 48 | * h_max (int) : 6 - The maximum header level to build an anchor for; any header greater than this value will be ignored 49 | * bodyPrefix (string) : '' - Anything that should be inserted inside of the heading tag _before_ its anchor and content 50 | * bodySuffix (string) : '' - Anything that should be inserted inside of the heading tag _after_ its anchor and content 51 | * generateId (true) : false - Set to true if a header without id should generate an id to use. 52 | 53 | Output: 54 | The original HTML with the addition of anchors inside of all of the h1-h6 headings. 55 | {% endcomment %} 56 | 57 | {% assign minHeader = include.h_min | default: 1 %} 58 | {% assign maxHeader = include.h_max | default: 6 %} 59 | {% assign beforeHeading = include.beforeHeading %} 60 | {% assign headerAttrs = include.headerAttrs %} 61 | {% assign nodes = include.html | split: ' 76 | {% if headerLevel == 0 %} 77 | 78 | {% assign firstChunk = node | split: '>' | first %} 79 | 80 | 81 | {% unless firstChunk contains '<' %} 82 | {% capture node %}{% endcapture %} 90 | {% assign _workspace = node | split: _closingTag %} 91 | {% capture _hAttrToStrip %}{{ _workspace[0] | split: '>' | first }}>{% endcapture %} 92 | {% assign header = _workspace[0] | replace: _hAttrToStrip, '' %} 93 | {% assign escaped_header = header | strip_html | strip %} 94 | 95 | {% assign _classWorkspace = _workspace[0] | split: 'class="' %} 96 | {% assign _classWorkspace = _classWorkspace[1] | split: '"' %} 97 | {% assign _html_class = _classWorkspace[0] %} 98 | 99 | {% if _html_class contains "no_anchor" %} 100 | {% assign skip_anchor = true %} 101 | {% else %} 102 | {% assign skip_anchor = false %} 103 | {% endif %} 104 | 105 | {% assign _idWorkspace = _workspace[0] | split: 'id="' %} 106 | {% if _idWorkspace[1] %} 107 | {% assign _idWorkspace = _idWorkspace[1] | split: '"' %} 108 | {% assign html_id = _idWorkspace[0] %} 109 | {% elsif include.generateId %} 110 | 111 | {% assign html_id = escaped_header | slugify %} 112 | {% if html_id == "" %} 113 | {% assign html_id = false %} 114 | {% endif %} 115 | {% capture headerAttrs %}{{ headerAttrs }} id="%html_id%"{% endcapture %} 116 | {% endif %} 117 | 118 | 119 | {% capture anchor %}{% endcapture %} 120 | 121 | {% if skip_anchor == false and html_id and headerLevel >= minHeader and headerLevel <= maxHeader %} 122 | {% if headerAttrs %} 123 | {% capture _hAttrToStrip %}{{ _hAttrToStrip | split: '>' | first }} {{ headerAttrs | replace: '%heading%', escaped_header | replace: '%html_id%', html_id }}>{% endcapture %} 124 | {% endif %} 125 | 126 | {% capture anchor %}href="#{{ html_id }}"{% endcapture %} 127 | 128 | {% if include.anchorClass %} 129 | {% capture anchor %}{{ anchor }} class="{{ include.anchorClass }}"{% endcapture %} 130 | {% endif %} 131 | 132 | {% if include.anchorTitle %} 133 | {% capture anchor %}{{ anchor }} title="{{ include.anchorTitle | replace: '%heading%', escaped_header }}"{% endcapture %} 134 | {% endif %} 135 | 136 | {% if include.anchorAttrs %} 137 | {% capture anchor %}{{ anchor }} {{ include.anchorAttrs | replace: '%heading%', escaped_header | replace: '%html_id%', html_id }}{% endcapture %} 138 | {% endif %} 139 | 140 | {% capture anchor %}{{ include.anchorBody | replace: '%heading%', escaped_header | default: '' }}{% endcapture %} 141 | 142 | 143 | {% if beforeHeading %} 144 | {% capture anchor %}{{ anchor }} {% endcapture %} 145 | {% else %} 146 | {% capture anchor %} {{ anchor }}{% endcapture %} 147 | {% endif %} 148 | {% endif %} 149 | 150 | {% capture new_heading %} 151 | 160 | {% endcapture %} 161 | 162 | 165 | {% assign chunkCount = _workspace | size %} 166 | {% if chunkCount > 1 %} 167 | {% capture new_heading %}{{ new_heading }}{{ _workspace | last }}{% endcapture %} 168 | {% endif %} 169 | 170 | {% capture edited_headings %}{{ edited_headings }}{{ new_heading }}{% endcapture %} 171 | {% endfor %} 172 | {% endcapture %}{% assign headingsWorkspace = '' %}{{ edited_headings | strip }} 173 | -------------------------------------------------------------------------------- /compatibility.csv: -------------------------------------------------------------------------------- 1 | Title,ID,System,Link cable players,Compatibility rating,Compatibility notes 2 | 4-in-1 Fun Pak,DMG-F4-USA,DMG,2,Unsupported, 3 | 4-in-1 FunPak Volume II,DMG-F9-USA,DMG,2,Unsupported, 4 | All Star Tennis 2000,DMG-AZTP-EUR,DMG/CGB,2,Unsupported, 5 | Asteroids,DMG-AN-USA,DMG,2,Unsupported, 6 | Asteroids,DMG-AARE-USA,DMG/CGB,2,Unsupported, 7 | Atomic Punk,DMG-HB-USA,DMG,2,Unsupported, 8 | Azure Dreams,DMG-AAZE-USA,DMG/CGB,2,Unsupported, 9 | Balloon Kid,DMG-BT-USA,DMG,2,Unsupported, 10 | Baseball,DMG-BS-USA,DMG,2,Unsupported, 11 | Bases Loaded,DMG-BK-USA,DMG,2,Unsupported, 12 | Battle Arena Toshinden,DMG-ATDE-USA,DMG,2,Unsupported, 13 | Battle Bull,DMG-BR-USA,DMG,2,Unsupported, 14 | Battleship,DMG-NB-USA,DMG,2,Unsupported, 15 | Bill Elliot's NASCAR Fast Tracks,DMG-EL-USA,DMG,2,Unsupported, 16 | Bionic Battler,DMG-VS-USA,DMG,2,Unsupported, 17 | Blades of Steel,DMG-UB-USA,DMG,2,Unsupported, 18 | Bo Jackson: Two Games in One,DMG-BJ-USA,DMG,2,Unsupported, 19 | Bomberman Quest,DMG-AVQE-USA,DMG/CGB,2,Unsupported, 20 | Boomer's Adventures in ASMIK World,DMG-AS-USA,DMG,2,Unsupported, 21 | BreakThru!,DMG-ABXE-USA,DMG,2,Unsupported, 22 | Burai Fighter Deluxe,DMG-BU-USA,DMG,2,Unsupported, 23 | Burgertime Deluxe,DMG-GM-USA,DMG,2,Unsupported, 24 | Bust-a-Move Millennium,CGB-BANE-USA,CGB,2,Unsupported, 25 | Centipede,DMG-AC4E-USA,DMG,2,Unsupported, 26 | Championship Pool,DMG-H4-USA,DMG,2,Unsupported, 27 | CosmoTank,DMG-CT-USA,DMG,2,Unsupported, 28 | Cyraid,DMG-WR-USA,DMG,2,Unsupported, 29 | Dead Heat Scramble,DMG-DH-USA,DMG,2,Unsupported, 30 | Dexterity,DMG-FU-USA,DMG,2,Unsupported, 31 | Donkey Kong Country,CGB-BDDE-USA,CGB,2,Unsupported, 32 | Double Dragon,DMG-DD-USA,DMG,2,Unsupported, 33 | Double Dragon II,DMG-D2-USA,DMG,2,Unsupported, 34 | Double Dragon 3: The Arcade Game,DMG-DX-USA,DMG,2,Unsupported, 35 | Double Dribble: 5 on 5,DMG-DW-USA,DMG,2,Unsupported, 36 | Dr. Mario,DMG-VU-USA,DMG,2,Unsupported, 37 | Dragon Ball Z: Legendary Super Warriors,CGB-BBZE-USA,CGB,2,Unsupported, 38 | Dragon Warrior III,CGB-BD3E-USA,CGB,2,Unsupported, 39 | Dragon Warrior Monsters,DMG-AWQE-USA,DMG/CGB,2,Unsupported, 40 | Dragon Warrior Monsters 2: Cobi's Journey,DMG-BQLE-USA,DMG/CGB,2,Unsupported, 41 | Dragon Warrior Monsters 2: Tara's Adventure,DMG-BQIE-USA,DMG/CGB,2,Unsupported, 42 | Extra Bases,DMG-FS-USA,DMG,2,Unsupported, 43 | F-1 Race,DMG-F1-USA,DMG,4,Unsupported, 44 | F-1 World Grand Prix,CGB-AFIP-USA,CGB,2,Unsupported, 45 | F1 Pole Position,DMG-F5-USA,DMG,4,Unsupported, 46 | Faceball 2000,DMG-FA-USA,DMG,4,Unsupported, 47 | Fastest Lap,DMG-F2-USA,DMG,2,Unsupported, 48 | Fighting Simulator 2 in 1: Flying Warriors,DMG-HR-USA,DMG,2,Unsupported, 49 | Fist of the North Star,DMG-HK-USA,DMG,2,Unsupported, 50 | Flipull,DMG-FP-USA,DMG,2,Unsupported, 51 | Fortified Zone,DMG-IY-USA,DMG,2,Unsupported, 52 | Game & Watch Gallery,DMG-AGAE-USA,DMG,2,Unsupported, 53 | Game & Watch Gallery 2,DMG-AGLE-USA,DMG/CGB,2,Unsupported, 54 | Game & Watch Gallery 3,DMG-AGQE-USA,DMG/CGB,2,Unsupported, 55 | Gauntlet II,DMG-G2-USA,DMG,4,Unsupported, 56 | Go! Go! Tank,DMG-GT-USA,DMG,2,Unsupported, 57 | Goal!,DMG-JV-USA,DMG,2,Unsupported, 58 | Golf,DMG-GO-USA,DMG,2,Unsupported, 59 | HAL Wrestling,DMG-PR-USA,DMG,2,Unsupported, 60 | Harry Potter and the Chamber of Secrets,CGB-BH6E-USA,CGB,2,Unsupported, 61 | Harvest Moon,DMG-AYWE-USA,DMG,2,Unsupported, 62 | Hatris,DMG-HT-USA,DMG,2,Unsupported, 63 | Heavyweight Championship Boxing,DMG-BX-USA,DMG,2,Unsupported, 64 | Heiankyo Alien,DMG-HA-USA,DMG,2,Unsupported, 65 | High Stakes Gambling,DMG-HY-USA,DMG,2,Unsupported, 66 | Hit the Ice,DMG-HC-USA,DMG,2,Unsupported, 67 | The Hunt For Red October,DMG-HF-USA,DMG,2,Unsupported, 68 | Hyper Lode Runner,DMG-HL-USA,DMG,2,Unsupported, 69 | In Your Face,DMG-YF-USA,DMG,2,Unsupported, 70 | Ishido: The Way of the Stones,DMG-ST-USA,DMG,2,Unsupported, 71 | Jeep Jamboree Off-Road Adventure,DMG-JJ-USA,DMG,2,Unsupported, 72 | Jeopardy!,DMG-JP-USA,DMG,2,Unsupported, 73 | Jeopardy! Sports Edition,DMG-JE-USA,DMG,2,Unsupported, 74 | Jimmy Connors Tennis,DMG-JC-USA,DMG,2,Unsupported, 75 | Ken Griffey Jr. Presents: Major League Baseball,DMG-AKGE-USA,DMG,2,Unsupported, 76 | Killer Instinct,DMG-AKLE-USA,DMG,2,Unsupported, 77 | The King of Fighters '95,DMG-AKFE-USA,DMG,2,Unsupported, 78 | Kingdom Crusade,DMG-KC-USA,DMG,2,Unsupported, 79 | Kirby's Star Stacker,DMG-AKCE-USA,DMG,2,Unsupported, 80 | Kwirk,DMG-AP-USA,DMG,2,Unsupported, 81 | Loopz,DMG-LP-USA,DMG,2,Unsupported, 82 | Magical Tetris Challenge,CGB-AT7E-USA,CGB,2,Unsupported, 83 | Malibu Beach Volleyball,DMG-SV-USA,DMG,2,Unsupported, 84 | Mario Golf,CGB-AWXE-USA,CGB,2,Unsupported, 85 | Mario Tennis,CGB-BM8E-USA,CGB,2,Unsupported, 86 | Marble Madness,DMG-MB-USA,DMG,2,Unsupported, 87 | Metal Gear Solid,CGB-BMGE-USA,CGB,2,Unsupported, 88 | Metal Masters,DMG-E6-USA,DMG,2,Unsupported, 89 | Mickey’s Speedway USA,CGB-BSNE-USA,CGB,2,Unsupported, 90 | Micro Machines,DMG-H3-USA,DMG,4,Unsupported, 91 | Micro Machines 2: Turbo Tournament,DMG-A2XP-UKV,DMG,4,Unsupported, 92 | Mole Mania,DMG-AMOE-USA,DMG,2,Unsupported, 93 | Monopoly,DMG-LY-USA,DMG,4,Unsupported, 94 | Monster Rancher Battle Card GB,DMG-A6TE-USA,DMG/CGB,2,Unsupported, 95 | Mortal Kombat,DMG-C9-USA,DMG,2,Unsupported, 96 | Mortal Kombat II,DMG-AMKE-USA,DMG,2,Unsupported, 97 | Motocross Maniacs,DMG-MX-USA,DMG,2,Unsupported, 98 | Nail 'n Scale,DMG-DR-USA,DMG,2,Unsupported, 99 | NBA 3 on 3 Featuring Kobe Bryant,DMG-ACVE-USA,DMG/CGB,2,Unsupported, 100 | NBA All-Star Challenge,DMG-AC-USA,DMG,2,Unsupported, 101 | NBA All-Star Challenge 2,DMG-H2-USA,DMG,2,Unsupported, 102 | NFL Football,DMG-FT-USA,DMG,2,Unsupported, 103 | Ninja Boy 2,DMG-CH-USA,DMG,2,Unsupported, 104 | Nintendo World Cup,DMG-NC-USA,DMG,2,Unsupported, 105 | Pac-Man,DMG-PC-USA,DMG,2,Unsupported, 106 | Penguin Wars,DMG-PW-USA,DMG,10,Unsupported, 107 | Pipe Dream,DMG-PD-USA,DMG,2,Unsupported, 108 | Play Action Football,DMG-FL-USA,DMG,2,Unsupported, 109 | Pokémon: Blue Version,DMG-APEE-USA,DMG,2,Unsupported, 110 | Pokémon: Crystal Version,CGB-BYTE-USA,CGB,2,Unsupported, 111 | Pokémon: Gold Version,CGB-AAUE-USA,DMG/CGB,2,Unsupported, 112 | Pokémon Puzzle Challenge,CGB-BPNE-USA,CGB,2,Unsupported, 113 | Pokémon: Red Version,DMG-APAE-USA,DMG,2,Unsupported, 114 | Pokémon: Silver Version,CGB-AAXE-USA,DMG/CGB,2,Unsupported, 115 | Pokémon Trading Card Game,DMG-AXQE-USA,DMG/CGB,2,Unsupported, 116 | Pokémon: Yellow Version: Special Pikachu Edition,DMG-APSE-USA,DMG,2,Unsupported, 117 | Popeye 2,DMG-PG-USA,DMG,2,Unsupported, 118 | Power Quest,DMG-APQE-USA,DMG/CGB,2,Unsupported, 119 | Power Racer,DMG-PQ-USA,DMG,2,Unsupported, 120 | Q Billion,DMG-QB-USA,DMG,2,Unsupported, 121 | Qix,DMG-QX-USA,DMG,2,Unsupported, 122 | Quarth,DMG-QR-USA,DMG,2,Unsupported, 123 | Radar Misson,DMG-RM-USA,DMG,2,Unsupported, 124 | Revelations: The Demon Slayer,DMG-ALBE-USA,DMG/CGB,2,Unsupported, 125 | Revenge of the 'Gator,DMG-PB-USA,DMG,2,Unsupported, 126 | Road Rash,CGB-BRRE-USA,CGB,2,Unsupported, 127 | Roger Clemens' MVP Baseball,DMG-VM-USA,DMG,2,Unsupported, 128 | Rolan's Curse,DMG-VE-USA,DMG,2,Unsupported, 129 | Snoopy Tennis,CGB-BS9E-USA,CGB,2,Unsupported, 130 | Snoopy's Magic Show,DMG-SN-USA,DMG,2,Unsupported, 131 | Soccer Mania,DMG-SB-USA,DMG,2,Unsupported, 132 | Stargate,DMG-AGTE-USA,DMG,2,Unsupported, 133 | Street Fighter II,DMG-ASFE-USA-1,DMG,2,Unsupported, 134 | Street Racer,DMG-ASRE-USA,DMG,2,Unsupported, 135 | Super Mario Bros. Deluxe,CGB-AHYE-USA,CGB,2,Unsupported, 136 | Super R.C. Pro-Am,DMG-RC-USA,DMG,4,Unsupported, 137 | Tecmo Bowl,DMG-TL-USA,DMG,2,Unsupported, 138 | Tennis,DMG-TN-USA,DMG,2,Unsupported, 139 | Test Drive 2001,CGB-BTEE-USA,CGB,2,Unsupported, 140 | Tetris,DMG-TR-USA,DMG,2,Playable,Music selection is hard-coded to A-type 141 | Tetris 2,DMG-EH-USA,DMG,2,Unsupported, 142 | Tetris Attack,DMG-AYLE-USA,DMG,2,Unsupported, 143 | Tetris Blast,DMG-ASBE-USA,DMG,2,Unsupported, 144 | Tetris DX,DMG-ATEA-USA,DMG/CGB,2,Unsupported, 145 | Tetris Plus,DMG-ATRE-USA,DMG,2,Unsupported, 146 | The Legend of Zelda: Oracle of Ages,CGB-AZBE-USA,CGB,2,Unsupported, 147 | The Legend of Zelda: Oracle of Seasons,CGB-AZ7E-USA,CGB,2,Unsupported, 148 | Titus the Fox,DMG-FO-USA,DMG,2,Unsupported, 149 | Tony Hawk’s Pro Skater,CGB-BTFE-USA,CGB,2,Unsupported, 150 | Tony Hawk’s Pro Skater 2,CGB-BTGE-USA,CGB,2,Unsupported, 151 | Tony Hawk’s Pro Skater 3,CGB-B3TE-USA,CGB,2,Unsupported, 152 | Top Gear Pocket,CGB-VGRE-USA,CGB,2,Unsupported, 153 | Top Gear Pocket 2,CGB-A33E-USA,CGB,2,Unsupported, 154 | Top Rank Tennis,DMG-XT-USA,DMG,4,Unsupported, 155 | Track & Field,DMG-KH-USA,DMG,2,Unsupported, 156 | Ultima: Runes of Virtue,DMG-UT-USA,DMG,2,Unsupported, 157 | Warlocked,CGB-BWLE-USA,CGB,2,Unsupported, 158 | Wave Race,DMG-WA-USA,DMG,4,Unsupported, 159 | Wild Snake,DMG-AWSE-USA,DMG,2,Unsupported, 160 | Word Zap,DMG-WZ-USA,DMG,2,Unsupported, 161 | WWF King of the Ring,DMG-WP-USA,DMG,2,Unsupported, 162 | WWF Superstars,DMG-LW-USA,DMG,2,Unsupported, 163 | WWF Superstars 2,DMG-WX-USA,DMG,2,Unsupported, 164 | Yoshi,DMG-YO-USA,DMG,2,Unsupported, 165 | Yoshi’s Cookie,DMG-CI-USA,DMG,4,Unsupported, 166 | Yu-Gi-Oh! Dark Duel Stories,CGB-BY3E-USA,CGB,2,Unsupported, 167 | Zoop,DMG-AZPE-USA,DMG,2,Unsupported, 168 | -------------------------------------------------------------------------------- /server/src/games/tetris.ts: -------------------------------------------------------------------------------- 1 | import { GameSession, stateHandler } from "../game-session"; 2 | import { sleep } from "../util"; 3 | 4 | enum TetrisGameState { 5 | WaitingForPlayers, 6 | PlayersConnected, 7 | DifficultySelection, 8 | SendingInitializationData, 9 | Playing, 10 | RoundOver 11 | }; 12 | 13 | enum TetrisCtrlByte { 14 | Master = 0x29, 15 | Slave = 0x55, 16 | SolidTile = 0x80, 17 | EmptyTile = 0x2F, 18 | ReadyForMusic = 0x39, 19 | MusicTypeA = 0x1C, 20 | MusicTypeB = 0x1D, 21 | MusicTypeC = 0x1E, 22 | MusicOff = 0x1F, 23 | ConfirmMusic = 0x50, 24 | ConfirmMenu = 0x60, 25 | Win = 0x77, 26 | Lose = 0xAA, 27 | Poll = 0x02, 28 | ReadyForRoundEnd = 0x34, 29 | ReadyForRestart = 0x27, 30 | BeginRoundOverScreen = 0x43, 31 | EndRoundOverScreen = 0x79 32 | }; 33 | 34 | function generateGarbageLines(): number[] { 35 | // Same algorithm as the original game. 50/50 chance of an empty space 36 | // versus a filled one. Filled spaces use 1 of 8 different tiles. 37 | // See https://github.com/alexsteb/tetris_disassembly/blob/b4bbceb3cc086121ab4fe9bf4dad6752fe956ec0/main.asm#L4604 38 | const garbage: number[] = []; 39 | 40 | for (let i = 0; i < 100; ++i) { 41 | if (Math.random() >= 0.5) { 42 | const tile = Math.floor(Math.random() * 8); 43 | garbage.push(tile | TetrisCtrlByte.SolidTile); 44 | } else { 45 | garbage.push(TetrisCtrlByte.EmptyTile); 46 | } 47 | } 48 | 49 | return garbage; 50 | } 51 | 52 | function generatePieces(): number[] { 53 | // Same algorithm as the original game. 54 | // See https://harddrop.com/wiki/Tetris_(Game_Boy)#Randomizer and 55 | // https://github.com/alexsteb/tetris_disassembly/blob/b4bbceb3cc086121ab4fe9bf4dad6752fe956ec0/main.asm#L1780 56 | const pieces: number[] = []; 57 | 58 | for (let i = 0; i < 256; ++i) { 59 | let nextPiece = 0; 60 | 61 | // Don't try forever 62 | for (let attempt = 0; attempt < 3; ++attempt) { 63 | // 7 choices of pieces, each is a multiple of 4 starting from 0 64 | nextPiece = Math.floor(Math.random() * 7) * 4; 65 | 66 | // Try to avoid repeats 67 | const prevPiece1 = pieces[i - 1] || 0; 68 | const prevPiece2 = pieces[i - 2] || 0; 69 | if (((nextPiece | prevPiece1 | prevPiece2) & 0xFC) != prevPiece2) { 70 | break; 71 | } 72 | } 73 | 74 | pieces.push(nextPiece); 75 | } 76 | 77 | return pieces; 78 | } 79 | 80 | export class TetrisGameSession extends GameSession { 81 | // TODO: configurable 82 | private readonly musicType = TetrisCtrlByte.MusicTypeA; 83 | 84 | private client1WinCount: number = 0; 85 | private client2WinCount: number = 0; 86 | 87 | constructor(id: string) { 88 | super(id); 89 | this.state = TetrisGameState.WaitingForPlayers; 90 | } 91 | 92 | private reset(): void { 93 | this.client1WinCount = 0; 94 | this.client2WinCount = 0; 95 | } 96 | 97 | @stateHandler(TetrisGameState.WaitingForPlayers) 98 | handleWaitingForPlayers() { 99 | if (this.requiredClientsHaveJoined()) { 100 | this.state = TetrisGameState.PlayersConnected; 101 | } 102 | } 103 | 104 | @stateHandler(TetrisGameState.PlayersConnected) 105 | async handlePlayersConnected() { 106 | await this.forAllClients(async c => { 107 | c.setSendDelayMs(30); 108 | 109 | await c.waitForByte(TetrisCtrlByte.Master, TetrisCtrlByte.Slave); 110 | 111 | // Music selection 112 | await c.waitForByte(this.musicType, TetrisCtrlByte.ReadyForMusic); 113 | return c.exchangeByte(TetrisCtrlByte.ConfirmMusic); 114 | }); 115 | 116 | this.state = TetrisGameState.DifficultySelection; 117 | } 118 | 119 | @stateHandler(TetrisGameState.DifficultySelection) 120 | async handleDifficultySelection() { 121 | this.reset(); 122 | 123 | let lastDifficultyChangeTime = Date.now(); 124 | let client1Difficulty = 0; 125 | let client2Difficulty = 0; 126 | 127 | // Neither player has the ability to confirm difficulty, so 128 | // do it automatically after changes have stopped occurring 129 | while ((Date.now() - lastDifficultyChangeTime) < 5000) { 130 | await this.forwardClientBytes((client1Byte: number, client2Byte: number) => { 131 | if (client1Byte !== client1Difficulty || client2Byte !== client2Difficulty) { 132 | client1Difficulty = client1Byte; 133 | client2Difficulty = client2Byte; 134 | lastDifficultyChangeTime = Date.now(); 135 | } 136 | }); 137 | } 138 | 139 | await this.forAllClients(c => { 140 | return c.waitForByte(TetrisCtrlByte.ConfirmMenu, TetrisCtrlByte.Slave); 141 | }); 142 | 143 | this.state = TetrisGameState.SendingInitializationData; 144 | } 145 | 146 | @stateHandler(TetrisGameState.SendingInitializationData) 147 | async handleSendingInitializationData() { 148 | const garbageLineData = generateGarbageLines(); 149 | const pieceData = generatePieces(); 150 | 151 | // Send global data 152 | await this.forAllClients(async c => { 153 | // This is a lot of data, and timing requirements aren't as strict 154 | c.setSendDelayMs(0); 155 | 156 | await c.waitForByte(TetrisCtrlByte.Master, TetrisCtrlByte.Slave); 157 | await c.sendBuffer(garbageLineData); 158 | 159 | await c.waitForByte(TetrisCtrlByte.Master, TetrisCtrlByte.Slave); 160 | return c.sendBuffer(pieceData); 161 | }); 162 | 163 | // Start the game 164 | await this.forAllClients(c => { 165 | // The main game loop needs some time for each transfer 166 | c.setSendDelayMs(30); 167 | 168 | const startSequence = [0x30, 0x00, 0x02, 0x02, 0x20]; 169 | return c.sendBuffer(startSequence); 170 | }); 171 | 172 | this.state = TetrisGameState.Playing; 173 | } 174 | 175 | @stateHandler(TetrisGameState.Playing) 176 | async handlePlaying() { 177 | // We can't send the game anything right away or it will freeze 178 | await sleep(500); 179 | 180 | let roundOver = false; 181 | 182 | let client1StatusByte = 0; 183 | let client2StatusByte = 0; 184 | 185 | const isGameEndingByte = (b: number) => { 186 | return b === TetrisCtrlByte.Win || b === TetrisCtrlByte.Lose; 187 | }; 188 | 189 | while (!roundOver) { 190 | await this.forwardClientBytes((client1Byte: number, client2Byte: number) => { 191 | if (isGameEndingByte(client1Byte)) { 192 | client1StatusByte = client1Byte; 193 | } 194 | if (isGameEndingByte(client2Byte)) { 195 | client2StatusByte = client2Byte; 196 | } 197 | 198 | if (client1Byte === TetrisCtrlByte.ReadyForRoundEnd && 199 | client2Byte === TetrisCtrlByte.ReadyForRoundEnd) { 200 | roundOver = true; 201 | } 202 | }); 203 | } 204 | 205 | let roundEndPollByte = TetrisCtrlByte.Poll; 206 | 207 | if (client1StatusByte === client2StatusByte) { 208 | // Draw. Notify clients in round end polling phase. 209 | roundEndPollByte = client1StatusByte; 210 | } else if (client1StatusByte === TetrisCtrlByte.Win || 211 | client2StatusByte === TetrisCtrlByte.Lose) { 212 | ++this.client1WinCount; 213 | } else { 214 | ++this.client2WinCount; 215 | } 216 | 217 | await this.forAllClients(async c => { 218 | await c.waitForByte(roundEndPollByte, TetrisCtrlByte.ReadyForRoundEnd); 219 | return c.exchangeByte(TetrisCtrlByte.BeginRoundOverScreen); 220 | }); 221 | 222 | this.state = TetrisGameState.RoundOver; 223 | } 224 | 225 | @stateHandler(TetrisGameState.RoundOver) 226 | async handleRoundOver() { 227 | // Give time to look at results 228 | await sleep(10000); 229 | 230 | let expectedByte: number; 231 | let nextState: TetrisGameState; 232 | 233 | if (this.client1WinCount < 4 && this.client2WinCount < 4) { 234 | // New round 235 | expectedByte = TetrisCtrlByte.Slave; 236 | nextState = TetrisGameState.SendingInitializationData; 237 | } else { 238 | // New game 239 | expectedByte = 0; 240 | nextState = TetrisGameState.DifficultySelection; 241 | } 242 | 243 | // Prepare for a restart 244 | await this.forAllClients(async c => { 245 | await c.exchangeByte(TetrisCtrlByte.ConfirmMenu); 246 | await c.waitForByte(TetrisCtrlByte.Poll, TetrisCtrlByte.ReadyForRestart); 247 | return c.waitForByte(TetrisCtrlByte.EndRoundOverScreen, expectedByte); 248 | }); 249 | 250 | this.state = nextState; 251 | } 252 | }; 253 | -------------------------------------------------------------------------------- /docs/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (6.0.5) 5 | concurrent-ruby (~> 1.0, >= 1.0.2) 6 | i18n (>= 0.7, < 2) 7 | minitest (~> 5.1) 8 | tzinfo (~> 1.1) 9 | zeitwerk (~> 2.2, >= 2.2.2) 10 | addressable (2.8.0) 11 | public_suffix (>= 2.0.2, < 5.0) 12 | coffee-script (2.4.1) 13 | coffee-script-source 14 | execjs 15 | coffee-script-source (1.11.1) 16 | colorator (1.1.0) 17 | commonmarker (0.23.4) 18 | concurrent-ruby (1.1.10) 19 | dnsruby (1.61.9) 20 | simpleidn (~> 0.1) 21 | em-websocket (0.5.3) 22 | eventmachine (>= 0.12.9) 23 | http_parser.rb (~> 0) 24 | ethon (0.15.0) 25 | ffi (>= 1.15.0) 26 | eventmachine (1.2.7) 27 | execjs (2.8.1) 28 | faraday (1.10.0) 29 | faraday-em_http (~> 1.0) 30 | faraday-em_synchrony (~> 1.0) 31 | faraday-excon (~> 1.1) 32 | faraday-httpclient (~> 1.0) 33 | faraday-multipart (~> 1.0) 34 | faraday-net_http (~> 1.0) 35 | faraday-net_http_persistent (~> 1.0) 36 | faraday-patron (~> 1.0) 37 | faraday-rack (~> 1.0) 38 | faraday-retry (~> 1.0) 39 | ruby2_keywords (>= 0.0.4) 40 | faraday-em_http (1.0.0) 41 | faraday-em_synchrony (1.0.0) 42 | faraday-excon (1.1.0) 43 | faraday-httpclient (1.0.1) 44 | faraday-multipart (1.0.3) 45 | multipart-post (>= 1.2, < 3) 46 | faraday-net_http (1.0.1) 47 | faraday-net_http_persistent (1.2.0) 48 | faraday-patron (1.0.0) 49 | faraday-rack (1.0.0) 50 | faraday-retry (1.0.3) 51 | ffi (1.15.5) 52 | forwardable-extended (2.6.0) 53 | gemoji (3.0.1) 54 | github-pages (226) 55 | github-pages-health-check (= 1.17.9) 56 | jekyll (= 3.9.2) 57 | jekyll-avatar (= 0.7.0) 58 | jekyll-coffeescript (= 1.1.1) 59 | jekyll-commonmark-ghpages (= 0.2.0) 60 | jekyll-default-layout (= 0.1.4) 61 | jekyll-feed (= 0.15.1) 62 | jekyll-gist (= 1.5.0) 63 | jekyll-github-metadata (= 2.13.0) 64 | jekyll-include-cache (= 0.2.1) 65 | jekyll-mentions (= 1.6.0) 66 | jekyll-optional-front-matter (= 0.3.2) 67 | jekyll-paginate (= 1.1.0) 68 | jekyll-readme-index (= 0.3.0) 69 | jekyll-redirect-from (= 0.16.0) 70 | jekyll-relative-links (= 0.6.1) 71 | jekyll-remote-theme (= 0.4.3) 72 | jekyll-sass-converter (= 1.5.2) 73 | jekyll-seo-tag (= 2.8.0) 74 | jekyll-sitemap (= 1.4.0) 75 | jekyll-swiss (= 1.0.0) 76 | jekyll-theme-architect (= 0.2.0) 77 | jekyll-theme-cayman (= 0.2.0) 78 | jekyll-theme-dinky (= 0.2.0) 79 | jekyll-theme-hacker (= 0.2.0) 80 | jekyll-theme-leap-day (= 0.2.0) 81 | jekyll-theme-merlot (= 0.2.0) 82 | jekyll-theme-midnight (= 0.2.0) 83 | jekyll-theme-minimal (= 0.2.0) 84 | jekyll-theme-modernist (= 0.2.0) 85 | jekyll-theme-primer (= 0.6.0) 86 | jekyll-theme-slate (= 0.2.0) 87 | jekyll-theme-tactile (= 0.2.0) 88 | jekyll-theme-time-machine (= 0.2.0) 89 | jekyll-titles-from-headings (= 0.5.3) 90 | jemoji (= 0.12.0) 91 | kramdown (= 2.3.2) 92 | kramdown-parser-gfm (= 1.1.0) 93 | liquid (= 4.0.3) 94 | mercenary (~> 0.3) 95 | minima (= 2.5.1) 96 | nokogiri (>= 1.13.4, < 2.0) 97 | rouge (= 3.26.0) 98 | terminal-table (~> 1.4) 99 | github-pages-health-check (1.17.9) 100 | addressable (~> 2.3) 101 | dnsruby (~> 1.60) 102 | octokit (~> 4.0) 103 | public_suffix (>= 3.0, < 5.0) 104 | typhoeus (~> 1.3) 105 | html-pipeline (2.14.1) 106 | activesupport (>= 2) 107 | nokogiri (>= 1.4) 108 | http_parser.rb (0.8.0) 109 | i18n (0.9.5) 110 | concurrent-ruby (~> 1.0) 111 | jekyll (3.9.2) 112 | addressable (~> 2.4) 113 | colorator (~> 1.0) 114 | em-websocket (~> 0.5) 115 | i18n (~> 0.7) 116 | jekyll-sass-converter (~> 1.0) 117 | jekyll-watch (~> 2.0) 118 | kramdown (>= 1.17, < 3) 119 | liquid (~> 4.0) 120 | mercenary (~> 0.3.3) 121 | pathutil (~> 0.9) 122 | rouge (>= 1.7, < 4) 123 | safe_yaml (~> 1.0) 124 | jekyll-avatar (0.7.0) 125 | jekyll (>= 3.0, < 5.0) 126 | jekyll-coffeescript (1.1.1) 127 | coffee-script (~> 2.2) 128 | coffee-script-source (~> 1.11.1) 129 | jekyll-commonmark (1.4.0) 130 | commonmarker (~> 0.22) 131 | jekyll-commonmark-ghpages (0.2.0) 132 | commonmarker (~> 0.23.4) 133 | jekyll (~> 3.9.0) 134 | jekyll-commonmark (~> 1.4.0) 135 | rouge (>= 2.0, < 4.0) 136 | jekyll-default-layout (0.1.4) 137 | jekyll (~> 3.0) 138 | jekyll-feed (0.15.1) 139 | jekyll (>= 3.7, < 5.0) 140 | jekyll-gist (1.5.0) 141 | octokit (~> 4.2) 142 | jekyll-github-metadata (2.13.0) 143 | jekyll (>= 3.4, < 5.0) 144 | octokit (~> 4.0, != 4.4.0) 145 | jekyll-include-cache (0.2.1) 146 | jekyll (>= 3.7, < 5.0) 147 | jekyll-mentions (1.6.0) 148 | html-pipeline (~> 2.3) 149 | jekyll (>= 3.7, < 5.0) 150 | jekyll-optional-front-matter (0.3.2) 151 | jekyll (>= 3.0, < 5.0) 152 | jekyll-paginate (1.1.0) 153 | jekyll-readme-index (0.3.0) 154 | jekyll (>= 3.0, < 5.0) 155 | jekyll-redirect-from (0.16.0) 156 | jekyll (>= 3.3, < 5.0) 157 | jekyll-relative-links (0.6.1) 158 | jekyll (>= 3.3, < 5.0) 159 | jekyll-remote-theme (0.4.3) 160 | addressable (~> 2.0) 161 | jekyll (>= 3.5, < 5.0) 162 | jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0) 163 | rubyzip (>= 1.3.0, < 3.0) 164 | jekyll-sass-converter (1.5.2) 165 | sass (~> 3.4) 166 | jekyll-seo-tag (2.8.0) 167 | jekyll (>= 3.8, < 5.0) 168 | jekyll-sitemap (1.4.0) 169 | jekyll (>= 3.7, < 5.0) 170 | jekyll-swiss (1.0.0) 171 | jekyll-theme-architect (0.2.0) 172 | jekyll (> 3.5, < 5.0) 173 | jekyll-seo-tag (~> 2.0) 174 | jekyll-theme-cayman (0.2.0) 175 | jekyll (> 3.5, < 5.0) 176 | jekyll-seo-tag (~> 2.0) 177 | jekyll-theme-dinky (0.2.0) 178 | jekyll (> 3.5, < 5.0) 179 | jekyll-seo-tag (~> 2.0) 180 | jekyll-theme-hacker (0.2.0) 181 | jekyll (> 3.5, < 5.0) 182 | jekyll-seo-tag (~> 2.0) 183 | jekyll-theme-leap-day (0.2.0) 184 | jekyll (> 3.5, < 5.0) 185 | jekyll-seo-tag (~> 2.0) 186 | jekyll-theme-merlot (0.2.0) 187 | jekyll (> 3.5, < 5.0) 188 | jekyll-seo-tag (~> 2.0) 189 | jekyll-theme-midnight (0.2.0) 190 | jekyll (> 3.5, < 5.0) 191 | jekyll-seo-tag (~> 2.0) 192 | jekyll-theme-minimal (0.2.0) 193 | jekyll (> 3.5, < 5.0) 194 | jekyll-seo-tag (~> 2.0) 195 | jekyll-theme-modernist (0.2.0) 196 | jekyll (> 3.5, < 5.0) 197 | jekyll-seo-tag (~> 2.0) 198 | jekyll-theme-primer (0.6.0) 199 | jekyll (> 3.5, < 5.0) 200 | jekyll-github-metadata (~> 2.9) 201 | jekyll-seo-tag (~> 2.0) 202 | jekyll-theme-slate (0.2.0) 203 | jekyll (> 3.5, < 5.0) 204 | jekyll-seo-tag (~> 2.0) 205 | jekyll-theme-tactile (0.2.0) 206 | jekyll (> 3.5, < 5.0) 207 | jekyll-seo-tag (~> 2.0) 208 | jekyll-theme-time-machine (0.2.0) 209 | jekyll (> 3.5, < 5.0) 210 | jekyll-seo-tag (~> 2.0) 211 | jekyll-titles-from-headings (0.5.3) 212 | jekyll (>= 3.3, < 5.0) 213 | jekyll-watch (2.2.1) 214 | listen (~> 3.0) 215 | jemoji (0.12.0) 216 | gemoji (~> 3.0) 217 | html-pipeline (~> 2.2) 218 | jekyll (>= 3.0, < 5.0) 219 | kramdown (2.3.2) 220 | rexml 221 | kramdown-parser-gfm (1.1.0) 222 | kramdown (~> 2.0) 223 | liquid (4.0.3) 224 | listen (3.7.1) 225 | rb-fsevent (~> 0.10, >= 0.10.3) 226 | rb-inotify (~> 0.9, >= 0.9.10) 227 | mercenary (0.3.6) 228 | minima (2.5.1) 229 | jekyll (>= 3.5, < 5.0) 230 | jekyll-feed (~> 0.9) 231 | jekyll-seo-tag (~> 2.1) 232 | minitest (5.15.0) 233 | multipart-post (2.1.1) 234 | nokogiri (1.13.6-x86_64-linux) 235 | racc (~> 1.4) 236 | octokit (4.22.0) 237 | faraday (>= 0.9) 238 | sawyer (~> 0.8.0, >= 0.5.3) 239 | pathutil (0.16.2) 240 | forwardable-extended (~> 2.6) 241 | public_suffix (4.0.7) 242 | racc (1.6.0) 243 | rb-fsevent (0.11.1) 244 | rb-inotify (0.10.1) 245 | ffi (~> 1.0) 246 | rexml (3.2.5) 247 | rouge (3.26.0) 248 | ruby2_keywords (0.0.5) 249 | rubyzip (2.3.2) 250 | safe_yaml (1.0.5) 251 | sass (3.7.4) 252 | sass-listen (~> 4.0.0) 253 | sass-listen (4.0.0) 254 | rb-fsevent (~> 0.9, >= 0.9.4) 255 | rb-inotify (~> 0.9, >= 0.9.7) 256 | sawyer (0.8.2) 257 | addressable (>= 2.3.5) 258 | faraday (> 0.8, < 2.0) 259 | simpleidn (0.2.1) 260 | unf (~> 0.1.4) 261 | terminal-table (1.8.0) 262 | unicode-display_width (~> 1.1, >= 1.1.1) 263 | thread_safe (0.3.6) 264 | typhoeus (1.4.0) 265 | ethon (>= 0.9.0) 266 | tzinfo (1.2.10) 267 | thread_safe (~> 0.1) 268 | unf (0.1.4) 269 | unf_ext 270 | unf_ext (0.0.8.1) 271 | unicode-display_width (1.8.0) 272 | zeitwerk (2.5.4) 273 | 274 | PLATFORMS 275 | x86_64-linux 276 | 277 | DEPENDENCIES 278 | github-pages (~> 226) 279 | 280 | BUNDLED WITH 281 | 2.2.20 282 | -------------------------------------------------------------------------------- /docs/_game-protocols/Tetris.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: protocol-doc 3 | 4 | game: Tetris 5 | serial: DMG-TR-USA 6 | year: 1989 7 | max_players: 2 8 | data_capture_name: tetris.csv 9 | 10 | title: Tetris Link Cable Protocol Documentation 11 | image: /images/games/tetris/tetris_boxart.png 12 | excerpt: >- 13 | Let's analyze the link cable protocol for Tetris on the Game Boy. 14 | --- 15 | 16 | {% 17 | include image.html 18 | src="/images/games/tetris/tetris_boxart.png" 19 | caption="The real puzzle was figuring out who the [rights holders](https://en.wikipedia.org/wiki/Tetris#History) were" 20 | %} 21 | 22 | ## Description 23 | 24 | Tetris needs no introduction. The classic puzzle game has been re-released on 25 | countless platforms over the years. During gameplay, tetrominoes (pieces 26 | composed of 4 tiles in different arrangements) fall one at a time from the top 27 | of the playfield. The player can move and rotate each piece until it reaches a 28 | previously-placed piece or the bottom of the playfield. When a horizontal line 29 | of tiles is created, the line is cleared and all pieces above it move down one 30 | row. The objective of the game is to clear as many lines as possible for a high 31 | score before the stack of tetrominoes reaches the top of the playfield. 32 | 33 | ## Multiplayer gameplay 34 | 35 | In Tetris' multiplayer mode both players play the game in parallel. The 36 | completion of horizontal lines causes unclearable "garbage" lines to appear at 37 | the bottom of the opposing player's playfield to add difficulty. More cleared 38 | lines result in more garbage lines. A player wins a round when they have cleared 39 | 30 lines or the opponent has lost. After one player has won 4 rounds, the 40 | multiplayer game ends and can be restarted if desired. 41 | 42 | While a round is in progress, the height of the opposing player's stack is 43 | shown via a thermometer-style indicator on the left side of the screen. Both 44 | players receive the same set of random tetrominoes to ensure fairness, and both 45 | have the option of selecting a difficulty before the game begins. The difficulty 46 | determines how many garbage lines will be present at the bottom of the playfield 47 | at the start of each round. The possible lines are shared by both players (i.e., 48 | each selects the relevant number of garbage lines from the top of the shared 49 | data). 50 | 51 | ## Link cable protocol 52 | 53 | ### Role negotiation 54 | 55 | {% 56 | include image.html 57 | src="/images/games/tetris/tetris_link_role_negotiation.png" 58 | caption="Initiating a connection" 59 | %} 60 | 61 | The connection begins with the two games choosing which device will act as the 62 | master and initiate all data transfers. This role goes to the first player to 63 | select "2 player" on the main menu, which causes the byte `0x29` to be sent. 64 | Upon receiving this byte, the linked game sends `0x55` as an acknowledgement and 65 | both move to the music selection screen. The required delay between transfers is 66 | ~30 ms. 67 | 68 | ### Music selection 69 | 70 | {% 71 | include image.html 72 | src="/images/games/tetris/tetris_link_music_selection.png" 73 | caption="Choosing the music track. Both UIs are synchronized." 74 | %} 75 | 76 | On the music selection screen, the master (and **only** the master) chooses which 77 | track will play during the game's rounds. The current menu position (`0x1C` - 78 | `0x1F`) is repeatedly sent to the slave so that its graphics and sound can be 79 | updated accordingly (its responses are ignored). When the selection is 80 | confirmed by pressing start, `0x50` is sent and both games move to the 81 | difficulty selection screen. The required delay between transfers is ~30 ms. 82 | 83 | ### Difficulty selection 84 | 85 | {% 86 | include image.html 87 | src="/images/games/tetris/tetris_link_difficulty_selection.png" 88 | caption="Choosing the difficulty. This time communication is bidirectional." 89 | %} 90 | 91 | After music selection, each player chooses their difficulty. This controls the 92 | number of garbage lines present at the bottom of the playfield when a round 93 | begins. Unlike music selection, both players have control over their own 94 | difficulty setting. During this time, the two games repeatedly exchange their 95 | current menu position (`0x00` - `0x05`) so their graphics can be updated to 96 | reflect the other player's choice. Again, only the master can press start and 97 | trigger the next state (pre-round initialization). This is indicated with the 98 | byte `0x60`, to which the slave responds with `0x55`. The required delay between 99 | transfers is ~30 ms. 100 | 101 | ### Pre-round initialization 102 | 103 | {% 104 | include image.html 105 | src="/images/games/tetris/tetris_link_pre_game_initialization.png" 106 | caption="Sending initialization data. This screenshot captures some tetromino data -- `0x0C`, `0x04`, `0x14`, `0x18`, `0x08` represents O, J, S, T, and I pieces." 107 | %} 108 | 109 | To avoid giving any player an unfair advantage, the master sends 256 random 110 | tetrominoes and then 10 random garbage lines before each round begins so that 111 | both players receive the same pieces and have the same initial garbage (if 112 | difficulty settings are different between players, they just truncate the shared 113 | garbage tiles at different heights). The start of each block of data is signaled 114 | with the same exchange as in role negotiation (`0x29`/`0x55`). The required 115 | delay between transfers is ~5 ms. 116 | 117 | After this data is sent, the game can begin. This is indicated with the magic 118 | bytes `0x30`, `0x00`, `0x02`, `0x02`, `0x20`. The required delay between these 119 | transfers is ~30 ms. After this, there is a delay of ~500 ms during which 120 | receiving any data will freeze the game. 121 | 122 | #### Tetromino generation 123 | 124 | Each random tetromino is generated by selecting a number between 0 and 6 125 | (inclusive) as the candidate piece. Next, the bitwise OR of the candidate piece 126 | and previous two pieces is computed. If the resulting number is not equal to the 127 | that of the tetromino from two selections prior, the candidate is accepted and 128 | the value multiplied by 4 is sent. Otherwise, the process repeats up to two more 129 | times, after which the candidate is accepted even if the check fails. This logic 130 | is intended to lower the likelihood of duplicate consecutive pieces. More 131 | information is available 132 | [here](https://harddrop.com/wiki/Tetris_(Game_Boy)#Randomizer). Tetromino IDs 133 | are listed below. 134 | 135 | |ID |Tetromino shape| 136 | |------|---------------| 137 | |`0x00`|L | 138 | |`0x04`|J | 139 | |`0x08`|I | 140 | |`0x0C`|O | 141 | |`0x10`|Z | 142 | |`0x14`|S | 143 | |`0x18`|T | 144 | 145 | If a round lasts long enough to exhaust the 256-tetromino buffer, the sequence 146 | repeats. 147 | 148 | #### Garbage tile generation 149 | 150 | Garbage lines are generated much more simply than tetrominoes. For each 151 | individual tile that can contain garbage at the start of a round (10 rows x 10 152 | tiles = 100 bytes) there is a ~50% chance of the tile being solid. If yes, bit 7 153 | is set and the bottom 3 bits are randomly populated to select the visual style 154 | of the tile. Otherwise, the value `0x2F` is used to denote that the tile is 155 | clear. 156 | 157 | ### Main gameplay 158 | 159 | {% 160 | include image.html 161 | src="/images/games/tetris/tetris_link_main_gameplay.png" 162 | caption="A game in progress. Note how the sent data matches the sending player's stack height and received data matches the thermometer-style indicator." 163 | %} 164 | 165 | Finally, the round can begin. During gameplay, both games constantly send the 166 | height of their stack to each other. This information is used to draw the 167 | thermometer-style indicator on the left of the screen. When the opponent's stack 168 | height decreases (meaning that lines were cleared), the receiving game adds 169 | garbage to the bottom of the playfield (i.e., the opponent "attacks" and makes 170 | the game more difficult). At any time, the master game can also trigger a pause 171 | by sending `0x94` (acknowledged with a `0x00` from the slave). The pause will 172 | end when `0x94` stops being sent (acknowledged with `0xFF`). The required delay 173 | between transfers is ~30 ms. 174 | 175 | The game indicates a win (clearing 30 lines) with `0x77` and a loss (exceeding 176 | the height of the playfield) with `0xAA`. Once both games acknowledge the end of 177 | the round by sending `0x34`, the master delays for a short time and then sends 178 | `0x43` to move to the round end screen. 179 | 180 | ### Round end 181 | 182 | {% 183 | include image.html 184 | src="/images/games/tetris/tetris_link_round_end.png" 185 | caption="The score summary at the end of a round. In this case the master player won." 186 | %} 187 | 188 | The round end screen displays who won or lost, or whether the round ended in a 189 | draw. The master player (who always appears as Mario) decides when to move on 190 | by pressing start, at which point `0x60` is sent (acknowledged with `0x27`) 191 | followed by `0x79`. The required delay between transfers is ~30 ms. 192 | 193 | What happens next depends on whether a player has won 4 rounds or not. If 194 | yes, both games return to difficulty selection. If no, another round begins 195 | (pre-round initialization state). There is no opportunity to change the music. 196 | 197 | ## Summary and notes 198 | 199 | Tetris' link cable protocol is simple and straightforward. So simple, in fact, 200 | that it makes no attempt to detect a dropped connection. The link cable can be 201 | unplugged at any time. If done during the setup menus, this will freeze the 202 | game for the slave. However, during the main gameplay state both players can 203 | continue to play -- albeit with out-of-date opponent stack information and the 204 | inability to send or receive garbage lines. After reconnecting the cable, both 205 | games will resume as expected. 206 | 207 | This makes a lot of sense for a Game Boy launch title. Later games and those 208 | with more real-time elements have more complex protocols. Tetris' primitive 209 | protocol is as good as it needs to be. 210 | -------------------------------------------------------------------------------- /docs/_posts/2021-05-10-An-8-Bit-Idea_The-Internet-of-Game-Boys.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: post 3 | 4 | title: "An 8-bit Idea: The Internet of Game Boys" 5 | image: /images/dmg_link_cable_port.jpg 6 | excerpt: >- 7 | After coming across an incredibly cool project involving online Game Boy 8 | multiplayer, we saw room for several improvements and lots of learning. 9 | So we decided to start our own project! 10 | 11 | author: matt 12 | date: 2021-05-10 13 | --- 14 | 15 | {% 16 | include image.html 17 | src="/images/dmg_link_cable_port.jpg" 18 | caption="The side of an original DMG-01 Game Boy" 19 | %} 20 | 21 | ## The spark 22 | 23 | In early May, my friend [@aidancrowther](https://github.com/aidancrowther) 24 | and I came across an incredibly cool video by hardware hacking YouTuber 25 | [stacksmashing](https://www.youtube.com/c/stacksmashing). For his latest project 26 | he'd created an adapter to connect an original Game Boy to a PC via the link cable 27 | peripheral, along with a web server which could host multiplayer Tetris games by 28 | bridging that connection. This combination allows players to face off in 29 | competitive block stacking match-ups over the internet with original hardware! 30 | 31 | As someone who loves 80s and 90s-era Nintendo and has 32 | [dabbled](https://github.com/mwpenny/GameDroid) in 33 | [emulation](https://github.com/mwpenny/pureNES), this is awesome! 34 | However Aidan and I both saw room for improvement and a chance to learn a lot 35 | along the way. So we decided to start our own online link cable project. 36 | Before diving into more detail, check out stacksmashing's original video below. 37 | 38 | {% include youtube.html id="KtHu693wE9o" caption="Stacksmashing's original video" %} 39 | 40 | ## How it works 41 | 42 | To summarize the video, there are three main components involved in making this work: 43 | 44 | 1. A Raspberry Pi Pico, which implements the Game Boy's link cable protocol and mediates 45 | data transfer between the PC and handheld 46 | 47 | 2. A website, which utilizes [WebUSB](https://developer.mozilla.org/en-US/docs/Web/API/WebUSB_API) 48 | to communicate with the Pi and send data to and from the backend server 49 | 50 | 3. A backend server, which manages players and host-side game logic such as game over, 51 | sending garbage lines to other players when lines are cleared, etc. 52 | 53 | Under normal conditions (before we started living in the future) one Tetris 54 | instance acts as the game host and controls certain aspects of play such as the 55 | music and when the game ends. Stacksmashing moved that logic to the server side 56 | which allows each instance of the game to act as a client, thereby supporting a 57 | theoretically infinite number of players! The server broadcasts received messages 58 | to all connected clients so everyone is effectively part of the same game. 59 | 60 | In Tetris, the only substantial interaction one player has with another is 61 | attacking them by sending garbage lines to add some challenge. This makes the 62 | game well-suited to a "battle royale" style like this (in fact, that's exactly 63 | what the officially-licensed 64 | [Tetris 99](https://en.wikipedia.org/wiki/Tetris_99) is). 65 | 66 | ## Improvements and goals 67 | 68 | This is all very impressive but there are a few limitations: it only supports 69 | Tetris and in order to play you need to be sitting at your computer using a 70 | hard-wired connection. This is a handheld console! Furthermore, stacksmashing 71 | didn't flesh out the hardware too much beyond the proof of concept stage, which 72 | isn't the easiest to share with non-technical friends. 73 | 74 | Being on the lookout for new side project ideas, we couldn't resist. 75 | 76 | Our goal is to create a solution that supports as much of the Game Boy library 77 | as possible (ideally generic/game-agnostic) and is plug and play, such that 78 | everything can be done entirely from the Game Boy. This will take the form of 79 | a small dongle with a link cable connector and Wi-Fi connectivity. Configuration 80 | will be done via a custom Game Boy cartridge, with a mobile-friendly web page as 81 | a backup option -- much more portable. 82 | 83 | Overly-ambitious? Probably. But a fantastic opportunity for both of us to learn 84 | more about hardware projects and stretch those low-level programming muscles. We 85 | both love seeing how far old technology can be pushed beyond what it was ever 86 | intended to do, and creating custom hardware to go with it will add a whole other 87 | layer of depth and fun. We're extremely excited to start. 88 | 89 | ## Hardware details and feasibility 90 | 91 | With our goals and ideas in mind, how realistic is this project? Clearly, 92 | something along these lines is possible -- stacksmashing's project exists. But 93 | before any work can begin we need to understand as many of the hardware details 94 | as possible. Tetris works, but can other games cope with the latency of the 95 | internet? We don't want to come to a disappointing conclusion after putting in 96 | hours of time and effort. 97 | 98 | ### Understanding the link cable protocol 99 | 100 | First, let's look at the link cable protocol. It's essentially 101 | [SPI](https://en.wikipedia.org/wiki/Serial_Peripheral_Interface), which is still 102 | in wide use today in embedded devices ranging from SD cards to LCD screens. That 103 | means there are many ready-to-use libraries for interfacing with it. Doing so 104 | manually isn't particularly difficult either (by design). It's a very simple 105 | serial protocol in which one master device communicates with one or more slave 106 | devices. A minimum of three signals are needed: 107 | 108 | 1. A clock signal, to indicate when a bit is being transferred (SCLK; serial clock) 109 | 110 | 2. Output to slave device(s) (MOSI; master out, slave in) 111 | 112 | 3. Input from slave device(s) (MISO; master in, slave out) 113 | 114 | The Game Boy exposes these signals on its link cable port. 115 | 116 | {% 117 | include image.html 118 | src="/images/dmg_link_port_pinout.png" 119 | caption="Game Boy link port pinout. The link cable swaps the wires for "out" and "in" on each end." 120 | %} 121 | 122 | In SPI configurations with multiple devices, there are also slave select (SS) 123 | signals to indicate which device the master is communicating with at a given 124 | time. However that's not the case with the Game Boy -- only 2 devices can ever 125 | be directly involved in a transfer. This is true even when using the 126 | [4-player adapter](https://shonumi.github.io/articles/art9.html), which 127 | communicates separately with each connected Game Boy to get around the hardware 128 | limitation. 129 | 130 | With SPI, when the master device generates a clock pulse, both the master and 131 | slave send a bit on their output lines and receive a bit on their input lines. 132 | In other words, the protocol is bidirectional and synchronous. On the Game Boy, 133 | games store the next byte to send in an on-board shift register. When a transfer 134 | is initiated or a clock pulse is received, the next bit to send is shifted out 135 | of the register and the received bit is simultaneously shifted in. After 8 of 136 | these 1-bit transfers have occurred, an interrupt is generated on each Game Boy 137 | to signal completion and that it's safe for the game to read the value. 138 | 139 | GB SPI holds the clock line high when idle and indicates a transfer by pulsing 140 | it low. Data is shifted out (most significant bit first) on each falling clock 141 | edge and sampled on each rising edge. This configuration is known as SPI mode 3. 142 | 143 | {% 144 | include image.html 145 | src="/images/gb_spi.png" 146 | caption="An example GB SPI transfer. Here, the master sends `0xD9` (`217`) and the slave sends `0x45` (`69`)." 147 | %} 148 | 149 | ### GB SPI and latency 150 | 151 | Since the link protocol is synchronous and bits are sent and received 152 | simultaneously, that means the master device requires the slave to send its 153 | response at a rate equal to the clock speed. In non-Game Boy Color mode, the 154 | master Game Boy supplies an 8KHz clock (data transfer speed of 1KB/s). This 155 | means that there is only a ~120μs window to respond! The Game Boy Color can 156 | operate at even higher speeds. No internet connection could possibly satisfy 157 | this latency requirement. However, the slave device has no such constraints. 158 | It just responds when it receives data! 159 | 160 | According to the excellent 161 | [Pan Docs](https://gbdev.io/pandocs/Serial_Data_Transfer_(Link_Cable).html#external-clock), 162 | the Game Boy can operate with link cable clock speeds of up to 500KHz (62.5KB/s 163 | transfer speed) and importantly there is no lower bound. By default, the slave 164 | will wait forever until it receives data from the master. In fact, the clock 165 | pulses don't even need to be sent at a consistent speed and there can be large 166 | gaps in between. 167 | 168 | Of course, some games may still implement timeouts but this sounds perfect for 169 | our use-case. If we can somehow force both Game Boys to operate in slave mode 170 | then the latency can be theoretically infinite, supporting even the roughest of 171 | connections. As it turns out, this is exactly what stacksmashing did for Tetris 172 | and what another project called [TCPoke](http://pepijndevos.nl/TCPoke/) 173 | did for Pokemon Generation 1 and 2. Bingo! This is a viable approach and has 174 | already been proven to work with another game. 175 | 176 | ## Next steps 177 | 178 | Understanding the link cable protocol and seeing similar projects gave us 179 | confidence that what we want to do is possible. So, we did what anybody else 180 | in this situation would: ordered an obscene amount of hardware! Link cables, 181 | breakout boards, Game Boy games, microcontrollers, flash cartridges, and more. 182 | With all of this in hand, the plan is to get a basic prototype PC to Game Boy 183 | interface working and then build on top of that. Once proven, we'll be able 184 | to design the hardware and software to run it all. 185 | 186 | There are still some unknowns that will need to be delved into through 187 | experimentation -- namely, testing compatibility with different games and 188 | finding a game-agnostic way to force both Game Boys to operate in slave mode. 189 | In the worst case, we'll need to write game-specific code. But since we don't 190 | plan on supporting massive game lobbies like stacksmashing (as of now), once 191 | the Game Boys are initialized properly the rest should just be a matter of 192 | forwarding bytes back and forth between them as normal. We could be wrong, but 193 | that's the fun part! 194 | 195 | Stay tuned for further updates and more deep dives. 196 | -------------------------------------------------------------------------------- /esp/GBPlay/main/hardware/wifi.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | #include "storage.h" 9 | #include "wifi.h" 10 | 11 | #define MAX_CONNECTION_RETRY_COUNT 3 12 | #define CONNECTION_TIMEOUT_MS 15000 13 | 14 | #define WIFI_SAVED_NETWORKS_STORAGE_KEY "wifi_networks" 15 | 16 | // Assumes dst is a statically allocated array 17 | #define TRUNCATED_STRING_COPY(dst, src) \ 18 | strncpy(dst, src, sizeof(dst)); \ 19 | dst[sizeof(dst) - 1] = '\0'; 20 | 21 | typedef struct { 22 | wifi_network_credentials networks[WIFI_MAX_SAVED_NETWORKS]; 23 | int count; 24 | } wifi_saved_network_info; 25 | 26 | ESP_EVENT_DEFINE_BASE(NETWORK_EVENT); 27 | 28 | static SemaphoreHandle_t s_wifi_lock; 29 | static SemaphoreHandle_t s_wifi_storage_lock; 30 | static EventGroupHandle_t s_wifi_event_group; // For blocking on connect 31 | static wifi_saved_network_info s_saved_networks = {0}; 32 | static esp_netif_t* s_wifi_iface = NULL; 33 | static volatile bool s_is_connected = false; 34 | 35 | static void _set_connection_status(bool connected) 36 | { 37 | s_is_connected = connected; 38 | } 39 | 40 | static void _on_disconnect(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) 41 | { 42 | wifi_event_sta_disconnected_t* event = (wifi_event_sta_disconnected_t*)event_data; 43 | ESP_LOGI(__func__, "Disconnected from network %s", event->ssid); 44 | 45 | _set_connection_status(false); 46 | 47 | network_event network_event_id = (event->reason == WIFI_REASON_ASSOC_LEAVE) ? 48 | NETWORK_EVENT_LEFT : 49 | NETWORK_EVENT_DROPPED; 50 | 51 | xEventGroupSetBits(s_wifi_event_group, network_event_id); 52 | 53 | ESP_ERROR_CHECK(esp_event_post( 54 | NETWORK_EVENT, 55 | network_event_id, 56 | NULL, 57 | 0, 58 | portMAX_DELAY 59 | )); 60 | } 61 | 62 | static void _on_connect(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) 63 | { 64 | ip_event_got_ip_t* event = (ip_event_got_ip_t*)event_data; 65 | 66 | char address[16] = {0}; 67 | esp_ip4addr_ntoa(&event->ip_info.ip, address, sizeof(address)); 68 | ESP_LOGI(__func__, "Got IP: %s", address); 69 | 70 | wifi_ap_record_t ap_info = { 0 }; 71 | 72 | // If we are actually disconnected, we will get a disconnect event soon 73 | if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) 74 | { 75 | network_event_connected connect_event = {0}; 76 | TRUNCATED_STRING_COPY(connect_event.ssid, (char*)ap_info.ssid); 77 | 78 | _set_connection_status(true); 79 | xEventGroupSetBits(s_wifi_event_group, NETWORK_EVENT_CONNECTED); 80 | 81 | ESP_ERROR_CHECK(esp_event_post( 82 | NETWORK_EVENT, 83 | NETWORK_EVENT_CONNECTED, 84 | &connect_event, 85 | sizeof(connect_event), 86 | portMAX_DELAY 87 | )); 88 | } 89 | } 90 | 91 | void wifi_initialize() 92 | { 93 | s_wifi_lock = xSemaphoreCreateMutex(); 94 | s_wifi_storage_lock = xSemaphoreCreateMutex(); 95 | 96 | s_wifi_event_group = xEventGroupCreate(); 97 | 98 | // Load previously saved credentials 99 | void* saved_networks = storage_get_blob(WIFI_SAVED_NETWORKS_STORAGE_KEY); 100 | if (saved_networks) 101 | { 102 | memcpy(&s_saved_networks, saved_networks, sizeof(s_saved_networks)); 103 | free(saved_networks); 104 | } 105 | 106 | _set_connection_status(false); 107 | 108 | // Init TCP/IP stack 109 | ESP_ERROR_CHECK(esp_netif_init()); 110 | s_wifi_iface = esp_netif_create_default_wifi_sta(); 111 | 112 | // Station mode 113 | wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); 114 | ESP_ERROR_CHECK(esp_wifi_init(&cfg)); 115 | ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); 116 | 117 | // Event handlers 118 | ESP_ERROR_CHECK(esp_event_handler_instance_register( 119 | WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED, &_on_disconnect, NULL, NULL 120 | )); 121 | ESP_ERROR_CHECK(esp_event_handler_instance_register( 122 | IP_EVENT, IP_EVENT_STA_GOT_IP, &_on_connect, NULL, NULL 123 | )); 124 | 125 | ESP_ERROR_CHECK(esp_wifi_start()); 126 | } 127 | 128 | void wifi_deinitialize() 129 | { 130 | wifi_disconnect(); 131 | 132 | ESP_ERROR_CHECK(esp_wifi_stop()); 133 | ESP_ERROR_CHECK(esp_wifi_deinit()); 134 | 135 | esp_netif_destroy_default_wifi(s_wifi_iface); 136 | ESP_ERROR_CHECK(esp_netif_deinit()); 137 | 138 | vEventGroupDelete(s_wifi_event_group); 139 | 140 | vSemaphoreDelete(s_wifi_storage_lock); 141 | vSemaphoreDelete(s_wifi_lock); 142 | } 143 | 144 | void wifi_scan(wifi_ap_info* out_ap_list, uint16_t* ap_count) 145 | { 146 | assert(xSemaphoreTake(s_wifi_lock, portMAX_DELAY) == pdTRUE); 147 | 148 | if (esp_wifi_scan_start(NULL, true) != ESP_OK) 149 | { 150 | // Could fail due to timeout or wifi still connecting 151 | *ap_count = 0; 152 | xSemaphoreGive(s_wifi_lock); 153 | return; 154 | } 155 | 156 | wifi_ap_record_t* temp_ap_list = calloc(*ap_count, sizeof(wifi_ap_record_t)); 157 | 158 | ESP_ERROR_CHECK(esp_wifi_scan_get_ap_records(ap_count, temp_ap_list)); 159 | 160 | // Convert result from internal format to public-facing one 161 | uint16_t total_aps_returned = 0; 162 | for (int i = 0; i < *ap_count; ++i) 163 | { 164 | wifi_ap_record_t* src = &temp_ap_list[i]; 165 | wifi_ap_info* dst = &out_ap_list[i]; 166 | 167 | if (src->rssi < WIFI_MINIMUM_RSSI) 168 | { 169 | continue; 170 | } 171 | 172 | TRUNCATED_STRING_COPY(dst->ssid, (char*)src->ssid); 173 | 174 | dst->rssi = src->rssi; 175 | dst->channel = src->primary; 176 | dst->requires_password = src->authmode != WIFI_AUTH_OPEN; 177 | 178 | ++total_aps_returned; 179 | } 180 | 181 | free(temp_ap_list); 182 | *ap_count = total_aps_returned; 183 | 184 | xSemaphoreGive(s_wifi_lock); 185 | } 186 | 187 | static void _disconnect() 188 | { 189 | xEventGroupClearBits(s_wifi_event_group, 0xFF); 190 | 191 | if (wifi_is_connected()) 192 | { 193 | esp_wifi_disconnect(); 194 | 195 | // Wait for disconnect 196 | xEventGroupWaitBits( 197 | s_wifi_event_group, 198 | NETWORK_EVENT_DROPPED | NETWORK_EVENT_LEFT, 199 | pdTRUE, // xClearOnExit 200 | pdFALSE, // xWaitForAllBits 201 | portMAX_DELAY 202 | ); 203 | } 204 | } 205 | 206 | bool wifi_connect(const char* ssid, const char* password, bool force) 207 | { 208 | wifi_config_t cfg = { 0 }; 209 | 210 | int max_ssid_len = sizeof(cfg.sta.ssid); 211 | int max_pass_len = sizeof(cfg.sta.password); 212 | int ssid_len = snprintf((char*)&cfg.sta.ssid, max_ssid_len, "%s", ssid); 213 | int pass_len = snprintf((char*)&cfg.sta.password, max_pass_len, "%s", password); 214 | 215 | if (ssid_len > max_ssid_len || pass_len > max_pass_len) 216 | { 217 | // This should never happen since SSIDs come from the hardware 218 | // and passwords are validated at the client layer 219 | ESP_LOGE( 220 | __func__, 221 | "Wi-Fi configuration out of bounds. SSID length: %d, password length: %d", 222 | ssid_len, pass_len 223 | ); 224 | return false; 225 | } 226 | 227 | { 228 | assert(xSemaphoreTake(s_wifi_lock, portMAX_DELAY) == pdTRUE); 229 | 230 | bool did_connect = false; 231 | 232 | if (!force && wifi_is_connected()) 233 | { 234 | // Connected to something else. Give up. 235 | // When the user chooses to connect, force = true. 236 | // When the connection manager tries to connect, force = false. 237 | ESP_LOGI( 238 | __func__, 239 | "Already connected to a network and new connection is not forced" 240 | ); 241 | } 242 | else 243 | { 244 | ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &cfg)); 245 | 246 | for (int i = 0; !did_connect && i < MAX_CONNECTION_RETRY_COUNT; ++i) 247 | { 248 | _disconnect(); 249 | 250 | ESP_LOGI( 251 | __func__, 252 | "Trying to connect to network '%s' (attempt %d of %d)...", 253 | ssid, i + 1, MAX_CONNECTION_RETRY_COUNT 254 | ); 255 | 256 | xEventGroupClearBits(s_wifi_event_group, 0xFF); 257 | 258 | if (esp_wifi_connect() != ESP_OK) 259 | { 260 | ESP_LOGE(__func__, "Connection failed"); 261 | break; 262 | } 263 | 264 | did_connect = (xEventGroupWaitBits( 265 | s_wifi_event_group, 266 | 0xFF, // Bits to wait for (any bits) 267 | pdTRUE, // xClearOnExit 268 | pdFALSE, // xWaitForAllBits 269 | CONNECTION_TIMEOUT_MS / portTICK_PERIOD_MS 270 | ) & NETWORK_EVENT_CONNECTED) == NETWORK_EVENT_CONNECTED; 271 | } 272 | } 273 | 274 | xSemaphoreGive(s_wifi_lock); 275 | return did_connect; 276 | } 277 | } 278 | 279 | void wifi_disconnect() 280 | { 281 | assert(xSemaphoreTake(s_wifi_lock, portMAX_DELAY) == pdTRUE); 282 | 283 | _disconnect(); 284 | 285 | xSemaphoreGive(s_wifi_lock); 286 | } 287 | 288 | bool wifi_is_connected() 289 | { 290 | //wifi_ap_record_t ap_info = { 0 }; 291 | //return esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK; 292 | return s_is_connected; 293 | } 294 | 295 | 296 | static void _wifi_flush_saved_networks() 297 | { 298 | storage_set_blob( 299 | WIFI_SAVED_NETWORKS_STORAGE_KEY, 300 | &s_saved_networks, 301 | sizeof(s_saved_networks) 302 | ); 303 | } 304 | 305 | static wifi_network_credentials* _get_saved_network(const char* ssid) 306 | { 307 | for (int i = 0; i < s_saved_networks.count; ++i) 308 | { 309 | wifi_network_credentials* ap = &s_saved_networks.networks[i]; 310 | if (strcmp(ap->ssid, ssid) == 0) 311 | { 312 | return ap; 313 | } 314 | } 315 | 316 | return NULL; 317 | } 318 | 319 | bool wifi_get_saved_network(const char* ssid, wifi_network_credentials* out_network) 320 | { 321 | bool found = false; 322 | 323 | { 324 | assert(xSemaphoreTake(s_wifi_storage_lock, portMAX_DELAY) == pdTRUE); 325 | 326 | wifi_network_credentials* existing = _get_saved_network(ssid); 327 | if (existing != NULL) 328 | { 329 | *out_network = *existing; 330 | found = true; 331 | } 332 | 333 | xSemaphoreGive(s_wifi_storage_lock); 334 | } 335 | 336 | return found; 337 | } 338 | 339 | int wifi_get_all_saved_networks(wifi_network_credentials* out_networks) 340 | { 341 | int count = 0; 342 | 343 | { 344 | assert(xSemaphoreTake(s_wifi_storage_lock, portMAX_DELAY) == pdTRUE); 345 | 346 | count = s_saved_networks.count; 347 | 348 | for (int i = 0; i < count; ++i) 349 | { 350 | out_networks[i] = s_saved_networks.networks[i]; 351 | } 352 | 353 | xSemaphoreGive(s_wifi_storage_lock); 354 | } 355 | 356 | return count; 357 | } 358 | 359 | bool wifi_save_network(const char* ssid, const char* password) 360 | { 361 | bool saved = false; 362 | 363 | { 364 | assert(xSemaphoreTake(s_wifi_storage_lock, portMAX_DELAY) == pdTRUE); 365 | 366 | wifi_network_credentials* existing = _get_saved_network(ssid); 367 | if (existing) 368 | { 369 | TRUNCATED_STRING_COPY(existing->pass, password); 370 | 371 | _wifi_flush_saved_networks(); 372 | 373 | saved = true; 374 | } 375 | else if (s_saved_networks.count < WIFI_MAX_SAVED_NETWORKS) 376 | { 377 | wifi_network_credentials* ap = &s_saved_networks.networks[s_saved_networks.count]; 378 | 379 | TRUNCATED_STRING_COPY(ap->ssid, ssid); 380 | TRUNCATED_STRING_COPY(ap->pass, password); 381 | 382 | ++s_saved_networks.count; 383 | _wifi_flush_saved_networks(); 384 | 385 | saved = true; 386 | } 387 | else 388 | { 389 | // No room. Should have checked and cleared a spot first. 390 | } 391 | 392 | xSemaphoreGive(s_wifi_storage_lock); 393 | } 394 | 395 | return saved; 396 | } 397 | 398 | void wifi_forget_network(const char* ssid) 399 | { 400 | assert(xSemaphoreTake(s_wifi_storage_lock, portMAX_DELAY) == pdTRUE); 401 | 402 | bool found_existing = false; 403 | 404 | for (int i = 0; i < s_saved_networks.count; ++i) 405 | { 406 | wifi_network_credentials* ap = &s_saved_networks.networks[i]; 407 | 408 | if (found_existing) 409 | { 410 | // Shift left 411 | wifi_network_credentials* prev = &s_saved_networks.networks[i - 1]; 412 | memcpy(prev, ap, sizeof(*prev)); 413 | memset(ap, 0, sizeof(*ap)); 414 | } 415 | else if (strcmp(ap->ssid, ssid) == 0) 416 | { 417 | found_existing = true; 418 | memset(ap, 0, sizeof(*ap)); 419 | } 420 | } 421 | 422 | if (found_existing) 423 | { 424 | --s_saved_networks.count; 425 | _wifi_flush_saved_networks(); 426 | } 427 | 428 | xSemaphoreGive(s_wifi_storage_lock); 429 | } 430 | -------------------------------------------------------------------------------- /esp/GBPlay/main/commands.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include "http.h" 6 | #include "hardware/spi.h" 7 | #include "hardware/storage.h" 8 | #include "hardware/wifi.h" 9 | 10 | #define DEFAULT_SCAN_LIST_SIZE 10 11 | 12 | static struct { 13 | struct arg_lit* save; 14 | struct arg_str* ssid; 15 | struct arg_str* pass; 16 | struct arg_end* end; 17 | } wifi_connect_args; 18 | 19 | static struct { 20 | struct arg_str* url; 21 | struct arg_end* end; 22 | } http_get_args; 23 | 24 | static struct { 25 | struct arg_int* index; 26 | struct arg_end* end; 27 | } load_connection_args; 28 | 29 | static struct { 30 | struct arg_int* index; 31 | struct arg_end* end; 32 | } forget_connection_args; 33 | 34 | static struct { 35 | struct arg_int* tx; 36 | struct arg_end* end; 37 | } spi_exchange_args; 38 | 39 | static struct { 40 | struct arg_str* key; 41 | struct arg_str* value; 42 | struct arg_end* end; 43 | } set_value_args; 44 | 45 | static struct { 46 | struct arg_str* key; 47 | struct arg_end* end; 48 | } get_value_args; 49 | 50 | static struct { 51 | struct arg_str* key; 52 | struct arg_end* end; 53 | } delete_value_args; 54 | 55 | static int _wifi_scan(int argc, char** argv) 56 | { 57 | uint16_t ap_count = DEFAULT_SCAN_LIST_SIZE; 58 | wifi_ap_info ap_info[DEFAULT_SCAN_LIST_SIZE] = {0}; 59 | 60 | wifi_scan(ap_info, &ap_count); 61 | 62 | ESP_LOGI(__func__, "Total APs scanned = %u\n", ap_count); 63 | for (int i = 0; i < ap_count; ++i) 64 | { 65 | ESP_LOGI(__func__, "SSID \t\t%s", ap_info[i].ssid); 66 | ESP_LOGI(__func__, "CHANNEL \t\t%d", ap_info[i].channel); 67 | ESP_LOGI(__func__, "RSSI \t\t%d", ap_info[i].rssi); 68 | ESP_LOGI(__func__, "SECURE \t\t%d\n", ap_info[i].requires_password); 69 | } 70 | 71 | return 0; 72 | } 73 | 74 | static int _wifi_connect(int argc, char **argv) 75 | { 76 | int nerrors = arg_parse(argc, argv, (void**)&wifi_connect_args); 77 | if (nerrors != 0) 78 | { 79 | arg_print_errors(stderr, wifi_connect_args.end, argv[0]); 80 | return 1; 81 | } 82 | 83 | const char* ssid = wifi_connect_args.ssid->sval[0]; 84 | const char* pass = wifi_connect_args.pass->sval[0]; 85 | 86 | if (wifi_connect(ssid, pass, true /* force */)) 87 | { 88 | if (wifi_connect_args.save->count > 0) 89 | { 90 | if (!wifi_save_network(ssid, pass)) 91 | { 92 | ESP_LOGW( 93 | __func__, 94 | "Could not save network %s. Maximum number are already saved.", 95 | ssid 96 | ); 97 | } 98 | } 99 | return 0; 100 | } 101 | 102 | return 1; 103 | } 104 | 105 | static int _wifi_disconnect(int argc, char** argv) 106 | { 107 | wifi_disconnect(); 108 | return 0; 109 | } 110 | 111 | static int _wifi_status(int argc, char** argv) 112 | { 113 | if (!wifi_is_connected()) 114 | { 115 | ESP_LOGI(__func__, "No connection"); 116 | } 117 | else 118 | { 119 | ESP_LOGI(__func__, "Connected"); 120 | } 121 | 122 | return 0; 123 | } 124 | 125 | static int _http_get(int argc, char** argv) 126 | { 127 | if (!wifi_is_connected()) 128 | { 129 | ESP_LOGI(__func__, "Please first establish a connection"); 130 | return 1; 131 | } 132 | 133 | int nerrors = arg_parse(argc, argv, (void**)&http_get_args); 134 | if (nerrors != 0) 135 | { 136 | arg_print_errors(stderr, http_get_args.end, argv[0]); 137 | return 1; 138 | } 139 | 140 | const char* url = http_get_args.url->sval[0]; 141 | char body[1024] = {0}; 142 | 143 | int data_read = http_get(url, body, sizeof(body)); 144 | if (data_read < 0) 145 | { 146 | ESP_LOGE(__func__, "HTTP GET failed for %s", url); 147 | } 148 | 149 | ESP_LOGI(__func__, "%.*s", data_read, body); 150 | 151 | return (data_read < 0) ? 1 : 0; 152 | } 153 | 154 | static void _list_saved_networks(wifi_network_credentials* saved_networks, int count) 155 | { 156 | ESP_LOGI(__func__, "Total saved networks = %d", count); 157 | for (int i = 0; i < count; ++i) 158 | { 159 | ESP_LOGI(__func__, "%d: %s", i, saved_networks[i].ssid); 160 | } 161 | } 162 | 163 | static int _load_connection(int argc, char** argv) 164 | { 165 | int nerrors = arg_parse(argc, argv, (void**)&load_connection_args); 166 | if (nerrors != 0) 167 | { 168 | arg_print_errors(stderr, load_connection_args.end, argv[0]); 169 | return 1; 170 | } 171 | 172 | wifi_network_credentials saved_networks[WIFI_MAX_SAVED_NETWORKS] = {0}; 173 | int saved_network_count = wifi_get_all_saved_networks(saved_networks); 174 | 175 | if (load_connection_args.index->count == 0) 176 | { 177 | _list_saved_networks(saved_networks, saved_network_count); 178 | return 0; 179 | } 180 | else 181 | { 182 | int index = load_connection_args.index->ival[0]; 183 | if (index < 0 || index >= saved_network_count) 184 | { 185 | ESP_LOGE(__func__, "No saved network with index %d", index); 186 | return 1; 187 | } 188 | 189 | wifi_network_credentials* ap = &saved_networks[index]; 190 | return wifi_connect(ap->ssid, ap->pass, true /* force */) ? 0 : 1; 191 | } 192 | } 193 | 194 | static int _forget_connection(int argc, char** argv) 195 | { 196 | int nerrors = arg_parse(argc, argv, (void**)&forget_connection_args); 197 | if (nerrors != 0) 198 | { 199 | arg_print_errors(stderr, forget_connection_args.end, argv[0]); 200 | return 1; 201 | } 202 | 203 | wifi_network_credentials saved_networks[WIFI_MAX_SAVED_NETWORKS] = {0}; 204 | int saved_network_count = wifi_get_all_saved_networks(saved_networks); 205 | 206 | if (forget_connection_args.index->count == 0) 207 | { 208 | _list_saved_networks(saved_networks, saved_network_count); 209 | return 0; 210 | } 211 | else 212 | { 213 | int index = forget_connection_args.index->ival[0]; 214 | if (index < 0 || index >= saved_network_count) 215 | { 216 | ESP_LOGE(__func__, "No saved network with index %d", index); 217 | return 1; 218 | } 219 | 220 | wifi_forget_network(saved_networks[index].ssid); 221 | return 0; 222 | } 223 | } 224 | 225 | static int _spi_exchange(int argc, char** argv) 226 | { 227 | int nerrors = arg_parse(argc, argv, (void**)&spi_exchange_args); 228 | if (nerrors != 0) 229 | { 230 | arg_print_errors(stderr, spi_exchange_args.end, argv[0]); 231 | return 1; 232 | } 233 | 234 | uint8_t tx = spi_exchange_args.tx->ival[0] & 0xFF; 235 | uint8_t rx = spi_exchange_byte(tx); 236 | 237 | ESP_LOGI(__func__, "Tx: 0x%02X, Rx: 0x%02X", tx, rx); 238 | return 0; 239 | } 240 | 241 | static int _set_value(int argc, char** argv) 242 | { 243 | int nerrors = arg_parse(argc, argv, (void**)&set_value_args); 244 | if (nerrors != 0) 245 | { 246 | arg_print_errors(stderr, set_value_args.end, argv[0]); 247 | return 1; 248 | } 249 | 250 | const char* key = set_value_args.key->sval[0]; 251 | const char* value = set_value_args.value->sval[0]; 252 | 253 | storage_set_string(key, value); 254 | return 0; 255 | } 256 | 257 | static int _get_value(int argc, char** argv) 258 | { 259 | int nerrors = arg_parse(argc, argv, (void**)&get_value_args); 260 | if (nerrors != 0) 261 | { 262 | arg_print_errors(stderr, get_value_args.end, argv[0]); 263 | return 1; 264 | } 265 | 266 | const char* key = get_value_args.key->sval[0]; 267 | char* value = storage_get_string(key); 268 | 269 | if (value == NULL) 270 | { 271 | ESP_LOGE(__func__, "No value exists with key='%s'", key); 272 | return 1; 273 | } 274 | 275 | ESP_LOGI(__func__, "Retrieved %s='%s'", key, value); 276 | free(value); 277 | 278 | return 0; 279 | } 280 | 281 | static int _delete_value(int argc, char** argv) 282 | { 283 | int nerrors = arg_parse(argc, argv, (void**)&delete_value_args); 284 | if (nerrors != 0) 285 | { 286 | arg_print_errors(stderr, delete_value_args.end, argv[0]); 287 | return 1; 288 | } 289 | 290 | const char* key = delete_value_args.key->sval[0]; 291 | storage_delete(key); 292 | 293 | return 0; 294 | } 295 | 296 | 297 | static void _register_wifi_scan() 298 | { 299 | const esp_console_cmd_t scan_def = { 300 | .command = "scan", 301 | .help = "Scan available WiFi networks", 302 | .hint = NULL, 303 | .func = &_wifi_scan 304 | }; 305 | 306 | ESP_ERROR_CHECK(esp_console_cmd_register(&scan_def)); 307 | } 308 | 309 | static void _register_wifi_connect() 310 | { 311 | wifi_connect_args.save = arg_lit0("s", "save", "Whether to save the configuration"); 312 | wifi_connect_args.ssid = arg_str1(NULL, NULL, "ssid", "SSID of Wi-Fi network"); 313 | wifi_connect_args.pass = arg_str1(NULL, NULL, "password", "Password of Wi-Fi network"); 314 | wifi_connect_args.end = arg_end(10 /* max error count */); 315 | 316 | const esp_console_cmd_t connect_def = { 317 | .command = "connect", 318 | .help = "Connect to Wi-Fi network using specified credentials", 319 | .hint = NULL, 320 | .func = &_wifi_connect, 321 | .argtable = &wifi_connect_args 322 | }; 323 | 324 | ESP_ERROR_CHECK(esp_console_cmd_register(&connect_def)); 325 | } 326 | 327 | static void _register_wifi_disconnect() 328 | { 329 | const esp_console_cmd_t disconnect_def = { 330 | .command = "disconnect", 331 | .help = "Disconnect from Wi-Fi network", 332 | .hint = NULL, 333 | .func = &_wifi_disconnect 334 | }; 335 | 336 | ESP_ERROR_CHECK(esp_console_cmd_register(&disconnect_def)); 337 | } 338 | 339 | static void _register_wifi_status() 340 | { 341 | const esp_console_cmd_t status_def = { 342 | .command = "status", 343 | .help = "Report Wi-Fi status", 344 | .hint = NULL, 345 | .func = &_wifi_status 346 | }; 347 | ESP_ERROR_CHECK(esp_console_cmd_register(&status_def)); 348 | } 349 | 350 | static void _register_http_get() 351 | { 352 | http_get_args.url = arg_str1(NULL, NULL, "url", "URL of web page to GET"); 353 | http_get_args.end = arg_end(10 /* max error count */); 354 | 355 | const esp_console_cmd_t get_def = { 356 | .command = "query", 357 | .help = "Query an HTTP address and returns the result (specify HTTPS)", 358 | .hint = NULL, 359 | .func = &_http_get, 360 | .argtable = &http_get_args 361 | }; 362 | 363 | ESP_ERROR_CHECK(esp_console_cmd_register(&get_def)); 364 | } 365 | 366 | void _register_load_connection() 367 | { 368 | load_connection_args.index = arg_int0( 369 | NULL, 370 | NULL, 371 | "index", "Index of saved network. Omit to list all saved networks." 372 | ); 373 | load_connection_args.end = arg_end(10 /* max error count */); 374 | 375 | const esp_console_cmd_t load_def = { 376 | .command = "load", 377 | .help = "Load saved network configuration", 378 | .hint = NULL, 379 | .func = &_load_connection, 380 | .argtable = &load_connection_args 381 | }; 382 | 383 | ESP_ERROR_CHECK(esp_console_cmd_register(&load_def)); 384 | } 385 | 386 | void _register_forget_connection() 387 | { 388 | forget_connection_args.index = arg_int0( 389 | NULL, 390 | NULL, 391 | "index", "Index of saved network. Omit to list all saved networks." 392 | ); 393 | forget_connection_args.end = arg_end(10 /* max error count */); 394 | 395 | const esp_console_cmd_t forget_def = { 396 | .command = "forget", 397 | .help = "Forget saved network configuration", 398 | .hint = NULL, 399 | .func = &_forget_connection, 400 | .argtable = &forget_connection_args 401 | }; 402 | 403 | ESP_ERROR_CHECK(esp_console_cmd_register(&forget_def)); 404 | } 405 | 406 | void _register_spi_exchange() 407 | { 408 | spi_exchange_args.tx = arg_int1( 409 | NULL, 410 | NULL, 411 | "tx", "The byte to send. Only the lower 8 bits will be used." 412 | ); 413 | spi_exchange_args.end = arg_end(10 /* max error count */); 414 | 415 | const esp_console_cmd_t spi_def = { 416 | .command = "spi", 417 | .help = "Exchange byte with connected SPI slave", 418 | .hint = NULL, 419 | .func = &_spi_exchange, 420 | .argtable = &spi_exchange_args 421 | }; 422 | 423 | ESP_ERROR_CHECK(esp_console_cmd_register(&spi_def)); 424 | } 425 | 426 | void _register_set_value() 427 | { 428 | set_value_args.key = arg_str1(NULL, NULL, "key", "The ID of the value to store"); 429 | set_value_args.value = arg_str1(NULL, NULL, "value", "The value to store"); 430 | set_value_args.end = arg_end(10 /* max error count */); 431 | 432 | const esp_console_cmd_t set_value_def = { 433 | .command = "set-value", 434 | .help = "Save a value in flash storage", 435 | .hint = NULL, 436 | .func = &_set_value, 437 | .argtable = &set_value_args 438 | }; 439 | 440 | ESP_ERROR_CHECK(esp_console_cmd_register(&set_value_def)); 441 | } 442 | 443 | void _register_get_value() 444 | { 445 | get_value_args.key = arg_str1(NULL, NULL, "key", "The ID of the value to retrieve"); 446 | get_value_args.end = arg_end(10 /* max error count */); 447 | 448 | const esp_console_cmd_t get_value_def = { 449 | .command = "get-value", 450 | .help = "Retrieve a value from flash storage", 451 | .hint = NULL, 452 | .func = &_get_value, 453 | .argtable = &get_value_args 454 | }; 455 | 456 | ESP_ERROR_CHECK(esp_console_cmd_register(&get_value_def)); 457 | } 458 | 459 | void _register_delete_value() 460 | { 461 | delete_value_args.key = arg_str1(NULL, NULL, "key", "The ID of the value to delete"); 462 | delete_value_args.end = arg_end(10 /* max error count */); 463 | 464 | const esp_console_cmd_t delete_value_def = { 465 | .command = "delete-value", 466 | .help = "Delete a value from flash storage", 467 | .hint = NULL, 468 | .func = &_delete_value, 469 | .argtable = &delete_value_args 470 | }; 471 | 472 | ESP_ERROR_CHECK(esp_console_cmd_register(&delete_value_def)); 473 | } 474 | 475 | void cmds_register() 476 | { 477 | _register_wifi_scan(); 478 | _register_wifi_connect(); 479 | _register_wifi_disconnect(); 480 | _register_wifi_status(); 481 | 482 | _register_http_get(); 483 | 484 | _register_load_connection(); 485 | _register_forget_connection(); 486 | 487 | _register_spi_exchange(); 488 | 489 | _register_set_value(); 490 | _register_get_value(); 491 | _register_delete_value(); 492 | 493 | esp_console_register_help_command(); 494 | } 495 | --------------------------------------------------------------------------------