mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-04-05 23:42:30 +02:00
merge for delete handlers_shop_gacha.go
This commit is contained in:
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -1,2 +1,5 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Force LF for shell scripts (prevents CRLF breakage in Docker containers)
|
||||
*.sh text eol=lf
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug.md
vendored
6
.github/ISSUE_TEMPLATE/bug.md
vendored
@@ -19,3 +19,9 @@ If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Your Erupe Server version**
|
||||
The version or commit number of your running Erupe installation.
|
||||
|
||||
**Client version**
|
||||
MHFrontier client version.
|
||||
|
||||
42
.github/workflows/docker.yml
vendored
42
.github/workflows/docker.yml
vendored
@@ -1,48 +1,58 @@
|
||||
name: Create and publish a Docker image
|
||||
name: Docker
|
||||
|
||||
# Configures this workflow to run every time a tag is created.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '*'
|
||||
- 'v*'
|
||||
|
||||
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
#
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
|
||||
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
|
||||
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
100
.github/workflows/go.yml
vendored
100
.github/workflows/go.yml
vendored
@@ -1,7 +1,12 @@
|
||||
name: Build
|
||||
name: Build and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- 'fix-*'
|
||||
- 'feature-*'
|
||||
paths:
|
||||
- 'common/**'
|
||||
- 'config/**'
|
||||
@@ -11,22 +16,83 @@ on:
|
||||
- 'go.sum'
|
||||
- 'main.go'
|
||||
- '.github/workflows/go.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: erupe_test
|
||||
ports:
|
||||
- 5433:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 2s
|
||||
--health-timeout 2s
|
||||
--health-retries 10
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run Tests with Race Detector and Coverage
|
||||
run: go test -race -coverprofile=coverage.out ./... -timeout=10m
|
||||
env:
|
||||
TEST_DB_HOST: localhost
|
||||
TEST_DB_PORT: 5433
|
||||
TEST_DB_USER: test
|
||||
TEST_DB_PASSWORD: test
|
||||
TEST_DB_NAME: erupe_test
|
||||
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
COVERAGE=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print substr($3, 1, length($3)-1)}')
|
||||
echo "Total coverage: ${COVERAGE}%"
|
||||
if [ "$(echo "$COVERAGE < 50" | bc)" -eq 1 ]; then
|
||||
echo "::error::Coverage ${COVERAGE}% is below 50% threshold"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: [test, lint]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build Linux-amd64
|
||||
run: env GOOS=linux GOARCH=amd64 go build -v
|
||||
|
||||
|
||||
- name: Upload Linux-amd64 artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -37,11 +103,11 @@ jobs:
|
||||
./www/
|
||||
./savedata/
|
||||
./bin/
|
||||
./bundled-schema/
|
||||
retention-days: 7
|
||||
|
||||
- name: Build Windows-amd64
|
||||
run: env GOOS=windows GOARCH=amd64 go build -v
|
||||
|
||||
|
||||
- name: Upload Windows-amd64 artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -52,4 +118,22 @@ jobs:
|
||||
./www/
|
||||
./savedata/
|
||||
./bin/
|
||||
./bundled-schema/
|
||||
retention-days: 7
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: v2.10.1
|
||||
args: --timeout=5m
|
||||
|
||||
82
.github/workflows/release.yml
vendored
Normal file
82
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.os_name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
os_name: Linux-amd64
|
||||
binary: erupe-ce
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
os_name: Windows-amd64
|
||||
binary: erupe-ce.exe
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build
|
||||
run: env GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -v -o ${{ matrix.binary }}
|
||||
|
||||
- name: Prepare release archive
|
||||
run: |
|
||||
mkdir -p staging
|
||||
cp ${{ matrix.binary }} staging/
|
||||
cp config.example.json staging/
|
||||
cp config.reference.json staging/
|
||||
cp -r www/ staging/www/
|
||||
cp -r savedata/ staging/savedata/
|
||||
# Schema is now embedded in the binary via server/migrations/
|
||||
cd staging && zip -r ../erupe-${{ matrix.os_name }}.zip .
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.os_name }}
|
||||
path: erupe-${{ matrix.os_name }}.zip
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Copy standalone schema for download
|
||||
run: cp server/migrations/sql/0001_init.sql SCHEMA.sql
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/Linux-amd64/erupe-Linux-amd64.zip
|
||||
artifacts/Windows-amd64/erupe-Windows-amd64.zip
|
||||
SCHEMA.sql
|
||||
32
.gitignore
vendored
32
.gitignore
vendored
@@ -8,4 +8,34 @@ savedata/*/
|
||||
*.lnk
|
||||
*.bat
|
||||
/docker/db-data
|
||||
screenshots/*
|
||||
/docker/savedata
|
||||
/docker/bin
|
||||
/docker/config.json
|
||||
screenshots/*
|
||||
|
||||
# We don't need built files
|
||||
/erupe-ce
|
||||
/erupe
|
||||
/protbot
|
||||
/tools/loganalyzer/loganalyzer
|
||||
|
||||
# config is install dependent
|
||||
config.json
|
||||
.env
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
||||
# Deployment scripts
|
||||
deploy.sh
|
||||
|
||||
# Editor artifacts
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Test/build artifacts
|
||||
coverage.out
|
||||
|
||||
# Claude Code local config
|
||||
.claude/
|
||||
|
||||
7
.golangci.yml
Normal file
7
.golangci.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
default: standard
|
||||
188
AUTHORS.md
188
AUTHORS.md
@@ -1,30 +1,190 @@
|
||||
# List of authors who contributed to Erupe
|
||||
|
||||
## Point of current development
|
||||
The project is currently developed under https://github.com/ZeruLight/Erupe
|
||||
|
||||
The project is currently developed under <https://github.com/ZeruLight/Erupe>
|
||||
|
||||
## History of development
|
||||
|
||||
Development of this project dates back to 2019, and was developed under various umbrellas over time:
|
||||
* Cappuccino (Fist/Ando/Ellie42) ("The Erupe Developers"), 2019-2020 (https://github.com/Ellie42/Erupe / https://github.com/ricochhet/Erupe-Legacy) (Still active closed source)
|
||||
* Einherjar Team, ????-2022 Feb (There is no git history for this period, this team's work was taken and used as a foundation for future repositories)
|
||||
* Community Edition, 2022 (https://github.com/xl3lackout/Erupe)
|
||||
* sekaiwish Fork, 2022 (https://github.com/sekaiwish/Erupe)
|
||||
* ZeruLight, 2022-2023 (https://github.com/ZeruLight/Erupe)
|
||||
|
||||
### Cappuccino (Fist/Ando/Ellie42) - "The Erupe Developers" (2019-2020)
|
||||
|
||||
<https://github.com/Ellie42/Erupe> / <https://github.com/ricochhet/Erupe-Legacy>
|
||||
|
||||
**Initial proof-of-concept** and foundational work:
|
||||
|
||||
* Basic server infrastructure (Sign, Entrance, Channel servers)
|
||||
* Account registration and character creation systems
|
||||
* Initial multiplayer lobby functionality
|
||||
* Core network communication layer
|
||||
* Save data compression using delta/diff encoding
|
||||
* Stage management and reservation systems for multiplayer quests
|
||||
* Party system supporting up to 4 players
|
||||
* Chat system (local, party, private messaging)
|
||||
* Hunter Navi NPC interactions
|
||||
* Diva Defense feature
|
||||
* Quest selection and basic quest support
|
||||
* PostgreSQL database integration with migration support
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
* Repository created: March 6, 2020
|
||||
* Public commits: March 4-12, 2020 (9 days of visible development)
|
||||
* Total commits: 142
|
||||
* Status: Still active closed source
|
||||
|
||||
The original developers created this as an educational project to learn server emulation. This version established the fundamental architecture that all subsequent versions built upon.
|
||||
|
||||
### Einherjar Team (~2020-2022 Feb)
|
||||
|
||||
**Major expansion period** (estimated March 2020 - February 2022):
|
||||
|
||||
Unfortunately, **no public git history exists** for this critical development period. The Einherjar Team's work was used as the foundation for all subsequent community repositories. Based on features present in the Community Edition fork (February 2022) that weren't in the original Cappuccino version, the Einherjar Team likely implemented:
|
||||
|
||||
* Extensive quest system improvements
|
||||
* Guild system foundations
|
||||
* Economy and item distribution systems
|
||||
* Additional game mechanics and features
|
||||
* Stability improvements and bug fixes
|
||||
* Database schema expansions
|
||||
|
||||
This ~2-year period represents the largest gap in documented history. If anyone has information about this team's contributions, please contact the project maintainers.
|
||||
|
||||
### Community Edition (2022)
|
||||
|
||||
<https://github.com/xl3lackout/Erupe>
|
||||
|
||||
**Community-driven consolidation** (February 6 - August 7, 2022):
|
||||
|
||||
* Guild system enhancements:
|
||||
* Guild alliances support
|
||||
* Guild member management (Pugi renaming)
|
||||
* SJIS support for guild posts (Japanese characters)
|
||||
* Guild message boards
|
||||
* Character and account improvements:
|
||||
* Mail system with locking mechanism
|
||||
* Favorite quest persistence
|
||||
* Title/achievement enumeration
|
||||
* Character data handler rewrites
|
||||
* Game economy features:
|
||||
* Item distribution handling
|
||||
* Road Shop rotation system
|
||||
* Scenario counter tracking
|
||||
* Technical improvements:
|
||||
* Stage and semaphore overhaul
|
||||
* Discord bot integration with chat broadcasting
|
||||
* Error handling enhancements in launcher
|
||||
* Configuration improvements
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
* Repository created: February 6, 2022
|
||||
* Active development: May 11 - August 7, 2022 (3 months)
|
||||
* Total commits: 69
|
||||
* Contributors: Ando, Fists Team, the French Team, Mai's Team, and the MHFZ community
|
||||
|
||||
This version focused on making the server accessible to the broader community and implementing social/multiplayer features.
|
||||
|
||||
### ZeruLight / Mezeporta (2022-present)
|
||||
|
||||
<https://github.com/ZeruLight/Erupe> (now <https://github.com/Mezeporta/Erupe>)
|
||||
|
||||
**Major feature expansion and maturation** (March 24, 2022 - Present):
|
||||
|
||||
**Version 9.0.0 (August 2022)** - Major systems implementation:
|
||||
|
||||
* MezFes festival gameplay (singleplayer minigames)
|
||||
* Friends lists and block lists (blacklists)
|
||||
* Guild systems:
|
||||
* Guild Treasure Hunts
|
||||
* Guild Cooking system
|
||||
* Guild semaphore locking
|
||||
* Series Quests playability
|
||||
* My Series visits customization
|
||||
* Raviente rework (multiple simultaneous instances)
|
||||
* Stage system improvements
|
||||
* Currency point limitations
|
||||
|
||||
**Version 9.1.0 (November 2022)** - Internationalization:
|
||||
|
||||
* Multi-language support system (Japanese initially)
|
||||
* JP string support in broadcasts
|
||||
* Guild scout language support
|
||||
* Screenshot sharing support
|
||||
* New sign server implementation
|
||||
* Language-based chat command responses
|
||||
* Configuration restructuring
|
||||
|
||||
**Version 9.2.0 (April 2023)** - Gacha and advanced systems:
|
||||
|
||||
* Complete gacha system (box gacha, stepup gacha)
|
||||
* Multiple login notices
|
||||
* Daily quest allowance configuration
|
||||
* Gameplay options system
|
||||
* Feature weapon schema and generation
|
||||
* Gacha reward tracking and fulfillment
|
||||
* Koban my mission exchange
|
||||
* NetCafe course activation improvements
|
||||
* Guild meal enumeration and timers
|
||||
* Mail system improvements
|
||||
* Logging and broadcast function overhauls
|
||||
|
||||
**Unreleased/Current (2023-2025)** - Stability and quality improvements:
|
||||
|
||||
* Comprehensive production logging for all save operations
|
||||
* Session lifecycle tracking with metrics
|
||||
* Disconnect type tracking (graceful, connection_lost, error)
|
||||
* Critical race condition fixes in stage handlers
|
||||
* Deadlock fixes in zone changes
|
||||
* Save data corruption fixes
|
||||
* Transmog/plate data persistence fixes
|
||||
* Logout flow improvements preventing data loss
|
||||
* Config file handling improvements
|
||||
* Object ID allocation rework (per-session IDs, stage entry notification cleanup)
|
||||
* Security updates (golang dependencies)
|
||||
|
||||
**Technical Details:**
|
||||
|
||||
* Repository created: March 24, 2022
|
||||
* Latest activity: January 2025 (actively maintained)
|
||||
* Total commits: 1,295+
|
||||
* Contributors: 20+
|
||||
* Releases: 9 major releases
|
||||
* Multi-version support: Season 6.0 to ZZ
|
||||
* Multi-platform: PC, PS3, PS Vita, Wii U (up to Z2)
|
||||
|
||||
This version transformed Erupe from a proof-of-concept into a feature-complete, stable server emulator with extensive game system implementations and ongoing maintenance.
|
||||
|
||||
### sekaiwish Fork (2024)
|
||||
|
||||
<https://github.com/sekaiwish/Erupe>
|
||||
|
||||
**Recent fork** (November 10, 2024):
|
||||
|
||||
* Fork of Mezeporta/Erupe
|
||||
* Total commits: 1,260
|
||||
* Purpose and specific contributions: Unknown (recently created)
|
||||
|
||||
This is a recent fork and its specific goals or contributions are not yet documented.
|
||||
|
||||
## Authorship of the code
|
||||
Authorship is assigned for each commit within the git history, which is stored in these git repos:
|
||||
* https://github.com/ZeruLight/Erupe
|
||||
* https://github.com/Ellie42/Erupe
|
||||
* https://github.com/ricochhet/Erupe-Legacy
|
||||
* https://github.com/xl3lackout/Erupe
|
||||
|
||||
Note the divergence between Ellie42's branch and xl3lackout's where history has been lost.
|
||||
Authorship is assigned for each commit within the git history, which is stored in these git repos:
|
||||
|
||||
* <https://github.com/ZeruLight/Erupe>
|
||||
* <https://github.com/Ellie42/Erupe>
|
||||
* <https://github.com/ricochhet/Erupe-Legacy>
|
||||
* <https://github.com/xl3lackout/Erupe>
|
||||
|
||||
Note the divergence between Ellie42's branch and xl3lackout's where history has been lost.
|
||||
|
||||
Unfortunately, we have no detailed information on the history of Erupe before 2022.
|
||||
If somebody can provide information, please contact us, so that we can make this history available.
|
||||
If somebody can provide information, please contact us, so that we can make this history available.
|
||||
|
||||
## Exceptions with third-party libraries
|
||||
|
||||
The third-party libraries have their own way of addressing authorship and the authorship of commits importing/updating
|
||||
a third-party library reflects who did the importing instead of who wrote the code within the commit.
|
||||
|
||||
The authors of third-party libraries are not explicitly mentioned, and usually is possible to obtain from the files belonging to the third-party libraries.
|
||||
The authors of third-party libraries are not explicitly mentioned, and usually is possible to obtain from the files belonging to the third-party libraries.
|
||||
|
||||
361
CHANGELOG.md
Normal file
361
CHANGELOG.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Catch-up migration (`0002_catch_up_patches.sql`) for databases with partially-applied patch schemas — idempotent no-op on fresh or fully-patched databases, fills gaps for partial installations
|
||||
- Embedded auto-migrating database schema system (`server/migrations/`): the server binary now contains all SQL schemas and runs migrations automatically on startup — no more `pg_restore`, manual patch ordering, or external `schemas/` directory needed
|
||||
- Setup wizard: web-based first-run configuration at `http://localhost:8080` when `config.json` is missing — guides users through database connection, schema initialization, and server settings
|
||||
- CI: Coverage threshold enforcement — fails build if total coverage drops below 50%
|
||||
- CI: Release workflow that automatically builds and uploads Linux/Windows binaries to GitHub Releases on tag push
|
||||
- Monthly guild item claim tracking per character per type (standard/HLC/EXC), with schema migration (`31-monthly-items.sql`) adding claim timestamps to the `stamps` table
|
||||
- API: `GET /version` endpoint returning server name and client mode (`{"clientMode":"ZZ","name":"Erupe-CE"}`)
|
||||
- Rework object ID allocation: per-session IDs replace shared map, simplify stage entry notifications
|
||||
- Better config file handling and structure
|
||||
- Comprehensive production logging for save operations (warehouse, Koryo points, savedata, Hunter Navi, plate equipment)
|
||||
- Disconnect type tracking (graceful, connection_lost, error) with detailed logging
|
||||
- Session lifecycle logging with duration and metrics tracking
|
||||
- Structured logging with timing metrics for all database save operations
|
||||
- Plate data (transmog) safety net in logout flow - adds monitoring checkpoint for platedata, platebox, and platemyset persistence
|
||||
- Unit tests for `handlers_data_paper.go`: 20 tests covering all DataType branches, ACK payload structure, serialization round-trips, and paperGiftData table integrity
|
||||
|
||||
### Changed
|
||||
|
||||
- Schema management consolidated: replaced 4 independent code paths (Docker shell script, setup wizard, test helpers, manual psql) with a single embedded migration runner
|
||||
- Setup wizard simplified: 3 schema checkboxes replaced with single "Apply database schema" checkbox
|
||||
- Docker simplified: removed schema volume mounts and init script — the server binary handles everything
|
||||
- Test helpers simplified: `ApplyTestSchema` now uses the migration runner instead of `pg_restore` + manual patch application
|
||||
- Updated minimum Go version requirement from 1.23 to 1.25
|
||||
- Improved config handling
|
||||
- Refactored logout flow to save all data before cleanup (prevents data loss race conditions)
|
||||
- Unified save operation into single `saveAllCharacterData()` function with proper error handling
|
||||
- Removed duplicate save calls in `logoutPlayer()` function
|
||||
|
||||
### Fixed
|
||||
|
||||
- Config file handling and validation
|
||||
- Fixes 3 critical race condition in handlers_stage.go.
|
||||
- Fix an issue causing a crash on clans with 0 members.
|
||||
- Fixed deadlock in zone change causing 60-second timeout when players change zones
|
||||
- Fixed crash when sending empty packets in QueueSend/QueueSendNonBlocking
|
||||
- Fixed missing stage transfer packet for empty zones
|
||||
- Fixed save data corruption check rejecting valid saves due to name encoding mismatches (SJIS/UTF-8)
|
||||
- Fixed incomplete saves during logout - character savedata now persisted even during ungraceful disconnects
|
||||
- Fixed double-save bug in logout flow that caused unnecessary database operations
|
||||
- Fixed save operation ordering - now saves data before session cleanup instead of after
|
||||
- Fixed stale transmog/armor appearance shown to other players - user binary cache now invalidated when plate data is saved
|
||||
- Fixed client crash when quest or scenario files are missing - now sends failure ack instead of nil data
|
||||
- Fixed server crash when Discord relay receives messages with unsupported Shift-JIS characters (emoji, Lenny faces, cuneiform, etc.)
|
||||
- Fixed data race in token.RNG global used concurrently across goroutines
|
||||
|
||||
### Security
|
||||
|
||||
- Bumped golang.org/x/net from 0.33.0 to 0.38.0
|
||||
- Bumped golang.org/x/crypto from 0.31.0 to 0.35.0
|
||||
|
||||
## Removed
|
||||
|
||||
- Compatibility with Go 1.21 removed.
|
||||
|
||||
## [9.2.0] - 2023-04-01
|
||||
|
||||
### Added in 9.2.0
|
||||
|
||||
- Gacha system with box gacha and stepup gacha support
|
||||
- Multiple login notices support
|
||||
- Daily quest allowance configuration
|
||||
- Gameplay options system
|
||||
- Support for stepping stone gacha rewards
|
||||
- Guild semaphore locking mechanism
|
||||
- Feature weapon schema and generation system
|
||||
- Gacha reward tracking and fulfillment
|
||||
- Koban my mission exchange for gacha
|
||||
|
||||
### Changed in 9.2.0
|
||||
|
||||
- Reworked logging code and syntax
|
||||
- Reworked broadcast functions
|
||||
- Reworked netcafe course activation
|
||||
- Reworked command responses for JP chat
|
||||
- Refactored guild message board code
|
||||
- Separated out gacha function code
|
||||
- Rearranged gacha functions
|
||||
- Updated golang dependencies
|
||||
- Made various handlers non-fatal errors
|
||||
- Moved various packet handlers
|
||||
- Moved caravan event handlers
|
||||
- Enhanced feature weapon RNG
|
||||
|
||||
### Fixed in 9.2.0
|
||||
|
||||
- Mail item workaround removed (replaced with proper implementation)
|
||||
- Possible infinite loop in gacha rolls
|
||||
- Feature weapon RNG and generation
|
||||
- Feature weapon times and return expiry
|
||||
- Netcafe timestamp handling
|
||||
- Guild meal enumeration and timer
|
||||
- Guild message board enumerating too many posts
|
||||
- Gacha koban my mission exchange
|
||||
- Gacha rolling and reward handling
|
||||
- Gacha enumeration recommendation tag
|
||||
- Login boost creating hanging connections
|
||||
- Shop-db schema issues
|
||||
- Scout enumeration data
|
||||
- Missing primary key in schema
|
||||
- Time fixes and initialization
|
||||
- Concurrent stage map write issue
|
||||
- Nil savedata errors on logout
|
||||
- Patch schema inconsistencies
|
||||
- Edge cases in rights integer handling
|
||||
- Missing period in broadcast strings
|
||||
|
||||
### Removed in 9.2.0
|
||||
|
||||
- Unused database tables
|
||||
- Obsolete LauncherServer code
|
||||
- Unused code from gacha functionality
|
||||
- Mail item workaround (replaced with proper implementation)
|
||||
|
||||
### Security in 9.2.0
|
||||
|
||||
- Escaped database connection arguments
|
||||
|
||||
## [9.1.1] - 2022-11-10
|
||||
|
||||
### Changed in 9.1.1
|
||||
|
||||
- Temporarily reverted versioning system
|
||||
- Fixed netcafe time reset behavior
|
||||
|
||||
## [9.1.0] - 2022-11-04
|
||||
|
||||
### Added in 9.1.0
|
||||
|
||||
- Multi-language support system
|
||||
- Support for JP strings in broadcasts
|
||||
- Guild scout language support
|
||||
- Screenshot sharing support
|
||||
- New sign server implementation
|
||||
- Multi-language string mappings
|
||||
- Language-based chat command responses
|
||||
|
||||
### Changed in 9.1.0
|
||||
|
||||
- Rearranged configuration options
|
||||
- Converted token to library
|
||||
- Renamed sign server
|
||||
- Mapped language to server instead of session
|
||||
|
||||
### Fixed in 9.1.0
|
||||
|
||||
- Various packet responses
|
||||
|
||||
## [9.1.0-rc3] - 2022-11-02
|
||||
|
||||
### Fixed in 9.1.0-rc3
|
||||
|
||||
- Prevented invalid bitfield issues
|
||||
|
||||
## [9.1.0-rc2] - 2022-10-28
|
||||
|
||||
### Changed in 9.1.0-rc2
|
||||
|
||||
- Set default featured weapons to 1
|
||||
|
||||
## [9.1.0-rc1] - 2022-10-24
|
||||
|
||||
### Removed in 9.1.0-rc1
|
||||
|
||||
- Migrations directory
|
||||
|
||||
## [9.0.1] - 2022-08-04
|
||||
|
||||
### Changed in 9.0.1
|
||||
|
||||
- Updated login notice
|
||||
|
||||
## [9.0.0] - 2022-08-03
|
||||
|
||||
### Fixed in 9.0.0
|
||||
|
||||
- Fixed readlocked channels issue
|
||||
- Prevent rp logs being nil
|
||||
- Prevent applicants from receiving message board notifications
|
||||
|
||||
### Added in 9.0.0
|
||||
|
||||
- Implement guild semaphore locking
|
||||
- Support for more courses
|
||||
- Option to flag corruption attempted saves as deleted
|
||||
- Point limitations for currency
|
||||
|
||||
---
|
||||
|
||||
## Pre-9.0.0 Development (2022-02-25 to 2022-08-03)
|
||||
|
||||
The period before version 9.0.0 represents the early community development phase, starting with the Community Edition reupload and continuing through multiple feature additions leading up to the first semantic versioning release.
|
||||
|
||||
### [Pre-release] - 2022-06-01 to 2022-08-03
|
||||
|
||||
Major feature implementations leading to 9.0.0:
|
||||
|
||||
#### Added (June-August 2022)
|
||||
|
||||
- **Friend System**: Friend list functionality with cross-character enumeration
|
||||
- **Blacklist System**: Player blocking functionality
|
||||
- **My Series System**: Basic My Series functionality with shared data and bookshelf support
|
||||
- **Guild Treasure Hunts**: Complete guild treasure hunting system with cooldowns
|
||||
- **House System**:
|
||||
- House interior updates and furniture loading
|
||||
- House entry handling improvements
|
||||
- Visit other players' houses with correct furniture display
|
||||
- **Festa System**:
|
||||
- Initial Festa build and decoding
|
||||
- Canned Festa prizes implementation
|
||||
- Festa finale acquisition handling
|
||||
- Festa info and packet handling improvements
|
||||
- **Achievement System**: Hunting career achievements concept implementation
|
||||
- **Object System**:
|
||||
- Object indexing (v3, v3.1)
|
||||
- Semaphore indexes
|
||||
- Object index limits and reuse prevention
|
||||
- **Transit Message**: Correct parsing of transit messages for minigames
|
||||
- **World Chat**: Enabled world chat functionality
|
||||
- **Rights System**: Rights command and permission updates on login
|
||||
- **Customizable Login Notice**: Support for custom login notices
|
||||
|
||||
#### Changed (June-August 2022)
|
||||
|
||||
- **Stage System**: Major stage rework and improvements
|
||||
- **Raviente System**: Cleanup, fixes, and announcement improvements
|
||||
- **Discord Integration**: Mediated Discord handling improvements
|
||||
- **Server Logging**: Improved server logging throughout
|
||||
- **Configuration**: Edited default configs
|
||||
- **Repository**: Extensive repository cleanup
|
||||
- **Build System**: Implemented build actions and artifact generation
|
||||
|
||||
#### Fixed (June-August 2022)
|
||||
|
||||
- Critical semaphore bug fixes
|
||||
- Raviente-related fixes and cleanup
|
||||
- Read-locked channels issue
|
||||
- Stubbed title enumeration
|
||||
- Object index reuse prevention
|
||||
- Crash when not in guild on logout
|
||||
- Invalid schema issues
|
||||
- Stage enumeration crash prevention
|
||||
- Gook (book) enumeration and cleanup
|
||||
- Guild SQL fixes
|
||||
- Various packet parsing improvements
|
||||
- Semaphore checking changes
|
||||
- User insertion not broadcasting
|
||||
|
||||
### [Pre-release] - 2022-05-01 to 2022-06-01
|
||||
|
||||
Guild system enhancements and social features:
|
||||
|
||||
#### Added (May-June 2022)
|
||||
|
||||
- **Guild Features**:
|
||||
- Guild alliance support with complete implementation
|
||||
- Guild member (Pugi) management and renaming
|
||||
- Guild post SJIS (Japanese) character encoding support
|
||||
- Guild message board functionality
|
||||
- Guild meal system
|
||||
- Diva Hall adventure cat support
|
||||
- Guild adventure cat implementation
|
||||
- Alliance members included in guild member enumeration
|
||||
- **Character System**:
|
||||
- Mail locking mechanism
|
||||
- Favorite quest save/load functionality
|
||||
- Title/achievement enumeration parsing
|
||||
- Character data handler rewrite
|
||||
- **Game Features**:
|
||||
- Item distribution handling system
|
||||
- Road Shop weekly rotation
|
||||
- Scenario counter implementation
|
||||
- Diva adventure dispatch parsing
|
||||
- House interior query support
|
||||
- Entrance and sign server response improvements
|
||||
- **Launcher**:
|
||||
- Discord bot integration with configurable channels and dev roles
|
||||
- Launcher error handling improvements
|
||||
- Launcher finalization with modal, news, menu, safety links
|
||||
- Auto character addition
|
||||
- Variable centered text support
|
||||
- Last login timestamp updates
|
||||
|
||||
#### Changed (May-June 2022)
|
||||
|
||||
- Stage and semaphore overhaul with improved casting handling
|
||||
- Simplified guild handler code
|
||||
- String support improvements with PascalString helpers
|
||||
- Byte frame converted to local package
|
||||
- Local package conversions (byteframe, pascalstring)
|
||||
|
||||
#### Fixed (May-June 2022)
|
||||
|
||||
- SJIS guild post support
|
||||
- Nil guild failsafes
|
||||
- SQL queries with missing counter functionality
|
||||
- Enumerate airoulist parsing
|
||||
- Mail item description crashes
|
||||
- Ambiguous mail query
|
||||
- Last character updates
|
||||
- Compatibility issues
|
||||
- Various packet files
|
||||
|
||||
### [Pre-release] - 2022-02-25 to 2022-05-01
|
||||
|
||||
Initial Community Edition and foundational work:
|
||||
|
||||
#### Added (February-May 2022)
|
||||
|
||||
- **Core Systems**:
|
||||
- Japanese Shift-JIS character name support
|
||||
- Character creation with automatic addition
|
||||
- Raviente system patches
|
||||
- Diva reward handling
|
||||
- Conquest quest support
|
||||
- Quest clear timer
|
||||
- Garden cat/shared account box implementation
|
||||
- **Guild Features**:
|
||||
- Guild hall available on creation
|
||||
- Unlocked all street titles
|
||||
- Guild schema corrections
|
||||
- **Launcher**:
|
||||
- Complete launcher implementation
|
||||
- Modal dialogs
|
||||
- News system
|
||||
- Menu and safety links
|
||||
- Button functionality
|
||||
- Caching system
|
||||
|
||||
#### Changed (February-May 2022)
|
||||
|
||||
- Save compression updates
|
||||
- Migration folder moved to root
|
||||
- Improved launcher code structure
|
||||
|
||||
#### Fixed (February-May 2022)
|
||||
|
||||
- Mercenary/cat handler fixes
|
||||
- Error code 10054 (savedata directory creation)
|
||||
- Conflicts resolution
|
||||
- Various syntax corrections
|
||||
|
||||
---
|
||||
|
||||
## Historical Context
|
||||
|
||||
This changelog documents all known changes from the Community Edition reupload (February 25, 2022) onwards. The period before this (Einherjar Team era, ~2020-2022) has no public git history.
|
||||
|
||||
Earlier development by Cappuccino/Ellie42 (March 2020) focused on basic server infrastructure, multiplayer systems, and core functionality. See [AUTHORS.md](AUTHORS.md) for detailed development history.
|
||||
|
||||
The project began following semantic versioning with v9.0.0 (August 3, 2022) and maintains tagged releases for stable versions. Development continues on the main branch with features merged from feature branches.
|
||||
159
CLAUDE.md
Normal file
159
CLAUDE.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Erupe is a Go server emulator for Monster Hunter Frontier, a shut-down MMORPG. It handles authentication, world selection, and gameplay in a single binary running four TCP/HTTP servers. Go 1.25+ required.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
go build -o erupe-ce # Build server
|
||||
go build -o protbot ./cmd/protbot/ # Build protocol bot
|
||||
go test -race ./... -timeout=10m # Run tests (race detection mandatory)
|
||||
go test -v ./server/channelserver/... # Test one package
|
||||
go test -run TestHandleMsg ./server/channelserver/... # Single test
|
||||
go test -coverprofile=coverage.out ./... && go tool cover -func=coverage.out # Coverage (CI requires ≥50%)
|
||||
gofmt -w . # Format
|
||||
golangci-lint run ./... # Lint (v2 standard preset, must pass CI)
|
||||
```
|
||||
|
||||
Docker (from `docker/`):
|
||||
```bash
|
||||
docker compose up db pgadmin # PostgreSQL + pgAdmin (port 5050)
|
||||
docker compose up server # Erupe (after DB is healthy)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Four-Server Model (single binary, orchestrated from `main.go`)
|
||||
|
||||
```
|
||||
Client ←[Blowfish TCP]→ Sign Server (53312) → Authentication, sessions
|
||||
→ Entrance Server (53310) → Server list, character select
|
||||
→ Channel Servers (54001+) → Gameplay, quests, multiplayer
|
||||
→ API Server (8080) → REST API (/health, /version, V2 sign)
|
||||
```
|
||||
|
||||
Each server is in its own package under `server/`. The channel server is by far the largest (~200 files).
|
||||
|
||||
### Channel Server Packet Flow
|
||||
|
||||
1. `network/crypt_conn.go` decrypts TCP stream (Blowfish)
|
||||
2. `network/mhfpacket/` deserializes binary packet into typed struct (~453 packet types, one file each)
|
||||
3. `handlers_table.go` dispatches via `buildHandlerTable()` (~200+ `PacketID → handlerFunc` entries)
|
||||
4. Handler in appropriate `handlers_*.go` processes it (organized by game system)
|
||||
|
||||
Handler signature: `func(s *Session, p mhfpacket.MHFPacket)`
|
||||
|
||||
### Layered Architecture
|
||||
|
||||
```
|
||||
handlers_*.go → svc_*.go (service layer) → repo_*.go (data access)
|
||||
(where needed) ↓
|
||||
repo_interfaces.go (21 interfaces)
|
||||
↓
|
||||
repo_mocks_test.go (test doubles)
|
||||
```
|
||||
|
||||
- **Handlers**: Parse packets, call services or repos, build responses. Must always send ACK (see Error Handling below). Simple CRUD operations call repos directly; multi-step or cross-repo logic goes through services.
|
||||
- **Services**: Encapsulate business logic that spans multiple repos or requires orchestration beyond simple CRUD. Not a mandatory pass-through — handlers call repos directly for straightforward data access.
|
||||
- **Repositories**: All SQL lives in `repo_*.go` files behind interfaces in `repo_interfaces.go`. The `Server` struct holds interface types, not concrete implementations. Handler code must never contain inline SQL.
|
||||
- **Sign server** has its own repo pattern: 3 interfaces in `server/signserver/repo_interfaces.go`.
|
||||
|
||||
#### Services
|
||||
|
||||
| Service | File | Methods | Purpose |
|
||||
|---------|------|---------|---------|
|
||||
| `GuildService` | `svc_guild.go` | 6 | Member operations, disband, resign, leave, scout — triggers cross-repo mail |
|
||||
| `MailService` | `svc_mail.go` | 4 | Send/broadcast mail with message type routing |
|
||||
| `GachaService` | `svc_gacha.go` | 6 | Gacha rolls (normal/stepup/box), point transactions, reward resolution |
|
||||
| `AchievementService` | `svc_achievement.go` | 2 | Achievement fetch with score computation, increment |
|
||||
| `TowerService` | `svc_tower.go` | 3 | Tower gem management, tenrourai progress capping, guild RP donation |
|
||||
| `FestaService` | `svc_festa.go` | 2 | Event lifecycle (expiry/cleanup/creation), soul submission filtering |
|
||||
|
||||
Each service takes repo interfaces + `*zap.Logger` in its constructor, making it testable with mocks. Tests live in `svc_*_test.go` files alongside the service.
|
||||
|
||||
### Key Subsystems
|
||||
|
||||
| File(s) | Purpose |
|
||||
|---------|---------|
|
||||
| `sys_session.go` | Per-connection state: character, stage, semaphores, send queue |
|
||||
| `sys_stage.go` | `StageMap` (`sync.Map`-backed), multiplayer rooms/lobbies |
|
||||
| `sys_channel_server.go` | Server lifecycle, Raviente shared state, world management |
|
||||
| `sys_semaphore.go` | Distributed locks for events (Raviente siege, guild ops) |
|
||||
| `channel_registry.go` | Cross-channel operations (worldcast, session lookup, mail) |
|
||||
| `handlers_cast_binary.go` | Binary state relay between clients (position, animation) |
|
||||
| `handlers_helpers.go` | `loadCharacterData`/`saveCharacterData` shared helpers |
|
||||
| `guild_model.go` | Guild data structures |
|
||||
|
||||
### Binary Serialization
|
||||
|
||||
`common/byteframe.ByteFrame` — sequential big-endian reads/writes with sticky error pattern (`bf.Err()`). Used for all packet parsing, response building, and save data manipulation. Use `encoding/binary` only for random-access reads at computed offsets on existing `[]byte` slices.
|
||||
|
||||
### Database
|
||||
|
||||
PostgreSQL with embedded auto-migrating schema in `server/migrations/`:
|
||||
- `sql/0001_init.sql` — consolidated baseline
|
||||
- `seed/*.sql` — demo data (applied via `migrations.ApplySeedData()` on fresh DB)
|
||||
- New migrations: `sql/0002_description.sql`, etc. (each runs in its own transaction)
|
||||
|
||||
The server runs `migrations.Migrate()` automatically on startup.
|
||||
|
||||
### Configuration
|
||||
|
||||
Two reference files: `config.example.json` (minimal) and `config.reference.json` (all options). Loaded via Viper in `config/config.go`. All defaults registered in code. Supports 40 client versions (S1.0 → ZZ) via `ClientMode`. If `config.json` is missing, an interactive setup wizard launches at `http://localhost:8080`.
|
||||
|
||||
### Protocol Bot (`cmd/protbot/`)
|
||||
|
||||
Headless MHF client implementing the complete sign → entrance → channel flow. Shares `common/` and `network/crypto` but avoids `config` dependency via its own `conn/` package.
|
||||
|
||||
## Concurrency
|
||||
|
||||
Lock ordering: `Server.Mutex → Stage.RWMutex → semaphoreLock`. Stage map uses `sync.Map`; individual `Stage` structs have `sync.RWMutex`. Cross-channel operations go exclusively through `ChannelRegistry` — never access other servers' state directly.
|
||||
|
||||
## Error Handling in Handlers
|
||||
|
||||
The MHF client expects `MsgSysAck` for most requests. Missing ACKs cause client softlocks. On error paths, always send `doAckBufFail`/`doAckSimpleFail` before returning.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Mock repos**: Handler tests use `repo_mocks_test.go` — no database needed
|
||||
- **Table-driven tests**: Standard pattern (see `handlers_achievement_test.go`)
|
||||
- **Race detection**: `go test -race` is mandatory in CI
|
||||
- **Coverage floor**: CI enforces ≥50% total coverage
|
||||
|
||||
## Adding a New Packet
|
||||
|
||||
1. Define struct in `network/mhfpacket/msg_*.go` (implements `MHFPacket` interface: `Parse`, `Build`, `Opcode`)
|
||||
2. Add packet ID constant in `network/packetid.go`
|
||||
3. Register handler in `server/channelserver/handlers_table.go`
|
||||
4. Implement handler in appropriate `handlers_*.go` file
|
||||
|
||||
## Adding a Database Query
|
||||
|
||||
1. Add method signature to the relevant interface in `repo_interfaces.go`
|
||||
2. Implement in the corresponding `repo_*.go` file
|
||||
3. Add mock implementation in `repo_mocks_test.go`
|
||||
|
||||
## Adding Business Logic
|
||||
|
||||
If the new logic involves multi-step orchestration, cross-repo coordination, or non-trivial data transformation:
|
||||
|
||||
1. Add or extend a service in the appropriate `svc_*.go` file
|
||||
2. Wire it in `sys_channel_server.go` (constructor + field on `Server` struct)
|
||||
3. Add tests in `svc_*_test.go` using mock repos
|
||||
4. Call the service from the handler instead of the repo directly
|
||||
|
||||
Simple CRUD operations should stay as direct repo calls from handlers — not everything needs a service.
|
||||
|
||||
## Known Issues
|
||||
|
||||
See `docs/anti-patterns.md` for structural patterns and `docs/technical-debt.md` for specific fixable items with file paths and line numbers.
|
||||
|
||||
## Contributing
|
||||
|
||||
- Branch naming: `feature/`, `fix/`, `refactor/`, `docs/`
|
||||
- Commit messages: conventional commits (`feat:`, `fix:`, `refactor:`, `docs:`)
|
||||
- Update `CHANGELOG.md` under "Unreleased" for all changes
|
||||
234
CONTRIBUTING.md
Normal file
234
CONTRIBUTING.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Contributing to Erupe
|
||||
|
||||
Thank you for your interest in contributing to Erupe! This guide will help you get started.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Go 1.25+](https://go.dev/dl/)
|
||||
- [PostgreSQL](https://www.postgresql.org/download/)
|
||||
- Git
|
||||
|
||||
### Setting Up Your Development Environment
|
||||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Erupe.git
|
||||
cd Erupe
|
||||
```
|
||||
|
||||
3. Set up the database following the [Installation guide](README.md#installation)
|
||||
4. Copy `config.example.json` to `config.json` and set your database password (see `config.reference.json` for all available options)
|
||||
5. Install dependencies:
|
||||
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
6. Build and run:
|
||||
|
||||
```bash
|
||||
go build
|
||||
./erupe-ce
|
||||
```
|
||||
|
||||
## Code Contribution Workflow
|
||||
|
||||
1. **Create a branch** for your changes:
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
Use descriptive branch names:
|
||||
- `feature/` for new features
|
||||
- `fix/` for bug fixes
|
||||
- `refactor/` for code refactoring
|
||||
- `docs/` for documentation changes
|
||||
|
||||
2. **Make your changes** and commit them with clear, descriptive messages:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add new quest loading system"
|
||||
git commit -m "fix: resolve database connection timeout"
|
||||
git commit -m "docs: update configuration examples"
|
||||
```
|
||||
|
||||
3. **Test your changes** (see [Testing Requirements](#testing-requirements))
|
||||
|
||||
4. **Push to your fork**:
|
||||
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
5. **Create a Pull Request** on GitHub with:
|
||||
- Clear description of what changes you made
|
||||
- Why the changes are needed
|
||||
- Any related issue numbers
|
||||
|
||||
6. **Respond to code review feedback** promptly
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### Go Style
|
||||
|
||||
- Run `gofmt` before committing:
|
||||
|
||||
```bash
|
||||
gofmt -w .
|
||||
```
|
||||
|
||||
- Use `golangci-lint` for linting:
|
||||
|
||||
```bash
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
- Follow standard Go naming conventions
|
||||
- Keep functions focused and reasonably sized
|
||||
- Add comments for exported functions and complex logic
|
||||
- Handle errors explicitly (don't ignore them)
|
||||
|
||||
### Code Organization
|
||||
|
||||
- Place new handlers in appropriate files under `server/channelserver/`
|
||||
- Keep database queries in structured locations
|
||||
- Use the existing pattern for message handlers
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
Before submitting a pull request:
|
||||
|
||||
1. **Run all tests**:
|
||||
|
||||
```bash
|
||||
go test -v ./...
|
||||
```
|
||||
|
||||
2. **Check for race conditions**:
|
||||
|
||||
```bash
|
||||
go test -v -race ./...
|
||||
```
|
||||
|
||||
3. **Ensure your code has adequate test coverage**:
|
||||
|
||||
```bash
|
||||
go test -v -cover ./...
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
- Add tests for new features in `*_test.go` files
|
||||
- Test edge cases and error conditions
|
||||
- Use table-driven tests for multiple scenarios
|
||||
- Mock external dependencies where appropriate
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
func TestYourFunction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input int
|
||||
want int
|
||||
}{
|
||||
{"basic case", 1, 2},
|
||||
{"edge case", 0, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := YourFunction(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema Changes
|
||||
|
||||
Erupe uses an embedded auto-migrating schema system in `server/migrations/`.
|
||||
|
||||
When adding schema changes:
|
||||
|
||||
1. Create a new file in `server/migrations/sql/` with format: `NNNN_description.sql` (e.g. `0002_add_new_table.sql`)
|
||||
2. Increment the number from the last migration
|
||||
3. Test the migration on both a fresh and existing database
|
||||
4. Document what the migration does in SQL comments
|
||||
|
||||
Migrations run automatically on startup in order. Each runs in its own transaction and is tracked in the `schema_version` table.
|
||||
|
||||
For seed/demo data (shops, events, gacha), add files to `server/migrations/seed/`. Seed data is applied automatically on fresh databases and can be re-applied via the setup wizard.
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
### Always Update
|
||||
|
||||
- **[CHANGELOG.md](CHANGELOG.md)**: Document your changes under "Unreleased" section
|
||||
- Use categories: Added, Changed, Fixed, Removed, Security
|
||||
- Be specific about what changed and why
|
||||
|
||||
### When Applicable
|
||||
|
||||
- **[README.md](README.md)**: Update if you change:
|
||||
- Installation steps
|
||||
- Configuration options
|
||||
- Requirements
|
||||
- Usage instructions
|
||||
|
||||
- **Code Comments**: Add or update comments for:
|
||||
- Exported functions and types
|
||||
- Complex algorithms
|
||||
- Non-obvious business logic
|
||||
- Packet structures and handling
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Questions and Discussion
|
||||
|
||||
- **[Mogapedia's Discord](https://discord.gg/f77VwBX5w7)**: Active development discussions
|
||||
- **[Mezeporta Square Discord](https://discord.gg/DnwcpXM488)**: Community support
|
||||
- **GitHub Issues**: For bug reports and feature requests
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
When filing a bug report, include:
|
||||
|
||||
1. **Erupe version** (git commit hash or release version)
|
||||
2. **Client version** (ClientMode setting)
|
||||
3. **Go version**: `go version`
|
||||
4. **PostgreSQL version**: `psql --version`
|
||||
5. **Steps to reproduce** the issue
|
||||
6. **Expected behavior** vs actual behavior
|
||||
7. **Relevant logs** (enable debug logging if needed)
|
||||
8. **Configuration** (sanitize passwords!)
|
||||
|
||||
### Requesting Features
|
||||
|
||||
For feature requests:
|
||||
|
||||
1. Check existing issues first
|
||||
2. Describe the feature and its use case
|
||||
3. Explain why it would benefit the project
|
||||
4. Be open to discussion about implementation
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
- Be respectful and constructive
|
||||
- Welcome newcomers and help them learn
|
||||
- Focus on the code, not the person
|
||||
- Assume good intentions
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Erupe, you agree that your contributions will be licensed under the same license as the project.
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to Erupe!
|
||||
32
Dockerfile
32
Dockerfile
@@ -1,14 +1,30 @@
|
||||
FROM golang:1.21-alpine3.19
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine3.21 AS builder
|
||||
|
||||
ENV GO111MODULE=on
|
||||
|
||||
WORKDIR /app/erupe
|
||||
|
||||
COPY go.mod .
|
||||
COPY go.sum .
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o erupe-ce .
|
||||
|
||||
CMD [ "go", "run", "." ]
|
||||
# Runtime stage
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN adduser -D -h /app erupe
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/erupe-ce .
|
||||
|
||||
# www/ and bin/ are mounted at runtime if needed
|
||||
|
||||
# bin/ and savedata/ are mounted at runtime via docker-compose
|
||||
# config.json is also mounted at runtime
|
||||
|
||||
USER erupe
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:8080/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./erupe-ce"]
|
||||
|
||||
286
README.md
286
README.md
@@ -1,52 +1,282 @@
|
||||
# Erupe
|
||||
|
||||
[](https://github.com/Mezeporta/Erupe/actions/workflows/go.yml)
|
||||
[](https://github.com/Mezeporta/Erupe/actions/workflows/github-code-scanning/codeql)
|
||||
[](https://go.dev/)
|
||||
[](https://github.com/Mezeporta/Erupe/releases/latest)
|
||||
|
||||
Erupe is a community-maintained server emulator for Monster Hunter Frontier written in Go. It is a complete reverse-engineered solution to self-host a Monster Hunter Frontier server, using no code from Capcom.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Pick one of three installation methods, then continue to [Quest & Scenario Files](#quest--scenario-files).
|
||||
|
||||
### Option A: Docker (recommended)
|
||||
|
||||
Docker handles the database automatically. You only need to provide quest files and a config.
|
||||
|
||||
1. Clone the repository and enter the Docker directory:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Mezeporta/Erupe.git
|
||||
cd Erupe
|
||||
```
|
||||
|
||||
2. Copy and edit the config (set your database password to match `docker-compose.yml`):
|
||||
|
||||
```bash
|
||||
cp config.example.json docker/config.json
|
||||
# Edit docker/config.json — set Database.Host to "db"
|
||||
```
|
||||
|
||||
3. Download [quest/scenario files](#quest--scenario-files) and extract them to `docker/bin/`
|
||||
|
||||
4. Start everything:
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose up
|
||||
```
|
||||
|
||||
pgAdmin is available at `http://localhost:5050` for database management.
|
||||
|
||||
See [docker/README.md](./docker/README.md) for more details (local builds, troubleshooting).
|
||||
|
||||
### Option B: Pre-compiled Binary
|
||||
|
||||
1. Download the latest release for your platform from [GitHub Releases](https://github.com/Mezeporta/Erupe/releases/latest):
|
||||
- `erupe-ce` for Linux
|
||||
- `erupe.exe` for Windows
|
||||
|
||||
2. Set up PostgreSQL and create a database:
|
||||
|
||||
```bash
|
||||
createdb -U postgres erupe
|
||||
```
|
||||
|
||||
The server will automatically apply all schema migrations on first startup.
|
||||
|
||||
3. Copy and edit the config:
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
# Edit config.json with your database credentials
|
||||
```
|
||||
|
||||
4. Download [quest/scenario files](#quest--scenario-files) and extract them to `bin/`
|
||||
|
||||
5. Run: `./erupe-ce`
|
||||
|
||||
### Option C: From Source
|
||||
|
||||
Requires [Go 1.25+](https://go.dev/dl/) and [PostgreSQL](https://www.postgresql.org/download/).
|
||||
|
||||
1. Clone and build:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Mezeporta/Erupe.git
|
||||
cd Erupe
|
||||
go mod download
|
||||
go build -o erupe-ce
|
||||
```
|
||||
|
||||
2. Set up the database (same as Option B, steps 2–3)
|
||||
|
||||
3. Copy and edit the config:
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
4. Download [quest/scenario files](#quest--scenario-files) and extract them to `bin/`
|
||||
|
||||
5. Run: `./erupe-ce`
|
||||
|
||||
## Quest & Scenario Files
|
||||
|
||||
**Download**: [Quest and Scenario Binary Files](https://files.catbox.moe/xf0l7w.7z)
|
||||
|
||||
These files contain quest definitions and scenario data that the server sends to clients during gameplay. Extract the archive into your `bin/` directory (or `docker/bin/` for Docker installs). The path must match the `BinPath` setting in your config (default: `"bin"`).
|
||||
|
||||
**Without these files, quests will not load and the client will crash.**
|
||||
|
||||
## Client Setup
|
||||
|
||||
1. Obtain a Monster Hunter Frontier client (version G10 or later recommended)
|
||||
2. Point the client to your server by editing `host.txt` or using a launcher to redirect to your server's IP
|
||||
3. Launch `mhf.exe`, select your server, and create an account
|
||||
|
||||
If you have an **installed** copy of Monster Hunter Frontier on an old hard drive, **please** get in contact so we can archive it!
|
||||
|
||||
## Updating
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
go mod tidy
|
||||
go build -o erupe-ce
|
||||
```
|
||||
|
||||
Database schema migrations are applied automatically when the server starts — no manual SQL steps needed.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose down
|
||||
docker compose build
|
||||
docker compose up
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `config.json` before starting the server. The essential settings are:
|
||||
|
||||
```json
|
||||
{
|
||||
"Host": "127.0.0.1",
|
||||
"BinPath": "bin",
|
||||
"Language": "en",
|
||||
"ClientMode": "ZZ",
|
||||
"Database": {
|
||||
"Host": "localhost",
|
||||
"Port": 5432,
|
||||
"User": "postgres",
|
||||
"Password": "your_password",
|
||||
"Database": "erupe"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| `Host` | IP advertised to clients. Use `127.0.0.1` for local play, your LAN/WAN IP for remote. Leave blank in config to auto-detect |
|
||||
| `ClientMode` | Target client version (`ZZ`, `G10`, `Forward4`, etc.) |
|
||||
| `BinPath` | Path to quest/scenario files |
|
||||
| `Language` | `"en"` or `"jp"` |
|
||||
|
||||
`config.example.json` is intentionally minimal — all other settings have sane defaults built into the server. For the full configuration reference (gameplay multipliers, debug options, Discord integration, in-game commands, entrance/channel definitions), see [config.reference.json](./config.reference.json) and the [Erupe Wiki](https://github.com/Mezeporta/Erupe/wiki).
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-version Support**: Compatible with all Monster Hunter Frontier versions from Season 6.0 to ZZ
|
||||
- **Multi-platform**: Supports PC, PlayStation 3, PlayStation Vita, and Wii U (up to Z2)
|
||||
- **Complete Server Emulation**: Entry/Sign server, Channel server, and Launcher server
|
||||
- **Gameplay Customization**: Configurable multipliers for experience, currency, and materials
|
||||
- **Event Systems**: Support for Raviente, MezFes, Diva, Festa, and Tournament events
|
||||
- **Discord Integration**: Optional real-time Discord bot integration
|
||||
- **In-game Commands**: Extensible command system with configurable prefixes
|
||||
- **Developer Tools**: Comprehensive logging, packet debugging, and save data dumps
|
||||
|
||||
## Architecture
|
||||
|
||||
Erupe consists of three main server components:
|
||||
|
||||
- **Sign Server** (Port 53312): Handles authentication and account management
|
||||
- **Entrance Server** (Port 53310): Manages world/server selection
|
||||
- **Channel Servers** (Ports 54001+): Handle game sessions, quests, and player interactions
|
||||
|
||||
Multiple channel servers can run simultaneously, organized by world types: Newbie, Normal, Cities, Tavern, Return, and MezFes.
|
||||
|
||||
## Client Compatibility
|
||||
|
||||
### Platforms
|
||||
|
||||
- PC
|
||||
- PlayStation 3
|
||||
- PlayStation Vita
|
||||
- Wii U (Up to Z2)
|
||||
### Versions (ClientMode)
|
||||
- All versions after HR compression (G10-ZZ) have been tested extensively and have great functionality.
|
||||
- All versions available on Wii U (G3-Z2) have been tested and should have good functionality.
|
||||
- The second oldest found version is Forward.4 (FW.4), this version has basic functionality.
|
||||
- The oldest found version is Season 6.0 (S6.0), however functionality is very limited.
|
||||
|
||||
If you have an **installed** copy of Monster Hunter Frontier on an old hard drive, **please** get in contact so we can archive it!
|
||||
### Versions
|
||||
|
||||
## Setup
|
||||
- **G10-ZZ** (ClientMode): Extensively tested with great functionality
|
||||
- **G3-Z2** (Wii U): Tested with good functionality
|
||||
- **Forward.4**: Basic functionality
|
||||
- **Season 6.0**: Limited functionality (oldest supported version)
|
||||
|
||||
If you are only looking to install Erupe, please use [a pre-compiled binary](https://github.com/ZeruLight/Erupe/releases/latest).
|
||||
## Database Schemas
|
||||
|
||||
If you want to modify or compile Erupe yourself, please read on.
|
||||
Erupe uses an embedded auto-migrating schema system. Migrations in [server/migrations/sql/](./server/migrations/sql/) are applied automatically on startup — no manual SQL steps needed.
|
||||
|
||||
## Requirements
|
||||
- **Migrations**: Numbered SQL files (`0001_init.sql`, `0002_*.sql`, ...) tracked in a `schema_version` table
|
||||
- **Seed Data**: Demo templates for shops, distributions, events, and gacha in [server/migrations/seed/](./server/migrations/seed/) — applied automatically on fresh databases
|
||||
|
||||
- [Go](https://go.dev/dl/)
|
||||
- [PostgreSQL](https://www.postgresql.org/download/)
|
||||
## Development
|
||||
|
||||
## Installation
|
||||
### Branch Strategy
|
||||
|
||||
1. Bring up a fresh database by using the [backup file attached with the latest release](https://github.com/ZeruLight/Erupe/releases/latest/download/SCHEMA.sql).
|
||||
2. Run each script under [patch-schema](./schemas/patch-schema) as they introduce newer schema.
|
||||
3. Edit [config.json](./config.json) such that the database password matches your PostgreSQL setup.
|
||||
4. Run `go build` or `go run .` to compile Erupe.
|
||||
- **main**: Active development branch with the latest features and improvements
|
||||
- **stable/v9.2.x**: Stable release branch for those seeking stability over cutting-edge features
|
||||
|
||||
## Docker
|
||||
### Running Tests
|
||||
|
||||
Please see [docker/README.md](./docker/README.md). This is intended for quick installs and development, not for production.
|
||||
```bash
|
||||
go test -v ./... # Run all tests
|
||||
go test -v -race ./... # Check for race conditions (mandatory before merging)
|
||||
```
|
||||
|
||||
## Schemas
|
||||
## Troubleshooting
|
||||
|
||||
We source control the following schemas:
|
||||
- Initialization Schema: This initializes the application database to a specific version (9.1.0).
|
||||
- Update Schemas: These are update files that should be ran on top of the initialization schema.
|
||||
- Patch Schemas: These are for development and should be run after running all initialization and update schema. These get condensed into `Update Schemas` and deleted when updated to a new release.
|
||||
- Bundled Schemas: These are demo reference files to give servers standard set-ups.
|
||||
### Server won't start
|
||||
|
||||
Note: Patch schemas are subject to change! You should only be using them if you are following along with development.
|
||||
- Verify PostgreSQL is running: `systemctl status postgresql` (Linux) or `pg_ctl status` (Windows)
|
||||
- Check database credentials in `config.json`
|
||||
- Ensure all required ports are available and not blocked by firewall
|
||||
|
||||
### Client can't connect
|
||||
|
||||
- Verify server is listening: `netstat -an | grep 53310`
|
||||
- Check firewall rules allow traffic on ports 53310, 53312, and 54001+
|
||||
- Ensure client's `host.txt` points to correct server IP
|
||||
- For remote connections, set `"Host"` in config.json to `0.0.0.0` or your server's IP
|
||||
|
||||
### Database schema errors
|
||||
|
||||
- Schema migrations run automatically on startup — check the server logs for migration errors
|
||||
- Check PostgreSQL logs for detailed error messages
|
||||
- Verify database user has sufficient privileges
|
||||
|
||||
### Quest files not loading
|
||||
|
||||
- Confirm `BinPath` in config.json points to extracted quest/scenario files
|
||||
- Verify binary files match your `ClientMode` setting
|
||||
- Check file permissions
|
||||
|
||||
### Debug Logging
|
||||
|
||||
Enable detailed logging in `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"DebugOptions": {
|
||||
"LogInboundMessages": true,
|
||||
"LogOutboundMessages": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Quest and Scenario Binary Files](https://files.catbox.moe/xf0l7w.7z)
|
||||
- [Mezeporta Square Discord](https://discord.gg/DnwcpXM488)
|
||||
- **Quest/Scenario Files**: [Download (catbox)](https://files.catbox.moe/xf0l7w.7z)
|
||||
- **Documentation**: [Erupe Wiki](https://github.com/Mezeporta/Erupe/wiki)
|
||||
- **Discord Communities**:
|
||||
- [Mezeporta Square](https://discord.gg/DnwcpXM488)
|
||||
- [Mogapedia](https://discord.gg/f77VwBX5w7) (French Monster Hunter community, current Erupe maintainers)
|
||||
- [PewPewDojo](https://discord.gg/CFnzbhQ)
|
||||
- **Community Tools**:
|
||||
- [Ferias](https://xl3lackout.github.io/MHFZ-Ferias-English-Project/) — Material and item database
|
||||
- [Damage Calculator](https://mh.fist.moe/damagecalc.html) — Online damage calculator
|
||||
- [Armor Set Searcher](https://github.com/matthe815/mhfz-ass/releases) — Armor set search application
|
||||
|
||||
## Changelog
|
||||
|
||||
View [CHANGELOG.md](CHANGELOG.md) for version history and changes.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## Authors
|
||||
|
||||
A list of authors can be found at [AUTHORS.md](AUTHORS.md).
|
||||
|
||||
19
SECURITY.md
Normal file
19
SECURITY.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| main | Yes |
|
||||
| stable/v9.2.x | Yes |
|
||||
| All other branches | No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
|
||||
1. **Do not** open a public GitHub issue
|
||||
2. Contact us privately via [Mogapedia's Discord](https://discord.gg/f77VwBX5w7) or [Mezeporta Square Discord](https://discord.gg/DnwcpXM488)
|
||||
3. Include a description of the vulnerability, steps to reproduce, and any potential impact
|
||||
|
||||
We will acknowledge your report within 72 hours and work with you to address the issue before any public disclosure.
|
||||
37
cmd/protbot/conn/bin8.go
Normal file
37
cmd/protbot/conn/bin8.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package conn
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
var (
|
||||
bin8Key = []byte{0x01, 0x23, 0x34, 0x45, 0x56, 0xAB, 0xCD, 0xEF}
|
||||
sum32Table0 = []byte{0x35, 0x7A, 0xAA, 0x97, 0x53, 0x66, 0x12}
|
||||
sum32Table1 = []byte{0x7A, 0xAA, 0x97, 0x53, 0x66, 0x12, 0xDE, 0xDE, 0x35}
|
||||
)
|
||||
|
||||
// CalcSum32 calculates the custom MHF "sum32" checksum.
|
||||
func CalcSum32(data []byte) uint32 {
|
||||
tableIdx0 := (len(data) + 1) & 0xFF
|
||||
tableIdx1 := int((data[len(data)>>1] + 1) & 0xFF)
|
||||
out := make([]byte, 4)
|
||||
for i := 0; i < len(data); i++ {
|
||||
key := data[i] ^ sum32Table0[(tableIdx0+i)%7] ^ sum32Table1[(tableIdx1+i)%9]
|
||||
out[i&3] = (out[i&3] + key) & 0xFF
|
||||
}
|
||||
return binary.BigEndian.Uint32(out)
|
||||
}
|
||||
|
||||
func rotate(k *uint32) {
|
||||
*k = uint32(((54323 * uint(*k)) + 1) & 0xFFFFFFFF)
|
||||
}
|
||||
|
||||
// DecryptBin8 decrypts MHF "binary8" data.
|
||||
func DecryptBin8(data []byte, key byte) []byte {
|
||||
k := uint32(key)
|
||||
output := make([]byte, len(data))
|
||||
for i := 0; i < len(data); i++ {
|
||||
rotate(&k)
|
||||
tmp := data[i] ^ byte((k>>13)&0xFF)
|
||||
output[i] = tmp ^ bin8Key[i&7]
|
||||
}
|
||||
return output
|
||||
}
|
||||
52
cmd/protbot/conn/bin8_test.go
Normal file
52
cmd/protbot/conn/bin8_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package conn
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCalcSum32 verifies the checksum against a known input.
|
||||
func TestCalcSum32(t *testing.T) {
|
||||
// Verify determinism: same input gives same output.
|
||||
data := []byte("Hello, MHF!")
|
||||
sum1 := CalcSum32(data)
|
||||
sum2 := CalcSum32(data)
|
||||
if sum1 != sum2 {
|
||||
t.Fatalf("CalcSum32 not deterministic: %08X != %08X", sum1, sum2)
|
||||
}
|
||||
|
||||
// Different inputs produce different outputs (basic sanity).
|
||||
data2 := []byte("Hello, MHF?")
|
||||
sum3 := CalcSum32(data2)
|
||||
if sum1 == sum3 {
|
||||
t.Fatalf("CalcSum32 collision on different inputs: both %08X", sum1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecryptBin8RoundTrip verifies that encrypting and decrypting with Bin8
|
||||
// produces the original data. We only have DecryptBin8, but we can verify
|
||||
// the encrypt→decrypt path by implementing encrypt inline here.
|
||||
func TestDecryptBin8RoundTrip(t *testing.T) {
|
||||
original := []byte("Test data for Bin8 encryption round-trip")
|
||||
key := byte(0x42)
|
||||
|
||||
// Encrypt (inline copy of Erupe's EncryptBin8)
|
||||
k := uint32(key)
|
||||
encrypted := make([]byte, len(original))
|
||||
for i := 0; i < len(original); i++ {
|
||||
rotate(&k)
|
||||
tmp := bin8Key[i&7] ^ byte((k>>13)&0xFF)
|
||||
encrypted[i] = original[i] ^ tmp
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted := DecryptBin8(encrypted, key)
|
||||
|
||||
if len(decrypted) != len(original) {
|
||||
t.Fatalf("length mismatch: got %d, want %d", len(decrypted), len(original))
|
||||
}
|
||||
for i := range original {
|
||||
if decrypted[i] != original[i] {
|
||||
t.Fatalf("byte %d: got 0x%02X, want 0x%02X", i, decrypted[i], original[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
52
cmd/protbot/conn/conn.go
Normal file
52
cmd/protbot/conn/conn.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package conn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// MHFConn wraps a CryptConn and provides convenience methods for MHF connections.
|
||||
type MHFConn struct {
|
||||
*CryptConn
|
||||
RawConn net.Conn
|
||||
}
|
||||
|
||||
// DialWithInit connects to addr and sends the 8 NULL byte initialization
|
||||
// required by sign and entrance servers.
|
||||
func DialWithInit(addr string) (*MHFConn, error) {
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
// Sign and entrance servers expect 8 NULL bytes to initialize the connection.
|
||||
_, err = conn.Write(make([]byte, 8))
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("write init bytes to %s: %w", addr, err)
|
||||
}
|
||||
|
||||
return &MHFConn{
|
||||
CryptConn: NewCryptConn(conn),
|
||||
RawConn: conn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DialDirect connects to addr without sending initialization bytes.
|
||||
// Used for channel server connections.
|
||||
func DialDirect(addr string) (*MHFConn, error) {
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
return &MHFConn{
|
||||
CryptConn: NewCryptConn(conn),
|
||||
RawConn: conn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying connection.
|
||||
func (c *MHFConn) Close() error {
|
||||
return c.RawConn.Close()
|
||||
}
|
||||
115
cmd/protbot/conn/crypt_conn.go
Normal file
115
cmd/protbot/conn/crypt_conn.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package conn
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"erupe-ce/network/crypto"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
)
|
||||
|
||||
// CryptConn is an MHF encrypted two-way connection.
|
||||
// Adapted from Erupe's network/crypt_conn.go with config dependency removed.
|
||||
// Hardcoded to ZZ mode (supports Pf0-based extended data size).
|
||||
type CryptConn struct {
|
||||
conn net.Conn
|
||||
readKeyRot uint32
|
||||
sendKeyRot uint32
|
||||
sentPackets int32
|
||||
prevRecvPacketCombinedCheck uint16
|
||||
prevSendPacketCombinedCheck uint16
|
||||
}
|
||||
|
||||
// NewCryptConn creates a new CryptConn with proper default values.
|
||||
func NewCryptConn(conn net.Conn) *CryptConn {
|
||||
return &CryptConn{
|
||||
conn: conn,
|
||||
readKeyRot: 995117,
|
||||
sendKeyRot: 995117,
|
||||
}
|
||||
}
|
||||
|
||||
// ReadPacket reads a packet from the connection and returns the decrypted data.
|
||||
func (cc *CryptConn) ReadPacket() ([]byte, error) {
|
||||
headerData := make([]byte, CryptPacketHeaderLength)
|
||||
_, err := io.ReadFull(cc.conn, headerData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cph, err := NewCryptPacketHeader(headerData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ZZ mode: extended data size using Pf0 field.
|
||||
encryptedPacketBody := make([]byte, uint32(cph.DataSize)+(uint32(cph.Pf0-0x03)*0x1000))
|
||||
_, err = io.ReadFull(cc.conn, encryptedPacketBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cph.KeyRotDelta != 0 {
|
||||
cc.readKeyRot = uint32(cph.KeyRotDelta) * (cc.readKeyRot + 1)
|
||||
}
|
||||
|
||||
out, combinedCheck, check0, check1, check2 := crypto.Crypto(encryptedPacketBody, cc.readKeyRot, false, nil)
|
||||
if cph.Check0 != check0 || cph.Check1 != check1 || cph.Check2 != check2 {
|
||||
fmt.Printf("got c0 %X, c1 %X, c2 %X\n", check0, check1, check2)
|
||||
fmt.Printf("want c0 %X, c1 %X, c2 %X\n", cph.Check0, cph.Check1, cph.Check2)
|
||||
fmt.Printf("headerData:\n%s\n", hex.Dump(headerData))
|
||||
fmt.Printf("encryptedPacketBody:\n%s\n", hex.Dump(encryptedPacketBody))
|
||||
|
||||
// Attempt bruteforce recovery.
|
||||
fmt.Println("Crypto out of sync? Attempting bruteforce")
|
||||
for key := byte(0); key < 255; key++ {
|
||||
out, combinedCheck, check0, check1, check2 = crypto.Crypto(encryptedPacketBody, 0, false, &key)
|
||||
if cph.Check0 == check0 && cph.Check1 == check1 && cph.Check2 == check2 {
|
||||
fmt.Printf("Bruteforce successful, override key: 0x%X\n", key)
|
||||
cc.prevRecvPacketCombinedCheck = combinedCheck
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("decrypted data checksum doesn't match header")
|
||||
}
|
||||
|
||||
cc.prevRecvPacketCombinedCheck = combinedCheck
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SendPacket encrypts and sends a packet.
|
||||
func (cc *CryptConn) SendPacket(data []byte) error {
|
||||
keyRotDelta := byte(3)
|
||||
|
||||
if keyRotDelta != 0 {
|
||||
cc.sendKeyRot = uint32(keyRotDelta) * (cc.sendKeyRot + 1)
|
||||
}
|
||||
|
||||
encData, combinedCheck, check0, check1, check2 := crypto.Crypto(data, cc.sendKeyRot, true, nil)
|
||||
|
||||
header := &CryptPacketHeader{}
|
||||
header.Pf0 = byte(((uint(len(encData)) >> 12) & 0xF3) | 3)
|
||||
header.KeyRotDelta = keyRotDelta
|
||||
header.PacketNum = uint16(cc.sentPackets)
|
||||
header.DataSize = uint16(len(encData))
|
||||
header.PrevPacketCombinedCheck = cc.prevSendPacketCombinedCheck
|
||||
header.Check0 = check0
|
||||
header.Check1 = check1
|
||||
header.Check2 = check2
|
||||
|
||||
headerBytes, err := header.Encode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = cc.conn.Write(append(headerBytes, encData...))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cc.sentPackets++
|
||||
cc.prevSendPacketCombinedCheck = combinedCheck
|
||||
|
||||
return nil
|
||||
}
|
||||
152
cmd/protbot/conn/crypt_conn_test.go
Normal file
152
cmd/protbot/conn/crypt_conn_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package conn
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCryptConnRoundTrip verifies that encrypting and decrypting a packet
|
||||
// through a pair of CryptConn instances produces the original data.
|
||||
func TestCryptConnRoundTrip(t *testing.T) {
|
||||
// Create an in-process TCP pipe.
|
||||
server, client := net.Pipe()
|
||||
defer func() { _ = server.Close() }()
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
sender := NewCryptConn(client)
|
||||
receiver := NewCryptConn(server)
|
||||
|
||||
testCases := [][]byte{
|
||||
{0x00, 0x14, 0x00, 0x00, 0x00, 0x01}, // Minimal login-like packet
|
||||
{0xDE, 0xAD, 0xBE, 0xEF},
|
||||
make([]byte, 256), // Larger packet
|
||||
}
|
||||
|
||||
for i, original := range testCases {
|
||||
// Send in a goroutine to avoid blocking.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- sender.SendPacket(original)
|
||||
}()
|
||||
|
||||
received, err := receiver.ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatalf("case %d: ReadPacket error: %v", i, err)
|
||||
}
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("case %d: SendPacket error: %v", i, err)
|
||||
}
|
||||
|
||||
if len(received) != len(original) {
|
||||
t.Fatalf("case %d: length mismatch: got %d, want %d", i, len(received), len(original))
|
||||
}
|
||||
for j := range original {
|
||||
if received[j] != original[j] {
|
||||
t.Fatalf("case %d: byte %d mismatch: got 0x%02X, want 0x%02X", i, j, received[j], original[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCryptPacketHeaderRoundTrip verifies header encode/decode.
|
||||
func TestCryptPacketHeaderRoundTrip(t *testing.T) {
|
||||
original := &CryptPacketHeader{
|
||||
Pf0: 0x03,
|
||||
KeyRotDelta: 0x03,
|
||||
PacketNum: 42,
|
||||
DataSize: 100,
|
||||
PrevPacketCombinedCheck: 0x1234,
|
||||
Check0: 0xAAAA,
|
||||
Check1: 0xBBBB,
|
||||
Check2: 0xCCCC,
|
||||
}
|
||||
|
||||
encoded, err := original.Encode()
|
||||
if err != nil {
|
||||
t.Fatalf("Encode error: %v", err)
|
||||
}
|
||||
|
||||
if len(encoded) != CryptPacketHeaderLength {
|
||||
t.Fatalf("encoded length: got %d, want %d", len(encoded), CryptPacketHeaderLength)
|
||||
}
|
||||
|
||||
decoded, err := NewCryptPacketHeader(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCryptPacketHeader error: %v", err)
|
||||
}
|
||||
|
||||
if *decoded != *original {
|
||||
t.Fatalf("header mismatch:\ngot %+v\nwant %+v", *decoded, *original)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiPacketSequence verifies that key rotation stays in sync across
|
||||
// multiple sequential packets.
|
||||
func TestMultiPacketSequence(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer func() { _ = server.Close() }()
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
sender := NewCryptConn(client)
|
||||
receiver := NewCryptConn(server)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
data := []byte{byte(i), byte(i + 1), byte(i + 2), byte(i + 3)}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- sender.SendPacket(data)
|
||||
}()
|
||||
|
||||
received, err := receiver.ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatalf("packet %d: ReadPacket error: %v", i, err)
|
||||
}
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("packet %d: SendPacket error: %v", i, err)
|
||||
}
|
||||
|
||||
for j := range data {
|
||||
if received[j] != data[j] {
|
||||
t.Fatalf("packet %d byte %d: got 0x%02X, want 0x%02X", i, j, received[j], data[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialWithInit verifies that DialWithInit sends 8 NULL bytes on connect.
|
||||
func TestDialWithInit(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = listener.Close() }()
|
||||
|
||||
done := make(chan []byte, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
buf := make([]byte, 8)
|
||||
_, _ = io.ReadFull(conn, buf)
|
||||
done <- buf
|
||||
}()
|
||||
|
||||
c, err := DialWithInit(listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
initBytes := <-done
|
||||
for i, b := range initBytes {
|
||||
if b != 0 {
|
||||
t.Fatalf("init byte %d: got 0x%02X, want 0x00", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
cmd/protbot/conn/crypt_packet.go
Normal file
78
cmd/protbot/conn/crypt_packet.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Package conn provides MHF encrypted connection primitives.
|
||||
//
|
||||
// This is adapted from Erupe's network/crypt_packet.go to avoid importing
|
||||
// erupe-ce/config (whose init() calls os.Exit without a config file).
|
||||
package conn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
const CryptPacketHeaderLength = 14
|
||||
|
||||
// CryptPacketHeader represents the parsed information of an encrypted packet header.
|
||||
type CryptPacketHeader struct {
|
||||
Pf0 byte
|
||||
KeyRotDelta byte
|
||||
PacketNum uint16
|
||||
DataSize uint16
|
||||
PrevPacketCombinedCheck uint16
|
||||
Check0 uint16
|
||||
Check1 uint16
|
||||
Check2 uint16
|
||||
}
|
||||
|
||||
// NewCryptPacketHeader parses raw bytes into a CryptPacketHeader.
|
||||
func NewCryptPacketHeader(data []byte) (*CryptPacketHeader, error) {
|
||||
var c CryptPacketHeader
|
||||
r := bytes.NewReader(data)
|
||||
|
||||
if err := binary.Read(r, binary.BigEndian, &c.Pf0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &c.KeyRotDelta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &c.PacketNum); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &c.DataSize); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &c.PrevPacketCombinedCheck); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &c.Check0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &c.Check1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &c.Check2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Encode encodes the CryptPacketHeader into raw bytes.
|
||||
func (c *CryptPacketHeader) Encode() ([]byte, error) {
|
||||
buf := bytes.NewBuffer([]byte{})
|
||||
data := []interface{}{
|
||||
c.Pf0,
|
||||
c.KeyRotDelta,
|
||||
c.PacketNum,
|
||||
c.DataSize,
|
||||
c.PrevPacketCombinedCheck,
|
||||
c.Check0,
|
||||
c.Check1,
|
||||
c.Check2,
|
||||
}
|
||||
for _, v := range data {
|
||||
if err := binary.Write(buf, binary.BigEndian, v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
154
cmd/protbot/main.go
Normal file
154
cmd/protbot/main.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// protbot is a headless MHF protocol bot for testing Erupe server instances.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// protbot --sign-addr 127.0.0.1:53312 --user test --pass test --action login
|
||||
// protbot --sign-addr 127.0.0.1:53312 --user test --pass test --action lobby
|
||||
// protbot --sign-addr 127.0.0.1:53312 --user test --pass test --action session
|
||||
// protbot --sign-addr 127.0.0.1:53312 --user test --pass test --action chat --message "Hello"
|
||||
// protbot --sign-addr 127.0.0.1:53312 --user test --pass test --action quests
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"erupe-ce/cmd/protbot/scenario"
|
||||
)
|
||||
|
||||
func main() {
|
||||
signAddr := flag.String("sign-addr", "127.0.0.1:53312", "Sign server address (host:port)")
|
||||
user := flag.String("user", "", "Username")
|
||||
pass := flag.String("pass", "", "Password")
|
||||
action := flag.String("action", "login", "Action to perform: login, lobby, session, chat, quests")
|
||||
message := flag.String("message", "", "Chat message to send (used with --action chat)")
|
||||
flag.Parse()
|
||||
|
||||
if *user == "" || *pass == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --user and --pass are required")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch *action {
|
||||
case "login":
|
||||
result, err := scenario.Login(*signAddr, *user, *pass)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("[done] Login successful!")
|
||||
_ = result.Channel.Close()
|
||||
|
||||
case "lobby":
|
||||
result, err := scenario.Login(*signAddr, *user, *pass)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := scenario.EnterLobby(result.Channel); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "enter lobby failed: %v\n", err)
|
||||
_ = result.Channel.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("[done] Lobby entry successful!")
|
||||
_ = result.Channel.Close()
|
||||
|
||||
case "session":
|
||||
result, err := scenario.Login(*signAddr, *user, *pass)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
charID := result.Sign.CharIDs[0]
|
||||
if _, err := scenario.SetupSession(result.Channel, charID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "session setup failed: %v\n", err)
|
||||
_ = result.Channel.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := scenario.EnterLobby(result.Channel); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "enter lobby failed: %v\n", err)
|
||||
_ = result.Channel.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("[session] Connected. Press Ctrl+C to disconnect.")
|
||||
waitForSignal()
|
||||
_ = scenario.Logout(result.Channel)
|
||||
|
||||
case "chat":
|
||||
result, err := scenario.Login(*signAddr, *user, *pass)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
charID := result.Sign.CharIDs[0]
|
||||
if _, err := scenario.SetupSession(result.Channel, charID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "session setup failed: %v\n", err)
|
||||
_ = result.Channel.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := scenario.EnterLobby(result.Channel); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "enter lobby failed: %v\n", err)
|
||||
_ = result.Channel.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Register chat listener.
|
||||
scenario.ListenChat(result.Channel, func(msg scenario.ChatMessage) {
|
||||
fmt.Printf("[chat] <%s> (type=%d): %s\n", msg.SenderName, msg.ChatType, msg.Message)
|
||||
})
|
||||
|
||||
// Send a message if provided.
|
||||
if *message != "" {
|
||||
if err := scenario.SendChat(result.Channel, 0x03, 1, *message, *user); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "send chat failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("[chat] Listening for chat messages. Press Ctrl+C to disconnect.")
|
||||
waitForSignal()
|
||||
_ = scenario.Logout(result.Channel)
|
||||
|
||||
case "quests":
|
||||
result, err := scenario.Login(*signAddr, *user, *pass)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
charID := result.Sign.CharIDs[0]
|
||||
if _, err := scenario.SetupSession(result.Channel, charID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "session setup failed: %v\n", err)
|
||||
_ = result.Channel.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := scenario.EnterLobby(result.Channel); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "enter lobby failed: %v\n", err)
|
||||
_ = result.Channel.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
data, err := scenario.EnumerateQuests(result.Channel, 0, 0)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "enumerate quests failed: %v\n", err)
|
||||
_ = scenario.Logout(result.Channel)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("[quests] Received %d bytes of quest data\n", len(data))
|
||||
_ = scenario.Logout(result.Channel)
|
||||
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown action: %s (supported: login, lobby, session, chat, quests)\n", *action)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForSignal blocks until SIGINT or SIGTERM is received.
|
||||
func waitForSignal() {
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
fmt.Println("\n[signal] Shutting down...")
|
||||
}
|
||||
190
cmd/protbot/protocol/channel.go
Normal file
190
cmd/protbot/protocol/channel.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"erupe-ce/cmd/protbot/conn"
|
||||
)
|
||||
|
||||
// PacketHandler is a callback invoked when a server-pushed packet is received.
|
||||
type PacketHandler func(opcode uint16, data []byte)
|
||||
|
||||
// ChannelConn manages a connection to a channel server.
|
||||
type ChannelConn struct {
|
||||
conn *conn.MHFConn
|
||||
ackCounter uint32
|
||||
waiters sync.Map // map[uint32]chan *AckResponse
|
||||
handlers sync.Map // map[uint16]PacketHandler
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
// OnPacket registers a handler for a specific server-pushed opcode.
|
||||
// Only one handler per opcode; later registrations replace earlier ones.
|
||||
func (ch *ChannelConn) OnPacket(opcode uint16, handler PacketHandler) {
|
||||
ch.handlers.Store(opcode, handler)
|
||||
}
|
||||
|
||||
// AckResponse holds the parsed ACK data from the server.
|
||||
type AckResponse struct {
|
||||
AckHandle uint32
|
||||
IsBufferResponse bool
|
||||
ErrorCode uint8
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// ConnectChannel establishes a connection to a channel server.
|
||||
// Channel servers do NOT use the 8 NULL byte initialization.
|
||||
func ConnectChannel(addr string) (*ChannelConn, error) {
|
||||
c, err := conn.DialDirect(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("channel connect: %w", err)
|
||||
}
|
||||
|
||||
ch := &ChannelConn{
|
||||
conn: c,
|
||||
}
|
||||
|
||||
go ch.recvLoop()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// NextAckHandle returns the next unique ACK handle for packet requests.
|
||||
func (ch *ChannelConn) NextAckHandle() uint32 {
|
||||
return atomic.AddUint32(&ch.ackCounter, 1)
|
||||
}
|
||||
|
||||
// SendPacket encrypts and sends raw packet data (including the 0x00 0x10 terminator
|
||||
// which is already appended by the Build* functions in packets.go).
|
||||
func (ch *ChannelConn) SendPacket(data []byte) error {
|
||||
return ch.conn.SendPacket(data)
|
||||
}
|
||||
|
||||
// WaitForAck waits for an ACK response matching the given handle.
|
||||
func (ch *ChannelConn) WaitForAck(handle uint32, timeout time.Duration) (*AckResponse, error) {
|
||||
waitCh := make(chan *AckResponse, 1)
|
||||
ch.waiters.Store(handle, waitCh)
|
||||
defer ch.waiters.Delete(handle)
|
||||
|
||||
select {
|
||||
case resp := <-waitCh:
|
||||
return resp, nil
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("ACK timeout for handle %d", handle)
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the channel connection.
|
||||
func (ch *ChannelConn) Close() error {
|
||||
ch.closed.Store(true)
|
||||
return ch.conn.Close()
|
||||
}
|
||||
|
||||
// recvLoop continuously reads packets from the channel server and dispatches ACKs.
|
||||
func (ch *ChannelConn) recvLoop() {
|
||||
for {
|
||||
if ch.closed.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
pkt, err := ch.conn.ReadPacket()
|
||||
if err != nil {
|
||||
if ch.closed.Load() {
|
||||
return
|
||||
}
|
||||
fmt.Printf("[channel] read error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pkt) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Strip trailing 0x00 0x10 terminator if present for opcode parsing.
|
||||
// Packets from server: [opcode uint16][fields...][0x00 0x10]
|
||||
opcode := binary.BigEndian.Uint16(pkt[0:2])
|
||||
|
||||
switch opcode {
|
||||
case MSG_SYS_ACK:
|
||||
ch.handleAck(pkt[2:])
|
||||
case MSG_SYS_PING:
|
||||
ch.handlePing(pkt[2:])
|
||||
default:
|
||||
if val, ok := ch.handlers.Load(opcode); ok {
|
||||
val.(PacketHandler)(opcode, pkt[2:])
|
||||
} else {
|
||||
fmt.Printf("[channel] recv opcode 0x%04X (%d bytes)\n", opcode, len(pkt))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleAck parses an ACK packet and dispatches it to a waiting caller.
|
||||
// Reference: Erupe network/mhfpacket/msg_sys_ack.go
|
||||
func (ch *ChannelConn) handleAck(data []byte) {
|
||||
if len(data) < 8 {
|
||||
return
|
||||
}
|
||||
|
||||
ackHandle := binary.BigEndian.Uint32(data[0:4])
|
||||
isBuffer := data[4] > 0
|
||||
errorCode := data[5]
|
||||
|
||||
var ackData []byte
|
||||
if isBuffer {
|
||||
payloadSize := binary.BigEndian.Uint16(data[6:8])
|
||||
offset := uint32(8)
|
||||
if payloadSize == 0xFFFF {
|
||||
if len(data) < 12 {
|
||||
return
|
||||
}
|
||||
payloadSize32 := binary.BigEndian.Uint32(data[8:12])
|
||||
offset = 12
|
||||
if uint32(len(data)) >= offset+payloadSize32 {
|
||||
ackData = data[offset : offset+payloadSize32]
|
||||
}
|
||||
} else {
|
||||
if uint32(len(data)) >= offset+uint32(payloadSize) {
|
||||
ackData = data[offset : offset+uint32(payloadSize)]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Simple ACK: 4 bytes of data after the uint16 field.
|
||||
if len(data) >= 12 {
|
||||
ackData = data[8:12]
|
||||
}
|
||||
}
|
||||
|
||||
resp := &AckResponse{
|
||||
AckHandle: ackHandle,
|
||||
IsBufferResponse: isBuffer,
|
||||
ErrorCode: errorCode,
|
||||
Data: ackData,
|
||||
}
|
||||
|
||||
if val, ok := ch.waiters.Load(ackHandle); ok {
|
||||
waitCh := val.(chan *AckResponse)
|
||||
select {
|
||||
case waitCh <- resp:
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[channel] unexpected ACK handle %d (error=%d, buffer=%v, %d bytes)\n",
|
||||
ackHandle, errorCode, isBuffer, len(ackData))
|
||||
}
|
||||
}
|
||||
|
||||
// handlePing responds to a server ping to keep the connection alive.
|
||||
func (ch *ChannelConn) handlePing(data []byte) {
|
||||
var ackHandle uint32
|
||||
if len(data) >= 4 {
|
||||
ackHandle = binary.BigEndian.Uint32(data[0:4])
|
||||
}
|
||||
pkt := BuildPingPacket(ackHandle)
|
||||
if err := ch.conn.SendPacket(pkt); err != nil {
|
||||
fmt.Printf("[channel] ping response failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
142
cmd/protbot/protocol/entrance.go
Normal file
142
cmd/protbot/protocol/entrance.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"erupe-ce/common/byteframe"
|
||||
|
||||
"erupe-ce/cmd/protbot/conn"
|
||||
)
|
||||
|
||||
// ServerEntry represents a channel server from the entrance server response.
|
||||
type ServerEntry struct {
|
||||
IP string
|
||||
Port uint16
|
||||
Name string
|
||||
}
|
||||
|
||||
// DoEntrance connects to the entrance server and retrieves the server list.
|
||||
// Reference: Erupe server/entranceserver/entrance_server.go and make_resp.go.
|
||||
func DoEntrance(addr string) ([]ServerEntry, error) {
|
||||
c, err := conn.DialWithInit(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entrance connect: %w", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
// Send a minimal packet (the entrance server reads it, checks len > 5 for USR data).
|
||||
// An empty/short packet triggers only SV2 response.
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint8(0)
|
||||
if err := c.SendPacket(bf.Data()); err != nil {
|
||||
return nil, fmt.Errorf("entrance send: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.ReadPacket()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entrance recv: %w", err)
|
||||
}
|
||||
|
||||
return parseEntranceResponse(resp)
|
||||
}
|
||||
|
||||
// parseEntranceResponse parses the Bin8-encrypted entrance server response.
|
||||
// Reference: Erupe server/entranceserver/make_resp.go (makeHeader, makeSv2Resp)
|
||||
func parseEntranceResponse(data []byte) ([]ServerEntry, error) {
|
||||
if len(data) < 2 {
|
||||
return nil, fmt.Errorf("entrance response too short")
|
||||
}
|
||||
|
||||
// First byte is the Bin8 encryption key.
|
||||
key := data[0]
|
||||
decrypted := conn.DecryptBin8(data[1:], key)
|
||||
|
||||
rbf := byteframe.NewByteFrameFromBytes(decrypted)
|
||||
|
||||
// Read response type header: "SV2" or "SVR"
|
||||
respType := string(rbf.ReadBytes(3))
|
||||
if respType != "SV2" && respType != "SVR" {
|
||||
return nil, fmt.Errorf("unexpected entrance response type: %s", respType)
|
||||
}
|
||||
|
||||
entryCount := rbf.ReadUint16()
|
||||
dataLen := rbf.ReadUint16()
|
||||
if dataLen == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
expectedSum := rbf.ReadUint32()
|
||||
serverData := rbf.ReadBytes(uint(dataLen))
|
||||
|
||||
actualSum := conn.CalcSum32(serverData)
|
||||
if expectedSum != actualSum {
|
||||
return nil, fmt.Errorf("entrance checksum mismatch: expected %08X, got %08X", expectedSum, actualSum)
|
||||
}
|
||||
|
||||
return parseServerEntries(serverData, entryCount)
|
||||
}
|
||||
|
||||
// parseServerEntries parses the server info binary blob.
|
||||
// Reference: Erupe server/entranceserver/make_resp.go (encodeServerInfo)
|
||||
func parseServerEntries(data []byte, entryCount uint16) ([]ServerEntry, error) {
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
var entries []ServerEntry
|
||||
|
||||
for i := uint16(0); i < entryCount; i++ {
|
||||
ipBytes := bf.ReadBytes(4)
|
||||
ip := net.IP([]byte{
|
||||
byte(ipBytes[3]), byte(ipBytes[2]),
|
||||
byte(ipBytes[1]), byte(ipBytes[0]),
|
||||
})
|
||||
|
||||
_ = bf.ReadUint16() // serverIdx | 16
|
||||
_ = bf.ReadUint16() // 0
|
||||
channelCount := bf.ReadUint16()
|
||||
_ = bf.ReadUint8() // Type
|
||||
_ = bf.ReadUint8() // Season/rotation
|
||||
|
||||
// G1+ recommended flag
|
||||
_ = bf.ReadUint8()
|
||||
|
||||
// G51+ (ZZ): skip 1 byte, then read 65-byte padded name
|
||||
_ = bf.ReadUint8()
|
||||
nameBytes := bf.ReadBytes(65)
|
||||
|
||||
// GG+: AllowedClientFlags
|
||||
_ = bf.ReadUint32()
|
||||
|
||||
// Parse name (null-separated: name + description)
|
||||
name := ""
|
||||
for j := 0; j < len(nameBytes); j++ {
|
||||
if nameBytes[j] == 0 {
|
||||
break
|
||||
}
|
||||
name += string(nameBytes[j])
|
||||
}
|
||||
|
||||
// Read channel entries (14 x uint16 = 28 bytes each)
|
||||
for j := uint16(0); j < channelCount; j++ {
|
||||
port := bf.ReadUint16()
|
||||
_ = bf.ReadUint16() // channelIdx | 16
|
||||
_ = bf.ReadUint16() // maxPlayers
|
||||
_ = bf.ReadUint16() // currentPlayers
|
||||
_ = bf.ReadBytes(18) // remaining channel fields (9 x uint16: 6 zeros + unk319 + unk254 + unk255)
|
||||
_ = bf.ReadUint16() // 12345
|
||||
|
||||
serverIP := ip.String()
|
||||
// Convert 127.0.0.1 representation
|
||||
if binary.LittleEndian.Uint32(ipBytes) == 0x0100007F {
|
||||
serverIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
entries = append(entries, ServerEntry{
|
||||
IP: serverIP,
|
||||
Port: port,
|
||||
Name: fmt.Sprintf("%s ch%d", name, j+1),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
23
cmd/protbot/protocol/opcodes.go
Normal file
23
cmd/protbot/protocol/opcodes.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Package protocol implements MHF network protocol message building and parsing.
|
||||
package protocol
|
||||
|
||||
// Packet opcodes (subset from Erupe's network/packetid.go iota).
|
||||
const (
|
||||
MSG_SYS_ACK uint16 = 0x0012
|
||||
MSG_SYS_LOGIN uint16 = 0x0014
|
||||
MSG_SYS_LOGOUT uint16 = 0x0015
|
||||
MSG_SYS_PING uint16 = 0x0017
|
||||
MSG_SYS_CAST_BINARY uint16 = 0x0018
|
||||
MSG_SYS_TIME uint16 = 0x001A
|
||||
MSG_SYS_CASTED_BINARY uint16 = 0x001B
|
||||
MSG_SYS_ISSUE_LOGKEY uint16 = 0x001D
|
||||
MSG_SYS_ENTER_STAGE uint16 = 0x0022
|
||||
MSG_SYS_ENUMERATE_STAGE uint16 = 0x002F
|
||||
MSG_SYS_INSERT_USER uint16 = 0x0050
|
||||
MSG_SYS_DELETE_USER uint16 = 0x0051
|
||||
MSG_SYS_UPDATE_RIGHT uint16 = 0x0058
|
||||
MSG_SYS_RIGHTS_RELOAD uint16 = 0x005D
|
||||
MSG_MHF_LOADDATA uint16 = 0x0061
|
||||
MSG_MHF_ENUMERATE_QUEST uint16 = 0x009F
|
||||
MSG_MHF_GET_WEEKLY_SCHED uint16 = 0x00E1
|
||||
)
|
||||
229
cmd/protbot/protocol/packets.go
Normal file
229
cmd/protbot/protocol/packets.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"erupe-ce/common/byteframe"
|
||||
"erupe-ce/common/stringsupport"
|
||||
)
|
||||
|
||||
// BuildLoginPacket builds a MSG_SYS_LOGIN packet.
|
||||
// Layout mirrors Erupe's MsgSysLogin.Parse:
|
||||
//
|
||||
// uint16 opcode
|
||||
// uint32 ackHandle
|
||||
// uint32 charID
|
||||
// uint32 loginTokenNumber
|
||||
// uint16 hardcodedZero
|
||||
// uint16 requestVersion (set to 0xCAFE as dummy)
|
||||
// uint32 charID (repeated)
|
||||
// uint16 zeroed
|
||||
// uint16 always 11
|
||||
// null-terminated tokenString
|
||||
// 0x00 0x10 terminator
|
||||
func BuildLoginPacket(ackHandle, charID, tokenNumber uint32, tokenString string) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(MSG_SYS_LOGIN)
|
||||
bf.WriteUint32(ackHandle)
|
||||
bf.WriteUint32(charID)
|
||||
bf.WriteUint32(tokenNumber)
|
||||
bf.WriteUint16(0) // HardcodedZero0
|
||||
bf.WriteUint16(0xCAFE) // RequestVersion (dummy)
|
||||
bf.WriteUint32(charID) // CharID1 (repeated)
|
||||
bf.WriteUint16(0) // Zeroed
|
||||
bf.WriteUint16(11) // Always 11
|
||||
bf.WriteNullTerminatedBytes([]byte(tokenString))
|
||||
bf.WriteBytes([]byte{0x00, 0x10})
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// BuildEnumerateStagePacket builds a MSG_SYS_ENUMERATE_STAGE packet.
|
||||
// Layout mirrors Erupe's MsgSysEnumerateStage.Parse:
|
||||
//
|
||||
// uint16 opcode
|
||||
// uint32 ackHandle
|
||||
// uint8 always 1
|
||||
// uint8 prefix length (including null terminator)
|
||||
// null-terminated stagePrefix
|
||||
// 0x00 0x10 terminator
|
||||
func BuildEnumerateStagePacket(ackHandle uint32, prefix string) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(MSG_SYS_ENUMERATE_STAGE)
|
||||
bf.WriteUint32(ackHandle)
|
||||
bf.WriteUint8(1) // Always 1
|
||||
bf.WriteUint8(uint8(len(prefix) + 1)) // Length including null terminator
|
||||
bf.WriteNullTerminatedBytes([]byte(prefix))
|
||||
bf.WriteBytes([]byte{0x00, 0x10})
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// BuildEnterStagePacket builds a MSG_SYS_ENTER_STAGE packet.
|
||||
// Layout mirrors Erupe's MsgSysEnterStage.Parse:
|
||||
//
|
||||
// uint16 opcode
|
||||
// uint32 ackHandle
|
||||
// uint8 isQuest (0=false)
|
||||
// uint8 stageID length (including null terminator)
|
||||
// null-terminated stageID
|
||||
// 0x00 0x10 terminator
|
||||
func BuildEnterStagePacket(ackHandle uint32, stageID string) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(MSG_SYS_ENTER_STAGE)
|
||||
bf.WriteUint32(ackHandle)
|
||||
bf.WriteUint8(0) // IsQuest = false
|
||||
bf.WriteUint8(uint8(len(stageID) + 1)) // Length including null terminator
|
||||
bf.WriteNullTerminatedBytes([]byte(stageID))
|
||||
bf.WriteBytes([]byte{0x00, 0x10})
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// BuildPingPacket builds a MSG_SYS_PING response packet.
|
||||
//
|
||||
// uint16 opcode
|
||||
// uint32 ackHandle
|
||||
// 0x00 0x10 terminator
|
||||
func BuildPingPacket(ackHandle uint32) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(MSG_SYS_PING)
|
||||
bf.WriteUint32(ackHandle)
|
||||
bf.WriteBytes([]byte{0x00, 0x10})
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// BuildLogoutPacket builds a MSG_SYS_LOGOUT packet.
|
||||
//
|
||||
// uint16 opcode
|
||||
// uint8 logoutType (1 = normal logout)
|
||||
// 0x00 0x10 terminator
|
||||
func BuildLogoutPacket() []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(MSG_SYS_LOGOUT)
|
||||
bf.WriteUint8(1) // LogoutType = normal
|
||||
bf.WriteBytes([]byte{0x00, 0x10})
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// BuildIssueLogkeyPacket builds a MSG_SYS_ISSUE_LOGKEY packet.
|
||||
//
|
||||
// uint16 opcode
|
||||
// uint32 ackHandle
|
||||
// uint16 unk0
|
||||
// uint16 unk1
|
||||
// 0x00 0x10 terminator
|
||||
func BuildIssueLogkeyPacket(ackHandle uint32) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(MSG_SYS_ISSUE_LOGKEY)
|
||||
bf.WriteUint32(ackHandle)
|
||||
bf.WriteUint16(0)
|
||||
bf.WriteUint16(0)
|
||||
bf.WriteBytes([]byte{0x00, 0x10})
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// BuildRightsReloadPacket builds a MSG_SYS_RIGHTS_RELOAD packet.
|
||||
//
|
||||
// uint16 opcode
|
||||
// uint32 ackHandle
|
||||
// uint8 count (0 = empty)
|
||||
// 0x00 0x10 terminator
|
||||
func BuildRightsReloadPacket(ackHandle uint32) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(MSG_SYS_RIGHTS_RELOAD)
|
||||
bf.WriteUint32(ackHandle)
|
||||
bf.WriteUint8(0) // Count = 0 (no rights entries)
|
||||
bf.WriteBytes([]byte{0x00, 0x10})
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// BuildLoaddataPacket builds a MSG_MHF_LOADDATA packet.
|
||||
//
|
||||
// uint16 opcode
|
||||
// uint32 ackHandle
|
||||
// 0x00 0x10 terminator
|
||||
func BuildLoaddataPacket(ackHandle uint32) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(MSG_MHF_LOADDATA)
|
||||
bf.WriteUint32(ackHandle)
|
||||
bf.WriteBytes([]byte{0x00, 0x10})
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// BuildCastBinaryPacket builds a MSG_SYS_CAST_BINARY packet.
|
||||
// Layout mirrors Erupe's MsgSysCastBinary.Parse:
|
||||
//
|
||||
// uint16 opcode
|
||||
// uint32 unk (always 0)
|
||||
// uint8 broadcastType
|
||||
// uint8 messageType
|
||||
// uint16 dataSize
|
||||
// []byte payload
|
||||
// 0x00 0x10 terminator
|
||||
func BuildCastBinaryPacket(broadcastType, messageType uint8, payload []byte) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(MSG_SYS_CAST_BINARY)
|
||||
bf.WriteUint32(0) // Unk
|
||||
bf.WriteUint8(broadcastType)
|
||||
bf.WriteUint8(messageType)
|
||||
bf.WriteUint16(uint16(len(payload)))
|
||||
bf.WriteBytes(payload)
|
||||
bf.WriteBytes([]byte{0x00, 0x10})
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// BuildChatPayload builds the inner MsgBinChat binary blob for use with BuildCastBinaryPacket.
|
||||
// Layout mirrors Erupe's binpacket/msg_bin_chat.go Build:
|
||||
//
|
||||
// uint8 unk0 (always 0)
|
||||
// uint8 chatType
|
||||
// uint16 flags (always 0)
|
||||
// uint16 senderNameLen (SJIS bytes + null terminator)
|
||||
// uint16 messageLen (SJIS bytes + null terminator)
|
||||
// null-terminated SJIS message
|
||||
// null-terminated SJIS senderName
|
||||
func BuildChatPayload(chatType uint8, message, senderName string) []byte {
|
||||
sjisMsg := stringsupport.UTF8ToSJIS(message)
|
||||
sjisName := stringsupport.UTF8ToSJIS(senderName)
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint8(0) // Unk0
|
||||
bf.WriteUint8(chatType) // Type
|
||||
bf.WriteUint16(0) // Flags
|
||||
bf.WriteUint16(uint16(len(sjisName) + 1)) // SenderName length (+ null term)
|
||||
bf.WriteUint16(uint16(len(sjisMsg) + 1)) // Message length (+ null term)
|
||||
bf.WriteNullTerminatedBytes(sjisMsg) // Message
|
||||
bf.WriteNullTerminatedBytes(sjisName) // SenderName
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// BuildEnumerateQuestPacket builds a MSG_MHF_ENUMERATE_QUEST packet.
|
||||
//
|
||||
// uint16 opcode
|
||||
// uint32 ackHandle
|
||||
// uint8 unk0 (always 0)
|
||||
// uint8 world
|
||||
// uint16 counter
|
||||
// uint16 offset
|
||||
// uint8 unk1 (always 0)
|
||||
// 0x00 0x10 terminator
|
||||
func BuildEnumerateQuestPacket(ackHandle uint32, world uint8, counter, offset uint16) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(MSG_MHF_ENUMERATE_QUEST)
|
||||
bf.WriteUint32(ackHandle)
|
||||
bf.WriteUint8(0) // Unk0
|
||||
bf.WriteUint8(world)
|
||||
bf.WriteUint16(counter)
|
||||
bf.WriteUint16(offset)
|
||||
bf.WriteUint8(0) // Unk1
|
||||
bf.WriteBytes([]byte{0x00, 0x10})
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// BuildGetWeeklySchedulePacket builds a MSG_MHF_GET_WEEKLY_SCHEDULE packet.
|
||||
//
|
||||
// uint16 opcode
|
||||
// uint32 ackHandle
|
||||
// 0x00 0x10 terminator
|
||||
func BuildGetWeeklySchedulePacket(ackHandle uint32) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(MSG_MHF_GET_WEEKLY_SCHED)
|
||||
bf.WriteUint32(ackHandle)
|
||||
bf.WriteBytes([]byte{0x00, 0x10})
|
||||
return bf.Data()
|
||||
}
|
||||
412
cmd/protbot/protocol/packets_test.go
Normal file
412
cmd/protbot/protocol/packets_test.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"erupe-ce/common/byteframe"
|
||||
)
|
||||
|
||||
// TestBuildLoginPacket verifies that the binary layout matches Erupe's Parse.
|
||||
func TestBuildLoginPacket(t *testing.T) {
|
||||
ackHandle := uint32(1)
|
||||
charID := uint32(100)
|
||||
tokenNumber := uint32(42)
|
||||
tokenString := "0123456789ABCDEF"
|
||||
|
||||
pkt := BuildLoginPacket(ackHandle, charID, tokenNumber, tokenString)
|
||||
|
||||
bf := byteframe.NewByteFrameFromBytes(pkt)
|
||||
|
||||
opcode := bf.ReadUint16()
|
||||
if opcode != MSG_SYS_LOGIN {
|
||||
t.Fatalf("opcode: got 0x%04X, want 0x%04X", opcode, MSG_SYS_LOGIN)
|
||||
}
|
||||
|
||||
gotAck := bf.ReadUint32()
|
||||
if gotAck != ackHandle {
|
||||
t.Fatalf("ackHandle: got %d, want %d", gotAck, ackHandle)
|
||||
}
|
||||
|
||||
gotCharID0 := bf.ReadUint32()
|
||||
if gotCharID0 != charID {
|
||||
t.Fatalf("charID0: got %d, want %d", gotCharID0, charID)
|
||||
}
|
||||
|
||||
gotTokenNum := bf.ReadUint32()
|
||||
if gotTokenNum != tokenNumber {
|
||||
t.Fatalf("tokenNumber: got %d, want %d", gotTokenNum, tokenNumber)
|
||||
}
|
||||
|
||||
gotZero := bf.ReadUint16()
|
||||
if gotZero != 0 {
|
||||
t.Fatalf("hardcodedZero: got %d, want 0", gotZero)
|
||||
}
|
||||
|
||||
gotVersion := bf.ReadUint16()
|
||||
if gotVersion != 0xCAFE {
|
||||
t.Fatalf("requestVersion: got 0x%04X, want 0xCAFE", gotVersion)
|
||||
}
|
||||
|
||||
gotCharID1 := bf.ReadUint32()
|
||||
if gotCharID1 != charID {
|
||||
t.Fatalf("charID1: got %d, want %d", gotCharID1, charID)
|
||||
}
|
||||
|
||||
gotZeroed := bf.ReadUint16()
|
||||
if gotZeroed != 0 {
|
||||
t.Fatalf("zeroed: got %d, want 0", gotZeroed)
|
||||
}
|
||||
|
||||
gotEleven := bf.ReadUint16()
|
||||
if gotEleven != 11 {
|
||||
t.Fatalf("always11: got %d, want 11", gotEleven)
|
||||
}
|
||||
|
||||
gotToken := string(bf.ReadNullTerminatedBytes())
|
||||
if gotToken != tokenString {
|
||||
t.Fatalf("tokenString: got %q, want %q", gotToken, tokenString)
|
||||
}
|
||||
|
||||
// Verify terminator.
|
||||
term := bf.ReadBytes(2)
|
||||
if term[0] != 0x00 || term[1] != 0x10 {
|
||||
t.Fatalf("terminator: got %02X %02X, want 00 10", term[0], term[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildEnumerateStagePacket verifies binary layout matches Erupe's Parse.
|
||||
func TestBuildEnumerateStagePacket(t *testing.T) {
|
||||
ackHandle := uint32(5)
|
||||
prefix := "sl1Ns"
|
||||
|
||||
pkt := BuildEnumerateStagePacket(ackHandle, prefix)
|
||||
bf := byteframe.NewByteFrameFromBytes(pkt)
|
||||
|
||||
opcode := bf.ReadUint16()
|
||||
if opcode != MSG_SYS_ENUMERATE_STAGE {
|
||||
t.Fatalf("opcode: got 0x%04X, want 0x%04X", opcode, MSG_SYS_ENUMERATE_STAGE)
|
||||
}
|
||||
|
||||
gotAck := bf.ReadUint32()
|
||||
if gotAck != ackHandle {
|
||||
t.Fatalf("ackHandle: got %d, want %d", gotAck, ackHandle)
|
||||
}
|
||||
|
||||
alwaysOne := bf.ReadUint8()
|
||||
if alwaysOne != 1 {
|
||||
t.Fatalf("alwaysOne: got %d, want 1", alwaysOne)
|
||||
}
|
||||
|
||||
prefixLen := bf.ReadUint8()
|
||||
if prefixLen != uint8(len(prefix)+1) {
|
||||
t.Fatalf("prefixLen: got %d, want %d", prefixLen, len(prefix)+1)
|
||||
}
|
||||
|
||||
gotPrefix := string(bf.ReadNullTerminatedBytes())
|
||||
if gotPrefix != prefix {
|
||||
t.Fatalf("prefix: got %q, want %q", gotPrefix, prefix)
|
||||
}
|
||||
|
||||
term := bf.ReadBytes(2)
|
||||
if term[0] != 0x00 || term[1] != 0x10 {
|
||||
t.Fatalf("terminator: got %02X %02X, want 00 10", term[0], term[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildEnterStagePacket verifies binary layout matches Erupe's Parse.
|
||||
func TestBuildEnterStagePacket(t *testing.T) {
|
||||
ackHandle := uint32(7)
|
||||
stageID := "sl1Ns200p0a0u0"
|
||||
|
||||
pkt := BuildEnterStagePacket(ackHandle, stageID)
|
||||
bf := byteframe.NewByteFrameFromBytes(pkt)
|
||||
|
||||
opcode := bf.ReadUint16()
|
||||
if opcode != MSG_SYS_ENTER_STAGE {
|
||||
t.Fatalf("opcode: got 0x%04X, want 0x%04X", opcode, MSG_SYS_ENTER_STAGE)
|
||||
}
|
||||
|
||||
gotAck := bf.ReadUint32()
|
||||
if gotAck != ackHandle {
|
||||
t.Fatalf("ackHandle: got %d, want %d", gotAck, ackHandle)
|
||||
}
|
||||
|
||||
isQuest := bf.ReadUint8()
|
||||
if isQuest != 0 {
|
||||
t.Fatalf("isQuest: got %d, want 0", isQuest)
|
||||
}
|
||||
|
||||
stageLen := bf.ReadUint8()
|
||||
if stageLen != uint8(len(stageID)+1) {
|
||||
t.Fatalf("stageLen: got %d, want %d", stageLen, len(stageID)+1)
|
||||
}
|
||||
|
||||
gotStage := string(bf.ReadNullTerminatedBytes())
|
||||
if gotStage != stageID {
|
||||
t.Fatalf("stageID: got %q, want %q", gotStage, stageID)
|
||||
}
|
||||
|
||||
term := bf.ReadBytes(2)
|
||||
if term[0] != 0x00 || term[1] != 0x10 {
|
||||
t.Fatalf("terminator: got %02X %02X, want 00 10", term[0], term[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPingPacket verifies MSG_SYS_PING binary layout.
|
||||
func TestBuildPingPacket(t *testing.T) {
|
||||
ackHandle := uint32(99)
|
||||
pkt := BuildPingPacket(ackHandle)
|
||||
bf := byteframe.NewByteFrameFromBytes(pkt)
|
||||
|
||||
if op := bf.ReadUint16(); op != MSG_SYS_PING {
|
||||
t.Fatalf("opcode: got 0x%04X, want 0x%04X", op, MSG_SYS_PING)
|
||||
}
|
||||
if ack := bf.ReadUint32(); ack != ackHandle {
|
||||
t.Fatalf("ackHandle: got %d, want %d", ack, ackHandle)
|
||||
}
|
||||
term := bf.ReadBytes(2)
|
||||
if term[0] != 0x00 || term[1] != 0x10 {
|
||||
t.Fatalf("terminator: got %02X %02X, want 00 10", term[0], term[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLogoutPacket verifies MSG_SYS_LOGOUT binary layout.
|
||||
func TestBuildLogoutPacket(t *testing.T) {
|
||||
pkt := BuildLogoutPacket()
|
||||
bf := byteframe.NewByteFrameFromBytes(pkt)
|
||||
|
||||
if op := bf.ReadUint16(); op != MSG_SYS_LOGOUT {
|
||||
t.Fatalf("opcode: got 0x%04X, want 0x%04X", op, MSG_SYS_LOGOUT)
|
||||
}
|
||||
if lt := bf.ReadUint8(); lt != 1 {
|
||||
t.Fatalf("logoutType: got %d, want 1", lt)
|
||||
}
|
||||
term := bf.ReadBytes(2)
|
||||
if term[0] != 0x00 || term[1] != 0x10 {
|
||||
t.Fatalf("terminator: got %02X %02X, want 00 10", term[0], term[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildIssueLogkeyPacket verifies MSG_SYS_ISSUE_LOGKEY binary layout.
|
||||
func TestBuildIssueLogkeyPacket(t *testing.T) {
|
||||
ackHandle := uint32(10)
|
||||
pkt := BuildIssueLogkeyPacket(ackHandle)
|
||||
bf := byteframe.NewByteFrameFromBytes(pkt)
|
||||
|
||||
if op := bf.ReadUint16(); op != MSG_SYS_ISSUE_LOGKEY {
|
||||
t.Fatalf("opcode: got 0x%04X, want 0x%04X", op, MSG_SYS_ISSUE_LOGKEY)
|
||||
}
|
||||
if ack := bf.ReadUint32(); ack != ackHandle {
|
||||
t.Fatalf("ackHandle: got %d, want %d", ack, ackHandle)
|
||||
}
|
||||
if v := bf.ReadUint16(); v != 0 {
|
||||
t.Fatalf("unk0: got %d, want 0", v)
|
||||
}
|
||||
if v := bf.ReadUint16(); v != 0 {
|
||||
t.Fatalf("unk1: got %d, want 0", v)
|
||||
}
|
||||
term := bf.ReadBytes(2)
|
||||
if term[0] != 0x00 || term[1] != 0x10 {
|
||||
t.Fatalf("terminator: got %02X %02X, want 00 10", term[0], term[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRightsReloadPacket verifies MSG_SYS_RIGHTS_RELOAD binary layout.
|
||||
func TestBuildRightsReloadPacket(t *testing.T) {
|
||||
ackHandle := uint32(20)
|
||||
pkt := BuildRightsReloadPacket(ackHandle)
|
||||
bf := byteframe.NewByteFrameFromBytes(pkt)
|
||||
|
||||
if op := bf.ReadUint16(); op != MSG_SYS_RIGHTS_RELOAD {
|
||||
t.Fatalf("opcode: got 0x%04X, want 0x%04X", op, MSG_SYS_RIGHTS_RELOAD)
|
||||
}
|
||||
if ack := bf.ReadUint32(); ack != ackHandle {
|
||||
t.Fatalf("ackHandle: got %d, want %d", ack, ackHandle)
|
||||
}
|
||||
if c := bf.ReadUint8(); c != 0 {
|
||||
t.Fatalf("count: got %d, want 0", c)
|
||||
}
|
||||
term := bf.ReadBytes(2)
|
||||
if term[0] != 0x00 || term[1] != 0x10 {
|
||||
t.Fatalf("terminator: got %02X %02X, want 00 10", term[0], term[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLoaddataPacket verifies MSG_MHF_LOADDATA binary layout.
|
||||
func TestBuildLoaddataPacket(t *testing.T) {
|
||||
ackHandle := uint32(30)
|
||||
pkt := BuildLoaddataPacket(ackHandle)
|
||||
bf := byteframe.NewByteFrameFromBytes(pkt)
|
||||
|
||||
if op := bf.ReadUint16(); op != MSG_MHF_LOADDATA {
|
||||
t.Fatalf("opcode: got 0x%04X, want 0x%04X", op, MSG_MHF_LOADDATA)
|
||||
}
|
||||
if ack := bf.ReadUint32(); ack != ackHandle {
|
||||
t.Fatalf("ackHandle: got %d, want %d", ack, ackHandle)
|
||||
}
|
||||
term := bf.ReadBytes(2)
|
||||
if term[0] != 0x00 || term[1] != 0x10 {
|
||||
t.Fatalf("terminator: got %02X %02X, want 00 10", term[0], term[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCastBinaryPacket verifies MSG_SYS_CAST_BINARY binary layout.
|
||||
func TestBuildCastBinaryPacket(t *testing.T) {
|
||||
payload := []byte{0xDE, 0xAD, 0xBE, 0xEF}
|
||||
pkt := BuildCastBinaryPacket(0x03, 1, payload)
|
||||
bf := byteframe.NewByteFrameFromBytes(pkt)
|
||||
|
||||
if op := bf.ReadUint16(); op != MSG_SYS_CAST_BINARY {
|
||||
t.Fatalf("opcode: got 0x%04X, want 0x%04X", op, MSG_SYS_CAST_BINARY)
|
||||
}
|
||||
if unk := bf.ReadUint32(); unk != 0 {
|
||||
t.Fatalf("unk: got %d, want 0", unk)
|
||||
}
|
||||
if bt := bf.ReadUint8(); bt != 0x03 {
|
||||
t.Fatalf("broadcastType: got %d, want 3", bt)
|
||||
}
|
||||
if mt := bf.ReadUint8(); mt != 1 {
|
||||
t.Fatalf("messageType: got %d, want 1", mt)
|
||||
}
|
||||
if ds := bf.ReadUint16(); ds != uint16(len(payload)) {
|
||||
t.Fatalf("dataSize: got %d, want %d", ds, len(payload))
|
||||
}
|
||||
gotPayload := bf.ReadBytes(uint(len(payload)))
|
||||
for i, b := range payload {
|
||||
if gotPayload[i] != b {
|
||||
t.Fatalf("payload[%d]: got 0x%02X, want 0x%02X", i, gotPayload[i], b)
|
||||
}
|
||||
}
|
||||
term := bf.ReadBytes(2)
|
||||
if term[0] != 0x00 || term[1] != 0x10 {
|
||||
t.Fatalf("terminator: got %02X %02X, want 00 10", term[0], term[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildChatPayload verifies the MsgBinChat inner binary layout and SJIS encoding.
|
||||
func TestBuildChatPayload(t *testing.T) {
|
||||
chatType := uint8(1)
|
||||
message := "Hello"
|
||||
senderName := "TestUser"
|
||||
|
||||
payload := BuildChatPayload(chatType, message, senderName)
|
||||
bf := byteframe.NewByteFrameFromBytes(payload)
|
||||
|
||||
if unk := bf.ReadUint8(); unk != 0 {
|
||||
t.Fatalf("unk0: got %d, want 0", unk)
|
||||
}
|
||||
if ct := bf.ReadUint8(); ct != chatType {
|
||||
t.Fatalf("chatType: got %d, want %d", ct, chatType)
|
||||
}
|
||||
if flags := bf.ReadUint16(); flags != 0 {
|
||||
t.Fatalf("flags: got %d, want 0", flags)
|
||||
}
|
||||
nameLen := bf.ReadUint16()
|
||||
msgLen := bf.ReadUint16()
|
||||
// "Hello" in ASCII/SJIS = 5 bytes + 1 null = 6
|
||||
if msgLen != 6 {
|
||||
t.Fatalf("messageLen: got %d, want 6", msgLen)
|
||||
}
|
||||
// "TestUser" in ASCII/SJIS = 8 bytes + 1 null = 9
|
||||
if nameLen != 9 {
|
||||
t.Fatalf("senderNameLen: got %d, want 9", nameLen)
|
||||
}
|
||||
|
||||
gotMsg := string(bf.ReadNullTerminatedBytes())
|
||||
if gotMsg != message {
|
||||
t.Fatalf("message: got %q, want %q", gotMsg, message)
|
||||
}
|
||||
gotName := string(bf.ReadNullTerminatedBytes())
|
||||
if gotName != senderName {
|
||||
t.Fatalf("senderName: got %q, want %q", gotName, senderName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildEnumerateQuestPacket verifies MSG_MHF_ENUMERATE_QUEST binary layout.
|
||||
func TestBuildEnumerateQuestPacket(t *testing.T) {
|
||||
ackHandle := uint32(40)
|
||||
world := uint8(2)
|
||||
counter := uint16(100)
|
||||
offset := uint16(50)
|
||||
|
||||
pkt := BuildEnumerateQuestPacket(ackHandle, world, counter, offset)
|
||||
bf := byteframe.NewByteFrameFromBytes(pkt)
|
||||
|
||||
if op := bf.ReadUint16(); op != MSG_MHF_ENUMERATE_QUEST {
|
||||
t.Fatalf("opcode: got 0x%04X, want 0x%04X", op, MSG_MHF_ENUMERATE_QUEST)
|
||||
}
|
||||
if ack := bf.ReadUint32(); ack != ackHandle {
|
||||
t.Fatalf("ackHandle: got %d, want %d", ack, ackHandle)
|
||||
}
|
||||
if u0 := bf.ReadUint8(); u0 != 0 {
|
||||
t.Fatalf("unk0: got %d, want 0", u0)
|
||||
}
|
||||
if w := bf.ReadUint8(); w != world {
|
||||
t.Fatalf("world: got %d, want %d", w, world)
|
||||
}
|
||||
if c := bf.ReadUint16(); c != counter {
|
||||
t.Fatalf("counter: got %d, want %d", c, counter)
|
||||
}
|
||||
if o := bf.ReadUint16(); o != offset {
|
||||
t.Fatalf("offset: got %d, want %d", o, offset)
|
||||
}
|
||||
if u1 := bf.ReadUint8(); u1 != 0 {
|
||||
t.Fatalf("unk1: got %d, want 0", u1)
|
||||
}
|
||||
term := bf.ReadBytes(2)
|
||||
if term[0] != 0x00 || term[1] != 0x10 {
|
||||
t.Fatalf("terminator: got %02X %02X, want 00 10", term[0], term[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildGetWeeklySchedulePacket verifies MSG_MHF_GET_WEEKLY_SCHEDULE binary layout.
|
||||
func TestBuildGetWeeklySchedulePacket(t *testing.T) {
|
||||
ackHandle := uint32(50)
|
||||
pkt := BuildGetWeeklySchedulePacket(ackHandle)
|
||||
bf := byteframe.NewByteFrameFromBytes(pkt)
|
||||
|
||||
if op := bf.ReadUint16(); op != MSG_MHF_GET_WEEKLY_SCHED {
|
||||
t.Fatalf("opcode: got 0x%04X, want 0x%04X", op, MSG_MHF_GET_WEEKLY_SCHED)
|
||||
}
|
||||
if ack := bf.ReadUint32(); ack != ackHandle {
|
||||
t.Fatalf("ackHandle: got %d, want %d", ack, ackHandle)
|
||||
}
|
||||
term := bf.ReadBytes(2)
|
||||
if term[0] != 0x00 || term[1] != 0x10 {
|
||||
t.Fatalf("terminator: got %02X %02X, want 00 10", term[0], term[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpcodeValues verifies opcode constants match Erupe's iota-based enum.
|
||||
func TestOpcodeValues(t *testing.T) {
|
||||
_ = binary.BigEndian // ensure import used
|
||||
tests := []struct {
|
||||
name string
|
||||
got uint16
|
||||
want uint16
|
||||
}{
|
||||
{"MSG_SYS_ACK", MSG_SYS_ACK, 0x0012},
|
||||
{"MSG_SYS_LOGIN", MSG_SYS_LOGIN, 0x0014},
|
||||
{"MSG_SYS_LOGOUT", MSG_SYS_LOGOUT, 0x0015},
|
||||
{"MSG_SYS_PING", MSG_SYS_PING, 0x0017},
|
||||
{"MSG_SYS_CAST_BINARY", MSG_SYS_CAST_BINARY, 0x0018},
|
||||
{"MSG_SYS_TIME", MSG_SYS_TIME, 0x001A},
|
||||
{"MSG_SYS_CASTED_BINARY", MSG_SYS_CASTED_BINARY, 0x001B},
|
||||
{"MSG_SYS_ISSUE_LOGKEY", MSG_SYS_ISSUE_LOGKEY, 0x001D},
|
||||
{"MSG_SYS_ENTER_STAGE", MSG_SYS_ENTER_STAGE, 0x0022},
|
||||
{"MSG_SYS_ENUMERATE_STAGE", MSG_SYS_ENUMERATE_STAGE, 0x002F},
|
||||
{"MSG_SYS_INSERT_USER", MSG_SYS_INSERT_USER, 0x0050},
|
||||
{"MSG_SYS_DELETE_USER", MSG_SYS_DELETE_USER, 0x0051},
|
||||
{"MSG_SYS_UPDATE_RIGHT", MSG_SYS_UPDATE_RIGHT, 0x0058},
|
||||
{"MSG_SYS_RIGHTS_RELOAD", MSG_SYS_RIGHTS_RELOAD, 0x005D},
|
||||
{"MSG_MHF_LOADDATA", MSG_MHF_LOADDATA, 0x0061},
|
||||
{"MSG_MHF_ENUMERATE_QUEST", MSG_MHF_ENUMERATE_QUEST, 0x009F},
|
||||
{"MSG_MHF_GET_WEEKLY_SCHED", MSG_MHF_GET_WEEKLY_SCHED, 0x00E1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s: got 0x%04X, want 0x%04X", tt.name, tt.got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
106
cmd/protbot/protocol/sign.go
Normal file
106
cmd/protbot/protocol/sign.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"erupe-ce/common/byteframe"
|
||||
"erupe-ce/common/stringsupport"
|
||||
|
||||
"erupe-ce/cmd/protbot/conn"
|
||||
)
|
||||
|
||||
// SignResult holds the parsed response from a successful DSGN sign-in.
|
||||
type SignResult struct {
|
||||
TokenID uint32
|
||||
TokenString string // 16 raw bytes as string
|
||||
Timestamp uint32
|
||||
EntranceAddr string
|
||||
CharIDs []uint32
|
||||
}
|
||||
|
||||
// DoSign connects to the sign server and performs a DSGN login.
|
||||
// Reference: Erupe server/signserver/session.go (handleDSGN) and dsgn_resp.go (makeSignResponse).
|
||||
func DoSign(addr, username, password string) (*SignResult, error) {
|
||||
c, err := conn.DialWithInit(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign connect: %w", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
// Build DSGN request: "DSGN:041" + \x00 + SJIS(user) + \x00 + SJIS(pass) + \x00 + \x00
|
||||
// The server reads: null-terminated request type, null-terminated user, null-terminated pass, null-terminated unk.
|
||||
// The request type has a 3-char version suffix (e.g. "041" for ZZ client mode 41) that the server strips.
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteNullTerminatedBytes([]byte("DSGN:041")) // reqType with version suffix (server strips last 3 chars to get "DSGN:")
|
||||
bf.WriteNullTerminatedBytes(stringsupport.UTF8ToSJIS(username))
|
||||
bf.WriteNullTerminatedBytes(stringsupport.UTF8ToSJIS(password))
|
||||
bf.WriteUint8(0) // Unk null-terminated empty string
|
||||
|
||||
if err := c.SendPacket(bf.Data()); err != nil {
|
||||
return nil, fmt.Errorf("sign send: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.ReadPacket()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign recv: %w", err)
|
||||
}
|
||||
|
||||
return parseSignResponse(resp)
|
||||
}
|
||||
|
||||
// parseSignResponse parses the binary response from the sign server.
|
||||
// Reference: Erupe server/signserver/dsgn_resp.go:makeSignResponse
|
||||
func parseSignResponse(data []byte) (*SignResult, error) {
|
||||
if len(data) < 1 {
|
||||
return nil, fmt.Errorf("empty sign response")
|
||||
}
|
||||
|
||||
rbf := byteframe.NewByteFrameFromBytes(data)
|
||||
|
||||
resultCode := rbf.ReadUint8()
|
||||
if resultCode != 1 { // SIGN_SUCCESS = 1
|
||||
return nil, fmt.Errorf("sign failed with code %d", resultCode)
|
||||
}
|
||||
|
||||
patchCount := rbf.ReadUint8() // patch server count (usually 2)
|
||||
_ = rbf.ReadUint8() // entrance server count (usually 1)
|
||||
charCount := rbf.ReadUint8() // character count
|
||||
|
||||
result := &SignResult{}
|
||||
result.TokenID = rbf.ReadUint32()
|
||||
result.TokenString = string(rbf.ReadBytes(16)) // 16 raw bytes
|
||||
result.Timestamp = rbf.ReadUint32()
|
||||
|
||||
// Skip patch server URLs (pascal strings with uint8 length prefix)
|
||||
for i := uint8(0); i < patchCount; i++ {
|
||||
strLen := rbf.ReadUint8()
|
||||
_ = rbf.ReadBytes(uint(strLen))
|
||||
}
|
||||
|
||||
// Read entrance server address (pascal string with uint8 length prefix)
|
||||
entranceLen := rbf.ReadUint8()
|
||||
result.EntranceAddr = string(rbf.ReadBytes(uint(entranceLen - 1)))
|
||||
_ = rbf.ReadUint8() // null terminator
|
||||
|
||||
// Read character entries
|
||||
for i := uint8(0); i < charCount; i++ {
|
||||
charID := rbf.ReadUint32()
|
||||
result.CharIDs = append(result.CharIDs, charID)
|
||||
|
||||
_ = rbf.ReadUint16() // HR
|
||||
_ = rbf.ReadUint16() // WeaponType
|
||||
_ = rbf.ReadUint32() // LastLogin
|
||||
_ = rbf.ReadUint8() // IsFemale
|
||||
_ = rbf.ReadUint8() // IsNewCharacter
|
||||
_ = rbf.ReadUint8() // Old GR
|
||||
_ = rbf.ReadUint8() // Use uint16 GR flag
|
||||
_ = rbf.ReadBytes(16) // Character name (padded)
|
||||
_ = rbf.ReadBytes(32) // Unk desc string (padded)
|
||||
// ZZ mode: additional fields
|
||||
_ = rbf.ReadUint16() // GR
|
||||
_ = rbf.ReadUint8() // Unk
|
||||
_ = rbf.ReadUint8() // Unk
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
74
cmd/protbot/scenario/chat.go
Normal file
74
cmd/protbot/scenario/chat.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"erupe-ce/common/byteframe"
|
||||
"erupe-ce/common/stringsupport"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// ChatMessage holds a parsed incoming chat message.
|
||||
type ChatMessage struct {
|
||||
ChatType uint8
|
||||
SenderName string
|
||||
Message string
|
||||
}
|
||||
|
||||
// SendChat sends a chat message via MSG_SYS_CAST_BINARY with a MsgBinChat payload.
|
||||
// broadcastType controls delivery scope: 0x03 = stage, 0x06 = world.
|
||||
func SendChat(ch *protocol.ChannelConn, broadcastType, chatType uint8, message, senderName string) error {
|
||||
payload := protocol.BuildChatPayload(chatType, message, senderName)
|
||||
pkt := protocol.BuildCastBinaryPacket(broadcastType, 1, payload)
|
||||
fmt.Printf("[chat] Sending chat (type=%d, broadcast=%d): %s\n", chatType, broadcastType, message)
|
||||
return ch.SendPacket(pkt)
|
||||
}
|
||||
|
||||
// ChatCallback is invoked when a chat message is received.
|
||||
type ChatCallback func(msg ChatMessage)
|
||||
|
||||
// ListenChat registers a handler on MSG_SYS_CASTED_BINARY that parses chat
|
||||
// messages (messageType=1) and invokes the callback.
|
||||
func ListenChat(ch *protocol.ChannelConn, cb ChatCallback) {
|
||||
ch.OnPacket(protocol.MSG_SYS_CASTED_BINARY, func(opcode uint16, data []byte) {
|
||||
// MSG_SYS_CASTED_BINARY layout from server:
|
||||
// uint32 unk
|
||||
// uint8 broadcastType
|
||||
// uint8 messageType
|
||||
// uint16 dataSize
|
||||
// []byte payload
|
||||
if len(data) < 8 {
|
||||
return
|
||||
}
|
||||
messageType := data[5]
|
||||
if messageType != 1 { // Only handle chat messages.
|
||||
return
|
||||
}
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
_ = bf.ReadUint32() // unk
|
||||
_ = bf.ReadUint8() // broadcastType
|
||||
_ = bf.ReadUint8() // messageType
|
||||
dataSize := bf.ReadUint16()
|
||||
if dataSize == 0 {
|
||||
return
|
||||
}
|
||||
payload := bf.ReadBytes(uint(dataSize))
|
||||
|
||||
// Parse MsgBinChat inner payload.
|
||||
pbf := byteframe.NewByteFrameFromBytes(payload)
|
||||
_ = pbf.ReadUint8() // unk0
|
||||
chatType := pbf.ReadUint8()
|
||||
_ = pbf.ReadUint16() // flags
|
||||
_ = pbf.ReadUint16() // senderNameLen
|
||||
_ = pbf.ReadUint16() // messageLen
|
||||
msg := stringsupport.SJISToUTF8Lossy(pbf.ReadNullTerminatedBytes())
|
||||
sender := stringsupport.SJISToUTF8Lossy(pbf.ReadNullTerminatedBytes())
|
||||
|
||||
cb(ChatMessage{
|
||||
ChatType: chatType,
|
||||
SenderName: sender,
|
||||
Message: msg,
|
||||
})
|
||||
})
|
||||
}
|
||||
82
cmd/protbot/scenario/login.go
Normal file
82
cmd/protbot/scenario/login.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Package scenario provides high-level MHF protocol flows.
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// LoginResult holds the outcome of a full login flow.
|
||||
type LoginResult struct {
|
||||
Sign *protocol.SignResult
|
||||
Servers []protocol.ServerEntry
|
||||
Channel *protocol.ChannelConn
|
||||
}
|
||||
|
||||
// Login performs the full sign → entrance → channel login flow.
|
||||
func Login(signAddr, username, password string) (*LoginResult, error) {
|
||||
// Step 1: Sign server authentication.
|
||||
fmt.Printf("[sign] Connecting to %s...\n", signAddr)
|
||||
sign, err := protocol.DoSign(signAddr, username, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign: %w", err)
|
||||
}
|
||||
fmt.Printf("[sign] OK — tokenID=%d, %d character(s), entrance=%s\n",
|
||||
sign.TokenID, len(sign.CharIDs), sign.EntranceAddr)
|
||||
|
||||
if len(sign.CharIDs) == 0 {
|
||||
return nil, fmt.Errorf("no characters on account")
|
||||
}
|
||||
|
||||
// Step 2: Entrance server — get server/channel list.
|
||||
fmt.Printf("[entrance] Connecting to %s...\n", sign.EntranceAddr)
|
||||
servers, err := protocol.DoEntrance(sign.EntranceAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entrance: %w", err)
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
return nil, fmt.Errorf("no channels available")
|
||||
}
|
||||
for i, s := range servers {
|
||||
fmt.Printf("[entrance] [%d] %s — %s:%d\n", i, s.Name, s.IP, s.Port)
|
||||
}
|
||||
|
||||
// Step 3: Connect to the first channel server.
|
||||
first := servers[0]
|
||||
channelAddr := fmt.Sprintf("%s:%d", first.IP, first.Port)
|
||||
fmt.Printf("[channel] Connecting to %s...\n", channelAddr)
|
||||
ch, err := protocol.ConnectChannel(channelAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("channel connect: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Send MSG_SYS_LOGIN.
|
||||
charID := sign.CharIDs[0]
|
||||
ack := ch.NextAckHandle()
|
||||
loginPkt := protocol.BuildLoginPacket(ack, charID, sign.TokenID, sign.TokenString)
|
||||
fmt.Printf("[channel] Sending MSG_SYS_LOGIN (charID=%d, ackHandle=%d)...\n", charID, ack)
|
||||
if err := ch.SendPacket(loginPkt); err != nil {
|
||||
_ = ch.Close()
|
||||
return nil, fmt.Errorf("channel send login: %w", err)
|
||||
}
|
||||
|
||||
resp, err := ch.WaitForAck(ack, 10*time.Second)
|
||||
if err != nil {
|
||||
_ = ch.Close()
|
||||
return nil, fmt.Errorf("channel login ack: %w", err)
|
||||
}
|
||||
if resp.ErrorCode != 0 {
|
||||
_ = ch.Close()
|
||||
return nil, fmt.Errorf("channel login failed: error code %d", resp.ErrorCode)
|
||||
}
|
||||
fmt.Printf("[channel] Login ACK received (error=%d, %d bytes data)\n",
|
||||
resp.ErrorCode, len(resp.Data))
|
||||
|
||||
return &LoginResult{
|
||||
Sign: sign,
|
||||
Servers: servers,
|
||||
Channel: ch,
|
||||
}, nil
|
||||
}
|
||||
17
cmd/protbot/scenario/logout.go
Normal file
17
cmd/protbot/scenario/logout.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// Logout sends MSG_SYS_LOGOUT and closes the channel connection.
|
||||
func Logout(ch *protocol.ChannelConn) error {
|
||||
fmt.Println("[logout] Sending MSG_SYS_LOGOUT...")
|
||||
if err := ch.SendPacket(protocol.BuildLogoutPacket()); err != nil {
|
||||
_ = ch.Close()
|
||||
return fmt.Errorf("logout send: %w", err)
|
||||
}
|
||||
return ch.Close()
|
||||
}
|
||||
31
cmd/protbot/scenario/quest.go
Normal file
31
cmd/protbot/scenario/quest.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// EnumerateQuests sends MSG_MHF_ENUMERATE_QUEST and returns the raw quest list data.
|
||||
func EnumerateQuests(ch *protocol.ChannelConn, world uint8, counter uint16) ([]byte, error) {
|
||||
ack := ch.NextAckHandle()
|
||||
pkt := protocol.BuildEnumerateQuestPacket(ack, world, counter, 0)
|
||||
fmt.Printf("[quest] Sending MSG_MHF_ENUMERATE_QUEST (world=%d, counter=%d, ackHandle=%d)...\n",
|
||||
world, counter, ack)
|
||||
if err := ch.SendPacket(pkt); err != nil {
|
||||
return nil, fmt.Errorf("enumerate quest send: %w", err)
|
||||
}
|
||||
|
||||
resp, err := ch.WaitForAck(ack, 15*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enumerate quest ack: %w", err)
|
||||
}
|
||||
if resp.ErrorCode != 0 {
|
||||
return nil, fmt.Errorf("enumerate quest failed: error code %d", resp.ErrorCode)
|
||||
}
|
||||
fmt.Printf("[quest] ENUMERATE_QUEST ACK (error=%d, %d bytes data)\n",
|
||||
resp.ErrorCode, len(resp.Data))
|
||||
|
||||
return resp.Data, nil
|
||||
}
|
||||
50
cmd/protbot/scenario/session.go
Normal file
50
cmd/protbot/scenario/session.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// SetupSession performs the post-login session setup: ISSUE_LOGKEY, RIGHTS_RELOAD, LOADDATA.
|
||||
// Returns the loaddata response blob for inspection.
|
||||
func SetupSession(ch *protocol.ChannelConn, charID uint32) ([]byte, error) {
|
||||
// Step 1: Issue logkey.
|
||||
ack := ch.NextAckHandle()
|
||||
fmt.Printf("[session] Sending MSG_SYS_ISSUE_LOGKEY (ackHandle=%d)...\n", ack)
|
||||
if err := ch.SendPacket(protocol.BuildIssueLogkeyPacket(ack)); err != nil {
|
||||
return nil, fmt.Errorf("issue logkey send: %w", err)
|
||||
}
|
||||
resp, err := ch.WaitForAck(ack, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue logkey ack: %w", err)
|
||||
}
|
||||
fmt.Printf("[session] ISSUE_LOGKEY ACK (error=%d, %d bytes)\n", resp.ErrorCode, len(resp.Data))
|
||||
|
||||
// Step 2: Rights reload.
|
||||
ack = ch.NextAckHandle()
|
||||
fmt.Printf("[session] Sending MSG_SYS_RIGHTS_RELOAD (ackHandle=%d)...\n", ack)
|
||||
if err := ch.SendPacket(protocol.BuildRightsReloadPacket(ack)); err != nil {
|
||||
return nil, fmt.Errorf("rights reload send: %w", err)
|
||||
}
|
||||
resp, err = ch.WaitForAck(ack, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rights reload ack: %w", err)
|
||||
}
|
||||
fmt.Printf("[session] RIGHTS_RELOAD ACK (error=%d, %d bytes)\n", resp.ErrorCode, len(resp.Data))
|
||||
|
||||
// Step 3: Load save data.
|
||||
ack = ch.NextAckHandle()
|
||||
fmt.Printf("[session] Sending MSG_MHF_LOADDATA (ackHandle=%d)...\n", ack)
|
||||
if err := ch.SendPacket(protocol.BuildLoaddataPacket(ack)); err != nil {
|
||||
return nil, fmt.Errorf("loaddata send: %w", err)
|
||||
}
|
||||
resp, err = ch.WaitForAck(ack, 30*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loaddata ack: %w", err)
|
||||
}
|
||||
fmt.Printf("[session] LOADDATA ACK (error=%d, %d bytes)\n", resp.ErrorCode, len(resp.Data))
|
||||
|
||||
return resp.Data, nil
|
||||
}
|
||||
111
cmd/protbot/scenario/stage.go
Normal file
111
cmd/protbot/scenario/stage.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"erupe-ce/common/byteframe"
|
||||
|
||||
"erupe-ce/cmd/protbot/protocol"
|
||||
)
|
||||
|
||||
// StageInfo holds a parsed stage entry from MSG_SYS_ENUMERATE_STAGE response.
|
||||
type StageInfo struct {
|
||||
ID string
|
||||
Reserved uint16
|
||||
Clients uint16
|
||||
Displayed uint16
|
||||
MaxPlayers uint16
|
||||
Flags uint8
|
||||
}
|
||||
|
||||
// EnterLobby enumerates available lobby stages and enters the first one.
|
||||
func EnterLobby(ch *protocol.ChannelConn) error {
|
||||
// Step 1: Enumerate stages with "sl1Ns" prefix (main lobby stages).
|
||||
ack := ch.NextAckHandle()
|
||||
enumPkt := protocol.BuildEnumerateStagePacket(ack, "sl1Ns")
|
||||
fmt.Printf("[stage] Sending MSG_SYS_ENUMERATE_STAGE (prefix=\"sl1Ns\", ackHandle=%d)...\n", ack)
|
||||
if err := ch.SendPacket(enumPkt); err != nil {
|
||||
return fmt.Errorf("enumerate stage send: %w", err)
|
||||
}
|
||||
|
||||
resp, err := ch.WaitForAck(ack, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enumerate stage ack: %w", err)
|
||||
}
|
||||
if resp.ErrorCode != 0 {
|
||||
return fmt.Errorf("enumerate stage failed: error code %d", resp.ErrorCode)
|
||||
}
|
||||
|
||||
stages := parseEnumerateStageResponse(resp.Data)
|
||||
fmt.Printf("[stage] Found %d stage(s)\n", len(stages))
|
||||
for i, s := range stages {
|
||||
fmt.Printf("[stage] [%d] %s — %d/%d players, flags=0x%02X\n",
|
||||
i, s.ID, s.Clients, s.MaxPlayers, s.Flags)
|
||||
}
|
||||
|
||||
// Step 2: Enter the default lobby stage.
|
||||
// Even if no stages were enumerated, use the default stage ID.
|
||||
stageID := "sl1Ns200p0a0u0"
|
||||
if len(stages) > 0 {
|
||||
stageID = stages[0].ID
|
||||
}
|
||||
|
||||
ack = ch.NextAckHandle()
|
||||
enterPkt := protocol.BuildEnterStagePacket(ack, stageID)
|
||||
fmt.Printf("[stage] Sending MSG_SYS_ENTER_STAGE (stageID=%q, ackHandle=%d)...\n", stageID, ack)
|
||||
if err := ch.SendPacket(enterPkt); err != nil {
|
||||
return fmt.Errorf("enter stage send: %w", err)
|
||||
}
|
||||
|
||||
resp, err = ch.WaitForAck(ack, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enter stage ack: %w", err)
|
||||
}
|
||||
if resp.ErrorCode != 0 {
|
||||
return fmt.Errorf("enter stage failed: error code %d", resp.ErrorCode)
|
||||
}
|
||||
fmt.Printf("[stage] Enter stage ACK received (error=%d)\n", resp.ErrorCode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseEnumerateStageResponse parses the ACK data from MSG_SYS_ENUMERATE_STAGE.
|
||||
// Reference: Erupe server/channelserver/handlers_stage.go (handleMsgSysEnumerateStage)
|
||||
func parseEnumerateStageResponse(data []byte) []StageInfo {
|
||||
if len(data) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
count := bf.ReadUint16()
|
||||
|
||||
var stages []StageInfo
|
||||
for i := uint16(0); i < count; i++ {
|
||||
s := StageInfo{}
|
||||
s.Reserved = bf.ReadUint16()
|
||||
s.Clients = bf.ReadUint16()
|
||||
s.Displayed = bf.ReadUint16()
|
||||
s.MaxPlayers = bf.ReadUint16()
|
||||
s.Flags = bf.ReadUint8()
|
||||
|
||||
// Stage ID is a pascal string with uint8 length prefix.
|
||||
strLen := bf.ReadUint8()
|
||||
if strLen > 0 {
|
||||
idBytes := bf.ReadBytes(uint(strLen))
|
||||
// Remove null terminator if present.
|
||||
if len(idBytes) > 0 && idBytes[len(idBytes)-1] == 0 {
|
||||
idBytes = idBytes[:len(idBytes)-1]
|
||||
}
|
||||
s.ID = string(idBytes)
|
||||
}
|
||||
|
||||
stages = append(stages, s)
|
||||
}
|
||||
|
||||
// After stages: uint32 timestamp, uint32 max clan members (we ignore these).
|
||||
_ = binary.BigEndian // suppress unused import if needed
|
||||
|
||||
return stages
|
||||
}
|
||||
135
cmd/replay/compare.go
Normal file
135
cmd/replay/compare.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"erupe-ce/network"
|
||||
"erupe-ce/network/pcap"
|
||||
)
|
||||
|
||||
// maxPayloadDiffs is the maximum number of byte-level diffs to report per packet.
|
||||
const maxPayloadDiffs = 16
|
||||
|
||||
// ByteDiff describes a single byte difference between expected and actual payloads.
|
||||
type ByteDiff struct {
|
||||
Offset int
|
||||
Expected byte
|
||||
Actual byte
|
||||
}
|
||||
|
||||
// PacketDiff describes a difference between an expected and actual packet.
|
||||
type PacketDiff struct {
|
||||
Index int
|
||||
Expected pcap.PacketRecord
|
||||
Actual *pcap.PacketRecord // nil if no response received
|
||||
OpcodeMismatch bool
|
||||
SizeDelta int
|
||||
PayloadDiffs []ByteDiff // byte-level diffs (when opcodes match and sizes match)
|
||||
}
|
||||
|
||||
func (d PacketDiff) String() string {
|
||||
if d.Actual == nil {
|
||||
if d.Expected.Opcode == 0 {
|
||||
return fmt.Sprintf("#%d: unexpected extra response 0x%04X (%s)",
|
||||
d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode))
|
||||
}
|
||||
return fmt.Sprintf("#%d: expected 0x%04X (%s), got no response",
|
||||
d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode))
|
||||
}
|
||||
if d.OpcodeMismatch {
|
||||
return fmt.Sprintf("#%d: opcode mismatch: expected 0x%04X (%s), got 0x%04X (%s)",
|
||||
d.Index,
|
||||
d.Expected.Opcode, network.PacketID(d.Expected.Opcode),
|
||||
d.Actual.Opcode, network.PacketID(d.Actual.Opcode))
|
||||
}
|
||||
if d.SizeDelta != 0 {
|
||||
return fmt.Sprintf("#%d: 0x%04X (%s) size delta %+d bytes",
|
||||
d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode), d.SizeDelta)
|
||||
}
|
||||
if len(d.PayloadDiffs) > 0 {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "#%d: 0x%04X (%s) %d byte diff(s):",
|
||||
d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode), len(d.PayloadDiffs))
|
||||
for _, bd := range d.PayloadDiffs {
|
||||
fmt.Fprintf(&sb, " [0x%04X: %02X→%02X]", bd.Offset, bd.Expected, bd.Actual)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
return fmt.Sprintf("#%d: 0x%04X (%s) unknown diff",
|
||||
d.Index, d.Expected.Opcode, network.PacketID(d.Expected.Opcode))
|
||||
}
|
||||
|
||||
// ComparePackets compares expected server responses against actual responses.
|
||||
// Only compares S→C packets (server responses).
|
||||
func ComparePackets(expected, actual []pcap.PacketRecord) []PacketDiff {
|
||||
expectedS2C := pcap.FilterByDirection(expected, pcap.DirServerToClient)
|
||||
actualS2C := pcap.FilterByDirection(actual, pcap.DirServerToClient)
|
||||
|
||||
var diffs []PacketDiff
|
||||
for i, exp := range expectedS2C {
|
||||
if i >= len(actualS2C) {
|
||||
diffs = append(diffs, PacketDiff{
|
||||
Index: i,
|
||||
Expected: exp,
|
||||
Actual: nil,
|
||||
})
|
||||
continue
|
||||
}
|
||||
act := actualS2C[i]
|
||||
if exp.Opcode != act.Opcode {
|
||||
diffs = append(diffs, PacketDiff{
|
||||
Index: i,
|
||||
Expected: exp,
|
||||
Actual: &act,
|
||||
OpcodeMismatch: true,
|
||||
})
|
||||
} else if len(exp.Payload) != len(act.Payload) {
|
||||
diffs = append(diffs, PacketDiff{
|
||||
Index: i,
|
||||
Expected: exp,
|
||||
Actual: &act,
|
||||
SizeDelta: len(act.Payload) - len(exp.Payload),
|
||||
})
|
||||
} else {
|
||||
// Same opcode and size — check for byte-level diffs.
|
||||
byteDiffs := comparePayloads(exp.Payload, act.Payload)
|
||||
if len(byteDiffs) > 0 {
|
||||
diffs = append(diffs, PacketDiff{
|
||||
Index: i,
|
||||
Expected: exp,
|
||||
Actual: &act,
|
||||
PayloadDiffs: byteDiffs,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extra actual packets beyond expected.
|
||||
for i := len(expectedS2C); i < len(actualS2C); i++ {
|
||||
act := actualS2C[i]
|
||||
diffs = append(diffs, PacketDiff{
|
||||
Index: i,
|
||||
Expected: pcap.PacketRecord{},
|
||||
Actual: &act,
|
||||
})
|
||||
}
|
||||
|
||||
return diffs
|
||||
}
|
||||
|
||||
// comparePayloads returns byte-level diffs between two equal-length payloads.
|
||||
// Returns at most maxPayloadDiffs entries.
|
||||
func comparePayloads(expected, actual []byte) []ByteDiff {
|
||||
var diffs []ByteDiff
|
||||
for i := 0; i < len(expected) && len(diffs) < maxPayloadDiffs; i++ {
|
||||
if expected[i] != actual[i] {
|
||||
diffs = append(diffs, ByteDiff{
|
||||
Offset: i,
|
||||
Expected: expected[i],
|
||||
Actual: actual[i],
|
||||
})
|
||||
}
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
397
cmd/replay/main.go
Normal file
397
cmd/replay/main.go
Normal file
@@ -0,0 +1,397 @@
|
||||
// replay is a CLI tool for inspecting and replaying .mhfr packet capture files.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// replay --capture file.mhfr --mode dump # Human-readable text output
|
||||
// replay --capture file.mhfr --mode json # JSON export
|
||||
// replay --capture file.mhfr --mode stats # Opcode histogram, duration, counts
|
||||
// replay --capture file.mhfr --mode replay --target 127.0.0.1:54001 --no-auth # Replay against live server
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"erupe-ce/cmd/protbot/conn"
|
||||
"erupe-ce/network"
|
||||
"erupe-ce/network/pcap"
|
||||
)
|
||||
|
||||
// MSG_SYS_PING opcode for auto-responding to server pings.
|
||||
const opcodeSysPing = 0x0017
|
||||
|
||||
func main() {
|
||||
capturePath := flag.String("capture", "", "Path to .mhfr capture file (required)")
|
||||
mode := flag.String("mode", "dump", "Mode: dump, json, stats, replay")
|
||||
target := flag.String("target", "", "Target server address for replay mode (host:port)")
|
||||
speed := flag.Float64("speed", 1.0, "Replay speed multiplier (e.g. 2.0 = 2x faster)")
|
||||
noAuth := flag.Bool("no-auth", false, "Skip auth token patching (requires DisableTokenCheck on server)")
|
||||
_ = noAuth // currently only no-auth mode is supported
|
||||
flag.Parse()
|
||||
|
||||
if *capturePath == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --capture is required")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch *mode {
|
||||
case "dump":
|
||||
if err := runDump(*capturePath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "dump failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "json":
|
||||
if err := runJSON(*capturePath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "json failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "stats":
|
||||
if err := runStats(*capturePath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "stats failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "replay":
|
||||
if *target == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --target is required for replay mode")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := runReplay(*capturePath, *target, *speed); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "replay failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown mode: %s\n", *mode)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func openCapture(path string) (*pcap.Reader, *os.File, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("open capture: %w", err)
|
||||
}
|
||||
r, err := pcap.NewReader(f)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, nil, fmt.Errorf("read capture: %w", err)
|
||||
}
|
||||
return r, f, nil
|
||||
}
|
||||
|
||||
func readAllPackets(r *pcap.Reader) ([]pcap.PacketRecord, error) {
|
||||
var records []pcap.PacketRecord
|
||||
for {
|
||||
rec, err := r.ReadPacket()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return records, err
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func runReplay(path, target string, speed float64) error {
|
||||
r, f, err := openCapture(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
records, err := readAllPackets(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c2s := pcap.FilterByDirection(records, pcap.DirClientToServer)
|
||||
expectedS2C := pcap.FilterByDirection(records, pcap.DirServerToClient)
|
||||
|
||||
if len(c2s) == 0 {
|
||||
fmt.Println("No C→S packets in capture, nothing to replay.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("=== Replay: %s ===\n", path)
|
||||
fmt.Printf("Server type: %s Target: %s Speed: %.1fx\n", r.Header.ServerType, target, speed)
|
||||
fmt.Printf("C→S packets to send: %d Expected S→C responses: %d\n\n", len(c2s), len(expectedS2C))
|
||||
|
||||
// Connect based on server type.
|
||||
var mhf *conn.MHFConn
|
||||
switch r.Header.ServerType {
|
||||
case pcap.ServerTypeChannel:
|
||||
mhf, err = conn.DialDirect(target)
|
||||
default:
|
||||
mhf, err = conn.DialWithInit(target)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to %s: %w", target, err)
|
||||
}
|
||||
|
||||
// Collect S→C responses concurrently.
|
||||
var actualS2C []pcap.PacketRecord
|
||||
var mu sync.Mutex
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
pkt, err := mhf.ReadPacket()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var opcode uint16
|
||||
if len(pkt) >= 2 {
|
||||
opcode = binary.BigEndian.Uint16(pkt[:2])
|
||||
}
|
||||
|
||||
// Auto-respond to ping to keep connection alive.
|
||||
if opcode == opcodeSysPing {
|
||||
pong := buildPingResponse()
|
||||
_ = mhf.SendPacket(pong)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
actualS2C = append(actualS2C, pcap.PacketRecord{
|
||||
TimestampNs: time.Now().UnixNano(),
|
||||
Direction: pcap.DirServerToClient,
|
||||
Opcode: opcode,
|
||||
Payload: pkt,
|
||||
})
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Send C→S packets with timing.
|
||||
var lastTs int64
|
||||
for i, pkt := range c2s {
|
||||
if i > 0 && speed > 0 {
|
||||
delta := time.Duration(float64(pkt.TimestampNs-lastTs) / speed)
|
||||
if delta > 0 {
|
||||
time.Sleep(delta)
|
||||
}
|
||||
}
|
||||
lastTs = pkt.TimestampNs
|
||||
opcodeName := network.PacketID(pkt.Opcode).String()
|
||||
fmt.Printf("[replay] #%d sending 0x%04X %-30s (%d bytes)\n", i, pkt.Opcode, opcodeName, len(pkt.Payload))
|
||||
if err := mhf.SendPacket(pkt.Payload); err != nil {
|
||||
fmt.Printf("[replay] send error: %v\n", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for remaining responses.
|
||||
fmt.Println("\n[replay] All packets sent, waiting for remaining responses...")
|
||||
time.Sleep(2 * time.Second)
|
||||
_ = mhf.Close()
|
||||
<-done
|
||||
|
||||
// Compare.
|
||||
mu.Lock()
|
||||
diffs := ComparePackets(expectedS2C, actualS2C)
|
||||
mu.Unlock()
|
||||
|
||||
// Report.
|
||||
fmt.Printf("\n=== Replay Results ===\n")
|
||||
fmt.Printf("Sent: %d C→S packets\n", len(c2s))
|
||||
fmt.Printf("Expected: %d S→C responses\n", len(expectedS2C))
|
||||
fmt.Printf("Received: %d S→C responses\n", len(actualS2C))
|
||||
fmt.Printf("Differences: %d\n\n", len(diffs))
|
||||
for _, d := range diffs {
|
||||
fmt.Println(d.String())
|
||||
}
|
||||
|
||||
if len(diffs) == 0 {
|
||||
fmt.Println("All responses match!")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPingResponse builds a minimal MSG_SYS_PING response packet.
|
||||
// Format: [opcode 0x0017][0x00 0x10 terminator]
|
||||
func buildPingResponse() []byte {
|
||||
return []byte{0x00, 0x17, 0x00, 0x10}
|
||||
}
|
||||
|
||||
func runDump(path string) error {
|
||||
r, f, err := openCapture(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
// Print header info.
|
||||
startTime := time.Unix(0, r.Header.SessionStartNs)
|
||||
fmt.Printf("=== MHFR Capture: %s ===\n", path)
|
||||
fmt.Printf("Server: %s ClientMode: %d Start: %s\n",
|
||||
r.Header.ServerType, r.Header.ClientMode, startTime.Format(time.RFC3339Nano))
|
||||
if r.Meta.Host != "" {
|
||||
fmt.Printf("Host: %s Port: %d Remote: %s\n", r.Meta.Host, r.Meta.Port, r.Meta.RemoteAddr)
|
||||
}
|
||||
if r.Meta.CharID != 0 {
|
||||
fmt.Printf("CharID: %d UserID: %d\n", r.Meta.CharID, r.Meta.UserID)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
records, err := readAllPackets(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, rec := range records {
|
||||
elapsed := time.Duration(rec.TimestampNs - r.Header.SessionStartNs)
|
||||
opcodeName := network.PacketID(rec.Opcode).String()
|
||||
fmt.Printf("#%04d +%-12s %s 0x%04X %-30s %d bytes\n",
|
||||
i, elapsed, rec.Direction, rec.Opcode, opcodeName, len(rec.Payload))
|
||||
}
|
||||
|
||||
fmt.Printf("\nTotal: %d packets\n", len(records))
|
||||
return nil
|
||||
}
|
||||
|
||||
type jsonCapture struct {
|
||||
Header jsonHeader `json:"header"`
|
||||
Meta pcap.SessionMetadata `json:"metadata"`
|
||||
Packets []jsonPacket `json:"packets"`
|
||||
}
|
||||
|
||||
type jsonHeader struct {
|
||||
Version uint16 `json:"version"`
|
||||
ServerType string `json:"server_type"`
|
||||
ClientMode int `json:"client_mode"`
|
||||
StartTime string `json:"start_time"`
|
||||
}
|
||||
|
||||
type jsonPacket struct {
|
||||
Index int `json:"index"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
ElapsedNs int64 `json:"elapsed_ns"`
|
||||
Direction string `json:"direction"`
|
||||
Opcode uint16 `json:"opcode"`
|
||||
OpcodeName string `json:"opcode_name"`
|
||||
PayloadLen int `json:"payload_len"`
|
||||
}
|
||||
|
||||
func runJSON(path string) error {
|
||||
r, f, err := openCapture(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
records, err := readAllPackets(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := jsonCapture{
|
||||
Header: jsonHeader{
|
||||
Version: r.Header.Version,
|
||||
ServerType: r.Header.ServerType.String(),
|
||||
ClientMode: int(r.Header.ClientMode),
|
||||
StartTime: time.Unix(0, r.Header.SessionStartNs).Format(time.RFC3339Nano),
|
||||
},
|
||||
Meta: r.Meta,
|
||||
Packets: make([]jsonPacket, len(records)),
|
||||
}
|
||||
|
||||
for i, rec := range records {
|
||||
out.Packets[i] = jsonPacket{
|
||||
Index: i,
|
||||
Timestamp: time.Unix(0, rec.TimestampNs).Format(time.RFC3339Nano),
|
||||
ElapsedNs: rec.TimestampNs - r.Header.SessionStartNs,
|
||||
Direction: rec.Direction.String(),
|
||||
Opcode: rec.Opcode,
|
||||
OpcodeName: network.PacketID(rec.Opcode).String(),
|
||||
PayloadLen: len(rec.Payload),
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
func runStats(path string) error {
|
||||
r, f, err := openCapture(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
records, err := readAllPackets(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
fmt.Println("Empty capture (0 packets)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compute stats.
|
||||
type opcodeStats struct {
|
||||
opcode uint16
|
||||
count int
|
||||
bytes int
|
||||
}
|
||||
statsMap := make(map[uint16]*opcodeStats)
|
||||
var totalC2S, totalS2C int
|
||||
var bytesC2S, bytesS2C int
|
||||
|
||||
for _, rec := range records {
|
||||
s, ok := statsMap[rec.Opcode]
|
||||
if !ok {
|
||||
s = &opcodeStats{opcode: rec.Opcode}
|
||||
statsMap[rec.Opcode] = s
|
||||
}
|
||||
s.count++
|
||||
s.bytes += len(rec.Payload)
|
||||
|
||||
switch rec.Direction {
|
||||
case pcap.DirClientToServer:
|
||||
totalC2S++
|
||||
bytesC2S += len(rec.Payload)
|
||||
case pcap.DirServerToClient:
|
||||
totalS2C++
|
||||
bytesS2C += len(rec.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by count descending.
|
||||
sorted := make([]*opcodeStats, 0, len(statsMap))
|
||||
for _, s := range statsMap {
|
||||
sorted = append(sorted, s)
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].count > sorted[j].count
|
||||
})
|
||||
|
||||
duration := time.Duration(records[len(records)-1].TimestampNs - records[0].TimestampNs)
|
||||
|
||||
fmt.Printf("=== Capture Stats: %s ===\n", path)
|
||||
fmt.Printf("Server: %s Duration: %s Packets: %d\n",
|
||||
r.Header.ServerType, duration, len(records))
|
||||
fmt.Printf("C→S: %d packets (%d bytes) S→C: %d packets (%d bytes)\n\n",
|
||||
totalC2S, bytesC2S, totalS2C, bytesS2C)
|
||||
|
||||
fmt.Printf("%-8s %-35s %8s %10s\n", "Opcode", "Name", "Count", "Bytes")
|
||||
fmt.Printf("%-8s %-35s %8s %10s\n", "------", "----", "-----", "-----")
|
||||
for _, s := range sorted {
|
||||
name := network.PacketID(s.opcode).String()
|
||||
fmt.Printf("0x%04X %-35s %8d %10d\n", s.opcode, name, s.count, s.bytes)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
312
cmd/replay/replay_test.go
Normal file
312
cmd/replay/replay_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"erupe-ce/network/pcap"
|
||||
)
|
||||
|
||||
func createTestCapture(t *testing.T, records []pcap.PacketRecord) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp(t.TempDir(), "test-*.mhfr")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
hdr := pcap.FileHeader{
|
||||
Version: pcap.FormatVersion,
|
||||
ServerType: pcap.ServerTypeChannel,
|
||||
ClientMode: 40,
|
||||
SessionStartNs: 1000000000,
|
||||
}
|
||||
meta := pcap.SessionMetadata{Host: "127.0.0.1", Port: 54001}
|
||||
|
||||
w, err := pcap.NewWriter(f, hdr, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWriter: %v", err)
|
||||
}
|
||||
for _, r := range records {
|
||||
if err := w.WritePacket(r); err != nil {
|
||||
t.Fatalf("WritePacket: %v", err)
|
||||
}
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func TestRunDump(t *testing.T) {
|
||||
path := createTestCapture(t, []pcap.PacketRecord{
|
||||
{TimestampNs: 1000000100, Direction: pcap.DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13}},
|
||||
{TimestampNs: 1000000200, Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xFF}},
|
||||
})
|
||||
// Just verify it doesn't error.
|
||||
if err := runDump(path); err != nil {
|
||||
t.Fatalf("runDump: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStats(t *testing.T) {
|
||||
path := createTestCapture(t, []pcap.PacketRecord{
|
||||
{TimestampNs: 1000000100, Direction: pcap.DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13}},
|
||||
{TimestampNs: 1000000200, Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xFF}},
|
||||
{TimestampNs: 1000000300, Direction: pcap.DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13, 0xAA}},
|
||||
})
|
||||
if err := runStats(path); err != nil {
|
||||
t.Fatalf("runStats: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStatsEmpty(t *testing.T) {
|
||||
path := createTestCapture(t, nil)
|
||||
if err := runStats(path); err != nil {
|
||||
t.Fatalf("runStats empty: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJSON(t *testing.T) {
|
||||
path := createTestCapture(t, []pcap.PacketRecord{
|
||||
{TimestampNs: 1000000100, Direction: pcap.DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13}},
|
||||
})
|
||||
// Capture stdout.
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
if err := runJSON(path); err != nil {
|
||||
os.Stdout = old
|
||||
t.Fatalf("runJSON: %v", err)
|
||||
}
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r)
|
||||
if buf.Len() == 0 {
|
||||
t.Error("runJSON produced no output")
|
||||
}
|
||||
// Should be valid JSON containing "packets".
|
||||
if !bytes.Contains(buf.Bytes(), []byte(`"packets"`)) {
|
||||
t.Error("runJSON output missing 'packets' key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparePackets(t *testing.T) {
|
||||
expected := []pcap.PacketRecord{
|
||||
{Direction: pcap.DirClientToServer, Opcode: 0x0013, Payload: []byte{0x00, 0x13}},
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xAA}},
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0061, Payload: []byte{0x00, 0x61}},
|
||||
}
|
||||
actual := []pcap.PacketRecord{
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xBB, 0xCC}}, // size diff
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0099, Payload: []byte{0x00, 0x99}}, // opcode mismatch
|
||||
}
|
||||
|
||||
diffs := ComparePackets(expected, actual)
|
||||
if len(diffs) != 2 {
|
||||
t.Fatalf("expected 2 diffs, got %d", len(diffs))
|
||||
}
|
||||
|
||||
// First diff: size delta.
|
||||
if diffs[0].SizeDelta != 1 {
|
||||
t.Errorf("diffs[0] SizeDelta = %d, want 1", diffs[0].SizeDelta)
|
||||
}
|
||||
|
||||
// Second diff: opcode mismatch.
|
||||
if !diffs[1].OpcodeMismatch {
|
||||
t.Error("diffs[1] expected OpcodeMismatch=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparePacketsMissingResponse(t *testing.T) {
|
||||
expected := []pcap.PacketRecord{
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12}},
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0061, Payload: []byte{0x00, 0x61}},
|
||||
}
|
||||
actual := []pcap.PacketRecord{
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12}},
|
||||
}
|
||||
|
||||
diffs := ComparePackets(expected, actual)
|
||||
if len(diffs) != 1 {
|
||||
t.Fatalf("expected 1 diff, got %d", len(diffs))
|
||||
}
|
||||
if diffs[0].Actual != nil {
|
||||
t.Error("expected nil Actual for missing response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparePacketsPayloadDiff(t *testing.T) {
|
||||
expected := []pcap.PacketRecord{
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xAA, 0xBB}},
|
||||
}
|
||||
actual := []pcap.PacketRecord{
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xCC, 0xBB}},
|
||||
}
|
||||
|
||||
diffs := ComparePackets(expected, actual)
|
||||
if len(diffs) != 1 {
|
||||
t.Fatalf("expected 1 diff, got %d", len(diffs))
|
||||
}
|
||||
if len(diffs[0].PayloadDiffs) != 1 {
|
||||
t.Fatalf("expected 1 payload diff, got %d", len(diffs[0].PayloadDiffs))
|
||||
}
|
||||
bd := diffs[0].PayloadDiffs[0]
|
||||
if bd.Offset != 2 || bd.Expected != 0xAA || bd.Actual != 0xCC {
|
||||
t.Errorf("ByteDiff = {Offset:%d, Expected:0x%02X, Actual:0x%02X}, want {2, 0xAA, 0xCC}",
|
||||
bd.Offset, bd.Expected, bd.Actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparePacketsIdentical(t *testing.T) {
|
||||
records := []pcap.PacketRecord{
|
||||
{Direction: pcap.DirServerToClient, Opcode: 0x0012, Payload: []byte{0x00, 0x12, 0xAA}},
|
||||
}
|
||||
diffs := ComparePackets(records, records)
|
||||
if len(diffs) != 0 {
|
||||
t.Errorf("expected 0 diffs for identical packets, got %d", len(diffs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketDiffString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
diff PacketDiff
|
||||
contains string
|
||||
}{
|
||||
{
|
||||
name: "missing response",
|
||||
diff: PacketDiff{
|
||||
Index: 0,
|
||||
Expected: pcap.PacketRecord{Opcode: 0x0012},
|
||||
Actual: nil,
|
||||
},
|
||||
contains: "no response",
|
||||
},
|
||||
{
|
||||
name: "opcode mismatch",
|
||||
diff: PacketDiff{
|
||||
Index: 1,
|
||||
Expected: pcap.PacketRecord{Opcode: 0x0012},
|
||||
Actual: &pcap.PacketRecord{Opcode: 0x0099},
|
||||
OpcodeMismatch: true,
|
||||
},
|
||||
contains: "opcode mismatch",
|
||||
},
|
||||
{
|
||||
name: "size delta",
|
||||
diff: PacketDiff{
|
||||
Index: 2,
|
||||
Expected: pcap.PacketRecord{Opcode: 0x0012},
|
||||
Actual: &pcap.PacketRecord{Opcode: 0x0012},
|
||||
SizeDelta: 5,
|
||||
},
|
||||
contains: "size delta",
|
||||
},
|
||||
{
|
||||
name: "payload diffs",
|
||||
diff: PacketDiff{
|
||||
Index: 3,
|
||||
Expected: pcap.PacketRecord{Opcode: 0x0012},
|
||||
Actual: &pcap.PacketRecord{Opcode: 0x0012},
|
||||
PayloadDiffs: []ByteDiff{
|
||||
{Offset: 2, Expected: 0xAA, Actual: 0xBB},
|
||||
},
|
||||
},
|
||||
contains: "byte diff",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := tc.diff.String()
|
||||
if !strings.Contains(s, tc.contains) {
|
||||
t.Errorf("String() = %q, want it to contain %q", s, tc.contains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReplayWithMockServer(t *testing.T) {
|
||||
// Start a mock TCP server that echoes a response for each received packet.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
defer func() { _ = ln.Close() }()
|
||||
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
// This mock doesn't do Blowfish encryption — it just reads raw and echoes.
|
||||
// Since the replay uses protbot's CryptConn (Blowfish), we need a real crypto echo.
|
||||
// For a simpler test, just verify the function handles connection errors gracefully.
|
||||
// Read a bit and close.
|
||||
buf := make([]byte, 1024)
|
||||
_, _ = c.Read(buf)
|
||||
}()
|
||||
|
||||
// Create a minimal capture with one C→S packet.
|
||||
path := createTestCapture(t, []pcap.PacketRecord{
|
||||
{TimestampNs: 1000000100, Direction: pcap.DirClientToServer, Opcode: 0x0013,
|
||||
Payload: []byte{0x00, 0x13, 0xDE, 0xAD}},
|
||||
})
|
||||
|
||||
// Run replay — the connection will fail (no Blowfish on mock), but it should not panic.
|
||||
err = runReplay(path, ln.Addr().String(), 0)
|
||||
// We expect an error or graceful handling since the mock doesn't speak Blowfish.
|
||||
// The important thing is no panic.
|
||||
_ = err
|
||||
}
|
||||
|
||||
func TestComparePayloads(t *testing.T) {
|
||||
a := []byte{0x00, 0x12, 0xAA, 0xBB, 0xCC}
|
||||
b := []byte{0x00, 0x12, 0xAA, 0xDD, 0xCC}
|
||||
|
||||
diffs := comparePayloads(a, b)
|
||||
if len(diffs) != 1 {
|
||||
t.Fatalf("expected 1 diff, got %d", len(diffs))
|
||||
}
|
||||
if diffs[0].Offset != 3 {
|
||||
t.Errorf("Offset = %d, want 3", diffs[0].Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparePayloadsMaxDiffs(t *testing.T) {
|
||||
// All bytes different — should cap at maxPayloadDiffs.
|
||||
a := make([]byte, 100)
|
||||
b := make([]byte, 100)
|
||||
for i := range b {
|
||||
b[i] = 0xFF
|
||||
}
|
||||
|
||||
diffs := comparePayloads(a, b)
|
||||
if len(diffs) != maxPayloadDiffs {
|
||||
t.Errorf("expected %d diffs (capped), got %d", maxPayloadDiffs, len(diffs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPingResponse(t *testing.T) {
|
||||
pong := buildPingResponse()
|
||||
if len(pong) < 2 {
|
||||
t.Fatal("ping response too short")
|
||||
}
|
||||
opcode := binary.BigEndian.Uint16(pong[:2])
|
||||
if opcode != opcodeSysPing {
|
||||
t.Errorf("opcode = 0x%04X, want 0x%04X", opcode, opcodeSysPing)
|
||||
}
|
||||
}
|
||||
105
common/bfutil/bfutil_test.go
Normal file
105
common/bfutil/bfutil_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package bfutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpToNull(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected []byte
|
||||
}{
|
||||
{
|
||||
name: "data with null terminator",
|
||||
input: []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x00, 0x57, 0x6F, 0x72, 0x6C, 0x64},
|
||||
expected: []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F}, // "Hello"
|
||||
},
|
||||
{
|
||||
name: "data without null terminator",
|
||||
input: []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F},
|
||||
expected: []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F}, // "Hello"
|
||||
},
|
||||
{
|
||||
name: "data with null at start",
|
||||
input: []byte{0x00, 0x48, 0x65, 0x6C, 0x6C, 0x6F},
|
||||
expected: []byte{},
|
||||
},
|
||||
{
|
||||
name: "empty slice",
|
||||
input: []byte{},
|
||||
expected: []byte{},
|
||||
},
|
||||
{
|
||||
name: "only null byte",
|
||||
input: []byte{0x00},
|
||||
expected: []byte{},
|
||||
},
|
||||
{
|
||||
name: "multiple null bytes",
|
||||
input: []byte{0x48, 0x65, 0x00, 0x00, 0x6C, 0x6C, 0x6F},
|
||||
expected: []byte{0x48, 0x65}, // "He"
|
||||
},
|
||||
{
|
||||
name: "binary data with null",
|
||||
input: []byte{0xFF, 0xAB, 0x12, 0x00, 0x34, 0x56},
|
||||
expected: []byte{0xFF, 0xAB, 0x12},
|
||||
},
|
||||
{
|
||||
name: "binary data without null",
|
||||
input: []byte{0xFF, 0xAB, 0x12, 0x34, 0x56},
|
||||
expected: []byte{0xFF, 0xAB, 0x12, 0x34, 0x56},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := UpToNull(tt.input)
|
||||
if !bytes.Equal(result, tt.expected) {
|
||||
t.Errorf("UpToNull() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpToNull_ReturnsSliceNotCopy(t *testing.T) {
|
||||
// Test that UpToNull returns a slice of the original array, not a copy
|
||||
input := []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x00, 0x57, 0x6F, 0x72, 0x6C, 0x64}
|
||||
result := UpToNull(input)
|
||||
|
||||
// Verify we got the expected data
|
||||
expected := []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F}
|
||||
if !bytes.Equal(result, expected) {
|
||||
t.Errorf("UpToNull() = %v, want %v", result, expected)
|
||||
}
|
||||
|
||||
// The result should be a slice of the input array
|
||||
if len(result) > 0 && cap(result) < len(expected) {
|
||||
t.Error("Result should be a slice of input array")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUpToNull(b *testing.B) {
|
||||
data := []byte("Hello, World!\x00Extra data here")
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = UpToNull(data)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUpToNull_NoNull(b *testing.B) {
|
||||
data := []byte("Hello, World! No null terminator in this string at all")
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = UpToNull(data)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUpToNull_NullAtStart(b *testing.B) {
|
||||
data := []byte("\x00Hello, World!")
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = UpToNull(data)
|
||||
}
|
||||
}
|
||||
3
common/bfutil/doc.go
Normal file
3
common/bfutil/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package bfutil provides byte-slice utility functions for working with
|
||||
// null-terminated binary data commonly found in MHF network packets.
|
||||
package bfutil
|
||||
@@ -9,16 +9,21 @@ import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ErrReadOverflow is returned when a read exceeds the buffer bounds.
|
||||
var ErrReadOverflow = errors.New("byteframe: read beyond buffer bounds")
|
||||
|
||||
// ByteFrame is a struct for reading and writing raw byte data.
|
||||
type ByteFrame struct {
|
||||
index uint
|
||||
usedSize uint
|
||||
buf []byte
|
||||
byteOrder binary.ByteOrder
|
||||
err error // sticky error set on read overflow
|
||||
}
|
||||
|
||||
// NewByteFrame creates a new ByteFrame with valid default values.
|
||||
@@ -92,7 +97,14 @@ func (b *ByteFrame) rprologue(size uint) {
|
||||
}
|
||||
|
||||
func (b *ByteFrame) rerr() {
|
||||
panic("Error while reading!")
|
||||
if b.err == nil {
|
||||
b.err = fmt.Errorf("%w: at index %d, usedSize %d", ErrReadOverflow, b.index, b.usedSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Err returns the first read error encountered, if any.
|
||||
func (b *ByteFrame) Err() error {
|
||||
return b.err
|
||||
}
|
||||
|
||||
// Seek (implements the io.Seeker interface)
|
||||
@@ -103,7 +115,6 @@ func (b *ByteFrame) Seek(offset int64, whence int) (int64, error) {
|
||||
return int64(b.index), errors.New("cannot seek beyond the max index")
|
||||
}
|
||||
b.index = uint(offset)
|
||||
break
|
||||
case io.SeekCurrent:
|
||||
newPos := int64(b.index) + offset
|
||||
if newPos > int64(b.usedSize) {
|
||||
@@ -112,7 +123,6 @@ func (b *ByteFrame) Seek(offset int64, whence int) (int64, error) {
|
||||
return int64(b.index), errors.New("cannot seek before the buffer start")
|
||||
}
|
||||
b.index = uint(newPos)
|
||||
break
|
||||
case io.SeekEnd:
|
||||
newPos := int64(b.usedSize) + offset
|
||||
if newPos > int64(b.usedSize) {
|
||||
@@ -121,7 +131,6 @@ func (b *ByteFrame) Seek(offset int64, whence int) (int64, error) {
|
||||
return int64(b.index), errors.New("cannot seek before the buffer start")
|
||||
}
|
||||
b.index = uint(newPos)
|
||||
break
|
||||
|
||||
}
|
||||
|
||||
@@ -138,6 +147,7 @@ func (b *ByteFrame) DataFromCurrent() []byte {
|
||||
return b.buf[b.index:b.usedSize]
|
||||
}
|
||||
|
||||
// Index returns the current read/write position in the buffer.
|
||||
func (b *ByteFrame) Index() uint {
|
||||
return b.index
|
||||
}
|
||||
@@ -249,8 +259,12 @@ func (b *ByteFrame) WriteNullTerminatedBytes(x []byte) {
|
||||
|
||||
// ReadUint8 reads a uint8 at the current index.
|
||||
func (b *ByteFrame) ReadUint8() (x uint8) {
|
||||
if b.err != nil {
|
||||
return 0
|
||||
}
|
||||
if !b.rcheck(1) {
|
||||
b.rerr()
|
||||
return 0
|
||||
}
|
||||
x = uint8(b.buf[b.index])
|
||||
b.rprologue(1)
|
||||
@@ -267,8 +281,12 @@ func (b *ByteFrame) ReadBool() (x bool) {
|
||||
|
||||
// ReadUint16 reads a uint16 at the current index.
|
||||
func (b *ByteFrame) ReadUint16() (x uint16) {
|
||||
if b.err != nil {
|
||||
return 0
|
||||
}
|
||||
if !b.rcheck(2) {
|
||||
b.rerr()
|
||||
return 0
|
||||
}
|
||||
x = b.byteOrder.Uint16(b.buf[b.index:])
|
||||
b.rprologue(2)
|
||||
@@ -277,8 +295,12 @@ func (b *ByteFrame) ReadUint16() (x uint16) {
|
||||
|
||||
// ReadUint32 reads a uint32 at the current index.
|
||||
func (b *ByteFrame) ReadUint32() (x uint32) {
|
||||
if b.err != nil {
|
||||
return 0
|
||||
}
|
||||
if !b.rcheck(4) {
|
||||
b.rerr()
|
||||
return 0
|
||||
}
|
||||
x = b.byteOrder.Uint32(b.buf[b.index:])
|
||||
b.rprologue(4)
|
||||
@@ -287,8 +309,12 @@ func (b *ByteFrame) ReadUint32() (x uint32) {
|
||||
|
||||
// ReadUint64 reads a uint64 at the current index.
|
||||
func (b *ByteFrame) ReadUint64() (x uint64) {
|
||||
if b.err != nil {
|
||||
return 0
|
||||
}
|
||||
if !b.rcheck(8) {
|
||||
b.rerr()
|
||||
return 0
|
||||
}
|
||||
x = b.byteOrder.Uint64(b.buf[b.index:])
|
||||
b.rprologue(8)
|
||||
@@ -297,8 +323,12 @@ func (b *ByteFrame) ReadUint64() (x uint64) {
|
||||
|
||||
// ReadInt8 reads a int8 at the current index.
|
||||
func (b *ByteFrame) ReadInt8() (x int8) {
|
||||
if b.err != nil {
|
||||
return 0
|
||||
}
|
||||
if !b.rcheck(1) {
|
||||
b.rerr()
|
||||
return 0
|
||||
}
|
||||
x = int8(b.buf[b.index])
|
||||
b.rprologue(1)
|
||||
@@ -307,8 +337,12 @@ func (b *ByteFrame) ReadInt8() (x int8) {
|
||||
|
||||
// ReadInt16 reads a int16 at the current index.
|
||||
func (b *ByteFrame) ReadInt16() (x int16) {
|
||||
if b.err != nil {
|
||||
return 0
|
||||
}
|
||||
if !b.rcheck(2) {
|
||||
b.rerr()
|
||||
return 0
|
||||
}
|
||||
x = int16(b.byteOrder.Uint16(b.buf[b.index:]))
|
||||
b.rprologue(2)
|
||||
@@ -317,8 +351,12 @@ func (b *ByteFrame) ReadInt16() (x int16) {
|
||||
|
||||
// ReadInt32 reads a int32 at the current index.
|
||||
func (b *ByteFrame) ReadInt32() (x int32) {
|
||||
if b.err != nil {
|
||||
return 0
|
||||
}
|
||||
if !b.rcheck(4) {
|
||||
b.rerr()
|
||||
return 0
|
||||
}
|
||||
x = int32(b.byteOrder.Uint32(b.buf[b.index:]))
|
||||
b.rprologue(4)
|
||||
@@ -327,8 +365,12 @@ func (b *ByteFrame) ReadInt32() (x int32) {
|
||||
|
||||
// ReadInt64 reads a int64 at the current index.
|
||||
func (b *ByteFrame) ReadInt64() (x int64) {
|
||||
if b.err != nil {
|
||||
return 0
|
||||
}
|
||||
if !b.rcheck(8) {
|
||||
b.rerr()
|
||||
return 0
|
||||
}
|
||||
x = int64(b.byteOrder.Uint64(b.buf[b.index:]))
|
||||
b.rprologue(8)
|
||||
@@ -337,8 +379,12 @@ func (b *ByteFrame) ReadInt64() (x int64) {
|
||||
|
||||
// ReadFloat32 reads a float32 at the current index.
|
||||
func (b *ByteFrame) ReadFloat32() (x float32) {
|
||||
if b.err != nil {
|
||||
return 0
|
||||
}
|
||||
if !b.rcheck(4) {
|
||||
b.rerr()
|
||||
return 0
|
||||
}
|
||||
x = math.Float32frombits(b.byteOrder.Uint32(b.buf[b.index:]))
|
||||
b.rprologue(4)
|
||||
@@ -347,8 +393,12 @@ func (b *ByteFrame) ReadFloat32() (x float32) {
|
||||
|
||||
// ReadFloat64 reads a float64 at the current index.
|
||||
func (b *ByteFrame) ReadFloat64() (x float64) {
|
||||
if b.err != nil {
|
||||
return 0
|
||||
}
|
||||
if !b.rcheck(8) {
|
||||
b.rerr()
|
||||
return 0
|
||||
}
|
||||
x = math.Float64frombits(b.byteOrder.Uint64(b.buf[b.index:]))
|
||||
b.rprologue(8)
|
||||
@@ -357,8 +407,12 @@ func (b *ByteFrame) ReadFloat64() (x float64) {
|
||||
|
||||
// ReadBytes reads `size` many bytes at the current index.
|
||||
func (b *ByteFrame) ReadBytes(size uint) (x []byte) {
|
||||
if b.err != nil {
|
||||
return nil
|
||||
}
|
||||
if !b.rcheck(size) {
|
||||
b.rerr()
|
||||
return nil
|
||||
}
|
||||
x = b.buf[b.index : b.index+size]
|
||||
b.rprologue(size)
|
||||
|
||||
58
common/byteframe/byteframe_setbe_test.go
Normal file
58
common/byteframe/byteframe_setbe_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package byteframe
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestByteFrame_SetBE(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
// Default is already BigEndian, switch to LE first
|
||||
bf.SetLE()
|
||||
if bf.byteOrder != binary.LittleEndian {
|
||||
t.Error("SetLE() should set LittleEndian")
|
||||
}
|
||||
|
||||
// Now test SetBE
|
||||
bf.SetBE()
|
||||
if bf.byteOrder != binary.BigEndian {
|
||||
t.Error("SetBE() should set BigEndian")
|
||||
}
|
||||
|
||||
// Verify write/read works correctly in BE mode after switching
|
||||
bf.WriteUint16(0x1234)
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
got := bf.ReadUint16()
|
||||
if got != 0x1234 {
|
||||
t.Errorf("ReadUint16() = 0x%04X, want 0x1234", got)
|
||||
}
|
||||
|
||||
// Verify raw bytes are in big endian order
|
||||
bf2 := NewByteFrame()
|
||||
bf2.SetLE()
|
||||
bf2.SetBE()
|
||||
bf2.WriteUint32(0xDEADBEEF)
|
||||
data := bf2.Data()
|
||||
if data[0] != 0xDE || data[1] != 0xAD || data[2] != 0xBE || data[3] != 0xEF {
|
||||
t.Errorf("SetBE bytes: got %X, want DEADBEEF", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_LEReadWrite(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
bf.SetLE()
|
||||
|
||||
bf.WriteUint32(0x12345678)
|
||||
data := bf.Data()
|
||||
// In LE, LSB first
|
||||
if data[0] != 0x78 || data[1] != 0x56 || data[2] != 0x34 || data[3] != 0x12 {
|
||||
t.Errorf("LE WriteUint32 bytes: got %X, want 78563412", data)
|
||||
}
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
got := bf.ReadUint32()
|
||||
if got != 0x12345678 {
|
||||
t.Errorf("LE ReadUint32() = 0x%08X, want 0x12345678", got)
|
||||
}
|
||||
}
|
||||
518
common/byteframe/byteframe_test.go
Normal file
518
common/byteframe/byteframe_test.go
Normal file
@@ -0,0 +1,518 @@
|
||||
package byteframe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewByteFrame(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
if bf == nil {
|
||||
t.Fatal("NewByteFrame() returned nil")
|
||||
}
|
||||
if bf.index != 0 {
|
||||
t.Errorf("index = %d, want 0", bf.index)
|
||||
}
|
||||
if bf.usedSize != 0 {
|
||||
t.Errorf("usedSize = %d, want 0", bf.usedSize)
|
||||
}
|
||||
if len(bf.buf) != 4 {
|
||||
t.Errorf("buf length = %d, want 4", len(bf.buf))
|
||||
}
|
||||
if bf.byteOrder != binary.BigEndian {
|
||||
t.Error("byteOrder should be BigEndian by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewByteFrameFromBytes(t *testing.T) {
|
||||
input := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
bf := NewByteFrameFromBytes(input)
|
||||
if bf == nil {
|
||||
t.Fatal("NewByteFrameFromBytes() returned nil")
|
||||
}
|
||||
if bf.index != 0 {
|
||||
t.Errorf("index = %d, want 0", bf.index)
|
||||
}
|
||||
if bf.usedSize != uint(len(input)) {
|
||||
t.Errorf("usedSize = %d, want %d", bf.usedSize, len(input))
|
||||
}
|
||||
if !bytes.Equal(bf.buf, input) {
|
||||
t.Errorf("buf = %v, want %v", bf.buf, input)
|
||||
}
|
||||
// Verify it's a copy, not the same slice
|
||||
input[0] = 0xFF
|
||||
if bf.buf[0] == 0xFF {
|
||||
t.Error("NewByteFrameFromBytes should make a copy, not use the same slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadUint8(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
values := []uint8{0, 1, 127, 128, 255}
|
||||
|
||||
for _, v := range values {
|
||||
bf.WriteUint8(v)
|
||||
}
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
for i, expected := range values {
|
||||
got := bf.ReadUint8()
|
||||
if got != expected {
|
||||
t.Errorf("ReadUint8()[%d] = %d, want %d", i, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadUint16(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value uint16
|
||||
}{
|
||||
{"zero", 0},
|
||||
{"one", 1},
|
||||
{"max_int8", 127},
|
||||
{"max_uint8", 255},
|
||||
{"max_int16", 32767},
|
||||
{"max_uint16", 65535},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
bf.WriteUint16(tt.value)
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
got := bf.ReadUint16()
|
||||
if got != tt.value {
|
||||
t.Errorf("ReadUint16() = %d, want %d", got, tt.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadUint32(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value uint32
|
||||
}{
|
||||
{"zero", 0},
|
||||
{"one", 1},
|
||||
{"max_uint16", 65535},
|
||||
{"max_uint32", 4294967295},
|
||||
{"arbitrary", 0x12345678},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
bf.WriteUint32(tt.value)
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
got := bf.ReadUint32()
|
||||
if got != tt.value {
|
||||
t.Errorf("ReadUint32() = %d, want %d", got, tt.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadUint64(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value uint64
|
||||
}{
|
||||
{"zero", 0},
|
||||
{"one", 1},
|
||||
{"max_uint32", 4294967295},
|
||||
{"max_uint64", 18446744073709551615},
|
||||
{"arbitrary", 0x123456789ABCDEF0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
bf.WriteUint64(tt.value)
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
got := bf.ReadUint64()
|
||||
if got != tt.value {
|
||||
t.Errorf("ReadUint64() = %d, want %d", got, tt.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadInt8(t *testing.T) {
|
||||
values := []int8{-128, -1, 0, 1, 127}
|
||||
bf := NewByteFrame()
|
||||
|
||||
for _, v := range values {
|
||||
bf.WriteInt8(v)
|
||||
}
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
for i, expected := range values {
|
||||
got := bf.ReadInt8()
|
||||
if got != expected {
|
||||
t.Errorf("ReadInt8()[%d] = %d, want %d", i, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadInt16(t *testing.T) {
|
||||
values := []int16{-32768, -1, 0, 1, 32767}
|
||||
bf := NewByteFrame()
|
||||
|
||||
for _, v := range values {
|
||||
bf.WriteInt16(v)
|
||||
}
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
for i, expected := range values {
|
||||
got := bf.ReadInt16()
|
||||
if got != expected {
|
||||
t.Errorf("ReadInt16()[%d] = %d, want %d", i, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadInt32(t *testing.T) {
|
||||
values := []int32{-2147483648, -1, 0, 1, 2147483647}
|
||||
bf := NewByteFrame()
|
||||
|
||||
for _, v := range values {
|
||||
bf.WriteInt32(v)
|
||||
}
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
for i, expected := range values {
|
||||
got := bf.ReadInt32()
|
||||
if got != expected {
|
||||
t.Errorf("ReadInt32()[%d] = %d, want %d", i, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadInt64(t *testing.T) {
|
||||
values := []int64{-9223372036854775808, -1, 0, 1, 9223372036854775807}
|
||||
bf := NewByteFrame()
|
||||
|
||||
for _, v := range values {
|
||||
bf.WriteInt64(v)
|
||||
}
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
for i, expected := range values {
|
||||
got := bf.ReadInt64()
|
||||
if got != expected {
|
||||
t.Errorf("ReadInt64()[%d] = %d, want %d", i, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadFloat32(t *testing.T) {
|
||||
values := []float32{0.0, -1.5, 1.5, 3.14159, math.MaxFloat32, -math.MaxFloat32}
|
||||
bf := NewByteFrame()
|
||||
|
||||
for _, v := range values {
|
||||
bf.WriteFloat32(v)
|
||||
}
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
for i, expected := range values {
|
||||
got := bf.ReadFloat32()
|
||||
if got != expected {
|
||||
t.Errorf("ReadFloat32()[%d] = %f, want %f", i, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadFloat64(t *testing.T) {
|
||||
values := []float64{0.0, -1.5, 1.5, 3.14159265358979, math.MaxFloat64, -math.MaxFloat64}
|
||||
bf := NewByteFrame()
|
||||
|
||||
for _, v := range values {
|
||||
bf.WriteFloat64(v)
|
||||
}
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
for i, expected := range values {
|
||||
got := bf.ReadFloat64()
|
||||
if got != expected {
|
||||
t.Errorf("ReadFloat64()[%d] = %f, want %f", i, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadBool(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
bf.WriteBool(true)
|
||||
bf.WriteBool(false)
|
||||
bf.WriteBool(true)
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
if got := bf.ReadBool(); got != true {
|
||||
t.Errorf("ReadBool()[0] = %v, want true", got)
|
||||
}
|
||||
if got := bf.ReadBool(); got != false {
|
||||
t.Errorf("ReadBool()[1] = %v, want false", got)
|
||||
}
|
||||
if got := bf.ReadBool(); got != true {
|
||||
t.Errorf("ReadBool()[2] = %v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadBytes(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
input := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
bf.WriteBytes(input)
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
got := bf.ReadBytes(uint(len(input)))
|
||||
if !bytes.Equal(got, input) {
|
||||
t.Errorf("ReadBytes() = %v, want %v", got, input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_WriteAndReadNullTerminatedBytes(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
input := []byte("Hello, World!")
|
||||
bf.WriteNullTerminatedBytes(input)
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
got := bf.ReadNullTerminatedBytes()
|
||||
if !bytes.Equal(got, input) {
|
||||
t.Errorf("ReadNullTerminatedBytes() = %v, want %v", got, input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_ReadNullTerminatedBytes_NoNull(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
input := []byte("Hello")
|
||||
bf.WriteBytes(input)
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
got := bf.ReadNullTerminatedBytes()
|
||||
// When there's no null terminator, it should return empty slice
|
||||
if len(got) != 0 {
|
||||
t.Errorf("ReadNullTerminatedBytes() = %v, want empty slice", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_Endianness(t *testing.T) {
|
||||
// Test BigEndian (default)
|
||||
bfBE := NewByteFrame()
|
||||
bfBE.WriteUint16(0x1234)
|
||||
dataBE := bfBE.Data()
|
||||
if dataBE[0] != 0x12 || dataBE[1] != 0x34 {
|
||||
t.Errorf("BigEndian: got %X %X, want 12 34", dataBE[0], dataBE[1])
|
||||
}
|
||||
|
||||
// Test LittleEndian
|
||||
bfLE := NewByteFrame()
|
||||
bfLE.SetLE()
|
||||
bfLE.WriteUint16(0x1234)
|
||||
dataLE := bfLE.Data()
|
||||
if dataLE[0] != 0x34 || dataLE[1] != 0x12 {
|
||||
t.Errorf("LittleEndian: got %X %X, want 34 12", dataLE[0], dataLE[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_Seek(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
bf.WriteBytes([]byte{0x01, 0x02, 0x03, 0x04, 0x05})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
offset int64
|
||||
whence int
|
||||
wantIndex uint
|
||||
wantErr bool
|
||||
}{
|
||||
{"seek_start_0", 0, io.SeekStart, 0, false},
|
||||
{"seek_start_2", 2, io.SeekStart, 2, false},
|
||||
{"seek_start_5", 5, io.SeekStart, 5, false},
|
||||
{"seek_start_beyond", 6, io.SeekStart, 5, true},
|
||||
{"seek_current_forward", 2, io.SeekCurrent, 5, true}, // Will go beyond max
|
||||
{"seek_current_backward", -3, io.SeekCurrent, 2, false},
|
||||
{"seek_current_before_start", -10, io.SeekCurrent, 2, true},
|
||||
{"seek_end_0", 0, io.SeekEnd, 5, false},
|
||||
{"seek_end_negative", -2, io.SeekEnd, 3, false},
|
||||
{"seek_end_beyond", 1, io.SeekEnd, 3, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset to known position for each test
|
||||
_, _ = bf.Seek(5, io.SeekStart)
|
||||
|
||||
pos, err := bf.Seek(tt.offset, tt.whence)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Seek() expected error, got nil")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Seek() unexpected error: %v", err)
|
||||
}
|
||||
if bf.index != tt.wantIndex {
|
||||
t.Errorf("index = %d, want %d", bf.index, tt.wantIndex)
|
||||
}
|
||||
if uint(pos) != tt.wantIndex {
|
||||
t.Errorf("returned position = %d, want %d", pos, tt.wantIndex)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_Data(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
input := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
bf.WriteBytes(input)
|
||||
|
||||
data := bf.Data()
|
||||
if !bytes.Equal(data, input) {
|
||||
t.Errorf("Data() = %v, want %v", data, input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_DataFromCurrent(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
bf.WriteBytes([]byte{0x01, 0x02, 0x03, 0x04, 0x05})
|
||||
_, _ = bf.Seek(2, io.SeekStart)
|
||||
|
||||
data := bf.DataFromCurrent()
|
||||
expected := []byte{0x03, 0x04, 0x05}
|
||||
if !bytes.Equal(data, expected) {
|
||||
t.Errorf("DataFromCurrent() = %v, want %v", data, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_Index(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
if bf.Index() != 0 {
|
||||
t.Errorf("Index() = %d, want 0", bf.Index())
|
||||
}
|
||||
|
||||
bf.WriteUint8(0x01)
|
||||
if bf.Index() != 1 {
|
||||
t.Errorf("Index() = %d, want 1", bf.Index())
|
||||
}
|
||||
|
||||
bf.WriteUint16(0x0102)
|
||||
if bf.Index() != 3 {
|
||||
t.Errorf("Index() = %d, want 3", bf.Index())
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_BufferGrowth(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
initialCap := len(bf.buf)
|
||||
|
||||
// Write enough data to force growth
|
||||
for i := 0; i < 100; i++ {
|
||||
bf.WriteUint32(uint32(i))
|
||||
}
|
||||
|
||||
if len(bf.buf) <= initialCap {
|
||||
t.Errorf("Buffer should have grown, initial cap: %d, current: %d", initialCap, len(bf.buf))
|
||||
}
|
||||
|
||||
// Verify all data is still accessible
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
for i := 0; i < 100; i++ {
|
||||
got := bf.ReadUint32()
|
||||
if got != uint32(i) {
|
||||
t.Errorf("After growth, ReadUint32()[%d] = %d, want %d", i, got, i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_ReadOverflowSetsError(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
bf.WriteUint8(0x01)
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
bf.ReadUint8()
|
||||
|
||||
if bf.Err() != nil {
|
||||
t.Fatal("Err() should be nil before overflow")
|
||||
}
|
||||
|
||||
// Should set sticky error - trying to read 2 bytes when only 1 was written
|
||||
got := bf.ReadUint16()
|
||||
if got != 0 {
|
||||
t.Errorf("ReadUint16() after overflow = %d, want 0", got)
|
||||
}
|
||||
if bf.Err() == nil {
|
||||
t.Error("Err() should be non-nil after read overflow")
|
||||
}
|
||||
if !errors.Is(bf.Err(), ErrReadOverflow) {
|
||||
t.Errorf("Err() = %v, want ErrReadOverflow", bf.Err())
|
||||
}
|
||||
|
||||
// Subsequent reads should also return zero without changing the error
|
||||
got32 := bf.ReadUint32()
|
||||
if got32 != 0 {
|
||||
t.Errorf("ReadUint32() after overflow = %d, want 0", got32)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteFrame_SequentialWrites(t *testing.T) {
|
||||
bf := NewByteFrame()
|
||||
bf.WriteUint8(0x01)
|
||||
bf.WriteUint16(0x0203)
|
||||
bf.WriteUint32(0x04050607)
|
||||
bf.WriteUint64(0x08090A0B0C0D0E0F)
|
||||
|
||||
expected := []byte{
|
||||
0x01, // uint8
|
||||
0x02, 0x03, // uint16
|
||||
0x04, 0x05, 0x06, 0x07, // uint32
|
||||
0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, // uint64
|
||||
}
|
||||
|
||||
data := bf.Data()
|
||||
if !bytes.Equal(data, expected) {
|
||||
t.Errorf("Sequential writes: got %X, want %X", data, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkByteFrame_WriteUint8(b *testing.B) {
|
||||
bf := NewByteFrame()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bf.WriteUint8(0x42)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkByteFrame_WriteUint32(b *testing.B) {
|
||||
bf := NewByteFrame()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bf.WriteUint32(0x12345678)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkByteFrame_ReadUint32(b *testing.B) {
|
||||
bf := NewByteFrame()
|
||||
for i := 0; i < 1000; i++ {
|
||||
bf.WriteUint32(0x12345678)
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
bf.ReadUint32()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkByteFrame_WriteBytes(b *testing.B) {
|
||||
bf := NewByteFrame()
|
||||
data := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bf.WriteBytes(data)
|
||||
}
|
||||
}
|
||||
4
common/byteframe/doc.go
Normal file
4
common/byteframe/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package byteframe provides a seekable, growable byte buffer for reading and
|
||||
// writing binary data in big-endian or little-endian byte order. It is the
|
||||
// primary serialization primitive used throughout the Erupe network layer.
|
||||
package byteframe
|
||||
4
common/decryption/doc.go
Normal file
4
common/decryption/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package decryption implements the JPK decompression algorithm used by
|
||||
// Monster Hunter Frontier to compress game data files. The format is
|
||||
// identified by the magic bytes 0x1A524B4A ("JKR").
|
||||
package decryption
|
||||
@@ -10,19 +10,22 @@ import (
|
||||
"io"
|
||||
)
|
||||
|
||||
var mShiftIndex = 0
|
||||
var mFlag = byte(0)
|
||||
// jpkState holds the mutable bit-reader state for a single JPK decompression.
|
||||
// This is local to each call, making concurrent UnpackSimple calls safe.
|
||||
type jpkState struct {
|
||||
shiftIndex int
|
||||
flag byte
|
||||
}
|
||||
|
||||
// UnpackSimple decompresses a JPK type-3 compressed byte slice. If the data
|
||||
// does not start with the JKR magic header it is returned unchanged.
|
||||
func UnpackSimple(data []byte) []byte {
|
||||
mShiftIndex = 0
|
||||
mFlag = byte(0)
|
||||
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
bf.SetLE()
|
||||
header := bf.ReadUint32()
|
||||
|
||||
if header == 0x1A524B4A {
|
||||
bf.Seek(0x2, io.SeekCurrent)
|
||||
_, _ = bf.Seek(0x2, io.SeekCurrent)
|
||||
jpkType := bf.ReadUint16()
|
||||
|
||||
switch jpkType {
|
||||
@@ -30,8 +33,9 @@ func UnpackSimple(data []byte) []byte {
|
||||
startOffset := bf.ReadInt32()
|
||||
outSize := bf.ReadInt32()
|
||||
outBuffer := make([]byte, outSize)
|
||||
bf.Seek(int64(startOffset), io.SeekStart)
|
||||
ProcessDecode(bf, outBuffer)
|
||||
_, _ = bf.Seek(int64(startOffset), io.SeekStart)
|
||||
s := &jpkState{}
|
||||
s.processDecode(bf, outBuffer)
|
||||
|
||||
return outBuffer
|
||||
}
|
||||
@@ -40,17 +44,24 @@ func UnpackSimple(data []byte) []byte {
|
||||
return data
|
||||
}
|
||||
|
||||
// ProcessDecode runs the JPK LZ-style decompression loop, reading compressed
|
||||
// tokens from data and writing decompressed bytes into outBuffer.
|
||||
func ProcessDecode(data *byteframe.ByteFrame, outBuffer []byte) {
|
||||
s := &jpkState{}
|
||||
s.processDecode(data, outBuffer)
|
||||
}
|
||||
|
||||
func (s *jpkState) processDecode(data *byteframe.ByteFrame, outBuffer []byte) {
|
||||
outIndex := 0
|
||||
|
||||
for int(data.Index()) < len(data.Data()) && outIndex < len(outBuffer)-1 {
|
||||
if JPKBitShift(data) == 0 {
|
||||
if s.bitShift(data) == 0 {
|
||||
outBuffer[outIndex] = ReadByte(data)
|
||||
outIndex++
|
||||
continue
|
||||
} else {
|
||||
if JPKBitShift(data) == 0 {
|
||||
length := (JPKBitShift(data) << 1) | JPKBitShift(data)
|
||||
if s.bitShift(data) == 0 {
|
||||
length := (s.bitShift(data) << 1) | s.bitShift(data)
|
||||
off := ReadByte(data)
|
||||
JPKCopy(outBuffer, int(off), int(length)+3, &outIndex)
|
||||
continue
|
||||
@@ -63,8 +74,8 @@ func ProcessDecode(data *byteframe.ByteFrame, outBuffer []byte) {
|
||||
JPKCopy(outBuffer, off, length+2, &outIndex)
|
||||
continue
|
||||
} else {
|
||||
if JPKBitShift(data) == 0 {
|
||||
length := (JPKBitShift(data) << 3) | (JPKBitShift(data) << 2) | (JPKBitShift(data) << 1) | JPKBitShift(data)
|
||||
if s.bitShift(data) == 0 {
|
||||
length := (s.bitShift(data) << 3) | (s.bitShift(data) << 2) | (s.bitShift(data) << 1) | s.bitShift(data)
|
||||
JPKCopy(outBuffer, off, int(length)+2+8, &outIndex)
|
||||
continue
|
||||
} else {
|
||||
@@ -85,17 +96,21 @@ func ProcessDecode(data *byteframe.ByteFrame, outBuffer []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func JPKBitShift(data *byteframe.ByteFrame) byte {
|
||||
mShiftIndex--
|
||||
// bitShift reads one bit from the compressed stream's flag byte, refilling
|
||||
// the flag from the next byte in data when all 8 bits have been consumed.
|
||||
func (s *jpkState) bitShift(data *byteframe.ByteFrame) byte {
|
||||
s.shiftIndex--
|
||||
|
||||
if mShiftIndex < 0 {
|
||||
mShiftIndex = 7
|
||||
mFlag = ReadByte(data)
|
||||
if s.shiftIndex < 0 {
|
||||
s.shiftIndex = 7
|
||||
s.flag = ReadByte(data)
|
||||
}
|
||||
|
||||
return (byte)((mFlag >> mShiftIndex) & 1)
|
||||
return (s.flag >> s.shiftIndex) & 1
|
||||
}
|
||||
|
||||
// JPKCopy copies length bytes from a previous position in outBuffer (determined
|
||||
// by offset back from the current index) to implement LZ back-references.
|
||||
func JPKCopy(outBuffer []byte, offset int, length int, index *int) {
|
||||
for i := 0; i < length; i++ {
|
||||
outBuffer[*index] = outBuffer[*index-offset-1]
|
||||
@@ -103,6 +118,7 @@ func JPKCopy(outBuffer []byte, offset int, length int, index *int) {
|
||||
}
|
||||
}
|
||||
|
||||
// ReadByte reads a single byte from the ByteFrame.
|
||||
func ReadByte(bf *byteframe.ByteFrame) byte {
|
||||
value := bf.ReadUint8()
|
||||
return value
|
||||
|
||||
253
common/decryption/jpk_test.go
Normal file
253
common/decryption/jpk_test.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package decryption
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"erupe-ce/common/byteframe"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnpackSimple_UncompressedData(t *testing.T) {
|
||||
// Test data that doesn't have JPK header - should be returned as-is
|
||||
input := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
result := UnpackSimple(input)
|
||||
|
||||
if !bytes.Equal(result, input) {
|
||||
t.Errorf("UnpackSimple() with uncompressed data should return input as-is, got %v, want %v", result, input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackSimple_InvalidHeader(t *testing.T) {
|
||||
// Test data with wrong header
|
||||
input := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x02, 0x03, 0x04}
|
||||
result := UnpackSimple(input)
|
||||
|
||||
if !bytes.Equal(result, input) {
|
||||
t.Errorf("UnpackSimple() with invalid header should return input as-is, got %v, want %v", result, input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackSimple_JPKHeaderWrongType(t *testing.T) {
|
||||
// Test JPK header but wrong type (not type 3)
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.SetLE()
|
||||
bf.WriteUint32(0x1A524B4A) // JPK header
|
||||
bf.WriteUint16(0x00) // Reserved
|
||||
bf.WriteUint16(1) // Type 1 instead of 3
|
||||
bf.WriteInt32(12) // Start offset
|
||||
bf.WriteInt32(10) // Out size
|
||||
|
||||
result := UnpackSimple(bf.Data())
|
||||
// Should return the input as-is since it's not type 3
|
||||
if !bytes.Equal(result, bf.Data()) {
|
||||
t.Error("UnpackSimple() with non-type-3 JPK should return input as-is")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackSimple_ValidJPKType3_EmptyData(t *testing.T) {
|
||||
// Create a valid JPK type 3 header with minimal compressed data
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.SetLE()
|
||||
bf.WriteUint32(0x1A524B4A) // JPK header "JKR\x1A"
|
||||
bf.WriteUint16(0x00) // Reserved
|
||||
bf.WriteUint16(3) // Type 3
|
||||
bf.WriteInt32(12) // Start offset (points to byte 12, after header)
|
||||
bf.WriteInt32(0) // Out size (empty output)
|
||||
|
||||
result := UnpackSimple(bf.Data())
|
||||
// Should return empty buffer
|
||||
if len(result) != 0 {
|
||||
t.Errorf("UnpackSimple() with zero output size should return empty slice, got length %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackSimple_JPKHeader(t *testing.T) {
|
||||
// Test that the function correctly identifies JPK header (0x1A524B4A = "JKR\x1A" in little endian)
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.SetLE()
|
||||
bf.WriteUint32(0x1A524B4A) // Correct JPK magic
|
||||
|
||||
data := bf.Data()
|
||||
if len(data) < 4 {
|
||||
t.Fatal("Not enough data written")
|
||||
}
|
||||
|
||||
// Verify the header bytes are correct
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
header := bf.ReadUint32()
|
||||
if header != 0x1A524B4A {
|
||||
t.Errorf("Header = 0x%X, want 0x1A524B4A", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJPKBitShift_Initialization(t *testing.T) {
|
||||
// Test that bitShift correctly initializes from zero state
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint8(0xFF) // All bits set
|
||||
bf.WriteUint8(0x00) // No bits set
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
s := &jpkState{}
|
||||
|
||||
// First call should read 0xFF as flag and return bit 7 = 1
|
||||
bit := s.bitShift(bf)
|
||||
if bit != 1 {
|
||||
t.Errorf("bitShift() first bit of 0xFF = %d, want 1", bit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackSimple_ConcurrentSafety(t *testing.T) {
|
||||
// Verify that concurrent UnpackSimple calls don't race.
|
||||
// Non-JPK data is returned as-is; the important thing is no data race.
|
||||
input := []byte{0x00, 0x01, 0x02, 0x03}
|
||||
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < 8; i++ {
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
for j := 0; j < 100; j++ {
|
||||
result := UnpackSimple(input)
|
||||
if !bytes.Equal(result, input) {
|
||||
t.Errorf("concurrent UnpackSimple returned wrong data")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadByte(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint8(0x42)
|
||||
bf.WriteUint8(0xAB)
|
||||
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
b1 := ReadByte(bf)
|
||||
b2 := ReadByte(bf)
|
||||
|
||||
if b1 != 0x42 {
|
||||
t.Errorf("ReadByte() = 0x%X, want 0x42", b1)
|
||||
}
|
||||
if b2 != 0xAB {
|
||||
t.Errorf("ReadByte() = 0x%X, want 0xAB", b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJPKCopy(t *testing.T) {
|
||||
outBuffer := make([]byte, 20)
|
||||
// Set up some initial data
|
||||
outBuffer[0] = 'A'
|
||||
outBuffer[1] = 'B'
|
||||
outBuffer[2] = 'C'
|
||||
|
||||
index := 3
|
||||
// Copy 3 bytes from offset 2 (looking back 2+1=3 positions)
|
||||
JPKCopy(outBuffer, 2, 3, &index)
|
||||
|
||||
// Should have copied 'A', 'B', 'C' to positions 3, 4, 5
|
||||
if outBuffer[3] != 'A' || outBuffer[4] != 'B' || outBuffer[5] != 'C' {
|
||||
t.Errorf("JPKCopy failed: got %v at positions 3-5, want ['A', 'B', 'C']", outBuffer[3:6])
|
||||
}
|
||||
if index != 6 {
|
||||
t.Errorf("index = %d, want 6", index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJPKCopy_OverlappingCopy(t *testing.T) {
|
||||
// Test copying with overlapping regions (common in LZ-style compression)
|
||||
outBuffer := make([]byte, 20)
|
||||
outBuffer[0] = 'X'
|
||||
|
||||
index := 1
|
||||
// Copy from 1 position back, 5 times - should repeat the pattern
|
||||
JPKCopy(outBuffer, 0, 5, &index)
|
||||
|
||||
// Should produce: X X X X X (repeating X)
|
||||
for i := 1; i < 6; i++ {
|
||||
if outBuffer[i] != 'X' {
|
||||
t.Errorf("outBuffer[%d] = %c, want 'X'", i, outBuffer[i])
|
||||
}
|
||||
}
|
||||
if index != 6 {
|
||||
t.Errorf("index = %d, want 6", index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDecode_EmptyOutput(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint8(0x00)
|
||||
|
||||
outBuffer := make([]byte, 0)
|
||||
// Should not panic with empty output buffer
|
||||
ProcessDecode(bf, outBuffer)
|
||||
}
|
||||
|
||||
func TestUnpackSimple_EdgeCases(t *testing.T) {
|
||||
// Test with data that has at least 4 bytes (header size required)
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
}{
|
||||
{
|
||||
name: "four bytes non-JPK",
|
||||
input: []byte{0x00, 0x01, 0x02, 0x03},
|
||||
},
|
||||
{
|
||||
name: "partial header padded",
|
||||
input: []byte{0x4A, 0x4B, 0x00, 0x00},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := UnpackSimple(tt.input)
|
||||
// Should return input as-is without crashing
|
||||
if !bytes.Equal(result, tt.input) {
|
||||
t.Errorf("UnpackSimple() = %v, want %v", result, tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnpackSimple_Uncompressed(b *testing.B) {
|
||||
data := make([]byte, 1024)
|
||||
for i := range data {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = UnpackSimple(data)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnpackSimple_JPKHeader(b *testing.B) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.SetLE()
|
||||
bf.WriteUint32(0x1A524B4A) // JPK header
|
||||
bf.WriteUint16(0x00)
|
||||
bf.WriteUint16(3)
|
||||
bf.WriteInt32(12)
|
||||
bf.WriteInt32(0)
|
||||
data := bf.Data()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = UnpackSimple(data)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkReadByte(b *testing.B) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
for i := 0; i < 1000; i++ {
|
||||
bf.WriteUint8(byte(i % 256))
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = bf.Seek(0, io.SeekStart)
|
||||
_ = ReadByte(bf)
|
||||
}
|
||||
}
|
||||
4
common/gametime/doc.go
Normal file
4
common/gametime/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package gametime provides time helpers anchored to the JST (UTC+9) timezone
|
||||
// used by Monster Hunter Frontier's game clock, including weekly reset
|
||||
// boundaries and the in-game absolute time cycle.
|
||||
package gametime
|
||||
44
common/gametime/gametime.go
Normal file
44
common/gametime/gametime.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package gametime
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Adjusted returns the current time in JST (UTC+9), the timezone used by MHF.
|
||||
func Adjusted() time.Time {
|
||||
baseTime := time.Now().In(time.FixedZone("UTC+9", 9*60*60))
|
||||
return time.Date(baseTime.Year(), baseTime.Month(), baseTime.Day(), baseTime.Hour(), baseTime.Minute(), baseTime.Second(), baseTime.Nanosecond(), baseTime.Location())
|
||||
}
|
||||
|
||||
// Midnight returns today's midnight (00:00) in JST.
|
||||
func Midnight() time.Time {
|
||||
baseTime := time.Now().In(time.FixedZone("UTC+9", 9*60*60))
|
||||
return time.Date(baseTime.Year(), baseTime.Month(), baseTime.Day(), 0, 0, 0, 0, baseTime.Location())
|
||||
}
|
||||
|
||||
// WeekStart returns the most recent Monday at midnight in JST.
|
||||
func WeekStart() time.Time {
|
||||
midnight := Midnight()
|
||||
offset := int(midnight.Weekday()) - int(time.Monday)
|
||||
if offset < 0 {
|
||||
offset += 7
|
||||
}
|
||||
return midnight.Add(-time.Duration(offset) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
// WeekNext returns the next Monday at midnight in JST.
|
||||
func WeekNext() time.Time {
|
||||
return WeekStart().Add(time.Hour * 24 * 7)
|
||||
}
|
||||
|
||||
// MonthStart returns the first day of the current month at midnight in JST.
|
||||
func MonthStart() time.Time {
|
||||
midnight := Midnight()
|
||||
return time.Date(midnight.Year(), midnight.Month(), 1, 0, 0, 0, 0, midnight.Location())
|
||||
}
|
||||
|
||||
// GameAbsolute returns the current position within the 5760-second (96-minute)
|
||||
// in-game day/night cycle, offset by 2160 seconds.
|
||||
func GameAbsolute() uint32 {
|
||||
return uint32((Adjusted().Unix() - 2160) % 5760)
|
||||
}
|
||||
157
common/gametime/gametime_test.go
Normal file
157
common/gametime/gametime_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package gametime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdjusted(t *testing.T) {
|
||||
result := Adjusted()
|
||||
|
||||
_, offset := result.Zone()
|
||||
expectedOffset := 9 * 60 * 60
|
||||
if offset != expectedOffset {
|
||||
t.Errorf("Adjusted() zone offset = %d, want %d (UTC+9)", offset, expectedOffset)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
diff := result.Sub(now.In(time.FixedZone("UTC+9", 9*60*60)))
|
||||
if diff < -time.Second || diff > time.Second {
|
||||
t.Errorf("Adjusted() time differs from expected by %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMidnight(t *testing.T) {
|
||||
midnight := Midnight()
|
||||
|
||||
if midnight.Hour() != 0 {
|
||||
t.Errorf("Midnight() hour = %d, want 0", midnight.Hour())
|
||||
}
|
||||
if midnight.Minute() != 0 {
|
||||
t.Errorf("Midnight() minute = %d, want 0", midnight.Minute())
|
||||
}
|
||||
if midnight.Second() != 0 {
|
||||
t.Errorf("Midnight() second = %d, want 0", midnight.Second())
|
||||
}
|
||||
if midnight.Nanosecond() != 0 {
|
||||
t.Errorf("Midnight() nanosecond = %d, want 0", midnight.Nanosecond())
|
||||
}
|
||||
|
||||
_, offset := midnight.Zone()
|
||||
expectedOffset := 9 * 60 * 60
|
||||
if offset != expectedOffset {
|
||||
t.Errorf("Midnight() zone offset = %d, want %d (UTC+9)", offset, expectedOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekStart(t *testing.T) {
|
||||
weekStart := WeekStart()
|
||||
|
||||
if weekStart.Weekday() != time.Monday {
|
||||
t.Errorf("WeekStart() weekday = %v, want Monday", weekStart.Weekday())
|
||||
}
|
||||
|
||||
if weekStart.Hour() != 0 || weekStart.Minute() != 0 || weekStart.Second() != 0 {
|
||||
t.Errorf("WeekStart() should be at midnight, got %02d:%02d:%02d",
|
||||
weekStart.Hour(), weekStart.Minute(), weekStart.Second())
|
||||
}
|
||||
|
||||
_, offset := weekStart.Zone()
|
||||
expectedOffset := 9 * 60 * 60
|
||||
if offset != expectedOffset {
|
||||
t.Errorf("WeekStart() zone offset = %d, want %d (UTC+9)", offset, expectedOffset)
|
||||
}
|
||||
|
||||
midnight := Midnight()
|
||||
if weekStart.After(midnight) {
|
||||
t.Errorf("WeekStart() %v should be <= current midnight %v", weekStart, midnight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekNext(t *testing.T) {
|
||||
weekStart := WeekStart()
|
||||
weekNext := WeekNext()
|
||||
|
||||
expectedNext := weekStart.Add(time.Hour * 24 * 7)
|
||||
if !weekNext.Equal(expectedNext) {
|
||||
t.Errorf("WeekNext() = %v, want %v (7 days after WeekStart)", weekNext, expectedNext)
|
||||
}
|
||||
|
||||
if weekNext.Weekday() != time.Monday {
|
||||
t.Errorf("WeekNext() weekday = %v, want Monday", weekNext.Weekday())
|
||||
}
|
||||
|
||||
if weekNext.Hour() != 0 || weekNext.Minute() != 0 || weekNext.Second() != 0 {
|
||||
t.Errorf("WeekNext() should be at midnight, got %02d:%02d:%02d",
|
||||
weekNext.Hour(), weekNext.Minute(), weekNext.Second())
|
||||
}
|
||||
|
||||
if !weekNext.After(weekStart) {
|
||||
t.Errorf("WeekNext() %v should be after WeekStart() %v", weekNext, weekStart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekStartSundayEdge(t *testing.T) {
|
||||
weekStart := WeekStart()
|
||||
|
||||
if weekStart.Weekday() != time.Monday {
|
||||
t.Errorf("WeekStart() on any day should return Monday, got %v", weekStart.Weekday())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMidnightSameDay(t *testing.T) {
|
||||
adjusted := Adjusted()
|
||||
midnight := Midnight()
|
||||
|
||||
if midnight.Year() != adjusted.Year() ||
|
||||
midnight.Month() != adjusted.Month() ||
|
||||
midnight.Day() != adjusted.Day() {
|
||||
t.Errorf("Midnight() date = %v, want same day as Adjusted() %v",
|
||||
midnight.Format("2006-01-02"), adjusted.Format("2006-01-02"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekDuration(t *testing.T) {
|
||||
weekStart := WeekStart()
|
||||
weekNext := WeekNext()
|
||||
|
||||
duration := weekNext.Sub(weekStart)
|
||||
expectedDuration := time.Hour * 24 * 7
|
||||
|
||||
if duration != expectedDuration {
|
||||
t.Errorf("Duration between WeekStart and WeekNext = %v, want %v", duration, expectedDuration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeZoneConsistency(t *testing.T) {
|
||||
adjusted := Adjusted()
|
||||
midnight := Midnight()
|
||||
weekStart := WeekStart()
|
||||
weekNext := WeekNext()
|
||||
|
||||
times := []struct {
|
||||
name string
|
||||
time time.Time
|
||||
}{
|
||||
{"Adjusted", adjusted},
|
||||
{"Midnight", midnight},
|
||||
{"WeekStart", weekStart},
|
||||
{"WeekNext", weekNext},
|
||||
}
|
||||
|
||||
expectedOffset := 9 * 60 * 60
|
||||
for _, tt := range times {
|
||||
_, offset := tt.time.Zone()
|
||||
if offset != expectedOffset {
|
||||
t.Errorf("%s() zone offset = %d, want %d (UTC+9)", tt.name, offset, expectedOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameAbsolute(t *testing.T) {
|
||||
result := GameAbsolute()
|
||||
|
||||
if result >= 5760 {
|
||||
t.Errorf("GameAbsolute() = %d, should be < 5760", result)
|
||||
}
|
||||
}
|
||||
3
common/mhfcid/doc.go
Normal file
3
common/mhfcid/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package mhfcid converts MHF Character ID strings (a base-32 encoding that
|
||||
// omits the ambiguous characters 0, I, O, and S) to their numeric equivalents.
|
||||
package mhfcid
|
||||
258
common/mhfcid/mhfcid_test.go
Normal file
258
common/mhfcid/mhfcid_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package mhfcid
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvertCID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected uint32
|
||||
}{
|
||||
{
|
||||
name: "all ones",
|
||||
input: "111111",
|
||||
expected: 0, // '1' maps to 0, so 0*32^0 + 0*32^1 + ... = 0
|
||||
},
|
||||
{
|
||||
name: "all twos",
|
||||
input: "222222",
|
||||
expected: 1 + 32 + 1024 + 32768 + 1048576 + 33554432, // 1*32^0 + 1*32^1 + 1*32^2 + 1*32^3 + 1*32^4 + 1*32^5
|
||||
},
|
||||
{
|
||||
name: "sequential",
|
||||
input: "123456",
|
||||
expected: 0 + 32 + 2*1024 + 3*32768 + 4*1048576 + 5*33554432, // 0 + 1*32 + 2*32^2 + 3*32^3 + 4*32^4 + 5*32^5
|
||||
},
|
||||
{
|
||||
name: "with letters A-Z",
|
||||
input: "ABCDEF",
|
||||
expected: 9 + 10*32 + 11*1024 + 12*32768 + 13*1048576 + 14*33554432,
|
||||
},
|
||||
{
|
||||
name: "mixed numbers and letters",
|
||||
input: "1A2B3C",
|
||||
expected: 0 + 9*32 + 1*1024 + 10*32768 + 2*1048576 + 11*33554432,
|
||||
},
|
||||
{
|
||||
name: "max valid characters",
|
||||
input: "ZZZZZZ",
|
||||
expected: 31 + 31*32 + 31*1024 + 31*32768 + 31*1048576 + 31*33554432, // 31 * (1 + 32 + 1024 + 32768 + 1048576 + 33554432)
|
||||
},
|
||||
{
|
||||
name: "no banned chars: O excluded",
|
||||
input: "N1P1Q1", // N=21, P=22, Q=23 - note no O
|
||||
expected: 21 + 0*32 + 22*1024 + 0*32768 + 23*1048576 + 0*33554432,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConvertCID(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ConvertCID(%q) = %d, want %d", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCID_InvalidLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"too short - 1", "1"},
|
||||
{"too short - 5", "12345"},
|
||||
{"too long - 7", "1234567"},
|
||||
{"too long - 10", "1234567890"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConvertCID(tt.input)
|
||||
if result != 0 {
|
||||
t.Errorf("ConvertCID(%q) = %d, want 0 (invalid length should return 0)", tt.input, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCID_BannedCharacters(t *testing.T) {
|
||||
// Banned characters: 0, I, O, S
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"contains 0", "111011"},
|
||||
{"contains I", "111I11"},
|
||||
{"contains O", "11O111"},
|
||||
{"contains S", "S11111"},
|
||||
{"all banned", "000III"},
|
||||
{"mixed banned", "I0OS11"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConvertCID(tt.input)
|
||||
// Characters not in the map will contribute 0 to the result
|
||||
// The function doesn't explicitly reject them, it just doesn't map them
|
||||
// So we're testing that banned characters don't crash the function
|
||||
_ = result // Just verify it doesn't panic
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCID_LowercaseNotSupported(t *testing.T) {
|
||||
// The map only contains uppercase letters
|
||||
input := "abcdef"
|
||||
result := ConvertCID(input)
|
||||
// Lowercase letters aren't mapped, so they'll contribute 0
|
||||
if result != 0 {
|
||||
t.Logf("ConvertCID(%q) = %d (lowercase not in map, contributes 0)", input, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCID_CharacterMapping(t *testing.T) {
|
||||
// Verify specific character mappings
|
||||
tests := []struct {
|
||||
char rune
|
||||
expected uint32
|
||||
}{
|
||||
{'1', 0},
|
||||
{'2', 1},
|
||||
{'9', 8},
|
||||
{'A', 9},
|
||||
{'B', 10},
|
||||
{'Z', 31},
|
||||
{'J', 17}, // J comes after I is skipped
|
||||
{'P', 22}, // P comes after O is skipped
|
||||
{'T', 25}, // T comes after S is skipped
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.char), func(t *testing.T) {
|
||||
// Create a CID with the character in the first position (32^0)
|
||||
input := string(tt.char) + "11111"
|
||||
result := ConvertCID(input)
|
||||
// The first character contributes its value * 32^0 = value * 1
|
||||
if result != tt.expected {
|
||||
t.Errorf("ConvertCID(%q) first char value = %d, want %d", input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCID_Base32Like(t *testing.T) {
|
||||
// Test that it behaves like base-32 conversion
|
||||
// The position multiplier should be powers of 32
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected uint32
|
||||
}{
|
||||
{
|
||||
name: "position 0 only",
|
||||
input: "211111", // 2 in position 0
|
||||
expected: 1, // 1 * 32^0
|
||||
},
|
||||
{
|
||||
name: "position 1 only",
|
||||
input: "121111", // 2 in position 1
|
||||
expected: 32, // 1 * 32^1
|
||||
},
|
||||
{
|
||||
name: "position 2 only",
|
||||
input: "112111", // 2 in position 2
|
||||
expected: 1024, // 1 * 32^2
|
||||
},
|
||||
{
|
||||
name: "position 3 only",
|
||||
input: "111211", // 2 in position 3
|
||||
expected: 32768, // 1 * 32^3
|
||||
},
|
||||
{
|
||||
name: "position 4 only",
|
||||
input: "111121", // 2 in position 4
|
||||
expected: 1048576, // 1 * 32^4
|
||||
},
|
||||
{
|
||||
name: "position 5 only",
|
||||
input: "111112", // 2 in position 5
|
||||
expected: 33554432, // 1 * 32^5
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConvertCID(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ConvertCID(%q) = %d, want %d", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCID_SkippedCharacters(t *testing.T) {
|
||||
// Verify that 0, I, O, S are actually skipped in the character sequence
|
||||
// The alphabet should be: 1-9 (0 skipped), A-H (I skipped), J-N (O skipped), P-R (S skipped), T-Z
|
||||
|
||||
// Test that characters after skipped ones have the right values
|
||||
tests := []struct {
|
||||
name string
|
||||
char1 string // Character before skip
|
||||
char2 string // Character after skip
|
||||
diff uint32 // Expected difference (should be 1)
|
||||
}{
|
||||
{"before/after I skip", "H", "J", 1}, // H=16, J=17
|
||||
{"before/after O skip", "N", "P", 1}, // N=21, P=22
|
||||
{"before/after S skip", "R", "T", 1}, // R=24, T=25
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cid1 := tt.char1 + "11111"
|
||||
cid2 := tt.char2 + "11111"
|
||||
val1 := ConvertCID(cid1)
|
||||
val2 := ConvertCID(cid2)
|
||||
diff := val2 - val1
|
||||
if diff != tt.diff {
|
||||
t.Errorf("Difference between %s and %s = %d, want %d (val1=%d, val2=%d)",
|
||||
tt.char1, tt.char2, diff, tt.diff, val1, val2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConvertCID(b *testing.B) {
|
||||
testCID := "A1B2C3"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ConvertCID(testCID)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConvertCID_AllLetters(b *testing.B) {
|
||||
testCID := "ABCDEF"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ConvertCID(testCID)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConvertCID_AllNumbers(b *testing.B) {
|
||||
testCID := "123456"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ConvertCID(testCID)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConvertCID_InvalidLength(b *testing.B) {
|
||||
testCID := "123" // Too short
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ConvertCID(testCID)
|
||||
}
|
||||
}
|
||||
5
common/mhfcourse/doc.go
Normal file
5
common/mhfcourse/doc.go
Normal file
@@ -0,0 +1,5 @@
|
||||
// Package mhfcourse models the subscription course system used by Monster
|
||||
// Hunter Frontier. Courses (Trial, HunterLife, Extra, Premium, etc.) are
|
||||
// represented as bit flags in a uint32 rights field and control which game
|
||||
// features a player can access.
|
||||
package mhfcourse
|
||||
@@ -1,12 +1,12 @@
|
||||
package mhfcourse
|
||||
|
||||
import (
|
||||
_config "erupe-ce/config"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Course represents an active subscription course with its ID and expiry time.
|
||||
type Course struct {
|
||||
ID uint16
|
||||
Expiry time.Time
|
||||
@@ -39,10 +39,12 @@ var aliases = map[uint16][]string{
|
||||
// 30 = Real NetCafe course
|
||||
}
|
||||
|
||||
// Aliases returns the human-readable names for this course (e.g. "HunterLife", "HL").
|
||||
func (c Course) Aliases() []string {
|
||||
return aliases[c.ID]
|
||||
}
|
||||
|
||||
// Courses returns all 32 possible course slots with zero-value expiry times.
|
||||
func Courses() []Course {
|
||||
courses := make([]Course, 32)
|
||||
for i := range courses {
|
||||
@@ -51,6 +53,7 @@ func Courses() []Course {
|
||||
return courses
|
||||
}
|
||||
|
||||
// Value returns the bitmask value for this course (2^ID).
|
||||
func (c Course) Value() uint32 {
|
||||
return uint32(math.Pow(2, float64(c.ID)))
|
||||
}
|
||||
@@ -66,9 +69,9 @@ func CourseExists(ID uint16, c []Course) bool {
|
||||
}
|
||||
|
||||
// GetCourseStruct returns a slice of Course(s) from a rights integer
|
||||
func GetCourseStruct(rights uint32) ([]Course, uint32) {
|
||||
func GetCourseStruct(rights uint32, defaultCourses []uint16) ([]Course, uint32) {
|
||||
var resp []Course
|
||||
for _, c := range _config.ErupeConfig.DefaultCourses {
|
||||
for _, c := range defaultCourses {
|
||||
resp = append(resp, Course{ID: c})
|
||||
}
|
||||
s := Courses()
|
||||
|
||||
336
common/mhfcourse/mhfcourse_test.go
Normal file
336
common/mhfcourse/mhfcourse_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package mhfcourse
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCourse_Aliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
id uint16
|
||||
wantLen int
|
||||
want []string
|
||||
}{
|
||||
{1, 2, []string{"Trial", "TL"}},
|
||||
{2, 2, []string{"HunterLife", "HL"}},
|
||||
{3, 3, []string{"Extra", "ExtraA", "EX"}},
|
||||
{8, 4, []string{"Assist", "***ist", "Legend", "Rasta"}},
|
||||
{26, 4, []string{"NetCafe", "Cafe", "OfficialCafe", "Official"}},
|
||||
{13, 0, nil}, // Unknown course
|
||||
{99, 0, nil}, // Unknown course
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(rune(tt.id)), func(t *testing.T) {
|
||||
c := Course{ID: tt.id}
|
||||
got := c.Aliases()
|
||||
if len(got) != tt.wantLen {
|
||||
t.Errorf("Course{ID: %d}.Aliases() length = %d, want %d", tt.id, len(got), tt.wantLen)
|
||||
}
|
||||
if tt.want != nil {
|
||||
for i, alias := range tt.want {
|
||||
if i >= len(got) || got[i] != alias {
|
||||
t.Errorf("Course{ID: %d}.Aliases()[%d] = %q, want %q", tt.id, i, got[i], alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCourses(t *testing.T) {
|
||||
courses := Courses()
|
||||
if len(courses) != 32 {
|
||||
t.Errorf("Courses() length = %d, want 32", len(courses))
|
||||
}
|
||||
|
||||
// Verify IDs are sequential from 0 to 31
|
||||
for i, course := range courses {
|
||||
if course.ID != uint16(i) {
|
||||
t.Errorf("Courses()[%d].ID = %d, want %d", i, course.ID, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCourse_Value(t *testing.T) {
|
||||
tests := []struct {
|
||||
id uint16
|
||||
expected uint32
|
||||
}{
|
||||
{0, 1}, // 2^0
|
||||
{1, 2}, // 2^1
|
||||
{2, 4}, // 2^2
|
||||
{3, 8}, // 2^3
|
||||
{4, 16}, // 2^4
|
||||
{5, 32}, // 2^5
|
||||
{10, 1024}, // 2^10
|
||||
{15, 32768}, // 2^15
|
||||
{20, 1048576}, // 2^20
|
||||
{31, 2147483648}, // 2^31
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(rune(tt.id)), func(t *testing.T) {
|
||||
c := Course{ID: tt.id}
|
||||
got := c.Value()
|
||||
if got != tt.expected {
|
||||
t.Errorf("Course{ID: %d}.Value() = %d, want %d", tt.id, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCourseExists(t *testing.T) {
|
||||
courses := []Course{
|
||||
{ID: 1},
|
||||
{ID: 5},
|
||||
{ID: 10},
|
||||
{ID: 15},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id uint16
|
||||
expected bool
|
||||
}{
|
||||
{"exists first", 1, true},
|
||||
{"exists middle", 5, true},
|
||||
{"exists last", 15, true},
|
||||
{"not exists", 3, false},
|
||||
{"not exists 0", 0, false},
|
||||
{"not exists 20", 20, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := CourseExists(tt.id, courses)
|
||||
if got != tt.expected {
|
||||
t.Errorf("CourseExists(%d, courses) = %v, want %v", tt.id, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCourseExists_EmptySlice(t *testing.T) {
|
||||
var courses []Course
|
||||
if CourseExists(1, courses) {
|
||||
t.Error("CourseExists(1, []) should return false for empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCourseStruct(t *testing.T) {
|
||||
defaultCourses := []uint16{1, 2}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rights uint32
|
||||
wantMinLen int // Minimum expected courses (including defaults)
|
||||
checkCourses []uint16
|
||||
}{
|
||||
{
|
||||
name: "no rights",
|
||||
rights: 0,
|
||||
wantMinLen: 2, // Just default courses
|
||||
checkCourses: []uint16{1, 2},
|
||||
},
|
||||
{
|
||||
name: "course 3 only",
|
||||
rights: 8, // 2^3
|
||||
wantMinLen: 3, // defaults + course 3
|
||||
checkCourses: []uint16{1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "course 1",
|
||||
rights: 2, // 2^1
|
||||
wantMinLen: 2,
|
||||
checkCourses: []uint16{1, 2},
|
||||
},
|
||||
{
|
||||
name: "multiple courses",
|
||||
rights: 2 + 8 + 32, // courses 1, 3, 5
|
||||
wantMinLen: 4,
|
||||
checkCourses: []uint16{1, 2, 3, 5},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
courses, newRights := GetCourseStruct(tt.rights, defaultCourses)
|
||||
|
||||
if len(courses) < tt.wantMinLen {
|
||||
t.Errorf("GetCourseStruct(%d) returned %d courses, want at least %d", tt.rights, len(courses), tt.wantMinLen)
|
||||
}
|
||||
|
||||
// Verify expected courses are present
|
||||
for _, id := range tt.checkCourses {
|
||||
found := false
|
||||
for _, c := range courses {
|
||||
if c.ID == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetCourseStruct(%d) missing expected course ID %d", tt.rights, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify newRights is a valid sum of course values
|
||||
if newRights < tt.rights {
|
||||
t.Logf("GetCourseStruct(%d) newRights = %d (may include additional courses)", tt.rights, newRights)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCourseStruct_NetcafeCourse(t *testing.T) {
|
||||
// Course 26 (NetCafe) should add course 25
|
||||
courses, _ := GetCourseStruct(1<<26, nil)
|
||||
|
||||
hasNetcafe := false
|
||||
hasCafeSP := false
|
||||
hasRealNetcafe := false
|
||||
for _, c := range courses {
|
||||
if c.ID == 26 {
|
||||
hasNetcafe = true
|
||||
}
|
||||
if c.ID == 25 {
|
||||
hasCafeSP = true
|
||||
}
|
||||
if c.ID == 30 {
|
||||
hasRealNetcafe = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasNetcafe {
|
||||
t.Error("Course 26 (NetCafe) should be present")
|
||||
}
|
||||
if !hasCafeSP {
|
||||
t.Error("Course 25 should be added when course 26 is present")
|
||||
}
|
||||
if !hasRealNetcafe {
|
||||
t.Error("Course 30 should be added when course 26 is present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCourseStruct_NCourse(t *testing.T) {
|
||||
// Course 9 should add course 30
|
||||
courses, _ := GetCourseStruct(1<<9, nil)
|
||||
|
||||
hasNCourse := false
|
||||
hasRealNetcafe := false
|
||||
for _, c := range courses {
|
||||
if c.ID == 9 {
|
||||
hasNCourse = true
|
||||
}
|
||||
if c.ID == 30 {
|
||||
hasRealNetcafe = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasNCourse {
|
||||
t.Error("Course 9 (N) should be present")
|
||||
}
|
||||
if !hasRealNetcafe {
|
||||
t.Error("Course 30 should be added when course 9 is present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCourseStruct_HidenCourse(t *testing.T) {
|
||||
// Course 10 (Hiden) should add course 31
|
||||
courses, _ := GetCourseStruct(1<<10, nil)
|
||||
|
||||
hasHiden := false
|
||||
hasHidenExtra := false
|
||||
for _, c := range courses {
|
||||
if c.ID == 10 {
|
||||
hasHiden = true
|
||||
}
|
||||
if c.ID == 31 {
|
||||
hasHidenExtra = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasHiden {
|
||||
t.Error("Course 10 (Hiden) should be present")
|
||||
}
|
||||
if !hasHidenExtra {
|
||||
t.Error("Course 31 should be added when course 10 is present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCourseStruct_ExpiryDate(t *testing.T) {
|
||||
courses, _ := GetCourseStruct(1<<3, nil)
|
||||
|
||||
expectedExpiry := time.Date(2030, 1, 1, 0, 0, 0, 0, time.FixedZone("UTC+9", 9*60*60))
|
||||
|
||||
for _, c := range courses {
|
||||
if c.ID == 3 && !c.Expiry.IsZero() {
|
||||
if !c.Expiry.Equal(expectedExpiry) {
|
||||
t.Errorf("Course expiry = %v, want %v", c.Expiry, expectedExpiry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCourseStruct_ReturnsRecalculatedRights(t *testing.T) {
|
||||
courses, newRights := GetCourseStruct(2+8+32, nil) // courses 1, 3, 5
|
||||
|
||||
// Calculate expected rights from returned courses
|
||||
var expectedRights uint32
|
||||
for _, c := range courses {
|
||||
expectedRights += c.Value()
|
||||
}
|
||||
|
||||
if newRights != expectedRights {
|
||||
t.Errorf("GetCourseStruct() newRights = %d, want %d (sum of returned course values)", newRights, expectedRights)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCourse_ValueMatchesPowerOfTwo(t *testing.T) {
|
||||
// Verify that Value() correctly implements 2^ID
|
||||
for id := uint16(0); id < 32; id++ {
|
||||
c := Course{ID: id}
|
||||
expected := uint32(math.Pow(2, float64(id)))
|
||||
got := c.Value()
|
||||
if got != expected {
|
||||
t.Errorf("Course{ID: %d}.Value() = %d, want %d", id, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCourse_Value(b *testing.B) {
|
||||
c := Course{ID: 15}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = c.Value()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCourseExists(b *testing.B) {
|
||||
courses := []Course{
|
||||
{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}, {ID: 5},
|
||||
{ID: 10}, {ID: 15}, {ID: 20}, {ID: 25}, {ID: 30},
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = CourseExists(15, courses)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetCourseStruct(b *testing.B) {
|
||||
defaultCourses := []uint16{1, 2}
|
||||
rights := uint32(2 + 8 + 32 + 128 + 512)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = GetCourseStruct(rights, defaultCourses)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCourses(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Courses()
|
||||
}
|
||||
}
|
||||
4
common/mhfitem/doc.go
Normal file
4
common/mhfitem/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package mhfitem defines item, equipment, and sigil data structures as they
|
||||
// appear in the MHF binary protocol, and provides serialization helpers for
|
||||
// warehouse (box/storage) operations.
|
||||
package mhfitem
|
||||
@@ -3,18 +3,21 @@ package mhfitem
|
||||
import (
|
||||
"erupe-ce/common/byteframe"
|
||||
"erupe-ce/common/token"
|
||||
_config "erupe-ce/config"
|
||||
cfg "erupe-ce/config"
|
||||
)
|
||||
|
||||
// MHFItem represents a single item identified by its in-game item ID.
|
||||
type MHFItem struct {
|
||||
ItemID uint16
|
||||
}
|
||||
|
||||
// MHFSigilEffect represents a single effect slot on a sigil with an ID and level.
|
||||
type MHFSigilEffect struct {
|
||||
ID uint16
|
||||
Level uint16
|
||||
}
|
||||
|
||||
// MHFSigil represents a weapon sigil containing up to three effects.
|
||||
type MHFSigil struct {
|
||||
Effects []MHFSigilEffect
|
||||
Unk0 uint8
|
||||
@@ -23,6 +26,8 @@ type MHFSigil struct {
|
||||
Unk3 uint8
|
||||
}
|
||||
|
||||
// MHFEquipment represents an equipment piece (weapon or armor) with its
|
||||
// decorations and sigils as stored in the player's warehouse.
|
||||
type MHFEquipment struct {
|
||||
WarehouseID uint32
|
||||
ItemType uint8
|
||||
@@ -34,6 +39,7 @@ type MHFEquipment struct {
|
||||
Unk1 uint16
|
||||
}
|
||||
|
||||
// MHFItemStack represents a stacked item slot in the warehouse with a quantity.
|
||||
type MHFItemStack struct {
|
||||
WarehouseID uint32
|
||||
Item MHFItem
|
||||
@@ -41,6 +47,8 @@ type MHFItemStack struct {
|
||||
Unk0 uint32
|
||||
}
|
||||
|
||||
// ReadWarehouseItem deserializes an MHFItemStack from a ByteFrame, assigning a
|
||||
// random warehouse ID if the encoded ID is zero.
|
||||
func ReadWarehouseItem(bf *byteframe.ByteFrame) MHFItemStack {
|
||||
var item MHFItemStack
|
||||
item.WarehouseID = bf.ReadUint32()
|
||||
@@ -53,6 +61,9 @@ func ReadWarehouseItem(bf *byteframe.ByteFrame) MHFItemStack {
|
||||
return item
|
||||
}
|
||||
|
||||
// DiffItemStacks merges an updated item stack list into an existing one,
|
||||
// matching by warehouse ID. New items receive a random ID; items with zero
|
||||
// quantity in the old list are removed.
|
||||
func DiffItemStacks(o []MHFItemStack, u []MHFItemStack) []MHFItemStack {
|
||||
// o = old, u = update, f = final
|
||||
var f []MHFItemStack
|
||||
@@ -77,6 +88,7 @@ func DiffItemStacks(o []MHFItemStack, u []MHFItemStack) []MHFItemStack {
|
||||
return f
|
||||
}
|
||||
|
||||
// ToBytes serializes the item stack to its binary protocol representation.
|
||||
func (is MHFItemStack) ToBytes() []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint32(is.WarehouseID)
|
||||
@@ -86,6 +98,8 @@ func (is MHFItemStack) ToBytes() []byte {
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
// SerializeWarehouseItems serializes a slice of item stacks with a uint16
|
||||
// count header for transmission in warehouse response packets.
|
||||
func SerializeWarehouseItems(i []MHFItemStack) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(uint16(len(i)))
|
||||
@@ -96,7 +110,10 @@ func SerializeWarehouseItems(i []MHFItemStack) []byte {
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
func ReadWarehouseEquipment(bf *byteframe.ByteFrame) MHFEquipment {
|
||||
// ReadWarehouseEquipment deserializes an MHFEquipment from a ByteFrame. The
|
||||
// binary layout varies by game version: sigils are present from G1 onward and
|
||||
// an additional field is present from Z1 onward.
|
||||
func ReadWarehouseEquipment(bf *byteframe.ByteFrame, mode cfg.Mode) MHFEquipment {
|
||||
var equipment MHFEquipment
|
||||
equipment.Decorations = make([]MHFItem, 3)
|
||||
equipment.Sigils = make([]MHFSigil, 3)
|
||||
@@ -114,7 +131,7 @@ func ReadWarehouseEquipment(bf *byteframe.ByteFrame) MHFEquipment {
|
||||
for i := 0; i < 3; i++ {
|
||||
equipment.Decorations[i].ItemID = bf.ReadUint16()
|
||||
}
|
||||
if _config.ErupeConfig.RealClientMode >= _config.G1 {
|
||||
if mode >= cfg.G1 {
|
||||
for i := 0; i < 3; i++ {
|
||||
for j := 0; j < 3; j++ {
|
||||
equipment.Sigils[i].Effects[j].ID = bf.ReadUint16()
|
||||
@@ -128,13 +145,14 @@ func ReadWarehouseEquipment(bf *byteframe.ByteFrame) MHFEquipment {
|
||||
equipment.Sigils[i].Unk3 = bf.ReadUint8()
|
||||
}
|
||||
}
|
||||
if _config.ErupeConfig.RealClientMode >= _config.Z1 {
|
||||
if mode >= cfg.Z1 {
|
||||
equipment.Unk1 = bf.ReadUint16()
|
||||
}
|
||||
return equipment
|
||||
}
|
||||
|
||||
func (e MHFEquipment) ToBytes() []byte {
|
||||
// ToBytes serializes the equipment to its binary protocol representation.
|
||||
func (e MHFEquipment) ToBytes(mode cfg.Mode) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint32(e.WarehouseID)
|
||||
bf.WriteUint8(e.ItemType)
|
||||
@@ -144,7 +162,7 @@ func (e MHFEquipment) ToBytes() []byte {
|
||||
for i := 0; i < 3; i++ {
|
||||
bf.WriteUint16(e.Decorations[i].ItemID)
|
||||
}
|
||||
if _config.ErupeConfig.RealClientMode >= _config.G1 {
|
||||
if mode >= cfg.G1 {
|
||||
for i := 0; i < 3; i++ {
|
||||
for j := 0; j < 3; j++ {
|
||||
bf.WriteUint16(e.Sigils[i].Effects[j].ID)
|
||||
@@ -158,18 +176,20 @@ func (e MHFEquipment) ToBytes() []byte {
|
||||
bf.WriteUint8(e.Sigils[i].Unk3)
|
||||
}
|
||||
}
|
||||
if _config.ErupeConfig.RealClientMode >= _config.Z1 {
|
||||
if mode >= cfg.Z1 {
|
||||
bf.WriteUint16(e.Unk1)
|
||||
}
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
func SerializeWarehouseEquipment(i []MHFEquipment) []byte {
|
||||
// SerializeWarehouseEquipment serializes a slice of equipment with a uint16
|
||||
// count header for transmission in warehouse response packets.
|
||||
func SerializeWarehouseEquipment(i []MHFEquipment, mode cfg.Mode) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(uint16(len(i)))
|
||||
bf.WriteUint16(0) // Unused
|
||||
for _, j := range i {
|
||||
bf.WriteBytes(j.ToBytes())
|
||||
bf.WriteBytes(j.ToBytes(mode))
|
||||
}
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
526
common/mhfitem/mhfitem_test.go
Normal file
526
common/mhfitem/mhfitem_test.go
Normal file
@@ -0,0 +1,526 @@
|
||||
package mhfitem
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"erupe-ce/common/byteframe"
|
||||
"erupe-ce/common/token"
|
||||
cfg "erupe-ce/config"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadWarehouseItem(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint32(12345) // WarehouseID
|
||||
bf.WriteUint16(100) // ItemID
|
||||
bf.WriteUint16(5) // Quantity
|
||||
bf.WriteUint32(999999) // Unk0
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
item := ReadWarehouseItem(bf)
|
||||
|
||||
if item.WarehouseID != 12345 {
|
||||
t.Errorf("WarehouseID = %d, want 12345", item.WarehouseID)
|
||||
}
|
||||
if item.Item.ItemID != 100 {
|
||||
t.Errorf("ItemID = %d, want 100", item.Item.ItemID)
|
||||
}
|
||||
if item.Quantity != 5 {
|
||||
t.Errorf("Quantity = %d, want 5", item.Quantity)
|
||||
}
|
||||
if item.Unk0 != 999999 {
|
||||
t.Errorf("Unk0 = %d, want 999999", item.Unk0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWarehouseItem_ZeroWarehouseID(t *testing.T) {
|
||||
// When WarehouseID is 0, it should be replaced with a random value
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint32(0) // WarehouseID = 0
|
||||
bf.WriteUint16(100) // ItemID
|
||||
bf.WriteUint16(5) // Quantity
|
||||
bf.WriteUint32(0) // Unk0
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
item := ReadWarehouseItem(bf)
|
||||
|
||||
if item.WarehouseID == 0 {
|
||||
t.Error("WarehouseID should be replaced with random value when input is 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMHFItemStack_ToBytes(t *testing.T) {
|
||||
item := MHFItemStack{
|
||||
WarehouseID: 12345,
|
||||
Item: MHFItem{ItemID: 100},
|
||||
Quantity: 5,
|
||||
Unk0: 999999,
|
||||
}
|
||||
|
||||
data := item.ToBytes()
|
||||
if len(data) != 12 { // 4 + 2 + 2 + 4
|
||||
t.Errorf("ToBytes() length = %d, want 12", len(data))
|
||||
}
|
||||
|
||||
// Read it back
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
readItem := ReadWarehouseItem(bf)
|
||||
|
||||
if readItem.WarehouseID != item.WarehouseID {
|
||||
t.Errorf("WarehouseID = %d, want %d", readItem.WarehouseID, item.WarehouseID)
|
||||
}
|
||||
if readItem.Item.ItemID != item.Item.ItemID {
|
||||
t.Errorf("ItemID = %d, want %d", readItem.Item.ItemID, item.Item.ItemID)
|
||||
}
|
||||
if readItem.Quantity != item.Quantity {
|
||||
t.Errorf("Quantity = %d, want %d", readItem.Quantity, item.Quantity)
|
||||
}
|
||||
if readItem.Unk0 != item.Unk0 {
|
||||
t.Errorf("Unk0 = %d, want %d", readItem.Unk0, item.Unk0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeWarehouseItems(t *testing.T) {
|
||||
items := []MHFItemStack{
|
||||
{WarehouseID: 1, Item: MHFItem{ItemID: 100}, Quantity: 5, Unk0: 0},
|
||||
{WarehouseID: 2, Item: MHFItem{ItemID: 200}, Quantity: 10, Unk0: 0},
|
||||
}
|
||||
|
||||
data := SerializeWarehouseItems(items)
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
|
||||
count := bf.ReadUint16()
|
||||
if count != 2 {
|
||||
t.Errorf("count = %d, want 2", count)
|
||||
}
|
||||
|
||||
bf.ReadUint16() // Skip unused
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
item := ReadWarehouseItem(bf)
|
||||
if item.WarehouseID != items[i].WarehouseID {
|
||||
t.Errorf("item[%d] WarehouseID = %d, want %d", i, item.WarehouseID, items[i].WarehouseID)
|
||||
}
|
||||
if item.Item.ItemID != items[i].Item.ItemID {
|
||||
t.Errorf("item[%d] ItemID = %d, want %d", i, item.Item.ItemID, items[i].Item.ItemID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeWarehouseItems_Empty(t *testing.T) {
|
||||
items := []MHFItemStack{}
|
||||
data := SerializeWarehouseItems(items)
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
|
||||
count := bf.ReadUint16()
|
||||
if count != 0 {
|
||||
t.Errorf("count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffItemStacks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
old []MHFItemStack
|
||||
update []MHFItemStack
|
||||
wantLen int
|
||||
checkFn func(t *testing.T, result []MHFItemStack)
|
||||
}{
|
||||
{
|
||||
name: "update existing quantity",
|
||||
old: []MHFItemStack{
|
||||
{WarehouseID: 1, Item: MHFItem{ItemID: 100}, Quantity: 5},
|
||||
},
|
||||
update: []MHFItemStack{
|
||||
{WarehouseID: 1, Item: MHFItem{ItemID: 100}, Quantity: 10},
|
||||
},
|
||||
wantLen: 1,
|
||||
checkFn: func(t *testing.T, result []MHFItemStack) {
|
||||
if result[0].Quantity != 10 {
|
||||
t.Errorf("Quantity = %d, want 10", result[0].Quantity)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add new item",
|
||||
old: []MHFItemStack{
|
||||
{WarehouseID: 1, Item: MHFItem{ItemID: 100}, Quantity: 5},
|
||||
},
|
||||
update: []MHFItemStack{
|
||||
{WarehouseID: 1, Item: MHFItem{ItemID: 100}, Quantity: 5},
|
||||
{WarehouseID: 0, Item: MHFItem{ItemID: 200}, Quantity: 3}, // WarehouseID 0 = new
|
||||
},
|
||||
wantLen: 2,
|
||||
checkFn: func(t *testing.T, result []MHFItemStack) {
|
||||
hasNewItem := false
|
||||
for _, item := range result {
|
||||
if item.Item.ItemID == 200 {
|
||||
hasNewItem = true
|
||||
if item.WarehouseID == 0 {
|
||||
t.Error("New item should have generated WarehouseID")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasNewItem {
|
||||
t.Error("New item should be in result")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove item (quantity 0)",
|
||||
old: []MHFItemStack{
|
||||
{WarehouseID: 1, Item: MHFItem{ItemID: 100}, Quantity: 5},
|
||||
{WarehouseID: 2, Item: MHFItem{ItemID: 200}, Quantity: 10},
|
||||
},
|
||||
update: []MHFItemStack{
|
||||
{WarehouseID: 1, Item: MHFItem{ItemID: 100}, Quantity: 0}, // Removed
|
||||
},
|
||||
wantLen: 1,
|
||||
checkFn: func(t *testing.T, result []MHFItemStack) {
|
||||
for _, item := range result {
|
||||
if item.WarehouseID == 1 {
|
||||
t.Error("Item with quantity 0 should be removed")
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty old, add new",
|
||||
old: []MHFItemStack{},
|
||||
update: []MHFItemStack{{WarehouseID: 0, Item: MHFItem{ItemID: 100}, Quantity: 5}},
|
||||
wantLen: 1,
|
||||
checkFn: func(t *testing.T, result []MHFItemStack) {
|
||||
if len(result) != 1 || result[0].Item.ItemID != 100 {
|
||||
t.Error("Should add new item to empty list")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := DiffItemStacks(tt.old, tt.update)
|
||||
if len(result) != tt.wantLen {
|
||||
t.Errorf("DiffItemStacks() length = %d, want %d", len(result), tt.wantLen)
|
||||
}
|
||||
if tt.checkFn != nil {
|
||||
tt.checkFn(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWarehouseEquipment(t *testing.T) {
|
||||
mode := cfg.Z1
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint32(12345) // WarehouseID
|
||||
bf.WriteUint8(1) // ItemType
|
||||
bf.WriteUint8(2) // Unk0
|
||||
bf.WriteUint16(100) // ItemID
|
||||
bf.WriteUint16(5) // Level
|
||||
|
||||
// Write 3 decorations
|
||||
bf.WriteUint16(201)
|
||||
bf.WriteUint16(202)
|
||||
bf.WriteUint16(203)
|
||||
|
||||
// Write 3 sigils (G1+)
|
||||
for i := 0; i < 3; i++ {
|
||||
// 3 effects per sigil
|
||||
for j := 0; j < 3; j++ {
|
||||
bf.WriteUint16(uint16(300 + i*10 + j)) // Effect ID
|
||||
}
|
||||
for j := 0; j < 3; j++ {
|
||||
bf.WriteUint16(uint16(1 + j)) // Effect Level
|
||||
}
|
||||
bf.WriteUint8(10)
|
||||
bf.WriteUint8(11)
|
||||
bf.WriteUint8(12)
|
||||
bf.WriteUint8(13)
|
||||
}
|
||||
|
||||
// Unk1 (Z1+)
|
||||
bf.WriteUint16(9999)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
equipment := ReadWarehouseEquipment(bf, mode)
|
||||
|
||||
if equipment.WarehouseID != 12345 {
|
||||
t.Errorf("WarehouseID = %d, want 12345", equipment.WarehouseID)
|
||||
}
|
||||
if equipment.ItemType != 1 {
|
||||
t.Errorf("ItemType = %d, want 1", equipment.ItemType)
|
||||
}
|
||||
if equipment.ItemID != 100 {
|
||||
t.Errorf("ItemID = %d, want 100", equipment.ItemID)
|
||||
}
|
||||
if equipment.Level != 5 {
|
||||
t.Errorf("Level = %d, want 5", equipment.Level)
|
||||
}
|
||||
if equipment.Decorations[0].ItemID != 201 {
|
||||
t.Errorf("Decoration[0] = %d, want 201", equipment.Decorations[0].ItemID)
|
||||
}
|
||||
if equipment.Sigils[0].Effects[0].ID != 300 {
|
||||
t.Errorf("Sigil[0].Effect[0].ID = %d, want 300", equipment.Sigils[0].Effects[0].ID)
|
||||
}
|
||||
if equipment.Unk1 != 9999 {
|
||||
t.Errorf("Unk1 = %d, want 9999", equipment.Unk1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWarehouseEquipment_ZeroWarehouseID(t *testing.T) {
|
||||
mode := cfg.Z1
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint32(0) // WarehouseID = 0
|
||||
bf.WriteUint8(1)
|
||||
bf.WriteUint8(2)
|
||||
bf.WriteUint16(100)
|
||||
bf.WriteUint16(5)
|
||||
// Write decorations
|
||||
for i := 0; i < 3; i++ {
|
||||
bf.WriteUint16(0)
|
||||
}
|
||||
// Write sigils
|
||||
for i := 0; i < 3; i++ {
|
||||
for j := 0; j < 6; j++ {
|
||||
bf.WriteUint16(0)
|
||||
}
|
||||
bf.WriteUint8(0)
|
||||
bf.WriteUint8(0)
|
||||
bf.WriteUint8(0)
|
||||
bf.WriteUint8(0)
|
||||
}
|
||||
bf.WriteUint16(0)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
equipment := ReadWarehouseEquipment(bf, mode)
|
||||
|
||||
if equipment.WarehouseID == 0 {
|
||||
t.Error("WarehouseID should be replaced with random value when input is 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMHFEquipment_ToBytes(t *testing.T) {
|
||||
mode := cfg.Z1
|
||||
|
||||
equipment := MHFEquipment{
|
||||
WarehouseID: 12345,
|
||||
ItemType: 1,
|
||||
Unk0: 2,
|
||||
ItemID: 100,
|
||||
Level: 5,
|
||||
Decorations: []MHFItem{{ItemID: 201}, {ItemID: 202}, {ItemID: 203}},
|
||||
Sigils: make([]MHFSigil, 3),
|
||||
Unk1: 9999,
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
equipment.Sigils[i].Effects = make([]MHFSigilEffect, 3)
|
||||
}
|
||||
|
||||
data := equipment.ToBytes(mode)
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
readEquipment := ReadWarehouseEquipment(bf, mode)
|
||||
|
||||
if readEquipment.WarehouseID != equipment.WarehouseID {
|
||||
t.Errorf("WarehouseID = %d, want %d", readEquipment.WarehouseID, equipment.WarehouseID)
|
||||
}
|
||||
if readEquipment.ItemID != equipment.ItemID {
|
||||
t.Errorf("ItemID = %d, want %d", readEquipment.ItemID, equipment.ItemID)
|
||||
}
|
||||
if readEquipment.Level != equipment.Level {
|
||||
t.Errorf("Level = %d, want %d", readEquipment.Level, equipment.Level)
|
||||
}
|
||||
if readEquipment.Unk1 != equipment.Unk1 {
|
||||
t.Errorf("Unk1 = %d, want %d", readEquipment.Unk1, equipment.Unk1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeWarehouseEquipment(t *testing.T) {
|
||||
mode := cfg.Z1
|
||||
|
||||
equipment := []MHFEquipment{
|
||||
{
|
||||
WarehouseID: 1,
|
||||
ItemType: 1,
|
||||
ItemID: 100,
|
||||
Level: 5,
|
||||
Decorations: []MHFItem{{ItemID: 0}, {ItemID: 0}, {ItemID: 0}},
|
||||
Sigils: make([]MHFSigil, 3),
|
||||
},
|
||||
{
|
||||
WarehouseID: 2,
|
||||
ItemType: 2,
|
||||
ItemID: 200,
|
||||
Level: 10,
|
||||
Decorations: []MHFItem{{ItemID: 0}, {ItemID: 0}, {ItemID: 0}},
|
||||
Sigils: make([]MHFSigil, 3),
|
||||
},
|
||||
}
|
||||
for i := range equipment {
|
||||
for j := 0; j < 3; j++ {
|
||||
equipment[i].Sigils[j].Effects = make([]MHFSigilEffect, 3)
|
||||
}
|
||||
}
|
||||
|
||||
data := SerializeWarehouseEquipment(equipment, mode)
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
|
||||
count := bf.ReadUint16()
|
||||
if count != 2 {
|
||||
t.Errorf("count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMHFEquipment_RoundTrip(t *testing.T) {
|
||||
mode := cfg.Z1
|
||||
|
||||
original := MHFEquipment{
|
||||
WarehouseID: 99999,
|
||||
ItemType: 5,
|
||||
Unk0: 10,
|
||||
ItemID: 500,
|
||||
Level: 25,
|
||||
Decorations: []MHFItem{{ItemID: 1}, {ItemID: 2}, {ItemID: 3}},
|
||||
Sigils: make([]MHFSigil, 3),
|
||||
Unk1: 12345,
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
original.Sigils[i].Effects = []MHFSigilEffect{
|
||||
{ID: uint16(100 + i), Level: 1},
|
||||
{ID: uint16(200 + i), Level: 2},
|
||||
{ID: uint16(300 + i), Level: 3},
|
||||
}
|
||||
}
|
||||
|
||||
// Write to bytes
|
||||
data := original.ToBytes(mode)
|
||||
|
||||
// Read back
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
recovered := ReadWarehouseEquipment(bf, mode)
|
||||
|
||||
// Compare
|
||||
if recovered.WarehouseID != original.WarehouseID {
|
||||
t.Errorf("WarehouseID = %d, want %d", recovered.WarehouseID, original.WarehouseID)
|
||||
}
|
||||
if recovered.ItemType != original.ItemType {
|
||||
t.Errorf("ItemType = %d, want %d", recovered.ItemType, original.ItemType)
|
||||
}
|
||||
if recovered.ItemID != original.ItemID {
|
||||
t.Errorf("ItemID = %d, want %d", recovered.ItemID, original.ItemID)
|
||||
}
|
||||
if recovered.Level != original.Level {
|
||||
t.Errorf("Level = %d, want %d", recovered.Level, original.Level)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if recovered.Decorations[i].ItemID != original.Decorations[i].ItemID {
|
||||
t.Errorf("Decoration[%d] = %d, want %d", i, recovered.Decorations[i].ItemID, original.Decorations[i].ItemID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkReadWarehouseItem(b *testing.B) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint32(12345)
|
||||
bf.WriteUint16(100)
|
||||
bf.WriteUint16(5)
|
||||
bf.WriteUint32(0)
|
||||
data := bf.Data()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
_ = ReadWarehouseItem(bf)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDiffItemStacks(b *testing.B) {
|
||||
old := []MHFItemStack{
|
||||
{WarehouseID: 1, Item: MHFItem{ItemID: 100}, Quantity: 5},
|
||||
{WarehouseID: 2, Item: MHFItem{ItemID: 200}, Quantity: 10},
|
||||
{WarehouseID: 3, Item: MHFItem{ItemID: 300}, Quantity: 15},
|
||||
}
|
||||
update := []MHFItemStack{
|
||||
{WarehouseID: 1, Item: MHFItem{ItemID: 100}, Quantity: 8},
|
||||
{WarehouseID: 0, Item: MHFItem{ItemID: 400}, Quantity: 3},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = DiffItemStacks(old, update)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSerializeWarehouseItems(b *testing.B) {
|
||||
items := make([]MHFItemStack, 100)
|
||||
for i := range items {
|
||||
items[i] = MHFItemStack{
|
||||
WarehouseID: uint32(i),
|
||||
Item: MHFItem{ItemID: uint16(i)},
|
||||
Quantity: uint16(i % 99),
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = SerializeWarehouseItems(items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMHFItemStack_ToBytes_RoundTrip(t *testing.T) {
|
||||
original := MHFItemStack{
|
||||
WarehouseID: 12345,
|
||||
Item: MHFItem{ItemID: 999},
|
||||
Quantity: 42,
|
||||
Unk0: 777,
|
||||
}
|
||||
|
||||
data := original.ToBytes()
|
||||
bf := byteframe.NewByteFrameFromBytes(data)
|
||||
recovered := ReadWarehouseItem(bf)
|
||||
|
||||
if !bytes.Equal(original.ToBytes(), recovered.ToBytes()) {
|
||||
t.Error("Round-trip serialization failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffItemStacks_PreserveOldWarehouseID(t *testing.T) {
|
||||
// Verify that when updating existing items, the old WarehouseID is preserved
|
||||
old := []MHFItemStack{
|
||||
{WarehouseID: 555, Item: MHFItem{ItemID: 100}, Quantity: 5},
|
||||
}
|
||||
update := []MHFItemStack{
|
||||
{WarehouseID: 555, Item: MHFItem{ItemID: 100}, Quantity: 10},
|
||||
}
|
||||
|
||||
result := DiffItemStacks(old, update)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 item, got %d", len(result))
|
||||
}
|
||||
if result[0].WarehouseID != 555 {
|
||||
t.Errorf("WarehouseID = %d, want 555", result[0].WarehouseID)
|
||||
}
|
||||
if result[0].Quantity != 10 {
|
||||
t.Errorf("Quantity = %d, want 10", result[0].Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffItemStacks_GeneratesNewWarehouseID(t *testing.T) {
|
||||
// Verify that new items get a generated WarehouseID
|
||||
old := []MHFItemStack{}
|
||||
update := []MHFItemStack{
|
||||
{WarehouseID: 0, Item: MHFItem{ItemID: 100}, Quantity: 5},
|
||||
}
|
||||
|
||||
// Reset RNG for consistent test
|
||||
token.RNG = token.NewSafeRand()
|
||||
|
||||
result := DiffItemStacks(old, update)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 item, got %d", len(result))
|
||||
}
|
||||
if result[0].WarehouseID == 0 {
|
||||
t.Error("New item should have generated WarehouseID, got 0")
|
||||
}
|
||||
}
|
||||
4
common/mhfmon/doc.go
Normal file
4
common/mhfmon/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package mhfmon enumerates every monster in Monster Hunter Frontier by its
|
||||
// internal enemy ID (em001–em176) and provides metadata such as display name
|
||||
// and large/small classification.
|
||||
package mhfmon
|
||||
@@ -180,11 +180,13 @@ const (
|
||||
KingShakalaka
|
||||
)
|
||||
|
||||
// Monster holds display metadata for a single monster species.
|
||||
type Monster struct {
|
||||
Name string
|
||||
Large bool
|
||||
}
|
||||
|
||||
// Monsters is an ordered table of all MHF monsters, indexed by enemy ID.
|
||||
var Monsters = []Monster{
|
||||
{"Mon0", false},
|
||||
{"Rathian", true},
|
||||
|
||||
371
common/mhfmon/mhfmon_test.go
Normal file
371
common/mhfmon/mhfmon_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package mhfmon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMonsters_Length(t *testing.T) {
|
||||
// Verify that the Monsters slice has entries
|
||||
actualLen := len(Monsters)
|
||||
if actualLen == 0 {
|
||||
t.Fatal("Monsters slice is empty")
|
||||
}
|
||||
// The slice has 177 entries (some constants may not have entries)
|
||||
if actualLen < 170 {
|
||||
t.Errorf("Monsters length = %d, seems too small", actualLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_IndexMatchesConstant(t *testing.T) {
|
||||
// Test that the index in the slice matches the constant value
|
||||
tests := []struct {
|
||||
index int
|
||||
name string
|
||||
large bool
|
||||
}{
|
||||
{Mon0, "Mon0", false},
|
||||
{Rathian, "Rathian", true},
|
||||
{Fatalis, "Fatalis", true},
|
||||
{Kelbi, "Kelbi", false},
|
||||
{Rathalos, "Rathalos", true},
|
||||
{Diablos, "Diablos", true},
|
||||
{Rajang, "Rajang", true},
|
||||
{Zinogre, "Zinogre", true},
|
||||
{Deviljho, "Deviljho", true},
|
||||
{KingShakalaka, "King Shakalaka", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.index >= len(Monsters) {
|
||||
t.Fatalf("Index %d out of bounds", tt.index)
|
||||
}
|
||||
monster := Monsters[tt.index]
|
||||
if monster.Name != tt.name {
|
||||
t.Errorf("Monsters[%d].Name = %q, want %q", tt.index, monster.Name, tt.name)
|
||||
}
|
||||
if monster.Large != tt.large {
|
||||
t.Errorf("Monsters[%d].Large = %v, want %v", tt.index, monster.Large, tt.large)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_AllLargeMonsters(t *testing.T) {
|
||||
// Verify some known large monsters
|
||||
largeMonsters := []int{
|
||||
Rathian,
|
||||
Fatalis,
|
||||
YianKutKu,
|
||||
LaoShanLung,
|
||||
Cephadrome,
|
||||
Rathalos,
|
||||
Diablos,
|
||||
Khezu,
|
||||
Gravios,
|
||||
Tigrex,
|
||||
Zinogre,
|
||||
Deviljho,
|
||||
Brachydios,
|
||||
}
|
||||
|
||||
for _, idx := range largeMonsters {
|
||||
if !Monsters[idx].Large {
|
||||
t.Errorf("Monsters[%d] (%s) should be marked as large", idx, Monsters[idx].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_AllSmallMonsters(t *testing.T) {
|
||||
// Verify some known small monsters
|
||||
smallMonsters := []int{
|
||||
Kelbi,
|
||||
Mosswine,
|
||||
Bullfango,
|
||||
Felyne,
|
||||
Aptonoth,
|
||||
Genprey,
|
||||
Velociprey,
|
||||
Melynx,
|
||||
Hornetaur,
|
||||
Apceros,
|
||||
Ioprey,
|
||||
Giaprey,
|
||||
Cephalos,
|
||||
Blango,
|
||||
Conga,
|
||||
Remobra,
|
||||
GreatThunderbug,
|
||||
Shakalaka,
|
||||
}
|
||||
|
||||
for _, idx := range smallMonsters {
|
||||
if Monsters[idx].Large {
|
||||
t.Errorf("Monsters[%d] (%s) should be marked as small", idx, Monsters[idx].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_Constants(t *testing.T) {
|
||||
// Test that constants have expected values
|
||||
tests := []struct {
|
||||
constant int
|
||||
expected int
|
||||
}{
|
||||
{Mon0, 0},
|
||||
{Rathian, 1},
|
||||
{Fatalis, 2},
|
||||
{Kelbi, 3},
|
||||
{Rathalos, 11},
|
||||
{Diablos, 14},
|
||||
{Rajang, 53},
|
||||
{Zinogre, 146},
|
||||
{Deviljho, 147},
|
||||
{Brachydios, 148},
|
||||
{KingShakalaka, 176},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if tt.constant != tt.expected {
|
||||
t.Errorf("Constant = %d, want %d", tt.constant, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_NameConsistency(t *testing.T) {
|
||||
// Test that specific monsters have correct names
|
||||
tests := []struct {
|
||||
index int
|
||||
expectedName string
|
||||
}{
|
||||
{Rathian, "Rathian"},
|
||||
{Rathalos, "Rathalos"},
|
||||
{YianKutKu, "Yian Kut-Ku"},
|
||||
{LaoShanLung, "Lao-Shan Lung"},
|
||||
{KushalaDaora, "Kushala Daora"},
|
||||
{Tigrex, "Tigrex"},
|
||||
{Rajang, "Rajang"},
|
||||
{Zinogre, "Zinogre"},
|
||||
{Deviljho, "Deviljho"},
|
||||
{Brachydios, "Brachydios"},
|
||||
{Nargacuga, "Nargacuga"},
|
||||
{GoreMagala, "Gore Magala"},
|
||||
{ShagaruMagala, "Shagaru Magala"},
|
||||
{KingShakalaka, "King Shakalaka"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expectedName, func(t *testing.T) {
|
||||
if Monsters[tt.index].Name != tt.expectedName {
|
||||
t.Errorf("Monsters[%d].Name = %q, want %q", tt.index, Monsters[tt.index].Name, tt.expectedName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_SubspeciesNames(t *testing.T) {
|
||||
// Test subspecies have appropriate names
|
||||
tests := []struct {
|
||||
index int
|
||||
expectedName string
|
||||
}{
|
||||
{PinkRathian, "Pink Rathian"},
|
||||
{AzureRathalos, "Azure Rathalos"},
|
||||
{SilverRathalos, "Silver Rathalos"},
|
||||
{GoldRathian, "Gold Rathian"},
|
||||
{BlackDiablos, "Black Diablos"},
|
||||
{WhiteMonoblos, "White Monoblos"},
|
||||
{RedKhezu, "Red Khezu"},
|
||||
{CrimsonFatalis, "Crimson Fatalis"},
|
||||
{WhiteFatalis, "White Fatalis"},
|
||||
{StygianZinogre, "Stygian Zinogre"},
|
||||
{SavageDeviljho, "Savage Deviljho"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expectedName, func(t *testing.T) {
|
||||
if Monsters[tt.index].Name != tt.expectedName {
|
||||
t.Errorf("Monsters[%d].Name = %q, want %q", tt.index, Monsters[tt.index].Name, tt.expectedName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_PlaceholderMonsters(t *testing.T) {
|
||||
// Test that placeholder monsters exist
|
||||
placeholders := []int{Mon0, Mon18, Mon29, Mon32, Mon72, Mon86, Mon87, Mon88, Mon118, Mon133, Mon134, Mon135, Mon136, Mon137, Mon138, Mon156, Mon168, Mon171}
|
||||
|
||||
for _, idx := range placeholders {
|
||||
if idx >= len(Monsters) {
|
||||
t.Errorf("Placeholder monster index %d out of bounds", idx)
|
||||
continue
|
||||
}
|
||||
// Placeholder monsters should be marked as small (non-large)
|
||||
if Monsters[idx].Large {
|
||||
t.Errorf("Placeholder Monsters[%d] (%s) should not be marked as large", idx, Monsters[idx].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_FrontierMonsters(t *testing.T) {
|
||||
// Test some MH Frontier-specific monsters
|
||||
frontierMonsters := []struct {
|
||||
index int
|
||||
name string
|
||||
}{
|
||||
{Espinas, "Espinas"},
|
||||
{Berukyurosu, "Berukyurosu"},
|
||||
{Pariapuria, "Pariapuria"},
|
||||
{Raviente, "Raviente"},
|
||||
{Dyuragaua, "Dyuragaua"},
|
||||
{Doragyurosu, "Doragyurosu"},
|
||||
{Gurenzeburu, "Gurenzeburu"},
|
||||
{Rukodiora, "Rukodiora"},
|
||||
{Gogomoa, "Gogomoa"},
|
||||
{Disufiroa, "Disufiroa"},
|
||||
{Rebidiora, "Rebidiora"},
|
||||
{MiRu, "Mi-Ru"},
|
||||
{Shantien, "Shantien"},
|
||||
{Zerureusu, "Zerureusu"},
|
||||
{GarubaDaora, "Garuba Daora"},
|
||||
{Harudomerugu, "Harudomerugu"},
|
||||
{Toridcless, "Toridcless"},
|
||||
{Guanzorumu, "Guanzorumu"},
|
||||
{Egyurasu, "Egyurasu"},
|
||||
{Bogabadorumu, "Bogabadorumu"},
|
||||
}
|
||||
|
||||
for _, tt := range frontierMonsters {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.index >= len(Monsters) {
|
||||
t.Fatalf("Index %d out of bounds", tt.index)
|
||||
}
|
||||
if Monsters[tt.index].Name != tt.name {
|
||||
t.Errorf("Monsters[%d].Name = %q, want %q", tt.index, Monsters[tt.index].Name, tt.name)
|
||||
}
|
||||
// Most Frontier monsters should be large
|
||||
if !Monsters[tt.index].Large {
|
||||
t.Logf("Frontier monster %s is marked as small", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_DuremudiraVariants(t *testing.T) {
|
||||
// Test Duremudira variants
|
||||
tests := []struct {
|
||||
index int
|
||||
name string
|
||||
}{
|
||||
{Block1Duremudira, "1st Block Duremudira"},
|
||||
{Block2Duremudira, "2nd Block Duremudira"},
|
||||
{MusouDuremudira, "Musou Duremudira"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if Monsters[tt.index].Name != tt.name {
|
||||
t.Errorf("Monsters[%d].Name = %q, want %q", tt.index, Monsters[tt.index].Name, tt.name)
|
||||
}
|
||||
if !Monsters[tt.index].Large {
|
||||
t.Errorf("Duremudira variant should be marked as large")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_RalienteVariants(t *testing.T) {
|
||||
// Test Raviente variants
|
||||
tests := []struct {
|
||||
index int
|
||||
name string
|
||||
}{
|
||||
{Raviente, "Raviente"},
|
||||
{BerserkRaviente, "Berserk Raviente"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if Monsters[tt.index].Name != tt.name {
|
||||
t.Errorf("Monsters[%d].Name = %q, want %q", tt.index, Monsters[tt.index].Name, tt.name)
|
||||
}
|
||||
if !Monsters[tt.index].Large {
|
||||
t.Errorf("Raviente variant should be marked as large")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_NoHoles(t *testing.T) {
|
||||
// Verify that there are no nil entries or empty names (except for placeholder "MonXX" entries)
|
||||
for i, monster := range Monsters {
|
||||
if monster.Name == "" {
|
||||
t.Errorf("Monsters[%d] has empty name", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonster_Struct(t *testing.T) {
|
||||
// Test that Monster struct is properly defined
|
||||
m := Monster{
|
||||
Name: "Test Monster",
|
||||
Large: true,
|
||||
}
|
||||
|
||||
if m.Name != "Test Monster" {
|
||||
t.Errorf("Name = %q, want %q", m.Name, "Test Monster")
|
||||
}
|
||||
if !m.Large {
|
||||
t.Error("Large should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAccessMonster(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Monsters[Rathalos]
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAccessMonsterName(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Monsters[Zinogre].Name
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAccessMonsterLarge(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Monsters[Deviljho].Large
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonsters_CrossoverMonsters(t *testing.T) {
|
||||
// Test crossover monsters (from other games)
|
||||
tests := []struct {
|
||||
index int
|
||||
name string
|
||||
}{
|
||||
{Zinogre, "Zinogre"}, // From MH Portable 3rd
|
||||
{Deviljho, "Deviljho"}, // From MH3
|
||||
{Brachydios, "Brachydios"}, // From MH3G
|
||||
{Barioth, "Barioth"}, // From MH3
|
||||
{Uragaan, "Uragaan"}, // From MH3
|
||||
{Nargacuga, "Nargacuga"}, // From MH Freedom Unite
|
||||
{GoreMagala, "Gore Magala"}, // From MH4
|
||||
{Amatsu, "Amatsu"}, // From MH Portable 3rd
|
||||
{Seregios, "Seregios"}, // From MH4G
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if Monsters[tt.index].Name != tt.name {
|
||||
t.Errorf("Monsters[%d].Name = %q, want %q", tt.index, Monsters[tt.index].Name, tt.name)
|
||||
}
|
||||
if !Monsters[tt.index].Large {
|
||||
t.Errorf("Crossover large monster %s should be marked as large", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
4
common/pascalstring/doc.go
Normal file
4
common/pascalstring/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package pascalstring writes length-prefixed, null-terminated strings into a
|
||||
// ByteFrame. The prefix width is selectable (uint8, uint16, or uint32) and
|
||||
// strings are optionally encoded to Shift-JIS before writing.
|
||||
package pascalstring
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// Uint8 writes x as a null-terminated string with a uint8 length prefix. If t
|
||||
// is true the string is first encoded to Shift-JIS.
|
||||
func Uint8(bf *byteframe.ByteFrame, x string, t bool) {
|
||||
if t {
|
||||
e := japanese.ShiftJIS.NewEncoder()
|
||||
@@ -20,6 +22,8 @@ func Uint8(bf *byteframe.ByteFrame, x string, t bool) {
|
||||
bf.WriteNullTerminatedBytes([]byte(x))
|
||||
}
|
||||
|
||||
// Uint16 writes x as a null-terminated string with a uint16 length prefix. If
|
||||
// t is true the string is first encoded to Shift-JIS.
|
||||
func Uint16(bf *byteframe.ByteFrame, x string, t bool) {
|
||||
if t {
|
||||
e := japanese.ShiftJIS.NewEncoder()
|
||||
@@ -34,6 +38,8 @@ func Uint16(bf *byteframe.ByteFrame, x string, t bool) {
|
||||
bf.WriteNullTerminatedBytes([]byte(x))
|
||||
}
|
||||
|
||||
// Uint32 writes x as a null-terminated string with a uint32 length prefix. If
|
||||
// t is true the string is first encoded to Shift-JIS.
|
||||
func Uint32(bf *byteframe.ByteFrame, x string, t bool) {
|
||||
if t {
|
||||
e := japanese.ShiftJIS.NewEncoder()
|
||||
|
||||
369
common/pascalstring/pascalstring_test.go
Normal file
369
common/pascalstring/pascalstring_test.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package pascalstring
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"erupe-ce/common/byteframe"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUint8_NoTransform(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
testString := "Hello"
|
||||
|
||||
Uint8(bf, testString, false)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint8()
|
||||
expectedLength := uint8(len(testString) + 1) // +1 for null terminator
|
||||
|
||||
if length != expectedLength {
|
||||
t.Errorf("length = %d, want %d", length, expectedLength)
|
||||
}
|
||||
|
||||
data := bf.ReadBytes(uint(length))
|
||||
// Should be "Hello\x00"
|
||||
expected := []byte("Hello\x00")
|
||||
if !bytes.Equal(data, expected) {
|
||||
t.Errorf("data = %v, want %v", data, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint8_WithTransform(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
// ASCII string (no special characters)
|
||||
testString := "Test"
|
||||
|
||||
Uint8(bf, testString, true)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint8()
|
||||
|
||||
if length == 0 {
|
||||
t.Error("length should not be 0 for ASCII string")
|
||||
}
|
||||
|
||||
data := bf.ReadBytes(uint(length))
|
||||
// Should end with null terminator
|
||||
if data[len(data)-1] != 0 {
|
||||
t.Error("data should end with null terminator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint8_EmptyString(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
testString := ""
|
||||
|
||||
Uint8(bf, testString, false)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint8()
|
||||
|
||||
if length != 1 { // Just null terminator
|
||||
t.Errorf("length = %d, want 1", length)
|
||||
}
|
||||
|
||||
data := bf.ReadBytes(uint(length))
|
||||
if data[0] != 0 {
|
||||
t.Error("empty string should produce just null terminator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint16_NoTransform(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
testString := "World"
|
||||
|
||||
Uint16(bf, testString, false)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint16()
|
||||
expectedLength := uint16(len(testString) + 1)
|
||||
|
||||
if length != expectedLength {
|
||||
t.Errorf("length = %d, want %d", length, expectedLength)
|
||||
}
|
||||
|
||||
data := bf.ReadBytes(uint(length))
|
||||
expected := []byte("World\x00")
|
||||
if !bytes.Equal(data, expected) {
|
||||
t.Errorf("data = %v, want %v", data, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint16_WithTransform(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
testString := "Test"
|
||||
|
||||
Uint16(bf, testString, true)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint16()
|
||||
|
||||
if length == 0 {
|
||||
t.Error("length should not be 0 for ASCII string")
|
||||
}
|
||||
|
||||
data := bf.ReadBytes(uint(length))
|
||||
if data[len(data)-1] != 0 {
|
||||
t.Error("data should end with null terminator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint16_EmptyString(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
testString := ""
|
||||
|
||||
Uint16(bf, testString, false)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint16()
|
||||
|
||||
if length != 1 {
|
||||
t.Errorf("length = %d, want 1", length)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint32_NoTransform(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
testString := "Testing"
|
||||
|
||||
Uint32(bf, testString, false)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint32()
|
||||
expectedLength := uint32(len(testString) + 1)
|
||||
|
||||
if length != expectedLength {
|
||||
t.Errorf("length = %d, want %d", length, expectedLength)
|
||||
}
|
||||
|
||||
data := bf.ReadBytes(uint(length))
|
||||
expected := []byte("Testing\x00")
|
||||
if !bytes.Equal(data, expected) {
|
||||
t.Errorf("data = %v, want %v", data, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint32_WithTransform(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
testString := "Test"
|
||||
|
||||
Uint32(bf, testString, true)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint32()
|
||||
|
||||
if length == 0 {
|
||||
t.Error("length should not be 0 for ASCII string")
|
||||
}
|
||||
|
||||
data := bf.ReadBytes(uint(length))
|
||||
if data[len(data)-1] != 0 {
|
||||
t.Error("data should end with null terminator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint32_EmptyString(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
testString := ""
|
||||
|
||||
Uint32(bf, testString, false)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint32()
|
||||
|
||||
if length != 1 {
|
||||
t.Errorf("length = %d, want 1", length)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint8_LongString(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
testString := "This is a longer test string with more characters"
|
||||
|
||||
Uint8(bf, testString, false)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint8()
|
||||
expectedLength := uint8(len(testString) + 1)
|
||||
|
||||
if length != expectedLength {
|
||||
t.Errorf("length = %d, want %d", length, expectedLength)
|
||||
}
|
||||
|
||||
data := bf.ReadBytes(uint(length))
|
||||
if !bytes.HasSuffix(data, []byte{0}) {
|
||||
t.Error("data should end with null terminator")
|
||||
}
|
||||
if !bytes.HasPrefix(data, []byte("This is")) {
|
||||
t.Error("data should start with expected string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint16_LongString(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
// Create a string longer than 255 to test uint16
|
||||
testString := ""
|
||||
for i := 0; i < 300; i++ {
|
||||
testString += "A"
|
||||
}
|
||||
|
||||
Uint16(bf, testString, false)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint16()
|
||||
expectedLength := uint16(len(testString) + 1)
|
||||
|
||||
if length != expectedLength {
|
||||
t.Errorf("length = %d, want %d", length, expectedLength)
|
||||
}
|
||||
|
||||
data := bf.ReadBytes(uint(length))
|
||||
if !bytes.HasSuffix(data, []byte{0}) {
|
||||
t.Error("data should end with null terminator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllFunctions_NullTermination(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
writeFn func(*byteframe.ByteFrame, string, bool)
|
||||
readSize func(*byteframe.ByteFrame) uint
|
||||
}{
|
||||
{
|
||||
name: "Uint8",
|
||||
writeFn: func(bf *byteframe.ByteFrame, s string, t bool) {
|
||||
Uint8(bf, s, t)
|
||||
},
|
||||
readSize: func(bf *byteframe.ByteFrame) uint {
|
||||
return uint(bf.ReadUint8())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Uint16",
|
||||
writeFn: func(bf *byteframe.ByteFrame, s string, t bool) {
|
||||
Uint16(bf, s, t)
|
||||
},
|
||||
readSize: func(bf *byteframe.ByteFrame) uint {
|
||||
return uint(bf.ReadUint16())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Uint32",
|
||||
writeFn: func(bf *byteframe.ByteFrame, s string, t bool) {
|
||||
Uint32(bf, s, t)
|
||||
},
|
||||
readSize: func(bf *byteframe.ByteFrame) uint {
|
||||
return uint(bf.ReadUint32())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
testString := "Test"
|
||||
|
||||
tt.writeFn(bf, testString, false)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
size := tt.readSize(bf)
|
||||
data := bf.ReadBytes(size)
|
||||
|
||||
// Verify null termination
|
||||
if data[len(data)-1] != 0 {
|
||||
t.Errorf("%s: data should end with null terminator", tt.name)
|
||||
}
|
||||
|
||||
// Verify length includes null terminator
|
||||
if size != uint(len(testString)+1) {
|
||||
t.Errorf("%s: size = %d, want %d", tt.name, size, len(testString)+1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransform_JapaneseCharacters(t *testing.T) {
|
||||
// Test with Japanese characters that should be transformed to Shift-JIS
|
||||
bf := byteframe.NewByteFrame()
|
||||
testString := "テスト" // "Test" in Japanese katakana
|
||||
|
||||
Uint16(bf, testString, true)
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint16()
|
||||
|
||||
if length == 0 {
|
||||
t.Error("Transformed Japanese string should have non-zero length")
|
||||
}
|
||||
|
||||
// The transformed Shift-JIS should be different length than UTF-8
|
||||
// UTF-8: 9 bytes (3 chars * 3 bytes each), Shift-JIS: 6 bytes (3 chars * 2 bytes each) + 1 null
|
||||
data := bf.ReadBytes(uint(length))
|
||||
if data[len(data)-1] != 0 {
|
||||
t.Error("Transformed string should end with null terminator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransform_InvalidUTF8(t *testing.T) {
|
||||
// This test verifies graceful handling of encoding errors
|
||||
// When transformation fails, the functions should write length 0
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
// Create a string with invalid UTF-8 sequence
|
||||
// Note: Go strings are generally valid UTF-8, but we can test the error path
|
||||
testString := "Valid ASCII"
|
||||
|
||||
Uint8(bf, testString, true)
|
||||
// Should succeed for ASCII characters
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
length := bf.ReadUint8()
|
||||
if length == 0 {
|
||||
t.Error("ASCII string should transform successfully")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUint8_NoTransform(b *testing.B) {
|
||||
testString := "Hello, World!"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bf := byteframe.NewByteFrame()
|
||||
Uint8(bf, testString, false)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUint8_WithTransform(b *testing.B) {
|
||||
testString := "Hello, World!"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bf := byteframe.NewByteFrame()
|
||||
Uint8(bf, testString, true)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUint16_NoTransform(b *testing.B) {
|
||||
testString := "Hello, World!"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bf := byteframe.NewByteFrame()
|
||||
Uint16(bf, testString, false)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUint32_NoTransform(b *testing.B) {
|
||||
testString := "Hello, World!"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bf := byteframe.NewByteFrame()
|
||||
Uint32(bf, testString, false)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUint16_Japanese(b *testing.B) {
|
||||
testString := "テストメッセージ"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bf := byteframe.NewByteFrame()
|
||||
Uint16(bf, testString, true)
|
||||
}
|
||||
}
|
||||
3
common/stringstack/doc.go
Normal file
3
common/stringstack/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package stringstack provides a minimal LIFO stack for strings, used
|
||||
// internally to track hierarchical state such as nested stage paths.
|
||||
package stringstack
|
||||
343
common/stringstack/stringstack_test.go
Normal file
343
common/stringstack/stringstack_test.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package stringstack
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
s := New()
|
||||
if s == nil {
|
||||
t.Fatal("New() returned nil")
|
||||
}
|
||||
if len(s.stack) != 0 {
|
||||
t.Errorf("New() stack length = %d, want 0", len(s.stack))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_Set(t *testing.T) {
|
||||
s := New()
|
||||
s.Set("first")
|
||||
|
||||
if len(s.stack) != 1 {
|
||||
t.Errorf("Set() stack length = %d, want 1", len(s.stack))
|
||||
}
|
||||
if s.stack[0] != "first" {
|
||||
t.Errorf("stack[0] = %q, want %q", s.stack[0], "first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_Set_Replaces(t *testing.T) {
|
||||
s := New()
|
||||
s.Push("item1")
|
||||
s.Push("item2")
|
||||
s.Push("item3")
|
||||
|
||||
// Set should replace the entire stack
|
||||
s.Set("new_item")
|
||||
|
||||
if len(s.stack) != 1 {
|
||||
t.Errorf("Set() stack length = %d, want 1", len(s.stack))
|
||||
}
|
||||
if s.stack[0] != "new_item" {
|
||||
t.Errorf("stack[0] = %q, want %q", s.stack[0], "new_item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_Push(t *testing.T) {
|
||||
s := New()
|
||||
s.Push("first")
|
||||
s.Push("second")
|
||||
s.Push("third")
|
||||
|
||||
if len(s.stack) != 3 {
|
||||
t.Errorf("Push() stack length = %d, want 3", len(s.stack))
|
||||
}
|
||||
if s.stack[0] != "first" {
|
||||
t.Errorf("stack[0] = %q, want %q", s.stack[0], "first")
|
||||
}
|
||||
if s.stack[1] != "second" {
|
||||
t.Errorf("stack[1] = %q, want %q", s.stack[1], "second")
|
||||
}
|
||||
if s.stack[2] != "third" {
|
||||
t.Errorf("stack[2] = %q, want %q", s.stack[2], "third")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_Pop(t *testing.T) {
|
||||
s := New()
|
||||
s.Push("first")
|
||||
s.Push("second")
|
||||
s.Push("third")
|
||||
|
||||
// Pop should return LIFO (last in, first out)
|
||||
val, err := s.Pop()
|
||||
if err != nil {
|
||||
t.Errorf("Pop() error = %v, want nil", err)
|
||||
}
|
||||
if val != "third" {
|
||||
t.Errorf("Pop() = %q, want %q", val, "third")
|
||||
}
|
||||
|
||||
val, err = s.Pop()
|
||||
if err != nil {
|
||||
t.Errorf("Pop() error = %v, want nil", err)
|
||||
}
|
||||
if val != "second" {
|
||||
t.Errorf("Pop() = %q, want %q", val, "second")
|
||||
}
|
||||
|
||||
val, err = s.Pop()
|
||||
if err != nil {
|
||||
t.Errorf("Pop() error = %v, want nil", err)
|
||||
}
|
||||
if val != "first" {
|
||||
t.Errorf("Pop() = %q, want %q", val, "first")
|
||||
}
|
||||
|
||||
if len(s.stack) != 0 {
|
||||
t.Errorf("stack length = %d, want 0 after popping all items", len(s.stack))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_Pop_Empty(t *testing.T) {
|
||||
s := New()
|
||||
|
||||
val, err := s.Pop()
|
||||
if err == nil {
|
||||
t.Error("Pop() on empty stack should return error")
|
||||
}
|
||||
if val != "" {
|
||||
t.Errorf("Pop() on empty stack returned %q, want empty string", val)
|
||||
}
|
||||
|
||||
expectedError := "no items on stack"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("Pop() error = %q, want %q", err.Error(), expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_LIFO_Behavior(t *testing.T) {
|
||||
s := New()
|
||||
items := []string{"A", "B", "C", "D", "E"}
|
||||
|
||||
for _, item := range items {
|
||||
s.Push(item)
|
||||
}
|
||||
|
||||
// Pop should return in reverse order (LIFO)
|
||||
for i := len(items) - 1; i >= 0; i-- {
|
||||
val, err := s.Pop()
|
||||
if err != nil {
|
||||
t.Fatalf("Pop() error = %v", err)
|
||||
}
|
||||
if val != items[i] {
|
||||
t.Errorf("Pop() = %q, want %q", val, items[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_PushAfterPop(t *testing.T) {
|
||||
s := New()
|
||||
s.Push("first")
|
||||
s.Push("second")
|
||||
|
||||
val, _ := s.Pop()
|
||||
if val != "second" {
|
||||
t.Errorf("Pop() = %q, want %q", val, "second")
|
||||
}
|
||||
|
||||
s.Push("third")
|
||||
|
||||
val, _ = s.Pop()
|
||||
if val != "third" {
|
||||
t.Errorf("Pop() = %q, want %q", val, "third")
|
||||
}
|
||||
|
||||
val, _ = s.Pop()
|
||||
if val != "first" {
|
||||
t.Errorf("Pop() = %q, want %q", val, "first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_EmptyStrings(t *testing.T) {
|
||||
s := New()
|
||||
s.Push("")
|
||||
s.Push("text")
|
||||
s.Push("")
|
||||
|
||||
val, err := s.Pop()
|
||||
if err != nil {
|
||||
t.Errorf("Pop() error = %v", err)
|
||||
}
|
||||
if val != "" {
|
||||
t.Errorf("Pop() = %q, want empty string", val)
|
||||
}
|
||||
|
||||
val, err = s.Pop()
|
||||
if err != nil {
|
||||
t.Errorf("Pop() error = %v", err)
|
||||
}
|
||||
if val != "text" {
|
||||
t.Errorf("Pop() = %q, want %q", val, "text")
|
||||
}
|
||||
|
||||
val, err = s.Pop()
|
||||
if err != nil {
|
||||
t.Errorf("Pop() error = %v", err)
|
||||
}
|
||||
if val != "" {
|
||||
t.Errorf("Pop() = %q, want empty string", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_LongStrings(t *testing.T) {
|
||||
s := New()
|
||||
longString := ""
|
||||
for i := 0; i < 1000; i++ {
|
||||
longString += "A"
|
||||
}
|
||||
|
||||
s.Push(longString)
|
||||
val, err := s.Pop()
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Pop() error = %v", err)
|
||||
}
|
||||
if val != longString {
|
||||
t.Error("Pop() returned different string than pushed")
|
||||
}
|
||||
if len(val) != 1000 {
|
||||
t.Errorf("Pop() string length = %d, want 1000", len(val))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_ManyItems(t *testing.T) {
|
||||
s := New()
|
||||
count := 1000
|
||||
|
||||
// Push many items
|
||||
for i := 0; i < count; i++ {
|
||||
s.Push("item")
|
||||
}
|
||||
|
||||
if len(s.stack) != count {
|
||||
t.Errorf("stack length = %d, want %d", len(s.stack), count)
|
||||
}
|
||||
|
||||
// Pop all items
|
||||
for i := 0; i < count; i++ {
|
||||
_, err := s.Pop()
|
||||
if err != nil {
|
||||
t.Errorf("Pop()[%d] error = %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Should be empty now
|
||||
if len(s.stack) != 0 {
|
||||
t.Errorf("stack length = %d, want 0 after popping all", len(s.stack))
|
||||
}
|
||||
|
||||
// Next pop should error
|
||||
_, err := s.Pop()
|
||||
if err == nil {
|
||||
t.Error("Pop() on empty stack should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_SetAfterOperations(t *testing.T) {
|
||||
s := New()
|
||||
s.Push("a")
|
||||
s.Push("b")
|
||||
s.Push("c")
|
||||
_, _ = s.Pop()
|
||||
s.Push("d")
|
||||
|
||||
// Set should clear everything
|
||||
s.Set("reset")
|
||||
|
||||
if len(s.stack) != 1 {
|
||||
t.Errorf("stack length = %d, want 1 after Set", len(s.stack))
|
||||
}
|
||||
|
||||
val, err := s.Pop()
|
||||
if err != nil {
|
||||
t.Errorf("Pop() error = %v", err)
|
||||
}
|
||||
if val != "reset" {
|
||||
t.Errorf("Pop() = %q, want %q", val, "reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringStack_SpecialCharacters(t *testing.T) {
|
||||
s := New()
|
||||
specialStrings := []string{
|
||||
"Hello\nWorld",
|
||||
"Tab\tSeparated",
|
||||
"Quote\"Test",
|
||||
"Backslash\\Test",
|
||||
"Unicode: テスト",
|
||||
"Emoji: 😀",
|
||||
"",
|
||||
" ",
|
||||
" spaces ",
|
||||
}
|
||||
|
||||
for _, str := range specialStrings {
|
||||
s.Push(str)
|
||||
}
|
||||
|
||||
// Pop in reverse order
|
||||
for i := len(specialStrings) - 1; i >= 0; i-- {
|
||||
val, err := s.Pop()
|
||||
if err != nil {
|
||||
t.Errorf("Pop() error = %v", err)
|
||||
}
|
||||
if val != specialStrings[i] {
|
||||
t.Errorf("Pop() = %q, want %q", val, specialStrings[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStringStack_Push(b *testing.B) {
|
||||
s := New()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.Push("test string")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStringStack_Pop(b *testing.B) {
|
||||
s := New()
|
||||
// Pre-populate
|
||||
for i := 0; i < 10000; i++ {
|
||||
s.Push("test string")
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if len(s.stack) == 0 {
|
||||
// Repopulate
|
||||
for j := 0; j < 10000; j++ {
|
||||
s.Push("test string")
|
||||
}
|
||||
}
|
||||
_, _ = s.Pop()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStringStack_PushPop(b *testing.B) {
|
||||
s := New()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.Push("test")
|
||||
_, _ = s.Pop()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStringStack_Set(b *testing.B) {
|
||||
s := New()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.Set("test string")
|
||||
}
|
||||
}
|
||||
5
common/stringsupport/doc.go
Normal file
5
common/stringsupport/doc.go
Normal file
@@ -0,0 +1,5 @@
|
||||
// Package stringsupport provides string conversion utilities for the MHF
|
||||
// protocol, including UTF-8 ↔ Shift-JIS transcoding, padded fixed-width
|
||||
// string encoding, NG-word conversion, and comma-separated integer list
|
||||
// manipulation used for database storage.
|
||||
package stringsupport
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -11,34 +12,58 @@ import (
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// UTF8ToSJIS encodes a UTF-8 string to Shift-JIS bytes, silently dropping any
|
||||
// runes that cannot be represented in Shift-JIS.
|
||||
func UTF8ToSJIS(x string) []byte {
|
||||
e := japanese.ShiftJIS.NewEncoder()
|
||||
xt, _, err := transform.String(e, x)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
// Filter out runes that can't be encoded to Shift-JIS instead of
|
||||
// crashing the server (see PR #116).
|
||||
var filtered []rune
|
||||
for _, r := range x {
|
||||
if _, _, err := transform.String(japanese.ShiftJIS.NewEncoder(), string(r)); err == nil {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
xt, _, _ = transform.String(japanese.ShiftJIS.NewEncoder(), string(filtered))
|
||||
}
|
||||
return []byte(xt)
|
||||
}
|
||||
|
||||
func SJISToUTF8(b []byte) string {
|
||||
// SJISToUTF8 decodes Shift-JIS bytes to a UTF-8 string.
|
||||
func SJISToUTF8(b []byte) (string, error) {
|
||||
d := japanese.ShiftJIS.NewDecoder()
|
||||
result, err := io.ReadAll(transform.NewReader(bytes.NewReader(b), d))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return "", fmt.Errorf("ShiftJIS decode: %w", err)
|
||||
}
|
||||
return string(result)
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// SJISToUTF8Lossy decodes Shift-JIS bytes to a UTF-8 string, logging
|
||||
// any decoding error at debug level instead of returning it.
|
||||
func SJISToUTF8Lossy(b []byte) string {
|
||||
s, err := SJISToUTF8(b)
|
||||
if err != nil {
|
||||
slog.Debug("SJIS decode failed", "error", err, "raw_len", len(b))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ToNGWord converts a UTF-8 string into a slice of uint16 values in the
|
||||
// Shift-JIS byte-swapped format used by the MHF NG-word (chat filter) system.
|
||||
func ToNGWord(x string) []uint16 {
|
||||
var w []uint16
|
||||
for _, r := range []rune(x) {
|
||||
for _, r := range x {
|
||||
if r > 0xFF {
|
||||
t := UTF8ToSJIS(string(r))
|
||||
if len(t) > 1 {
|
||||
w = append(w, uint16(t[1])<<8|uint16(t[0]))
|
||||
} else {
|
||||
} else if len(t) == 1 {
|
||||
w = append(w, uint16(t[0]))
|
||||
}
|
||||
// Skip runes that produced no SJIS output (unsupported characters)
|
||||
} else {
|
||||
w = append(w, uint16(r))
|
||||
}
|
||||
@@ -46,6 +71,8 @@ func ToNGWord(x string) []uint16 {
|
||||
return w
|
||||
}
|
||||
|
||||
// PaddedString returns a fixed-width null-terminated byte slice of the given
|
||||
// size. If t is true the string is first encoded to Shift-JIS.
|
||||
func PaddedString(x string, size uint, t bool) []byte {
|
||||
if t {
|
||||
e := japanese.ShiftJIS.NewEncoder()
|
||||
@@ -61,6 +88,7 @@ func PaddedString(x string, size uint, t bool) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
// CSVAdd appends v to the comma-separated integer list if not already present.
|
||||
func CSVAdd(csv string, v int) string {
|
||||
if len(csv) == 0 {
|
||||
return strconv.Itoa(v)
|
||||
@@ -72,6 +100,7 @@ func CSVAdd(csv string, v int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// CSVRemove removes v from the comma-separated integer list.
|
||||
func CSVRemove(csv string, v int) string {
|
||||
s := strings.Split(csv, ",")
|
||||
for i, e := range s {
|
||||
@@ -83,6 +112,7 @@ func CSVRemove(csv string, v int) string {
|
||||
return strings.Join(s, ",")
|
||||
}
|
||||
|
||||
// CSVContains reports whether v is present in the comma-separated integer list.
|
||||
func CSVContains(csv string, v int) bool {
|
||||
s := strings.Split(csv, ",")
|
||||
for i := 0; i < len(s); i++ {
|
||||
@@ -94,6 +124,7 @@ func CSVContains(csv string, v int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CSVLength returns the number of elements in the comma-separated list.
|
||||
func CSVLength(csv string) int {
|
||||
if csv == "" {
|
||||
return 0
|
||||
@@ -102,6 +133,7 @@ func CSVLength(csv string) int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// CSVElems parses the comma-separated integer list into an int slice.
|
||||
func CSVElems(csv string) []int {
|
||||
var r []int
|
||||
if csv == "" {
|
||||
@@ -115,6 +147,8 @@ func CSVElems(csv string) []int {
|
||||
return r
|
||||
}
|
||||
|
||||
// CSVGetIndex returns the integer at position i in the comma-separated list,
|
||||
// or 0 if i is out of range.
|
||||
func CSVGetIndex(csv string, i int) int {
|
||||
s := CSVElems(csv)
|
||||
if i < len(s) {
|
||||
@@ -123,6 +157,8 @@ func CSVGetIndex(csv string, i int) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// CSVSetIndex replaces the integer at position i in the comma-separated list
|
||||
// with v. If i is out of range the list is returned unchanged.
|
||||
func CSVSetIndex(csv string, i int, v int) string {
|
||||
s := CSVElems(csv)
|
||||
if i < len(s) {
|
||||
|
||||
587
common/stringsupport/string_convert_test.go
Normal file
587
common/stringsupport/string_convert_test.go
Normal file
@@ -0,0 +1,587 @@
|
||||
package stringsupport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUTF8ToSJIS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"ascii", "Hello World"},
|
||||
{"numbers", "12345"},
|
||||
{"symbols", "!@#$%"},
|
||||
{"japanese_hiragana", "あいうえお"},
|
||||
{"japanese_katakana", "アイウエオ"},
|
||||
{"japanese_kanji", "日本語"},
|
||||
{"mixed", "Hello世界"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := UTF8ToSJIS(tt.input)
|
||||
if len(result) == 0 && len(tt.input) > 0 {
|
||||
t.Error("UTF8ToSJIS returned empty result for non-empty input")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSJISToUTF8(t *testing.T) {
|
||||
// Test ASCII characters (which are the same in SJIS and UTF-8)
|
||||
asciiBytes := []byte("Hello World")
|
||||
result, err := SJISToUTF8(asciiBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("SJISToUTF8() unexpected error: %v", err)
|
||||
}
|
||||
if result != "Hello World" {
|
||||
t.Errorf("SJISToUTF8() = %q, want %q", result, "Hello World")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUTF8ToSJIS_RoundTrip(t *testing.T) {
|
||||
// Test round-trip conversion for ASCII
|
||||
original := "Hello World 123"
|
||||
sjis := UTF8ToSJIS(original)
|
||||
back, _ := SJISToUTF8(sjis)
|
||||
|
||||
if back != original {
|
||||
t.Errorf("Round-trip failed: got %q, want %q", back, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToNGWord(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
minLen int
|
||||
checkFn func(t *testing.T, result []uint16)
|
||||
}{
|
||||
{
|
||||
name: "ascii characters",
|
||||
input: "ABC",
|
||||
minLen: 3,
|
||||
checkFn: func(t *testing.T, result []uint16) {
|
||||
if result[0] != uint16('A') {
|
||||
t.Errorf("result[0] = %d, want %d", result[0], 'A')
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "numbers",
|
||||
input: "123",
|
||||
minLen: 3,
|
||||
checkFn: func(t *testing.T, result []uint16) {
|
||||
if result[0] != uint16('1') {
|
||||
t.Errorf("result[0] = %d, want %d", result[0], '1')
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "japanese characters",
|
||||
input: "あ",
|
||||
minLen: 1,
|
||||
checkFn: func(t *testing.T, result []uint16) {
|
||||
if len(result) == 0 {
|
||||
t.Error("result should not be empty")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
minLen: 0,
|
||||
checkFn: func(t *testing.T, result []uint16) {
|
||||
if len(result) != 0 {
|
||||
t.Errorf("result length = %d, want 0", len(result))
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ToNGWord(tt.input)
|
||||
if len(result) < tt.minLen {
|
||||
t.Errorf("ToNGWord() length = %d, want at least %d", len(result), tt.minLen)
|
||||
}
|
||||
if tt.checkFn != nil {
|
||||
tt.checkFn(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaddedString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
size uint
|
||||
transform bool
|
||||
wantLen uint
|
||||
}{
|
||||
{"short string", "Hello", 10, false, 10},
|
||||
{"exact size", "Test", 5, false, 5},
|
||||
{"longer than size", "This is a long string", 10, false, 10},
|
||||
{"empty string", "", 5, false, 5},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := PaddedString(tt.input, tt.size, tt.transform)
|
||||
if uint(len(result)) != tt.wantLen {
|
||||
t.Errorf("PaddedString() length = %d, want %d", len(result), tt.wantLen)
|
||||
}
|
||||
// Verify last byte is null
|
||||
if result[len(result)-1] != 0 {
|
||||
t.Error("PaddedString() should end with null byte")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaddedString_NullTermination(t *testing.T) {
|
||||
result := PaddedString("Test", 10, false)
|
||||
if result[9] != 0 {
|
||||
t.Error("Last byte should be null")
|
||||
}
|
||||
// First 4 bytes should be "Test"
|
||||
if !bytes.Equal(result[0:4], []byte("Test")) {
|
||||
t.Errorf("First 4 bytes = %v, want %v", result[0:4], []byte("Test"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVAdd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
csv string
|
||||
value int
|
||||
expected string
|
||||
}{
|
||||
{"add to empty", "", 1, "1"},
|
||||
{"add to existing", "1,2,3", 4, "1,2,3,4"},
|
||||
{"add duplicate", "1,2,3", 2, "1,2,3"},
|
||||
{"add to single", "5", 10, "5,10"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := CSVAdd(tt.csv, tt.value)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CSVAdd(%q, %d) = %q, want %q", tt.csv, tt.value, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVRemove(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
csv string
|
||||
value int
|
||||
check func(t *testing.T, result string)
|
||||
}{
|
||||
{
|
||||
name: "remove from middle",
|
||||
csv: "1,2,3,4,5",
|
||||
value: 3,
|
||||
check: func(t *testing.T, result string) {
|
||||
if CSVContains(result, 3) {
|
||||
t.Error("Result should not contain 3")
|
||||
}
|
||||
if CSVLength(result) != 4 {
|
||||
t.Errorf("Result length = %d, want 4", CSVLength(result))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove from start",
|
||||
csv: "1,2,3",
|
||||
value: 1,
|
||||
check: func(t *testing.T, result string) {
|
||||
if CSVContains(result, 1) {
|
||||
t.Error("Result should not contain 1")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove non-existent",
|
||||
csv: "1,2,3",
|
||||
value: 99,
|
||||
check: func(t *testing.T, result string) {
|
||||
if CSVLength(result) != 3 {
|
||||
t.Errorf("Length should remain 3, got %d", CSVLength(result))
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := CSVRemove(tt.csv, tt.value)
|
||||
tt.check(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVContains(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
csv string
|
||||
value int
|
||||
expected bool
|
||||
}{
|
||||
{"contains in middle", "1,2,3,4,5", 3, true},
|
||||
{"contains at start", "1,2,3", 1, true},
|
||||
{"contains at end", "1,2,3", 3, true},
|
||||
{"does not contain", "1,2,3", 5, false},
|
||||
{"empty csv", "", 1, false},
|
||||
{"single value match", "42", 42, true},
|
||||
{"single value no match", "42", 43, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := CSVContains(tt.csv, tt.value)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CSVContains(%q, %d) = %v, want %v", tt.csv, tt.value, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
csv string
|
||||
expected int
|
||||
}{
|
||||
{"empty", "", 0},
|
||||
{"single", "1", 1},
|
||||
{"multiple", "1,2,3,4,5", 5},
|
||||
{"two", "10,20", 2},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := CSVLength(tt.csv)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CSVLength(%q) = %d, want %d", tt.csv, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVElems(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
csv string
|
||||
expected []int
|
||||
}{
|
||||
{"empty", "", []int{}},
|
||||
{"single", "42", []int{42}},
|
||||
{"multiple", "1,2,3,4,5", []int{1, 2, 3, 4, 5}},
|
||||
{"negative numbers", "-1,0,1", []int{-1, 0, 1}},
|
||||
{"large numbers", "100,200,300", []int{100, 200, 300}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := CSVElems(tt.csv)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("CSVElems(%q) length = %d, want %d", tt.csv, len(result), len(tt.expected))
|
||||
}
|
||||
for i, v := range tt.expected {
|
||||
if i >= len(result) || result[i] != v {
|
||||
t.Errorf("CSVElems(%q)[%d] = %d, want %d", tt.csv, i, result[i], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVGetIndex(t *testing.T) {
|
||||
csv := "10,20,30,40,50"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
index int
|
||||
expected int
|
||||
}{
|
||||
{"first", 0, 10},
|
||||
{"middle", 2, 30},
|
||||
{"last", 4, 50},
|
||||
{"out of bounds", 10, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := CSVGetIndex(csv, tt.index)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CSVGetIndex(%q, %d) = %d, want %d", csv, tt.index, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVSetIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
csv string
|
||||
index int
|
||||
value int
|
||||
check func(t *testing.T, result string)
|
||||
}{
|
||||
{
|
||||
name: "set first",
|
||||
csv: "10,20,30",
|
||||
index: 0,
|
||||
value: 99,
|
||||
check: func(t *testing.T, result string) {
|
||||
if CSVGetIndex(result, 0) != 99 {
|
||||
t.Errorf("Index 0 = %d, want 99", CSVGetIndex(result, 0))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set middle",
|
||||
csv: "10,20,30",
|
||||
index: 1,
|
||||
value: 88,
|
||||
check: func(t *testing.T, result string) {
|
||||
if CSVGetIndex(result, 1) != 88 {
|
||||
t.Errorf("Index 1 = %d, want 88", CSVGetIndex(result, 1))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set last",
|
||||
csv: "10,20,30",
|
||||
index: 2,
|
||||
value: 77,
|
||||
check: func(t *testing.T, result string) {
|
||||
if CSVGetIndex(result, 2) != 77 {
|
||||
t.Errorf("Index 2 = %d, want 77", CSVGetIndex(result, 2))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set out of bounds",
|
||||
csv: "10,20,30",
|
||||
index: 10,
|
||||
value: 99,
|
||||
check: func(t *testing.T, result string) {
|
||||
// Should not modify the CSV
|
||||
if CSVLength(result) != 3 {
|
||||
t.Errorf("CSV length changed when setting out of bounds")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := CSVSetIndex(tt.csv, tt.index, tt.value)
|
||||
tt.check(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSV_CompleteWorkflow(t *testing.T) {
|
||||
// Test a complete workflow
|
||||
csv := ""
|
||||
|
||||
// Add elements
|
||||
csv = CSVAdd(csv, 10)
|
||||
csv = CSVAdd(csv, 20)
|
||||
csv = CSVAdd(csv, 30)
|
||||
|
||||
if CSVLength(csv) != 3 {
|
||||
t.Errorf("Length = %d, want 3", CSVLength(csv))
|
||||
}
|
||||
|
||||
// Check contains
|
||||
if !CSVContains(csv, 20) {
|
||||
t.Error("Should contain 20")
|
||||
}
|
||||
|
||||
// Get element
|
||||
if CSVGetIndex(csv, 1) != 20 {
|
||||
t.Errorf("Index 1 = %d, want 20", CSVGetIndex(csv, 1))
|
||||
}
|
||||
|
||||
// Set element
|
||||
csv = CSVSetIndex(csv, 1, 99)
|
||||
if CSVGetIndex(csv, 1) != 99 {
|
||||
t.Errorf("Index 1 = %d, want 99 after set", CSVGetIndex(csv, 1))
|
||||
}
|
||||
|
||||
// Remove element
|
||||
csv = CSVRemove(csv, 99)
|
||||
if CSVContains(csv, 99) {
|
||||
t.Error("Should not contain 99 after removal")
|
||||
}
|
||||
|
||||
if CSVLength(csv) != 2 {
|
||||
t.Errorf("Length = %d, want 2 after removal", CSVLength(csv))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCSVAdd(b *testing.B) {
|
||||
csv := "1,2,3,4,5"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = CSVAdd(csv, 6)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCSVContains(b *testing.B) {
|
||||
csv := "1,2,3,4,5,6,7,8,9,10"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = CSVContains(csv, 5)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCSVRemove(b *testing.B) {
|
||||
csv := "1,2,3,4,5,6,7,8,9,10"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = CSVRemove(csv, 5)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCSVElems(b *testing.B) {
|
||||
csv := "1,2,3,4,5,6,7,8,9,10"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = CSVElems(csv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSJISToUTF8Lossy(t *testing.T) {
|
||||
// Valid SJIS (ASCII subset) decodes correctly.
|
||||
got := SJISToUTF8Lossy([]byte("Hello"))
|
||||
if got != "Hello" {
|
||||
t.Errorf("SJISToUTF8Lossy(valid) = %q, want %q", got, "Hello")
|
||||
}
|
||||
|
||||
// Truncated multi-byte SJIS sequence (lead byte 0x82 without trail byte)
|
||||
// does not panic and returns some result (lossy).
|
||||
got = SJISToUTF8Lossy([]byte{0x82})
|
||||
_ = got // must not panic
|
||||
|
||||
// Nil input returns empty string.
|
||||
got = SJISToUTF8Lossy(nil)
|
||||
if got != "" {
|
||||
t.Errorf("SJISToUTF8Lossy(nil) = %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUTF8ToSJIS_UnsupportedCharacters(t *testing.T) {
|
||||
// Regression test for PR #116: Characters outside the Shift-JIS range
|
||||
// (e.g. Lenny face, cuneiform) previously caused a panic in UTF8ToSJIS,
|
||||
// crashing the server when relayed from Discord.
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"lenny_face", "( ͡° ͜ʖ ͡°)"},
|
||||
{"cuneiform", "𒀜"},
|
||||
{"emoji", "Hello 🎮 World"},
|
||||
{"mixed_unsupported", "Test ͡° message 𒀜 here"},
|
||||
{"zalgo_text", "H̷e̸l̵l̶o̷"},
|
||||
{"only_unsupported", "🎮🎲🎯"},
|
||||
{"cyrillic", "Привет"},
|
||||
{"arabic", "مرحبا"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Must not panic - the old code would panic here
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("UTF8ToSJIS panicked on input %q: %v", tt.input, r)
|
||||
}
|
||||
}()
|
||||
result := UTF8ToSJIS(tt.input)
|
||||
if result == nil {
|
||||
t.Error("UTF8ToSJIS returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUTF8ToSJIS_PreservesValidContent(t *testing.T) {
|
||||
// Verify that valid Shift-JIS content is preserved when mixed with
|
||||
// unsupported characters.
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"ascii_with_emoji", "Hello 🎮 World", "Hello World"},
|
||||
{"japanese_with_emoji", "テスト🎮データ", "テストデータ"},
|
||||
{"only_valid", "Hello World", "Hello World"},
|
||||
{"only_invalid", "🎮🎲🎯", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sjis := UTF8ToSJIS(tt.input)
|
||||
roundTripped, _ := SJISToUTF8(sjis)
|
||||
if roundTripped != tt.expected {
|
||||
t.Errorf("UTF8ToSJIS(%q) round-tripped to %q, want %q", tt.input, roundTripped, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToNGWord_UnsupportedCharacters(t *testing.T) {
|
||||
// ToNGWord also calls UTF8ToSJIS internally, so it must not panic either.
|
||||
inputs := []string{"( ͡° ͜ʖ ͡°)", "🎮", "Hello 🎮 World"}
|
||||
for _, input := range inputs {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("ToNGWord panicked on input %q: %v", input, r)
|
||||
}
|
||||
}()
|
||||
_ = ToNGWord(input)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUTF8ToSJIS(b *testing.B) {
|
||||
text := "Hello World テスト"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = UTF8ToSJIS(text)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSJISToUTF8(b *testing.B) {
|
||||
text := []byte("Hello World")
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = SJISToUTF8(text)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPaddedString(b *testing.B) {
|
||||
text := "Test String"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = PaddedString(text, 50, false)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkToNGWord(b *testing.B) {
|
||||
text := "TestString"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ToNGWord(text)
|
||||
}
|
||||
}
|
||||
3
common/token/doc.go
Normal file
3
common/token/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package token provides concurrency-safe random number generation and
|
||||
// alphanumeric token generation for session tokens and warehouse IDs.
|
||||
package token
|
||||
@@ -2,10 +2,43 @@ package token
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var RNG = NewRNG()
|
||||
// SafeRand is a concurrency-safe wrapper around *rand.Rand.
|
||||
type SafeRand struct {
|
||||
mu sync.Mutex
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
// NewSafeRand creates a SafeRand seeded with the current time.
|
||||
func NewSafeRand() *SafeRand {
|
||||
return &SafeRand{
|
||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
// Intn returns a non-negative pseudo-random int in [0,n). It is safe for
|
||||
// concurrent use.
|
||||
func (sr *SafeRand) Intn(n int) int {
|
||||
sr.mu.Lock()
|
||||
v := sr.rng.Intn(n)
|
||||
sr.mu.Unlock()
|
||||
return v
|
||||
}
|
||||
|
||||
// Uint32 returns a pseudo-random uint32. It is safe for concurrent use.
|
||||
func (sr *SafeRand) Uint32() uint32 {
|
||||
sr.mu.Lock()
|
||||
v := sr.rng.Uint32()
|
||||
sr.mu.Unlock()
|
||||
return v
|
||||
}
|
||||
|
||||
// RNG is the global concurrency-safe random number generator used throughout
|
||||
// the server for generating warehouse IDs, session tokens, and other values.
|
||||
var RNG = NewSafeRand()
|
||||
|
||||
// Generate returns an alphanumeric token of specified length
|
||||
func Generate(length int) string {
|
||||
@@ -16,8 +49,3 @@ func Generate(length int) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// NewRNG returns a new NewRNG generator
|
||||
func NewRNG() *rand.Rand {
|
||||
return rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
}
|
||||
|
||||
340
common/token/token_test.go
Normal file
340
common/token/token_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerate_Length(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
length int
|
||||
}{
|
||||
{"zero length", 0},
|
||||
{"short", 5},
|
||||
{"medium", 32},
|
||||
{"long", 100},
|
||||
{"very long", 1000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := Generate(tt.length)
|
||||
if len(result) != tt.length {
|
||||
t.Errorf("Generate(%d) length = %d, want %d", tt.length, len(result), tt.length)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_CharacterSet(t *testing.T) {
|
||||
// Verify that generated tokens only contain alphanumeric characters
|
||||
validChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
validCharMap := make(map[rune]bool)
|
||||
for _, c := range validChars {
|
||||
validCharMap[c] = true
|
||||
}
|
||||
|
||||
token := Generate(1000) // Large sample
|
||||
for _, c := range token {
|
||||
if !validCharMap[c] {
|
||||
t.Errorf("Generate() produced invalid character: %c", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_Randomness(t *testing.T) {
|
||||
// Generate multiple tokens and verify they're different
|
||||
tokens := make(map[string]bool)
|
||||
count := 100
|
||||
length := 32
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
token := Generate(length)
|
||||
if tokens[token] {
|
||||
t.Errorf("Generate() produced duplicate token: %s", token)
|
||||
}
|
||||
tokens[token] = true
|
||||
}
|
||||
|
||||
if len(tokens) != count {
|
||||
t.Errorf("Generated %d unique tokens, want %d", len(tokens), count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ContainsUppercase(t *testing.T) {
|
||||
// With enough characters, should contain at least one uppercase letter
|
||||
token := Generate(1000)
|
||||
hasUpper := false
|
||||
for _, c := range token {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
hasUpper = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasUpper {
|
||||
t.Error("Generate(1000) should contain at least one uppercase letter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ContainsLowercase(t *testing.T) {
|
||||
// With enough characters, should contain at least one lowercase letter
|
||||
token := Generate(1000)
|
||||
hasLower := false
|
||||
for _, c := range token {
|
||||
if c >= 'a' && c <= 'z' {
|
||||
hasLower = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLower {
|
||||
t.Error("Generate(1000) should contain at least one lowercase letter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ContainsDigit(t *testing.T) {
|
||||
// With enough characters, should contain at least one digit
|
||||
token := Generate(1000)
|
||||
hasDigit := false
|
||||
for _, c := range token {
|
||||
if c >= '0' && c <= '9' {
|
||||
hasDigit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDigit {
|
||||
t.Error("Generate(1000) should contain at least one digit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_Distribution(t *testing.T) {
|
||||
// Test that characters are reasonably distributed
|
||||
token := Generate(6200) // 62 chars * 100 = good sample size
|
||||
charCount := make(map[rune]int)
|
||||
|
||||
for _, c := range token {
|
||||
charCount[c]++
|
||||
}
|
||||
|
||||
// With 62 valid characters and 6200 samples, average should be 100 per char
|
||||
// We'll accept a range to account for randomness
|
||||
minExpected := 50 // Allow some variance
|
||||
maxExpected := 150
|
||||
|
||||
for c, count := range charCount {
|
||||
if count < minExpected || count > maxExpected {
|
||||
t.Logf("Character %c appeared %d times (outside expected range %d-%d)", c, count, minExpected, maxExpected)
|
||||
}
|
||||
}
|
||||
|
||||
// Just verify we have a good spread of characters
|
||||
if len(charCount) < 50 {
|
||||
t.Errorf("Only %d different characters used, want at least 50", len(charCount))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSafeRand(t *testing.T) {
|
||||
rng := NewSafeRand()
|
||||
if rng == nil {
|
||||
t.Fatal("NewSafeRand() returned nil")
|
||||
}
|
||||
|
||||
// Test that it produces different values on subsequent calls
|
||||
val1 := rng.Intn(1000000)
|
||||
val2 := rng.Intn(1000000)
|
||||
|
||||
if val1 == val2 {
|
||||
// This is possible but unlikely, let's try a few more times
|
||||
same := true
|
||||
for i := 0; i < 10; i++ {
|
||||
if rng.Intn(1000000) != val1 {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
t.Error("NewSafeRand() produced same value 12 times in a row")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRNG_GlobalVariable(t *testing.T) {
|
||||
// Test that the global RNG variable is initialized
|
||||
if RNG == nil {
|
||||
t.Fatal("Global RNG is nil")
|
||||
}
|
||||
|
||||
// Test that it works
|
||||
val := RNG.Intn(100)
|
||||
if val < 0 || val >= 100 {
|
||||
t.Errorf("RNG.Intn(100) = %d, out of range [0, 100)", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRNG_Uint32(t *testing.T) {
|
||||
// Test that RNG can generate uint32 values
|
||||
val1 := RNG.Uint32()
|
||||
val2 := RNG.Uint32()
|
||||
|
||||
// They should be different (with very high probability)
|
||||
if val1 == val2 {
|
||||
// Try a few more times
|
||||
same := true
|
||||
for i := 0; i < 10; i++ {
|
||||
if RNG.Uint32() != val1 {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
t.Error("RNG.Uint32() produced same value 12 times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_Concurrency(t *testing.T) {
|
||||
// Test that Generate works correctly when called concurrently
|
||||
done := make(chan string, 100)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
go func() {
|
||||
token := Generate(32)
|
||||
done <- token
|
||||
}()
|
||||
}
|
||||
|
||||
tokens := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
token := <-done
|
||||
if len(token) != 32 {
|
||||
t.Errorf("Token length = %d, want 32", len(token))
|
||||
}
|
||||
tokens[token] = true
|
||||
}
|
||||
|
||||
// Should have many unique tokens (allow some small chance of duplicates)
|
||||
if len(tokens) < 95 {
|
||||
t.Errorf("Only %d unique tokens from 100 concurrent calls", len(tokens))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_EmptyString(t *testing.T) {
|
||||
token := Generate(0)
|
||||
if token != "" {
|
||||
t.Errorf("Generate(0) = %q, want empty string", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_OnlyAlphanumeric(t *testing.T) {
|
||||
// Verify no special characters
|
||||
token := Generate(1000)
|
||||
for i, c := range token {
|
||||
isValid := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
|
||||
if !isValid {
|
||||
t.Errorf("Token[%d] = %c (invalid character)", i, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSafeRand_DifferentSeeds(t *testing.T) {
|
||||
// Create two RNGs at different times and verify they produce different sequences
|
||||
rng1 := NewSafeRand()
|
||||
time.Sleep(1 * time.Millisecond) // Ensure different seed
|
||||
rng2 := NewSafeRand()
|
||||
|
||||
val1 := rng1.Intn(1000000)
|
||||
val2 := rng2.Intn(1000000)
|
||||
|
||||
// They should be different with high probability
|
||||
if val1 == val2 {
|
||||
// Try again
|
||||
val1 = rng1.Intn(1000000)
|
||||
val2 = rng2.Intn(1000000)
|
||||
if val1 == val2 {
|
||||
t.Log("Two RNGs created at different times produced same first two values (possible but unlikely)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerate_Short(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Generate(8)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerate_Medium(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Generate(32)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerate_Long(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Generate(128)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNewSafeRand(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = NewSafeRand()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRNG_Intn(b *testing.B) {
|
||||
rng := NewSafeRand()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = rng.Intn(62)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRNG_Uint32(b *testing.B) {
|
||||
rng := NewSafeRand()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = rng.Uint32()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_ConsistentCharacterSet(t *testing.T) {
|
||||
// Verify the character set matches what's defined in the code
|
||||
expectedChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
if len(expectedChars) != 62 {
|
||||
t.Errorf("Expected character set length = %d, want 62", len(expectedChars))
|
||||
}
|
||||
|
||||
// Count each type
|
||||
lowercase := 0
|
||||
uppercase := 0
|
||||
digits := 0
|
||||
for _, c := range expectedChars {
|
||||
if c >= 'a' && c <= 'z' {
|
||||
lowercase++
|
||||
} else if c >= 'A' && c <= 'Z' {
|
||||
uppercase++
|
||||
} else if c >= '0' && c <= '9' {
|
||||
digits++
|
||||
}
|
||||
}
|
||||
|
||||
if lowercase != 26 {
|
||||
t.Errorf("Lowercase count = %d, want 26", lowercase)
|
||||
}
|
||||
if uppercase != 26 {
|
||||
t.Errorf("Uppercase count = %d, want 26", uppercase)
|
||||
}
|
||||
if digits != 10 {
|
||||
t.Errorf("Digits count = %d, want 10", digits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRNG_Type(t *testing.T) {
|
||||
// Verify RNG is of type *SafeRand
|
||||
var _ = (*SafeRand)(nil)
|
||||
_ = RNG
|
||||
_ = NewSafeRand()
|
||||
}
|
||||
12
config.example.json
Normal file
12
config.example.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Host": "",
|
||||
"Database": {
|
||||
"Host": "localhost",
|
||||
"Port": 5432,
|
||||
"User": "postgres",
|
||||
"Password": "",
|
||||
"Database": "erupe"
|
||||
},
|
||||
"ClientMode": "ZZ",
|
||||
"AutoCreateAccount": true
|
||||
}
|
||||
@@ -31,6 +31,14 @@
|
||||
"RawEnabled": false,
|
||||
"OutputDir": "save-backups"
|
||||
},
|
||||
"Capture": {
|
||||
"Enabled": false,
|
||||
"OutputDir": "captures",
|
||||
"ExcludeOpcodes": [],
|
||||
"CaptureSign": true,
|
||||
"CaptureEntrance": true,
|
||||
"CaptureChannel": true
|
||||
},
|
||||
"DebugOptions": {
|
||||
"CleanDB": false,
|
||||
"MaxLauncherHR": false,
|
||||
@@ -207,7 +215,12 @@
|
||||
"PatchServer": "",
|
||||
"Banners": [],
|
||||
"Messages": [],
|
||||
"Links": []
|
||||
"Links": [],
|
||||
"LandingPage": {
|
||||
"Enabled": true,
|
||||
"Title": "My Frontier Server",
|
||||
"Content": "<p>Welcome! Download the client from our <a href=\"https://discord.gg/example\">Discord</a>.</p>"
|
||||
}
|
||||
},
|
||||
"Channel": {
|
||||
"Enabled": true
|
||||
@@ -219,34 +232,34 @@
|
||||
{
|
||||
"Name": "Newbie", "Description": "", "IP": "", "Type": 3, "Recommended": 2, "AllowedClientFlags": 0,
|
||||
"Channels": [
|
||||
{ "Port": 54001, "MaxPlayers": 100 },
|
||||
{ "Port": 54002, "MaxPlayers": 100 }
|
||||
{ "Port": 54001, "MaxPlayers": 100, "Enabled": true },
|
||||
{ "Port": 54002, "MaxPlayers": 100, "Enabled": true }
|
||||
]
|
||||
}, {
|
||||
"Name": "Normal", "Description": "", "IP": "", "Type": 1, "Recommended": 0, "AllowedClientFlags": 0,
|
||||
"Channels": [
|
||||
{ "Port": 54003, "MaxPlayers": 100 },
|
||||
{ "Port": 54004, "MaxPlayers": 100 }
|
||||
{ "Port": 54003, "MaxPlayers": 100, "Enabled": true },
|
||||
{ "Port": 54004, "MaxPlayers": 100, "Enabled": true }
|
||||
]
|
||||
}, {
|
||||
"Name": "Cities", "Description": "", "IP": "", "Type": 2, "Recommended": 0, "AllowedClientFlags": 0,
|
||||
"Channels": [
|
||||
{ "Port": 54005, "MaxPlayers": 100 }
|
||||
{ "Port": 54005, "MaxPlayers": 100, "Enabled": true }
|
||||
]
|
||||
}, {
|
||||
"Name": "Tavern", "Description": "", "IP": "", "Type": 4, "Recommended": 0, "AllowedClientFlags": 0,
|
||||
"Channels": [
|
||||
{ "Port": 54006, "MaxPlayers": 100 }
|
||||
{ "Port": 54006, "MaxPlayers": 100, "Enabled": true }
|
||||
]
|
||||
}, {
|
||||
"Name": "Return", "Description": "", "IP": "", "Type": 5, "Recommended": 0, "AllowedClientFlags": 0,
|
||||
"Channels": [
|
||||
{ "Port": 54007, "MaxPlayers": 100 }
|
||||
{ "Port": 54007, "MaxPlayers": 100, "Enabled": true }
|
||||
]
|
||||
}, {
|
||||
"Name": "MezFes", "Description": "", "IP": "", "Type": 6, "Recommended": 6, "AllowedClientFlags": 0,
|
||||
"Channels": [
|
||||
{ "Port": 54008, "MaxPlayers": 100 }
|
||||
{ "Port": 54008, "MaxPlayers": 100, "Enabled": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
257
config/config.go
257
config/config.go
@@ -1,12 +1,9 @@
|
||||
package _config
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -88,6 +85,7 @@ type Config struct {
|
||||
EarthMonsters []int32
|
||||
SaveDumps SaveDumpOptions
|
||||
Screenshots ScreenshotsOptions
|
||||
Capture CaptureOptions
|
||||
|
||||
DebugOptions DebugOptions
|
||||
GameplayOptions GameplayOptions
|
||||
@@ -115,6 +113,16 @@ type ScreenshotsOptions struct {
|
||||
UploadQuality int //Determines the upload quality to the server
|
||||
}
|
||||
|
||||
// CaptureOptions controls protocol packet capture recording.
|
||||
type CaptureOptions struct {
|
||||
Enabled bool // Enable packet capture
|
||||
OutputDir string // Directory for .mhfr capture files
|
||||
ExcludeOpcodes []uint16 // Opcodes to exclude from capture (e.g., ping, nop, position)
|
||||
CaptureSign bool // Capture sign server sessions
|
||||
CaptureEntrance bool // Capture entrance server sessions
|
||||
CaptureChannel bool // Capture channel server sessions
|
||||
}
|
||||
|
||||
// DebugOptions holds various debug/temporary options for use while developing Erupe.
|
||||
type DebugOptions struct {
|
||||
CleanDB bool // Automatically wipes the DB on server reset.
|
||||
@@ -246,6 +254,14 @@ type API struct {
|
||||
Banners []APISignBanner
|
||||
Messages []APISignMessage
|
||||
Links []APISignLink
|
||||
LandingPage LandingPage
|
||||
}
|
||||
|
||||
// LandingPage holds config for the browser-facing landing page at /.
|
||||
type LandingPage struct {
|
||||
Enabled bool // Toggle the landing page on/off
|
||||
Title string // Page title (e.g. "My Frontier Server")
|
||||
Content string // Body content — supports raw HTML
|
||||
}
|
||||
|
||||
type APISignBanner struct {
|
||||
@@ -297,30 +313,213 @@ type EntranceChannelInfo struct {
|
||||
Port uint16
|
||||
MaxPlayers uint16
|
||||
CurrentPlayers uint16
|
||||
Enabled *bool // nil defaults to true for backward compatibility
|
||||
}
|
||||
|
||||
var ErupeConfig *Config
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
ErupeConfig, err = LoadConfig()
|
||||
if err != nil {
|
||||
preventClose(fmt.Sprintf("Failed to load config: %s", err.Error()))
|
||||
// IsEnabled returns whether this channel is enabled. Defaults to true if Enabled is nil.
|
||||
func (c *EntranceChannelInfo) IsEnabled() bool {
|
||||
if c.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *c.Enabled
|
||||
}
|
||||
|
||||
// getOutboundIP4 gets the preferred outbound ip4 of this machine
|
||||
// From https://stackoverflow.com/a/37382208
|
||||
func getOutboundIP4() net.IP {
|
||||
func getOutboundIP4() (net.IP, error) {
|
||||
conn, err := net.Dial("udp4", "8.8.8.8:80")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return nil, fmt.Errorf("detecting outbound IP: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
localAddr := conn.LocalAddr().(*net.UDPAddr)
|
||||
|
||||
return localAddr.IP.To4()
|
||||
return localAddr.IP.To4(), nil
|
||||
}
|
||||
|
||||
// registerDefaults sets all sane defaults via Viper so that a minimal
|
||||
// config.json (just database credentials) produces a fully working server.
|
||||
func registerDefaults() {
|
||||
// Top-level settings
|
||||
viper.SetDefault("Language", "jp")
|
||||
viper.SetDefault("BinPath", "bin")
|
||||
viper.SetDefault("HideLoginNotice", true)
|
||||
viper.SetDefault("LoginNotices", []string{
|
||||
"<BODY><CENTER><SIZE_3><C_4>Welcome to Erupe!",
|
||||
})
|
||||
viper.SetDefault("ClientMode", "ZZ")
|
||||
viper.SetDefault("QuestCacheExpiry", 300)
|
||||
viper.SetDefault("CommandPrefix", "!")
|
||||
viper.SetDefault("AutoCreateAccount", true)
|
||||
viper.SetDefault("LoopDelay", 50)
|
||||
viper.SetDefault("DefaultCourses", []uint16{1, 23, 24})
|
||||
viper.SetDefault("EarthMonsters", []int32{0, 0, 0, 0})
|
||||
|
||||
// SaveDumps
|
||||
viper.SetDefault("SaveDumps", SaveDumpOptions{
|
||||
Enabled: true,
|
||||
OutputDir: "save-backups",
|
||||
})
|
||||
|
||||
// Screenshots
|
||||
viper.SetDefault("Screenshots", ScreenshotsOptions{
|
||||
Enabled: true,
|
||||
Host: "127.0.0.1",
|
||||
Port: 8080,
|
||||
OutputDir: "screenshots",
|
||||
UploadQuality: 100,
|
||||
})
|
||||
|
||||
// Capture
|
||||
viper.SetDefault("Capture", CaptureOptions{
|
||||
OutputDir: "captures",
|
||||
CaptureSign: true,
|
||||
CaptureEntrance: true,
|
||||
CaptureChannel: true,
|
||||
})
|
||||
|
||||
// DebugOptions (dot-notation for per-field merge)
|
||||
viper.SetDefault("DebugOptions.MaxHexdumpLength", 256)
|
||||
viper.SetDefault("DebugOptions.FestaOverride", -1)
|
||||
viper.SetDefault("DebugOptions.AutoQuestBackport", true)
|
||||
viper.SetDefault("DebugOptions.CapLink", CapLinkOptions{
|
||||
Values: []uint16{51728, 20000, 51729, 1, 20000},
|
||||
Port: 80,
|
||||
})
|
||||
|
||||
// GameplayOptions (dot-notation — critical to avoid zeroing multipliers)
|
||||
viper.SetDefault("GameplayOptions.MaxFeatureWeapons", 1)
|
||||
viper.SetDefault("GameplayOptions.MaximumNP", 100000)
|
||||
viper.SetDefault("GameplayOptions.MaximumRP", uint16(50000))
|
||||
viper.SetDefault("GameplayOptions.MaximumFP", uint32(120000))
|
||||
viper.SetDefault("GameplayOptions.TreasureHuntExpiry", uint32(604800))
|
||||
viper.SetDefault("GameplayOptions.BoostTimeDuration", 7200)
|
||||
viper.SetDefault("GameplayOptions.ClanMealDuration", 3600)
|
||||
viper.SetDefault("GameplayOptions.ClanMemberLimits", [][]uint8{{0, 30}, {3, 40}, {7, 50}, {10, 60}})
|
||||
viper.SetDefault("GameplayOptions.BonusQuestAllowance", uint32(3))
|
||||
viper.SetDefault("GameplayOptions.DailyQuestAllowance", uint32(1))
|
||||
viper.SetDefault("GameplayOptions.RegularRavienteMaxPlayers", uint8(8))
|
||||
viper.SetDefault("GameplayOptions.ViolentRavienteMaxPlayers", uint8(8))
|
||||
viper.SetDefault("GameplayOptions.BerserkRavienteMaxPlayers", uint8(32))
|
||||
viper.SetDefault("GameplayOptions.ExtremeRavienteMaxPlayers", uint8(32))
|
||||
viper.SetDefault("GameplayOptions.SmallBerserkRavienteMaxPlayers", uint8(8))
|
||||
viper.SetDefault("GameplayOptions.GUrgentRate", float64(0.10))
|
||||
// All reward multipliers default to 1.0 — without this, Go's zero value
|
||||
// (0.0) would zero out all quest rewards for minimal configs.
|
||||
for _, key := range []string{
|
||||
"GCPMultiplier", "HRPMultiplier", "HRPMultiplierNC",
|
||||
"SRPMultiplier", "SRPMultiplierNC", "GRPMultiplier", "GRPMultiplierNC",
|
||||
"GSRPMultiplier", "GSRPMultiplierNC", "ZennyMultiplier", "ZennyMultiplierNC",
|
||||
"GZennyMultiplier", "GZennyMultiplierNC", "MaterialMultiplier", "MaterialMultiplierNC",
|
||||
"GMaterialMultiplier", "GMaterialMultiplierNC",
|
||||
} {
|
||||
viper.SetDefault("GameplayOptions."+key, float64(1.0))
|
||||
}
|
||||
viper.SetDefault("GameplayOptions.MezFesSoloTickets", uint32(5))
|
||||
viper.SetDefault("GameplayOptions.MezFesGroupTickets", uint32(1))
|
||||
viper.SetDefault("GameplayOptions.MezFesDuration", 172800)
|
||||
|
||||
// Discord
|
||||
viper.SetDefault("Discord.RelayChannel.MaxMessageLength", 183)
|
||||
|
||||
// Commands (whole-struct default — replaced entirely if user provides any)
|
||||
viper.SetDefault("Commands", []Command{
|
||||
{Name: "Help", Enabled: true, Description: "Show enabled chat commands", Prefix: "help"},
|
||||
{Name: "Rights", Enabled: false, Description: "Overwrite the Rights value on your account", Prefix: "rights"},
|
||||
{Name: "Raviente", Enabled: true, Description: "Various Raviente siege commands", Prefix: "ravi"},
|
||||
{Name: "Teleport", Enabled: false, Description: "Teleport to specified coordinates", Prefix: "tele"},
|
||||
{Name: "Reload", Enabled: true, Description: "Reload all players in your Land", Prefix: "reload"},
|
||||
{Name: "KeyQuest", Enabled: false, Description: "Overwrite your HR Key Quest progress", Prefix: "kqf"},
|
||||
{Name: "Course", Enabled: true, Description: "Toggle Courses on your account", Prefix: "course"},
|
||||
{Name: "PSN", Enabled: true, Description: "Link a PlayStation Network ID to your account", Prefix: "psn"},
|
||||
{Name: "Discord", Enabled: true, Description: "Generate a token to link your Discord account", Prefix: "discord"},
|
||||
{Name: "Ban", Enabled: false, Description: "Ban/Temp Ban a user", Prefix: "ban"},
|
||||
{Name: "Timer", Enabled: true, Description: "Toggle the Quest timer", Prefix: "timer"},
|
||||
{Name: "Playtime", Enabled: true, Description: "Show your playtime", Prefix: "playtime"},
|
||||
})
|
||||
|
||||
// Courses
|
||||
viper.SetDefault("Courses", []Course{
|
||||
{Name: "HunterLife", Enabled: true},
|
||||
{Name: "Extra", Enabled: true},
|
||||
{Name: "Premium", Enabled: true},
|
||||
{Name: "Assist", Enabled: false},
|
||||
{Name: "N", Enabled: false},
|
||||
{Name: "Hiden", Enabled: false},
|
||||
{Name: "HunterSupport", Enabled: false},
|
||||
{Name: "NBoost", Enabled: false},
|
||||
{Name: "NetCafe", Enabled: true},
|
||||
{Name: "HLRenewing", Enabled: true},
|
||||
{Name: "EXRenewing", Enabled: true},
|
||||
})
|
||||
|
||||
// Database (Password deliberately has no default)
|
||||
viper.SetDefault("Database.Host", "localhost")
|
||||
viper.SetDefault("Database.Port", 5432)
|
||||
viper.SetDefault("Database.User", "postgres")
|
||||
viper.SetDefault("Database.Database", "erupe")
|
||||
|
||||
// Sign server
|
||||
viper.SetDefault("Sign.Enabled", true)
|
||||
viper.SetDefault("Sign.Port", 53312)
|
||||
|
||||
// API server
|
||||
viper.SetDefault("API.Enabled", true)
|
||||
viper.SetDefault("API.Port", 8080)
|
||||
viper.SetDefault("API.LandingPage", LandingPage{
|
||||
Enabled: true,
|
||||
Title: "My Frontier Server",
|
||||
Content: "<p>Welcome! Server is running.</p>",
|
||||
})
|
||||
|
||||
// Channel server
|
||||
viper.SetDefault("Channel.Enabled", true)
|
||||
|
||||
// Entrance server
|
||||
viper.SetDefault("Entrance.Enabled", true)
|
||||
viper.SetDefault("Entrance.Port", uint16(53310))
|
||||
boolTrue := true
|
||||
viper.SetDefault("Entrance.Entries", []EntranceServerInfo{
|
||||
{
|
||||
Name: "Newbie", Type: 3, Recommended: 2,
|
||||
Channels: []EntranceChannelInfo{
|
||||
{Port: 54001, MaxPlayers: 100, Enabled: &boolTrue},
|
||||
{Port: 54002, MaxPlayers: 100, Enabled: &boolTrue},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Normal", Type: 1,
|
||||
Channels: []EntranceChannelInfo{
|
||||
{Port: 54003, MaxPlayers: 100, Enabled: &boolTrue},
|
||||
{Port: 54004, MaxPlayers: 100, Enabled: &boolTrue},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Cities", Type: 2,
|
||||
Channels: []EntranceChannelInfo{
|
||||
{Port: 54005, MaxPlayers: 100, Enabled: &boolTrue},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Tavern", Type: 4,
|
||||
Channels: []EntranceChannelInfo{
|
||||
{Port: 54006, MaxPlayers: 100, Enabled: &boolTrue},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Return", Type: 5,
|
||||
Channels: []EntranceChannelInfo{
|
||||
{Port: 54007, MaxPlayers: 100, Enabled: &boolTrue},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "MezFes", Type: 6, Recommended: 6,
|
||||
Channels: []EntranceChannelInfo{
|
||||
{Port: 54008, MaxPlayers: 100, Enabled: &boolTrue},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// LoadConfig loads the given config toml file.
|
||||
@@ -328,10 +527,7 @@ func LoadConfig() (*Config, error) {
|
||||
viper.SetConfigName("config")
|
||||
viper.AddConfigPath(".")
|
||||
|
||||
viper.SetDefault("DevModeOptions.SaveDumps", SaveDumpOptions{
|
||||
Enabled: true,
|
||||
OutputDir: "save-backups",
|
||||
})
|
||||
registerDefaults()
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
@@ -345,7 +541,11 @@ func LoadConfig() (*Config, error) {
|
||||
}
|
||||
|
||||
if c.Host == "" {
|
||||
c.Host = getOutboundIP4().To4().String()
|
||||
ip, err := getOutboundIP4()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to detect host IP: %w", err)
|
||||
}
|
||||
c.Host = ip.To4().String()
|
||||
}
|
||||
|
||||
for i := range versionStrings {
|
||||
@@ -368,20 +568,3 @@ func LoadConfig() (*Config, error) {
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func preventClose(text string) {
|
||||
if ErupeConfig.DisableSoftCrash {
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Println("\nFailed to start Erupe:\n" + text)
|
||||
go wait()
|
||||
fmt.Println("\nPress Enter/Return to exit...")
|
||||
fmt.Scanln()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func wait() {
|
||||
for {
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
}
|
||||
}
|
||||
|
||||
690
config/config_load_test.go
Normal file
690
config/config_load_test.go
Normal file
@@ -0,0 +1,690 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// TestLoadConfigNoFile tests LoadConfig when config file doesn't exist
|
||||
func TestLoadConfigNoFile(t *testing.T) {
|
||||
// Change to temporary directory to ensure no config file exists
|
||||
tmpDir := t.TempDir()
|
||||
oldWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
defer func() { _ = os.Chdir(oldWd) }()
|
||||
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Failed to change directory: %v", err)
|
||||
}
|
||||
|
||||
// LoadConfig should fail when no config.toml exists
|
||||
config, err := LoadConfig()
|
||||
if err == nil {
|
||||
t.Error("LoadConfig() should return error when config file doesn't exist")
|
||||
}
|
||||
if config != nil {
|
||||
t.Error("LoadConfig() should return nil config on error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigClientModeMapping tests client mode string to Mode conversion
|
||||
func TestLoadConfigClientModeMapping(t *testing.T) {
|
||||
// Test that we can identify version strings and map them to modes
|
||||
tests := []struct {
|
||||
versionStr string
|
||||
expectedMode Mode
|
||||
shouldHaveDebug bool
|
||||
}{
|
||||
{"S1.0", S1, true},
|
||||
{"S10", S10, true},
|
||||
{"G10.1", G101, true},
|
||||
{"ZZ", ZZ, false},
|
||||
{"Z1", Z1, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.versionStr, func(t *testing.T) {
|
||||
// Find matching version string
|
||||
var foundMode Mode
|
||||
for i, vstr := range versionStrings {
|
||||
if vstr == tt.versionStr {
|
||||
foundMode = Mode(i + 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if foundMode != tt.expectedMode {
|
||||
t.Errorf("Version string %s: expected mode %v, got %v", tt.versionStr, tt.expectedMode, foundMode)
|
||||
}
|
||||
|
||||
// Check debug mode marking (versions <= G101 should have debug marking)
|
||||
hasDebug := tt.expectedMode <= G101
|
||||
if hasDebug != tt.shouldHaveDebug {
|
||||
t.Errorf("Debug mode flag for %v: expected %v, got %v", tt.expectedMode, tt.shouldHaveDebug, hasDebug)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigFeatureWeaponConstraint tests MinFeatureWeapons > MaxFeatureWeapons constraint
|
||||
func TestLoadConfigFeatureWeaponConstraint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
minWeapons int
|
||||
maxWeapons int
|
||||
expected int
|
||||
}{
|
||||
{"min < max", 2, 5, 2},
|
||||
{"min > max", 10, 5, 5}, // Should be clamped to max
|
||||
{"min == max", 3, 3, 3},
|
||||
{"min = 0, max = 0", 0, 0, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate constraint logic from LoadConfig
|
||||
min := tt.minWeapons
|
||||
max := tt.maxWeapons
|
||||
if min > max {
|
||||
min = max
|
||||
}
|
||||
if min != tt.expected {
|
||||
t.Errorf("Feature weapon constraint: expected min=%d, got %d", tt.expected, min)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigDefaultHost tests host assignment
|
||||
func TestLoadConfigDefaultHost(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Host: "",
|
||||
}
|
||||
|
||||
// When Host is empty, it should be set to the outbound IP
|
||||
if cfg.Host == "" {
|
||||
// Simulate the logic: if empty, set to outbound IP
|
||||
ip, err := getOutboundIP4()
|
||||
if err != nil {
|
||||
t.Fatalf("getOutboundIP4() error: %v", err)
|
||||
}
|
||||
cfg.Host = ip.To4().String()
|
||||
if cfg.Host == "" {
|
||||
t.Error("Host should be set to outbound IP, got empty string")
|
||||
}
|
||||
// Verify it looks like an IP address
|
||||
parts := len(strings.Split(cfg.Host, "."))
|
||||
if parts != 4 {
|
||||
t.Errorf("Host doesn't look like IPv4 address: %s", cfg.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigDefaultModeWhenInvalid tests default mode when invalid
|
||||
func TestLoadConfigDefaultModeWhenInvalid(t *testing.T) {
|
||||
// When RealClientMode is 0 (invalid), it should default to ZZ
|
||||
var realMode Mode = 0 // Invalid
|
||||
if realMode == 0 {
|
||||
realMode = ZZ
|
||||
}
|
||||
|
||||
if realMode != ZZ {
|
||||
t.Errorf("Invalid mode should default to ZZ, got %v", realMode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigStruct tests Config structure creation with all fields
|
||||
func TestConfigStruct(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Host: "localhost",
|
||||
BinPath: "/opt/erupe",
|
||||
Language: "en",
|
||||
DisableSoftCrash: false,
|
||||
HideLoginNotice: false,
|
||||
LoginNotices: []string{"Welcome"},
|
||||
PatchServerManifest: "http://patch.example.com/manifest",
|
||||
PatchServerFile: "http://patch.example.com/files",
|
||||
DeleteOnSaveCorruption: false,
|
||||
ClientMode: "ZZ",
|
||||
RealClientMode: ZZ,
|
||||
QuestCacheExpiry: 3600,
|
||||
CommandPrefix: "!",
|
||||
AutoCreateAccount: false,
|
||||
LoopDelay: 100,
|
||||
DefaultCourses: []uint16{1, 2, 3},
|
||||
EarthStatus: 0,
|
||||
EarthID: 0,
|
||||
EarthMonsters: []int32{100, 101, 102},
|
||||
SaveDumps: SaveDumpOptions{
|
||||
Enabled: true,
|
||||
RawEnabled: false,
|
||||
OutputDir: "save-backups",
|
||||
},
|
||||
Screenshots: ScreenshotsOptions{
|
||||
Enabled: true,
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
OutputDir: "screenshots",
|
||||
UploadQuality: 85,
|
||||
},
|
||||
DebugOptions: DebugOptions{
|
||||
CleanDB: false,
|
||||
MaxLauncherHR: false,
|
||||
LogInboundMessages: false,
|
||||
LogOutboundMessages: false,
|
||||
LogMessageData: false,
|
||||
},
|
||||
GameplayOptions: GameplayOptions{
|
||||
MinFeatureWeapons: 1,
|
||||
MaxFeatureWeapons: 5,
|
||||
},
|
||||
}
|
||||
|
||||
// Verify all fields are accessible
|
||||
if cfg.Host != "localhost" {
|
||||
t.Error("Failed to set Host")
|
||||
}
|
||||
if cfg.RealClientMode != ZZ {
|
||||
t.Error("Failed to set RealClientMode")
|
||||
}
|
||||
if len(cfg.LoginNotices) != 1 {
|
||||
t.Error("Failed to set LoginNotices")
|
||||
}
|
||||
if cfg.GameplayOptions.MaxFeatureWeapons != 5 {
|
||||
t.Error("Failed to set GameplayOptions.MaxFeatureWeapons")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigNilSafety tests that Config can be safely created as nil and populated
|
||||
func TestConfigNilSafety(t *testing.T) {
|
||||
var cfg *Config
|
||||
if cfg != nil {
|
||||
t.Error("Config should start as nil")
|
||||
}
|
||||
|
||||
cfg = &Config{}
|
||||
|
||||
cfg.Host = "test"
|
||||
if cfg.Host != "test" {
|
||||
t.Error("Failed to set field on allocated Config")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyConfigCreation tests creating empty Config struct
|
||||
func TestEmptyConfigCreation(t *testing.T) {
|
||||
cfg := Config{}
|
||||
|
||||
// Verify zero values
|
||||
if cfg.Host != "" {
|
||||
t.Error("Empty Config.Host should be empty string")
|
||||
}
|
||||
if cfg.RealClientMode != 0 {
|
||||
t.Error("Empty Config.RealClientMode should be 0")
|
||||
}
|
||||
if len(cfg.LoginNotices) != 0 {
|
||||
t.Error("Empty Config.LoginNotices should be empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionStringsMapped tests all version strings are present
|
||||
func TestVersionStringsMapped(t *testing.T) {
|
||||
// Verify all expected version strings are present
|
||||
expectedVersions := []string{
|
||||
"S1.0", "S1.5", "S2.0", "S2.5", "S3.0", "S3.5", "S4.0", "S5.0", "S5.5", "S6.0", "S7.0",
|
||||
"S8.0", "S8.5", "S9.0", "S10", "FW.1", "FW.2", "FW.3", "FW.4", "FW.5", "G1", "G2", "G3",
|
||||
"G3.1", "G3.2", "GG", "G5", "G5.1", "G5.2", "G6", "G6.1", "G7", "G8", "G8.1", "G9", "G9.1",
|
||||
"G10", "G10.1", "Z1", "Z2", "ZZ",
|
||||
}
|
||||
|
||||
if len(versionStrings) != len(expectedVersions) {
|
||||
t.Errorf("versionStrings count mismatch: got %d, want %d", len(versionStrings), len(expectedVersions))
|
||||
}
|
||||
|
||||
for i, expected := range expectedVersions {
|
||||
if i < len(versionStrings) && versionStrings[i] != expected {
|
||||
t.Errorf("versionStrings[%d]: got %s, want %s", i, versionStrings[i], expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultSaveDumpsConfig tests default SaveDumps configuration
|
||||
func TestDefaultSaveDumpsConfig(t *testing.T) {
|
||||
// The LoadConfig function sets default SaveDumps
|
||||
// viper.SetDefault("DevModeOptions.SaveDumps", SaveDumpOptions{...})
|
||||
|
||||
opts := SaveDumpOptions{
|
||||
Enabled: true,
|
||||
OutputDir: "save-backups",
|
||||
}
|
||||
|
||||
if !opts.Enabled {
|
||||
t.Error("Default SaveDumps should be enabled")
|
||||
}
|
||||
if opts.OutputDir != "save-backups" {
|
||||
t.Error("Default SaveDumps OutputDir should be 'save-backups'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntranceServerConfig tests complete entrance server configuration
|
||||
func TestEntranceServerConfig(t *testing.T) {
|
||||
entrance := Entrance{
|
||||
Enabled: true,
|
||||
Port: 10000,
|
||||
Entries: []EntranceServerInfo{
|
||||
{
|
||||
IP: "192.168.1.100",
|
||||
Type: 1, // open
|
||||
Season: 0, // green
|
||||
Recommended: 1,
|
||||
Name: "Main Server",
|
||||
Description: "Main hunting server",
|
||||
AllowedClientFlags: 8192,
|
||||
Channels: []EntranceChannelInfo{
|
||||
{Port: 10001, MaxPlayers: 4, CurrentPlayers: 2},
|
||||
{Port: 10002, MaxPlayers: 4, CurrentPlayers: 1},
|
||||
{Port: 10003, MaxPlayers: 4, CurrentPlayers: 4},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if !entrance.Enabled {
|
||||
t.Error("Entrance should be enabled")
|
||||
}
|
||||
if entrance.Port != 10000 {
|
||||
t.Error("Entrance port mismatch")
|
||||
}
|
||||
if len(entrance.Entries) != 1 {
|
||||
t.Error("Entrance should have 1 entry")
|
||||
}
|
||||
if len(entrance.Entries[0].Channels) != 3 {
|
||||
t.Error("Entry should have 3 channels")
|
||||
}
|
||||
|
||||
// Verify channel occupancy
|
||||
channels := entrance.Entries[0].Channels
|
||||
for _, ch := range channels {
|
||||
if ch.CurrentPlayers > ch.MaxPlayers {
|
||||
t.Errorf("Channel %d has more current players than max", ch.Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscordConfiguration tests Discord integration configuration
|
||||
func TestDiscordConfiguration(t *testing.T) {
|
||||
discord := Discord{
|
||||
Enabled: true,
|
||||
BotToken: "MTA4NTYT3Y0NzY0NTEwNjU0Ng.GMJX5x.example",
|
||||
RelayChannel: DiscordRelay{
|
||||
Enabled: true,
|
||||
MaxMessageLength: 2000,
|
||||
RelayChannelID: "987654321098765432",
|
||||
},
|
||||
}
|
||||
|
||||
if !discord.Enabled {
|
||||
t.Error("Discord should be enabled")
|
||||
}
|
||||
if discord.BotToken == "" {
|
||||
t.Error("Discord BotToken should be set")
|
||||
}
|
||||
if !discord.RelayChannel.Enabled {
|
||||
t.Error("Discord relay should be enabled")
|
||||
}
|
||||
if discord.RelayChannel.MaxMessageLength != 2000 {
|
||||
t.Error("Discord relay max message length should be 2000")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleEntranceServers tests configuration with multiple entrance servers
|
||||
func TestMultipleEntranceServers(t *testing.T) {
|
||||
entrance := Entrance{
|
||||
Enabled: true,
|
||||
Port: 10000,
|
||||
Entries: []EntranceServerInfo{
|
||||
{IP: "192.168.1.100", Type: 1, Name: "Beginner"},
|
||||
{IP: "192.168.1.101", Type: 2, Name: "Cities"},
|
||||
{IP: "192.168.1.102", Type: 3, Name: "Advanced"},
|
||||
},
|
||||
}
|
||||
|
||||
if len(entrance.Entries) != 3 {
|
||||
t.Errorf("Expected 3 servers, got %d", len(entrance.Entries))
|
||||
}
|
||||
|
||||
types := []uint8{1, 2, 3}
|
||||
for i, entry := range entrance.Entries {
|
||||
if entry.Type != types[i] {
|
||||
t.Errorf("Server %d type mismatch", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameplayMultiplierBoundaries tests gameplay multiplier values
|
||||
func TestGameplayMultiplierBoundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value float32
|
||||
ok bool
|
||||
}{
|
||||
{"zero multiplier", 0.0, true},
|
||||
{"one multiplier", 1.0, true},
|
||||
{"half multiplier", 0.5, true},
|
||||
{"double multiplier", 2.0, true},
|
||||
{"high multiplier", 10.0, true},
|
||||
{"negative multiplier", -1.0, true}, // No validation in code
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
opts := GameplayOptions{
|
||||
HRPMultiplier: tt.value,
|
||||
}
|
||||
// Just verify the value can be set
|
||||
if opts.HRPMultiplier != tt.value {
|
||||
t.Errorf("Multiplier not set correctly: expected %f, got %f", tt.value, opts.HRPMultiplier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandConfiguration tests command configuration
|
||||
func TestCommandConfiguration(t *testing.T) {
|
||||
commands := []Command{
|
||||
{Name: "help", Enabled: true, Description: "Show help", Prefix: "!"},
|
||||
{Name: "quest", Enabled: true, Description: "Quest commands", Prefix: "!"},
|
||||
{Name: "admin", Enabled: false, Description: "Admin commands", Prefix: "/"},
|
||||
}
|
||||
|
||||
enabledCount := 0
|
||||
for _, cmd := range commands {
|
||||
if cmd.Enabled {
|
||||
enabledCount++
|
||||
}
|
||||
}
|
||||
|
||||
if enabledCount != 2 {
|
||||
t.Errorf("Expected 2 enabled commands, got %d", enabledCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCourseConfiguration tests course configuration
|
||||
func TestCourseConfiguration(t *testing.T) {
|
||||
courses := []Course{
|
||||
{Name: "Rookie Road", Enabled: true},
|
||||
{Name: "High Rank", Enabled: true},
|
||||
{Name: "G Rank", Enabled: true},
|
||||
{Name: "Z Rank", Enabled: false},
|
||||
}
|
||||
|
||||
activeCount := 0
|
||||
for _, course := range courses {
|
||||
if course.Enabled {
|
||||
activeCount++
|
||||
}
|
||||
}
|
||||
|
||||
if activeCount != 3 {
|
||||
t.Errorf("Expected 3 active courses, got %d", activeCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIBannersAndLinks tests API configuration with banners and links
|
||||
func TestAPIBannersAndLinks(t *testing.T) {
|
||||
api := API{
|
||||
Enabled: true,
|
||||
Port: 8080,
|
||||
PatchServer: "http://patch.example.com",
|
||||
Banners: []APISignBanner{
|
||||
{Src: "banner1.jpg", Link: "http://example.com"},
|
||||
{Src: "banner2.jpg", Link: "http://example.com/2"},
|
||||
},
|
||||
Links: []APISignLink{
|
||||
{Name: "Forum", Icon: "forum", Link: "http://forum.example.com"},
|
||||
{Name: "Wiki", Icon: "wiki", Link: "http://wiki.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
if len(api.Banners) != 2 {
|
||||
t.Errorf("Expected 2 banners, got %d", len(api.Banners))
|
||||
}
|
||||
if len(api.Links) != 2 {
|
||||
t.Errorf("Expected 2 links, got %d", len(api.Links))
|
||||
}
|
||||
|
||||
for i, banner := range api.Banners {
|
||||
if banner.Link == "" {
|
||||
t.Errorf("Banner %d has empty link", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClanMemberLimits tests ClanMemberLimits configuration
|
||||
func TestClanMemberLimits(t *testing.T) {
|
||||
opts := GameplayOptions{
|
||||
ClanMemberLimits: [][]uint8{
|
||||
{1, 10},
|
||||
{2, 20},
|
||||
{3, 30},
|
||||
{4, 40},
|
||||
{5, 50},
|
||||
},
|
||||
}
|
||||
|
||||
if len(opts.ClanMemberLimits) != 5 {
|
||||
t.Errorf("Expected 5 clan member limits, got %d", len(opts.ClanMemberLimits))
|
||||
}
|
||||
|
||||
for i, limits := range opts.ClanMemberLimits {
|
||||
if limits[0] != uint8(i+1) {
|
||||
t.Errorf("Rank mismatch at index %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkConfigCreation benchmarks creating a full Config
|
||||
func BenchmarkConfigCreation(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = &Config{
|
||||
Host: "localhost",
|
||||
Language: "en",
|
||||
ClientMode: "ZZ",
|
||||
RealClientMode: ZZ,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeMinimalConfig writes a minimal config.json to dir and returns its path.
|
||||
func writeMinimalConfig(t *testing.T, dir, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0644); err != nil {
|
||||
t.Fatalf("writing config.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinimalConfigDefaults verifies that a minimal config.json produces a fully
|
||||
// populated Config with sane defaults (multipliers not zero, entrance entries present, etc).
|
||||
func TestMinimalConfigDefaults(t *testing.T) {
|
||||
viper.Reset()
|
||||
dir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(origDir) }()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeMinimalConfig(t, dir, `{
|
||||
"Database": { "Password": "test" }
|
||||
}`)
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
|
||||
// Multipliers must be 1.0 (not Go's zero value 0.0)
|
||||
multipliers := map[string]float32{
|
||||
"HRPMultiplier": cfg.GameplayOptions.HRPMultiplier,
|
||||
"SRPMultiplier": cfg.GameplayOptions.SRPMultiplier,
|
||||
"GRPMultiplier": cfg.GameplayOptions.GRPMultiplier,
|
||||
"ZennyMultiplier": cfg.GameplayOptions.ZennyMultiplier,
|
||||
"MaterialMultiplier": cfg.GameplayOptions.MaterialMultiplier,
|
||||
"GCPMultiplier": cfg.GameplayOptions.GCPMultiplier,
|
||||
"GMaterialMultiplier": cfg.GameplayOptions.GMaterialMultiplier,
|
||||
}
|
||||
for name, val := range multipliers {
|
||||
if val != 1.0 {
|
||||
t.Errorf("%s = %v, want 1.0", name, val)
|
||||
}
|
||||
}
|
||||
|
||||
// Entrance entries should be present
|
||||
if len(cfg.Entrance.Entries) != 6 {
|
||||
t.Errorf("Entrance.Entries = %d, want 6", len(cfg.Entrance.Entries))
|
||||
}
|
||||
|
||||
// Commands should be present
|
||||
if len(cfg.Commands) != 12 {
|
||||
t.Errorf("Commands = %d, want 12", len(cfg.Commands))
|
||||
}
|
||||
|
||||
// Courses should be present
|
||||
if len(cfg.Courses) != 11 {
|
||||
t.Errorf("Courses = %d, want 11", len(cfg.Courses))
|
||||
}
|
||||
|
||||
// Standard ports
|
||||
if cfg.Sign.Port != 53312 {
|
||||
t.Errorf("Sign.Port = %d, want 53312", cfg.Sign.Port)
|
||||
}
|
||||
if cfg.API.Port != 8080 {
|
||||
t.Errorf("API.Port = %d, want 8080", cfg.API.Port)
|
||||
}
|
||||
if cfg.Entrance.Port != 53310 {
|
||||
t.Errorf("Entrance.Port = %d, want 53310", cfg.Entrance.Port)
|
||||
}
|
||||
|
||||
// Servers enabled by default
|
||||
if !cfg.Sign.Enabled {
|
||||
t.Error("Sign.Enabled should be true")
|
||||
}
|
||||
if !cfg.API.Enabled {
|
||||
t.Error("API.Enabled should be true")
|
||||
}
|
||||
if !cfg.Channel.Enabled {
|
||||
t.Error("Channel.Enabled should be true")
|
||||
}
|
||||
if !cfg.Entrance.Enabled {
|
||||
t.Error("Entrance.Enabled should be true")
|
||||
}
|
||||
|
||||
// Database defaults
|
||||
if cfg.Database.Host != "localhost" {
|
||||
t.Errorf("Database.Host = %q, want localhost", cfg.Database.Host)
|
||||
}
|
||||
if cfg.Database.Port != 5432 {
|
||||
t.Errorf("Database.Port = %d, want 5432", cfg.Database.Port)
|
||||
}
|
||||
|
||||
// ClientMode defaults to ZZ
|
||||
if cfg.RealClientMode != ZZ {
|
||||
t.Errorf("RealClientMode = %v, want ZZ", cfg.RealClientMode)
|
||||
}
|
||||
|
||||
// BinPath default
|
||||
if cfg.BinPath != "bin" {
|
||||
t.Errorf("BinPath = %q, want bin", cfg.BinPath)
|
||||
}
|
||||
|
||||
// Gameplay limits
|
||||
if cfg.GameplayOptions.MaximumNP != 100000 {
|
||||
t.Errorf("MaximumNP = %d, want 100000", cfg.GameplayOptions.MaximumNP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullConfigBackwardCompat verifies that existing full configs still load correctly.
|
||||
func TestFullConfigBackwardCompat(t *testing.T) {
|
||||
viper.Reset()
|
||||
dir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(origDir) }()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Read the reference config (the full original config.example.json).
|
||||
// Look in the project root (one level up from config/).
|
||||
refPath := filepath.Join(origDir, "..", "config.reference.json")
|
||||
refData, err := os.ReadFile(refPath)
|
||||
if err != nil {
|
||||
t.Skipf("config.reference.json not found at %s, skipping backward compat test", refPath)
|
||||
}
|
||||
writeMinimalConfig(t, dir, string(refData))
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() with full config error: %v", err)
|
||||
}
|
||||
|
||||
// Spot-check values from the reference config
|
||||
if cfg.GameplayOptions.HRPMultiplier != 1.0 {
|
||||
t.Errorf("HRPMultiplier = %v, want 1.0", cfg.GameplayOptions.HRPMultiplier)
|
||||
}
|
||||
if cfg.Sign.Port != 53312 {
|
||||
t.Errorf("Sign.Port = %d, want 53312", cfg.Sign.Port)
|
||||
}
|
||||
if len(cfg.Entrance.Entries) != 6 {
|
||||
t.Errorf("Entrance.Entries = %d, want 6", len(cfg.Entrance.Entries))
|
||||
}
|
||||
if len(cfg.Commands) != 12 {
|
||||
t.Errorf("Commands = %d, want 12", len(cfg.Commands))
|
||||
}
|
||||
if cfg.GameplayOptions.MaximumNP != 100000 {
|
||||
t.Errorf("MaximumNP = %d, want 100000", cfg.GameplayOptions.MaximumNP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSingleFieldOverride verifies that overriding one field in a dot-notation
|
||||
// section doesn't clobber other fields' defaults.
|
||||
func TestSingleFieldOverride(t *testing.T) {
|
||||
viper.Reset()
|
||||
dir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(origDir) }()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeMinimalConfig(t, dir, `{
|
||||
"Database": { "Password": "test" },
|
||||
"GameplayOptions": { "HRPMultiplier": 2.0 }
|
||||
}`)
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
|
||||
// Overridden field
|
||||
if cfg.GameplayOptions.HRPMultiplier != 2.0 {
|
||||
t.Errorf("HRPMultiplier = %v, want 2.0", cfg.GameplayOptions.HRPMultiplier)
|
||||
}
|
||||
|
||||
// Other multipliers should retain defaults
|
||||
if cfg.GameplayOptions.SRPMultiplier != 1.0 {
|
||||
t.Errorf("SRPMultiplier = %v, want 1.0 (should retain default)", cfg.GameplayOptions.SRPMultiplier)
|
||||
}
|
||||
if cfg.GameplayOptions.ZennyMultiplier != 1.0 {
|
||||
t.Errorf("ZennyMultiplier = %v, want 1.0 (should retain default)", cfg.GameplayOptions.ZennyMultiplier)
|
||||
}
|
||||
if cfg.GameplayOptions.GCPMultiplier != 1.0 {
|
||||
t.Errorf("GCPMultiplier = %v, want 1.0 (should retain default)", cfg.GameplayOptions.GCPMultiplier)
|
||||
}
|
||||
}
|
||||
43
config/config_mode_test.go
Normal file
43
config/config_mode_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestModeStringMethod calls Mode.String() to cover the method.
|
||||
// Note: Mode.String() has a known off-by-one bug (Mode values are 1-indexed but
|
||||
// versionStrings is 0-indexed), so S1.String() returns "S1.5" instead of "S1.0".
|
||||
// ZZ (value 41) would panic because versionStrings only has 41 entries (indices 0-40).
|
||||
func TestModeStringMethod(t *testing.T) {
|
||||
// Test modes that don't panic (S1=1 through Z2=40)
|
||||
tests := []struct {
|
||||
mode Mode
|
||||
want string
|
||||
}{
|
||||
{S1, "S1.5"}, // versionStrings[1]
|
||||
{S15, "S2.0"}, // versionStrings[2]
|
||||
{G1, "G2"}, // versionStrings[21]
|
||||
{Z1, "Z2"}, // versionStrings[39]
|
||||
{Z2, "ZZ"}, // versionStrings[40]
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
got := tt.mode.String()
|
||||
if got != tt.want {
|
||||
t.Errorf("Mode(%d).String() = %q, want %q", tt.mode, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModeStringAllSafeVersions verifies all modes from S1 through Z2 produce valid strings
|
||||
// (ZZ is excluded because it's out of bounds due to the off-by-one bug)
|
||||
func TestModeStringAllSafeVersions(t *testing.T) {
|
||||
for m := S1; m <= Z2; m++ {
|
||||
got := m.String()
|
||||
if got == "" {
|
||||
t.Errorf("Mode(%d).String() returned empty string", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
704
config/config_test.go
Normal file
704
config/config_test.go
Normal file
@@ -0,0 +1,704 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestModeString tests the versionStrings array content
|
||||
func TestModeString(t *testing.T) {
|
||||
// NOTE: The Mode.String() method in config.go has a bug - it directly uses the Mode value
|
||||
// as an index (which is 1-41) but versionStrings is 0-indexed. This test validates
|
||||
// the versionStrings array content instead.
|
||||
|
||||
expectedStrings := map[int]string{
|
||||
0: "S1.0",
|
||||
1: "S1.5",
|
||||
2: "S2.0",
|
||||
3: "S2.5",
|
||||
4: "S3.0",
|
||||
5: "S3.5",
|
||||
6: "S4.0",
|
||||
7: "S5.0",
|
||||
8: "S5.5",
|
||||
9: "S6.0",
|
||||
10: "S7.0",
|
||||
11: "S8.0",
|
||||
12: "S8.5",
|
||||
13: "S9.0",
|
||||
14: "S10",
|
||||
15: "FW.1",
|
||||
16: "FW.2",
|
||||
17: "FW.3",
|
||||
18: "FW.4",
|
||||
19: "FW.5",
|
||||
20: "G1",
|
||||
21: "G2",
|
||||
22: "G3",
|
||||
23: "G3.1",
|
||||
24: "G3.2",
|
||||
25: "GG",
|
||||
26: "G5",
|
||||
27: "G5.1",
|
||||
28: "G5.2",
|
||||
29: "G6",
|
||||
30: "G6.1",
|
||||
31: "G7",
|
||||
32: "G8",
|
||||
33: "G8.1",
|
||||
34: "G9",
|
||||
35: "G9.1",
|
||||
36: "G10",
|
||||
37: "G10.1",
|
||||
38: "Z1",
|
||||
39: "Z2",
|
||||
40: "ZZ",
|
||||
}
|
||||
|
||||
for i, expected := range expectedStrings {
|
||||
if i < len(versionStrings) {
|
||||
if versionStrings[i] != expected {
|
||||
t.Errorf("versionStrings[%d] = %s, want %s", i, versionStrings[i], expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModeConstants verifies all mode constants are unique and in order
|
||||
func TestModeConstants(t *testing.T) {
|
||||
modes := []Mode{
|
||||
S1, S15, S2, S25, S3, S35, S4, S5, S55, S6, S7, S8, S85, S9, S10,
|
||||
F1, F2, F3, F4, F5,
|
||||
G1, G2, G3, G31, G32, GG, G5, G51, G52, G6, G61, G7, G8, G81, G9, G91, G10, G101,
|
||||
Z1, Z2, ZZ,
|
||||
}
|
||||
|
||||
// Verify all modes are unique
|
||||
seen := make(map[Mode]bool)
|
||||
for _, mode := range modes {
|
||||
if seen[mode] {
|
||||
t.Errorf("Duplicate mode constant: %v", mode)
|
||||
}
|
||||
seen[mode] = true
|
||||
}
|
||||
|
||||
// Verify modes are in sequential order
|
||||
for i, mode := range modes {
|
||||
if int(mode) != i+1 {
|
||||
t.Errorf("Mode %v at index %d has wrong value: got %d, want %d", mode, i, mode, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify total count
|
||||
if len(modes) != len(versionStrings) {
|
||||
t.Errorf("Number of modes (%d) doesn't match versionStrings count (%d)", len(modes), len(versionStrings))
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionStringsLength verifies versionStrings has correct length
|
||||
func TestVersionStringsLength(t *testing.T) {
|
||||
expectedCount := 41 // S1 through ZZ = 41 versions
|
||||
if len(versionStrings) != expectedCount {
|
||||
t.Errorf("versionStrings length = %d, want %d", len(versionStrings), expectedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionStringsContent verifies critical version strings
|
||||
func TestVersionStringsContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
index int
|
||||
expected string
|
||||
}{
|
||||
{0, "S1.0"}, // S1
|
||||
{14, "S10"}, // S10
|
||||
{15, "FW.1"}, // F1
|
||||
{19, "FW.5"}, // F5
|
||||
{20, "G1"}, // G1
|
||||
{38, "Z1"}, // Z1
|
||||
{39, "Z2"}, // Z2
|
||||
{40, "ZZ"}, // ZZ
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if versionStrings[tt.index] != tt.expected {
|
||||
t.Errorf("versionStrings[%d] = %s, want %s", tt.index, versionStrings[tt.index], tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetOutboundIP4 tests IP detection
|
||||
func TestGetOutboundIP4(t *testing.T) {
|
||||
ip, err := getOutboundIP4()
|
||||
if err != nil {
|
||||
t.Fatalf("getOutboundIP4() returned error: %v", err)
|
||||
}
|
||||
if ip == nil {
|
||||
t.Error("getOutboundIP4() returned nil IP")
|
||||
}
|
||||
|
||||
// Verify it returns IPv4
|
||||
if ip.To4() == nil {
|
||||
t.Error("getOutboundIP4() should return valid IPv4")
|
||||
}
|
||||
|
||||
// Verify it's not all zeros
|
||||
if len(ip) == 4 && ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 {
|
||||
t.Error("getOutboundIP4() returned 0.0.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigStructTypes verifies Config struct fields have correct types
|
||||
func TestConfigStructTypes(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Host: "localhost",
|
||||
BinPath: "/path/to/bin",
|
||||
Language: "en",
|
||||
DisableSoftCrash: false,
|
||||
HideLoginNotice: false,
|
||||
LoginNotices: []string{"Notice"},
|
||||
PatchServerManifest: "http://patch.example.com",
|
||||
PatchServerFile: "http://files.example.com",
|
||||
DeleteOnSaveCorruption: false,
|
||||
ClientMode: "ZZ",
|
||||
RealClientMode: ZZ,
|
||||
QuestCacheExpiry: 3600,
|
||||
CommandPrefix: "!",
|
||||
AutoCreateAccount: false,
|
||||
LoopDelay: 100,
|
||||
DefaultCourses: []uint16{1, 2, 3},
|
||||
EarthStatus: 1,
|
||||
EarthID: 1,
|
||||
EarthMonsters: []int32{1, 2, 3},
|
||||
SaveDumps: SaveDumpOptions{
|
||||
Enabled: true,
|
||||
RawEnabled: false,
|
||||
OutputDir: "/dumps",
|
||||
},
|
||||
Screenshots: ScreenshotsOptions{
|
||||
Enabled: true,
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
OutputDir: "/screenshots",
|
||||
UploadQuality: 85,
|
||||
},
|
||||
DebugOptions: DebugOptions{
|
||||
CleanDB: false,
|
||||
MaxLauncherHR: false,
|
||||
LogInboundMessages: false,
|
||||
LogOutboundMessages: false,
|
||||
LogMessageData: false,
|
||||
MaxHexdumpLength: 32,
|
||||
},
|
||||
GameplayOptions: GameplayOptions{
|
||||
MinFeatureWeapons: 1,
|
||||
MaxFeatureWeapons: 5,
|
||||
},
|
||||
}
|
||||
|
||||
// Verify fields are accessible and have correct types
|
||||
if cfg.Host != "localhost" {
|
||||
t.Error("Config.Host type mismatch")
|
||||
}
|
||||
if cfg.QuestCacheExpiry != 3600 {
|
||||
t.Error("Config.QuestCacheExpiry type mismatch")
|
||||
}
|
||||
if cfg.RealClientMode != ZZ {
|
||||
t.Error("Config.RealClientMode type mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveDumpOptions verifies SaveDumpOptions struct
|
||||
func TestSaveDumpOptions(t *testing.T) {
|
||||
opts := SaveDumpOptions{
|
||||
Enabled: true,
|
||||
RawEnabled: false,
|
||||
OutputDir: "/test/path",
|
||||
}
|
||||
|
||||
if !opts.Enabled {
|
||||
t.Error("SaveDumpOptions.Enabled should be true")
|
||||
}
|
||||
if opts.RawEnabled {
|
||||
t.Error("SaveDumpOptions.RawEnabled should be false")
|
||||
}
|
||||
if opts.OutputDir != "/test/path" {
|
||||
t.Error("SaveDumpOptions.OutputDir mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScreenshotsOptions verifies ScreenshotsOptions struct
|
||||
func TestScreenshotsOptions(t *testing.T) {
|
||||
opts := ScreenshotsOptions{
|
||||
Enabled: true,
|
||||
Host: "ss.example.com",
|
||||
Port: 8000,
|
||||
OutputDir: "/screenshots",
|
||||
UploadQuality: 90,
|
||||
}
|
||||
|
||||
if !opts.Enabled {
|
||||
t.Error("ScreenshotsOptions.Enabled should be true")
|
||||
}
|
||||
if opts.Host != "ss.example.com" {
|
||||
t.Error("ScreenshotsOptions.Host mismatch")
|
||||
}
|
||||
if opts.Port != 8000 {
|
||||
t.Error("ScreenshotsOptions.Port mismatch")
|
||||
}
|
||||
if opts.UploadQuality != 90 {
|
||||
t.Error("ScreenshotsOptions.UploadQuality mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDebugOptions verifies DebugOptions struct
|
||||
func TestDebugOptions(t *testing.T) {
|
||||
opts := DebugOptions{
|
||||
CleanDB: true,
|
||||
MaxLauncherHR: true,
|
||||
LogInboundMessages: true,
|
||||
LogOutboundMessages: true,
|
||||
LogMessageData: true,
|
||||
MaxHexdumpLength: 128,
|
||||
DivaOverride: 1,
|
||||
DisableTokenCheck: true,
|
||||
}
|
||||
|
||||
if !opts.CleanDB {
|
||||
t.Error("DebugOptions.CleanDB should be true")
|
||||
}
|
||||
if !opts.MaxLauncherHR {
|
||||
t.Error("DebugOptions.MaxLauncherHR should be true")
|
||||
}
|
||||
if opts.MaxHexdumpLength != 128 {
|
||||
t.Error("DebugOptions.MaxHexdumpLength mismatch")
|
||||
}
|
||||
if !opts.DisableTokenCheck {
|
||||
t.Error("DebugOptions.DisableTokenCheck should be true (security risk!)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameplayOptions verifies GameplayOptions struct
|
||||
func TestGameplayOptions(t *testing.T) {
|
||||
opts := GameplayOptions{
|
||||
MinFeatureWeapons: 2,
|
||||
MaxFeatureWeapons: 10,
|
||||
MaximumNP: 999999,
|
||||
MaximumRP: 9999,
|
||||
MaximumFP: 999999999,
|
||||
MezFesSoloTickets: 100,
|
||||
MezFesGroupTickets: 50,
|
||||
DisableHunterNavi: true,
|
||||
EnableKaijiEvent: true,
|
||||
EnableHiganjimaEvent: false,
|
||||
EnableNierEvent: false,
|
||||
}
|
||||
|
||||
if opts.MinFeatureWeapons != 2 {
|
||||
t.Error("GameplayOptions.MinFeatureWeapons mismatch")
|
||||
}
|
||||
if opts.MaxFeatureWeapons != 10 {
|
||||
t.Error("GameplayOptions.MaxFeatureWeapons mismatch")
|
||||
}
|
||||
if opts.MezFesSoloTickets != 100 {
|
||||
t.Error("GameplayOptions.MezFesSoloTickets mismatch")
|
||||
}
|
||||
if !opts.EnableKaijiEvent {
|
||||
t.Error("GameplayOptions.EnableKaijiEvent should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapLinkOptions verifies CapLinkOptions struct
|
||||
func TestCapLinkOptions(t *testing.T) {
|
||||
opts := CapLinkOptions{
|
||||
Values: []uint16{1, 2, 3},
|
||||
Key: "test-key",
|
||||
Host: "localhost",
|
||||
Port: 9999,
|
||||
}
|
||||
|
||||
if len(opts.Values) != 3 {
|
||||
t.Error("CapLinkOptions.Values length mismatch")
|
||||
}
|
||||
if opts.Key != "test-key" {
|
||||
t.Error("CapLinkOptions.Key mismatch")
|
||||
}
|
||||
if opts.Port != 9999 {
|
||||
t.Error("CapLinkOptions.Port mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDatabase verifies Database struct
|
||||
func TestDatabase(t *testing.T) {
|
||||
db := Database{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "password",
|
||||
Database: "erupe",
|
||||
}
|
||||
|
||||
if db.Host != "localhost" {
|
||||
t.Error("Database.Host mismatch")
|
||||
}
|
||||
if db.Port != 5432 {
|
||||
t.Error("Database.Port mismatch")
|
||||
}
|
||||
if db.User != "postgres" {
|
||||
t.Error("Database.User mismatch")
|
||||
}
|
||||
if db.Database != "erupe" {
|
||||
t.Error("Database.Database mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSign verifies Sign struct
|
||||
func TestSign(t *testing.T) {
|
||||
sign := Sign{
|
||||
Enabled: true,
|
||||
Port: 8081,
|
||||
}
|
||||
|
||||
if !sign.Enabled {
|
||||
t.Error("Sign.Enabled should be true")
|
||||
}
|
||||
if sign.Port != 8081 {
|
||||
t.Error("Sign.Port mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI verifies API struct
|
||||
func TestAPI(t *testing.T) {
|
||||
api := API{
|
||||
Enabled: true,
|
||||
Port: 8080,
|
||||
PatchServer: "http://patch.example.com",
|
||||
Banners: []APISignBanner{
|
||||
{Src: "banner.jpg", Link: "http://example.com"},
|
||||
},
|
||||
Messages: []APISignMessage{
|
||||
{Message: "Welcome", Date: 0, Kind: 0, Link: "http://example.com"},
|
||||
},
|
||||
Links: []APISignLink{
|
||||
{Name: "Forum", Icon: "forum", Link: "http://forum.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
if !api.Enabled {
|
||||
t.Error("API.Enabled should be true")
|
||||
}
|
||||
if api.Port != 8080 {
|
||||
t.Error("API.Port mismatch")
|
||||
}
|
||||
if len(api.Banners) != 1 {
|
||||
t.Error("API.Banners length mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPISignBanner verifies APISignBanner struct
|
||||
func TestAPISignBanner(t *testing.T) {
|
||||
banner := APISignBanner{
|
||||
Src: "http://example.com/banner.jpg",
|
||||
Link: "http://example.com",
|
||||
}
|
||||
|
||||
if banner.Src != "http://example.com/banner.jpg" {
|
||||
t.Error("APISignBanner.Src mismatch")
|
||||
}
|
||||
if banner.Link != "http://example.com" {
|
||||
t.Error("APISignBanner.Link mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPISignMessage verifies APISignMessage struct
|
||||
func TestAPISignMessage(t *testing.T) {
|
||||
msg := APISignMessage{
|
||||
Message: "Welcome to Erupe!",
|
||||
Date: 1625097600,
|
||||
Kind: 0,
|
||||
Link: "http://example.com",
|
||||
}
|
||||
|
||||
if msg.Message != "Welcome to Erupe!" {
|
||||
t.Error("APISignMessage.Message mismatch")
|
||||
}
|
||||
if msg.Date != 1625097600 {
|
||||
t.Error("APISignMessage.Date mismatch")
|
||||
}
|
||||
if msg.Kind != 0 {
|
||||
t.Error("APISignMessage.Kind mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPISignLink verifies APISignLink struct
|
||||
func TestAPISignLink(t *testing.T) {
|
||||
link := APISignLink{
|
||||
Name: "Forum",
|
||||
Icon: "forum",
|
||||
Link: "http://forum.example.com",
|
||||
}
|
||||
|
||||
if link.Name != "Forum" {
|
||||
t.Error("APISignLink.Name mismatch")
|
||||
}
|
||||
if link.Icon != "forum" {
|
||||
t.Error("APISignLink.Icon mismatch")
|
||||
}
|
||||
if link.Link != "http://forum.example.com" {
|
||||
t.Error("APISignLink.Link mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannel verifies Channel struct
|
||||
func TestChannel(t *testing.T) {
|
||||
ch := Channel{
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
if !ch.Enabled {
|
||||
t.Error("Channel.Enabled should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntrance verifies Entrance struct
|
||||
func TestEntrance(t *testing.T) {
|
||||
entrance := Entrance{
|
||||
Enabled: true,
|
||||
Port: 10000,
|
||||
Entries: []EntranceServerInfo{
|
||||
{
|
||||
IP: "192.168.1.1",
|
||||
Type: 1,
|
||||
Season: 0,
|
||||
Recommended: 0,
|
||||
Name: "Test Server",
|
||||
Description: "A test server",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if !entrance.Enabled {
|
||||
t.Error("Entrance.Enabled should be true")
|
||||
}
|
||||
if entrance.Port != 10000 {
|
||||
t.Error("Entrance.Port mismatch")
|
||||
}
|
||||
if len(entrance.Entries) != 1 {
|
||||
t.Error("Entrance.Entries length mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntranceServerInfo verifies EntranceServerInfo struct
|
||||
func TestEntranceServerInfo(t *testing.T) {
|
||||
info := EntranceServerInfo{
|
||||
IP: "192.168.1.1",
|
||||
Type: 1,
|
||||
Season: 0,
|
||||
Recommended: 0,
|
||||
Name: "Server 1",
|
||||
Description: "Main server",
|
||||
AllowedClientFlags: 4096,
|
||||
Channels: []EntranceChannelInfo{
|
||||
{Port: 10001, MaxPlayers: 4, CurrentPlayers: 2},
|
||||
},
|
||||
}
|
||||
|
||||
if info.IP != "192.168.1.1" {
|
||||
t.Error("EntranceServerInfo.IP mismatch")
|
||||
}
|
||||
if info.Type != 1 {
|
||||
t.Error("EntranceServerInfo.Type mismatch")
|
||||
}
|
||||
if len(info.Channels) != 1 {
|
||||
t.Error("EntranceServerInfo.Channels length mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntranceChannelInfo verifies EntranceChannelInfo struct
|
||||
func TestEntranceChannelInfo(t *testing.T) {
|
||||
info := EntranceChannelInfo{
|
||||
Port: 10001,
|
||||
MaxPlayers: 4,
|
||||
CurrentPlayers: 2,
|
||||
}
|
||||
|
||||
if info.Port != 10001 {
|
||||
t.Error("EntranceChannelInfo.Port mismatch")
|
||||
}
|
||||
if info.MaxPlayers != 4 {
|
||||
t.Error("EntranceChannelInfo.MaxPlayers mismatch")
|
||||
}
|
||||
if info.CurrentPlayers != 2 {
|
||||
t.Error("EntranceChannelInfo.CurrentPlayers mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntranceChannelInfoIsEnabled tests the Enabled field and IsEnabled helper
|
||||
func TestEntranceChannelInfoIsEnabled(t *testing.T) {
|
||||
trueVal := true
|
||||
falseVal := false
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled *bool
|
||||
want bool
|
||||
}{
|
||||
{"nil defaults to true", nil, true},
|
||||
{"explicit true", &trueVal, true},
|
||||
{"explicit false", &falseVal, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info := EntranceChannelInfo{
|
||||
Port: 10001,
|
||||
Enabled: tt.enabled,
|
||||
}
|
||||
if got := info.IsEnabled(); got != tt.want {
|
||||
t.Errorf("IsEnabled() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscord verifies Discord struct
|
||||
func TestDiscord(t *testing.T) {
|
||||
discord := Discord{
|
||||
Enabled: true,
|
||||
BotToken: "token123",
|
||||
RelayChannel: DiscordRelay{
|
||||
Enabled: true,
|
||||
MaxMessageLength: 2000,
|
||||
RelayChannelID: "123456789",
|
||||
},
|
||||
}
|
||||
|
||||
if !discord.Enabled {
|
||||
t.Error("Discord.Enabled should be true")
|
||||
}
|
||||
if discord.BotToken != "token123" {
|
||||
t.Error("Discord.BotToken mismatch")
|
||||
}
|
||||
if discord.RelayChannel.MaxMessageLength != 2000 {
|
||||
t.Error("Discord.RelayChannel.MaxMessageLength mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommand verifies Command struct
|
||||
func TestCommand(t *testing.T) {
|
||||
cmd := Command{
|
||||
Name: "test",
|
||||
Enabled: true,
|
||||
Description: "Test command",
|
||||
Prefix: "!",
|
||||
}
|
||||
|
||||
if cmd.Name != "test" {
|
||||
t.Error("Command.Name mismatch")
|
||||
}
|
||||
if !cmd.Enabled {
|
||||
t.Error("Command.Enabled should be true")
|
||||
}
|
||||
if cmd.Prefix != "!" {
|
||||
t.Error("Command.Prefix mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCourse verifies Course struct
|
||||
func TestCourse(t *testing.T) {
|
||||
course := Course{
|
||||
Name: "Rookie Road",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
if course.Name != "Rookie Road" {
|
||||
t.Error("Course.Name mismatch")
|
||||
}
|
||||
if !course.Enabled {
|
||||
t.Error("Course.Enabled should be true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameplayOptionsConstraints tests gameplay option constraints
|
||||
func TestGameplayOptionsConstraints(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts GameplayOptions
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "valid multipliers",
|
||||
opts: GameplayOptions{
|
||||
HRPMultiplier: 1.5,
|
||||
GRPMultiplier: 1.2,
|
||||
ZennyMultiplier: 1.0,
|
||||
MaterialMultiplier: 1.3,
|
||||
},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "zero multipliers",
|
||||
opts: GameplayOptions{
|
||||
HRPMultiplier: 0.0,
|
||||
},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "high multipliers",
|
||||
opts: GameplayOptions{
|
||||
GCPMultiplier: 10.0,
|
||||
},
|
||||
ok: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Just verify the struct can be created with these values
|
||||
_ = tt.opts
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModeValueRanges tests Mode constant value ranges
|
||||
func TestModeValueRanges(t *testing.T) {
|
||||
if S1 < 1 || S1 > ZZ {
|
||||
t.Error("S1 mode value out of range")
|
||||
}
|
||||
if ZZ <= G101 {
|
||||
t.Error("ZZ should be greater than G101")
|
||||
}
|
||||
if G101 <= F5 {
|
||||
t.Error("G101 should be greater than F5")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigDefaults tests default configuration creation
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
cfg := &Config{
|
||||
ClientMode: "ZZ",
|
||||
RealClientMode: ZZ,
|
||||
}
|
||||
|
||||
if cfg.ClientMode != "ZZ" {
|
||||
t.Error("Default ClientMode mismatch")
|
||||
}
|
||||
if cfg.RealClientMode != ZZ {
|
||||
t.Error("Default RealClientMode mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkModeString benchmarks Mode.String() method
|
||||
func BenchmarkModeString(b *testing.B) {
|
||||
mode := ZZ
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = mode.String()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGetOutboundIP4 benchmarks IP detection
|
||||
func BenchmarkGetOutboundIP4(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = getOutboundIP4()
|
||||
}
|
||||
}
|
||||
100
docker/README.md
100
docker/README.md
@@ -1,70 +1,58 @@
|
||||
# Docker for erupe
|
||||
# Docker for Erupe
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. From the repository root, copy and edit the config:
|
||||
|
||||
```bash
|
||||
cp config.example.json docker/config.json
|
||||
```
|
||||
|
||||
Edit `docker/config.json` — set `Database.Host` to `"db"` and `Database.Password` to match `docker-compose.yml` (default: `password`). The example config is minimal; see `config.reference.json` for all available options.
|
||||
|
||||
2. Place your [quest/scenario files](https://files.catbox.moe/xf0l7w.7z) in `docker/bin/`.
|
||||
|
||||
3. Start everything:
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose up
|
||||
```
|
||||
|
||||
The database schema is automatically applied on first start via the embedded migration system.
|
||||
|
||||
pgAdmin is available at `http://localhost:5050` (default login: `user@pgadmin.com` / `password`).
|
||||
|
||||
## Building Locally
|
||||
|
||||
By default the server service pulls the prebuilt image from GHCR. To build from source instead, edit `docker-compose.yml`: comment out the `image` line and uncomment the `build` section, then:
|
||||
|
||||
## Building the container
|
||||
Run the following from the route of the source folder. In this example we give it the tag of dev to seperate it from any other container verions.
|
||||
```bash
|
||||
docker build . -t erupe:dev
|
||||
docker compose up --build
|
||||
```
|
||||
## Running the container in isolation
|
||||
This is just running the container. You can do volume mounts into the container for the `config.json` to tell it to communicate to a database. You will need to do this also for other folders such as `bin` and `savedata`
|
||||
|
||||
## Stopping the Server
|
||||
|
||||
```bash
|
||||
docker run erupe:dev
|
||||
docker compose stop # Stop containers (preserves data)
|
||||
docker compose down # Stop and remove containers (preserves data volumes)
|
||||
```
|
||||
|
||||
## Docker compose
|
||||
Docker compose allows you to run multiple containers at once. The docker compose in this folder has 3 things set up.
|
||||
- postgres
|
||||
- pg admin (Admin interface to make db changes)
|
||||
- erupe
|
||||
To delete all persistent data, remove these directories after stopping:
|
||||
|
||||
We automatically populate the database to the latest version on start. If you you are updating you will need to apply the new schemas manually.
|
||||
- `docker/db-data/`
|
||||
- `docker/savedata/`
|
||||
|
||||
Before we get started you should make sure the database info matches whats in the docker compose file for the environment variables `POSTGRES_PASSWORD`,`POSTGRES_USER` and `POSTGRES_DB`. You can set the host to be the service name `db`.
|
||||
## Updating
|
||||
|
||||
Here is a example of what you would put in the config.json if you was to leave the defaults. It is strongly recommended to change the password.
|
||||
```txt
|
||||
"Database": {
|
||||
"Host": "db",
|
||||
"Port": 5432,
|
||||
"User": "postgres",
|
||||
"Password": "password",
|
||||
"Database": "erupe"
|
||||
},
|
||||
```
|
||||
After pulling new changes, rebuild and restart. Schema migrations are applied automatically on startup.
|
||||
|
||||
Place this file within ./docker/config.json
|
||||
|
||||
You will need to do the same for your bins place these in ./docker/bin
|
||||
|
||||
# Setting up the web hosted materials
|
||||
Clone the Severs repo into ./docker/Severs
|
||||
|
||||
Make sure your hosts are pointing to where this is hosted
|
||||
|
||||
|
||||
|
||||
## Turning off the server safely
|
||||
```bash
|
||||
docker-compose stop
|
||||
docker compose down
|
||||
docker compose build
|
||||
docker compose up
|
||||
```
|
||||
|
||||
## Turning off the server destructive
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
Make sure if you want to delete your data you delete the folders that persisted
|
||||
- ./docker/savedata
|
||||
- ./docker/db-data
|
||||
## Turning on the server again
|
||||
This boots the db pgadmin and the server in a detached state
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
if you want all the logs and you want it to be in an attached state
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
# Troubleshooting
|
||||
Q: My Postgres will not populate. A: You're setup.sh is maybe saved as CRLF it needs to be saved as LF.
|
||||
**Postgres won't start on Windows**: Ensure `docker/db-data/` doesn't contain stale data from a different PostgreSQL version. Delete it and restart to reinitialize.
|
||||
|
||||
24
docker/docker-compose.test.yml
Normal file
24
docker/docker-compose.test.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
# Docker Compose configuration for running integration tests
|
||||
# Usage: docker-compose -f docker/docker-compose.test.yml up -d
|
||||
services:
|
||||
test-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: erupe-test-db
|
||||
environment:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: erupe_test
|
||||
ports:
|
||||
- "5433:5432" # Different port to avoid conflicts with main DB
|
||||
# Use tmpfs for faster tests (in-memory database)
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
# Mount schema files for initialization
|
||||
volumes:
|
||||
- ../schemas/:/schemas/
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U test -d erupe_test"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
@@ -1,27 +1,24 @@
|
||||
version: "3.9"
|
||||
# 1. docker-compose up db pgadmin
|
||||
# 2. Use pgadmin to restore db and also apply patch-schema
|
||||
# 3. Configure the config.json example. in docker you can point to the service name for the database i.e db
|
||||
# 4. In seperate terminal docker-compose up server
|
||||
# 5. If all went well happy hunting!
|
||||
services:
|
||||
# 1. Copy config.example.json to docker/config.json and edit it
|
||||
# (set Database.Host to "db", adjust password to match below)
|
||||
# 2. Place quest/scenario files in docker/bin/
|
||||
# 3. docker compose up
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
image: postgres:18-alpine
|
||||
environment:
|
||||
# (Make sure these match config.json)
|
||||
# Change this password and match it in docker/config.json
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=password
|
||||
- POSTGRES_DB=erupe
|
||||
ports:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ./db-data/:/var/lib/postgresql/data/
|
||||
- ../schemas/:/schemas/
|
||||
- ./init/setup.sh:/docker-entrypoint-initdb.d/setup.sh
|
||||
- ./db-data/:/var/lib/postgresql/
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
start_period: 5s
|
||||
retries: 5
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
@@ -38,20 +35,22 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# If using prebuilt container change paths and config
|
||||
build:
|
||||
context: ../
|
||||
image: ghcr.io/mezeporta/erupe:main
|
||||
# To build locally instead of using the prebuilt image, comment out
|
||||
# the 'image' line above and uncomment the 'build' section below:
|
||||
# build:
|
||||
# context: ../
|
||||
volumes:
|
||||
- ../config.json:/app/erupe/config.json
|
||||
- ../bin:/app/erupe/bin
|
||||
- ./savedata:/app/erupe/savedata
|
||||
- ./config.json:/app/config.json
|
||||
- ./bin:/app/bin
|
||||
- ./savedata:/app/savedata
|
||||
ports:
|
||||
# (Make sure these match config.json)
|
||||
- "53312:53312" #Sign V1
|
||||
- "8080:8080" #Sign V2
|
||||
- "53310:53310" #Entrance
|
||||
# Channels
|
||||
- "54001:54001"
|
||||
- "54001:54001"
|
||||
- "54002:54002"
|
||||
- "54003:54003"
|
||||
- "54004:54004"
|
||||
@@ -59,13 +58,9 @@ services:
|
||||
- "54006:54006"
|
||||
- "54007:54007"
|
||||
- "54008:54008"
|
||||
web:
|
||||
image: httpd:latest
|
||||
container_name: my-apache-app
|
||||
ports:
|
||||
- '80:80'
|
||||
volumes:
|
||||
- ./Servers:/usr/local/apache2/htdocs
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
start_period: 15s
|
||||
retries: 3
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
echo "INIT!"
|
||||
pg_restore --username="$POSTGRES_USER" --dbname="$POSTGRES_DB" --verbose /schemas/init.sql
|
||||
|
||||
|
||||
|
||||
echo "Updating!"
|
||||
|
||||
for file in /schemas/update-schema/*
|
||||
do
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -1 -f $file
|
||||
done
|
||||
|
||||
|
||||
|
||||
echo "Patching!"
|
||||
|
||||
for file in /schemas/patch-schema/*
|
||||
do
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -1 -f $file
|
||||
done
|
||||
313
docs/anti-patterns.md
Normal file
313
docs/anti-patterns.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Erupe Codebase Anti-Patterns Analysis
|
||||
|
||||
> Analysis date: 2026-02-20
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [1. God Files — Massive Handler Files](#1-god-files--massive-handler-files)
|
||||
- [2. Silently Swallowed Errors](#2-silently-swallowed-errors)
|
||||
- [3. No Architectural Layering](#3-no-architectural-layering--handlers-do-everything)
|
||||
- [4. Magic Numbers Everywhere](#4-magic-numbers-everywhere)
|
||||
- [5. Inconsistent Binary I/O Patterns](#5-inconsistent-binary-io-patterns)
|
||||
- [6. Session God Object](#6-session-struct-is-a-god-object)
|
||||
- [7. Mutex Granularity Issues](#7-mutex-granularity-issues)
|
||||
- [8. Copy-Paste Handler Patterns](#8-copy-paste-handler-patterns)
|
||||
- [9. Raw SQL Scattered in Handlers](#9-raw-sql-strings-scattered-in-handlers)
|
||||
- [10. init() Handler Registration](#10-init-function-for-handler-registration)
|
||||
- [11. Panic-Based Flow](#11-panic-based-flow-in-some-paths)
|
||||
- [12. Inconsistent Logging](#12-inconsistent-logging)
|
||||
- [13. Tight Coupling to PostgreSQL](#13-tight-coupling-to-postgresql)
|
||||
- [Summary](#summary-by-severity)
|
||||
|
||||
---
|
||||
|
||||
## 1. God Files — Massive Handler Files
|
||||
|
||||
The channel server has large handler files, each mixing DB queries, business logic, binary serialization, and response writing with no layering. Actual line counts (non-test files):
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `server/channelserver/handlers_session.go` | 794 | Session setup/teardown |
|
||||
| `server/channelserver/handlers_data_paper_tables.go` | 765 | Paper table data |
|
||||
| `server/channelserver/handlers_quest.go` | 722 | Quest lifecycle |
|
||||
| `server/channelserver/handlers_house.go` | 638 | Housing system |
|
||||
| `server/channelserver/handlers_festa.go` | 637 | Festival events |
|
||||
| `server/channelserver/handlers_data_paper.go` | 621 | Paper/data system |
|
||||
| `server/channelserver/handlers_tower.go` | 529 | Tower gameplay |
|
||||
| `server/channelserver/handlers_mercenary.go` | 495 | Mercenary system |
|
||||
| `server/channelserver/handlers_stage.go` | 492 | Stage/lobby management |
|
||||
| `server/channelserver/handlers_guild_info.go` | 473 | Guild info queries |
|
||||
|
||||
These sizes (~500-800 lines) are not extreme by Go standards, but the files mix all architectural concerns. The bigger problem is the lack of layering within each file (see [#3](#3-no-architectural-layering--handlers-do-everything)), not the file sizes themselves.
|
||||
|
||||
**Impact:** Each handler function is a monolith mixing data access, business logic, and protocol serialization. Testing or reusing any single concern is impossible.
|
||||
|
||||
---
|
||||
|
||||
## 2. Missing ACK Responses on Error Paths (Client Softlocks)
|
||||
|
||||
Some handler error paths log the error and return without sending any ACK response to the client. The MHF client uses `MsgSysAck` with an `ErrorCode` field (0 = success, 1 = failure) to complete request/response cycles. When no ACK is sent at all, the client softlocks waiting for a response that never arrives.
|
||||
|
||||
### The three error handling patterns in the codebase
|
||||
|
||||
**Pattern A — Silent return (the bug):** Error logged, no ACK sent, client hangs.
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get ...", zap.Error(err))
|
||||
return // BUG: client gets no response, softlocks
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern B — Log and continue (acceptable):** Error logged, handler continues and sends a success ACK with default/empty data. The client proceeds with fallback behavior.
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to load mezfes data", zap.Error(err))
|
||||
}
|
||||
// Falls through to doAckBufSucceed with empty data
|
||||
```
|
||||
|
||||
**Pattern C — Fail ACK (correct):** Error logged, explicit fail ACK sent. The client shows an appropriate error dialog and stays connected.
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to read rengoku_data.bin", zap.Error(err))
|
||||
doAckBufFail(s, pkt.AckHandle, nil)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### Evidence that fail ACKs are safe
|
||||
|
||||
The codebase already sends ~70 `doAckSimpleFail`/`doAckBufFail` calls in production handler code across 15 files. The client handles them gracefully in all observed cases:
|
||||
|
||||
| File | Fail ACKs | Client behavior |
|
||||
|------|-----------|-----------------|
|
||||
| `handlers_guild_scout.go` | 17 | Guild recruitment error dialogs |
|
||||
| `handlers_guild_ops.go` | 10 | Permission denied, guild not found dialogs |
|
||||
| `handlers_stage.go` | 8 | "Room is full", "wrong password", "stage locked" |
|
||||
| `handlers_house.go` | 6 | Wrong password, invalid box index |
|
||||
| `handlers_guild.go` | 9 | Guild icon update errors, unimplemented features |
|
||||
| `handlers_guild_alliance.go` | 4 | Alliance permission errors |
|
||||
| `handlers_data.go` | 4 | Decompression failures, oversized payloads |
|
||||
| `handlers_festa.go` | 4 | Festival entry errors |
|
||||
| `handlers_quest.go` | 3 | Missing quest/scenario files |
|
||||
|
||||
A comment in `handlers_quest.go:188` explicitly documents the mechanism:
|
||||
|
||||
> sends doAckBufFail, which triggers the client's error dialog (snj_questd_matching_fail → SetDialogData) instead of a softlock
|
||||
|
||||
The original `mhfo-hd.dll` client reads the `ErrorCode` byte from `MsgSysAck` and dispatches to per-message error UI. A fail ACK causes the client to show an error dialog and remain functional. A missing ACK causes a softlock.
|
||||
|
||||
### Scope
|
||||
|
||||
A preliminary grep for `logger.Error` followed by bare `return` (no doAck call) found instances across ~25 handler files. However, a thorough manual audit (2026-02-20) revealed that the vast majority are Pattern B (log-and-continue to a success ACK with empty data) or Pattern C (explicit fail ACK). Only one true Pattern A instance was found, in `handleMsgSysOperateRegister` (`handlers_register.go`), which has been fixed.
|
||||
|
||||
**Status:** ~~Players experience softlocks on error paths.~~ **Fixed.** The last Pattern A instance (`handlers_register.go:62`) now sends `doAckBufSucceed` with nil data before returning. The ~87 existing `doAckSimpleFail`/`doAckBufFail` calls and the helper functions (`loadCharacterData`, `saveCharacterData`, `stubEnumerateNoResults`) provide comprehensive ACK coverage across all handler error paths.
|
||||
|
||||
---
|
||||
|
||||
## 3. No Architectural Layering — Handlers Do Everything
|
||||
|
||||
Handler functions directly embed raw SQL, binary parsing, business logic, and response building in a single function body. For example, a typical guild handler will:
|
||||
|
||||
1. Parse the incoming packet
|
||||
2. Run 3-5 inline SQL queries
|
||||
3. Apply business logic (permission checks, state transitions)
|
||||
4. Manually serialize a binary response
|
||||
|
||||
```go
|
||||
func handleMsgMhfCreateGuild(s *Session, p mhfpacket.MHFPacket) {
|
||||
pkt := p.(*mhfpacket.MsgMhfCreateGuild)
|
||||
|
||||
// Direct SQL in the handler
|
||||
var guildCount int
|
||||
err := s.Server.DB.QueryRow("SELECT count(*) FROM guilds WHERE leader_id=$1", s.CharID).Scan(&guildCount)
|
||||
if err != nil {
|
||||
s.logger.Error(...)
|
||||
return
|
||||
}
|
||||
|
||||
// Business logic inline
|
||||
if guildCount > 0 { ... }
|
||||
|
||||
// More SQL
|
||||
_, err = s.Server.DB.Exec("INSERT INTO guilds ...")
|
||||
|
||||
// Binary response building
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint32(...)
|
||||
doAckSimpleSucceed(s, pkt.AckHandle, bf.Data())
|
||||
}
|
||||
```
|
||||
|
||||
There is no repository layer, no service layer — just handlers.
|
||||
|
||||
**Impact:** Testing individual concerns is impossible without a real database and a full session. Business logic can't be reused. Schema changes require updating dozens of handler files.
|
||||
|
||||
**Recommendation:** Introduce at minimum a repository layer for data access and a service layer for business logic. Handlers should only deal with packet parsing and response serialization.
|
||||
|
||||
---
|
||||
|
||||
## 4. ~~Magic Numbers Everywhere~~ (Substantially Fixed)
|
||||
|
||||
**Status:** Two rounds of extraction have replaced the highest-impact magic numbers with named constants:
|
||||
|
||||
- **Round 1** (commit `7c444b0`): `constants_quest.go`, `handlers_guild_info.go`, `handlers_quest.go`, `handlers_rengoku.go`, `handlers_session.go`, `model_character.go`
|
||||
- **Round 2**: `constants_time.go` (shared `secsPerDay`, `secsPerWeek`), `constants_raviente.go` (register IDs, semaphore constants), plus constants in `handlers_register.go`, `handlers_semaphore.go`, `handlers_session.go`, `handlers_festa.go`, `handlers_diva.go`, `handlers_event.go`, `handlers_mercenary.go`, `handlers_misc.go`, `handlers_plate.go`, `handlers_cast_binary.go`, `handlers_commands.go`, `handlers_reward.go`, `handlers_guild_mission.go`, `sys_channel_server.go`
|
||||
|
||||
**Remaining:** Unknown protocol fields (e.g., `handlers_diva.go:112-115` `0x19, 0x2D, 0x02, 0x02`) are intentionally left as literals until their meaning is understood. Data tables (monster point tables, item IDs) are data, not protocol constants. Standard empty ACK payloads (`make([]byte, 4)`) are idiomatic Go.
|
||||
|
||||
**Impact:** ~~New contributors can't understand what these values mean.~~ Most protocol-meaningful constants now have names and comments.
|
||||
|
||||
---
|
||||
|
||||
## 5. ~~Inconsistent Binary I/O Patterns~~ (Resolved)
|
||||
|
||||
**Status:** Non-issue on closer inspection. The codebase has already standardized on `byteframe` for all sequential packet building and parsing.
|
||||
|
||||
The 12 remaining `encoding/binary` call sites (across `sys_session.go`, `handlers_session.go`, `model_character.go`, `handlers_quest.go`, `handlers_rengoku.go`) are all cases where `byteframe` is structurally wrong:
|
||||
|
||||
- **Zero-allocation spot-reads on existing `[]byte`** — reading an opcode or ack handle from an already-serialized packet for logging, or sentinel guard checks on raw blobs. Allocating a byteframe for a 2-byte read in a log path would be wasteful.
|
||||
- **Random-access reads/writes at computed offsets** — patching fields in the decompressed game save blob (`model_character.go`) or copying fields within quest binaries during version backport (`handlers_quest.go`). Byteframe is a sequential cursor and cannot do `buf[offset:offset+4]` style access.
|
||||
|
||||
Pattern C (raw `data[i] = byte(...)` serialization) does not exist in production code — only in test fixtures as loop fills for dummy payloads.
|
||||
|
||||
---
|
||||
|
||||
## 6. ~~Session Struct is a God Object~~ (Accepted Design)
|
||||
|
||||
`sys_session.go` defines a `Session` struct (~30 fields) that every handler receives. After analysis, this is accepted as appropriate design for this codebase:
|
||||
|
||||
- **Field clustering is natural:** The ~30 fields cluster into 7 groups (transport, identity, stage, semaphore, gameplay, mail, debug). Transport fields (`rawConn`, `cryptConn`, `sendPackets`) are only used by `sys_session.go` — already isolated. Stage, semaphore, and mail fields are each used by 1-5 dedicated handlers.
|
||||
- **Core identity is pervasive:** `charID` is used by 38 handlers — it's the core identity field. Extracting it adds indirection for zero benefit.
|
||||
- **`s.server` coupling is genuine:** Handlers need 2-5 repos + config + broadcast, so narrower interfaces would mirror the full server without meaningful decoupling.
|
||||
- **Cross-channel operations use `Registry`:** The `Channels []*Server` field has been removed. All cross-channel operations (worldcast, session lookup, disconnect, stage search, mail notification) now go exclusively through the `ChannelRegistry` interface, removing the last direct inter-server coupling.
|
||||
- **Standard game server pattern:** For a game server emulator with the `func(s *Session, p MHFPacket)` handler pattern, Session carrying identity + server reference is standard design.
|
||||
|
||||
**Status:** Accepted design. The `Channels` field was removed and all cross-channel operations are routed through `ChannelRegistry`. No further refactoring planned.
|
||||
|
||||
---
|
||||
|
||||
## 7. ~~Mutex Granularity Issues~~ (Stage Map Fixed)
|
||||
|
||||
~~`sys_stage.go` and `sys_channel_server.go` use coarse-grained `sync.RWMutex` locks on entire maps:~~
|
||||
|
||||
```go
|
||||
// A single lock for ALL stages
|
||||
s.stageMapLock.Lock()
|
||||
defer s.stageMapLock.Unlock()
|
||||
// Any operation on any stage blocks all other stage operations
|
||||
```
|
||||
|
||||
The Raviente shared state uses a single mutex for all Raviente data fields.
|
||||
|
||||
**Status:** **Partially fixed.** The global `stagesLock sync.RWMutex` + `map[string]*Stage` has been replaced with a typed `StageMap` wrapper around `sync.Map`, providing lock-free reads and concurrent writes to disjoint keys. Per-stage `sync.RWMutex` remains for protecting individual stage state. The Raviente mutex is unchanged — contention is inherently low (single world event, few concurrent accessors).
|
||||
|
||||
---
|
||||
|
||||
## 8. Copy-Paste Handler Patterns
|
||||
|
||||
~~Many handlers follow an identical template with minor variations but no shared abstraction.~~ **Substantially fixed.** `loadCharacterData` and `saveCharacterData` helpers in `handlers_helpers.go` now cover all standard character blob load/save patterns (11 load handlers, 6 save handlers including `handleMsgMhfSaveScenarioData`). The `saveCharacterData` helper sends `doAckSimpleFail` on oversized payloads and DB errors, matching the correct error-handling pattern.
|
||||
|
||||
Remaining inline DB patterns were audited and are genuinely different (non-blob types, wrong tables, diff compression, read-modify-write with bit ops, multi-column updates, or queries against other characters).
|
||||
|
||||
---
|
||||
|
||||
## 9. Raw SQL Strings Scattered in Handlers
|
||||
|
||||
SQL queries are string literals directly embedded in handler functions with no constants, no query builder, and no repository abstraction:
|
||||
|
||||
```go
|
||||
err := s.Server.DB.QueryRow(
|
||||
"SELECT id, name, leader_id, ... FROM guilds WHERE id=$1", guildID,
|
||||
).Scan(&id, &name, &leaderID, ...)
|
||||
```
|
||||
|
||||
The same table is queried in different handlers with slightly different column sets and joins.
|
||||
|
||||
**Impact:** Schema changes (renaming a column, adding a field) require finding and updating every handler that touches that table. There's no way to ensure all queries stay in sync. SQL injection risk is low (parameterized queries are used), but query correctness is hard to verify.
|
||||
|
||||
**Recommendation:** At minimum, define query constants. Ideally, introduce a repository layer that encapsulates all queries for a given entity.
|
||||
|
||||
**Status:** ~~Substantially fixed.~~ ~~Nearly complete.~~ **Complete.** 21 repository files now cover all major subsystems: character, guild, user, house, tower, festa, mail, rengoku, stamp, distribution, session, gacha, event, achievement, shop, cafe, goocoo, diva, misc, scenario, mercenary. All guild subsystem tables (`guild_posts`, `guild_adventures`, `guild_meals`, `guild_hunts`, `guild_hunts_claimed`, `guild_alliances`) are fully migrated into `repo_guild.go`. Zero inline SQL queries remain in handler files — the last 5 were migrated to `charRepo.LoadSaveData`, `userRepo.BanUser`, `eventRepo.GetEventQuests`, and `eventRepo.UpdateEventQuestStartTimes`.
|
||||
|
||||
---
|
||||
|
||||
## 10. init() Function for Handler Registration
|
||||
|
||||
`handlers_table.go` uses a massive `init()` function to register ~200+ handlers in a global map:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
handlers[network.MsgMhfSaveFoo] = handleMsgMhfSaveFoo
|
||||
handlers[network.MsgMhfLoadFoo] = handleMsgMhfLoadFoo
|
||||
// ... 200+ more entries
|
||||
}
|
||||
```
|
||||
|
||||
**Impact:** Registration is implicit and happens at package load time. It's impossible to selectively register handlers (e.g., for testing). The handler map can't be mocked. The `init()` function is ~200+ lines of boilerplate.
|
||||
|
||||
**Recommendation:** Use explicit registration (a function called from `main` or server setup) that builds and returns the handler map.
|
||||
|
||||
---
|
||||
|
||||
## 11. Panic-Based Flow in Some Paths
|
||||
|
||||
~~Some error paths use `panic()` or `log.Fatal()` (which calls `os.Exit`) instead of returning errors.~~ **Substantially fixed.** The 5 production `panic()` calls (4 in mhfpacket `Build()` stubs, 1 in binpacket `Parse()`) have been replaced with `fmt.Errorf` returns. The `byteframe.go` read-overflow panic has been replaced with a sticky error pattern (`ByteFrame.Err()`), and the packet dispatch loop in `sys_session.go` now checks `bf.Err()` after parsing to reject malformed packets cleanly.
|
||||
|
||||
**Remaining:** The `recover()` in `handlePacketGroup` is retained as a safety net for any future unexpected panics.
|
||||
|
||||
---
|
||||
|
||||
## 12. Inconsistent Logging
|
||||
|
||||
The codebase mixes logging approaches:
|
||||
|
||||
- `zap.Logger` (structured logging) — primary approach
|
||||
- Remnants of `fmt.Printf` / `log.Printf` in some packages
|
||||
- Some packages accept a logger parameter, others create their own
|
||||
|
||||
**Impact:** Log output format is inconsistent. Some logs lack structure (no fields, no levels). Filtering and aggregation in production is harder.
|
||||
|
||||
**Recommendation:** Standardize on `zap.Logger` everywhere. Pass the logger via dependency injection. Remove all `fmt.Printf` / `log.Printf` usage from non-CLI code.
|
||||
|
||||
---
|
||||
|
||||
## 13. ~~Tight Coupling to PostgreSQL~~ (Decoupled via Interfaces)
|
||||
|
||||
~~Database operations use raw `database/sql` with PostgreSQL-specific syntax throughout:~~
|
||||
|
||||
- ~~`$1` parameter placeholders (PostgreSQL-specific)~~
|
||||
- ~~PostgreSQL-specific types and functions in queries~~
|
||||
- ~~`*sql.DB` passed directly through the server struct to every handler~~
|
||||
- ~~No interface abstraction over data access~~
|
||||
|
||||
**Status:** **Fixed.** All 20 repository interfaces are defined in `repo_interfaces.go` (`CharacterRepo`, `GuildRepo`, `UserRepo`, `GachaRepo`, `HouseRepo`, `FestaRepo`, `TowerRepo`, `RengokuRepo`, `MailRepo`, `StampRepo`, `DistributionRepo`, `SessionRepo`, `EventRepo`, `AchievementRepo`, `ShopRepo`, `CafeRepo`, `GoocooRepo`, `DivaRepo`, `MiscRepo`, `ScenarioRepo`, `MercenaryRepo`). The `Server` struct holds interface types, not concrete types. Mock implementations in `repo_mocks_test.go` enable handler unit tests without PostgreSQL. SQL is still PostgreSQL-specific within the concrete `*Repository` types, but handlers are fully decoupled from the database.
|
||||
|
||||
---
|
||||
|
||||
## Summary by Severity
|
||||
|
||||
| Severity | Anti-patterns |
|
||||
|----------|--------------|
|
||||
| **High** | ~~Missing ACK responses / softlocks (#2)~~ **Fixed**, no architectural layering (#3), ~~tight DB coupling (#13)~~ **Fixed** (21 interfaces + mocks) |
|
||||
| **Medium** | ~~Magic numbers (#4)~~ **Fixed**, ~~inconsistent binary I/O (#5)~~ **Resolved**, ~~Session god object (#6)~~ **Accepted design** (Channels removed, Registry-only), ~~copy-paste handlers (#8)~~ **Fixed**, ~~raw SQL duplication (#9)~~ **Complete** (21 repos, 0 inline queries remain) |
|
||||
| **Low** | God files (#1), ~~`init()` registration (#10)~~ **Fixed**, ~~inconsistent logging (#12)~~ **Fixed**, ~~mutex granularity (#7)~~ **Partially fixed** (stage map done, Raviente unchanged), ~~panic-based flow (#11)~~ **Fixed** |
|
||||
|
||||
### Root Cause
|
||||
|
||||
Most of these anti-patterns stem from a single root cause: **the codebase grew organically from a protocol reverse-engineering effort without introducing architectural boundaries**. When the primary goal is "make this packet work," it's natural to put the SQL, logic, and response all in one function. Over time, this produces the pattern seen here — hundreds of handler functions that each independently implement the full stack.
|
||||
|
||||
### Recommended Refactoring Priority
|
||||
|
||||
1. ~~**Add fail ACKs to silent error paths**~~ — **Done** (see #2)
|
||||
2. ~~**Extract a character repository layer**~~ — **Done.** `repo_character.go` covers ~95%+ of character queries
|
||||
3. ~~**Extract load/save helpers**~~ — **Done.** `loadCharacterData`/`saveCharacterData` in `handlers_helpers.go`
|
||||
4. ~~**Extract a guild repository layer**~~ — **Done.** `repo_guild.go` covers all guild tables including subsystem tables
|
||||
5. ~~**Define protocol constants**~~ — **Done** (see #4)
|
||||
6. ~~**Standardize binary I/O**~~ — already standardized on `byteframe`; remaining `encoding/binary` uses are correct (see #5)
|
||||
7. ~~**Migrate last 5 inline queries**~~ — **Done.** Migrated to `charRepo.LoadSaveData`, `userRepo.BanUser`, `eventRepo.GetEventQuests`, `eventRepo.UpdateEventQuestStartTimes`
|
||||
8. ~~**Introduce repository interfaces**~~ — **Done.** 20 interfaces in `repo_interfaces.go`, mock implementations in `repo_mocks_test.go`, `Server` struct uses interface types
|
||||
9. ~~**Reduce mutex contention**~~ — **Done.** `StageMap` (`sync.Map`-backed) replaces global `stagesLock`. Raviente mutex unchanged (low contention)
|
||||
142
docs/improvements.md
Normal file
142
docs/improvements.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Erupe Improvement Plan
|
||||
|
||||
> Analysis date: 2026-02-24
|
||||
|
||||
Actionable improvements identified during a codebase audit. Items are ordered by priority and designed to be tackled sequentially. Complements `anti-patterns.md` and `technical-debt.md`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [1. Fix swallowed errors with nil-dereference risk](#1-fix-swallowed-errors-with-nil-dereference-risk)
|
||||
- [2. Fix bookshelf data pointer for three game versions](#2-fix-bookshelf-data-pointer-for-three-game-versions)
|
||||
- [3. Add error feedback to parseChatCommand](#3-add-error-feedback-to-parsechatcommand)
|
||||
- [4. Reconcile service layer docs vs reality](#4-reconcile-service-layer-docs-vs-reality)
|
||||
- [5. Consolidate GuildRepo mocks](#5-consolidate-guildrepo-mocks)
|
||||
- [6. Add mocks for 8 unmocked repo interfaces](#6-add-mocks-for-8-unmocked-repo-interfaces)
|
||||
- [7. Extract inline data tables from handler functions](#7-extract-inline-data-tables-from-handler-functions)
|
||||
|
||||
---
|
||||
|
||||
## 1. Fix swallowed errors with nil-dereference risk
|
||||
|
||||
**Priority:** High — latent panics triggered by any DB hiccup.
|
||||
|
||||
~30 sites use `_, _` to discard repo/service errors. Three are dangerous because the returned value is used without a nil guard:
|
||||
|
||||
| Location | Risk |
|
||||
|----------|------|
|
||||
| `handlers_guild_adventure.go:24,48,73` | `guild, _ := guildRepo.GetByCharID(...)` — no nil guard, will panic on DB error |
|
||||
| `handlers_gacha.go:56` | `fp, gp, gt, _ := userRepo.GetGachaPoints(...)` — balance silently becomes 0, enabling invalid transactions |
|
||||
| `handlers_house.go:167` | 7 return values from `GetHouseContents`, error discarded entirely |
|
||||
|
||||
Additional sites that don't panic but produce silently wrong data:
|
||||
|
||||
| Location | Issue |
|
||||
|----------|-------|
|
||||
| `handlers_distitem.go:35,111,129` | `distRepo.List()`/`GetItems()` errors become empty results, no logging |
|
||||
| `handlers_guild_ops.go:30,49` | `guildService.Disband()`/`Leave()` errors swallowed (nil-safe due to `result != nil` guard, but invisible failures) |
|
||||
| `handlers_shop.go:125,131` | Gacha type/weight lookups discarded |
|
||||
| `handlers_discord.go:34` | `bcrypt.GenerateFromPassword` error swallowed (only fails on OOM) |
|
||||
|
||||
**Fix:** Add error checks with logging and appropriate fail ACKs. For the three high-risk sites, add nil guards at minimum.
|
||||
|
||||
**Status:** **Done.** All swallowed errors fixed across 7 files:
|
||||
|
||||
- `handlers_guild_adventure.go` — 3 `GetByCharID` calls now check error and nil, early-return with ACK
|
||||
- `handlers_gacha.go` — `GetGachaPoints` now checks error and returns zeroed response; `GetStepupStatus` logs error
|
||||
- `handlers_house.go` — `GetHouseContents` now checks error and sends fail ACK; `HasApplication` moved inside nil guard to prevent nil dereference on `ownGuild`; `GetMission` and `GetWarehouseNames` now log errors
|
||||
- `handlers_distitem.go` — 3 `distRepo` calls now log errors
|
||||
- `handlers_guild_ops.go` — `Disband` and `Leave` service errors now logged
|
||||
- `handlers_shop.go` — `GetShopType`, `GetWeightDivisor`, `GetFpointExchangeList` now log errors
|
||||
- `handlers_discord.go` — `bcrypt.GenerateFromPassword` error now returns early with user-facing message
|
||||
|
||||
---
|
||||
|
||||
## 2. Fix bookshelf data pointer for three game versions
|
||||
|
||||
**Priority:** High — corrupts character save reads.
|
||||
|
||||
From `technical-debt.md`: `model_character.go:88,101,113` has `TODO: fix bookshelf data pointer` for G10-ZZ, F4-F5, and S6 versions. All three offsets are off by exactly 14810 vs the consistent delta pattern of other fields. Needs validation against actual save data.
|
||||
|
||||
**Fix:** Analyze save data from affected game versions to determine correct offsets. Apply fix and add regression test.
|
||||
|
||||
**Status:** Pending.
|
||||
|
||||
---
|
||||
|
||||
## 3. Add error feedback to parseChatCommand
|
||||
|
||||
**Priority:** Medium — improves operator experience with low effort.
|
||||
|
||||
`handlers_commands.go:71` is a 351-line switch statement dispatching 12 chat commands. Argument parsing errors (`strconv`, `hex.DecodeString`) are silently swallowed at lines 240, 256, 368, 369. Malformed commands silently use zero values instead of giving the operator feedback.
|
||||
|
||||
**Fix:** On parse error, send a chat message back to the player explaining the expected format, then return early. Each command's branch already has access to the session for sending messages.
|
||||
|
||||
**Status:** **Done.** All 4 sites now validate parse results and send the existing i18n error messages:
|
||||
|
||||
- `hex.DecodeString` (KeyQuest set) — sends kqf.set.error on invalid hex
|
||||
- `strconv.Atoi` (Rights) — sends rights.error on non-integer
|
||||
- `strconv.ParseInt` x/y (Teleport) — sends teleport.error on non-integer coords
|
||||
|
||||
---
|
||||
|
||||
## 4. Reconcile service layer docs vs reality
|
||||
|
||||
**Priority:** Medium — documentation mismatch causes confusion for contributors.
|
||||
|
||||
The CLAUDE.md architecture section shows a clean `handlers → svc_*.go → repo_*.go` layering, but in practice:
|
||||
|
||||
- **GuildService** has 7 methods. **GuildRepo** has 68. Handlers call `guildRepo` directly ~60+ times across 7 guild handler files.
|
||||
- The 4 services (`GuildService`, `MailService`, `AchievementService`, `GachaService`) were extracted for operations requiring cross-repo coordination (e.g., disband triggers mail), but the majority of handler logic goes directly to repos.
|
||||
|
||||
This isn't necessarily wrong — the services exist for multi-repo coordination, not as a mandatory pass-through.
|
||||
|
||||
**Fix:** Update the architecture diagram in `CLAUDE.md` to reflect the actual pattern: services are used for cross-repo coordination, handlers call repos directly for simple CRUD. Remove the implication that all handlers go through services. Alternatively, expand service coverage to match the documented architecture, but that is a much larger effort with diminishing returns.
|
||||
|
||||
**Status:** **Done.** Updated three files:
|
||||
|
||||
- `Erupe/CLAUDE.md` — Layered architecture diagram clarified ("where needed"), handler description updated to explain when to use services vs direct repo calls, added services table listing all 6 services with method counts and purpose, added "Adding Business Logic" section with guidelines
|
||||
- `server/CLAUDE.md` — Repository Pattern section renamed to "Repository & Service Pattern", added service layer summary with the 6 services listed
|
||||
- `docs/improvements.md` — This item marked as done
|
||||
|
||||
---
|
||||
|
||||
## 5. Consolidate GuildRepo mocks
|
||||
|
||||
**Priority:** Low — reduces friction for guild test authoring.
|
||||
|
||||
`repo_mocks_test.go` (1004 lines) has two separate GuildRepo mock types:
|
||||
|
||||
- `mockGuildRepoForMail` (67 methods, 104 lines) — used by mail tests
|
||||
- `mockGuildRepoOps` (38 methods, 266 lines) — used by ops/scout tests, with configurable behavior via struct fields
|
||||
|
||||
The `GuildRepo` interface has 68 methods. Neither mock implements the full interface. Adding any new `GuildRepo` method requires updating both mocks or compilation fails.
|
||||
|
||||
**Fix:** Merge into a single `mockGuildRepo` with all 68 methods as no-op defaults. Use struct fields (as `mockGuildRepoOps` already does for ~15 methods) for configurable returns in tests that need specific behavior.
|
||||
|
||||
**Status:** **Done.** Merged into a single `mockGuildRepo` (936 lines, down from 1004). All 12 test files updated. Adding a new `GuildRepo` method now requires a single stub addition.
|
||||
|
||||
---
|
||||
|
||||
## 6. Add mocks for 8 unmocked repo interfaces
|
||||
|
||||
**Priority:** Low — enables isolated handler tests for more subsystems.
|
||||
|
||||
8 of the 21 repo interfaces have no mock implementation: `TowerRepo`, `FestaRepo`, `RengokuRepo`, `DivaRepo`, `EventRepo`, `MiscRepo`, `MercenaryRepo`, `CafeRepo`.
|
||||
|
||||
Tests for those handlers either use stub handlers that skip repos or rely on integration tests. This limits the ability to write isolated unit tests.
|
||||
|
||||
**Fix:** Add no-op mock implementations for each, following the pattern established by existing mocks.
|
||||
|
||||
**Status:** **Done.** Added 8 mock implementations to `repo_mocks_test.go`: `mockTowerRepo`, `mockFestaRepo`, `mockRengokuRepo`, `mockDivaRepo`, `mockEventRepo`, `mockMiscRepo`, `mockMercenaryRepo`, `mockCafeRepo`. All follow the established pattern with no-op defaults and configurable struct fields for return values and errors.
|
||||
|
||||
---
|
||||
|
||||
## 7. Extract inline data tables from handler functions
|
||||
|
||||
**Priority:** Low — improves readability.
|
||||
|
||||
`handlers_items.go:18` — `handleMsgMhfEnumeratePrice` (164 lines) embeds two large `var` data blocks inline in the function body. These are static data tables, not logic.
|
||||
|
||||
**Fix:** Extract to package-level `var` declarations or a dedicated data file (following the pattern of `handlers_data_paper_tables.go`).
|
||||
|
||||
**Status:** **Done.** Extracted 3 inline data tables (LB prices, wanted list, GZ prices) and their anonymous struct types to `handlers_items_tables.go`. Handler function reduced from 164 to 35 lines.
|
||||
96
docs/technical-debt.md
Normal file
96
docs/technical-debt.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Erupe Technical Debt & Suggested Next Steps
|
||||
|
||||
> Last updated: 2026-02-22
|
||||
|
||||
This document tracks actionable technical debt items discovered during a codebase audit. It complements `anti-patterns.md` (which covers structural patterns) by focusing on specific, fixable items with file paths and line numbers.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [High Priority](#high-priority)
|
||||
- [1. Broken game features (gameplay-impacting TODOs)](#1-broken-game-features-gameplay-impacting-todos)
|
||||
- [2. Test gaps on critical paths](#2-test-gaps-on-critical-paths)
|
||||
- [Medium Priority](#medium-priority)
|
||||
- [3. Logging anti-patterns](#3-logging-anti-patterns)
|
||||
- [Low Priority](#low-priority)
|
||||
- [4. CI updates](#4-ci-updates)
|
||||
- [Completed Items](#completed-items)
|
||||
- [Suggested Execution Order](#suggested-execution-order)
|
||||
|
||||
---
|
||||
|
||||
## High Priority
|
||||
|
||||
### 1. Broken game features (gameplay-impacting TODOs)
|
||||
|
||||
These TODOs represent features that are visibly broken for players.
|
||||
|
||||
| Location | Issue | Impact |
|
||||
|----------|-------|--------|
|
||||
| `model_character.go:88,101,113` | `TODO: fix bookshelf data pointer` for G10-ZZ, F4-F5, and S6 versions | Wrong pointer corrupts character save reads for three game versions. Offset analysis shows all three are off by exactly 14810 vs the consistent delta pattern of other fields — but needs validation against actual save data. |
|
||||
| `handlers_achievement.go:125` | `TODO: Notify on rank increase` — always returns `false` | Achievement rank-up notifications are silently suppressed. Requires understanding what `MhfDisplayedAchievement` (currently an empty handler) sends to track "last displayed" state. |
|
||||
| `handlers_guild_info.go:443` | `TODO: Enable GuildAlliance applications` — hardcoded `true` | Guild alliance applications are always open regardless of setting. Needs research into where the toggle originates. |
|
||||
| `handlers_session.go:394` | `TODO(Andoryuuta): log key index off-by-one` | Known off-by-one in log key indexing is unresolved |
|
||||
| `handlers_session.go:535` | `TODO: This case might be <=G2` | Uncertain version detection in switch case |
|
||||
| `handlers_session.go:698` | `TODO: Retail returned the number of clients in quests` | Player count reported to clients does not match retail behavior |
|
||||
|
||||
### 2. Test gaps on critical paths
|
||||
|
||||
**All handler files now have test coverage.**
|
||||
|
||||
**Repository files with no store-level test file (17 total):**
|
||||
|
||||
`repo_achievement.go`, `repo_cafe.go`, `repo_distribution.go`, `repo_diva.go`, `repo_festa.go`, `repo_gacha.go`, `repo_goocoo.go`, `repo_house.go`, `repo_mail.go`, `repo_mercenary.go`, `repo_misc.go`, `repo_rengoku.go`, `repo_scenario.go`, `repo_session.go`, `repo_shop.go`, `repo_stamp.go`, `repo_tower.go`
|
||||
|
||||
These are validated indirectly through mock-based handler tests but have no SQL-level integration tests.
|
||||
|
||||
---
|
||||
|
||||
## Medium Priority
|
||||
|
||||
### 3. Logging anti-patterns
|
||||
|
||||
~~**a) `fmt.Sprintf` inside structured logger calls (6 sites):**~~ **Fixed.** All 6 sites now use `zap.Uint32`/`zap.Uint8`/`zap.String` structured fields instead of `fmt.Sprintf`.
|
||||
|
||||
~~**b) 20+ silently discarded SJIS encoding errors in packet parsing:**~~ **Fixed.** All call sites now use `SJISToUTF8Lossy()` which logs decode errors at `slog.Debug` level.
|
||||
|
||||
---
|
||||
|
||||
## Low Priority
|
||||
|
||||
### 4. CI updates
|
||||
|
||||
- ~~`codecov-action@v4` could be updated to `v5` (current stable)~~ **Removed.** Replaced with local `go tool cover` threshold check (no Codecov account needed).
|
||||
- ~~No coverage threshold is enforced — coverage is uploaded but regressions aren't caught~~ **Fixed.** CI now fails if total coverage drops below 50% (current: ~58%).
|
||||
|
||||
---
|
||||
|
||||
## Completed Items
|
||||
|
||||
Items resolved since the original audit:
|
||||
|
||||
| # | Item | Resolution |
|
||||
|---|------|------------|
|
||||
| ~~3~~ | **Sign server has no repository layer** | Fully refactored with `repo_interfaces.go`, `repo_user.go`, `repo_session.go`, `repo_character.go`, and mock tests. All 8 previously-discarded error paths are now handled. |
|
||||
| ~~4~~ | **Split `repo_guild.go`** | Split from 1004 lines into domain-focused files: `repo_guild.go` (466 lines, core CRUD), `repo_guild_posts.go`, `repo_guild_alliance.go`, `repo_guild_adventure.go`, `repo_guild_hunt.go`, `repo_guild_cooking.go`, `repo_guild_rp.go`. |
|
||||
| ~~6~~ | **Inconsistent transaction API** | All call sites now use `BeginTxx(context.Background(), nil)` with deferred rollback. |
|
||||
| ~~7~~ | **`LoopDelay` config has no Viper default** | `viper.SetDefault("LoopDelay", 50)` added in `config/config.go`. |
|
||||
| — | **Monthly guild item claim** (`handlers_guild.go:389`) | Now tracks per-character per-type monthly claims via `stamps` table. |
|
||||
| — | **Handler test coverage (4 files)** | Tests added for `handlers_session.go`, `handlers_gacha.go`, `handlers_plate.go`, `handlers_shop.go`. |
|
||||
| — | **Handler test coverage (`handlers_commands.go`)** | 62 tests covering all 12 commands, disabled-command gating, op overrides, error paths, raviente with semaphore, course enable/disable/locked, reload with players/objects. |
|
||||
| — | **Handler test coverage (`handlers_data_paper.go`)** | 20 tests covering all DataType branches (0/5/6/gift/>1000/unknown), ACK payload structure, earth succeed entry counts, timetable content, serialization round-trips, and paperGiftData table integrity. |
|
||||
| — | **Handler test coverage (5 files)** | Tests added for `handlers_seibattle.go` (9 tests), `handlers_kouryou.go` (7 tests), `handlers_scenario.go` (6 tests), `handlers_distitem.go` (8 tests), `handlers_guild_mission.go` (5 tests in coverage5). |
|
||||
| — | **Entrance server raw SQL** | Refactored to repository interfaces (`repo_interfaces.go`, `repo_session.go`, `repo_server.go`). |
|
||||
| — | **Guild daily RP rollover** (`handlers_guild_ops.go:148`) | Implemented via lazy rollover in `handlers_guild.go:110-119` using `RolloverDailyRP()`. Stale TODO removed. |
|
||||
| — | **Typos** (`sys_session.go`, `handlers_session.go`) | "For Debuging" and "offical" typos already fixed in previous commits. |
|
||||
| — | **`db != nil` guard** (`handlers_session.go:322`) | Investigated — this guard is intentional. Test servers run without repos; the guard protects the entire logout path from nil repo dereferences. Not a leaky abstraction. |
|
||||
|
||||
---
|
||||
|
||||
## Suggested Execution Order
|
||||
|
||||
Based on remaining impact:
|
||||
|
||||
1. ~~**Add tests for `handlers_commands.go`**~~ — **Done.** 62 tests covering all 12 commands (ban, timer, PSN, reload, key quest, rights, course, raviente, teleport, discord, playtime, help), disabled-command gating, op overrides, error paths, and `initCommands`.
|
||||
2. **Fix bookshelf data pointer** (`model_character.go`) — corrupts saves for three game versions (needs save data validation)
|
||||
3. **Fix achievement rank-up notifications** (`handlers_achievement.go:125`) — needs protocol research on `MhfDisplayedAchievement`
|
||||
4. ~~**Add coverage threshold** to CI~~ — **Done.** 50% floor enforced via `go tool cover` in CI; Codecov removed.
|
||||
13
go.mod
13
go.mod
@@ -1,6 +1,6 @@
|
||||
module erupe-ce
|
||||
|
||||
go 1.23.0
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.27.1
|
||||
@@ -10,12 +10,12 @@ require (
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/spf13/viper v1.17.0
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa
|
||||
golang.org/x/text v0.23.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.1 // indirect
|
||||
@@ -31,8 +31,9 @@ require (
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
19
go.sum
19
go.sum
@@ -38,6 +38,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
|
||||
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
@@ -143,6 +145,7 @@ github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Cc
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -220,8 +223,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -289,8 +292,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -345,8 +348,8 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -356,8 +359,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
|
||||
199
main.go
199
main.go
@@ -1,20 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_config "erupe-ce/config"
|
||||
cfg "erupe-ce/config"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"erupe-ce/common/gametime"
|
||||
"erupe-ce/server/api"
|
||||
"erupe-ce/server/channelserver"
|
||||
"erupe-ce/server/discordbot"
|
||||
"erupe-ce/server/entranceserver"
|
||||
"erupe-ce/server/migrations"
|
||||
"erupe-ce/server/setup"
|
||||
"erupe-ce/server/signserver"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
@@ -40,21 +46,71 @@ var Commit = func() string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func setupDiscordBot(config *cfg.Config, logger *zap.Logger) *discordbot.DiscordBot {
|
||||
bot, err := discordbot.NewDiscordBot(discordbot.Options{
|
||||
Logger: logger,
|
||||
Config: config,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
preventClose(config, fmt.Sprintf("Discord: Failed to start, %s", err.Error()))
|
||||
}
|
||||
|
||||
// Discord bot
|
||||
err = bot.Start()
|
||||
|
||||
if err != nil {
|
||||
preventClose(config, fmt.Sprintf("Discord: Failed to start, %s", err.Error()))
|
||||
}
|
||||
|
||||
_, err = bot.Session.ApplicationCommandBulkOverwrite(bot.Session.State.User.ID, "", discordbot.Commands)
|
||||
if err != nil {
|
||||
preventClose(config, fmt.Sprintf("Discord: Failed to start, %s", err.Error()))
|
||||
}
|
||||
|
||||
return bot
|
||||
}
|
||||
|
||||
func main() {
|
||||
runSetup := flag.Bool("setup", false, "Launch the setup wizard (even if config.json exists)")
|
||||
flag.Parse()
|
||||
|
||||
var err error
|
||||
|
||||
var zapLogger *zap.Logger
|
||||
config := _config.ErupeConfig
|
||||
zapLogger, _ = zap.NewDevelopment()
|
||||
|
||||
defer zapLogger.Sync()
|
||||
defer func() { _ = zapLogger.Sync() }()
|
||||
logger := zapLogger.Named("main")
|
||||
|
||||
if *runSetup {
|
||||
logger.Info("Launching setup wizard (--setup)")
|
||||
if err := setup.Run(logger.Named("setup"), 8080); err != nil {
|
||||
logger.Fatal("Setup wizard failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
config, cfgErr := cfg.LoadConfig()
|
||||
if cfgErr != nil {
|
||||
if _, err := os.Stat("config.json"); os.IsNotExist(err) {
|
||||
logger.Info("No config.json found, launching setup wizard")
|
||||
if err := setup.Run(logger.Named("setup"), 8080); err != nil {
|
||||
logger.Fatal("Setup wizard failed", zap.Error(err))
|
||||
}
|
||||
config, cfgErr = cfg.LoadConfig()
|
||||
if cfgErr != nil {
|
||||
logger.Fatal("Config still invalid after setup", zap.Error(cfgErr))
|
||||
}
|
||||
} else {
|
||||
preventClose(config, fmt.Sprintf("Failed to load config: %s", cfgErr.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info(fmt.Sprintf("Starting Erupe (9.3b-%s)", Commit()))
|
||||
logger.Info(fmt.Sprintf("Client Mode: %s (%d)", config.ClientMode, config.RealClientMode))
|
||||
|
||||
if config.Database.Password == "" {
|
||||
preventClose("Database password is blank")
|
||||
preventClose(config, "Database password is blank")
|
||||
}
|
||||
|
||||
if net.ParseIP(config.Host) == nil {
|
||||
@@ -66,7 +122,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
if net.ParseIP(config.Host) == nil {
|
||||
preventClose("Invalid host address")
|
||||
preventClose(config, "Invalid host address")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,28 +130,7 @@ func main() {
|
||||
var discordBot *discordbot.DiscordBot = nil
|
||||
|
||||
if config.Discord.Enabled {
|
||||
bot, err := discordbot.NewDiscordBot(discordbot.Options{
|
||||
Logger: logger,
|
||||
Config: _config.ErupeConfig,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
preventClose(fmt.Sprintf("Discord: Failed to start, %s", err.Error()))
|
||||
}
|
||||
|
||||
// Discord bot
|
||||
err = bot.Start()
|
||||
|
||||
if err != nil {
|
||||
preventClose(fmt.Sprintf("Discord: Failed to start, %s", err.Error()))
|
||||
}
|
||||
|
||||
discordBot = bot
|
||||
|
||||
_, err = discordBot.Session.ApplicationCommandBulkOverwrite(discordBot.Session.State.User.ID, "", discordbot.Commands)
|
||||
if err != nil {
|
||||
preventClose(fmt.Sprintf("Discord: Failed to start, %s", err.Error()))
|
||||
}
|
||||
discordBot = setupDiscordBot(config, logger)
|
||||
|
||||
logger.Info("Discord: Started successfully")
|
||||
} else {
|
||||
@@ -114,21 +149,70 @@ func main() {
|
||||
|
||||
db, err := sqlx.Open("postgres", connectString)
|
||||
if err != nil {
|
||||
preventClose(fmt.Sprintf("Database: Failed to open, %s", err.Error()))
|
||||
preventClose(config, fmt.Sprintf("Database: Failed to open, %s", err.Error()))
|
||||
}
|
||||
|
||||
// Test the DB connection.
|
||||
err = db.Ping()
|
||||
if err != nil {
|
||||
preventClose(fmt.Sprintf("Database: Failed to ping, %s", err.Error()))
|
||||
preventClose(config, fmt.Sprintf("Database: Failed to ping, %s", err.Error()))
|
||||
}
|
||||
|
||||
// Configure connection pool to avoid exhausting PostgreSQL under load.
|
||||
db.SetMaxOpenConns(50)
|
||||
db.SetMaxIdleConns(10)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
db.SetConnMaxIdleTime(2 * time.Minute)
|
||||
|
||||
logger.Info("Database: Started successfully")
|
||||
|
||||
// Clear stale data
|
||||
if config.DebugOptions.ProxyPort == 0 {
|
||||
_ = db.MustExec("DELETE FROM sign_sessions")
|
||||
// Run database migrations
|
||||
verBefore, _ := migrations.Version(db)
|
||||
applied, migErr := migrations.Migrate(db, logger.Named("migrations"))
|
||||
if migErr != nil {
|
||||
preventClose(config, fmt.Sprintf("Database migration failed: %s", migErr.Error()))
|
||||
}
|
||||
if applied > 0 {
|
||||
ver, _ := migrations.Version(db)
|
||||
logger.Info(fmt.Sprintf("Database: Applied %d migration(s), now at version %d", applied, ver))
|
||||
}
|
||||
|
||||
// Auto-apply seed data on a fresh database so users who skip the wizard
|
||||
// still get shops, events, and gacha. Seed files use ON CONFLICT DO NOTHING
|
||||
// so this is safe to run even if data already exists.
|
||||
if verBefore == 0 && applied > 0 {
|
||||
seedApplied, seedErr := migrations.ApplySeedData(db, logger.Named("migrations"))
|
||||
if seedErr != nil {
|
||||
logger.Warn(fmt.Sprintf("Seed data failed: %s", seedErr.Error()))
|
||||
} else if seedApplied > 0 {
|
||||
logger.Info(fmt.Sprintf("Database: Applied %d seed data file(s)", seedApplied))
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-compute all server IDs this instance will own, so we only
|
||||
// delete our own rows (safe for multi-instance on the same DB).
|
||||
var ownedServerIDs []string
|
||||
{
|
||||
si := 0
|
||||
for _, ee := range config.Entrance.Entries {
|
||||
ci := 0
|
||||
for range ee.Channels {
|
||||
sid := (4096 + si*256) + (16 + ci)
|
||||
ownedServerIDs = append(ownedServerIDs, fmt.Sprint(sid))
|
||||
ci++
|
||||
}
|
||||
si++
|
||||
}
|
||||
}
|
||||
|
||||
// Clear stale data scoped to this instance's server IDs
|
||||
if len(ownedServerIDs) > 0 {
|
||||
idList := strings.Join(ownedServerIDs, ",")
|
||||
if config.DebugOptions.ProxyPort == 0 {
|
||||
_ = db.MustExec("DELETE FROM sign_sessions WHERE server_id IN (" + idList + ")")
|
||||
}
|
||||
_ = db.MustExec("DELETE FROM servers WHERE server_id IN (" + idList + ")")
|
||||
}
|
||||
_ = db.MustExec("DELETE FROM servers")
|
||||
_ = db.MustExec(`UPDATE guild_characters SET treasure_hunt=NULL`)
|
||||
|
||||
// Clean the DB if the option is on.
|
||||
@@ -138,7 +222,16 @@ func main() {
|
||||
logger.Info("Database: Finished clearing")
|
||||
}
|
||||
|
||||
logger.Info(fmt.Sprintf("Server Time: %s", channelserver.TimeAdjusted().String()))
|
||||
logger.Info(fmt.Sprintf("Server Time: %s", gametime.Adjusted().String()))
|
||||
|
||||
// Warn if quest files are missing — clients crash without them.
|
||||
questPath := filepath.Join(config.BinPath, "quests")
|
||||
if entries, err := os.ReadDir(questPath); err != nil || len(entries) == 0 {
|
||||
logger.Warn("No quest files found in " + questPath)
|
||||
logger.Warn("Download quest/scenario files from: https://files.catbox.moe/xf0l7w.7z")
|
||||
logger.Warn("Extract into your BinPath directory (default: bin/)")
|
||||
logger.Warn("Without these files, quests will not load and clients will crash.")
|
||||
}
|
||||
|
||||
// Now start our server(s).
|
||||
|
||||
@@ -149,12 +242,12 @@ func main() {
|
||||
entranceServer = entranceserver.NewServer(
|
||||
&entranceserver.Config{
|
||||
Logger: logger.Named("entrance"),
|
||||
ErupeConfig: _config.ErupeConfig,
|
||||
ErupeConfig: config,
|
||||
DB: db,
|
||||
})
|
||||
err = entranceServer.Start()
|
||||
if err != nil {
|
||||
preventClose(fmt.Sprintf("Entrance: Failed to start, %s", err.Error()))
|
||||
preventClose(config, fmt.Sprintf("Entrance: Failed to start, %s", err.Error()))
|
||||
}
|
||||
logger.Info("Entrance: Started successfully")
|
||||
} else {
|
||||
@@ -168,12 +261,12 @@ func main() {
|
||||
signServer = signserver.NewServer(
|
||||
&signserver.Config{
|
||||
Logger: logger.Named("sign"),
|
||||
ErupeConfig: _config.ErupeConfig,
|
||||
ErupeConfig: config,
|
||||
DB: db,
|
||||
})
|
||||
err = signServer.Start()
|
||||
if err != nil {
|
||||
preventClose(fmt.Sprintf("Sign: Failed to start, %s", err.Error()))
|
||||
preventClose(config, fmt.Sprintf("Sign: Failed to start, %s", err.Error()))
|
||||
}
|
||||
logger.Info("Sign: Started successfully")
|
||||
} else {
|
||||
@@ -186,12 +279,12 @@ func main() {
|
||||
ApiServer = api.NewAPIServer(
|
||||
&api.Config{
|
||||
Logger: logger.Named("sign"),
|
||||
ErupeConfig: _config.ErupeConfig,
|
||||
ErupeConfig: config,
|
||||
DB: db,
|
||||
})
|
||||
err = ApiServer.Start()
|
||||
if err != nil {
|
||||
preventClose(fmt.Sprintf("API: Failed to start, %s", err.Error()))
|
||||
preventClose(config, fmt.Sprintf("API: Failed to start, %s", err.Error()))
|
||||
}
|
||||
logger.Info("API: Started successfully")
|
||||
} else {
|
||||
@@ -208,10 +301,16 @@ func main() {
|
||||
for j, ee := range config.Entrance.Entries {
|
||||
for i, ce := range ee.Channels {
|
||||
sid := (4096 + si*256) + (16 + ci)
|
||||
if !ce.IsEnabled() {
|
||||
logger.Info(fmt.Sprintf("Channel %d (%d): Disabled via config", count, ce.Port))
|
||||
ci++
|
||||
count++
|
||||
continue
|
||||
}
|
||||
c := *channelserver.NewServer(&channelserver.Config{
|
||||
ID: uint16(sid),
|
||||
Logger: logger.Named("channel-" + fmt.Sprint(count)),
|
||||
ErupeConfig: _config.ErupeConfig,
|
||||
ErupeConfig: config,
|
||||
DB: db,
|
||||
DiscordBot: discordBot,
|
||||
})
|
||||
@@ -224,14 +323,17 @@ func main() {
|
||||
c.GlobalID = fmt.Sprintf("%02d%02d", j+1, i+1)
|
||||
err = c.Start()
|
||||
if err != nil {
|
||||
preventClose(fmt.Sprintf("Channel: Failed to start, %s", err.Error()))
|
||||
preventClose(config, fmt.Sprintf("Channel: Failed to start, %s", err.Error()))
|
||||
} else {
|
||||
channelQuery += fmt.Sprintf(`INSERT INTO servers (server_id, current_players, world_name, world_description, land) VALUES (%d, 0, '%s', '%s', %d);`, sid, ee.Name, ee.Description, i+1)
|
||||
channelQuery += fmt.Sprintf(
|
||||
`INSERT INTO servers (server_id, current_players, world_name, world_description, land) VALUES (%d, 0, '%s', '%s', %d);`,
|
||||
sid, ee.Name, ee.Description, i+1,
|
||||
)
|
||||
channels = append(channels, &c)
|
||||
logger.Info(fmt.Sprintf("Channel %d (%d): Started successfully", count, ce.Port))
|
||||
ci++
|
||||
count++
|
||||
}
|
||||
ci++
|
||||
}
|
||||
ci = 0
|
||||
si++
|
||||
@@ -240,8 +342,9 @@ func main() {
|
||||
// Register all servers in DB
|
||||
_ = db.MustExec(channelQuery)
|
||||
|
||||
registry := channelserver.NewLocalChannelRegistry(channels)
|
||||
for _, c := range channels {
|
||||
c.Channels = channels
|
||||
c.Registry = registry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,13 +393,13 @@ func wait() {
|
||||
}
|
||||
}
|
||||
|
||||
func preventClose(text string) {
|
||||
if _config.ErupeConfig.DisableSoftCrash {
|
||||
func preventClose(config *cfg.Config, text string) {
|
||||
if config != nil && config.DisableSoftCrash {
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Println("\nFailed to start Erupe:\n" + text)
|
||||
go wait()
|
||||
fmt.Println("\nPress Enter/Return to exit...")
|
||||
fmt.Scanln()
|
||||
_, _ = fmt.Scanln()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
427
network/binpacket/binpacket_test.go
Normal file
427
network/binpacket/binpacket_test.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package binpacket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"erupe-ce/common/byteframe"
|
||||
"erupe-ce/network"
|
||||
)
|
||||
|
||||
func TestMsgBinTargetedOpcode(t *testing.T) {
|
||||
m := &MsgBinTargeted{}
|
||||
if m.Opcode() != network.MSG_SYS_CAST_BINARY {
|
||||
t.Errorf("MsgBinTargeted.Opcode() = %v, want MSG_SYS_CAST_BINARY", m.Opcode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinTargetedParseEmpty(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(0) // TargetCount = 0
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
|
||||
m := &MsgBinTargeted{}
|
||||
err := m.Parse(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
if m.TargetCount != 0 {
|
||||
t.Errorf("TargetCount = %d, want 0", m.TargetCount)
|
||||
}
|
||||
if len(m.TargetCharIDs) != 0 {
|
||||
t.Errorf("TargetCharIDs len = %d, want 0", len(m.TargetCharIDs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinTargetedParseSingleTarget(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(1) // TargetCount = 1
|
||||
bf.WriteUint32(0x12345678) // TargetCharID
|
||||
bf.WriteBytes([]byte{0xDE, 0xAD, 0xBE, 0xEF})
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
|
||||
m := &MsgBinTargeted{}
|
||||
err := m.Parse(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
if m.TargetCount != 1 {
|
||||
t.Errorf("TargetCount = %d, want 1", m.TargetCount)
|
||||
}
|
||||
if len(m.TargetCharIDs) != 1 {
|
||||
t.Errorf("TargetCharIDs len = %d, want 1", len(m.TargetCharIDs))
|
||||
}
|
||||
if m.TargetCharIDs[0] != 0x12345678 {
|
||||
t.Errorf("TargetCharIDs[0] = %x, want 0x12345678", m.TargetCharIDs[0])
|
||||
}
|
||||
if !bytes.Equal(m.RawDataPayload, []byte{0xDE, 0xAD, 0xBE, 0xEF}) {
|
||||
t.Errorf("RawDataPayload = %v, want [0xDE, 0xAD, 0xBE, 0xEF]", m.RawDataPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinTargetedParseMultipleTargets(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint16(3) // TargetCount = 3
|
||||
bf.WriteUint32(100)
|
||||
bf.WriteUint32(200)
|
||||
bf.WriteUint32(300)
|
||||
bf.WriteBytes([]byte{0x01, 0x02, 0x03})
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
|
||||
m := &MsgBinTargeted{}
|
||||
err := m.Parse(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
if m.TargetCount != 3 {
|
||||
t.Errorf("TargetCount = %d, want 3", m.TargetCount)
|
||||
}
|
||||
if len(m.TargetCharIDs) != 3 {
|
||||
t.Errorf("TargetCharIDs len = %d, want 3", len(m.TargetCharIDs))
|
||||
}
|
||||
if m.TargetCharIDs[0] != 100 || m.TargetCharIDs[1] != 200 || m.TargetCharIDs[2] != 300 {
|
||||
t.Errorf("TargetCharIDs = %v, want [100, 200, 300]", m.TargetCharIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinTargetedBuild(t *testing.T) {
|
||||
m := &MsgBinTargeted{
|
||||
TargetCount: 2,
|
||||
TargetCharIDs: []uint32{0x11111111, 0x22222222},
|
||||
RawDataPayload: []byte{0xAA, 0xBB},
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := m.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
expected := []byte{
|
||||
0x00, 0x02, // TargetCount
|
||||
0x11, 0x11, 0x11, 0x11, // TargetCharIDs[0]
|
||||
0x22, 0x22, 0x22, 0x22, // TargetCharIDs[1]
|
||||
0xAA, 0xBB, // RawDataPayload
|
||||
}
|
||||
|
||||
if !bytes.Equal(bf.Data(), expected) {
|
||||
t.Errorf("Build() = %v, want %v", bf.Data(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinTargetedRoundTrip(t *testing.T) {
|
||||
original := &MsgBinTargeted{
|
||||
TargetCount: 3,
|
||||
TargetCharIDs: []uint32{1000, 2000, 3000},
|
||||
RawDataPayload: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
|
||||
}
|
||||
|
||||
// Build
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := original.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Parse
|
||||
_, _ = bf.Seek(0, 0)
|
||||
parsed := &MsgBinTargeted{}
|
||||
err = parsed.Parse(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
// Compare
|
||||
if parsed.TargetCount != original.TargetCount {
|
||||
t.Errorf("TargetCount = %d, want %d", parsed.TargetCount, original.TargetCount)
|
||||
}
|
||||
if len(parsed.TargetCharIDs) != len(original.TargetCharIDs) {
|
||||
t.Errorf("TargetCharIDs len = %d, want %d", len(parsed.TargetCharIDs), len(original.TargetCharIDs))
|
||||
}
|
||||
for i := range original.TargetCharIDs {
|
||||
if parsed.TargetCharIDs[i] != original.TargetCharIDs[i] {
|
||||
t.Errorf("TargetCharIDs[%d] = %d, want %d", i, parsed.TargetCharIDs[i], original.TargetCharIDs[i])
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(parsed.RawDataPayload, original.RawDataPayload) {
|
||||
t.Errorf("RawDataPayload = %v, want %v", parsed.RawDataPayload, original.RawDataPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinMailNotifyOpcode(t *testing.T) {
|
||||
m := MsgBinMailNotify{}
|
||||
if m.Opcode() != network.MSG_SYS_CASTED_BINARY {
|
||||
t.Errorf("MsgBinMailNotify.Opcode() = %v, want MSG_SYS_CASTED_BINARY", m.Opcode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinMailNotifyBuild(t *testing.T) {
|
||||
m := MsgBinMailNotify{
|
||||
SenderName: "TestPlayer",
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := m.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
data := bf.Data()
|
||||
|
||||
// First byte should be 0x01 (Unk)
|
||||
if data[0] != 0x01 {
|
||||
t.Errorf("First byte = %x, want 0x01", data[0])
|
||||
}
|
||||
|
||||
// Total length should be 1 (Unk) + 21 (padded name) = 22
|
||||
if len(data) != 22 {
|
||||
t.Errorf("Data len = %d, want 22", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinMailNotifyBuildEmptyName(t *testing.T) {
|
||||
m := MsgBinMailNotify{
|
||||
SenderName: "",
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := m.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
if len(bf.Data()) != 22 {
|
||||
t.Errorf("Data len = %d, want 22", len(bf.Data()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinChatOpcode(t *testing.T) {
|
||||
m := &MsgBinChat{}
|
||||
if m.Opcode() != network.MSG_SYS_CAST_BINARY {
|
||||
t.Errorf("MsgBinChat.Opcode() = %v, want MSG_SYS_CAST_BINARY", m.Opcode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinChatTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
chatType ChatType
|
||||
value uint8
|
||||
}{
|
||||
{ChatTypeStage, 1},
|
||||
{ChatTypeGuild, 2},
|
||||
{ChatTypeAlliance, 3},
|
||||
{ChatTypeParty, 4},
|
||||
{ChatTypeWhisper, 5},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if uint8(tt.chatType) != tt.value {
|
||||
t.Errorf("ChatType %v = %d, want %d", tt.chatType, uint8(tt.chatType), tt.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinChatBuildParse(t *testing.T) {
|
||||
original := &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeStage,
|
||||
Flags: 0x0000,
|
||||
Message: "Hello",
|
||||
SenderName: "Player",
|
||||
}
|
||||
|
||||
// Build
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := original.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Parse
|
||||
_, _ = bf.Seek(0, 0)
|
||||
parsed := &MsgBinChat{}
|
||||
err = parsed.Parse(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
// Compare
|
||||
if parsed.Unk0 != original.Unk0 {
|
||||
t.Errorf("Unk0 = %d, want %d", parsed.Unk0, original.Unk0)
|
||||
}
|
||||
if parsed.Type != original.Type {
|
||||
t.Errorf("Type = %d, want %d", parsed.Type, original.Type)
|
||||
}
|
||||
if parsed.Flags != original.Flags {
|
||||
t.Errorf("Flags = %d, want %d", parsed.Flags, original.Flags)
|
||||
}
|
||||
if parsed.Message != original.Message {
|
||||
t.Errorf("Message = %q, want %q", parsed.Message, original.Message)
|
||||
}
|
||||
if parsed.SenderName != original.SenderName {
|
||||
t.Errorf("SenderName = %q, want %q", parsed.SenderName, original.SenderName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinChatBuildParseJapanese(t *testing.T) {
|
||||
original := &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeGuild,
|
||||
Flags: 0x0001,
|
||||
Message: "こんにちは",
|
||||
SenderName: "テスト",
|
||||
}
|
||||
|
||||
// Build
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := original.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Parse
|
||||
_, _ = bf.Seek(0, 0)
|
||||
parsed := &MsgBinChat{}
|
||||
err = parsed.Parse(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
if parsed.Message != original.Message {
|
||||
t.Errorf("Message = %q, want %q", parsed.Message, original.Message)
|
||||
}
|
||||
if parsed.SenderName != original.SenderName {
|
||||
t.Errorf("SenderName = %q, want %q", parsed.SenderName, original.SenderName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinChatBuildParseEmpty(t *testing.T) {
|
||||
original := &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeParty,
|
||||
Flags: 0x0000,
|
||||
Message: "",
|
||||
SenderName: "",
|
||||
}
|
||||
|
||||
// Build
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := original.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Parse
|
||||
_, _ = bf.Seek(0, 0)
|
||||
parsed := &MsgBinChat{}
|
||||
err = parsed.Parse(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
if parsed.Message != "" {
|
||||
t.Errorf("Message = %q, want empty", parsed.Message)
|
||||
}
|
||||
if parsed.SenderName != "" {
|
||||
t.Errorf("SenderName = %q, want empty", parsed.SenderName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinChatBuildFormat(t *testing.T) {
|
||||
m := &MsgBinChat{
|
||||
Unk0: 0x12,
|
||||
Type: ChatTypeWhisper,
|
||||
Flags: 0x3456,
|
||||
Message: "Hi",
|
||||
SenderName: "A",
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := m.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
data := bf.Data()
|
||||
|
||||
// Verify header structure
|
||||
if data[0] != 0x12 {
|
||||
t.Errorf("Unk0 = %x, want 0x12", data[0])
|
||||
}
|
||||
if data[1] != uint8(ChatTypeWhisper) {
|
||||
t.Errorf("Type = %x, want %x", data[1], uint8(ChatTypeWhisper))
|
||||
}
|
||||
// Flags at bytes 2-3 (big endian)
|
||||
if data[2] != 0x34 || data[3] != 0x56 {
|
||||
t.Errorf("Flags = %x%x, want 3456", data[2], data[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinChatAllTypes(t *testing.T) {
|
||||
types := []ChatType{
|
||||
ChatTypeStage,
|
||||
ChatTypeGuild,
|
||||
ChatTypeAlliance,
|
||||
ChatTypeParty,
|
||||
ChatTypeWhisper,
|
||||
}
|
||||
|
||||
for _, chatType := range types {
|
||||
t.Run("", func(t *testing.T) {
|
||||
original := &MsgBinChat{
|
||||
Type: chatType,
|
||||
Message: "Test",
|
||||
SenderName: "Player",
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := original.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
_, _ = bf.Seek(0, 0)
|
||||
parsed := &MsgBinChat{}
|
||||
err = parsed.Parse(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
if parsed.Type != chatType {
|
||||
t.Errorf("Type = %d, want %d", parsed.Type, chatType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinMailNotifyParseReturnsError(t *testing.T) {
|
||||
m := MsgBinMailNotify{}
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := m.Parse(bf)
|
||||
if err == nil {
|
||||
t.Error("Parse() should return an error (not implemented)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinMailNotifyBuildLongName(t *testing.T) {
|
||||
m := MsgBinMailNotify{
|
||||
SenderName: "ThisIsAVeryLongPlayerNameThatExceeds21Characters",
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := m.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Data should still be 22 bytes (1 + 21)
|
||||
if len(bf.Data()) != 22 {
|
||||
t.Errorf("Data len = %d, want 22", len(bf.Data()))
|
||||
}
|
||||
}
|
||||
4
network/binpacket/doc.go
Normal file
4
network/binpacket/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package binpacket defines higher-level binary message types that are carried
|
||||
// inside MSG_SYS_CAST_BINARY / MSG_SYS_CASTED_BINARY packets. These include
|
||||
// chat messages, mail notifications, and targeted player broadcasts.
|
||||
package binpacket
|
||||
@@ -12,11 +12,11 @@ type ChatType uint8
|
||||
// Chat types
|
||||
const (
|
||||
ChatTypeWorld ChatType = 0
|
||||
ChatTypeStage = 1
|
||||
ChatTypeGuild = 2
|
||||
ChatTypeAlliance = 3
|
||||
ChatTypeParty = 4
|
||||
ChatTypeWhisper = 5
|
||||
ChatTypeStage ChatType = 1
|
||||
ChatTypeGuild ChatType = 2
|
||||
ChatTypeAlliance ChatType = 3
|
||||
ChatTypeParty ChatType = 4
|
||||
ChatTypeWhisper ChatType = 5
|
||||
)
|
||||
|
||||
// MsgBinChat is a binpacket for chat messages.
|
||||
@@ -40,8 +40,8 @@ func (m *MsgBinChat) Parse(bf *byteframe.ByteFrame) error {
|
||||
m.Flags = bf.ReadUint16()
|
||||
_ = bf.ReadUint16() // lenSenderName
|
||||
_ = bf.ReadUint16() // lenMessage
|
||||
m.Message = stringsupport.SJISToUTF8(bf.ReadNullTerminatedBytes())
|
||||
m.SenderName = stringsupport.SJISToUTF8(bf.ReadNullTerminatedBytes())
|
||||
m.Message = stringsupport.SJISToUTF8Lossy(bf.ReadNullTerminatedBytes())
|
||||
m.SenderName = stringsupport.SJISToUTF8Lossy(bf.ReadNullTerminatedBytes())
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
380
network/binpacket/msg_bin_chat_test.go
Normal file
380
network/binpacket/msg_bin_chat_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package binpacket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"erupe-ce/common/byteframe"
|
||||
"erupe-ce/network"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMsgBinChat_Opcode(t *testing.T) {
|
||||
msg := &MsgBinChat{}
|
||||
if msg.Opcode() != network.MSG_SYS_CAST_BINARY {
|
||||
t.Errorf("Opcode() = %v, want %v", msg.Opcode(), network.MSG_SYS_CAST_BINARY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinChat_Build(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *MsgBinChat
|
||||
wantErr bool
|
||||
validate func(*testing.T, []byte)
|
||||
}{
|
||||
{
|
||||
name: "basic message",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x01,
|
||||
Type: ChatTypeWorld,
|
||||
Flags: 0x0000,
|
||||
Message: "Hello",
|
||||
SenderName: "Player1",
|
||||
},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, data []byte) {
|
||||
if len(data) == 0 {
|
||||
t.Error("Build() returned empty data")
|
||||
}
|
||||
// Verify the structure starts with Unk0, Type, Flags
|
||||
if data[0] != 0x01 {
|
||||
t.Errorf("Unk0 = 0x%X, want 0x01", data[0])
|
||||
}
|
||||
if data[1] != byte(ChatTypeWorld) {
|
||||
t.Errorf("Type = 0x%X, want 0x%X", data[1], byte(ChatTypeWorld))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all chat types",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeStage,
|
||||
Flags: 0x1234,
|
||||
Message: "Test",
|
||||
SenderName: "Sender",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty message",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeGuild,
|
||||
Flags: 0x0000,
|
||||
Message: "",
|
||||
SenderName: "Player",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty sender",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeParty,
|
||||
Flags: 0x0000,
|
||||
Message: "Hello",
|
||||
SenderName: "",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "long message",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeWhisper,
|
||||
Flags: 0x0000,
|
||||
Message: "This is a very long message that contains a lot of text to test the handling of longer strings in the binary packet format.",
|
||||
SenderName: "LongNamePlayer",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "special characters",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeAlliance,
|
||||
Flags: 0x0000,
|
||||
Message: "Hello!@#$%^&*()",
|
||||
SenderName: "Player_123",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := tt.msg.Build(bf)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Build() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
data := bf.Data()
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, data)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinChat_Parse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want *MsgBinChat
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "basic message",
|
||||
data: []byte{
|
||||
0x01, // Unk0
|
||||
0x00, // Type (ChatTypeWorld)
|
||||
0x00, 0x00, // Flags
|
||||
0x00, 0x08, // lenSenderName (8)
|
||||
0x00, 0x06, // lenMessage (6)
|
||||
// Message: "Hello" + null terminator (SJIS compatible ASCII)
|
||||
0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x00,
|
||||
// SenderName: "Player1" + null terminator
|
||||
0x50, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x31, 0x00,
|
||||
},
|
||||
want: &MsgBinChat{
|
||||
Unk0: 0x01,
|
||||
Type: ChatTypeWorld,
|
||||
Flags: 0x0000,
|
||||
Message: "Hello",
|
||||
SenderName: "Player1",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "different chat type",
|
||||
data: []byte{
|
||||
0x00, // Unk0
|
||||
0x02, // Type (ChatTypeGuild)
|
||||
0x12, 0x34, // Flags
|
||||
0x00, 0x05, // lenSenderName
|
||||
0x00, 0x03, // lenMessage
|
||||
// Message: "Hi" + null
|
||||
0x48, 0x69, 0x00,
|
||||
// SenderName: "Bob" + null + padding
|
||||
0x42, 0x6F, 0x62, 0x00, 0x00,
|
||||
},
|
||||
want: &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeGuild,
|
||||
Flags: 0x1234,
|
||||
Message: "Hi",
|
||||
SenderName: "Bob",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bf := byteframe.NewByteFrameFromBytes(tt.data)
|
||||
msg := &MsgBinChat{}
|
||||
|
||||
err := msg.Parse(bf)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
if msg.Unk0 != tt.want.Unk0 {
|
||||
t.Errorf("Unk0 = 0x%X, want 0x%X", msg.Unk0, tt.want.Unk0)
|
||||
}
|
||||
if msg.Type != tt.want.Type {
|
||||
t.Errorf("Type = %v, want %v", msg.Type, tt.want.Type)
|
||||
}
|
||||
if msg.Flags != tt.want.Flags {
|
||||
t.Errorf("Flags = 0x%X, want 0x%X", msg.Flags, tt.want.Flags)
|
||||
}
|
||||
if msg.Message != tt.want.Message {
|
||||
t.Errorf("Message = %q, want %q", msg.Message, tt.want.Message)
|
||||
}
|
||||
if msg.SenderName != tt.want.SenderName {
|
||||
t.Errorf("SenderName = %q, want %q", msg.SenderName, tt.want.SenderName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinChat_RoundTrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *MsgBinChat
|
||||
}{
|
||||
{
|
||||
name: "world chat",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x01,
|
||||
Type: ChatTypeWorld,
|
||||
Flags: 0x0000,
|
||||
Message: "Hello World",
|
||||
SenderName: "TestPlayer",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stage chat",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeStage,
|
||||
Flags: 0x1234,
|
||||
Message: "Stage message",
|
||||
SenderName: "Player2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "guild chat",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x02,
|
||||
Type: ChatTypeGuild,
|
||||
Flags: 0xFFFF,
|
||||
Message: "Guild announcement",
|
||||
SenderName: "GuildMaster",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alliance chat",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeAlliance,
|
||||
Flags: 0x0001,
|
||||
Message: "Alliance msg",
|
||||
SenderName: "AllyLeader",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "party chat",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x01,
|
||||
Type: ChatTypeParty,
|
||||
Flags: 0x0000,
|
||||
Message: "Party up!",
|
||||
SenderName: "PartyLeader",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "whisper",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeWhisper,
|
||||
Flags: 0x0002,
|
||||
Message: "Secret message",
|
||||
SenderName: "Whisperer",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty strings",
|
||||
msg: &MsgBinChat{
|
||||
Unk0: 0x00,
|
||||
Type: ChatTypeWorld,
|
||||
Flags: 0x0000,
|
||||
Message: "",
|
||||
SenderName: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Build
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := tt.msg.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Parse
|
||||
parsedMsg := &MsgBinChat{}
|
||||
parsedBf := byteframe.NewByteFrameFromBytes(bf.Data())
|
||||
err = parsedMsg.Parse(parsedBf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
// Compare
|
||||
if parsedMsg.Unk0 != tt.msg.Unk0 {
|
||||
t.Errorf("Unk0 = 0x%X, want 0x%X", parsedMsg.Unk0, tt.msg.Unk0)
|
||||
}
|
||||
if parsedMsg.Type != tt.msg.Type {
|
||||
t.Errorf("Type = %v, want %v", parsedMsg.Type, tt.msg.Type)
|
||||
}
|
||||
if parsedMsg.Flags != tt.msg.Flags {
|
||||
t.Errorf("Flags = 0x%X, want 0x%X", parsedMsg.Flags, tt.msg.Flags)
|
||||
}
|
||||
if parsedMsg.Message != tt.msg.Message {
|
||||
t.Errorf("Message = %q, want %q", parsedMsg.Message, tt.msg.Message)
|
||||
}
|
||||
if parsedMsg.SenderName != tt.msg.SenderName {
|
||||
t.Errorf("SenderName = %q, want %q", parsedMsg.SenderName, tt.msg.SenderName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatType_Values(t *testing.T) {
|
||||
tests := []struct {
|
||||
chatType ChatType
|
||||
expected uint8
|
||||
}{
|
||||
{ChatTypeWorld, 0},
|
||||
{ChatTypeStage, 1},
|
||||
{ChatTypeGuild, 2},
|
||||
{ChatTypeAlliance, 3},
|
||||
{ChatTypeParty, 4},
|
||||
{ChatTypeWhisper, 5},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if uint8(tt.chatType) != tt.expected {
|
||||
t.Errorf("ChatType value = %d, want %d", uint8(tt.chatType), tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinChat_BuildParseConsistency(t *testing.T) {
|
||||
// Test that Build and Parse are consistent with each other
|
||||
// by building, parsing, building again, and comparing
|
||||
original := &MsgBinChat{
|
||||
Unk0: 0x01,
|
||||
Type: ChatTypeWorld,
|
||||
Flags: 0x1234,
|
||||
Message: "Test message",
|
||||
SenderName: "TestSender",
|
||||
}
|
||||
|
||||
// First build
|
||||
bf1 := byteframe.NewByteFrame()
|
||||
err := original.Build(bf1)
|
||||
if err != nil {
|
||||
t.Fatalf("First Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Parse
|
||||
parsed := &MsgBinChat{}
|
||||
parsedBf := byteframe.NewByteFrameFromBytes(bf1.Data())
|
||||
err = parsed.Parse(parsedBf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
// Second build
|
||||
bf2 := byteframe.NewByteFrame()
|
||||
err = parsed.Build(bf2)
|
||||
if err != nil {
|
||||
t.Fatalf("Second Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Compare the two builds
|
||||
if !bytes.Equal(bf1.Data(), bf2.Data()) {
|
||||
t.Errorf("Build-Parse-Build inconsistency:\nFirst: %v\nSecond: %v", bf1.Data(), bf2.Data())
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,31 @@
|
||||
package binpacket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"erupe-ce/common/byteframe"
|
||||
"erupe-ce/common/stringsupport"
|
||||
"erupe-ce/network"
|
||||
)
|
||||
|
||||
// MsgBinMailNotify is a binpacket broadcast to notify a player of new mail.
|
||||
type MsgBinMailNotify struct {
|
||||
SenderName string
|
||||
}
|
||||
|
||||
// Parse parses the packet from binary.
|
||||
func (m MsgBinMailNotify) Parse(bf *byteframe.ByteFrame) error {
|
||||
panic("implement me")
|
||||
return fmt.Errorf("MsgBinMailNotify.Parse: not implemented")
|
||||
}
|
||||
|
||||
// Build builds a binary packet from the current data.
|
||||
func (m MsgBinMailNotify) Build(bf *byteframe.ByteFrame) error {
|
||||
bf.WriteUint8(0x01) // Unk
|
||||
bf.WriteBytes(stringsupport.PaddedString(m.SenderName, 21, true))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Opcode returns the ID associated with this packet type.
|
||||
func (m MsgBinMailNotify) Opcode() network.PacketID {
|
||||
return network.MSG_SYS_CASTED_BINARY
|
||||
}
|
||||
|
||||
215
network/binpacket/msg_bin_mail_notify_test.go
Normal file
215
network/binpacket/msg_bin_mail_notify_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package binpacket
|
||||
|
||||
import (
|
||||
"erupe-ce/common/byteframe"
|
||||
"erupe-ce/network"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMsgBinMailNotify_Opcode(t *testing.T) {
|
||||
msg := MsgBinMailNotify{}
|
||||
if msg.Opcode() != network.MSG_SYS_CASTED_BINARY {
|
||||
t.Errorf("Opcode() = %v, want %v", msg.Opcode(), network.MSG_SYS_CASTED_BINARY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinMailNotify_Build(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
senderName string
|
||||
wantErr bool
|
||||
validate func(*testing.T, []byte)
|
||||
}{
|
||||
{
|
||||
name: "basic sender name",
|
||||
senderName: "Player1",
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, data []byte) {
|
||||
if len(data) == 0 {
|
||||
t.Error("Build() returned empty data")
|
||||
}
|
||||
// First byte should be 0x01 (Unk)
|
||||
if data[0] != 0x01 {
|
||||
t.Errorf("First byte = 0x%X, want 0x01", data[0])
|
||||
}
|
||||
// Total length should be 1 (Unk) + 21 (padded string)
|
||||
expectedLen := 1 + 21
|
||||
if len(data) != expectedLen {
|
||||
t.Errorf("data length = %d, want %d", len(data), expectedLen)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty sender name",
|
||||
senderName: "",
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, data []byte) {
|
||||
if len(data) != 22 { // 1 + 21
|
||||
t.Errorf("data length = %d, want 22", len(data))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "long sender name",
|
||||
senderName: "VeryLongPlayerNameThatExceeds21Characters",
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, data []byte) {
|
||||
if len(data) != 22 { // 1 + 21 (truncated/padded)
|
||||
t.Errorf("data length = %d, want 22", len(data))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exactly 21 characters",
|
||||
senderName: "ExactlyTwentyOneChar1",
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, data []byte) {
|
||||
if len(data) != 22 {
|
||||
t.Errorf("data length = %d, want 22", len(data))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "special characters",
|
||||
senderName: "Player_123",
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, data []byte) {
|
||||
if len(data) != 22 {
|
||||
t.Errorf("data length = %d, want 22", len(data))
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msg := MsgBinMailNotify{
|
||||
SenderName: tt.senderName,
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := msg.Build(bf)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Build() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr && tt.validate != nil {
|
||||
tt.validate(t, bf.Data())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinMailNotify_Parse_ReturnsError(t *testing.T) {
|
||||
// Document that Parse() is not implemented and returns an error
|
||||
msg := MsgBinMailNotify{}
|
||||
bf := byteframe.NewByteFrame()
|
||||
|
||||
err := msg.Parse(bf)
|
||||
if err == nil {
|
||||
t.Error("Parse() should return an error (not implemented)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinMailNotify_BuildMultiple(t *testing.T) {
|
||||
// Test building multiple messages to ensure no state pollution
|
||||
names := []string{"Player1", "Player2", "Player3"}
|
||||
|
||||
for _, name := range names {
|
||||
msg := MsgBinMailNotify{SenderName: name}
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := msg.Build(bf)
|
||||
if err != nil {
|
||||
t.Errorf("Build(%s) error = %v", name, err)
|
||||
}
|
||||
|
||||
data := bf.Data()
|
||||
if len(data) != 22 {
|
||||
t.Errorf("Build(%s) length = %d, want 22", name, len(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinMailNotify_PaddingBehavior(t *testing.T) {
|
||||
// Test that the padded string is always 21 bytes
|
||||
tests := []struct {
|
||||
name string
|
||||
senderName string
|
||||
}{
|
||||
{"short", "A"},
|
||||
{"medium", "PlayerName"},
|
||||
{"long", "VeryVeryLongPlayerName"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msg := MsgBinMailNotify{SenderName: tt.senderName}
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := msg.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
data := bf.Data()
|
||||
// Skip first byte (Unk), check remaining 21 bytes
|
||||
if len(data) < 22 {
|
||||
t.Fatalf("data too short: %d bytes", len(data))
|
||||
}
|
||||
|
||||
paddedString := data[1:22]
|
||||
if len(paddedString) != 21 {
|
||||
t.Errorf("padded string length = %d, want 21", len(paddedString))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinMailNotify_BuildStructure(t *testing.T) {
|
||||
// Test the structure of the built data
|
||||
msg := MsgBinMailNotify{SenderName: "Test"}
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := msg.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
data := bf.Data()
|
||||
|
||||
// Check structure: 1 byte Unk + 21 bytes padded string = 22 bytes total
|
||||
if len(data) != 22 {
|
||||
t.Errorf("data length = %d, want 22", len(data))
|
||||
}
|
||||
|
||||
// First byte should be 0x01
|
||||
if data[0] != 0x01 {
|
||||
t.Errorf("Unk byte = 0x%X, want 0x01", data[0])
|
||||
}
|
||||
|
||||
// The rest (21 bytes) should contain the sender name (SJIS encoded) and padding
|
||||
// We can't verify exact content without knowing SJIS encoding details,
|
||||
// but we can verify length
|
||||
paddedPortion := data[1:]
|
||||
if len(paddedPortion) != 21 {
|
||||
t.Errorf("padded portion length = %d, want 21", len(paddedPortion))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinMailNotify_ValueSemantics(t *testing.T) {
|
||||
// Test that MsgBinMailNotify uses value semantics (not pointer receiver for Opcode)
|
||||
msg := MsgBinMailNotify{SenderName: "Test"}
|
||||
|
||||
// Should work with value
|
||||
opcode := msg.Opcode()
|
||||
if opcode != network.MSG_SYS_CASTED_BINARY {
|
||||
t.Errorf("Opcode() = %v, want %v", opcode, network.MSG_SYS_CASTED_BINARY)
|
||||
}
|
||||
|
||||
// Should also work with pointer (Go allows this)
|
||||
msgPtr := &MsgBinMailNotify{SenderName: "Test"}
|
||||
opcode2 := msgPtr.Opcode()
|
||||
if opcode2 != network.MSG_SYS_CASTED_BINARY {
|
||||
t.Errorf("Opcode() on pointer = %v, want %v", opcode2, network.MSG_SYS_CASTED_BINARY)
|
||||
}
|
||||
}
|
||||
404
network/binpacket/msg_bin_targeted_test.go
Normal file
404
network/binpacket/msg_bin_targeted_test.go
Normal file
@@ -0,0 +1,404 @@
|
||||
package binpacket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"erupe-ce/common/byteframe"
|
||||
"erupe-ce/network"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMsgBinTargeted_Opcode(t *testing.T) {
|
||||
msg := &MsgBinTargeted{}
|
||||
if msg.Opcode() != network.MSG_SYS_CAST_BINARY {
|
||||
t.Errorf("Opcode() = %v, want %v", msg.Opcode(), network.MSG_SYS_CAST_BINARY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinTargeted_Build(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *MsgBinTargeted
|
||||
wantErr bool
|
||||
validate func(*testing.T, []byte)
|
||||
}{
|
||||
{
|
||||
name: "single target with payload",
|
||||
msg: &MsgBinTargeted{
|
||||
TargetCount: 1,
|
||||
TargetCharIDs: []uint32{12345},
|
||||
RawDataPayload: []byte{0x01, 0x02, 0x03, 0x04},
|
||||
},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, data []byte) {
|
||||
if len(data) < 2+4+4 { // 2 bytes count + 4 bytes ID + 4 bytes payload
|
||||
t.Errorf("data length = %d, want at least %d", len(data), 2+4+4)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple targets",
|
||||
msg: &MsgBinTargeted{
|
||||
TargetCount: 3,
|
||||
TargetCharIDs: []uint32{100, 200, 300},
|
||||
RawDataPayload: []byte{0xAA, 0xBB},
|
||||
},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, data []byte) {
|
||||
expectedLen := 2 + (3 * 4) + 2 // count + 3 IDs + payload
|
||||
if len(data) != expectedLen {
|
||||
t.Errorf("data length = %d, want %d", len(data), expectedLen)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zero targets",
|
||||
msg: &MsgBinTargeted{
|
||||
TargetCount: 0,
|
||||
TargetCharIDs: []uint32{},
|
||||
RawDataPayload: []byte{0xFF},
|
||||
},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, data []byte) {
|
||||
if len(data) < 2+1 { // count + payload
|
||||
t.Errorf("data length = %d, want at least %d", len(data), 2+1)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty payload",
|
||||
msg: &MsgBinTargeted{
|
||||
TargetCount: 1,
|
||||
TargetCharIDs: []uint32{999},
|
||||
RawDataPayload: []byte{},
|
||||
},
|
||||
wantErr: false,
|
||||
validate: func(t *testing.T, data []byte) {
|
||||
expectedLen := 2 + 4 // count + 1 ID
|
||||
if len(data) != expectedLen {
|
||||
t.Errorf("data length = %d, want %d", len(data), expectedLen)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "large payload",
|
||||
msg: &MsgBinTargeted{
|
||||
TargetCount: 2,
|
||||
TargetCharIDs: []uint32{1000, 2000},
|
||||
RawDataPayload: bytes.Repeat([]byte{0xCC}, 256),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "max uint32 target IDs",
|
||||
msg: &MsgBinTargeted{
|
||||
TargetCount: 2,
|
||||
TargetCharIDs: []uint32{0xFFFFFFFF, 0x12345678},
|
||||
RawDataPayload: []byte{0x01},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := tt.msg.Build(bf)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Build() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
data := bf.Data()
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, data)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinTargeted_Parse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want *MsgBinTargeted
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "single target",
|
||||
data: []byte{
|
||||
0x00, 0x01, // TargetCount = 1
|
||||
0x00, 0x00, 0x30, 0x39, // TargetCharID = 12345
|
||||
0xAA, 0xBB, 0xCC, // RawDataPayload
|
||||
},
|
||||
want: &MsgBinTargeted{
|
||||
TargetCount: 1,
|
||||
TargetCharIDs: []uint32{12345},
|
||||
RawDataPayload: []byte{0xAA, 0xBB, 0xCC},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple targets",
|
||||
data: []byte{
|
||||
0x00, 0x03, // TargetCount = 3
|
||||
0x00, 0x00, 0x00, 0x64, // Target 1 = 100
|
||||
0x00, 0x00, 0x00, 0xC8, // Target 2 = 200
|
||||
0x00, 0x00, 0x01, 0x2C, // Target 3 = 300
|
||||
0x01, 0x02, // RawDataPayload
|
||||
},
|
||||
want: &MsgBinTargeted{
|
||||
TargetCount: 3,
|
||||
TargetCharIDs: []uint32{100, 200, 300},
|
||||
RawDataPayload: []byte{0x01, 0x02},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "zero targets",
|
||||
data: []byte{
|
||||
0x00, 0x00, // TargetCount = 0
|
||||
0xFF, 0xFF, // RawDataPayload
|
||||
},
|
||||
want: &MsgBinTargeted{
|
||||
TargetCount: 0,
|
||||
TargetCharIDs: []uint32{},
|
||||
RawDataPayload: []byte{0xFF, 0xFF},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no payload",
|
||||
data: []byte{
|
||||
0x00, 0x01, // TargetCount = 1
|
||||
0x00, 0x00, 0x03, 0xE7, // Target = 999
|
||||
},
|
||||
want: &MsgBinTargeted{
|
||||
TargetCount: 1,
|
||||
TargetCharIDs: []uint32{999},
|
||||
RawDataPayload: []byte{},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bf := byteframe.NewByteFrameFromBytes(tt.data)
|
||||
msg := &MsgBinTargeted{}
|
||||
|
||||
err := msg.Parse(bf)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
if msg.TargetCount != tt.want.TargetCount {
|
||||
t.Errorf("TargetCount = %d, want %d", msg.TargetCount, tt.want.TargetCount)
|
||||
}
|
||||
|
||||
if len(msg.TargetCharIDs) != len(tt.want.TargetCharIDs) {
|
||||
t.Errorf("len(TargetCharIDs) = %d, want %d", len(msg.TargetCharIDs), len(tt.want.TargetCharIDs))
|
||||
} else {
|
||||
for i, id := range msg.TargetCharIDs {
|
||||
if id != tt.want.TargetCharIDs[i] {
|
||||
t.Errorf("TargetCharIDs[%d] = %d, want %d", i, id, tt.want.TargetCharIDs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !bytes.Equal(msg.RawDataPayload, tt.want.RawDataPayload) {
|
||||
t.Errorf("RawDataPayload = %v, want %v", msg.RawDataPayload, tt.want.RawDataPayload)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinTargeted_RoundTrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *MsgBinTargeted
|
||||
}{
|
||||
{
|
||||
name: "single target",
|
||||
msg: &MsgBinTargeted{
|
||||
TargetCount: 1,
|
||||
TargetCharIDs: []uint32{12345},
|
||||
RawDataPayload: []byte{0x01, 0x02, 0x03},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple targets",
|
||||
msg: &MsgBinTargeted{
|
||||
TargetCount: 5,
|
||||
TargetCharIDs: []uint32{100, 200, 300, 400, 500},
|
||||
RawDataPayload: []byte{0xAA, 0xBB, 0xCC, 0xDD},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zero targets",
|
||||
msg: &MsgBinTargeted{
|
||||
TargetCount: 0,
|
||||
TargetCharIDs: []uint32{},
|
||||
RawDataPayload: []byte{0xFF},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty payload",
|
||||
msg: &MsgBinTargeted{
|
||||
TargetCount: 2,
|
||||
TargetCharIDs: []uint32{1000, 2000},
|
||||
RawDataPayload: []byte{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "large IDs and payload",
|
||||
msg: &MsgBinTargeted{
|
||||
TargetCount: 3,
|
||||
TargetCharIDs: []uint32{0xFFFFFFFF, 0x12345678, 0xABCDEF00},
|
||||
RawDataPayload: bytes.Repeat([]byte{0xDD}, 128),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Build
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := tt.msg.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Parse
|
||||
parsedMsg := &MsgBinTargeted{}
|
||||
parsedBf := byteframe.NewByteFrameFromBytes(bf.Data())
|
||||
err = parsedMsg.Parse(parsedBf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
// Compare
|
||||
if parsedMsg.TargetCount != tt.msg.TargetCount {
|
||||
t.Errorf("TargetCount = %d, want %d", parsedMsg.TargetCount, tt.msg.TargetCount)
|
||||
}
|
||||
|
||||
if len(parsedMsg.TargetCharIDs) != len(tt.msg.TargetCharIDs) {
|
||||
t.Errorf("len(TargetCharIDs) = %d, want %d", len(parsedMsg.TargetCharIDs), len(tt.msg.TargetCharIDs))
|
||||
} else {
|
||||
for i, id := range parsedMsg.TargetCharIDs {
|
||||
if id != tt.msg.TargetCharIDs[i] {
|
||||
t.Errorf("TargetCharIDs[%d] = %d, want %d", i, id, tt.msg.TargetCharIDs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !bytes.Equal(parsedMsg.RawDataPayload, tt.msg.RawDataPayload) {
|
||||
t.Errorf("RawDataPayload length mismatch: got %d, want %d", len(parsedMsg.RawDataPayload), len(tt.msg.RawDataPayload))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinTargeted_TargetCountMismatch(t *testing.T) {
|
||||
// Test that TargetCount and actual array length don't have to match
|
||||
// The Build function uses the TargetCount field
|
||||
msg := &MsgBinTargeted{
|
||||
TargetCount: 2, // Says 2
|
||||
TargetCharIDs: []uint32{100, 200, 300}, // But has 3
|
||||
RawDataPayload: []byte{0x01},
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := msg.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Parse should read exactly 2 IDs as specified by TargetCount
|
||||
parsedMsg := &MsgBinTargeted{}
|
||||
parsedBf := byteframe.NewByteFrameFromBytes(bf.Data())
|
||||
err = parsedMsg.Parse(parsedBf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
if parsedMsg.TargetCount != 2 {
|
||||
t.Errorf("TargetCount = %d, want 2", parsedMsg.TargetCount)
|
||||
}
|
||||
|
||||
if len(parsedMsg.TargetCharIDs) != 2 {
|
||||
t.Errorf("len(TargetCharIDs) = %d, want 2", len(parsedMsg.TargetCharIDs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinTargeted_BuildParseConsistency(t *testing.T) {
|
||||
original := &MsgBinTargeted{
|
||||
TargetCount: 3,
|
||||
TargetCharIDs: []uint32{111, 222, 333},
|
||||
RawDataPayload: []byte{0x11, 0x22, 0x33, 0x44},
|
||||
}
|
||||
|
||||
// First build
|
||||
bf1 := byteframe.NewByteFrame()
|
||||
err := original.Build(bf1)
|
||||
if err != nil {
|
||||
t.Fatalf("First Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Parse
|
||||
parsed := &MsgBinTargeted{}
|
||||
parsedBf := byteframe.NewByteFrameFromBytes(bf1.Data())
|
||||
err = parsed.Parse(parsedBf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
// Second build
|
||||
bf2 := byteframe.NewByteFrame()
|
||||
err = parsed.Build(bf2)
|
||||
if err != nil {
|
||||
t.Fatalf("Second Build() error = %v", err)
|
||||
}
|
||||
|
||||
// Compare the two builds
|
||||
if !bytes.Equal(bf1.Data(), bf2.Data()) {
|
||||
t.Errorf("Build-Parse-Build inconsistency:\nFirst: %v\nSecond: %v", bf1.Data(), bf2.Data())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsgBinTargeted_PayloadForwarding(t *testing.T) {
|
||||
// Test that RawDataPayload is correctly preserved
|
||||
// This is important as it forwards another binpacket
|
||||
originalPayload := []byte{
|
||||
0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80,
|
||||
0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0, 0xF0, 0xFF,
|
||||
}
|
||||
|
||||
msg := &MsgBinTargeted{
|
||||
TargetCount: 1,
|
||||
TargetCharIDs: []uint32{999},
|
||||
RawDataPayload: originalPayload,
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
err := msg.Build(bf)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
parsed := &MsgBinTargeted{}
|
||||
parsedBf := byteframe.NewByteFrameFromBytes(bf.Data())
|
||||
err = parsed.Parse(parsedBf)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(parsed.RawDataPayload, originalPayload) {
|
||||
t.Errorf("Payload not preserved:\ngot: %v\nwant: %v", parsed.RawDataPayload, originalPayload)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
package clientctx
|
||||
|
||||
import cfg "erupe-ce/config"
|
||||
|
||||
// ClientContext holds contextual data required for packet encoding/decoding.
|
||||
type ClientContext struct{} // Unused
|
||||
type ClientContext struct {
|
||||
RealClientMode cfg.Mode
|
||||
}
|
||||
|
||||
12
network/clientctx/clientcontext_test.go
Normal file
12
network/clientctx/clientcontext_test.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package clientctx
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestClientContext_Exists verifies that the ClientContext type exists
|
||||
// and can be instantiated.
|
||||
func TestClientContext_Exists(t *testing.T) {
|
||||
ctx := ClientContext{}
|
||||
_ = ctx
|
||||
}
|
||||
3
network/clientctx/doc.go
Normal file
3
network/clientctx/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package clientctx provides per-connection context passed to packet
|
||||
// Parse/Build methods, allowing version-dependent encoding decisions.
|
||||
package clientctx
|
||||
@@ -3,17 +3,30 @@ package network
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
_config "erupe-ce/config"
|
||||
cfg "erupe-ce/config"
|
||||
"erupe-ce/network/crypto"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Conn defines the interface for a packet-based connection.
|
||||
// This interface allows for mocking of connections in tests.
|
||||
type Conn interface {
|
||||
// ReadPacket reads and decrypts a packet from the connection
|
||||
ReadPacket() ([]byte, error)
|
||||
|
||||
// SendPacket encrypts and sends a packet on the connection
|
||||
SendPacket(data []byte) error
|
||||
}
|
||||
|
||||
// CryptConn represents a MHF encrypted two-way connection,
|
||||
// it automatically handles encryption, decryption, and key rotation via it's methods.
|
||||
type CryptConn struct {
|
||||
logger *zap.Logger
|
||||
conn net.Conn
|
||||
realClientMode cfg.Mode
|
||||
readKeyRot uint32
|
||||
sendKeyRot uint32
|
||||
sentPackets int32
|
||||
@@ -22,11 +35,16 @@ type CryptConn struct {
|
||||
}
|
||||
|
||||
// NewCryptConn creates a new CryptConn with proper default values.
|
||||
func NewCryptConn(conn net.Conn) *CryptConn {
|
||||
func NewCryptConn(conn net.Conn, mode cfg.Mode, logger *zap.Logger) *CryptConn {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
cc := &CryptConn{
|
||||
conn: conn,
|
||||
readKeyRot: 995117,
|
||||
sendKeyRot: 995117,
|
||||
logger: logger,
|
||||
conn: conn,
|
||||
realClientMode: mode,
|
||||
readKeyRot: 995117,
|
||||
sendKeyRot: 995117,
|
||||
}
|
||||
return cc
|
||||
}
|
||||
@@ -51,7 +69,7 @@ func (cc *CryptConn) ReadPacket() ([]byte, error) {
|
||||
var encryptedPacketBody []byte
|
||||
|
||||
// Don't know when support for this was added, works in Forward.4, doesn't work in Season 6.0
|
||||
if _config.ErupeConfig.RealClientMode < _config.F1 {
|
||||
if cc.realClientMode < cfg.F1 {
|
||||
encryptedPacketBody = make([]byte, cph.DataSize)
|
||||
} else {
|
||||
encryptedPacketBody = make([]byte, uint32(cph.DataSize)+(uint32(cph.Pf0-0x03)*0x1000))
|
||||
@@ -68,18 +86,19 @@ func (cc *CryptConn) ReadPacket() ([]byte, error) {
|
||||
|
||||
out, combinedCheck, check0, check1, check2 := crypto.Crypto(encryptedPacketBody, cc.readKeyRot, false, nil)
|
||||
if cph.Check0 != check0 || cph.Check1 != check1 || cph.Check2 != check2 {
|
||||
fmt.Printf("got c0 %X, c1 %X, c2 %X\n", check0, check1, check2)
|
||||
fmt.Printf("want c0 %X, c1 %X, c2 %X\n", cph.Check0, cph.Check1, cph.Check2)
|
||||
fmt.Printf("headerData:\n%s\n", hex.Dump(headerData))
|
||||
fmt.Printf("encryptedPacketBody:\n%s\n", hex.Dump(encryptedPacketBody))
|
||||
cc.logger.Warn("Crypto checksum mismatch",
|
||||
zap.String("got", hex.EncodeToString([]byte{byte(check0 >> 8), byte(check0), byte(check1 >> 8), byte(check1), byte(check2 >> 8), byte(check2)})),
|
||||
zap.String("want", hex.EncodeToString([]byte{byte(cph.Check0 >> 8), byte(cph.Check0), byte(cph.Check1 >> 8), byte(cph.Check1), byte(cph.Check2 >> 8), byte(cph.Check2)})),
|
||||
zap.String("headerData", hex.Dump(headerData)),
|
||||
zap.String("encryptedPacketBody", hex.Dump(encryptedPacketBody)),
|
||||
)
|
||||
|
||||
// Attempt to bruteforce it.
|
||||
fmt.Println("Crypto out of sync? Attempting bruteforce")
|
||||
cc.logger.Warn("Crypto out of sync, attempting bruteforce")
|
||||
for key := byte(0); key < 255; key++ {
|
||||
out, combinedCheck, check0, check1, check2 = crypto.Crypto(encryptedPacketBody, 0, false, &key)
|
||||
//fmt.Printf("Key: 0x%X\n%s\n", key, hex.Dump(out))
|
||||
if cph.Check0 == check0 && cph.Check1 == check1 && cph.Check2 == check2 {
|
||||
fmt.Printf("Bruceforce successful, override key: 0x%X\n", key)
|
||||
cc.logger.Info("Bruteforce successful", zap.Uint8("overrideKey", key))
|
||||
|
||||
// Try to fix key for subsequent packets?
|
||||
//cc.readKeyRot = (uint32(key) << 1) + 999983
|
||||
@@ -122,7 +141,10 @@ func (cc *CryptConn) SendPacket(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cc.conn.Write(append(headerBytes, encData...))
|
||||
_, err = cc.conn.Write(append(headerBytes, encData...))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cc.sentPackets++
|
||||
cc.prevSendPacketCombinedCheck = combinedCheck
|
||||
|
||||
|
||||
445
network/crypt_conn_test.go
Normal file
445
network/crypt_conn_test.go
Normal file
@@ -0,0 +1,445 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
cfg "erupe-ce/config"
|
||||
"erupe-ce/network/crypto"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockConn implements net.Conn for testing
|
||||
type mockConn struct {
|
||||
readData *bytes.Buffer
|
||||
writeData *bytes.Buffer
|
||||
closed bool
|
||||
readErr error
|
||||
writeErr error
|
||||
}
|
||||
|
||||
func newMockConn(readData []byte) *mockConn {
|
||||
return &mockConn{
|
||||
readData: bytes.NewBuffer(readData),
|
||||
writeData: bytes.NewBuffer(nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockConn) Read(b []byte) (n int, err error) {
|
||||
if m.readErr != nil {
|
||||
return 0, m.readErr
|
||||
}
|
||||
return m.readData.Read(b)
|
||||
}
|
||||
|
||||
func (m *mockConn) Write(b []byte) (n int, err error) {
|
||||
if m.writeErr != nil {
|
||||
return 0, m.writeErr
|
||||
}
|
||||
return m.writeData.Write(b)
|
||||
}
|
||||
|
||||
func (m *mockConn) Close() error {
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockConn) LocalAddr() net.Addr { return nil }
|
||||
func (m *mockConn) RemoteAddr() net.Addr { return nil }
|
||||
func (m *mockConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
func TestNewCryptConn(t *testing.T) {
|
||||
mockConn := newMockConn(nil)
|
||||
cc := NewCryptConn(mockConn, cfg.ZZ, nil)
|
||||
|
||||
if cc == nil {
|
||||
t.Fatal("NewCryptConn() returned nil")
|
||||
}
|
||||
|
||||
if cc.conn != mockConn {
|
||||
t.Error("conn not set correctly")
|
||||
}
|
||||
|
||||
if cc.readKeyRot != 995117 {
|
||||
t.Errorf("readKeyRot = %d, want 995117", cc.readKeyRot)
|
||||
}
|
||||
|
||||
if cc.sendKeyRot != 995117 {
|
||||
t.Errorf("sendKeyRot = %d, want 995117", cc.sendKeyRot)
|
||||
}
|
||||
|
||||
if cc.sentPackets != 0 {
|
||||
t.Errorf("sentPackets = %d, want 0", cc.sentPackets)
|
||||
}
|
||||
|
||||
if cc.prevRecvPacketCombinedCheck != 0 {
|
||||
t.Errorf("prevRecvPacketCombinedCheck = %d, want 0", cc.prevRecvPacketCombinedCheck)
|
||||
}
|
||||
|
||||
if cc.prevSendPacketCombinedCheck != 0 {
|
||||
t.Errorf("prevSendPacketCombinedCheck = %d, want 0", cc.prevSendPacketCombinedCheck)
|
||||
}
|
||||
|
||||
if cc.realClientMode != cfg.ZZ {
|
||||
t.Errorf("realClientMode = %d, want %d", cc.realClientMode, cfg.ZZ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_SendPacket(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{
|
||||
name: "small packet",
|
||||
data: []byte{0x01, 0x02, 0x03, 0x04},
|
||||
},
|
||||
{
|
||||
name: "empty packet",
|
||||
data: []byte{},
|
||||
},
|
||||
{
|
||||
name: "larger packet",
|
||||
data: bytes.Repeat([]byte{0xAA}, 256),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockConn := newMockConn(nil)
|
||||
cc := NewCryptConn(mockConn, cfg.ZZ, nil)
|
||||
|
||||
err := cc.SendPacket(tt.data)
|
||||
if err != nil {
|
||||
t.Fatalf("SendPacket() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
written := mockConn.writeData.Bytes()
|
||||
if len(written) < CryptPacketHeaderLength {
|
||||
t.Fatalf("written data length = %d, want at least %d", len(written), CryptPacketHeaderLength)
|
||||
}
|
||||
|
||||
// Verify header was written
|
||||
headerData := written[:CryptPacketHeaderLength]
|
||||
header, err := NewCryptPacketHeader(headerData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse header: %v", err)
|
||||
}
|
||||
|
||||
// Verify packet counter incremented
|
||||
if cc.sentPackets != 1 {
|
||||
t.Errorf("sentPackets = %d, want 1", cc.sentPackets)
|
||||
}
|
||||
|
||||
// Verify header fields
|
||||
if header.KeyRotDelta != 3 {
|
||||
t.Errorf("header.KeyRotDelta = %d, want 3", header.KeyRotDelta)
|
||||
}
|
||||
|
||||
if header.PacketNum != 0 {
|
||||
t.Errorf("header.PacketNum = %d, want 0", header.PacketNum)
|
||||
}
|
||||
|
||||
// Verify encrypted data was written
|
||||
encryptedData := written[CryptPacketHeaderLength:]
|
||||
if len(encryptedData) != int(header.DataSize) {
|
||||
t.Errorf("encrypted data length = %d, want %d", len(encryptedData), header.DataSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_SendPacket_MultiplePackets(t *testing.T) {
|
||||
mockConn := newMockConn(nil)
|
||||
cc := NewCryptConn(mockConn, cfg.ZZ, nil)
|
||||
|
||||
// Send first packet
|
||||
err := cc.SendPacket([]byte{0x01, 0x02})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPacket(1) error = %v", err)
|
||||
}
|
||||
|
||||
if cc.sentPackets != 1 {
|
||||
t.Errorf("After 1 packet: sentPackets = %d, want 1", cc.sentPackets)
|
||||
}
|
||||
|
||||
// Send second packet
|
||||
err = cc.SendPacket([]byte{0x03, 0x04})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPacket(2) error = %v", err)
|
||||
}
|
||||
|
||||
if cc.sentPackets != 2 {
|
||||
t.Errorf("After 2 packets: sentPackets = %d, want 2", cc.sentPackets)
|
||||
}
|
||||
|
||||
// Send third packet
|
||||
err = cc.SendPacket([]byte{0x05, 0x06})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPacket(3) error = %v", err)
|
||||
}
|
||||
|
||||
if cc.sentPackets != 3 {
|
||||
t.Errorf("After 3 packets: sentPackets = %d, want 3", cc.sentPackets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_SendPacket_KeyRotation(t *testing.T) {
|
||||
mockConn := newMockConn(nil)
|
||||
cc := NewCryptConn(mockConn, cfg.ZZ, nil)
|
||||
|
||||
initialKey := cc.sendKeyRot
|
||||
|
||||
err := cc.SendPacket([]byte{0x01, 0x02, 0x03})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPacket() error = %v", err)
|
||||
}
|
||||
|
||||
// Key should have been rotated (keyRotDelta=3, so new key = 3 * (oldKey + 1))
|
||||
expectedKey := 3 * (initialKey + 1)
|
||||
if cc.sendKeyRot != expectedKey {
|
||||
t.Errorf("sendKeyRot = %d, want %d", cc.sendKeyRot, expectedKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_SendPacket_WriteError(t *testing.T) {
|
||||
mockConn := newMockConn(nil)
|
||||
mockConn.writeErr = errors.New("write error")
|
||||
cc := NewCryptConn(mockConn, cfg.ZZ, nil)
|
||||
|
||||
err := cc.SendPacket([]byte{0x01, 0x02, 0x03})
|
||||
// Note: Current implementation doesn't return write error
|
||||
// This test documents the behavior
|
||||
if err != nil {
|
||||
t.Logf("SendPacket() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_ReadPacket_Success(t *testing.T) {
|
||||
testData := []byte{0x74, 0x65, 0x73, 0x74} // "test"
|
||||
key := uint32(0)
|
||||
|
||||
// Encrypt the data
|
||||
encryptedData, combinedCheck, check0, check1, check2 := crypto.Crypto(testData, key, true, nil)
|
||||
|
||||
// Build header
|
||||
header := &CryptPacketHeader{
|
||||
Pf0: 0x03,
|
||||
KeyRotDelta: 0,
|
||||
PacketNum: 0,
|
||||
DataSize: uint16(len(encryptedData)),
|
||||
PrevPacketCombinedCheck: 0,
|
||||
Check0: check0,
|
||||
Check1: check1,
|
||||
Check2: check2,
|
||||
}
|
||||
|
||||
headerBytes, _ := header.Encode()
|
||||
|
||||
// Combine header and encrypted data
|
||||
packet := append(headerBytes, encryptedData...)
|
||||
|
||||
mockConn := newMockConn(packet)
|
||||
cc := NewCryptConn(mockConn, cfg.Z1, nil)
|
||||
|
||||
// Set the key to match what we used for encryption
|
||||
cc.readKeyRot = key
|
||||
|
||||
result, err := cc.ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPacket() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(result, testData) {
|
||||
t.Errorf("ReadPacket() = %v, want %v", result, testData)
|
||||
}
|
||||
|
||||
if cc.prevRecvPacketCombinedCheck != combinedCheck {
|
||||
t.Errorf("prevRecvPacketCombinedCheck = %d, want %d", cc.prevRecvPacketCombinedCheck, combinedCheck)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_ReadPacket_KeyRotation(t *testing.T) {
|
||||
testData := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
key := uint32(995117)
|
||||
keyRotDelta := byte(3)
|
||||
|
||||
// Calculate expected rotated key
|
||||
rotatedKey := uint32(keyRotDelta) * (key + 1)
|
||||
|
||||
// Encrypt with the rotated key
|
||||
encryptedData, _, check0, check1, check2 := crypto.Crypto(testData, rotatedKey, true, nil)
|
||||
|
||||
// Build header with key rotation
|
||||
header := &CryptPacketHeader{
|
||||
Pf0: 0x03,
|
||||
KeyRotDelta: keyRotDelta,
|
||||
PacketNum: 0,
|
||||
DataSize: uint16(len(encryptedData)),
|
||||
PrevPacketCombinedCheck: 0,
|
||||
Check0: check0,
|
||||
Check1: check1,
|
||||
Check2: check2,
|
||||
}
|
||||
|
||||
headerBytes, _ := header.Encode()
|
||||
packet := append(headerBytes, encryptedData...)
|
||||
|
||||
mockConn := newMockConn(packet)
|
||||
cc := NewCryptConn(mockConn, cfg.Z1, nil)
|
||||
cc.readKeyRot = key
|
||||
|
||||
result, err := cc.ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPacket() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(result, testData) {
|
||||
t.Errorf("ReadPacket() = %v, want %v", result, testData)
|
||||
}
|
||||
|
||||
// Verify key was rotated
|
||||
if cc.readKeyRot != rotatedKey {
|
||||
t.Errorf("readKeyRot = %d, want %d", cc.readKeyRot, rotatedKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_ReadPacket_NoKeyRotation(t *testing.T) {
|
||||
testData := []byte{0x01, 0x02}
|
||||
key := uint32(12345)
|
||||
|
||||
// Encrypt without key rotation
|
||||
encryptedData, _, check0, check1, check2 := crypto.Crypto(testData, key, true, nil)
|
||||
|
||||
header := &CryptPacketHeader{
|
||||
Pf0: 0x03,
|
||||
KeyRotDelta: 0, // No rotation
|
||||
PacketNum: 0,
|
||||
DataSize: uint16(len(encryptedData)),
|
||||
PrevPacketCombinedCheck: 0,
|
||||
Check0: check0,
|
||||
Check1: check1,
|
||||
Check2: check2,
|
||||
}
|
||||
|
||||
headerBytes, _ := header.Encode()
|
||||
packet := append(headerBytes, encryptedData...)
|
||||
|
||||
mockConn := newMockConn(packet)
|
||||
cc := NewCryptConn(mockConn, cfg.Z1, nil)
|
||||
cc.readKeyRot = key
|
||||
|
||||
originalKeyRot := cc.readKeyRot
|
||||
|
||||
result, err := cc.ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPacket() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(result, testData) {
|
||||
t.Errorf("ReadPacket() = %v, want %v", result, testData)
|
||||
}
|
||||
|
||||
// Verify key was NOT rotated
|
||||
if cc.readKeyRot != originalKeyRot {
|
||||
t.Errorf("readKeyRot = %d, want %d (should not have changed)", cc.readKeyRot, originalKeyRot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_ReadPacket_HeaderReadError(t *testing.T) {
|
||||
mockConn := newMockConn([]byte{0x01, 0x02}) // Only 2 bytes, header needs 14
|
||||
cc := NewCryptConn(mockConn, cfg.ZZ, nil)
|
||||
|
||||
_, err := cc.ReadPacket()
|
||||
if err == nil {
|
||||
t.Fatal("ReadPacket() error = nil, want error")
|
||||
}
|
||||
|
||||
if err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
t.Errorf("ReadPacket() error = %v, want io.EOF or io.ErrUnexpectedEOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_ReadPacket_InvalidHeader(t *testing.T) {
|
||||
// Create invalid header data (wrong endianness or malformed)
|
||||
invalidHeader := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}
|
||||
mockConn := newMockConn(invalidHeader)
|
||||
cc := NewCryptConn(mockConn, cfg.ZZ, nil)
|
||||
|
||||
_, err := cc.ReadPacket()
|
||||
if err == nil {
|
||||
t.Fatal("ReadPacket() error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_ReadPacket_BodyReadError(t *testing.T) {
|
||||
// Create valid header but incomplete body
|
||||
header := &CryptPacketHeader{
|
||||
Pf0: 0x03,
|
||||
KeyRotDelta: 0,
|
||||
PacketNum: 0,
|
||||
DataSize: 100, // Claim 100 bytes
|
||||
PrevPacketCombinedCheck: 0,
|
||||
Check0: 0x1234,
|
||||
Check1: 0x5678,
|
||||
Check2: 0x9ABC,
|
||||
}
|
||||
|
||||
headerBytes, _ := header.Encode()
|
||||
incompleteBody := []byte{0x01, 0x02, 0x03} // Only 3 bytes, not 100
|
||||
|
||||
packet := append(headerBytes, incompleteBody...)
|
||||
|
||||
mockConn := newMockConn(packet)
|
||||
cc := NewCryptConn(mockConn, cfg.Z1, nil)
|
||||
|
||||
_, err := cc.ReadPacket()
|
||||
if err == nil {
|
||||
t.Fatal("ReadPacket() error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_ReadPacket_ChecksumMismatch(t *testing.T) {
|
||||
testData := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
key := uint32(0)
|
||||
|
||||
encryptedData, _, _, _, _ := crypto.Crypto(testData, key, true, nil)
|
||||
|
||||
// Build header with WRONG checksums
|
||||
header := &CryptPacketHeader{
|
||||
Pf0: 0x03,
|
||||
KeyRotDelta: 0,
|
||||
PacketNum: 0,
|
||||
DataSize: uint16(len(encryptedData)),
|
||||
PrevPacketCombinedCheck: 0,
|
||||
Check0: 0xFFFF, // Wrong checksum
|
||||
Check1: 0xFFFF, // Wrong checksum
|
||||
Check2: 0xFFFF, // Wrong checksum
|
||||
}
|
||||
|
||||
headerBytes, _ := header.Encode()
|
||||
packet := append(headerBytes, encryptedData...)
|
||||
|
||||
mockConn := newMockConn(packet)
|
||||
cc := NewCryptConn(mockConn, cfg.Z1, nil)
|
||||
cc.readKeyRot = key
|
||||
|
||||
_, err := cc.ReadPacket()
|
||||
if err == nil {
|
||||
t.Fatal("ReadPacket() error = nil, want error for checksum mismatch")
|
||||
}
|
||||
|
||||
expectedErr := "decrypted data checksum doesn't match header"
|
||||
if err.Error() != expectedErr {
|
||||
t.Errorf("ReadPacket() error = %q, want %q", err.Error(), expectedErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptConn_Interface(t *testing.T) {
|
||||
// Test that CryptConn implements Conn interface
|
||||
var _ Conn = (*CryptConn)(nil)
|
||||
}
|
||||
385
network/crypt_packet_test.go
Normal file
385
network/crypt_packet_test.go
Normal file
@@ -0,0 +1,385 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewCryptPacketHeader_ValidData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
expected *CryptPacketHeader
|
||||
}{
|
||||
{
|
||||
name: "basic header",
|
||||
data: []byte{
|
||||
0x03, // Pf0
|
||||
0x03, // KeyRotDelta
|
||||
0x00, 0x01, // PacketNum (1)
|
||||
0x00, 0x0A, // DataSize (10)
|
||||
0x00, 0x00, // PrevPacketCombinedCheck (0)
|
||||
0x12, 0x34, // Check0 (0x1234)
|
||||
0x56, 0x78, // Check1 (0x5678)
|
||||
0x9A, 0xBC, // Check2 (0x9ABC)
|
||||
},
|
||||
expected: &CryptPacketHeader{
|
||||
Pf0: 0x03,
|
||||
KeyRotDelta: 0x03,
|
||||
PacketNum: 1,
|
||||
DataSize: 10,
|
||||
PrevPacketCombinedCheck: 0,
|
||||
Check0: 0x1234,
|
||||
Check1: 0x5678,
|
||||
Check2: 0x9ABC,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all zero values",
|
||||
data: []byte{
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
},
|
||||
expected: &CryptPacketHeader{
|
||||
Pf0: 0x00,
|
||||
KeyRotDelta: 0x00,
|
||||
PacketNum: 0,
|
||||
DataSize: 0,
|
||||
PrevPacketCombinedCheck: 0,
|
||||
Check0: 0,
|
||||
Check1: 0,
|
||||
Check2: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "max values",
|
||||
data: []byte{
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
},
|
||||
expected: &CryptPacketHeader{
|
||||
Pf0: 0xFF,
|
||||
KeyRotDelta: 0xFF,
|
||||
PacketNum: 0xFFFF,
|
||||
DataSize: 0xFFFF,
|
||||
PrevPacketCombinedCheck: 0xFFFF,
|
||||
Check0: 0xFFFF,
|
||||
Check1: 0xFFFF,
|
||||
Check2: 0xFFFF,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := NewCryptPacketHeader(tt.data)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCryptPacketHeader() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if result.Pf0 != tt.expected.Pf0 {
|
||||
t.Errorf("Pf0 = 0x%X, want 0x%X", result.Pf0, tt.expected.Pf0)
|
||||
}
|
||||
if result.KeyRotDelta != tt.expected.KeyRotDelta {
|
||||
t.Errorf("KeyRotDelta = 0x%X, want 0x%X", result.KeyRotDelta, tt.expected.KeyRotDelta)
|
||||
}
|
||||
if result.PacketNum != tt.expected.PacketNum {
|
||||
t.Errorf("PacketNum = 0x%X, want 0x%X", result.PacketNum, tt.expected.PacketNum)
|
||||
}
|
||||
if result.DataSize != tt.expected.DataSize {
|
||||
t.Errorf("DataSize = 0x%X, want 0x%X", result.DataSize, tt.expected.DataSize)
|
||||
}
|
||||
if result.PrevPacketCombinedCheck != tt.expected.PrevPacketCombinedCheck {
|
||||
t.Errorf("PrevPacketCombinedCheck = 0x%X, want 0x%X", result.PrevPacketCombinedCheck, tt.expected.PrevPacketCombinedCheck)
|
||||
}
|
||||
if result.Check0 != tt.expected.Check0 {
|
||||
t.Errorf("Check0 = 0x%X, want 0x%X", result.Check0, tt.expected.Check0)
|
||||
}
|
||||
if result.Check1 != tt.expected.Check1 {
|
||||
t.Errorf("Check1 = 0x%X, want 0x%X", result.Check1, tt.expected.Check1)
|
||||
}
|
||||
if result.Check2 != tt.expected.Check2 {
|
||||
t.Errorf("Check2 = 0x%X, want 0x%X", result.Check2, tt.expected.Check2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCryptPacketHeader_InvalidData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{
|
||||
name: "empty data",
|
||||
data: []byte{},
|
||||
},
|
||||
{
|
||||
name: "too short - 1 byte",
|
||||
data: []byte{0x03},
|
||||
},
|
||||
{
|
||||
name: "too short - 13 bytes",
|
||||
data: []byte{0x03, 0x03, 0x00, 0x01, 0x00, 0x0A, 0x00, 0x00, 0x12, 0x34, 0x56, 0x78, 0x9A},
|
||||
},
|
||||
{
|
||||
name: "too short - 7 bytes",
|
||||
data: []byte{0x03, 0x03, 0x00, 0x01, 0x00, 0x0A, 0x00},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewCryptPacketHeader(tt.data)
|
||||
if err == nil {
|
||||
t.Fatal("NewCryptPacketHeader() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCryptPacketHeader_ExtraDataIgnored(t *testing.T) {
|
||||
// Test that extra data beyond 14 bytes is ignored
|
||||
data := []byte{
|
||||
0x03, 0x03,
|
||||
0x00, 0x01,
|
||||
0x00, 0x0A,
|
||||
0x00, 0x00,
|
||||
0x12, 0x34,
|
||||
0x56, 0x78,
|
||||
0x9A, 0xBC,
|
||||
0xFF, 0xFF, 0xFF, // Extra bytes
|
||||
}
|
||||
|
||||
result, err := NewCryptPacketHeader(data)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCryptPacketHeader() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
expected := &CryptPacketHeader{
|
||||
Pf0: 0x03,
|
||||
KeyRotDelta: 0x03,
|
||||
PacketNum: 1,
|
||||
DataSize: 10,
|
||||
PrevPacketCombinedCheck: 0,
|
||||
Check0: 0x1234,
|
||||
Check1: 0x5678,
|
||||
Check2: 0x9ABC,
|
||||
}
|
||||
|
||||
if result.Pf0 != expected.Pf0 || result.KeyRotDelta != expected.KeyRotDelta ||
|
||||
result.PacketNum != expected.PacketNum || result.DataSize != expected.DataSize {
|
||||
t.Errorf("Extra data affected parsing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptPacketHeader_Encode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header *CryptPacketHeader
|
||||
expected []byte
|
||||
}{
|
||||
{
|
||||
name: "basic header",
|
||||
header: &CryptPacketHeader{
|
||||
Pf0: 0x03,
|
||||
KeyRotDelta: 0x03,
|
||||
PacketNum: 1,
|
||||
DataSize: 10,
|
||||
PrevPacketCombinedCheck: 0,
|
||||
Check0: 0x1234,
|
||||
Check1: 0x5678,
|
||||
Check2: 0x9ABC,
|
||||
},
|
||||
expected: []byte{
|
||||
0x03, 0x03,
|
||||
0x00, 0x01,
|
||||
0x00, 0x0A,
|
||||
0x00, 0x00,
|
||||
0x12, 0x34,
|
||||
0x56, 0x78,
|
||||
0x9A, 0xBC,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all zeros",
|
||||
header: &CryptPacketHeader{
|
||||
Pf0: 0x00,
|
||||
KeyRotDelta: 0x00,
|
||||
PacketNum: 0,
|
||||
DataSize: 0,
|
||||
PrevPacketCombinedCheck: 0,
|
||||
Check0: 0,
|
||||
Check1: 0,
|
||||
Check2: 0,
|
||||
},
|
||||
expected: []byte{
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "max values",
|
||||
header: &CryptPacketHeader{
|
||||
Pf0: 0xFF,
|
||||
KeyRotDelta: 0xFF,
|
||||
PacketNum: 0xFFFF,
|
||||
DataSize: 0xFFFF,
|
||||
PrevPacketCombinedCheck: 0xFFFF,
|
||||
Check0: 0xFFFF,
|
||||
Check1: 0xFFFF,
|
||||
Check2: 0xFFFF,
|
||||
},
|
||||
expected: []byte{
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := tt.header.Encode()
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(result, tt.expected) {
|
||||
t.Errorf("Encode() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
|
||||
// Check that the length is always 14
|
||||
if len(result) != CryptPacketHeaderLength {
|
||||
t.Errorf("Encode() length = %d, want %d", len(result), CryptPacketHeaderLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptPacketHeader_RoundTrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header *CryptPacketHeader
|
||||
}{
|
||||
{
|
||||
name: "basic header",
|
||||
header: &CryptPacketHeader{
|
||||
Pf0: 0x03,
|
||||
KeyRotDelta: 0x03,
|
||||
PacketNum: 100,
|
||||
DataSize: 1024,
|
||||
PrevPacketCombinedCheck: 0x1234,
|
||||
Check0: 0xABCD,
|
||||
Check1: 0xEF01,
|
||||
Check2: 0x2345,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zero values",
|
||||
header: &CryptPacketHeader{
|
||||
Pf0: 0x00,
|
||||
KeyRotDelta: 0x00,
|
||||
PacketNum: 0,
|
||||
DataSize: 0,
|
||||
PrevPacketCombinedCheck: 0,
|
||||
Check0: 0,
|
||||
Check1: 0,
|
||||
Check2: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "max values",
|
||||
header: &CryptPacketHeader{
|
||||
Pf0: 0xFF,
|
||||
KeyRotDelta: 0xFF,
|
||||
PacketNum: 0xFFFF,
|
||||
DataSize: 0xFFFF,
|
||||
PrevPacketCombinedCheck: 0xFFFF,
|
||||
Check0: 0xFFFF,
|
||||
Check1: 0xFFFF,
|
||||
Check2: 0xFFFF,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "realistic values",
|
||||
header: &CryptPacketHeader{
|
||||
Pf0: 0x07,
|
||||
KeyRotDelta: 0x03,
|
||||
PacketNum: 523,
|
||||
DataSize: 2048,
|
||||
PrevPacketCombinedCheck: 0x2A56,
|
||||
Check0: 0x06EA,
|
||||
Check1: 0x0215,
|
||||
Check2: 0x8FB3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Encode
|
||||
encoded, err := tt.header.Encode()
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Decode
|
||||
decoded, err := NewCryptPacketHeader(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCryptPacketHeader() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Compare
|
||||
if decoded.Pf0 != tt.header.Pf0 {
|
||||
t.Errorf("Pf0 = 0x%X, want 0x%X", decoded.Pf0, tt.header.Pf0)
|
||||
}
|
||||
if decoded.KeyRotDelta != tt.header.KeyRotDelta {
|
||||
t.Errorf("KeyRotDelta = 0x%X, want 0x%X", decoded.KeyRotDelta, tt.header.KeyRotDelta)
|
||||
}
|
||||
if decoded.PacketNum != tt.header.PacketNum {
|
||||
t.Errorf("PacketNum = 0x%X, want 0x%X", decoded.PacketNum, tt.header.PacketNum)
|
||||
}
|
||||
if decoded.DataSize != tt.header.DataSize {
|
||||
t.Errorf("DataSize = 0x%X, want 0x%X", decoded.DataSize, tt.header.DataSize)
|
||||
}
|
||||
if decoded.PrevPacketCombinedCheck != tt.header.PrevPacketCombinedCheck {
|
||||
t.Errorf("PrevPacketCombinedCheck = 0x%X, want 0x%X", decoded.PrevPacketCombinedCheck, tt.header.PrevPacketCombinedCheck)
|
||||
}
|
||||
if decoded.Check0 != tt.header.Check0 {
|
||||
t.Errorf("Check0 = 0x%X, want 0x%X", decoded.Check0, tt.header.Check0)
|
||||
}
|
||||
if decoded.Check1 != tt.header.Check1 {
|
||||
t.Errorf("Check1 = 0x%X, want 0x%X", decoded.Check1, tt.header.Check1)
|
||||
}
|
||||
if decoded.Check2 != tt.header.Check2 {
|
||||
t.Errorf("Check2 = 0x%X, want 0x%X", decoded.Check2, tt.header.Check2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptPacketHeaderLength_Constant(t *testing.T) {
|
||||
if CryptPacketHeaderLength != 14 {
|
||||
t.Errorf("CryptPacketHeaderLength = %d, want 14", CryptPacketHeaderLength)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func TestDecrypt(t *testing.T) {
|
||||
for k, tt := range tests {
|
||||
testname := fmt.Sprintf("decrypt_test_%d", k)
|
||||
t.Run(testname, func(t *testing.T) {
|
||||
out, cc, c0, c1, c2 := Crypto(tt.decryptedData, tt.key, false, nil)
|
||||
out, cc, c0, c1, c2 := Crypto(tt.encryptedData, tt.key, false, nil)
|
||||
if cc != tt.ecc {
|
||||
t.Errorf("got cc 0x%X, want 0x%X", cc, tt.ecc)
|
||||
} else if c0 != tt.ec0 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user