diff --git a/docs/docs/setup/connecting/cemu.mdx b/docs/docs/setup/connecting/cemu.mdx
index 6aaba3a..ec8f629 100644
--- a/docs/docs/setup/connecting/cemu.mdx
+++ b/docs/docs/setup/connecting/cemu.mdx
@@ -26,21 +26,20 @@ This guide will show you how to access your server from the Cemu emulator.
- Prefer using the Wii U method if you have access to a Wii U console. This method is more complicated and
- requires using an account server patch that disables the console verification, which normally blocks invalid OTP
- and SEEPROM dumps. **The fake OTP and SEEPROM dumps will not work on the official Pretendo server.**
+ Prefer using the Wii U method if you have access to a Wii U console. This method is more complicated and requires
+ using an account server patch that disables console verification, which normally blocks invalid OTP and SEEPROM
+ dumps. **The fake OTP and SEEPROM dumps will not work on the official Pretendo server.**
- 1. Follow the [browser connecting guide](./browser.mdx) first.
- 2. Visit [the account settings page](https://pretendo.network/account) in your proxied browser and click the
- `Download account files` button.
-
- This button will only show up on your server. This feature was purposefully disabled on the official Pretendo
- servers to prevent abuse and ban evasion.
-
- 3. Copy `otp.bin`, `seeprom.bin`, and `mlc01` from the downloaded ZIP file to your Cemu directory.
- 4. Continue with the [official Pretendo Network Cemu installation guide](https://pretendo.network/docs/install/cemu)
- to enable online mode with Pretendo.
+ 1. Run `./scripts/create-cemu-online-files.sh --fake-dumps ` to generate the necessary online files, including
+ the `account.dat` and the fake dumps. Replace `` with your PNID username, and enter the password when
+ prompted.
+ - If you already have another account in Cemu, you can use the `--persistent-id` option to change the persistent
+ ID (like `80000001`) of the generated `account.dat` to one that is not already in use.
+ 2. Copy `otp.bin`, `seeprom.bin`, and `mlc01` from the created `online-files` directory to your Cemu directory.
+ Merge the `mlc01` directory with your existing Cemu MLC directory if you have one.
+ 3. Continue with the [official Pretendo Network Cemu installation guide](https://pretendo.network/docs/install/cemu)
+ to enable online mode with Pretendo in the account settings.
diff --git a/scripts/create-cemu-online-files.sh b/scripts/create-cemu-online-files.sh
new file mode 100755
index 0000000..19f6a7a
--- /dev/null
+++ b/scripts/create-cemu-online-files.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+
+# shellcheck source=./internal/framework.sh
+source "$(dirname "$(realpath "$0")")/internal/framework.sh"
+set_description "This creates the necessary files for Cemu online play with Pretendo as an alternative to using Dumpling.\
+It can also create fake OTP and SEEPROM dumps, which will only work on servers with console verification disabled."
+add_option "-f --force" "force" "Always overwrite the existing online files in the output directory without asking"
+add_option "-d --fake-dumps" "fake_dumps" "Create fake OTP and SEEPROM dumps (with all null bytes) in addition to the account.dat file"
+add_option_with_value "-i --persistent-id" "persistent_id" "id" "The persistent ID to use for the account.dat file" false "80000001"
+add_option_with_value "-o --output" "output_dir" "directory" "The output directory for the online files" false "./online-files"
+add_option_with_value "-p --password" "password" "password" "The password to use for the account.dat file (if not provided, you will be prompted to enter a password)" false
+add_positional_argument "pnid" "pnid" "The PNID to create an account.dat file" true
+parse_arguments "$@"
+
+if [[ -z "$password" ]]; then
+ printf "Enter the password for PNID $pnid: "
+ read -rs password
+ echo
+fi
+
+account_dat_path="$output_dir/mlc01/usr/save/system/act/$persistent_id/account.dat"
+otp_path="$output_dir/otp.bin"
+seeprom_path="$output_dir/seeprom.bin"
+if [[ -n "$fake_dumps" ]]; then
+ paths=("$account_dat_path" "$otp_path" "$seeprom_path")
+else
+ paths=("$account_dat_path")
+fi
+
+needs_confirmation=false
+for path in "${paths[@]}"; do
+ mkdir -p "$(dirname "$path")"
+ if [[ -f "$path" ]]; then
+ print_warning "Output file $path already exists. Continuing will overwrite it!"
+ needs_confirmation=true
+ fi
+done
+if [[ "$needs_confirmation" == true && -z "$force" ]]; then
+ printf "Continue? [y/N] "
+ read -r continue
+ if [[ "$continue" != "Y" && "$continue" != "y" ]]; then
+ echo "Aborting."
+ exit 1
+ fi
+fi
+
+print_info "Generating online files for PNID $pnid..."
+
+compose_no_progress up -d account
+
+create_account_dat_script=$(cat "$git_base_dir/scripts/run-in-container/create-account-dat.js")
+
+docker compose exec -u root account sh -c "touch /tmp/account.dat && chmod 777 /tmp/account.dat"
+run_verbose docker compose exec account node -e "$create_account_dat_script" "$pnid" "$password" "$persistent_id"
+
+compose_no_progress cp account:/tmp/account.dat "$account_dat_path"
+docker compose exec -u root account rm -f /tmp/account.dat
+
+if [[ -n "$fake_dumps" ]]; then
+ print_info "Creating fake OTP and SEEPROM dumps..."
+ head -c 1024 /dev/zero >"$otp_path"
+ head -c 512 /dev/zero >"$seeprom_path"
+fi
+
+print_success "Successfully generated online files for PNID $pnid."
diff --git a/scripts/run-in-container/create-account-dat.js b/scripts/run-in-container/create-account-dat.js
new file mode 100644
index 0000000..56d4716
--- /dev/null
+++ b/scripts/run-in-container/create-account-dat.js
@@ -0,0 +1,70 @@
+// This should be evaled in the account container
+const fs = require("fs").promises;
+const { v4: uuidv4 } = require("uuid");
+const { compare } = require("bcrypt");
+const { config } = require("./dist/config-manager");
+const { connect } = require("./dist/database");
+const { PNID } = require("./dist/models/pnid");
+const { nintendoPasswordHash } = require("./dist/util");
+
+// See https://github.com/GabIsAwesome/accountfile-generator and
+// https://github.com/PretendoNetwork/website/blob/99ee7ebe0aa1c2b632526f1de42f9f8b0d15940d/src/routes/account.js#L245-L284
+
+async function runAsync() {
+ if (process.argv.length < 4) {
+ console.log("Usage: ");
+ process.exit(1);
+ }
+
+ await connect();
+
+ const pnid = await PNID.findOne({
+ usernameLower: process.argv[1].toLowerCase(),
+ });
+ if (pnid) {
+ const accountDat = await generateAccountDat(pnid, process.argv[2], process.argv[3]);
+ await fs.writeFile(`/tmp/account.dat`, accountDat);
+ } else {
+ throw new Error(`No PNID found for username ${process.argv[1]}.`);
+ }
+}
+
+runAsync().then(() => {
+ process.exit(0);
+});
+
+async function generateAccountDat(pnid, password, persistentId) {
+ const hashedPassword = nintendoPasswordHash(password, pnid.pid);
+ if (!(await compare(hashedPassword, pnid.password))) {
+ throw new Error("Incorrect password specified.");
+ }
+
+ const [year, month, day] = pnid.birthdate.split("-");
+ const birthYear = parseInt(year, 10);
+ const birthMonth = parseInt(month, 10);
+ const birthDay = parseInt(day, 10);
+
+ let accountDat = "AccountInstance_00000000\n";
+ accountDat += `PersistentId=${persistentId}\n`;
+ accountDat += "TransferableIdBase=0\n";
+ accountDat += `Uuid=${uuidv4().replace(/-/g, "")}\n`;
+ accountDat += `MiiData=${Buffer.from(pnid.mii.data, "base64").toString("hex")}\n`;
+ accountDat += `MiiName=${Buffer.from(pnid.mii.name, "utf16le").swap16().toString("hex")}\n`;
+ accountDat += `AccountId=${pnid.username}\n`;
+ accountDat += `BirthYear=${birthYear.toString(16)}\n`;
+ accountDat += `BirthMonth=${birthMonth.toString(16)}\n`;
+ accountDat += `BirthDay=${birthDay.toString(16)}\n`;
+ accountDat += `Gender=${pnid.gender === "M" ? 1 : 0}\n`;
+ accountDat += "IsMailAddressValidated=1\n";
+ accountDat += `EmailAddress=${pnid.email.address}\n`;
+ // No convenient way to turn the country code into the necessary numerical ID format
+ accountDat += "Country=0\n";
+ accountDat += "SimpleAddressId=0\n";
+ accountDat += `PrincipalId=${pnid.pid.toString(16)}\n`;
+ accountDat += "NeedsToDownloadMiiImage=1\n";
+ accountDat += `MiiImageUrl=${config.cdn.base_url}/mii/${pnid.pid}/standard.tga\n`;
+ accountDat += "IsPasswordCacheEnabled=1\n";
+ accountDat += `AccountPasswordCache=${hashedPassword}`;
+
+ return accountDat;
+}